diff --git a/README.md b/README.md index 1e25dc2..da88772 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,21 @@ Wymagane zmienne środowiskowe (plik `.env`): | --------------- | ----------------------------------------------------------- | | `DISCORD_TOKEN` | Token bota Discord. | | `GUILD_ID` | Opcjonalnie - synchronizuje komendy tylko z jednym serwerem. | +| `POSTGRES_PASSWORD` | Hasło użytkownika bazy; domyślnie `zyrarbot`, zalecana zmiana w `.env`. | +| `POSTGRES_DB` / `POSTGRES_USER` | Opcjonalna nazwa bazy i użytkownika; domyślnie `zyrarbot`. | Panel działa pod `http://localhost:8000` podczas uruchomienia lokalnego. W Dockerze 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 -osobną kolejkę, historię, bazę plików i ustawienia odtwarzania. Istniejące dane ze -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. +osobną kolejkę, historię, bazę plików i ustawienia odtwarzania. Ustawienia, historia +i migawki kolejek przechodzą przez porty persystencji, a PostgreSQL jest obecnym +adapterem wybranym w korzeniu aplikacji. Baza działa jako usługa Compose, a jej dane +trwale przechowuje wolumen `postgres_data`. Przy pierwszym uruchomieniu +bot importuje istniejące pliki JSON z `config/` do bazy, a pliki źródłowe zostają +na miejscu jako kopia. Starsze wspólne dane są przypisywane do pierwszego serwera, +do którego bot należy. Uploady pozostają w +katalogach `uploads//`. ## Komendy Discorda @@ -78,12 +82,15 @@ odpowiadające warstwom aplikacji: zyrarbot/ ├── settings.py Settings - konfiguracja ze zmiennych środowiskowych ├── application.py Application - korzeń kompozycji, spina bota i API -├── storage/ Warstwa trwałości (pliki JSON, katalog uploadów) -│ ├── json_file_store.py JsonFileStore -│ ├── guild_config_repository.py GuildConfigRepository -│ ├── history_repository.py HistoryRepository -│ ├── uploads_repository.py UploadsRepository -│ └── upload_file_info.py UploadFileInfo +├── storage/ +│ ├── ports/ GuildConfigRepository, HistoryRepository, +│ │ QueueRepository, UploadsRepository, Persistence +│ ├── adapters/ +│ │ ├── postgres/ PostgresGuildConfig, PostgresHistory, PostgresQueue, +│ │ │ PostgresPersistence, PostgresDatabase +│ │ ├── filesystem/ Adapter plików uploadu +│ │ └── json/ Adapter plikowego magazynu JSON +│ └── upload_file_info.py UploadFileInfo ├── audio/ Pozyskiwanie dźwięku │ ├── ytdl_options.py YtdlOptions │ ├── ytdl_source.py YTDLSource diff --git a/compose.yaml b/compose.yaml index 2ae4621..0a442cb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,5 +1,21 @@ services: - # Bot Discorda wraz z API i panelem webowym serwowanym przez FastAPI. + database: + image: postgres:17-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-zyrarbot} + POSTGRES_USER: ${POSTGRES_USER:-zyrarbot} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-zyrarbot} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + restart: unless-stopped + ports: + - "5432:5432/tcp" discord-bot: build: context: . @@ -8,10 +24,21 @@ services: environment: - DISCORD_TOKEN=${DISCORD_TOKEN} - GUILD_ID=${GUILD_ID:-} + - POSTGRES_DB=${POSTGRES_DB:-zyrarbot} + - POSTGRES_USER=${POSTGRES_USER:-zyrarbot} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-zyrarbot} + - POSTGRES_HOST=database + - POSTGRES_PORT=5432 - PYTHONUNBUFFERED=1 + depends_on: + database: + condition: service_healthy volumes: - ./config:/app/config - ./uploads:/app/uploads ports: - "80:8000" restart: unless-stopped + +volumes: + postgres_data: diff --git a/frontend/src/components/HistoryPanel.vue b/frontend/src/components/HistoryPanel.vue index 104e5c6..1061e3c 100644 --- a/frontend/src/components/HistoryPanel.vue +++ b/frontend/src/components/HistoryPanel.vue @@ -26,7 +26,7 @@ async function replay(entry: HistoryTrack) { const pendingId = addPendingTrack(props.status.active_voice_channel.guild_id, entry.title) try { if (entry.is_local) { - const path = entry.filepath ?? entry.url ?? entry.title + const path = entry.url ?? entry.title await enqueue(path.split(/[\\/]/).pop() ?? path) return } diff --git a/frontend/src/types/status.ts b/frontend/src/types/status.ts index 0b875c8..36fdd81 100644 --- a/frontend/src/types/status.ts +++ b/frontend/src/types/status.ts @@ -41,7 +41,6 @@ export interface HistoryTrack { url: string | null duration: number | null is_local: boolean - filepath: string | null } export interface UploadedFile { diff --git a/requirements.txt b/requirements.txt index f626305..3e2a489 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ fastapi uvicorn websockets python-multipart +psycopg[binary] diff --git a/zyrarbot/application.py b/zyrarbot/application.py index 550f00d..db98363 100644 --- a/zyrarbot/application.py +++ b/zyrarbot/application.py @@ -19,11 +19,9 @@ from zyrarbot.music.music_player import MusicPlayer from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver from zyrarbot.music.track_embed_factory import TrackEmbedFactory 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.storage.adapters.filesystem.uploads import UploadsRepository +from zyrarbot.storage.adapters.postgres.persistence import PostgresPersistence +from zyrarbot.storage.ports.persistence import Persistence from zyrarbot.ui.api.factory import mount_api from zyrarbot.ui.bot.client import MusicBot from zyrarbot.ui.bot.guild_resolver import GuildResolver @@ -34,18 +32,21 @@ logger = logging.getLogger(__name__) class Application: """Korzeń kompozycji: składa wszystkie zależności i prowadzi bota razem z API.""" - def __init__(self, settings: Settings | None = None): + def __init__( + self, + settings: Settings | None = None, + persistence: Persistence | None = None, + ): self.settings = settings or Settings.from_env() - - self.config = GuildConfigRepository( - JsonFileStore(self.settings.config_file), - fallback_plays=self.settings.default_plays, - ) - self.history = HistoryRepository( - JsonFileStore(self.settings.history_file), limit=self.settings.history_limit + self.persistence: Persistence = persistence or PostgresPersistence( + config_dir=self.settings.config_dir, + default_plays=self.settings.default_plays, + history_limit=self.settings.history_limit, ) + self.config = self.persistence.config + self.history = self.persistence.history self.uploads = UploadsRepository(self.settings.uploads_dir) - self.queues = QueueRepository(os.path.join(self.settings.config_dir, "queues")) + self.queues = self.persistence.queues self.tracks = TrackFactory(self.uploads, MediaProbe()) self._server: uvicorn.Server | None = None @@ -55,6 +56,7 @@ class Application: self.uploads, self.config, self.history, + self.persistence, restart_callback=self.schedule_restart, ) self.guilds = GuildResolver(self.bot) diff --git a/zyrarbot/audio/track_factory.py b/zyrarbot/audio/track_factory.py index a5ce606..0031360 100644 --- a/zyrarbot/audio/track_factory.py +++ b/zyrarbot/audio/track_factory.py @@ -10,7 +10,7 @@ import yt_dlp from zyrarbot.audio.media_probe import MediaProbe from zyrarbot.audio.ytdl_options import YtdlOptions from zyrarbot.audio.ytdl_source import YTDLSource -from zyrarbot.storage.uploads_repository import UploadsRepository +from zyrarbot.storage.ports.uploads import UploadsRepository class TrackFactory: diff --git a/zyrarbot/music/history_entry.py b/zyrarbot/music/history_entry.py index d573e4f..bf8f90b 100644 --- a/zyrarbot/music/history_entry.py +++ b/zyrarbot/music/history_entry.py @@ -13,17 +13,11 @@ class HistoryEntry: url: str | None = None duration: float | None = None is_local: bool = False - filepath: str | None = None def is_same_track_as(self, other: HistoryEntry) -> bool: """Wpisy uznajemy za ten sam utwór przy zgodnym tytule albo adresie.""" return self.title == other.title or bool(other.url and self.url == other.url) - @property - def replay_source(self) -> str | None: - """Ścieżka lub adres, z którego wpis można odtworzyć ponownie.""" - return (self.filepath or self.url) if self.is_local else self.url - def to_dict(self) -> dict[str, Any]: return { "id": self.id, @@ -31,16 +25,4 @@ class HistoryEntry: "url": self.url, "duration": self.duration, "is_local": self.is_local, - "filepath": self.filepath, } - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> HistoryEntry: - return cls( - id=str(data.get("id", "")), - title=data.get("title", ""), - url=data.get("url"), - duration=data.get("duration"), - is_local=bool(data.get("is_local", False)), - filepath=data.get("filepath"), - ) diff --git a/zyrarbot/music/music_player.py b/zyrarbot/music/music_player.py index 4c8c3c2..2f82609 100644 --- a/zyrarbot/music/music_player.py +++ b/zyrarbot/music/music_player.py @@ -15,9 +15,9 @@ from zyrarbot.music.history_entry import HistoryEntry from zyrarbot.music.loop_mode import LoopMode from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver 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 +from zyrarbot.storage.ports.guild_config import GuildConfigRepository +from zyrarbot.storage.ports.history import HistoryRepository +from zyrarbot.storage.ports.queue import QueueRepository logger = logging.getLogger(__name__) @@ -542,7 +542,6 @@ class MusicPlayer: url=track.display_url, duration=track.duration, is_local=track.is_local, - filepath=track.filepath, ), ) diff --git a/zyrarbot/music/notification_channel_resolver.py b/zyrarbot/music/notification_channel_resolver.py index 68370ca..22d27e2 100644 --- a/zyrarbot/music/notification_channel_resolver.py +++ b/zyrarbot/music/notification_channel_resolver.py @@ -3,7 +3,7 @@ from __future__ import annotations import discord from discord.ext import commands -from zyrarbot.storage.guild_config_repository import GuildConfigRepository +from zyrarbot.storage.ports.guild_config import GuildConfigRepository class NotificationChannelResolver: diff --git a/zyrarbot/storage/adapters/__init__.py b/zyrarbot/storage/adapters/__init__.py new file mode 100644 index 0000000..80c5d98 --- /dev/null +++ b/zyrarbot/storage/adapters/__init__.py @@ -0,0 +1 @@ +"""Concrete persistence adapters implementing storage ports.""" diff --git a/zyrarbot/storage/adapters/filesystem/__init__.py b/zyrarbot/storage/adapters/filesystem/__init__.py new file mode 100644 index 0000000..1ed5c99 --- /dev/null +++ b/zyrarbot/storage/adapters/filesystem/__init__.py @@ -0,0 +1 @@ +"""Filesystem adapters for uploaded media.""" diff --git a/zyrarbot/storage/uploads_repository.py b/zyrarbot/storage/adapters/filesystem/uploads.py similarity index 100% rename from zyrarbot/storage/uploads_repository.py rename to zyrarbot/storage/adapters/filesystem/uploads.py diff --git a/zyrarbot/storage/adapters/json/__init__.py b/zyrarbot/storage/adapters/json/__init__.py new file mode 100644 index 0000000..b77b104 --- /dev/null +++ b/zyrarbot/storage/adapters/json/__init__.py @@ -0,0 +1 @@ +"""JSON file storage adapter.""" diff --git a/zyrarbot/storage/json_file_store.py b/zyrarbot/storage/adapters/json/file_store.py similarity index 100% rename from zyrarbot/storage/json_file_store.py rename to zyrarbot/storage/adapters/json/file_store.py diff --git a/zyrarbot/storage/adapters/postgres/__init__.py b/zyrarbot/storage/adapters/postgres/__init__.py new file mode 100644 index 0000000..9da0e47 --- /dev/null +++ b/zyrarbot/storage/adapters/postgres/__init__.py @@ -0,0 +1 @@ +"""PostgreSQL persistence adapters.""" diff --git a/zyrarbot/storage/adapters/postgres/database.py b/zyrarbot/storage/adapters/postgres/database.py new file mode 100644 index 0000000..d88aee1 --- /dev/null +++ b/zyrarbot/storage/adapters/postgres/database.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import json +import logging +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +import psycopg + +logger = logging.getLogger(__name__) + + +class PostgresDatabase: + """PostgreSQL storage shared by the application's repositories.""" + + def __init__( + self, + host: str, + port: int, + database: str, + user: str, + password: str, + ): + self.connection_options = { + "host": host, + "port": port, + "dbname": database, + "user": user, + "password": password, + } + with self.connect() as connection: + schema = """ + CREATE TABLE IF NOT EXISTS guilds ( + id BIGINT PRIMARY KEY, + notification_channel_id BIGINT, + default_plays INTEGER + ); + CREATE TABLE IF NOT EXISTS history_entries ( + guild_id BIGINT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + url TEXT, + duration DOUBLE PRECISION, + is_local BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT history_entries_guild_position_key UNIQUE (guild_id, position) + ); + CREATE TABLE IF NOT EXISTS queue_snapshots ( + guild_id BIGINT PRIMARY KEY REFERENCES guilds(id) ON DELETE CASCADE, + tracks JSONB NOT NULL + ); + CREATE TABLE IF NOT EXISTS migrations ( + name TEXT PRIMARY KEY + ); + """ + for statement in schema.split(";"): + if statement.strip(): + connection.execute(statement) + + @contextmanager + def connect(self) -> Iterator[psycopg.Connection[Any]]: + with psycopg.connect(**self.connection_options) as connection: + yield connection + + def migrate_legacy_json(self, config_dir: str, first_guild_id: int | None = None) -> None: + """Import legacy JSON files once, retaining the source files as a backup.""" + base = Path(config_dir) + with self.connect() as connection: + if connection.execute("SELECT 1 FROM migrations WHERE name='json-v1'").fetchone(): + return + + config_path = base / "config.json" + if config_path.exists(): + data = _read_json(config_path) + legacy_default = data.pop("default_plays", None) + for guild_key, raw in data.items(): + if not str(guild_key).isdigit(): + continue + values = raw if isinstance(raw, dict) else {"notification_channel_id": raw} + _upsert_guild(connection, int(guild_key), values) + if legacy_default is not None and first_guild_id is not None: + _upsert_guild(connection, first_guild_id, {"default_plays": legacy_default}) + + # Per-guild history files take precedence over the old shared file. + for path in (base / "history").glob("*.json"): + if path.stem.isdigit(): + entries = _read_json(path).get("entries", []) + if isinstance(entries, list): + _import_history(connection, int(path.stem), entries) + history_path = base / "history.json" + if history_path.exists(): + history = _read_json(history_path) + for key, entries in history.items(): + if key.isdigit() and isinstance(entries, list): + _import_history(connection, int(key), entries) + elif key == "history" and isinstance(entries, list) and first_guild_id is not None: + _import_history(connection, first_guild_id, entries) + + for path in (base / "queues").glob("*.json"): + if path.stem.isdigit(): + tracks = _read_json(path).get("tracks", []) + if isinstance(tracks, list) and tracks: + _ensure_guild(connection, int(path.stem)) + connection.execute( + "INSERT INTO queue_snapshots(guild_id, tracks) VALUES (%s, %s::jsonb) " + "ON CONFLICT(guild_id) DO NOTHING", + (int(path.stem), json.dumps(tracks, ensure_ascii=False)), + ) + connection.execute("INSERT INTO migrations(name) VALUES ('json-v1')") + + +def _read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else {} + except (OSError, ValueError) as error: + logger.error("Nie udało się zaimportować %s: %s", path, error) + return {} + + +def _ensure_guild(connection: psycopg.Connection[Any], guild_id: int) -> None: + connection.execute("INSERT INTO guilds(id) VALUES (%s) ON CONFLICT(id) DO NOTHING", (guild_id,)) + + +def _upsert_guild( + connection: psycopg.Connection[Any], guild_id: int, values: dict[str, Any] +) -> None: + _ensure_guild(connection, guild_id) + for field in ("notification_channel_id", "default_plays"): + if values.get(field) is not None: + try: + value = int(values[field]) + except (TypeError, ValueError): + logger.error("Nieprawidłowa wartość %s serwera %s: %r", field, guild_id, values[field]) + continue + connection.execute(f"UPDATE guilds SET {field}=%s WHERE id=%s", (value, guild_id)) + + +def _import_history( + connection: psycopg.Connection[Any], guild_id: int, entries: list[Any] +) -> None: + _ensure_guild(connection, guild_id) + if connection.execute( + "SELECT 1 FROM history_entries WHERE guild_id=%s LIMIT 1", (guild_id,) + ).fetchone(): + return + for position, item in enumerate(entries[:50]): + if isinstance(item, dict): + raw_duration = item.get("duration") + try: + duration = float(raw_duration) if raw_duration is not None else None + except (TypeError, ValueError): + logger.warning( + "Nieprawidłowy czas trwania historii serwera %s: %r", + guild_id, + raw_duration, + ) + duration = None + is_local = bool(item.get("is_local", False)) + source = item.get("url") + if is_local: + source = item.get("filepath") or source + connection.execute( + "INSERT INTO history_entries " + "(guild_id, position, id, title, url, duration, is_local) " + "VALUES (%s, %s, %s, %s, %s, %s, %s)", + ( + guild_id, + position, + str(item.get("id", "")), + str(item.get("title") or ""), + source, + duration, + is_local, + ), + ) diff --git a/zyrarbot/storage/adapters/postgres/guild_config.py b/zyrarbot/storage/adapters/postgres/guild_config.py new file mode 100644 index 0000000..dd82b50 --- /dev/null +++ b/zyrarbot/storage/adapters/postgres/guild_config.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from zyrarbot.storage.adapters.postgres.database import PostgresDatabase + + +class PostgresGuildConfig: + """PostgreSQL adapter for the per-server configuration port.""" + + def __init__(self, database: PostgresDatabase, fallback_plays: int = 5): + self._database = database + self._fallback_plays = fallback_plays + + def _ensure(self, guild_id: int) -> None: + with self._database.connect() as connection: + connection.execute("INSERT INTO guilds(id) VALUES (%s) ON CONFLICT(id) DO NOTHING", (guild_id,)) + + def get_notification_channel_id(self, guild_id: int) -> int | None: + with self._database.connect() as connection: + row = connection.execute( + "SELECT notification_channel_id FROM guilds WHERE id=%s", (guild_id,) + ).fetchone() + return row[0] if row else None + + def set_notification_channel_id(self, guild_id: int, channel_id: int | None) -> None: + self._ensure(guild_id) + with self._database.connect() as connection: + connection.execute( + "UPDATE guilds SET notification_channel_id=%s WHERE id=%s", (channel_id, guild_id) + ) + + def get_default_plays(self, guild_id: int | None = None) -> int: + if guild_id is None: + return self._fallback_plays + with self._database.connect() as connection: + row = connection.execute("SELECT default_plays FROM guilds WHERE id=%s", (guild_id,)).fetchone() + return int(row[0]) if row and row[0] is not None else self._fallback_plays + + def set_default_plays(self, guild_id: int, value: int) -> None: + self._ensure(guild_id) + with self._database.connect() as connection: + connection.execute("UPDATE guilds SET default_plays=%s WHERE id=%s", (int(value), guild_id)) diff --git a/zyrarbot/storage/adapters/postgres/history.py b/zyrarbot/storage/adapters/postgres/history.py new file mode 100644 index 0000000..e2f3e24 --- /dev/null +++ b/zyrarbot/storage/adapters/postgres/history.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from zyrarbot.music.history_entry import HistoryEntry +from zyrarbot.storage.adapters.postgres.database import PostgresDatabase + + +class PostgresHistory: + """PostgreSQL adapter for the per-guild history port.""" + + def __init__(self, database: PostgresDatabase, limit: int = 50): + self._database = database + self._limit = limit + + def list(self, guild_id: int) -> list[HistoryEntry]: + with self._database.connect() as connection: + rows = connection.execute( + "SELECT id, title, url, duration, is_local " + "FROM history_entries WHERE guild_id=%s ORDER BY position", + (guild_id,), + ).fetchall() + entries = [ + HistoryEntry( + id=row[0], + title=row[1], + url=row[2], + duration=row[3], + is_local=row[4], + ) + for row in rows + ] + seen: set[tuple[str, str | None]] = set() + unique_entries: list[HistoryEntry] = [] + for entry in entries: + key = (entry.title, entry.url) + if key not in seen: + seen.add(key) + unique_entries.append(entry) + return unique_entries + + def add(self, guild_id: int, entry: HistoryEntry) -> None: + if not entry.title: + return + entries = [item for item in self.list(guild_id) if not item.is_same_track_as(entry)] + entries.insert(0, entry) + entries = entries[: self._limit] + with self._database.connect() as connection: + connection.execute( + "INSERT INTO guilds(id) VALUES (%s) ON CONFLICT(id) DO NOTHING", (guild_id,) + ) + connection.execute("DELETE FROM history_entries WHERE guild_id=%s", (guild_id,)) + for position, item in enumerate(entries): + connection.execute( + "INSERT INTO history_entries " + "(guild_id, position, id, title, url, duration, is_local) " + "VALUES (%s, %s, %s, %s, %s, %s, %s)", + ( + guild_id, + position, + item.id, + item.title, + item.url, + item.duration, + item.is_local, + ), + ) + + def find(self, guild_id: int, entry_id: str) -> HistoryEntry | None: + return next((item for item in self.list(guild_id) if item.id == entry_id), None) diff --git a/zyrarbot/storage/adapters/postgres/persistence.py b/zyrarbot/storage/adapters/postgres/persistence.py new file mode 100644 index 0000000..309c4f0 --- /dev/null +++ b/zyrarbot/storage/adapters/postgres/persistence.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import os + +from zyrarbot.storage.adapters.postgres.database import PostgresDatabase +from zyrarbot.storage.adapters.postgres.guild_config import PostgresGuildConfig +from zyrarbot.storage.adapters.postgres.history import PostgresHistory +from zyrarbot.storage.adapters.postgres.queue import PostgresQueue +from zyrarbot.storage.ports.guild_config import GuildConfigRepository +from zyrarbot.storage.ports.history import HistoryRepository +from zyrarbot.storage.ports.queue import QueueRepository + + +class PostgresPersistence: + """Composition of PostgreSQL adapters implementing the storage ports.""" + + def __init__(self, config_dir: str, default_plays: int, history_limit: int): + self.database = PostgresDatabase( + host=os.environ.get("POSTGRES_HOST", "localhost"), + port=int(os.environ.get("POSTGRES_PORT", "5432")), + database=os.environ.get("POSTGRES_DB", "zyrarbot"), + user=os.environ.get("POSTGRES_USER", "zyrarbot"), + password=os.environ.get("POSTGRES_PASSWORD", "zyrarbot"), + ) + self.config: GuildConfigRepository = PostgresGuildConfig( + self.database, fallback_plays=default_plays + ) + self.history: HistoryRepository = PostgresHistory( + self.database, limit=history_limit + ) + self.queues: QueueRepository = PostgresQueue(self.database) + self._config_dir = config_dir + + def migrate_legacy(self, config_dir: str, first_guild_id: int) -> None: + self.database.migrate_legacy_json(config_dir or self._config_dir, first_guild_id) diff --git a/zyrarbot/storage/adapters/postgres/queue.py b/zyrarbot/storage/adapters/postgres/queue.py new file mode 100644 index 0000000..45f314e --- /dev/null +++ b/zyrarbot/storage/adapters/postgres/queue.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import json +from typing import Any + +from zyrarbot.storage.adapters.postgres.database import PostgresDatabase + + +class PostgresQueue: + """PostgreSQL adapter for per-guild queue snapshots.""" + + def __init__(self, database: PostgresDatabase): + self._database = database + + def save(self, guild_id: int, tracks: list[dict[str, Any]]) -> None: + if not tracks: + return + with self._database.connect() as connection: + connection.execute( + "INSERT INTO guilds(id) VALUES (%s) ON CONFLICT(id) DO NOTHING", (guild_id,) + ) + connection.execute( + "INSERT INTO queue_snapshots(guild_id, tracks) VALUES (%s, %s::jsonb) " + "ON CONFLICT(guild_id) DO UPDATE SET tracks=excluded.tracks", + (guild_id, json.dumps(tracks, ensure_ascii=False)), + ) + + def load(self, guild_id: int) -> list[dict[str, Any]]: + with self._database.connect() as connection: + row = connection.execute( + "SELECT tracks FROM queue_snapshots WHERE guild_id=%s", (guild_id,) + ).fetchone() + if not row: + return [] + tracks = row[0] + return tracks if isinstance(tracks, list) else [] + + def exists(self, guild_id: int) -> bool: + return bool(self.load(guild_id)) diff --git a/zyrarbot/storage/guild_config_repository.py b/zyrarbot/storage/guild_config_repository.py deleted file mode 100644 index 67c3a1d..0000000 --- a/zyrarbot/storage/guild_config_repository.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -import logging - -from zyrarbot.storage.json_file_store import JsonFileStore - -logger = logging.getLogger(__name__) - - -class GuildConfigRepository: - """Dostęp do konfiguracji przechowywanej osobno dla każdego serwera.""" - - DEFAULT_PLAYS_KEY = "default_plays" - - def __init__(self, store: JsonFileStore, fallback_plays: int = 5): - self._store = store - self._fallback_plays = fallback_plays - - def _guild_config(self, guild_id: int) -> dict[str, int | None]: - data = self._store.read() - key = str(guild_id) - value = data.get(key) - - # Migrate the old format {"guild_id": channel_id} lazily. - if isinstance(value, dict): - if self.DEFAULT_PLAYS_KEY in data: - try: - value.setdefault("default_plays", int(data[self.DEFAULT_PLAYS_KEY])) - except (TypeError, ValueError): - logger.error( - "Nieprawidłowa wartość %s: %r", - self.DEFAULT_PLAYS_KEY, - data[self.DEFAULT_PLAYS_KEY], - ) - data.pop(self.DEFAULT_PLAYS_KEY, None) - data[key] = value - self._store.write(data) - return value - config: dict[str, int | None] = {} - if value is not None: - try: - config["notification_channel_id"] = int(value) - except (TypeError, ValueError): - logger.error( - "Nieprawidłowe ID kanału w konfiguracji serwera %s: %r", guild_id, value - ) - - # The old default was global. The first guild that is accessed owns it. - legacy_default = data.get(self.DEFAULT_PLAYS_KEY) - if legacy_default is not None: - try: - config["default_plays"] = int(legacy_default) - except (TypeError, ValueError): - logger.error("Nieprawidłowa wartość %s: %r", self.DEFAULT_PLAYS_KEY, legacy_default) - data.pop(self.DEFAULT_PLAYS_KEY, None) - data[key] = config - self._store.write(data) - return config - - def migrate_legacy(self, guild_id: int) -> None: - """Assign the old global configuration to the first known guild.""" - self._guild_config(guild_id) - - def get_notification_channel_id(self, guild_id: int) -> int | None: - value = self._guild_config(guild_id).get("notification_channel_id") - try: - return int(value) if value is not None else None - except (TypeError, ValueError): - logger.error("Nieprawidłowe ID kanału w konfiguracji serwera %s: %r", guild_id, value) - return None - - def set_notification_channel_id(self, guild_id: int, channel_id: int | None) -> None: - config = self._guild_config(guild_id) - config["notification_channel_id"] = channel_id - self._store.set(str(guild_id), config) - - def get_default_plays(self, guild_id: int | None = None) -> int: - if guild_id is None: - return self._fallback_plays - value = self._guild_config(guild_id).get("default_plays") - if value is None: - return self._fallback_plays - try: - return int(value) - except (TypeError, ValueError): - logger.error("Nieprawidłowa wartość %s: %r", self.DEFAULT_PLAYS_KEY, value) - return self._fallback_plays - - def set_default_plays(self, guild_id: int, value: int) -> None: - config = self._guild_config(guild_id) - config["default_plays"] = int(value) - self._store.set(str(guild_id), config) diff --git a/zyrarbot/storage/history_repository.py b/zyrarbot/storage/history_repository.py deleted file mode 100644 index f19966d..0000000 --- a/zyrarbot/storage/history_repository.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -import os - -from zyrarbot.music.history_entry import HistoryEntry -from zyrarbot.storage.json_file_store import JsonFileStore - - -class HistoryRepository: - """Historia odtwarzania w osobnym pliku dla każdego serwera.""" - - def __init__(self, store: JsonFileStore, limit: int = 50): - self._store = store - self._limit = limit - self._cache: dict[int, list[HistoryEntry]] = {} - 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]: - if guild_id not in self._cache: - self._cache[guild_id] = self._load(guild_id) - return self._cache[guild_id] - - def add(self, guild_id: int, entry: HistoryEntry) -> None: - if not entry.title: - return - entries = [item for item in self.list(guild_id) if not item.is_same_track_as(entry)] - entries.insert(0, entry) - self._cache[guild_id] = entries[: self._limit] - self._persist(guild_id) - - def find(self, guild_id: int, entry_id: str) -> HistoryEntry | 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]: - raw_entries = self._guild_store(guild_id).get("entries", []) - raw_entries = raw_entries or [] - entries: list[HistoryEntry] = [] - seen: set[tuple[str, str | None]] = set() - for raw in raw_entries: - entry = HistoryEntry.from_dict(raw) - key = (entry.title, entry.url) - if key in seen: - continue - seen.add(key) - entries.append(entry) - return entries - - def _persist(self, guild_id: int) -> None: - 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]) diff --git a/zyrarbot/storage/ports/__init__.py b/zyrarbot/storage/ports/__init__.py new file mode 100644 index 0000000..c350e0b --- /dev/null +++ b/zyrarbot/storage/ports/__init__.py @@ -0,0 +1 @@ +"""Ports describing persistence operations used by the application.""" diff --git a/zyrarbot/storage/ports/guild_config.py b/zyrarbot/storage/ports/guild_config.py new file mode 100644 index 0000000..9cf71fe --- /dev/null +++ b/zyrarbot/storage/ports/guild_config.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Protocol + + +class GuildConfigRepository(Protocol): + def get_notification_channel_id(self, guild_id: int) -> int | None: ... + + def set_notification_channel_id(self, guild_id: int, channel_id: int | None) -> None: ... + + def get_default_plays(self, guild_id: int | None = None) -> int: ... + + def set_default_plays(self, guild_id: int, value: int) -> None: ... diff --git a/zyrarbot/storage/ports/history.py b/zyrarbot/storage/ports/history.py new file mode 100644 index 0000000..892fe9b --- /dev/null +++ b/zyrarbot/storage/ports/history.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Protocol + +from zyrarbot.music.history_entry import HistoryEntry + + +class HistoryRepository(Protocol): + def list(self, guild_id: int) -> list[HistoryEntry]: ... + + def add(self, guild_id: int, entry: HistoryEntry) -> None: ... + + def find(self, guild_id: int, entry_id: str) -> HistoryEntry | None: ... diff --git a/zyrarbot/storage/ports/legacy_data_migration.py b/zyrarbot/storage/ports/legacy_data_migration.py new file mode 100644 index 0000000..d1b0b55 --- /dev/null +++ b/zyrarbot/storage/ports/legacy_data_migration.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from typing import Protocol + + +class LegacyDataMigration(Protocol): + def migrate_legacy(self, config_dir: str, first_guild_id: int) -> None: ... diff --git a/zyrarbot/storage/ports/persistence.py b/zyrarbot/storage/ports/persistence.py new file mode 100644 index 0000000..8e2bedb --- /dev/null +++ b/zyrarbot/storage/ports/persistence.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Protocol + +from zyrarbot.storage.ports.guild_config import GuildConfigRepository +from zyrarbot.storage.ports.history import HistoryRepository +from zyrarbot.storage.ports.legacy_data_migration import LegacyDataMigration +from zyrarbot.storage.ports.queue import QueueRepository + + +class Persistence(LegacyDataMigration, Protocol): + config: GuildConfigRepository + history: HistoryRepository + queues: QueueRepository diff --git a/zyrarbot/storage/ports/queue.py b/zyrarbot/storage/ports/queue.py new file mode 100644 index 0000000..55c2dc4 --- /dev/null +++ b/zyrarbot/storage/ports/queue.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import Any, Protocol + + +class QueueRepository(Protocol): + def save(self, guild_id: int, tracks: list[dict[str, Any]]) -> None: ... + + def load(self, guild_id: int) -> list[dict[str, Any]]: ... + + def exists(self, guild_id: int) -> bool: ... diff --git a/zyrarbot/storage/ports/uploads.py b/zyrarbot/storage/ports/uploads.py new file mode 100644 index 0000000..d527091 --- /dev/null +++ b/zyrarbot/storage/ports/uploads.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import BinaryIO, Protocol + +from zyrarbot.storage.upload_file_info import UploadFileInfo + + +class UploadsRepository(Protocol): + @property + def directory(self) -> str: ... + + def is_supported_filename(self, filename: str) -> bool: ... + + def resolve(self, filename: str, guild_id: int | None = None) -> str: ... + + def exists(self, filename: str, guild_id: int | None = None) -> bool: ... + + def list(self, guild_id: int | None = None) -> list[UploadFileInfo]: ... + + def search(self, query: str, limit: int = 25, guild_id: int | None = None) -> list[str]: ... + + def save(self, filename: str, source: BinaryIO, guild_id: int | None = None) -> str: ... + + def delete(self, filename: str, guild_id: int | None = None) -> bool: ... + + def touch(self, filename: str, guild_id: int | None = None) -> None: ... + + def migrate_legacy(self, guild_id: int) -> None: ... diff --git a/zyrarbot/storage/queue_repository.py b/zyrarbot/storage/queue_repository.py deleted file mode 100644 index 84067ee..0000000 --- a/zyrarbot/storage/queue_repository.py +++ /dev/null @@ -1,33 +0,0 @@ -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: - if not tracks: - return - 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/dependencies.py b/zyrarbot/ui/api/dependencies.py index b81f414..d9f0f03 100644 --- a/zyrarbot/ui/api/dependencies.py +++ b/zyrarbot/ui/api/dependencies.py @@ -6,8 +6,8 @@ from fastapi import HTTPException, Query, Request, WebSocket from zyrarbot.audio.track_factory import TrackFactory from zyrarbot.music.music_player import MusicPlayer from zyrarbot.settings import Settings -from zyrarbot.storage.guild_config_repository import GuildConfigRepository -from zyrarbot.storage.uploads_repository import UploadsRepository +from zyrarbot.storage.ports.guild_config import GuildConfigRepository +from zyrarbot.storage.ports.uploads import UploadsRepository from zyrarbot.ui.api.connection_manager import ConnectionManager from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster from zyrarbot.ui.api.status_service import StatusService diff --git a/zyrarbot/ui/api/factory.py b/zyrarbot/ui/api/factory.py index 839e9b5..3acbf42 100644 --- a/zyrarbot/ui/api/factory.py +++ b/zyrarbot/ui/api/factory.py @@ -5,8 +5,8 @@ from fastapi import FastAPI from zyrarbot.audio.track_factory import TrackFactory from zyrarbot.music.music_player import MusicPlayer from zyrarbot.settings import Settings -from zyrarbot.storage.guild_config_repository import GuildConfigRepository -from zyrarbot.storage.uploads_repository import UploadsRepository +from zyrarbot.storage.ports.guild_config import GuildConfigRepository +from zyrarbot.storage.ports.uploads import UploadsRepository from zyrarbot.ui.api.connection_manager import ConnectionManager from zyrarbot.ui.api.routers import ( playback_router, diff --git a/zyrarbot/ui/api/routers/playback_router.py b/zyrarbot/ui/api/routers/playback_router.py index 95e6bdd..bb0b2a8 100644 --- a/zyrarbot/ui/api/routers/playback_router.py +++ b/zyrarbot/ui/api/routers/playback_router.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException from zyrarbot.audio.track_factory import TrackFactory from zyrarbot.music.loop_mode import LoopMode from zyrarbot.music.music_player import MusicPlayer -from zyrarbot.storage.uploads_repository import UploadsRepository +from zyrarbot.storage.ports.uploads import UploadsRepository from zyrarbot.ui.api.dependencies import ( get_active_guild, get_broadcaster, diff --git a/zyrarbot/ui/api/routers/uploads_router.py b/zyrarbot/ui/api/routers/uploads_router.py index 046e6a1..c4482c1 100644 --- a/zyrarbot/ui/api/routers/uploads_router.py +++ b/zyrarbot/ui/api/routers/uploads_router.py @@ -8,7 +8,7 @@ import discord from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from zyrarbot.audio.media_probe import MediaProbe -from zyrarbot.storage.uploads_repository import UploadsRepository +from zyrarbot.storage.ports.uploads import UploadsRepository from zyrarbot.ui.api.dependencies import get_active_guild, get_uploads router = APIRouter(prefix="/api", tags=["uploads"]) diff --git a/zyrarbot/ui/api/routers/voice_router.py b/zyrarbot/ui/api/routers/voice_router.py index 14e31f4..31b8e65 100644 --- a/zyrarbot/ui/api/routers/voice_router.py +++ b/zyrarbot/ui/api/routers/voice_router.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException from zyrarbot.music.music_player import MusicPlayer from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver -from zyrarbot.storage.guild_config_repository import GuildConfigRepository +from zyrarbot.storage.ports.guild_config import GuildConfigRepository from zyrarbot.ui.api.dependencies import ( get_active_guild, get_broadcaster, diff --git a/zyrarbot/ui/bot/client.py b/zyrarbot/ui/bot/client.py index 5714130..71b113a 100644 --- a/zyrarbot/ui/bot/client.py +++ b/zyrarbot/ui/bot/client.py @@ -12,9 +12,10 @@ from discord.ext import commands from zyrarbot.audio.track_factory import TrackFactory from zyrarbot.music.music_player import MusicPlayer from zyrarbot.settings import Settings -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.ports.guild_config import GuildConfigRepository +from zyrarbot.storage.ports.history import HistoryRepository +from zyrarbot.storage.ports.legacy_data_migration import LegacyDataMigration +from zyrarbot.storage.ports.uploads import UploadsRepository from zyrarbot.ui.bot.cogs.control_cog import ControlCog from zyrarbot.ui.bot.cogs.playback_cog import PlaybackCog from zyrarbot.ui.bot.cogs.player_cog import PlayerCog @@ -39,6 +40,7 @@ class MusicBot(commands.Bot): uploads: UploadsRepository, config: GuildConfigRepository, history: HistoryRepository, + migration: LegacyDataMigration, restart_callback: Callable[[], None] | None = None, ): intents = discord.Intents.default() @@ -51,6 +53,7 @@ class MusicBot(commands.Bot): self.uploads = uploads self.config = config self.history = history + self.migration = migration self.player: MusicPlayer | None = None self._restart_callback = restart_callback self._is_closing = False @@ -168,8 +171,7 @@ class MusicBot(commands.Bot): if self.guilds: first_guild_id = self.guilds[0].id self.uploads.migrate_legacy(first_guild_id) - self.config.migrate_legacy(first_guild_id) - self.history.migrate_legacy(first_guild_id) + self.migration.migrate_legacy(self.settings.config_dir, first_guild_id) # Jeśli po uruchomieniu bot z jakiegoś powodu znajduje się w kanale głosowym, # odłącz go, aby stan był czysty i zgodny z API (np. po awaryjnym restarcie). diff --git a/zyrarbot/ui/bot/cogs/playback_cog.py b/zyrarbot/ui/bot/cogs/playback_cog.py index 7526ccf..01a31c8 100644 --- a/zyrarbot/ui/bot/cogs/playback_cog.py +++ b/zyrarbot/ui/bot/cogs/playback_cog.py @@ -12,7 +12,7 @@ from discord.ext import commands from zyrarbot.audio.media_probe import MediaProbe from zyrarbot.audio.track_factory import TrackFactory from zyrarbot.music.music_player import MusicPlayer -from zyrarbot.storage.uploads_repository import UploadsRepository +from zyrarbot.storage.ports.uploads import UploadsRepository from zyrarbot.ui.bot.cogs.common import MusicCogBase from zyrarbot.ui.bot.discord_theme import BRAND_PURPLE, message_embed @@ -264,8 +264,8 @@ class PlaybackCog(MusicCogBase, name="Playback"): return try: if entry.is_local: - filepath = entry.replay_source or "" - if not self._uploads.exists(filepath, guild_id): + source = entry.url or "" + if not self._uploads.exists(source, guild_id): await interaction.followup.send( embed=message_embed( "Historia", "Plik lokalny z historii już nie istnieje na serwerze." @@ -274,7 +274,7 @@ class PlaybackCog(MusicCogBase, name="Playback"): ) return track = await self._tracks.from_file( - self._uploads.resolve(filepath, guild_id), title=entry.title + self._uploads.resolve(source, guild_id), title=entry.title ) else: track = await self._tracks.from_url(entry.url or "", loop=self.bot.loop)