refactor(storage): repozytoria konfiguracji, historii i plików
Dostęp do danych trwałych schodzi za trzy repozytoria, zamiast być rozsmarowany po metodach coga i endpointach: - GuildConfigRepository - kanały powiadomień per serwer i domyślna liczba odtworzeń. - HistoryRepository - historia per serwer z buforem w pamięci i limitem wpisów. Zasada odsiewania duplikatów (zgodny tytuł albo adres) siedzi teraz w HistoryEntry.is_same_track_as zamiast w warunku wbudowanym w listę składaną. - UploadsRepository - katalog uploadów. Każda nazwa przechodzi przez os.path.basename w jednym miejscu, co zamyka temat ścieżek względnych przychodzących z API. Wpisy historii i pliki opisują struktury HistoryEntry oraz UploadFileInfo zamiast luźnych słowników z kluczami sprawdzanymi przez .get(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e194a8611c
commit
e59b4ad9f1
6 files changed
+215
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Domena odtwarzania: stan kolejki, tryby zapętlania i silnik odtwarzacza."""
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HistoryEntry:
|
||||
"""Pojedynczy wpis historii odtwarzania zapisywany w history.json."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
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,
|
||||
"title": self.title,
|
||||
"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"),
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from zyrarbot.storage.json_file_store import JsonFileStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GuildConfigRepository:
|
||||
"""Dostęp do config.json: kanały powiadomień per serwer + globalne ustawienia."""
|
||||
|
||||
DEFAULT_PLAYS_KEY = "default_plays"
|
||||
|
||||
def __init__(self, store: JsonFileStore, fallback_plays: int = 5):
|
||||
self._store = store
|
||||
self._fallback_plays = fallback_plays
|
||||
|
||||
def get_notification_channel_id(self, guild_id: int) -> int | None:
|
||||
value = self._store.get(str(guild_id))
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
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:
|
||||
self._store.set(str(guild_id), channel_id)
|
||||
|
||||
def get_default_plays(self) -> int:
|
||||
value = self._store.get(self.DEFAULT_PLAYS_KEY)
|
||||
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, value: int) -> None:
|
||||
self._store.set(self.DEFAULT_PLAYS_KEY, int(value))
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from zyrarbot.music.history_entry import HistoryEntry
|
||||
from zyrarbot.storage.json_file_store import JsonFileStore
|
||||
|
||||
|
||||
class HistoryRepository:
|
||||
"""Historia odtwarzania per serwer, buforowana w pamięci i utrwalana w history.json."""
|
||||
|
||||
def __init__(self, store: JsonFileStore, limit: int = 50):
|
||||
self._store = store
|
||||
self._limit = limit
|
||||
self._cache: dict[int, list[HistoryEntry]] = {}
|
||||
|
||||
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._store.get(str(guild_id), []) 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._store.set(str(guild_id), [entry.to_dict() for entry in self._cache.get(guild_id, [])])
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UploadFileInfo:
|
||||
"""Metadane pliku znajdującego się w serwerowej bazie plików."""
|
||||
|
||||
filename: str
|
||||
size_mb: float
|
||||
mtime: float
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"filename": self.filename, "size_mb": self.size_mb, "mtime": self.mtime}
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import BinaryIO
|
||||
|
||||
from zyrarbot.storage.upload_file_info import UploadFileInfo
|
||||
|
||||
|
||||
class UploadsRepository:
|
||||
"""Katalog z plikami wgranymi na serwer; nazwy zawsze sprowadzane do basename."""
|
||||
|
||||
def __init__(self, directory: str):
|
||||
self._directory = directory
|
||||
|
||||
@property
|
||||
def directory(self) -> str:
|
||||
return self._directory
|
||||
|
||||
def resolve(self, filename: str) -> str:
|
||||
return os.path.join(self._directory, os.path.basename(filename))
|
||||
|
||||
def exists(self, filename: str) -> bool:
|
||||
return os.path.isfile(self.resolve(filename))
|
||||
|
||||
def list(self) -> list[UploadFileInfo]:
|
||||
os.makedirs(self._directory, exist_ok=True)
|
||||
files = []
|
||||
for name in os.listdir(self._directory):
|
||||
path = os.path.join(self._directory, name)
|
||||
if not os.path.isfile(path) or name.startswith("."):
|
||||
continue
|
||||
files.append(
|
||||
UploadFileInfo(
|
||||
filename=name,
|
||||
size_mb=round(os.path.getsize(path) / (1024 * 1024), 2),
|
||||
mtime=os.path.getmtime(path),
|
||||
)
|
||||
)
|
||||
files.sort(key=lambda item: item.mtime, reverse=True)
|
||||
return files
|
||||
|
||||
def search(self, query: str, limit: int = 25) -> list[str]:
|
||||
needle = query.lower()
|
||||
return [item.filename for item in self.list() if needle in item.filename.lower()][:limit]
|
||||
|
||||
def save(self, filename: str, source: BinaryIO) -> str:
|
||||
os.makedirs(self._directory, exist_ok=True)
|
||||
name = os.path.basename(filename)
|
||||
with open(self.resolve(name), "wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
return name
|
||||
|
||||
def delete(self, filename: str) -> bool:
|
||||
path = self.resolve(filename)
|
||||
if not os.path.isfile(path):
|
||||
return False
|
||||
os.remove(path)
|
||||
return True
|
||||
|
||||
def touch(self, filename: str) -> None:
|
||||
path = self.resolve(filename)
|
||||
if os.path.isfile(path):
|
||||
os.utime(path, None)
|
||||
Reference in new issue
Block a user