feat(api): add anime API endpoints and tests; update docs
This commit is contained in:
parent
3ffab4e70a
commit
9323eb6371
@ -138,6 +138,12 @@ conda activate AniWorld
|
|||||||
- `POST /api/anime/{id}/download` - Add episodes to download queue
|
- `POST /api/anime/{id}/download` - Add episodes to download queue
|
||||||
- `GET /api/anime/{id}` - Get anime details
|
- `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
|
### Download Management
|
||||||
|
|
||||||
- `GET /api/downloads` - Get download queue status
|
- `GET /api/downloads` - Get download queue status
|
||||||
|
|||||||
@ -38,29 +38,11 @@ The tasks should be completed in the following order to ensure proper dependenci
|
|||||||
2. Process the task
|
2. Process the task
|
||||||
3. Make Tests.
|
3. Make Tests.
|
||||||
4. Remove task from instructions.md.
|
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
|
6. Commit in git
|
||||||
|
|
||||||
## Core Tasks
|
## 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
|
### 5. Download Queue Management
|
||||||
|
|
||||||
#### [] Implement download queue models
|
#### [] Implement download queue models
|
||||||
|
|||||||
117
src/server/api/anime.py
Normal file
117
src/server/api/anime.py
Normal 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")
|
||||||
74
tests/api/test_anime_endpoints.py
Normal file
74
tests/api/test_anime_endpoints.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user