feat: introduce database as a persistent storage for settings and history

refactor: arrange persistence layer as a port-adapter schema
This commit is contained in:
mmacierzanka committed 2026-09-30 17:42:09 +02:00
1 parent d9565f6eae
commit 6f50fce3f9
38 files changed
+539 -269

No files matched your search

+16 -14
View File
@@ -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)
+1 -1
View File
@@ -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:
-18
View File
@@ -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"),
)
+3 -4
View File
@@ -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,
),
)
@@ -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:
+1
View File
@@ -0,0 +1 @@
"""Concrete persistence adapters implementing storage ports."""
@@ -0,0 +1 @@
"""Filesystem adapters for uploaded media."""
@@ -0,0 +1 @@
"""JSON file storage adapter."""
@@ -0,0 +1 @@
"""PostgreSQL persistence adapters."""
@@ -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,
),
)
@@ -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))
@@ -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)
@@ -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)
@@ -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))
@@ -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)
-75
View File
@@ -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])
+1
View File
@@ -0,0 +1 @@
"""Ports describing persistence operations used by the application."""
+13
View File
@@ -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: ...
+13
View File
@@ -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: ...
@@ -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: ...
+14
View File
@@ -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
+11
View File
@@ -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: ...
+28
View File
@@ -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: ...
-33
View File
@@ -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))
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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"])
+1 -1
View File
@@ -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,
+7 -5
View File
@@ -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).
+4 -4
View File
@@ -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)