From 9323eb63717cf1dc000fbff05b9efbc8a72abab8 Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 14 Oct 2025 22:01:56 +0200 Subject: [PATCH] feat(api): add anime API endpoints and tests; update docs --- infrastructure.md | 6 ++ instructions.md | 20 +---- src/server/api/anime.py | 117 ++++++++++++++++++++++++++++++ tests/api/test_anime_endpoints.py | 74 +++++++++++++++++++ 4 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 src/server/api/anime.py create mode 100644 tests/api/test_anime_endpoints.py diff --git a/infrastructure.md b/infrastructure.md index 935e8ff..ee2ff6a 100644 --- a/infrastructure.md +++ b/infrastructure.md @@ -138,6 +138,12 @@ conda activate AniWorld - `POST /api/anime/{id}/download` - Add episodes to download queue - `GET /api/anime/{id}` - Get anime details +Note: The anime management API has been implemented under `/api/v1/anime` with +endpoints for listing series with missing episodes, searching providers, +triggering a local rescan, and fetching series details. The implementation +delegates to the existing core `SeriesApp` and uses dependency injection for +initialization. + ### Download Management - `GET /api/downloads` - Get download queue status diff --git a/instructions.md b/instructions.md index 611c0fe..6c0579a 100644 --- a/instructions.md +++ b/instructions.md @@ -38,29 +38,11 @@ The tasks should be completed in the following order to ensure proper dependenci 2. Process the task 3. Make Tests. 4. Remove task from instructions.md. -5. Update infrastructure.md, but only add text that belongs to a infrastructure doc. +5. Update infrastructure.md, but only add text that belongs to a infrastructure doc. make sure to summarize text or delete text that do not belog to infrastructure.md. Keep it clear and short. 6. Commit in git ## Core Tasks -### 4. Anime Management Integration - -#### [x] Create anime service wrapper - -- [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 - -- []Create `src/server/api/anime.py` -- []Add GET `/api/v1/anime` - list series with missing episodes -- []Add POST `/api/v1/anime/rescan` - trigger rescan -- []Add POST `/api/v1/anime/search` - search for new anime -- []Add GET `/api/v1/anime/{id}` - get series details - ### 5. Download Queue Management #### [] Implement download queue models diff --git a/src/server/api/anime.py b/src/server/api/anime.py new file mode 100644 index 0000000..441976e --- /dev/null +++ b/src/server/api/anime.py @@ -0,0 +1,117 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel + +from src.server.utils.dependencies import get_series_app + +router = APIRouter(prefix="/api/v1/anime", tags=["anime"]) + + +class AnimeSummary(BaseModel): + id: str + title: str + missing_episodes: int + + +class AnimeDetail(BaseModel): + id: str + title: str + episodes: List[str] + description: Optional[str] = None + + +@router.get("/", response_model=List[AnimeSummary]) +async def list_anime(series_app=Depends(get_series_app)): + """List series with missing episodes using the core SeriesApp.""" + try: + series = series_app.List.GetMissingEpisode() + result = [] + for s in series: + missing = 0 + try: + missing = len(s.episodeDict) if getattr(s, "episodeDict", None) is not None else 0 + except Exception: + missing = 0 + result.append(AnimeSummary(id=getattr(s, "key", getattr(s, "folder", "")), title=getattr(s, "name", ""), missing_episodes=missing)) + return result + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve anime list") + + +@router.post("/rescan") +async def trigger_rescan(series_app=Depends(get_series_app)): + """Trigger a rescan of local series data using SeriesApp.ReScan.""" + try: + # SeriesApp.ReScan expects a callback; pass a no-op + if hasattr(series_app, "ReScan"): + series_app.ReScan(lambda *args, **kwargs: None) + return {"success": True, "message": "Rescan started"} + else: + raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Rescan not available") + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to start rescan") + + +class SearchRequest(BaseModel): + query: str + + +@router.post("/search", response_model=List[AnimeSummary]) +async def search_anime(request: SearchRequest, series_app=Depends(get_series_app)): + """Search for new anime by query text using the SeriesApp loader.""" + try: + matches = [] + if hasattr(series_app, "search"): + # SeriesApp.search is synchronous in core; call directly + matches = series_app.search(request.query) + + result = [] + for m in matches: + # matches may be dicts or objects + if isinstance(m, dict): + mid = m.get("key") or m.get("id") or "" + title = m.get("title") or m.get("name") or "" + missing = int(m.get("missing", 0)) if m.get("missing") is not None else 0 + else: + mid = getattr(m, "key", getattr(m, "id", "")) + title = getattr(m, "title", getattr(m, "name", "")) + missing = int(getattr(m, "missing", 0)) + result.append(AnimeSummary(id=mid, title=title, missing_episodes=missing)) + + return result + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Search failed") + + +@router.get("/{anime_id}", response_model=AnimeDetail) +async def get_anime(anime_id: str, series_app=Depends(get_series_app)): + """Return detailed info about a series from SeriesApp.List.""" + try: + series = series_app.List.GetList() + found = None + for s in series: + if getattr(s, "key", None) == anime_id or getattr(s, "folder", None) == anime_id: + found = s + break + + if not found: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Series not found") + + episodes = [] + epdict = getattr(found, "episodeDict", {}) or {} + for season, eps in epdict.items(): + for e in eps: + episodes.append(f"{season}-{e}") + + return AnimeDetail(id=getattr(found, "key", getattr(found, "folder", "")), title=getattr(found, "name", ""), episodes=episodes, description=getattr(found, "description", None)) + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve series details") diff --git a/tests/api/test_anime_endpoints.py b/tests/api/test_anime_endpoints.py new file mode 100644 index 0000000..01bec7e --- /dev/null +++ b/tests/api/test_anime_endpoints.py @@ -0,0 +1,74 @@ +from fastapi.testclient import TestClient + +from src.server.fastapi_app import app + + +class FakeSerie: + def __init__(self, key, name, folder, episodeDict=None): + self.key = key + self.name = name + self.folder = folder + self.episodeDict = episodeDict or {} + + +class FakeSeriesApp: + def __init__(self): + self.List = self + self._items = [FakeSerie("1", "Test Show", "test_show", {1: [1, 2]}), FakeSerie("2", "Complete Show", "complete_show", {})] + + def GetMissingEpisode(self): + return [s for s in self._items if s.episodeDict] + + def GetList(self): + return self._items + + def ReScan(self, callback): + # simulate rescan + callback() + + +def test_list_anime_override_dependency(monkeypatch): + fake = FakeSeriesApp() + + def _get_series_app(): + return fake + + app.dependency_overrides = {"src.server.utils.dependencies.get_series_app": _get_series_app} + client = TestClient(app) + + resp = client.get("/api/v1/anime/") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert any(item["title"] == "Test Show" for item in data) + + +def test_get_anime_detail(monkeypatch): + fake = FakeSeriesApp() + + def _get_series_app(): + return fake + + app.dependency_overrides = {"src.server.utils.dependencies.get_series_app": _get_series_app} + client = TestClient(app) + + resp = client.get("/api/v1/anime/1") + assert resp.status_code == 200 + data = resp.json() + assert data["title"] == "Test Show" + assert "1-1" in data["episodes"] + + +def test_rescan(monkeypatch): + fake = FakeSeriesApp() + + def _get_series_app(): + return fake + + app.dependency_overrides = {"src.server.utils.dependencies.get_series_app": _get_series_app} + client = TestClient(app) + + resp = client.post("/api/v1/anime/rescan") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True