fix: store playback history per Discord server
This commit is contained in:
5 files changed
+74
-14
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user