fix: store playback history per Discord server

This commit is contained in:
mmacierzanka committed 2026-08-21 16:21:57 +02:00
1 parent 32fd296d9e
commit cfe9a2fc31
5 files changed
+74 -14

No files matched your search

+2
View File
@@ -23,6 +23,8 @@ jest dostępny pod `http://localhost` po wykonaniu `docker compose up --build`.
Panel obsługuje wiele serwerów Discorda. Wybór serwera w nagłówku przełącza jego Panel obsługuje wiele serwerów Discorda. Wybór serwera w nagłówku przełącza jego
osobną kolejkę, historię, bazę plików i ustawienia odtwarzania. Istniejące dane ze osobną kolejkę, historię, bazę plików i ustawienia odtwarzania. Istniejące dane ze
starego, wspólnego formatu są przypisywane do pierwszego użytego serwera. starego, wspólnego formatu są przypisywane do pierwszego użytego serwera.
Historia jest zapisywana w osobnych plikach `config/history/<guild_id>.json`, a
uploady w katalogach `uploads/<guild_id>/`.
## Testy ## Testy
+35
View File
@@ -0,0 +1,35 @@
import json
import tempfile
import unittest
from pathlib import Path
from zyrarbot.music.history_entry import HistoryEntry
from zyrarbot.storage.history_repository import HistoryRepository
from zyrarbot.storage.json_file_store import JsonFileStore
class HistoryRepositoryTest(unittest.TestCase):
def test_history_is_stored_in_separate_files_and_legacy_data_is_migrated(self):
with tempfile.TemporaryDirectory() as directory:
legacy_path = Path(directory) / "history.json"
entry = HistoryEntry(
id="track-1",
title="Track",
url="https://example.test/track",
duration=10,
is_local=False,
filepath=None,
)
legacy_path.write_text(json.dumps({"10": [entry.to_dict()]}), encoding="utf-8")
repository = HistoryRepository(JsonFileStore(str(legacy_path)))
repository.migrate_legacy(10)
self.assertEqual(repository.list(10)[0].id, "track-1")
self.assertEqual(repository.list(20), [])
self.assertTrue((Path(directory) / "history" / "10.json").exists())
self.assertFalse((Path(directory) / "history" / "20.json").exists())
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -43,7 +43,7 @@ class Application:
self.uploads = UploadsRepository(self.settings.uploads_dir) self.uploads = UploadsRepository(self.settings.uploads_dir)
self.tracks = TrackFactory(self.uploads, MediaProbe()) self.tracks = TrackFactory(self.uploads, MediaProbe())
self.bot = MusicBot(self.settings, self.tracks, self.uploads, self.config) self.bot = MusicBot(self.settings, self.tracks, self.uploads, self.config, self.history)
self.guilds = GuildResolver(self.bot) self.guilds = GuildResolver(self.bot)
self.player = MusicPlayer( self.player = MusicPlayer(
bot=self.bot, bot=self.bot,
+32 -13
View File
@@ -1,17 +1,40 @@
from __future__ import annotations from __future__ import annotations
import os
from zyrarbot.music.history_entry import HistoryEntry from zyrarbot.music.history_entry import HistoryEntry
from zyrarbot.storage.json_file_store import JsonFileStore from zyrarbot.storage.json_file_store import JsonFileStore
class HistoryRepository: class HistoryRepository:
"""Historia odtwarzania per serwer, buforowana i utrwalana w history.json.""" """Historia odtwarzania w osobnym pliku dla każdego serwera."""
def __init__(self, store: JsonFileStore, limit: int = 50): def __init__(self, store: JsonFileStore, limit: int = 50):
self._store = store self._store = store
self._limit = limit self._limit = limit
self._cache: dict[int, list[HistoryEntry]] = {} self._cache: dict[int, list[HistoryEntry]] = {}
self._legacy_guild_id: int | None = None self._guild_stores: dict[int, JsonFileStore] = {}
self._legacy_migrated = False
def _guild_store(self, guild_id: int) -> JsonFileStore:
if guild_id not in self._guild_stores:
directory = os.path.join(os.path.dirname(self._store.path), "history")
path = os.path.join(directory, f"{guild_id}.json")
self._guild_stores[guild_id] = JsonFileStore(path)
return self._guild_stores[guild_id]
def migrate_legacy(self, first_guild_id: int) -> None:
"""Move the old shared history file into one file per guild."""
if self._legacy_migrated:
return
legacy_data = self._store.read()
for key, entries in legacy_data.items():
if key == "history" and isinstance(entries, list):
self._write_entries(first_guild_id, entries)
elif key.isdigit() and isinstance(entries, list):
self._write_entries(int(key), entries)
self._legacy_migrated = True
def list(self, guild_id: int) -> list[HistoryEntry]: def list(self, guild_id: int) -> list[HistoryEntry]:
if guild_id not in self._cache: if guild_id not in self._cache:
@@ -30,16 +53,7 @@ class HistoryRepository:
return next((item for item in self.list(guild_id) if item.id == entry_id), None) return next((item for item in self.list(guild_id) if item.id == entry_id), None)
def _load(self, guild_id: int) -> list[HistoryEntry]: def _load(self, guild_id: int) -> list[HistoryEntry]:
raw_entries = self._store.get(str(guild_id)) raw_entries = self._guild_store(guild_id).get("entries", [])
if raw_entries is None and self._legacy_guild_id is None:
legacy = self._store.get("history", [])
if isinstance(legacy, list):
raw_entries = legacy
self._legacy_guild_id = guild_id
self._store.set(str(guild_id), legacy)
data = self._store.read()
data.pop("history", None)
self._store.write(data)
raw_entries = raw_entries or [] raw_entries = raw_entries or []
entries: list[HistoryEntry] = [] entries: list[HistoryEntry] = []
seen: set[tuple[str, str | None]] = set() seen: set[tuple[str, str | None]] = set()
@@ -53,4 +67,9 @@ class HistoryRepository:
return entries return entries
def _persist(self, guild_id: int) -> None: def _persist(self, guild_id: int) -> None:
self._store.set(str(guild_id), [entry.to_dict() for entry in self._cache.get(guild_id, [])]) self._guild_store(guild_id).set(
"entries", [entry.to_dict() for entry in self._cache.get(guild_id, [])]
)
def _write_entries(self, guild_id: int, entries: list[dict]) -> None:
self._guild_store(guild_id).set("entries", entries[: self._limit])
+4
View File
@@ -9,6 +9,7 @@ from zyrarbot.audio.track_factory import TrackFactory
from zyrarbot.music.music_player import MusicPlayer from zyrarbot.music.music_player import MusicPlayer
from zyrarbot.settings import Settings from zyrarbot.settings import Settings
from zyrarbot.storage.guild_config_repository import GuildConfigRepository from zyrarbot.storage.guild_config_repository import GuildConfigRepository
from zyrarbot.storage.history_repository import HistoryRepository
from zyrarbot.storage.uploads_repository import UploadsRepository from zyrarbot.storage.uploads_repository import UploadsRepository
from zyrarbot.ui.bot.cogs.control_cog import ControlCog from zyrarbot.ui.bot.cogs.control_cog import ControlCog
from zyrarbot.ui.bot.cogs.playback_cog import PlaybackCog from zyrarbot.ui.bot.cogs.playback_cog import PlaybackCog
@@ -32,6 +33,7 @@ class MusicBot(commands.Bot):
tracks: TrackFactory, tracks: TrackFactory,
uploads: UploadsRepository, uploads: UploadsRepository,
config: GuildConfigRepository, config: GuildConfigRepository,
history: HistoryRepository,
): ):
intents = discord.Intents.default() intents = discord.Intents.default()
intents.message_content = True intents.message_content = True
@@ -42,6 +44,7 @@ class MusicBot(commands.Bot):
self.tracks = tracks self.tracks = tracks
self.uploads = uploads self.uploads = uploads
self.config = config self.config = config
self.history = history
self.player: MusicPlayer | None = None self.player: MusicPlayer | None = None
def _create_music_cogs(self) -> tuple[commands.Cog, ...]: def _create_music_cogs(self) -> tuple[commands.Cog, ...]:
@@ -77,5 +80,6 @@ class MusicBot(commands.Bot):
first_guild_id = self.guilds[0].id first_guild_id = self.guilds[0].id
self.uploads.migrate_legacy(first_guild_id) self.uploads.migrate_legacy(first_guild_id)
self.config.migrate_legacy(first_guild_id) self.config.migrate_legacy(first_guild_id)
self.history.migrate_legacy(first_guild_id)
if self.user: if self.user:
logger.info("Zalogowano jako %s (%s)", self.user.name, self.user.id) logger.info("Zalogowano jako %s (%s)", self.user.name, self.user.id)