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,17 +16,21 @@ Wymagane zmienne środowiskowe (plik `.env`):
|
|||||||
| --------------- | ----------------------------------------------------------- |
|
| --------------- | ----------------------------------------------------------- |
|
||||||
| `DISCORD_TOKEN` | Token bota Discord. |
|
| `DISCORD_TOKEN` | Token bota Discord. |
|
||||||
| `GUILD_ID` | Opcjonalnie - synchronizuje komendy tylko z jednym serwerem. |
|
| `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
|
Panel działa pod `http://localhost:8000` podczas uruchomienia lokalnego. W Dockerze
|
||||||
jest dostępny pod `http://localhost` po wykonaniu `docker compose up --build`.
|
jest dostępny pod `http://localhost` po wykonaniu `docker compose up --build`.
|
||||||
|
|
||||||
Panel obsługuje wiele serwerów Discorda. Wybór serwera w nagłówku przełącza jego
|
Panel obsługuje wiele serwerów Discorda. Wybór serwera w nagłówku przełącza jego
|
||||||
osobną kolejkę, historię, bazę plików i ustawienia odtwarzania. Istniejące dane ze
|
osobną kolejkę, historię, bazę plików i ustawienia odtwarzania. Ustawienia, historia
|
||||||
starego, wspólnego formatu są przypisywane do pierwszego użytego serwera.
|
i migawki kolejek przechodzą przez porty persystencji, a PostgreSQL jest obecnym
|
||||||
Historia jest zapisywana w osobnych plikach `config/history/<guild_id>.json`, a
|
adapterem wybranym w korzeniu aplikacji. Baza działa jako usługa Compose, a jej dane
|
||||||
uploady w katalogach `uploads/<guild_id>/`.
|
trwale przechowuje wolumen `postgres_data`. Przy pierwszym uruchomieniu
|
||||||
Ostatnia kolejka jest zapisywana w `config/queues/<guild_id>.json` przy zatrzymaniu
|
bot importuje istniejące pliki JSON z `config/` do bazy, a pliki źródłowe zostają
|
||||||
lub rozłączeniu i może zostać przywrócona z widoku kolejki.
|
na miejscu jako kopia. Starsze wspólne dane są przypisywane do pierwszego serwera,
|
||||||
|
do którego bot należy. Uploady pozostają w
|
||||||
|
katalogach `uploads/<guild_id>/`.
|
||||||
|
|
||||||
## Komendy Discorda
|
## Komendy Discorda
|
||||||
|
|
||||||
@@ -78,12 +82,15 @@ odpowiadające warstwom aplikacji:
|
|||||||
zyrarbot/
|
zyrarbot/
|
||||||
├── settings.py Settings - konfiguracja ze zmiennych środowiskowych
|
├── settings.py Settings - konfiguracja ze zmiennych środowiskowych
|
||||||
├── application.py Application - korzeń kompozycji, spina bota i API
|
├── application.py Application - korzeń kompozycji, spina bota i API
|
||||||
├── storage/ Warstwa trwałości (pliki JSON, katalog uploadów)
|
├── storage/
|
||||||
│ ├── json_file_store.py JsonFileStore
|
│ ├── ports/ GuildConfigRepository, HistoryRepository,
|
||||||
│ ├── guild_config_repository.py GuildConfigRepository
|
│ │ QueueRepository, UploadsRepository, Persistence
|
||||||
│ ├── history_repository.py HistoryRepository
|
│ ├── adapters/
|
||||||
│ ├── uploads_repository.py UploadsRepository
|
│ │ ├── postgres/ PostgresGuildConfig, PostgresHistory, PostgresQueue,
|
||||||
│ └── upload_file_info.py UploadFileInfo
|
│ │ │ PostgresPersistence, PostgresDatabase
|
||||||
|
│ │ ├── filesystem/ Adapter plików uploadu
|
||||||
|
│ │ └── json/ Adapter plikowego magazynu JSON
|
||||||
|
│ └── upload_file_info.py UploadFileInfo
|
||||||
├── audio/ Pozyskiwanie dźwięku
|
├── audio/ Pozyskiwanie dźwięku
|
||||||
│ ├── ytdl_options.py YtdlOptions
|
│ ├── ytdl_options.py YtdlOptions
|
||||||
│ ├── ytdl_source.py YTDLSource
|
│ ├── ytdl_source.py YTDLSource
|
||||||
|
|||||||
+28
-1
@@ -1,5 +1,21 @@
|
|||||||
services:
|
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:
|
discord-bot:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -8,10 +24,21 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DISCORD_TOKEN=${DISCORD_TOKEN}
|
- DISCORD_TOKEN=${DISCORD_TOKEN}
|
||||||
- GUILD_ID=${GUILD_ID:-}
|
- 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
|
- PYTHONUNBUFFERED=1
|
||||||
|
depends_on:
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
ports:
|
ports:
|
||||||
- "80:8000"
|
- "80:8000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -26,7 +26,7 @@ async function replay(entry: HistoryTrack) {
|
|||||||
const pendingId = addPendingTrack(props.status.active_voice_channel.guild_id, entry.title)
|
const pendingId = addPendingTrack(props.status.active_voice_channel.guild_id, entry.title)
|
||||||
try {
|
try {
|
||||||
if (entry.is_local) {
|
if (entry.is_local) {
|
||||||
const path = entry.filepath ?? entry.url ?? entry.title
|
const path = entry.url ?? entry.title
|
||||||
await enqueue(path.split(/[\\/]/).pop() ?? path)
|
await enqueue(path.split(/[\\/]/).pop() ?? path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export interface HistoryTrack {
|
|||||||
url: string | null
|
url: string | null
|
||||||
duration: number | null
|
duration: number | null
|
||||||
is_local: boolean
|
is_local: boolean
|
||||||
filepath: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadedFile {
|
export interface UploadedFile {
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ fastapi
|
|||||||
uvicorn
|
uvicorn
|
||||||
websockets
|
websockets
|
||||||
python-multipart
|
python-multipart
|
||||||
|
psycopg[binary]
|
||||||
+16
-14
@@ -19,11 +19,9 @@ from zyrarbot.music.music_player import MusicPlayer
|
|||||||
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
|
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
|
||||||
from zyrarbot.music.track_embed_factory import TrackEmbedFactory
|
from zyrarbot.music.track_embed_factory import TrackEmbedFactory
|
||||||
from zyrarbot.settings import Settings
|
from zyrarbot.settings import Settings
|
||||||
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
|
from zyrarbot.storage.adapters.filesystem.uploads import UploadsRepository
|
||||||
from zyrarbot.storage.history_repository import HistoryRepository
|
from zyrarbot.storage.adapters.postgres.persistence import PostgresPersistence
|
||||||
from zyrarbot.storage.json_file_store import JsonFileStore
|
from zyrarbot.storage.ports.persistence import Persistence
|
||||||
from zyrarbot.storage.queue_repository import QueueRepository
|
|
||||||
from zyrarbot.storage.uploads_repository import UploadsRepository
|
|
||||||
from zyrarbot.ui.api.factory import mount_api
|
from zyrarbot.ui.api.factory import mount_api
|
||||||
from zyrarbot.ui.bot.client import MusicBot
|
from zyrarbot.ui.bot.client import MusicBot
|
||||||
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
||||||
@@ -34,18 +32,21 @@ logger = logging.getLogger(__name__)
|
|||||||
class Application:
|
class Application:
|
||||||
"""Korzeń kompozycji: składa wszystkie zależności i prowadzi bota razem z API."""
|
"""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.settings = settings or Settings.from_env()
|
||||||
|
self.persistence: Persistence = persistence or PostgresPersistence(
|
||||||
self.config = GuildConfigRepository(
|
config_dir=self.settings.config_dir,
|
||||||
JsonFileStore(self.settings.config_file),
|
default_plays=self.settings.default_plays,
|
||||||
fallback_plays=self.settings.default_plays,
|
history_limit=self.settings.history_limit,
|
||||||
)
|
|
||||||
self.history = HistoryRepository(
|
|
||||||
JsonFileStore(self.settings.history_file), limit=self.settings.history_limit
|
|
||||||
)
|
)
|
||||||
|
self.config = self.persistence.config
|
||||||
|
self.history = self.persistence.history
|
||||||
self.uploads = UploadsRepository(self.settings.uploads_dir)
|
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.tracks = TrackFactory(self.uploads, MediaProbe())
|
||||||
|
|
||||||
self._server: uvicorn.Server | None = None
|
self._server: uvicorn.Server | None = None
|
||||||
@@ -55,6 +56,7 @@ class Application:
|
|||||||
self.uploads,
|
self.uploads,
|
||||||
self.config,
|
self.config,
|
||||||
self.history,
|
self.history,
|
||||||
|
self.persistence,
|
||||||
restart_callback=self.schedule_restart,
|
restart_callback=self.schedule_restart,
|
||||||
)
|
)
|
||||||
self.guilds = GuildResolver(self.bot)
|
self.guilds = GuildResolver(self.bot)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import yt_dlp
|
|||||||
from zyrarbot.audio.media_probe import MediaProbe
|
from zyrarbot.audio.media_probe import MediaProbe
|
||||||
from zyrarbot.audio.ytdl_options import YtdlOptions
|
from zyrarbot.audio.ytdl_options import YtdlOptions
|
||||||
from zyrarbot.audio.ytdl_source import YTDLSource
|
from zyrarbot.audio.ytdl_source import YTDLSource
|
||||||
from zyrarbot.storage.uploads_repository import UploadsRepository
|
from zyrarbot.storage.ports.uploads import UploadsRepository
|
||||||
|
|
||||||
|
|
||||||
class TrackFactory:
|
class TrackFactory:
|
||||||
|
|||||||
@@ -13,17 +13,11 @@ class HistoryEntry:
|
|||||||
url: str | None = None
|
url: str | None = None
|
||||||
duration: float | None = None
|
duration: float | None = None
|
||||||
is_local: bool = False
|
is_local: bool = False
|
||||||
filepath: str | None = None
|
|
||||||
|
|
||||||
def is_same_track_as(self, other: HistoryEntry) -> bool:
|
def is_same_track_as(self, other: HistoryEntry) -> bool:
|
||||||
"""Wpisy uznajemy za ten sam utwór przy zgodnym tytule albo adresie."""
|
"""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)
|
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]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
@@ -31,16 +25,4 @@ class HistoryEntry:
|
|||||||
"url": self.url,
|
"url": self.url,
|
||||||
"duration": self.duration,
|
"duration": self.duration,
|
||||||
"is_local": self.is_local,
|
"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.loop_mode import LoopMode
|
||||||
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
|
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
|
||||||
from zyrarbot.music.track_embed_factory import TrackEmbedFactory
|
from zyrarbot.music.track_embed_factory import TrackEmbedFactory
|
||||||
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
|
from zyrarbot.storage.ports.guild_config import GuildConfigRepository
|
||||||
from zyrarbot.storage.history_repository import HistoryRepository
|
from zyrarbot.storage.ports.history import HistoryRepository
|
||||||
from zyrarbot.storage.queue_repository import QueueRepository
|
from zyrarbot.storage.ports.queue import QueueRepository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -542,7 +542,6 @@ class MusicPlayer:
|
|||||||
url=track.display_url,
|
url=track.display_url,
|
||||||
duration=track.duration,
|
duration=track.duration,
|
||||||
is_local=track.is_local,
|
is_local=track.is_local,
|
||||||
filepath=track.filepath,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
|
from zyrarbot.storage.ports.guild_config import GuildConfigRepository
|
||||||
|
|
||||||
|
|
||||||
class NotificationChannelResolver:
|
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.audio.track_factory import TrackFactory
|
||||||
from zyrarbot.music.music_player import MusicPlayer
|
from zyrarbot.music.music_player import MusicPlayer
|
||||||
from zyrarbot.settings import Settings
|
from zyrarbot.settings import Settings
|
||||||
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
|
from zyrarbot.storage.ports.guild_config import GuildConfigRepository
|
||||||
from zyrarbot.storage.uploads_repository import UploadsRepository
|
from zyrarbot.storage.ports.uploads import UploadsRepository
|
||||||
from zyrarbot.ui.api.connection_manager import ConnectionManager
|
from zyrarbot.ui.api.connection_manager import ConnectionManager
|
||||||
from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
|
from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
|
||||||
from zyrarbot.ui.api.status_service import StatusService
|
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.audio.track_factory import TrackFactory
|
||||||
from zyrarbot.music.music_player import MusicPlayer
|
from zyrarbot.music.music_player import MusicPlayer
|
||||||
from zyrarbot.settings import Settings
|
from zyrarbot.settings import Settings
|
||||||
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
|
from zyrarbot.storage.ports.guild_config import GuildConfigRepository
|
||||||
from zyrarbot.storage.uploads_repository import UploadsRepository
|
from zyrarbot.storage.ports.uploads import UploadsRepository
|
||||||
from zyrarbot.ui.api.connection_manager import ConnectionManager
|
from zyrarbot.ui.api.connection_manager import ConnectionManager
|
||||||
from zyrarbot.ui.api.routers import (
|
from zyrarbot.ui.api.routers import (
|
||||||
playback_router,
|
playback_router,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from zyrarbot.audio.track_factory import TrackFactory
|
from zyrarbot.audio.track_factory import TrackFactory
|
||||||
from zyrarbot.music.loop_mode import LoopMode
|
from zyrarbot.music.loop_mode import LoopMode
|
||||||
from zyrarbot.music.music_player import MusicPlayer
|
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 (
|
from zyrarbot.ui.api.dependencies import (
|
||||||
get_active_guild,
|
get_active_guild,
|
||||||
get_broadcaster,
|
get_broadcaster,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import discord
|
|||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
|
|
||||||
from zyrarbot.audio.media_probe import MediaProbe
|
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
|
from zyrarbot.ui.api.dependencies import get_active_guild, get_uploads
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["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.music_player import MusicPlayer
|
||||||
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
|
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 (
|
from zyrarbot.ui.api.dependencies import (
|
||||||
get_active_guild,
|
get_active_guild,
|
||||||
get_broadcaster,
|
get_broadcaster,
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ from discord.ext import commands
|
|||||||
from zyrarbot.audio.track_factory import TrackFactory
|
from zyrarbot.audio.track_factory import TrackFactory
|
||||||
from zyrarbot.music.music_player import MusicPlayer
|
from zyrarbot.music.music_player import MusicPlayer
|
||||||
from zyrarbot.settings import Settings
|
from zyrarbot.settings import Settings
|
||||||
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
|
from zyrarbot.storage.ports.guild_config import GuildConfigRepository
|
||||||
from zyrarbot.storage.history_repository import HistoryRepository
|
from zyrarbot.storage.ports.history import HistoryRepository
|
||||||
from zyrarbot.storage.uploads_repository import UploadsRepository
|
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.control_cog import ControlCog
|
||||||
from zyrarbot.ui.bot.cogs.playback_cog import PlaybackCog
|
from zyrarbot.ui.bot.cogs.playback_cog import PlaybackCog
|
||||||
from zyrarbot.ui.bot.cogs.player_cog import PlayerCog
|
from zyrarbot.ui.bot.cogs.player_cog import PlayerCog
|
||||||
@@ -39,6 +40,7 @@ class MusicBot(commands.Bot):
|
|||||||
uploads: UploadsRepository,
|
uploads: UploadsRepository,
|
||||||
config: GuildConfigRepository,
|
config: GuildConfigRepository,
|
||||||
history: HistoryRepository,
|
history: HistoryRepository,
|
||||||
|
migration: LegacyDataMigration,
|
||||||
restart_callback: Callable[[], None] | None = None,
|
restart_callback: Callable[[], None] | None = None,
|
||||||
):
|
):
|
||||||
intents = discord.Intents.default()
|
intents = discord.Intents.default()
|
||||||
@@ -51,6 +53,7 @@ class MusicBot(commands.Bot):
|
|||||||
self.uploads = uploads
|
self.uploads = uploads
|
||||||
self.config = config
|
self.config = config
|
||||||
self.history = history
|
self.history = history
|
||||||
|
self.migration = migration
|
||||||
self.player: MusicPlayer | None = None
|
self.player: MusicPlayer | None = None
|
||||||
self._restart_callback = restart_callback
|
self._restart_callback = restart_callback
|
||||||
self._is_closing = False
|
self._is_closing = False
|
||||||
@@ -168,8 +171,7 @@ class MusicBot(commands.Bot):
|
|||||||
if self.guilds:
|
if self.guilds:
|
||||||
first_guild_id = self.guilds[0].id
|
first_guild_id = self.guilds[0].id
|
||||||
self.uploads.migrate_legacy(first_guild_id)
|
self.uploads.migrate_legacy(first_guild_id)
|
||||||
self.config.migrate_legacy(first_guild_id)
|
self.migration.migrate_legacy(self.settings.config_dir, first_guild_id)
|
||||||
self.history.migrate_legacy(first_guild_id)
|
|
||||||
|
|
||||||
# Jeśli po uruchomieniu bot z jakiegoś powodu znajduje się w kanale głosowym,
|
# 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).
|
# 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.media_probe import MediaProbe
|
||||||
from zyrarbot.audio.track_factory import TrackFactory
|
from zyrarbot.audio.track_factory import TrackFactory
|
||||||
from zyrarbot.music.music_player import MusicPlayer
|
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.cogs.common import MusicCogBase
|
||||||
from zyrarbot.ui.bot.discord_theme import BRAND_PURPLE, message_embed
|
from zyrarbot.ui.bot.discord_theme import BRAND_PURPLE, message_embed
|
||||||
|
|
||||||
@@ -264,8 +264,8 @@ class PlaybackCog(MusicCogBase, name="Playback"):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
if entry.is_local:
|
if entry.is_local:
|
||||||
filepath = entry.replay_source or ""
|
source = entry.url or ""
|
||||||
if not self._uploads.exists(filepath, guild_id):
|
if not self._uploads.exists(source, guild_id):
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
embed=message_embed(
|
embed=message_embed(
|
||||||
"Historia", "Plik lokalny z historii już nie istnieje na serwerze."
|
"Historia", "Plik lokalny z historii już nie istnieje na serwerze."
|
||||||
@@ -274,7 +274,7 @@ class PlaybackCog(MusicCogBase, name="Playback"):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
track = await self._tracks.from_file(
|
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:
|
else:
|
||||||
track = await self._tracks.from_url(entry.url or "", loop=self.bot.loop)
|
track = await self._tracks.from_url(entry.url or "", loop=self.bot.loop)
|
||||||
|
|||||||
Reference in new issue
Block a user