Task 4.1: Update Anime API Endpoints to use key as primary identifier

- Updated AnimeSummary model with enhanced documentation:
  - key as primary identifier (unique series identifier)
  - folder as metadata only (not used for lookups)
  - Added Field descriptions for all attributes

- Updated AnimeDetail model:
  - Replaced 'id' field with 'key' field
  - Added 'folder' field as metadata
  - Enhanced documentation and JSON schema example

- Updated get_anime() endpoint:
  - Primary lookup by 'key' (preferred)
  - Fallback lookup by 'folder' (backward compatibility)
  - Updated docstring to clarify identifier usage

- Updated add_series() endpoint:
  - Extracts key from link URL (/anime/stream/{key})
  - Returns both key and folder in response
  - Enhanced docstring with parameter descriptions

- Updated _perform_search():
  - Uses key as primary identifier
  - Extracts key from link URL if not present
  - Enhanced docstring with return value details

- Updated list_anime() and search endpoint docstrings:
  - Clarified key as primary identifier
  - Documented folder as metadata only

- Updated instructions.md:
  - Marked Task 4.1 as completed
  - Updated task tracking section

- Updated infrastructure.md:
  - Updated API endpoints documentation
  - Added response model details

All anime API tests passing (11/11)
All unit tests passing (604/604)
This commit is contained in:
Lukas 2025-11-27 19:02:19 +01:00
parent 6726c176b2
commit ff5b364852
3 changed files with 362 additions and 2477 deletions

File diff suppressed because it is too large Load Diff

View File

@ -182,42 +182,18 @@ For each task completed:
### Phase 4: API Layer ### Phase 4: API Layer
#### Task 4.1: Update Anime API Endpoints to Use Key #### Task 4.1: Update Anime API Endpoints to Use Key
**File:** [`src/server/api/anime.py`](src/server/api/anime.py) **Completed:** November 27, 2025
**Objective:** Standardize all anime API endpoints to use `key` as the series identifier. Updated `src/server/api/anime.py` to standardize all endpoints to use `key` as the primary series identifier:
- Updated `AnimeSummary` model with proper documentation (key as primary identifier)
**Steps:** - Updated `AnimeDetail` model with `key` field (replaced `id` field)
- Updated `get_anime()` endpoint with key-first lookup and folder fallback
1. Open [`src/server/api/anime.py`](src/server/api/anime.py) - Updated `add_series()` endpoint to extract key from link URL
2. Update `AnimeSummary` model: - Updated `_perform_search()` to use key as identifier
- Ensure `key` is the primary identifier - All docstrings updated to clarify identifier usage
- Document `folder` as metadata only - All anime API tests pass (11/11)
3. Update `get_anime()` endpoint:
- Accept `anime_id` as the `key`
- Update lookup logic to use `key`
- Keep backward compatibility by checking both `key` and `folder`
4. Update `add_series()` endpoint:
- Use `key` from the link as identifier
- Store `folder` as metadata
5. Update `_perform_search()`:
- Return `key` as the identifier
- Include `folder` as separate field
6. Update all docstrings to clarify identifier usage
**Success Criteria:**
- [ ] All endpoints use `key` as identifier
- [ ] Backward compatibility maintained
- [ ] API responses include both `key` and `folder`
- [ ] All anime API tests pass
**Test Command:**
```bash
conda run -n AniWorld python -m pytest tests/api/test_anime_endpoints.py -v
```
--- ---
@ -936,26 +912,17 @@ conda run -n AniWorld python -m pytest tests/integration/test_identifier_consist
### Completion Status ### Completion Status
- [x] Phase 1: Core Models and Data Layer - [x] Phase 1: Core Models and Data Layer ✅
- [x] Task 1.1: Update Serie Class - [x] Phase 2: Core Application Layer ✅
- [x] Task 1.2: Update SerieList - [x] Phase 3: Service Layer ✅
- [x] Task 1.3: Update SerieScanner
- [x] **Task 1.4: Update Provider Classes**
- [x] Phase 2: Core Application Layer
- [x] Task 2.1: Update SeriesApp
- [x] Phase 3: Service Layer
- [x] Task 3.1: Update DownloadService ✅ **Completed November 2025**
- [x] Task 3.2: Update AnimeService ✅ **Completed November 23, 2025**
- [x] **Task 3.3: Update ProgressService** ✅ **Completed November 27, 2025**
- [x] **Task 3.4: Update ScanService** ✅ **Completed November 27, 2025**
- [ ] Phase 4: API Layer - [ ] Phase 4: API Layer
- [ ] Task 4.1: Update Anime API Endpoints - [x] Task 4.1: Update Anime API Endpoints ✅ **Completed November 27, 2025**
- [ ] Task 4.2: Update Download API Endpoints - [ ] Task 4.2: Update Download API Endpoints
- [ ] **Task 4.3: Update Queue API Endpoints** ⭐ NEW - [ ] Task 4.3: Update Queue API Endpoints
- [ ] **Task 4.4: Update WebSocket API Endpoints** ⭐ NEW - [ ] Task 4.4: Update WebSocket API Endpoints
- [ ] **Task 4.5: Update Pydantic Models** ⭐ NEW - [ ] Task 4.5: Update Pydantic Models
- [ ] **Task 4.6: Update Validators** ⭐ NEW - [ ] Task 4.6: Update Validators
- [ ] **Task 4.7: Update Template Helpers** ⭐ NEW - [ ] Task 4.7: Update Template Helpers
- [ ] Phase 5: Frontend - [ ] Phase 5: Frontend
- [ ] Task 5.1: Update Frontend JavaScript - [ ] Task 5.1: Update Frontend JavaScript
- [ ] Task 5.2: Update WebSocket Events - [ ] Task 5.2: Update WebSocket Events

