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:
38 files changed
+539
-269
No files matched your search
+16
-14
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Concrete persistence adapters implementing storage ports."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Filesystem adapters for uploaded media."""
|
||||
File renamed without changes.
@@ -0,0 +1 @@
|
||||
"""JSON file storage adapter."""
|
||||
File renamed without changes.
@@ -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)
|
||||
@@ -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])
|
||||
@@ -0,0 +1 @@
|
||||
"""Ports describing persistence operations used by the application."""
|
||||
@@ -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: ...
|
||||
@@ -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: ...
|
||||
@@ -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
|
||||
@@ -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: ...
|
||||
@@ -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: ...
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user