Task 4: Update SerieList to use database storage

- Add db_session and skip_load parameters to SerieList.__init__
- Add async load_series_from_db() method for database loading
- Add async add_to_db() method for database storage
- Add async contains_in_db() method for database checks
- Add _convert_from_db() and _convert_to_db_dict() helper methods
- Add deprecation warnings to file-based add() method
- Maintain backward compatibility for file-based operations
- Add comprehensive unit tests (29 tests, all passing)
- Update instructions.md to mark Task 4 complete
This commit is contained in:
2025-12-01 19:18:50 +01:00
parent 646385b975
commit 795f83ada5
3 changed files with 606 additions and 25 deletions

View File

@@ -1,41 +1,119 @@
"""Utilities for loading and managing stored anime series metadata."""
"""Utilities for loading and managing stored anime series metadata.
This module provides the SerieList class for managing collections of anime
series metadata. It supports both file-based and database-backed storage.
The class can operate in two modes:
1. File-based mode (legacy): Reads/writes data files from disk
2. Database mode: Reads/writes to SQLite database via AnimeSeriesService
Database mode is preferred for new code. File-based mode is kept for
backward compatibility with CLI usage.
"""
from __future__ import annotations
import logging
import os
import warnings
from json import JSONDecodeError
from typing import Dict, Iterable, List, Optional
from typing import TYPE_CHECKING, Dict, Iterable, List, Optional
from src.core.entities.series import Serie
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from src.server.database.models import AnimeSeries
logger = logging.getLogger(__name__)
class SerieList:
"""
Represents the collection of cached series stored on disk.
Represents the collection of cached series stored on disk or database.
Series are identified by their unique 'key' (provider identifier).
The 'folder' is metadata only and not used for lookups.
The class supports two modes of operation:
1. File-based mode (legacy):
Initialize without db_session to use file-based storage.
Series are loaded from 'data' files in the anime directory.
2. Database mode (preferred):
Pass db_session to use database-backed storage via AnimeSeriesService.
Series are loaded from the AnimeSeries table.
Example:
# File-based mode (legacy)
serie_list = SerieList("/path/to/anime")
# Database mode (preferred)
async with get_db_session() as db:
serie_list = SerieList("/path/to/anime", db_session=db)
await serie_list.load_series_from_db()
Attributes:
directory: Path to the anime directory
keyDict: Internal dictionary mapping serie.key to Serie objects
_db_session: Optional database session for database mode
"""
def __init__(self, base_path: str) -> None:
def __init__(
self,
base_path: str,
db_session: Optional["AsyncSession"] = None,
skip_load: bool = False
) -> None:
"""Initialize the SerieList.
Args:
base_path: Path to the anime directory
db_session: Optional database session for database mode.
If provided, use load_series_from_db() instead of
the automatic file-based loading.
skip_load: If True, skip automatic loading of series.
Useful when using database mode to allow async loading.
"""
self.directory: str = base_path
# Internal storage using serie.key as the dictionary key
self.keyDict: Dict[str, Serie] = {}
self.load_series()
self._db_session: Optional["AsyncSession"] = db_session
# Only auto-load from files if no db_session and not skipping
if not skip_load and db_session is None:
self.load_series()
def add(self, serie: Serie) -> None:
"""
Persist a new series if it is not already present.
Persist a new series if it is not already present (file-based mode).
Uses serie.key for identification. The serie.folder is used for
filesystem operations only.
.. deprecated:: 2.0.0
Use :meth:`add_to_db` for database-backed storage.
File-based storage will be removed in a future version.
Args:
serie: The Serie instance to add
Note:
This method creates data files on disk. For database storage,
use add_to_db() instead.
"""
if self.contains(serie.key):
return
warnings.warn(
"File-based storage via add() is deprecated. "
"Use add_to_db() for database storage.",
DeprecationWarning,
stacklevel=2
)
data_path = os.path.join(self.directory, serie.folder, "data")
anime_path = os.path.join(self.directory, serie.folder)
os.makedirs(anime_path, exist_ok=True)
@@ -44,6 +122,63 @@ class SerieList:
# Store by key, not folder
self.keyDict[serie.key] = serie
async def add_to_db(
self,
serie: Serie,
db: "AsyncSession"
) -> Optional["AnimeSeries"]:
"""
Add a series to the database.
Uses serie.key for identification. Creates a new AnimeSeries
record in the database if it doesn't already exist.
Args:
serie: The Serie instance to add
db: Database session for async operations
Returns:
Created AnimeSeries instance, or None if already exists
Example:
async with get_db_session() as db:
result = await serie_list.add_to_db(serie, db)
if result:
print(f"Added series: {result.name}")
"""
from src.server.database.service import AnimeSeriesService
# Check if series already exists in DB
existing = await AnimeSeriesService.get_by_key(db, serie.key)
if existing:
logger.debug(
"Series already exists in database: %s (key=%s)",
serie.name,
serie.key
)
return None
# Create new series in database
anime_series = await AnimeSeriesService.create(
db=db,
key=serie.key,
name=serie.name,
site=serie.site,
folder=serie.folder,
episode_dict=serie.episodeDict,
)
# Also add to in-memory collection
self.keyDict[serie.key] = serie
logger.info(
"Added series to database: %s (key=%s)",
serie.name,
serie.key
)
return anime_series
def contains(self, key: str) -> bool:
"""
Return True when a series identified by ``key`` already exists.
@@ -107,6 +242,119 @@ class SerieList:
error,
)
async def load_series_from_db(self, db: "AsyncSession") -> int:
"""
Load all series from the database into the in-memory collection.
This is the preferred method for populating the series list
when using database-backed storage.
Args:
db: Database session for async operations
Returns:
Number of series loaded from the database
Example:
async with get_db_session() as db:
serie_list = SerieList("/path/to/anime", skip_load=True)
count = await serie_list.load_series_from_db(db)
print(f"Loaded {count} series from database")
"""
from src.server.database.service import AnimeSeriesService
# Clear existing in-memory data
self.keyDict.clear()
# Load all series from database
anime_series_list = await AnimeSeriesService.get_all(db)
for anime_series in anime_series_list:
serie = self._convert_from_db(anime_series)
self.keyDict[serie.key] = serie
logger.info(
"Loaded %d series from database",
len(self.keyDict)
)
return len(self.keyDict)
@staticmethod
def _convert_from_db(anime_series: "AnimeSeries") -> Serie:
"""
Convert an AnimeSeries database model to a Serie entity.
Args:
anime_series: AnimeSeries model from database
Returns:
Serie entity instance
"""
# Convert episode_dict from JSON (string keys) to int keys
episode_dict: dict[int, list[int]] = {}
if anime_series.episode_dict:
for season_str, episodes in anime_series.episode_dict.items():
try:
season = int(season_str)
episode_dict[season] = list(episodes)
except (ValueError, TypeError):
logger.warning(
"Invalid season key '%s' in episode_dict for %s",
season_str,
anime_series.key
)
return Serie(
key=anime_series.key,
name=anime_series.name,
site=anime_series.site,
folder=anime_series.folder,
episodeDict=episode_dict
)
@staticmethod
def _convert_to_db_dict(serie: Serie) -> dict:
"""
Convert a Serie entity to a dictionary for database creation.
Args:
serie: Serie entity instance
Returns:
Dictionary suitable for AnimeSeriesService.create()
"""
# Convert episode_dict keys to strings for JSON storage
episode_dict = None
if serie.episodeDict:
episode_dict = {
str(k): list(v) for k, v in serie.episodeDict.items()
}
return {
"key": serie.key,
"name": serie.name,
"site": serie.site,
"folder": serie.folder,
"episode_dict": episode_dict,
}
async def contains_in_db(self, key: str, db: "AsyncSession") -> bool:
"""
Check if a series with the given key exists in the database.
Args:
key: The unique provider identifier for the series
db: Database session for async operations
Returns:
True if the series exists in the database
"""
from src.server.database.service import AnimeSeriesService
existing = await AnimeSeriesService.get_by_key(db, key)
return existing is not None
def GetMissingEpisode(self) -> List[Serie]:
"""Return all series that still contain missing episodes."""
return [