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:
lgooseandClaude Bot committed 2026-08-21 01:09:52 +02:00
1 parent e194a8611c
commit e59b4ad9f1
6 files changed
+215

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Domena odtwarzania: stan kolejki, tryby zapętlania i silnik odtwarzacza."""
+46
View File
@@ -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))
+45
View File
@@ -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, [])])
+16
View File
@@ -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}
+64
View File
@@ -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)