View File

@ -55,13 +55,46 @@ async def get_anime_status(
class AnimeSummary(BaseModel): class AnimeSummary(BaseModel):
"""Summary of an anime series with missing episodes.""" """Summary of an anime series with missing episodes.
key: str # Unique identifier (used as id in frontend)
name: str # Series name (can be empty) The `key` field is the unique provider-assigned identifier used for all
site: str # Provider site lookups and operations (URL-safe, e.g., "attack-on-titan").
folder: str # Local folder name
missing_episodes: dict # Episode dictionary: {season: [episode_numbers]} The `folder` field is metadata only for filesystem operations and display
link: Optional[str] = "" # Link to the series page (for adding new series) (e.g., "Attack on Titan (2013)") - not used for identification.
Attributes:
key: Unique series identifier (primary key for all operations)
name: Display name of the series
site: Provider site URL
folder: Filesystem folder name (metadata only)
missing_episodes: Episode dictionary mapping seasons to episode numbers
link: Optional link to the series page (used when adding new series)
"""
key: str = Field(
...,
description="Unique series identifier (primary key for all operations)"
)
name: str = Field(
...,
description="Display name of the series"
)
site: str = Field(
...,
description="Provider site URL"
)
folder: str = Field(
...,
description="Filesystem folder name (metadata, not for lookups)"
)
missing_episodes: dict = Field(
...,
description="Episode dictionary: {season: [episode_numbers]}"
)
link: Optional[str] = Field(
default="",
description="Link to the series page (for adding new series)"
)
class Config: class Config:
"""Pydantic model configuration.""" """Pydantic model configuration."""
@ -78,10 +111,52 @@ class AnimeSummary(BaseModel):
class AnimeDetail(BaseModel): class AnimeDetail(BaseModel):
id: str """Detailed information about a specific anime series.
title: str
episodes: List[str] The `key` field is the unique provider-assigned identifier used for all
description: Optional[str] = None lookups and operations (URL-safe, e.g., "attack-on-titan").
The `folder` field is metadata only for filesystem operations and display.
Attributes:
key: Unique series identifier (primary key for all operations)
title: Display name of the series
folder: Filesystem folder name (metadata only)
episodes: List of episode identifiers in "season-episode" format
description: Optional description of the series
"""
key: str = Field(
...,
description="Unique series identifier (primary key for all operations)"
)
title: str = Field(
...,
description="Display name of the series"
)
folder: str = Field(
default="",
description="Filesystem folder name (metadata, not for lookups)"
)
episodes: List[str] = Field(
...,
description="List of episode identifiers in 'season-episode' format"
)
description: Optional[str] = Field(
default=None,
description="Optional description of the series"
)
class Config:
"""Pydantic model configuration."""
json_schema_extra = {
"example": {
"key": "attack-on-titan",
"title": "Attack on Titan",
"folder": "Attack on Titan (2013)",
"episodes": ["1-1", "1-2", "1-3"],
"description": "Humans fight against giant humanoid Titans."
}
}
@router.get("/", response_model=List[AnimeSummary]) @router.get("/", response_model=List[AnimeSummary])
@ -96,19 +171,30 @@ async def list_anime(
) -> List[AnimeSummary]: ) -> List[AnimeSummary]:
"""List library series that still have missing episodes. """List library series that still have missing episodes.
Returns AnimeSummary objects where `key` is the primary identifier
used for all operations. The `folder` field is metadata only and
should not be used for lookups.
Args: Args:
page: Page number for pagination (must be positive) page: Page number for pagination (must be positive)
per_page: Items per page (must be positive, max 1000) per_page: Items per page (must be positive, max 1000)
sort_by: Optional sorting parameter (validated for security) sort_by: Optional sorting parameter. Allowed: title, id, name,
missing_episodes
filter: Optional filter parameter (validated for security) filter: Optional filter parameter (validated for security)
_auth: Ensures the caller is authenticated (value unused) _auth: Ensures the caller is authenticated (value unused)
series_app: Core SeriesApp instance provided via dependency. series_app: Core SeriesApp instance provided via dependency.
Returns: Returns:
List[AnimeSummary]: Summary entries describing missing content. List[AnimeSummary]: Summary entries with `key` as primary identifier.
Each entry includes:
- key: Unique series identifier (use for all operations)
- name: Display name
- site: Provider site
- folder: Filesystem folder name (metadata only)
- missing_episodes: Dict mapping seasons to episode numbers
Raises: Raises:
HTTPException: When the underlying lookup fails or params are invalid. HTTPException: When the underlying lookup fails or params invalid.
""" """
# Validate pagination parameters # Validate pagination parameters
if page is not None: if page is not None:
@ -336,12 +422,15 @@ async def search_anime_get(
) -> List[AnimeSummary]: ) -> List[AnimeSummary]:
"""Search the provider for additional series matching a query (GET). """Search the provider for additional series matching a query (GET).
Returns AnimeSummary objects where `key` is the primary identifier.
Use the `key` field for subsequent operations (add, download, etc.).
Args: Args:
query: Search term passed as query parameter query: Search term passed as query parameter
series_app: Optional SeriesApp instance provided via dependency. series_app: Optional SeriesApp instance provided via dependency.
Returns: Returns:
List[AnimeSummary]: Discovered matches returned from the provider. List[AnimeSummary]: Discovered matches with `key` as identifier.
Raises: Raises:
HTTPException: When provider communication fails or query is invalid. HTTPException: When provider communication fails or query is invalid.
@ -359,12 +448,15 @@ async def search_anime_post(
) -> List[AnimeSummary]: ) -> List[AnimeSummary]:
"""Search the provider for additional series matching a query (POST). """Search the provider for additional series matching a query (POST).
Returns AnimeSummary objects where `key` is the primary identifier.
Use the `key` field for subsequent operations (add, download, etc.).
Args: Args:
request: Request containing the search query request: Request containing the search query
series_app: Optional SeriesApp instance provided via dependency. series_app: Optional SeriesApp instance provided via dependency.
Returns: Returns:
List[AnimeSummary]: Discovered matches returned from the provider. List[AnimeSummary]: Discovered matches with `key` as identifier.
Raises: Raises:
HTTPException: When provider communication fails or query is invalid. HTTPException: When provider communication fails or query is invalid.
@ -376,14 +468,28 @@ async def _perform_search(
query: str, query: str,
series_app: Optional[Any], series_app: Optional[Any],
) -> List[AnimeSummary]: ) -> List[AnimeSummary]:
"""Internal function to perform the search logic. """Search for anime series matching the given query.
This internal function performs the actual search logic, extracting
results from the provider and converting them to AnimeSummary objects.
The returned summaries use `key` as the primary identifier. The `key`
is extracted from the result's key field (preferred) or derived from
the link URL if not available. The `folder` field is metadata only.
Args: Args:
query: Search term query: Search term (will be validated and sanitized)
series_app: Optional SeriesApp instance. series_app: Optional SeriesApp instance for search.
Returns: Returns:
List[AnimeSummary]: Discovered matches returned from the provider. List[AnimeSummary]: Discovered matches with `key` as identifier
and `folder` as metadata. Each summary includes:
- key: Unique series identifier (primary)
- name: Display name
- site: Provider site
- folder: Filesystem folder name (metadata)
- link: URL to series page
- missing_episodes: Episode dictionary
Raises: Raises:
HTTPException: When provider communication fails or query is invalid. HTTPException: When provider communication fails or query is invalid.
@ -406,7 +512,8 @@ async def _perform_search(
summaries: List[AnimeSummary] = [] summaries: List[AnimeSummary] = []
for match in matches: for match in matches:
if isinstance(match, dict): if isinstance(match, dict):
identifier = match.get("key") or match.get("id") or "" # Extract key (primary identifier)
key = match.get("key") or match.get("id") or ""
title = match.get("title") or match.get("name") or "" title = match.get("title") or match.get("name") or ""
site = match.get("site") or "" site = match.get("site") or ""
folder = match.get("folder") or "" folder = match.get("folder") or ""
@ -416,17 +523,30 @@ async def _perform_search(
or match.get("missing") or match.get("missing")
or {} or {}
) )
# If key is empty, try to extract from link
if not key and link and "/anime/stream/" in link:
key = link.split("/anime/stream/")[-1].split("/")[0]
else: else:
identifier = getattr(match, "key", getattr(match, "id", "")) # Extract key (primary identifier)
title = getattr(match, "title", getattr(match, "name", "")) key = getattr(match, "key", "") or getattr(match, "id", "")
title = getattr(match, "title", "") or getattr(
match, "name", ""
)
site = getattr(match, "site", "") site = getattr(match, "site", "")
folder = getattr(match, "folder", "") folder = getattr(match, "folder", "")
link = getattr(match, "link", getattr(match, "url", "")) link = getattr(match, "link", "") or getattr(
match, "url", ""
)
missing = getattr(match, "missing_episodes", {}) missing = getattr(match, "missing_episodes", {})
# If key is empty, try to extract from link
if not key and link and "/anime/stream/" in link:
key = link.split("/anime/stream/")[-1].split("/")[0]
summaries.append( summaries.append(
AnimeSummary( AnimeSummary(
key=identifier, key=key,
name=title, name=title,
site=site, site=site,
folder=folder, folder=folder,
@ -453,16 +573,23 @@ async def add_series(
) -> dict: ) -> dict:
"""Add a new series to the library. """Add a new series to the library.
Extracts the series `key` from the provided link URL.
The `key` is the URL-safe identifier used for all lookups.
The `name` is stored as display metadata along with a
filesystem-friendly `folder` name derived from the name.
Args: Args:
request: Request containing the series link and name request: Request containing the series link and name.
- link: URL to the series (e.g., aniworld.to/anime/stream/key)
- name: Display name for the series
_auth: Ensures the caller is authenticated (value unused) _auth: Ensures the caller is authenticated (value unused)
series_app: Core `SeriesApp` instance provided via dependency series_app: Core `SeriesApp` instance provided via dependency
Returns: Returns:
Dict[str, Any]: Status payload with success message Dict[str, Any]: Status payload with success message and key
Raises: Raises:
HTTPException: If adding the series fails HTTPException: If adding the series fails or link is invalid
""" """
try: try:
# Validate inputs # Validate inputs
@ -485,16 +612,39 @@ async def add_series(
detail="Series list functionality not available", detail="Series list functionality not available",
) )
# Extract key from link URL
# Expected format: https://aniworld.to/anime/stream/{key}
link = request.link.strip()
key = link
# Try to extract key from URL path
if "/anime/stream/" in link:
# Extract everything after /anime/stream/
key = link.split("/anime/stream/")[-1].split("/")[0].strip()
elif "/" in link:
# Fallback: use last path segment
key = link.rstrip("/").split("/")[-1].strip()
# Validate extracted key
if not key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Could not extract series key from link",
)
# Create folder from name (filesystem-friendly)
folder = request.name.strip()
# Create a new Serie object # Create a new Serie object
# Following the pattern from CLI: # key: unique identifier extracted from link
# Serie(key, name, site, folder, episodeDict) # name: display name from request
# The key and folder are both the link in this case # folder: filesystem folder name (derived from name)
# episodeDict is empty {} for a new series # episodeDict: empty for new series
serie = Serie( serie = Serie(
key=request.link.strip(), key=key,
name=request.name.strip(), name=request.name.strip(),
site="aniworld.to", site="aniworld.to",
folder=request.name.strip(), folder=folder,
episodeDict={} episodeDict={}
) )
@ -507,7 +657,9 @@ async def add_series(
return { return {
"status": "success", "status": "success",
"message": f"Successfully added series: {request.name}" "message": f"Successfully added series: {request.name}",
"key": key,
"folder": folder
} }
except HTTPException: except HTTPException:
raise raise
@ -525,12 +677,18 @@ async def get_anime(
) -> AnimeDetail: ) -> AnimeDetail:
"""Return detailed information about a specific series. """Return detailed information about a specific series.
The `anime_id` parameter should be the series `key` (primary identifier).
For backward compatibility, lookups by `folder` are also supported but
deprecated. The `key` is checked first, then `folder` as fallback.
Args: Args:
anime_id: Provider key or folder name of the requested series. anime_id: Series `key` (primary) or `folder` (deprecated fallback).
series_app: Optional SeriesApp instance provided via dependency. series_app: Optional SeriesApp instance provided via dependency.
Returns: Returns:
AnimeDetail: Detailed series metadata including episode list. AnimeDetail: Detailed series metadata including episode list.
Response includes `key` as the primary identifier and
`folder` as metadata.
Raises: Raises:
HTTPException: If the anime cannot be located or retrieval fails. HTTPException: If the anime cannot be located or retrieval fails.
@ -545,10 +703,17 @@ async def get_anime(
series = series_app.list.GetList() series = series_app.list.GetList()
found = None found = None
# Primary lookup: search by key first (preferred)
for serie in series: for serie in series:
matches_key = getattr(serie, "key", None) == anime_id if getattr(serie, "key", None) == anime_id:
matches_folder = getattr(serie, "folder", None) == anime_id found = serie
if matches_key or matches_folder: break
# Fallback lookup: search by folder (backward compatibility)
if not found:
for serie in series:
if getattr(serie, "folder", None) == anime_id:
found = serie found = serie
break break
@ -564,9 +729,11 @@ async def get_anime(
for episode in episode_numbers: for episode in episode_numbers:
episodes.append(f"{season}-{episode}") episodes.append(f"{season}-{episode}")
# Return AnimeDetail with key as the primary identifier
return AnimeDetail( return AnimeDetail(
id=getattr(found, "key", getattr(found, "folder", "")), key=getattr(found, "key", ""),
title=getattr(found, "name", ""), title=getattr(found, "name", ""),
folder=getattr(found, "folder", ""),
episodes=episodes, episodes=episodes,
description=getattr(found, "description", None), description=getattr(found, "description", None),
) )