feat(api): add anime API endpoints and tests; update docs

This commit is contained in:
Lukas 2025-10-14 22:01:56 +02:00
parent 3ffab4e70a
commit 9323eb6371
4 changed files with 198 additions and 19 deletions

View File

@ -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

View File

@ -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

117
src/server/api/anime.py Normal file
View File

@ -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")

View File

@ -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