fix: advance playback after track completion
Backend / Lint i formatowanie (pull_request) Successful in 4s
Backend / Testy i kontrakt API (pull_request) Successful in 10s
Panel / Lint, typy i build (pull_request) Successful in 19s
Backend / Testowy build obrazu (pull_request) Successful in 9s

This commit is contained in:
mmacierzanka committed 2026-08-21 18:39:57 +02:00
1 parent 322087c50e
commit ed13d4af06
4 files changed
+83 -11

No files matched your search

+12 -4
View File
@@ -80,10 +80,17 @@ async function commitVolume() {
draggedVolume.value = null draggedVolume.value = null
} }
const togglePlayback = () => async function togglePlayback() {
playing.value if (!current.value) {
? run(() => api.pause(), 'Wstrzymano odtwarzanie', 'Błąd sterowania odtwarzaniem') notify('Nic nie jest obecnie odtwarzane', 'info')
: run(() => api.resume(), 'Wznowiono odtwarzanie', 'Błąd sterowania odtwarzaniem') return
}
await run(
() => (playing.value ? api.pause() : api.resume()),
playing.value ? 'Wstrzymano odtwarzanie' : 'Wznowiono odtwarzanie',
'Błąd sterowania odtwarzaniem',
)
}
const skip = () => run(() => api.skip(), 'Pominięto utwór', 'Błąd pomijania utworu') const skip = () => run(() => api.skip(), 'Pominięto utwór', 'Błąd pomijania utworu')
@@ -185,6 +192,7 @@ async function cycleLoop() {
</button> </button>
<button <button
class="btn-icon btn-primary" class="btn-icon btn-primary"
:disabled="!current"
:title="playing ? 'Wstrzymaj' : 'Odtwarzaj'" :title="playing ? 'Wstrzymaj' : 'Odtwarzaj'"
@click="togglePlayback" @click="togglePlayback"
> >
+24 -1
View File
@@ -93,6 +93,16 @@ class MusicPlayerQueueTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(state.index, 1) self.assertEqual(state.index, 1)
self.assertEqual(state.current.id, "b") self.assertEqual(state.current.id, "b")
async def test_initial_playback_callback_advances_from_zero_token(self):
_, voice_client, state = await self._queue_abc()
voice_client.after(None)
await asyncio.sleep(0.01)
self.assertEqual(state.index, 1)
self.assertEqual(state.current.id, "b")
self.assertEqual(state.queue[0].plays_left, 4)
async def test_loop_track_replays_same_index(self): async def test_loop_track_replays_same_index(self):
player, _, state = await self._queue_abc() player, _, state = await self._queue_abc()
await player.play_next(GUILD, advance=True) await player.play_next(GUILD, advance=True)
@@ -118,11 +128,24 @@ class MusicPlayerQueueTest(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(state.current) self.assertIsNone(state.current)
self.assertEqual(state.index, 3) self.assertEqual(state.index, 3)
async def test_queue_end_is_not_active_even_if_voice_client_lags(self):
player, voice_client, state = await self._queue_abc()
state.index = 2
await player.play_next(GUILD, advance=True)
self.assertIsNone(state.current)
self.assertFalse(voice_client.is_playing())
self.assertFalse(player.is_active(GUILD))
await player.enqueue(GUILD, FakeTrack("new"))
self.assertEqual(state.current.id, "new")
async def test_jump_after_queue_end_plays_selected_track(self): async def test_jump_after_queue_end_plays_selected_track(self):
player, voice_client, state = await self._queue_abc() player, voice_client, state = await self._queue_abc()
state.index = 2 state.index = 2
await player.play_next(GUILD, advance=True) await player.play_next(GUILD, advance=True)
voice_client.playing = None voice_client.playing = state.queue[2]
await player.jump(GUILD, 0) await player.jump(GUILD, 0)
+45 -4
View File
@@ -90,8 +90,13 @@ class MusicPlayer:
return discord.utils.get(self._bot.voice_clients, guild__id=guild_id) return discord.utils.get(self._bot.voice_clients, guild__id=guild_id)
def is_active(self, guild_id: int) -> bool: def is_active(self, guild_id: int) -> bool:
state = self.state(guild_id)
voice_client = self.voice_client(guild_id) voice_client = self.voice_client(guild_id)
return bool(voice_client and (voice_client.is_playing() or voice_client.is_paused())) return bool(
state.current
and voice_client
and (voice_client.is_playing() or voice_client.is_paused())
)
def clear(self, guild_id: int) -> None: def clear(self, guild_id: int) -> None:
self._playback_tokens[guild_id] = self._playback_tokens.get(guild_id, 0) + 1 self._playback_tokens[guild_id] = self._playback_tokens.get(guild_id, 0) + 1
@@ -130,6 +135,14 @@ class MusicPlayer:
self._interrupted_playbacks.add(guild_id) self._interrupted_playbacks.add(guild_id)
voice_client.stop() voice_client.stop()
def _stop_stale_voice(self, guild_id: int, voice_client: discord.VoiceClient) -> None:
"""Stop a voice client that still reports the source from a finished queue."""
if not (voice_client.is_playing() or voice_client.is_paused()):
return
self._playback_tokens[guild_id] = self._playback_tokens.get(guild_id, 0) + 1
self._interrupted_playbacks.discard(guild_id)
voice_client.stop()
async def restore_queue(self, guild_id: int) -> int: async def restore_queue(self, guild_id: int) -> int:
if not self._queues: if not self._queues:
return 0 return 0
@@ -281,7 +294,18 @@ class MusicPlayer:
async def _restart_playback(self, guild_id: int) -> None: async def _restart_playback(self, guild_id: int) -> None:
voice_client = self.voice_client(guild_id) voice_client = self.voice_client(guild_id)
if voice_client and (voice_client.is_playing() or voice_client.is_paused()): voice_is_active = bool(
voice_client and (voice_client.is_playing() or voice_client.is_paused())
)
if voice_is_active and not self.is_active(guild_id):
# Po końcu kolejki discord.py może jeszcze przez chwilę trzymać stare źródło.
# Unieważniamy jego callback i uruchamiamy wybrany utwór bez czekania na niego.
self._playback_tokens[guild_id] = self._playback_tokens.get(guild_id, 0) + 1
self._interrupted_playbacks.discard(guild_id)
voice_client.stop()
await self.play_next(guild_id, advance=False)
return
if self.is_active(guild_id):
self._interrupted_playbacks.add(guild_id) self._interrupted_playbacks.add(guild_id)
voice_client.stop() # `after` wywoła play_next, flaga przeskoku zablokuje advance voice_client.stop() # `after` wywoła play_next, flaga przeskoku zablokuje advance
else: else:
@@ -325,6 +349,7 @@ class MusicPlayer:
if not state.queue: if not state.queue:
state.index = -1 state.index = -1
state.current = None state.current = None
self._stop_stale_voice(guild_id, voice_client)
await self.trigger_state_change() await self.trigger_state_change()
return return
@@ -337,6 +362,7 @@ class MusicPlayer:
if next_index >= len(state.queue): if next_index >= len(state.queue):
state.index = next_index state.index = next_index
state.current = None state.current = None
self._stop_stale_voice(guild_id, voice_client)
await self.trigger_state_change() await self.trigger_state_change()
await self.notify(guild_id, "Kolejka zakończona.") await self.notify(guild_id, "Kolejka zakończona.")
return return
@@ -405,15 +431,30 @@ class MusicPlayer:
return index + 1 return index + 1
def _schedule_next(self, guild_id: int, error: Exception | None, playback_token: int) -> None: def _schedule_next(self, guild_id: int, error: Exception | None, playback_token: int) -> None:
if self._playback_tokens.get(guild_id) != playback_token: logger.info(
"Zakończono odtwarzanie na serwerze %s (token=%s, error=%s)",
guild_id,
playback_token,
error,
)
if self._playback_tokens.get(guild_id, 0) != playback_token:
logger.info("Pomijam nieaktualny callback odtwarzania na serwerze %s", guild_id)
return return
consume_current = guild_id not in self._interrupted_playbacks consume_current = guild_id not in self._interrupted_playbacks
self._interrupted_playbacks.discard(guild_id) self._interrupted_playbacks.discard(guild_id)
if error: if error:
logger.error("Odtwarzanie zakończone błędem na serwerze %s: %s", guild_id, error) logger.error("Odtwarzanie zakończone błędem na serwerze %s: %s", guild_id, error)
asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
self.play_next(guild_id, advance=True, consume_current=consume_current), self._bot.loop self.play_next(guild_id, advance=True, consume_current=consume_current), self._bot.loop
) )
future.add_done_callback(self._log_playback_failure)
@staticmethod
def _log_playback_failure(future: asyncio.Future) -> None:
try:
future.result()
except Exception:
logger.exception("Nie udało się przejść do kolejnego utworu")
def _record_history(self, guild_id: int, track: YTDLSource) -> None: def _record_history(self, guild_id: int, track: YTDLSource) -> None:
if not track.title: if not track.title:
+2 -2
View File
@@ -65,8 +65,8 @@ class StatusService:
notification_channel = self._player.notification_channel(guild.id) notification_channel = self._player.notification_channel(guild.id)
return { return {
"playing": bool(voice_client and voice_client.is_playing()), "playing": bool(self._player.is_active(guild.id) and voice_client.is_playing()),
"paused": bool(voice_client and voice_client.is_paused()), "paused": bool(self._player.is_active(guild.id) and voice_client.is_paused()),
"loop_single": state.loop_mode.loops_track, "loop_single": state.loop_mode.loops_track,
"loop_playlist": state.loop_mode.loops_queue, "loop_playlist": state.loop_mode.loops_queue,
"volume": int(state.volume * 100), "volume": int(state.volume * 100),