From 3ffab4e70a8f75759a576a805c4fa0c9ab9281bb Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 14 Oct 2025 21:57:20 +0200 Subject: [PATCH] feat(server): add anime_service wrapper, unit tests, update docs --- infrastructure.md | 11 +++ instructions.md | 12 +-- src/server/services/anime_service.py | 109 +++++++++++++++++++++++++++ tests/unit/test_anime_service.py | 27 +++++++ 4 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 src/server/services/anime_service.py create mode 100644 tests/unit/test_anime_service.py diff --git a/infrastructure.md b/infrastructure.md index fc4690a..935e8ff 100644 --- a/infrastructure.md +++ b/infrastructure.md @@ -219,6 +219,17 @@ Restructured the FastAPI application to use a controller-based architecture for #### Controller Architecture +### Anime Service Notes + +- The new `anime_service` runs the existing blocking `SeriesApp` inside a + threadpool (via ThreadPoolExecutor). This keeps the FastAPI event loop + responsive while leveraging the existing core logic. +- A small in-process LRU cache is used for the frequently-read "missing + episodes" list to reduce IO; cache invalidation happens after a rescan. +- For multi-worker or multi-host deployments, move cache/state to a shared + store (Redis) and ensure the threadpool sizing matches the worker's CPU + and IO profile. + **Health Controller** (`health_controller.py`): ```python diff --git a/instructions.md b/instructions.md index 8cb9b8c..611c0fe 100644 --- a/instructions.md +++ b/instructions.md @@ -45,13 +45,13 @@ The tasks should be completed in the following order to ensure proper dependenci ### 4. Anime Management Integration -#### [] Create anime service wrapper +#### [x] Create anime service wrapper -- []Create `src/server/services/anime_service.py` -- []Wrap SeriesApp functionality for web layer -- []Implement async wrappers for blocking operations -- []Add caching for frequently accessed data -- []Include error handling and logging +- [x]Create `src/server/services/anime_service.py` +- [x]Wrap SeriesApp functionality for web layer +- [x]Implement async wrappers for blocking operations +- [x]Add caching for frequently accessed data +- [x]Include error handling and logging #### [] Implement anime API endpoints diff --git a/src/server/services/anime_service.py b/src/server/services/anime_service.py new file mode 100644 index 0000000..5d27f1f --- /dev/null +++ b/src/server/services/anime_service.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import asyncio +from concurrent.futures import ThreadPoolExecutor +from functools import lru_cache +from typing import List, Optional + +import structlog + +from src.core.SeriesApp import SeriesApp + +logger = structlog.get_logger(__name__) + + +class AnimeServiceError(Exception): + """Service-level exception for anime operations.""" + + +class AnimeService: + """Wraps the blocking SeriesApp for use in the FastAPI web layer. + + - Runs blocking operations in a threadpool + - Exposes async methods + - Adds simple in-memory caching for read operations + """ + + def __init__(self, directory: str, max_workers: int = 4): + self._directory = directory + self._executor = ThreadPoolExecutor(max_workers=max_workers) + # SeriesApp is blocking; instantiate per-service + try: + self._app = SeriesApp(directory) + except Exception as e: + logger.exception("Failed to initialize SeriesApp") + raise AnimeServiceError("Initialization failed") from e + + async def _run_in_executor(self, func, *args, **kwargs): + loop = asyncio.get_event_loop() + try: + return await loop.run_in_executor(self._executor, lambda: func(*args, **kwargs)) + except Exception as e: + logger.exception("Executor task failed") + raise AnimeServiceError(str(e)) from e + + @lru_cache(maxsize=128) + def _cached_list_missing(self) -> List[dict]: + # Synchronous cached call used by async wrapper + try: + series = self._app.series_list + # normalize to simple dicts + return [s.to_dict() if hasattr(s, "to_dict") else s for s in series] + except Exception as e: + logger.exception("Failed to get missing episodes list") + raise + + async def list_missing(self) -> List[dict]: + """Return list of series with missing episodes.""" + try: + return await self._run_in_executor(self._cached_list_missing) + except AnimeServiceError: + raise + except Exception as e: + logger.exception("list_missing failed") + raise AnimeServiceError("Failed to list missing series") from e + + async def search(self, query: str) -> List[dict]: + """Search for series using underlying loader.Search.""" + if not query: + return [] + try: + result = await self._run_in_executor(self._app.search, query) + # result may already be list of dicts or objects + return result + except Exception as e: + logger.exception("search failed") + raise AnimeServiceError("Search failed") from e + + async def rescan(self, callback=None) -> None: + """Trigger a re-scan. Accepts an optional callback function. + + The callback is executed in the threadpool by SeriesApp. + """ + try: + await self._run_in_executor(self._app.ReScan, callback) + # invalidate cache + try: + self._cached_list_missing.cache_clear() + except Exception: + pass + except Exception as e: + logger.exception("rescan failed") + raise AnimeServiceError("Rescan failed") from e + + async def download(self, serie_folder: str, season: int, episode: int, key: str, callback=None) -> bool: + """Start a download via the underlying loader. + + Returns True on success or raises AnimeServiceError on failure. + """ + try: + result = await self._run_in_executor(self._app.download, serie_folder, season, episode, key, callback) + return bool(result) + except Exception as e: + logger.exception("download failed") + raise AnimeServiceError("Download failed") from e + + +def get_anime_service(directory: str = "./") -> AnimeService: + """Factory used by FastAPI dependency injection.""" + return AnimeService(directory) diff --git a/tests/unit/test_anime_service.py b/tests/unit/test_anime_service.py new file mode 100644 index 0000000..b840aa2 --- /dev/null +++ b/tests/unit/test_anime_service.py @@ -0,0 +1,27 @@ +import asyncio + +import pytest + +from src.server.services.anime_service import AnimeService, AnimeServiceError + + +@pytest.mark.asyncio +async def test_list_missing_empty(tmp_path): + svc = AnimeService(directory=str(tmp_path)) + # SeriesApp may return empty list depending on filesystem; ensure it returns a list + result = await svc.list_missing() + assert isinstance(result, list) + + +@pytest.mark.asyncio +async def test_search_empty_query(tmp_path): + svc = AnimeService(directory=str(tmp_path)) + res = await svc.search("") + assert res == [] + + +@pytest.mark.asyncio +async def test_rescan_and_cache_clear(tmp_path): + svc = AnimeService(directory=str(tmp_path)) + # calling rescan should not raise + await svc.rescan()