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:
@@ -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()
|
||||
|
||||
291
tests/unit/test_serie_list_db_loading.py
Normal file
291
tests/unit/test_serie_list_db_loading.py
Normal 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
|
||||
Reference in New Issue
Block a user