From d6bd302165aca723bec1fc8fa1ec2edae5772dd9 Mon Sep 17 00:00:00 2001 From: Marcin Macierzanka Date: Fri, 21 Aug 2026 17:10:40 +0200 Subject: [PATCH] feat: restore the last queue per server --- README.md | 2 + frontend/smoke-test.mjs | 2 + frontend/src/api/client.ts | 1 + frontend/src/components/QueuePanel.vue | 24 ++++++- frontend/src/types/status.ts | 1 + tests/test_api_surface.py | 1 + tests/test_history_repository.py | 18 +++++ tests/test_music_player.py | 28 ++++++++ tests/test_queue_repository.py | 25 +++++++ zyrarbot/application.py | 3 + zyrarbot/music/music_player.py | 84 +++++++++++++++++++++- zyrarbot/storage/queue_repository.py | 31 ++++++++ zyrarbot/ui/api/routers/playback_router.py | 1 + zyrarbot/ui/api/routers/queue_router.py | 13 ++++ zyrarbot/ui/api/routers/voice_router.py | 1 + zyrarbot/ui/api/status_service.py | 2 + zyrarbot/ui/bot/cogs/control_cog.py | 1 + zyrarbot/ui/bot/cogs/voice_cog.py | 2 + 18 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 tests/test_queue_repository.py create mode 100644 zyrarbot/storage/queue_repository.py diff --git a/README.md b/README.md index 3532989..7db87a4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ osobną kolejkę, historię, bazę plików i ustawienia odtwarzania. Istniejące starego, wspólnego formatu są przypisywane do pierwszego użytego serwera. Historia jest zapisywana w osobnych plikach `config/history/.json`, a uploady w katalogach `uploads//`. +Ostatnia kolejka jest zapisywana w `config/queues/.json` przy zatrzymaniu +lub rozłączeniu i może zostać przywrócona z widoku kolejki. ## Testy diff --git a/frontend/smoke-test.mjs b/frontend/smoke-test.mjs index 84f728f..bc5be05 100644 --- a/frontend/smoke-test.mjs +++ b/frontend/smoke-test.mjs @@ -61,6 +61,7 @@ const status = { }, ], default_plays: 5, + saved_queue_available: true, } const calls = [] @@ -129,6 +130,7 @@ check('pozycje list', app.querySelectorAll('.queue-item').length === 4) check('podświetlona aktywna pozycja', app.querySelectorAll('.playing-item').length === 1) check('animacja winylu przy odtwarzaniu', app.querySelector('.vinyl-record.playing') !== null) check('pobrano listę plików na starcie', hasCall('GET /api/uploads')) +check('przycisk przywracania kolejki', text().includes('Przywróć ostatnią kolejkę')) // --- Interakcje: kliknięcie ma trafić w konkretny endpoint --------------- diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6ce465f..f366dfa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -58,6 +58,7 @@ export const api = { jump: (index: number) => post('/api/jump', { index }), remove: (id: string) => post('/api/remove', { id }), reorder: (queueIds: string[]) => post('/api/reorder', { queue_ids: queueIds }), + restoreQueue: () => post('/api/queue/restore'), setTrackPlays: (id: string, plays: number) => post('/api/queue/plays', { id, plays }), setDefaultPlays: (plays: number) => post('/api/default_plays', { default_plays: plays }), diff --git a/frontend/src/components/QueuePanel.vue b/frontend/src/components/QueuePanel.vue index 078495d..6237560 100644 --- a/frontend/src/components/QueuePanel.vue +++ b/frontend/src/components/QueuePanel.vue @@ -1,6 +1,6 @@ + + diff --git a/frontend/src/types/status.ts b/frontend/src/types/status.ts index 6972543..e46c453 100644 --- a/frontend/src/types/status.ts +++ b/frontend/src/types/status.ts @@ -66,6 +66,7 @@ export interface BotStatus { active_text_channel_id: string | null guilds: GuildSettings[] default_plays: number + saved_queue_available: boolean } export type LoopMode = 'off' | 'playlist' | 'single' diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py index 8bb9af8..7d41bc3 100644 --- a/tests/test_api_surface.py +++ b/tests/test_api_surface.py @@ -24,6 +24,7 @@ EXPECTED_ENDPOINTS = { ("post", "/api/jump"), ("post", "/api/remove"), ("post", "/api/reorder"), + ("post", "/api/queue/restore"), ("post", "/api/queue/plays"), ("get", "/api/uploads"), ("post", "/api/upload"), diff --git a/tests/test_history_repository.py b/tests/test_history_repository.py index 49a9dc3..e9cc156 100644 --- a/tests/test_history_repository.py +++ b/tests/test_history_repository.py @@ -30,6 +30,24 @@ class HistoryRepositoryTest(unittest.TestCase): self.assertTrue((Path(directory) / "history" / "10.json").exists()) self.assertFalse((Path(directory) / "history" / "20.json").exists()) + def test_new_entries_are_available_after_repository_reload(self): + with tempfile.TemporaryDirectory() as directory: + store = JsonFileStore(str(Path(directory) / "history.json")) + entry = HistoryEntry( + id="track-2", + title="Another track", + url="https://example.test/another-track", + ) + + HistoryRepository(store).add(42, entry) + + reloaded = HistoryRepository(store) + self.assertEqual(reloaded.list(42), [entry]) + persisted = json.loads( + (Path(directory) / "history" / "42.json").read_text(encoding="utf-8") + ) + self.assertEqual(persisted["entries"][0]["id"], "track-2") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_music_player.py b/tests/test_music_player.py index 456a4b0..7a80341 100644 --- a/tests/test_music_player.py +++ b/tests/test_music_player.py @@ -2,10 +2,13 @@ from __future__ import annotations +import asyncio +import tempfile import unittest from tests.fakes import FakeTrack, build_player from zyrarbot.music.loop_mode import LoopMode +from zyrarbot.storage.queue_repository import QueueRepository GUILD = 1 @@ -177,6 +180,31 @@ class MusicPlayerQueueTest(unittest.IsolatedAsyncioTestCase): self.assertIsNone(state.current) + async def test_restore_replaces_active_queue_without_old_callback_interference(self): + player, voice_client = build_player() + with tempfile.TemporaryDirectory() as directory: + player._queues = QueueRepository(directory) + player._queues.save( + GUILD, + [{"id": "restored", "title": "Restored", "plays_left": 3}], + ) + + async def from_url(url, loop=None): + return FakeTrack("restored") + + player._tracks.from_url = from_url + await player.enqueue(GUILD, FakeTrack("old")) + old_callback = voice_client.after + + count = await player.restore_queue(GUILD) + + self.assertEqual(count, 1) + self.assertEqual([track.id for track in player.state(GUILD).queue], ["restored"]) + self.assertIs(player.state(GUILD).current, player.state(GUILD).queue[0]) + old_callback(None) + await asyncio.sleep(0) + self.assertEqual([track.id for track in player.state(GUILD).queue], ["restored"]) + class LoopModeTest(unittest.TestCase): def test_cycle_order(self): diff --git a/tests/test_queue_repository.py b/tests/test_queue_repository.py new file mode 100644 index 0000000..2fa3227 --- /dev/null +++ b/tests/test_queue_repository.py @@ -0,0 +1,25 @@ +import tempfile +import unittest +from pathlib import Path + +from zyrarbot.storage.queue_repository import QueueRepository + + +class QueueRepositoryTest(unittest.TestCase): + def test_snapshots_are_isolated_per_guild(self): + with tempfile.TemporaryDirectory() as directory: + repository = QueueRepository(directory) + first = [{"id": "first", "title": "First", "plays_left": 2}] + second = [{"id": "second", "title": "Second", "plays_left": 4}] + + repository.save(10, first) + repository.save(20, second) + + self.assertEqual(repository.load(10), first) + self.assertEqual(repository.load(20), second) + self.assertTrue((Path(directory) / "10.json").exists()) + self.assertTrue((Path(directory) / "20.json").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/zyrarbot/application.py b/zyrarbot/application.py index 354c5d5..e2544c5 100644 --- a/zyrarbot/application.py +++ b/zyrarbot/application.py @@ -20,6 +20,7 @@ from zyrarbot.settings import Settings from zyrarbot.storage.guild_config_repository import GuildConfigRepository from zyrarbot.storage.history_repository import HistoryRepository from zyrarbot.storage.json_file_store import JsonFileStore +from zyrarbot.storage.queue_repository import QueueRepository from zyrarbot.storage.uploads_repository import UploadsRepository from zyrarbot.ui.api.factory import mount_api from zyrarbot.ui.bot.client import MusicBot @@ -42,6 +43,7 @@ class Application: JsonFileStore(self.settings.history_file), limit=self.settings.history_limit ) self.uploads = UploadsRepository(self.settings.uploads_dir) + self.queues = QueueRepository(os.path.join(self.settings.config_dir, "queues")) self.tracks = TrackFactory(self.uploads, MediaProbe()) self.bot = MusicBot(self.settings, self.tracks, self.uploads, self.config, self.history) @@ -53,6 +55,7 @@ class Application: config=self.config, channels=NotificationChannelResolver(self.bot, self.config), embeds=TrackEmbedFactory(), + queues=self.queues, ) self.bot.player = self.player diff --git a/zyrarbot/music/music_player.py b/zyrarbot/music/music_player.py index a80c86c..08429b5 100644 --- a/zyrarbot/music/music_player.py +++ b/zyrarbot/music/music_player.py @@ -17,6 +17,7 @@ from zyrarbot.music.notification_channel_resolver import NotificationChannelReso from zyrarbot.music.track_embed_factory import TrackEmbedFactory from zyrarbot.storage.guild_config_repository import GuildConfigRepository from zyrarbot.storage.history_repository import HistoryRepository +from zyrarbot.storage.queue_repository import QueueRepository logger = logging.getLogger(__name__) @@ -38,6 +39,7 @@ class MusicPlayer: config: GuildConfigRepository, channels: NotificationChannelResolver, embeds: TrackEmbedFactory | None = None, + queues: QueueRepository | None = None, ): self._bot = bot self._tracks = track_factory @@ -45,7 +47,9 @@ class MusicPlayer: self._config = config self._channels = channels self._embeds = embeds or TrackEmbedFactory() + self._queues = queues self._states: dict[int, GuildMusicState] = {} + self._playback_tokens: dict[int, int] = {} self.on_state_change: StateChangeHook | None = None # --- Stan --------------------------------------------------------------- @@ -89,8 +93,78 @@ class MusicPlayer: return bool(voice_client and (voice_client.is_playing() or voice_client.is_paused())) def clear(self, guild_id: int) -> None: + self._playback_tokens[guild_id] = self._playback_tokens.get(guild_id, 0) + 1 self.state(guild_id).reset() + def has_saved_queue(self, guild_id: int) -> bool: + return bool(self._queues and self._queues.exists(guild_id)) + + def save_queue(self, guild_id: int) -> None: + if not self._queues: + return + state = self.state(guild_id) + self._queues.save(guild_id, [self._track_snapshot(track) for track in state.queue]) + + async def restore_queue(self, guild_id: int) -> int: + if not self._queues: + return 0 + + state = self.state(guild_id) + playback_token = self._playback_tokens.get(guild_id, 0) + 1 + self._playback_tokens[guild_id] = playback_token + voice_client = self.voice_client(guild_id) + if voice_client and (voice_client.is_playing() or voice_client.is_paused()): + voice_client.stop() + state.queue = [] + state.current = None + state.index = -1 + state.jump_pending = False + + restored: list[YTDLSource] = [] + for snapshot in self._queues.load(guild_id): + try: + if snapshot.get("is_local"): + track = await self._tracks.from_file( + str(snapshot.get("filepath") or ""), + title=snapshot.get("title"), + ) + else: + track = await self._tracks.from_url( + str(snapshot.get("url") or ""), loop=self._bot.loop + ) + if snapshot.get("id"): + track.id = str(snapshot["id"]) + track.plays_left = snapshot.get("plays_left") + restored.append(track) + except Exception as error: # noqa: BLE001 - pomijamy niedostępny utwór + logger.error( + "Nie udało się przywrócić utworu %s na serwerze %s: %s", + snapshot.get("title", ""), + guild_id, + error, + ) + + state.queue = restored + state.current = None + state.index = -1 + state.jump_pending = False + if restored and self.voice_client(guild_id): + await self.play_next(guild_id, advance=False) + await self.trigger_state_change() + return len(restored) + + @staticmethod + def _track_snapshot(track: YTDLSource) -> dict[str, Any]: + return { + "id": track.id, + "title": track.title, + "url": track.display_url, + "duration": track.duration, + "plays_left": track.plays_left, + "is_local": track.is_local, + "filepath": track.filepath, + } + def set_volume(self, guild_id: int, volume: float) -> None: self.state(guild_id).volume = volume voice_client = self.voice_client(guild_id) @@ -191,6 +265,7 @@ class MusicPlayer: async def play_next(self, guild_id: int, advance: bool = True, _failures: int = 0) -> None: """Przechodzi do kolejnego utworu; ``_failures`` chroni przed pętlą pomijania.""" state = self.state(guild_id) + playback_token = self._playback_tokens.get(guild_id, 0) voice_client = self.voice_client(guild_id) if not voice_client: state.current = None @@ -258,7 +333,10 @@ class MusicPlayer: self._record_history(guild_id, track) await self.trigger_state_change() - voice_client.play(track, after=lambda error: self._schedule_next(guild_id, error)) + voice_client.play( + track, + after=lambda error: self._schedule_next(guild_id, error, playback_token), + ) total = len(state.queue) embed = self._embeds.now_playing(track, position=next_index + 1, total=total) @@ -290,7 +368,9 @@ class MusicPlayer: return (index + 1) % len(state.queue) return index + 1 - def _schedule_next(self, guild_id: int, error: Exception | None) -> None: + def _schedule_next(self, guild_id: int, error: Exception | None, playback_token: int) -> None: + if self._playback_tokens.get(guild_id) != playback_token: + return if error: logger.error("Odtwarzanie zakończone błędem na serwerze %s: %s", guild_id, error) asyncio.run_coroutine_threadsafe(self.play_next(guild_id, advance=True), self._bot.loop) diff --git a/zyrarbot/storage/queue_repository.py b/zyrarbot/storage/queue_repository.py new file mode 100644 index 0000000..d51592b --- /dev/null +++ b/zyrarbot/storage/queue_repository.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import os +from typing import Any + +from zyrarbot.storage.json_file_store import JsonFileStore + + +class QueueRepository: + """Persists the last queue snapshot in a separate file per Discord guild.""" + + def __init__(self, directory: str): + self._directory = directory + self._stores: dict[int, JsonFileStore] = {} + + def _store(self, guild_id: int) -> JsonFileStore: + if guild_id not in self._stores: + self._stores[guild_id] = JsonFileStore( + os.path.join(self._directory, f"{guild_id}.json") + ) + return self._stores[guild_id] + + def save(self, guild_id: int, tracks: list[dict[str, Any]]) -> None: + self._store(guild_id).set("tracks", tracks) + + def load(self, guild_id: int) -> list[dict[str, Any]]: + tracks = self._store(guild_id).get("tracks", []) + return tracks if isinstance(tracks, list) else [] + + def exists(self, guild_id: int) -> bool: + return bool(self.load(guild_id)) diff --git a/zyrarbot/ui/api/routers/playback_router.py b/zyrarbot/ui/api/routers/playback_router.py index a87b0a8..ba2c053 100644 --- a/zyrarbot/ui/api/routers/playback_router.py +++ b/zyrarbot/ui/api/routers/playback_router.py @@ -133,6 +133,7 @@ async def stop( player: MusicPlayer = Depends(get_player), broadcaster: StatusBroadcaster = Depends(get_broadcaster), ) -> dict[str, str]: + player.save_queue(guild.id) if player.is_active(guild.id): player.voice_client(guild.id).stop() player.clear(guild.id) diff --git a/zyrarbot/ui/api/routers/queue_router.py b/zyrarbot/ui/api/routers/queue_router.py index d0f8a1f..1d39a2a 100644 --- a/zyrarbot/ui/api/routers/queue_router.py +++ b/zyrarbot/ui/api/routers/queue_router.py @@ -23,6 +23,19 @@ from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster router = APIRouter(prefix="/api", tags=["queue"]) +@router.post("/queue/restore") +async def restore_queue( + guild: discord.Guild = Depends(get_active_guild), + player: MusicPlayer = Depends(get_player), + broadcaster: StatusBroadcaster = Depends(get_broadcaster), +) -> dict[str, int | str]: + count = await player.restore_queue(guild.id) + if count == 0: + raise HTTPException(status_code=404, detail="No saved queue for this server") + await broadcaster.broadcast() + return {"status": "restored", "count": count} + + @router.post("/default_plays") async def set_default_plays( request: DefaultPlaysRequest, diff --git a/zyrarbot/ui/api/routers/voice_router.py b/zyrarbot/ui/api/routers/voice_router.py index b8e7ff9..22c55dc 100644 --- a/zyrarbot/ui/api/routers/voice_router.py +++ b/zyrarbot/ui/api/routers/voice_router.py @@ -90,6 +90,7 @@ async def disconnect( if not voice_client: raise HTTPException(status_code=400, detail="Bot not connected to voice on this server") + player.save_queue(guild.id) await voice_client.disconnect() player.clear(guild.id) await broadcaster.broadcast() diff --git a/zyrarbot/ui/api/status_service.py b/zyrarbot/ui/api/status_service.py index 11c6239..5426e35 100644 --- a/zyrarbot/ui/api/status_service.py +++ b/zyrarbot/ui/api/status_service.py @@ -31,6 +31,7 @@ class StatusService: "default_plays": self._player.guild_default_plays(guild.id) if guild else self._player.default_plays, + "saved_queue_available": self._player.has_saved_queue(guild.id) if guild else False, } if guild is None: payload.update(self._idle_payload()) @@ -55,6 +56,7 @@ class StatusService: "active_voice_channel": None, "text_channels": [], "active_text_channel_id": None, + "saved_queue_available": False, } def _guild_payload(self, guild: discord.Guild) -> dict[str, Any]: diff --git a/zyrarbot/ui/bot/cogs/control_cog.py b/zyrarbot/ui/bot/cogs/control_cog.py index b8dd204..5dfaaf0 100644 --- a/zyrarbot/ui/bot/cogs/control_cog.py +++ b/zyrarbot/ui/bot/cogs/control_cog.py @@ -49,6 +49,7 @@ class ControlCog(MusicCogBase, name="Control"): async def stop(self, interaction: discord.Interaction) -> None: guild_id = interaction.guild.id voice_client = interaction.guild.voice_client + self.player.save_queue(guild_id) if voice_client and voice_client.is_playing(): voice_client.stop() self.player.clear(guild_id) diff --git a/zyrarbot/ui/bot/cogs/voice_cog.py b/zyrarbot/ui/bot/cogs/voice_cog.py index b6eb063..a99f0e8 100644 --- a/zyrarbot/ui/bot/cogs/voice_cog.py +++ b/zyrarbot/ui/bot/cogs/voice_cog.py @@ -23,6 +23,7 @@ class VoiceCog(MusicCogBase, name="Voice"): and after.channel is None ) if is_bot_leaving: + self.player.save_queue(before.channel.guild.id) self.player.clear(before.channel.guild.id) await self.player.trigger_state_change() @@ -33,6 +34,7 @@ class VoiceCog(MusicCogBase, name="Voice"): "Nie jestem na kanale głosowym.", ephemeral=True ) return + self.player.save_queue(interaction.guild.id) await interaction.guild.voice_client.disconnect() self.player.clear(interaction.guild.id) await interaction.response.send_message("Rozłączono z kanału głosowego.")