Add database loading methods to SerieList

- Add load_all_from_db() for bulk loading series from DB
- Add _load_single_series_from_db() for loading single series by folder
- Add invalidate_cache() to clear in-memory cache
- Add tests for all new methods

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-26 18:26:25 +02:00
parent 102d83e947
commit 53d6da5dac
2 changed files with 818 additions and 392 deletions

View File

@@ -1,8 +1,8 @@
"""Utilities for loading and managing stored anime series metadata.
This module provides the SerieList class for managing collections of anime
series metadata. It uses file-based storage as fallback when database
is not available.
series metadata. It supports loading from both filesystem (legacy) and
database (primary).
Note:
This module is part of the core domain layer. Database operations
@@ -11,7 +11,6 @@ Note:
from __future__ import annotations
import asyncio
import logging
import os
import warnings
@@ -390,3 +389,139 @@ class SerieList:
if serie.folder == folder:
return serie
return None
async def load_all_from_db(self) -> int:
"""Load all series from database into in-memory cache.
Retrieves all anime series from the database with their episodes
and populates the in-memory keyDict for fast access.
This method replaces file-based loading. Use after initialization
when database is ready.
Returns:
int: Number of series loaded into cache
"""
from src.server.database.connection import get_async_session_factory
from src.server.database.service import AnimeSeriesService
try:
session_factory = get_async_session_factory()
db = session_factory()
try:
anime_series_list = await AnimeSeriesService.get_all(
db, with_episodes=True
)
count = 0
for anime_series in anime_series_list:
episode_dict: Dict[int, List[int]] = {}
if anime_series.episodes:
for ep in anime_series.episodes:
if ep.season not in episode_dict:
episode_dict[ep.season] = []
episode_dict[ep.season].append(ep.episode_number)
serie = Serie(
key=anime_series.key,
name=anime_series.name,
site=anime_series.site,
folder=anime_series.folder,
episodeDict=episode_dict,
year=anime_series.year
)
self.keyDict[serie.key] = serie
count += 1
logger.info(
"Loaded %d series from database into in-memory cache",
count
)
return count
finally:
await db.close()
except RuntimeError:
logger.warning(
"Database not available, skipping DB load"
)
return 0
async def _load_single_series_from_db(
self,
anime_folder: str
) -> Optional[Serie]:
"""Load a single series from database by folder name.
Looks up a series in the database by its folder name and adds
it to the in-memory cache.
Args:
anime_folder: The filesystem folder name to look up
Returns:
Serie if found and loaded, None otherwise
"""
from src.server.database.connection import get_async_session_factory
from src.server.database.service import AnimeSeriesService
try:
session_factory = get_async_session_factory()
db = session_factory()
try:
anime_series = await AnimeSeriesService.get_by_folder(
db, anime_folder
)
if not anime_series:
logger.debug(
"Series with folder '%s' not found in DB",
anime_folder
)
return None
episode_dict: Dict[int, List[int]] = {}
if anime_series.episodes:
for ep in anime_series.episodes:
if ep.season not in episode_dict:
episode_dict[ep.season] = []
episode_dict[ep.season].append(ep.episode_number)
serie = Serie(
key=anime_series.key,
name=anime_series.name,
site=anime_series.site,
folder=anime_series.folder,
episodeDict=episode_dict,
year=anime_series.year
)
self.keyDict[serie.key] = serie
logger.debug(
"Loaded series '%s' (key=%s) from DB",
serie.name, serie.key
)
return serie
finally:
await db.close()
except RuntimeError:
logger.warning(
"Database not available, cannot load series '%s'",
anime_folder
)
return None
def invalidate_cache(self) -> None:
"""Clear the in-memory cache.
Use after database modifications to force reload from DB
on next access.
"""
self.keyDict.clear()
logger.debug("SerieList in-memory cache invalidated")
def reload(self) -> None:
"""Reload series from filesystem (legacy mode).
Warning:
This method uses file-based loading and should only be
used as fallback when database is not available.
"""
self.load_series()

View File

@@ -0,0 +1,291 @@
"""Tests for SerieList database loading functionality."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.core.entities.series import Serie
@pytest.fixture
def mock_session_factory():
"""Create a mock async session factory."""
mock_session = AsyncMock()
mock_session_factory = MagicMock(return_value=mock_session)
return mock_session_factory, mock_session
@pytest.fixture
def sample_anime_series():
"""Create a sample AnimeSeries DB model for testing."""
mock = MagicMock()
mock.key = "attack-on-titan"
mock.name = "Attack on Titan"
mock.site = "aniworld.to"
mock.folder = "Attack on Titan (2013)"
mock.year = 2013
mock.episodes = [
MagicMock(season=1, episode_number=1),
MagicMock(season=1, episode_number=2),
MagicMock(season=1, episode_number=3),
MagicMock(season=2, episode_number=1),
MagicMock(season=2, episode_number=2),
]
return mock
@pytest.fixture
def sample_serie():
"""Create a sample Serie for testing."""
return Serie(
key="attack-on-titan",
name="Attack on Titan",
site="aniworld.to",
folder="Attack on Titan (2013)",
episodeDict={1: [1, 2, 3], 2: [1, 2]},
year=2013
)
class TestLoadAllFromDb:
"""Test load_all_from_db method."""
@pytest.mark.asyncio
async def test_load_all_from_db(self, mock_session_factory, sample_anime_series):
"""Verify SerieList loads all series from DB."""
mock_factory, mock_session = mock_session_factory
with patch(
"src.server.database.connection.get_async_session_factory",
return_value=mock_factory
):
with patch(
"src.server.database.service.AnimeSeriesService.get_all",
return_value=[sample_anime_series]
):
from src.core.entities.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
count = await serie_list.load_all_from_db()
assert count == 1
assert "attack-on-titan" in serie_list.keyDict
serie = serie_list.keyDict["attack-on-titan"]
assert serie.name == "Attack on Titan"
assert serie.key == "attack-on-titan"
assert serie.year == 2013
@pytest.mark.asyncio
async def test_load_all_from_db_multiple_series(
self, mock_session_factory, sample_anime_series
):
"""Verify SerieList loads multiple series from DB."""
mock_factory, mock_session = mock_session_factory
mock_series2 = MagicMock()
mock_series2.key = "one-piece"
mock_series2.name = "One Piece"
mock_series2.site = "aniworld.to"
mock_series2.folder = "One Piece"
mock_series2.year = 1999
mock_series2.episodes = []
with patch(
"src.server.database.connection.get_async_session_factory",
return_value=mock_factory
):
with patch(
"src.server.database.service.AnimeSeriesService.get_all",
return_value=[sample_anime_series, mock_series2]
):
from src.core.entities.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
count = await serie_list.load_all_from_db()
assert count == 2
assert "attack-on-titan" in serie_list.keyDict
assert "one-piece" in serie_list.keyDict
@pytest.mark.asyncio
async def test_load_all_from_db_rebuilds_episode_dict(
self, mock_session_factory, sample_anime_series
):
"""Verify episode_dict is correctly built from Episode records."""
mock_factory, mock_session = mock_session_factory
with patch(
"src.server.database.connection.get_async_session_factory",
return_value=mock_factory
):
with patch(
"src.server.database.service.AnimeSeriesService.get_all",
return_value=[sample_anime_series]
):
from src.core.entities.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
await serie_list.load_all_from_db()
serie = serie_list.keyDict["attack-on-titan"]
assert 1 in serie.episodeDict
assert 2 in serie.episodeDict
assert sorted(serie.episodeDict[1]) == [1, 2, 3]
assert sorted(serie.episodeDict[2]) == [1, 2]
@pytest.mark.asyncio
async def test_load_all_from_db_no_series(self, mock_session_factory):
"""Verify SerieList handles empty DB."""
mock_factory, mock_session = mock_session_factory
with patch(
"src.server.database.connection.get_async_session_factory",
return_value=mock_factory
):
with patch(
"src.server.database.service.AnimeSeriesService.get_all",
return_value=[]
):
from src.core.entities.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
count = await serie_list.load_all_from_db()
assert count == 0
assert len(serie_list.keyDict) == 0
@pytest.mark.asyncio
async def test_load_all_from_db_db_not_initialized(self, mock_session_factory):
"""Verify SerieList handles uninitialized DB gracefully."""
mock_factory, mock_session = mock_session_factory
with patch(
"src.server.database.connection.get_async_session_factory",
return_value=mock_factory
):
with patch(
"src.server.database.service.AnimeSeriesService.get_all",
side_effect=RuntimeError("Database not initialized")
):
from src.core.entities.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
count = await serie_list.load_all_from_db()
assert count == 0
assert len(serie_list.keyDict) == 0
class TestLoadSingleSeriesFromDb:
"""Test _load_single_series_from_db method."""
@pytest.mark.asyncio
async def test_load_single_series_from_db(
self, mock_session_factory, sample_anime_series
):
"""Verify SerieList loads a single series from DB by folder."""
mock_factory, mock_session = mock_session_factory
with patch(
"src.server.database.connection.get_async_session_factory",
return_value=mock_factory
):
with patch(
"src.server.database.service.AnimeSeriesService.get_by_folder",
return_value=sample_anime_series
):
from src.core.entities.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie = await serie_list._load_single_series_from_db("Attack on Titan (2013)")
assert serie is not None
assert serie.key == "attack-on-titan"
assert "attack-on-titan" in serie_list.keyDict
@pytest.mark.asyncio
async def test_load_single_series_from_db_not_found(
self, mock_session_factory
):
"""Verify SerieList handles series not found in DB."""
mock_factory, mock_session = mock_session_factory
with patch(
"src.server.database.connection.get_async_session_factory",
return_value=mock_factory
):
with patch(
"src.server.database.service.AnimeSeriesService.get_by_folder",
return_value=None
):
from src.core.entities.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie = await serie_list._load_single_series_from_db("Unknown Series")
assert serie is None
assert len(serie_list.keyDict) == 0
@pytest.mark.asyncio
async def test_load_single_series_from_db_db_not_initialized(
self, mock_session_factory
):
"""Verify SerieList handles uninitialized DB gracefully."""
mock_factory, mock_session = mock_session_factory
with patch(
"src.server.database.connection.get_async_session_factory",
return_value=mock_factory
):
with patch(
"src.server.database.service.AnimeSeriesService.get_by_folder",
side_effect=RuntimeError("Database not initialized")
):
from src.core.entities.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie = await serie_list._load_single_series_from_db("Some Folder")
assert serie is None
class TestInvalidateCache:
"""Test invalidate_cache method."""
def test_invalidate_cache_clears_keydict(self, sample_serie):
"""Verify invalidate_cache clears the in-memory cache."""
from src.core.entities.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie_list.keyDict["attack-on-titan"] = sample_serie
assert len(serie_list.keyDict) == 1
serie_list.invalidate_cache()
assert len(serie_list.keyDict) == 0
def test_invalidate_cache_allows_reload(self, mock_session_factory, sample_anime_series):
"""Verify cache can be reloaded after invalidation."""
mock_factory, mock_session = mock_session_factory
with patch(
"src.server.database.connection.get_async_session_factory",
return_value=mock_factory
):
with patch(
"src.server.database.service.AnimeSeriesService.get_all",
return_value=[sample_anime_series]
):
from src.core.entities.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie_list.keyDict["some-key"] = MagicMock()
serie_list.invalidate_cache()
assert len(serie_list.keyDict) == 0
# Reload
import asyncio
asyncio.get_event_loop().run_until_complete(serie_list.load_all_from_db())
assert len(serie_list.keyDict) == 1