From cb014cf547e4a5f9dbfcf42712c60cff198f8c8f Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 1 Dec 2025 19:42:04 +0100 Subject: [PATCH] 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 --- data/config.json | 2 +- data/download_queue.json | 325 +------------------------------ instructions.md | 7 +- src/core/SeriesApp.py | 71 ++++++- src/server/utils/dependencies.py | 42 +++- tests/unit/test_series_app.py | 174 +++++++++++++++++ 6 files changed, 291 insertions(+), 330 deletions(-) diff --git a/data/config.json b/data/config.json index 4346592..a1df1c5 100644 --- a/data/config.json +++ b/data/config.json @@ -17,7 +17,7 @@ "keep_days": 30 }, "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/" }, "version": "1.0.0" diff --git a/data/download_queue.json b/data/download_queue.json index 5cffb6f..edb79fb 100644 --- a/data/download_queue.json +++ b/data/download_queue.json @@ -1,327 +1,6 @@ { - "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 - } - ], + "pending": [], "active": [], "failed": [], - "timestamp": "2025-11-28T17:54:39.259761+00:00" + "timestamp": "2025-12-01T18:41:52.350980+00:00" } \ No newline at end of file diff --git a/instructions.md b/instructions.md index 8894a11..507df28 100644 --- a/instructions.md +++ b/instructions.md @@ -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` @@ -287,6 +287,11 @@ async def lifespan(app: FastAPI): - Test that added series appears in database - 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 ⬜ diff --git a/src/core/SeriesApp.py b/src/core/SeriesApp.py index 0b5df93..ef1857b 100644 --- a/src/core/SeriesApp.py +++ b/src/core/SeriesApp.py @@ -8,10 +8,16 @@ progress reporting, and error handling. import asyncio import logging +import warnings from typing import Any, Dict, List, Optional 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.series import Serie from src.core.providers.provider_factory import Loaders @@ -130,15 +136,20 @@ class SeriesApp: def __init__( self, directory_to_search: str, + db_session: Optional[AsyncSession] = None, ): """ Initialize SeriesApp. Args: 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._db_session = db_session # Initialize events self._events = Events() @@ -147,15 +158,20 @@ class SeriesApp: self.loaders = Loaders() self.loader = self.loaders.GetLoader(key="aniworld.to") - self.serie_scanner = SerieScanner(directory_to_search, self.loader) - self.list = SerieList(self.directory_to_search) + self.serie_scanner = SerieScanner( + 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 # in __init__ self._init_list_sync() logger.info( - "SeriesApp initialized for directory: %s", - directory_to_search + "SeriesApp initialized for directory: %s (db_session: %s)", + directory_to_search, + "provided" if db_session else "none" ) @property @@ -188,6 +204,53 @@ class SeriesApp: """Set scan_status event handler.""" 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: """Synchronous initialization helper for constructor.""" self.series_list = self.list.GetMissingEpisode() diff --git a/src/server/utils/dependencies.py b/src/server/utils/dependencies.py index 2e0aa1f..fea06d1 100644 --- a/src/server/utils/dependencies.py +++ b/src/server/utils/dependencies.py @@ -65,6 +65,10 @@ def get_series_app() -> SeriesApp: Raises: HTTPException: If SeriesApp is not initialized or anime directory 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 @@ -103,7 +107,6 @@ def reset_series_app() -> None: _series_app = None - async def get_database_session() -> AsyncGenerator: """ Dependency to get database session. @@ -166,6 +169,43 @@ async def get_optional_database_session() -> AsyncGenerator: 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( credentials: Optional[HTTPAuthorizationCredentials] = Depends( http_bearer_security diff --git a/tests/unit/test_series_app.py b/tests/unit/test_series_app.py index 22a7a73..4eaafba 100644 --- a/tests/unit/test_series_app.py +++ b/tests/unit/test_series_app.py @@ -385,3 +385,177 @@ class TestSeriesAppGetters: 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) +