feat(core): Add database support to SeriesApp (Task 7)

- Added db_session parameter to SeriesApp.__init__()
- Added db_session property and set_db_session() method
- Added init_from_db_async() for async database initialization
- Pass db_session to SerieList and SerieScanner during construction
- Added get_series_app_with_db() dependency for FastAPI endpoints
- All 815 unit tests and 55 API tests pass
This commit is contained in:
Lukas 2025-12-01 19:42:04 +01:00
parent 246782292f
commit cb014cf547
6 changed files with 291 additions and 330 deletions

View File

@ -17,7 +17,7 @@
"keep_days": 30 "keep_days": 30
}, },
"other": { "other": {
"master_password_hash": "$pbkdf2-sha256$29000$854zxnhvzXmPsVbqvXduTQ$G0HVRAt3kyO5eFwvo.ILkpX9JdmyXYJ9MNPTS/UxAGk", "master_password_hash": "$pbkdf2-sha256$29000$3rvXWouRkpIyRugdo1SqdQ$WQaiQF31djFIKeoDtZFY1urJL21G4ZJ3d0omSj5Yark",
"anime_directory": "/mnt/server/serien/Serien/" "anime_directory": "/mnt/server/serien/Serien/"
}, },
"version": "1.0.0" "version": "1.0.0"

View File

@ -1,327 +1,6 @@
{ {
"pending": [ "pending": [],
{
"id": "ae6424dc-558b-4946-9f07-20db1a09bf33",
"serie_id": "test-series-2",
"serie_folder": "Another Series (2024)",
"serie_name": "Another Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "HIGH",
"added_at": "2025-11-28T17:54:38.593236Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "011c2038-9fe3-41cb-844f-ce50c40e415f",
"serie_id": "series-high",
"serie_folder": "Series High (2024)",
"serie_name": "Series High",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "HIGH",
"added_at": "2025-11-28T17:54:38.632289Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "0eee56e0-414d-4cd7-8da7-b5a139abd8b5",
"serie_id": "series-normal",
"serie_folder": "Series Normal (2024)",
"serie_name": "Series Normal",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.635082Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "eea9f4f3-98e5-4041-9fc6-92e3d4c6fee6",
"serie_id": "series-low",
"serie_folder": "Series Low (2024)",
"serie_name": "Series Low",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "LOW",
"added_at": "2025-11-28T17:54:38.637038Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "b6f84ea9-86c8-4cc9-90e5-c7c6ce10c593",
"serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.801266Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "412aa28d-9763-41ef-913d-3d63919f9346",
"serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.867939Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "3a036824-2d14-41dd-81b8-094dd322a137",
"serie_id": "invalid-series",
"serie_folder": "Invalid Series (2024)",
"serie_name": "Invalid Series",
"episode": {
"season": 99,
"episode": 99,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.935125Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "1f4108ed-5488-4f46-ad5b-fe27e3b04790",
"serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.968296Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "5e880954-1a9f-450a-8008-5b9d6ac07d66",
"serie_id": "series-2",
"serie_folder": "Series 2 (2024)",
"serie_name": "Series 2",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.055885Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "2415ac21-509b-4d71-b5b9-b824116d6785",
"serie_id": "series-0",
"serie_folder": "Series 0 (2024)",
"serie_name": "Series 0",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.056795Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "716f9823-d59a-4b04-863b-c75fd54bc464",
"serie_id": "series-1",
"serie_folder": "Series 1 (2024)",
"serie_name": "Series 1",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.057486Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "36ad4323-daa9-49c4-97e8-a0aec0cca7a1",
"serie_id": "series-4",
"serie_folder": "Series 4 (2024)",
"serie_name": "Series 4",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.058179Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "695ee7a9-42bb-4953-9a8a-10bd7f533369",
"serie_id": "series-3",
"serie_folder": "Series 3 (2024)",
"serie_name": "Series 3",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.058816Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "aa948908-c410-42ec-85d6-a0298d7d95a5",
"serie_id": "persistent-series",
"serie_folder": "Persistent Series (2024)",
"serie_name": "Persistent Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.152427Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "2537f20e-f394-4c68-81d5-48be3c0c402a",
"serie_id": "ws-series",
"serie_folder": "WebSocket Series (2024)",
"serie_name": "WebSocket Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.219061Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "aaaf3b05-cce8-47d5-b350-59c5d72533ad",
"serie_id": "workflow-series",
"serie_folder": "Workflow Test Series (2024)",
"serie_name": "Workflow Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "HIGH",
"added_at": "2025-11-28T17:54:39.254462Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
}
],
"active": [], "active": [],
"failed": [], "failed": [],
"timestamp": "2025-11-28T17:54:39.259761+00:00" "timestamp": "2025-12-01T18:41:52.350980+00:00"
} }

View File

@ -250,7 +250,7 @@ async def lifespan(app: FastAPI):
--- ---
### Task 6: Update Anime API Endpoints ### Task 6: Update Anime API Endpoints
**File:** `src/server/api/anime.py` **File:** `src/server/api/anime.py`
@ -287,6 +287,11 @@ async def lifespan(app: FastAPI):
- Test that added series appears in database - Test that added series appears in database
- Test duplicate key handling - Test duplicate key handling
**Implementation Notes:**
- Added `get_optional_database_session()` dependency in `dependencies.py` for graceful fallback
- Endpoint saves to database when available, falls back to file-based storage when not
- All 55 API tests and 809 unit tests pass
--- ---
### Task 7: Update Dependencies and SeriesApp ⬜ ### Task 7: Update Dependencies and SeriesApp ⬜

View File

@ -8,10 +8,16 @@ progress reporting, and error handling.
import asyncio import asyncio
import logging import logging
import warnings
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from events import Events from events import Events
try:
from sqlalchemy.ext.asyncio import AsyncSession
except ImportError: # pragma: no cover - optional dependency
AsyncSession = object # type: ignore
from src.core.entities.SerieList import SerieList from src.core.entities.SerieList import SerieList
from src.core.entities.series import Serie from src.core.entities.series import Serie
from src.core.providers.provider_factory import Loaders from src.core.providers.provider_factory import Loaders
@ -130,15 +136,20 @@ class SeriesApp:
def __init__( def __init__(
self, self,
directory_to_search: str, directory_to_search: str,
db_session: Optional[AsyncSession] = None,
): ):
""" """
Initialize SeriesApp. Initialize SeriesApp.
Args: Args:
directory_to_search: Base directory for anime series directory_to_search: Base directory for anime series
db_session: Optional database session for database-backed
storage. When provided, SerieList and SerieScanner will
use the database instead of file-based storage.
""" """
self.directory_to_search = directory_to_search self.directory_to_search = directory_to_search
self._db_session = db_session
# Initialize events # Initialize events
self._events = Events() self._events = Events()
@ -147,15 +158,20 @@ class SeriesApp:
self.loaders = Loaders() self.loaders = Loaders()
self.loader = self.loaders.GetLoader(key="aniworld.to") self.loader = self.loaders.GetLoader(key="aniworld.to")
self.serie_scanner = SerieScanner(directory_to_search, self.loader) self.serie_scanner = SerieScanner(
self.list = SerieList(self.directory_to_search) directory_to_search, self.loader, db_session=db_session
)
self.list = SerieList(
self.directory_to_search, db_session=db_session
)
# Synchronous init used during constructor to avoid awaiting # Synchronous init used during constructor to avoid awaiting
# in __init__ # in __init__
self._init_list_sync() self._init_list_sync()
logger.info( logger.info(
"SeriesApp initialized for directory: %s", "SeriesApp initialized for directory: %s (db_session: %s)",
directory_to_search directory_to_search,
"provided" if db_session else "none"
) )
@property @property
@ -188,6 +204,53 @@ class SeriesApp:
"""Set scan_status event handler.""" """Set scan_status event handler."""
self._events.scan_status = value self._events.scan_status = value
@property
def db_session(self) -> Optional[AsyncSession]:
"""
Get the database session.
Returns:
AsyncSession or None: The database session if configured
"""
return self._db_session
def set_db_session(self, session: Optional[AsyncSession]) -> None:
"""
Update the database session.
Also updates the db_session on SerieList and SerieScanner.
Args:
session: The new database session or None
"""
self._db_session = session
self.list._db_session = session
self.serie_scanner._db_session = session
logger.debug(
"Database session updated: %s",
"provided" if session else "none"
)
async def init_from_db_async(self) -> None:
"""
Initialize series list from database (async).
This should be called when using database storage instead of
the synchronous file-based initialization.
"""
if self._db_session:
await self.list.load_series_from_db(self._db_session)
self.series_list = self.list.GetMissingEpisode()
logger.debug(
"Loaded %d series with missing episodes from database",
len(self.series_list)
)
else:
warnings.warn(
"init_from_db_async called without db_session configured",
UserWarning
)
def _init_list_sync(self) -> None: def _init_list_sync(self) -> None:
"""Synchronous initialization helper for constructor.""" """Synchronous initialization helper for constructor."""
self.series_list = self.list.GetMissingEpisode() self.series_list = self.list.GetMissingEpisode()

View File

@ -65,6 +65,10 @@ def get_series_app() -> SeriesApp:
Raises: Raises:
HTTPException: If SeriesApp is not initialized or anime directory HTTPException: If SeriesApp is not initialized or anime directory
is not configured is not configured
Note:
This creates a SeriesApp without database support. For database-
backed storage, use get_series_app_with_db() instead.
""" """
global _series_app global _series_app
@ -103,7 +107,6 @@ def reset_series_app() -> None:
_series_app = None _series_app = None
async def get_database_session() -> AsyncGenerator: async def get_database_session() -> AsyncGenerator:
""" """
Dependency to get database session. Dependency to get database session.
@ -166,6 +169,43 @@ async def get_optional_database_session() -> AsyncGenerator:
yield None yield None
async def get_series_app_with_db(
db: AsyncSession = Depends(get_optional_database_session),
) -> SeriesApp:
"""
Dependency to get SeriesApp instance with database support.
This creates or returns a SeriesApp instance and injects the
database session for database-backed storage.
Args:
db: Optional database session from dependency injection
Returns:
SeriesApp: The main application instance with database support
Raises:
HTTPException: If SeriesApp is not initialized or anime directory
is not configured
Example:
@app.post("/api/anime/scan")
async def scan_anime(
series_app: SeriesApp = Depends(get_series_app_with_db)
):
# series_app has db_session configured
await series_app.serie_scanner.scan_async()
"""
# Get the base SeriesApp
app = get_series_app()
# Inject database session if available
if db:
app.set_db_session(db)
return app
def get_current_user( def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends( credentials: Optional[HTTPAuthorizationCredentials] = Depends(
http_bearer_security http_bearer_security

View File

@ -385,3 +385,177 @@ class TestSeriesAppGetters:
pass pass
class TestSeriesAppDatabaseInit:
"""Test SeriesApp database initialization."""
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
def test_init_without_db_session(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test SeriesApp initializes without database session."""
test_dir = "/test/anime"
# Create app without db_session
app = SeriesApp(test_dir)
# Verify db_session is None
assert app._db_session is None
assert app.db_session is None
# Verify SerieList was called with db_session=None
mock_serie_list.assert_called_once()
call_kwargs = mock_serie_list.call_args[1]
assert call_kwargs.get("db_session") is None
# Verify SerieScanner was called with db_session=None
call_kwargs = mock_scanner.call_args[1]
assert call_kwargs.get("db_session") is None
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
def test_init_with_db_session(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test SeriesApp initializes with database session."""
test_dir = "/test/anime"
mock_db = Mock()
# Create app with db_session
app = SeriesApp(test_dir, db_session=mock_db)
# Verify db_session is set
assert app._db_session is mock_db
assert app.db_session is mock_db
# Verify SerieList was called with db_session
call_kwargs = mock_serie_list.call_args[1]
assert call_kwargs.get("db_session") is mock_db
# Verify SerieScanner was called with db_session
call_kwargs = mock_scanner.call_args[1]
assert call_kwargs.get("db_session") is mock_db
class TestSeriesAppDatabaseSession:
"""Test SeriesApp database session management."""
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
def test_set_db_session_updates_all_components(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test set_db_session updates app, list, and scanner."""
test_dir = "/test/anime"
mock_list = Mock()
mock_list.GetMissingEpisode.return_value = []
mock_scan = Mock()
mock_serie_list.return_value = mock_list
mock_scanner.return_value = mock_scan
# Create app without db_session
app = SeriesApp(test_dir)
assert app.db_session is None
# Create mock database session
mock_db = Mock()
# Set database session
app.set_db_session(mock_db)
# Verify all components are updated
assert app._db_session is mock_db
assert app.db_session is mock_db
assert mock_list._db_session is mock_db
assert mock_scan._db_session is mock_db
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
def test_set_db_session_to_none(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test setting db_session to None."""
test_dir = "/test/anime"
mock_list = Mock()
mock_list.GetMissingEpisode.return_value = []
mock_scan = Mock()
mock_serie_list.return_value = mock_list
mock_scanner.return_value = mock_scan
mock_db = Mock()
# Create app with db_session
app = SeriesApp(test_dir, db_session=mock_db)
# Set database session to None
app.set_db_session(None)
# Verify all components are updated
assert app._db_session is None
assert app.db_session is None
assert mock_list._db_session is None
assert mock_scan._db_session is None
class TestSeriesAppAsyncDbInit:
"""Test SeriesApp async database initialization."""
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
async def test_init_from_db_async_loads_from_database(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test init_from_db_async loads series from database."""
import warnings
test_dir = "/test/anime"
mock_list = Mock()
mock_list.load_series_from_db = AsyncMock()
mock_list.GetMissingEpisode.return_value = [{"name": "Test"}]
mock_serie_list.return_value = mock_list
mock_db = Mock()
# Create app with db_session
app = SeriesApp(test_dir, db_session=mock_db)
# Initialize from database
await app.init_from_db_async()
# Verify load_series_from_db was called
mock_list.load_series_from_db.assert_called_once_with(mock_db)
# Verify series_list is populated
assert len(app.series_list) == 1
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
async def test_init_from_db_async_without_session_warns(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test init_from_db_async warns without db_session."""
import warnings
test_dir = "/test/anime"
mock_list = Mock()
mock_list.GetMissingEpisode.return_value = []
mock_serie_list.return_value = mock_list
# Create app without db_session
app = SeriesApp(test_dir)
# Initialize from database should warn
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
await app.init_from_db_async()
# Check warning was raised
assert len(w) == 1
assert "without db_session" in str(w[0].message)