Aniworld/src/server/api/anime.py
Lukas 281b982abe Fix: Scanner availability for series addition
- Change 'scanner' to 'serie_scanner' attribute name
- Update tests to match SeriesApp attribute naming
- Scanner now properly detected and called on add
- All add_series tests passing (9/9)
2026-01-11 17:48:37 +01:00

927 lines
32 KiB
Python

import logging
import os
import warnings
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.entities.series import Serie
from src.server.database.service import AnimeSeriesService
from src.server.exceptions import (
BadRequestError,
NotFoundError,
ServerError,
ValidationError,
)
from src.server.services.anime_service import AnimeService, AnimeServiceError
from src.server.utils.dependencies import (
get_anime_service,
get_optional_database_session,
get_series_app,
require_auth,
)
from src.server.utils.filesystem import sanitize_folder_name
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/anime", tags=["anime"])
@router.get("/status")
async def get_anime_status(
_auth: dict = Depends(require_auth),
series_app: Any = Depends(get_series_app),
) -> dict:
"""Get anime library status information.
Args:
_auth: Ensures the caller is authenticated (value unused)
series_app: Core `SeriesApp` instance provided via dependency
Returns:
Dict[str, Any]: Status information including directory and series count
Raises:
HTTPException: If status retrieval fails
"""
try:
directory = (
getattr(series_app, "directory_to_search", "")
if series_app else ""
)
# Get series count
series_count = 0
if series_app and hasattr(series_app, "list"):
series = series_app.list.GetList()
series_count = len(series) if series else 0
return {
"directory": directory,
"series_count": series_count
}
except Exception as exc:
raise ServerError(
message=f"Failed to get status: {str(exc)}"
) from exc
class AnimeSummary(BaseModel):
"""Summary of an anime series with missing episodes.
The `key` field is the unique provider-assigned identifier used for all
lookups and operations (URL-safe, e.g., "attack-on-titan").
The `folder` field is metadata only for filesystem operations and display
(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
has_missing: Boolean flag indicating if series has missing episodes
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]}"
)
has_missing: bool = Field(
default=False,
description="Whether the series has any missing episodes"
)
link: Optional[str] = Field(
default="",
description="Link to the series page (for adding new series)"
)
class Config:
"""Pydantic model configuration."""
json_schema_extra = {
"example": {
"key": "beheneko-the-elf-girls-cat",
"name": "Beheneko",
"site": "aniworld.to",
"folder": "beheneko the elf girls cat (2025)",
"missing_episodes": {"1": [1, 2, 3, 4]},
"has_missing": True,
"link": "https://aniworld.to/anime/stream/beheneko"
}
}
class AnimeDetail(BaseModel):
"""Detailed information about a specific anime series.
The `key` field is the unique provider-assigned identifier used for all
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])
async def list_anime(
page: Optional[int] = 1,
per_page: Optional[int] = 20,
sort_by: Optional[str] = None,
filter: Optional[str] = None,
_auth: dict = Depends(require_auth),
series_app: Any = Depends(get_series_app),
) -> List[AnimeSummary]:
"""List all library series with their missing episodes status.
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.
All series are returned, with `has_missing` flag indicating whether
a series has any missing episodes.
Args:
page: Page number for pagination (must be positive)
per_page: Items per page (must be positive, max 1000)
sort_by: Optional sorting parameter. Allowed: title, id, name,
missing_episodes
filter: Optional filter parameter (validated for security)
_auth: Ensures the caller is authenticated (value unused)
series_app: Core SeriesApp instance provided via dependency.
Returns:
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
- has_missing: Whether the series has any missing episodes
Raises:
HTTPException: When the underlying lookup fails or params invalid.
"""
# Validate pagination parameters
if page is not None:
try:
page_num = int(page)
if page_num < 1:
raise ValidationError(
message="Page number must be positive"
)
page = page_num
except (ValueError, TypeError):
raise ValidationError(
message="Page must be a valid number"
)
if per_page is not None:
try:
per_page_num = int(per_page)
if per_page_num < 1:
raise ValidationError(
message="Per page must be positive"
)
if per_page_num > 1000:
raise ValidationError(
message="Per page cannot exceed 1000"
)
per_page = per_page_num
except (ValueError, TypeError):
raise ValidationError(
message="Per page must be a valid number"
)
# Validate sort_by parameter to prevent ORM injection
if sort_by:
# Only allow safe sort fields
allowed_sort_fields = ["title", "id", "missing_episodes", "name"]
if sort_by not in allowed_sort_fields:
allowed = ", ".join(allowed_sort_fields)
raise ValidationError(
message=f"Invalid sort_by parameter. Allowed: {allowed}"
)
# Validate filter parameter
if filter:
# Check for dangerous patterns in filter
dangerous_patterns = [
";", "--", "/*", "*/",
"drop", "delete", "insert", "update"
]
lower_filter = filter.lower()
for pattern in dangerous_patterns:
if pattern in lower_filter:
raise ValidationError(
message="Invalid filter parameter"
)
try:
# Get all series from series app
if not hasattr(series_app, "list"):
return []
series = series_app.list.GetList()
summaries: List[AnimeSummary] = []
for serie in series:
# Get all properties from the serie object
key = getattr(serie, "key", "")
name = getattr(serie, "name", "")
site = getattr(serie, "site", "")
folder = getattr(serie, "folder", "")
episode_dict = getattr(serie, "episodeDict", {}) or {}
# Convert episode dict keys to strings for JSON serialization
missing_episodes = {str(k): v for k, v in episode_dict.items()}
# Determine if series has missing episodes
has_missing = bool(episode_dict)
summaries.append(
AnimeSummary(
key=key,
name=name,
site=site,
folder=folder,
missing_episodes=missing_episodes,
has_missing=has_missing,
)
)
# Apply sorting if requested
if sort_by:
if sort_by in ["title", "name"]:
summaries.sort(key=lambda x: x.name or x.key)
elif sort_by == "id":
summaries.sort(key=lambda x: x.key)
elif sort_by == "missing_episodes":
# Sort by total number of missing episodes
# (count all episodes across all seasons)
summaries.sort(
key=lambda x: sum(
len(eps) for eps in x.missing_episodes.values()
),
reverse=True
)
return summaries
except (ValidationError, BadRequestError, NotFoundError, ServerError):
raise
except Exception as exc:
raise ServerError(
message="Failed to retrieve anime list"
) from exc
@router.post("/rescan")
async def trigger_rescan(
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> dict:
"""Kick off a rescan of the local library.
Args:
_auth: Ensures the caller is authenticated (value unused)
anime_service: AnimeService instance provided via dependency.
Returns:
Dict[str, Any]: Status payload confirming scan started
Raises:
HTTPException: If the rescan command fails.
"""
try:
# Use the async rescan method from AnimeService
# Progress tracking is handled automatically via event handlers
await anime_service.rescan()
return {
"success": True,
"message": "Rescan started successfully",
}
except AnimeServiceError as e:
raise ServerError(
message=f"Rescan failed: {str(e)}"
) from e
except Exception as exc:
raise ServerError(
message="Failed to start rescan"
) from exc
@router.get("/scan/status")
async def get_scan_status(
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> dict:
"""Get the current scan status.
Returns the current state of any ongoing library scan,
useful for restoring UI state after page reload.
Args:
_auth: Ensures the caller is authenticated (value unused)
anime_service: AnimeService instance provided via dependency.
Returns:
Dict[str, Any]: Current scan status including:
- is_scanning: Whether a scan is in progress
- total_items: Total items to scan
- directories_scanned: Items scanned so far
- current_directory: Current item being scanned
- directory: Root scan directory
"""
return anime_service.get_scan_status()
class AddSeriesRequest(BaseModel):
"""Request model for adding a new series."""
link: str
name: str
def validate_search_query(query: str) -> str:
"""Validate and sanitize search query.
Args:
query: The search query string
Returns:
str: The validated query
Raises:
HTTPException: If query is invalid
"""
if not query or not query.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Search query cannot be empty"
)
# Check for null bytes
if "\x00" in query:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Null bytes not allowed in query"
)
# Limit query length to prevent abuse
if len(query) > 200:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Search query too long (max 200 characters)"
)
# Strip and normalize whitespace
normalized = " ".join(query.strip().split())
# Prevent SQL-like injection patterns
dangerous_patterns = [
"--", "/*", "*/", "xp_", "sp_", "exec", "execute",
"union", "select", "insert", "update", "delete", "drop",
"create", "alter", "truncate", "sleep", "waitfor", "benchmark",
" or ", "||", " and ", "&&"
]
lower_query = normalized.lower()
for pattern in dangerous_patterns:
if pattern in lower_query:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid character sequence detected"
)
return normalized
class SearchAnimeRequest(BaseModel):
"""Request model for searching anime."""
query: str = Field(..., min_length=1, description="Search query string")
@router.get("/search", response_model=List[AnimeSummary])
async def search_anime_get(
query: str,
series_app: Optional[Any] = Depends(get_series_app),
) -> List[AnimeSummary]:
"""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:
query: Search term passed as query parameter
series_app: Optional SeriesApp instance provided via dependency.
Returns:
List[AnimeSummary]: Discovered matches with `key` as identifier.
Raises:
HTTPException: When provider communication fails or query is invalid.
"""
return await _perform_search(query, series_app)
@router.post(
"/search",
response_model=List[AnimeSummary],
)
async def search_anime_post(
request: SearchAnimeRequest,
series_app: Optional[Any] = Depends(get_series_app),
) -> List[AnimeSummary]:
"""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:
request: Request containing the search query
series_app: Optional SeriesApp instance provided via dependency.
Returns:
List[AnimeSummary]: Discovered matches with `key` as identifier.
Raises:
HTTPException: When provider communication fails or query is invalid.
"""
return await _perform_search(request.query, series_app)
async def _perform_search(
query: str,
series_app: Optional[Any],
) -> List[AnimeSummary]:
"""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:
query: Search term (will be validated and sanitized)
series_app: Optional SeriesApp instance for search.
Returns:
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:
HTTPException: When provider communication fails or query is invalid.
"""
try:
# Validate and sanitize the query
validated_query = validate_search_query(query)
# Check if series_app is available
if not series_app:
# Return empty list if service unavailable
# Tests can verify validation without needing a real series_app
return []
matches: List[Any] = []
if hasattr(series_app, "search"):
# SeriesApp.search is async; await the result
matches = await series_app.search(validated_query)
summaries: List[AnimeSummary] = []
for match in matches:
if isinstance(match, dict):
# Extract key (primary identifier)
key = match.get("key") or match.get("id") or ""
title = match.get("title") or match.get("name") or ""
site = match.get("site") or ""
folder = match.get("folder") or ""
link = match.get("link") or match.get("url") or ""
missing = (
match.get("missing_episodes")
or match.get("missing")
or {}
)
# If key is empty, try to extract from link
if not key and link:
if "/anime/stream/" in link:
key = link.split("/anime/stream/")[-1].split("/")[0]
elif link and "/" not in link:
# Link is just a slug (e.g., "attack-on-titan")
key = link
else:
# Extract key (primary identifier)
key = getattr(match, "key", "") or getattr(match, "id", "")
title = getattr(match, "title", "") or getattr(
match, "name", ""
)
site = getattr(match, "site", "")
folder = getattr(match, "folder", "")
link = getattr(match, "link", "") or getattr(
match, "url", ""
)
missing = getattr(match, "missing_episodes", {})
# If key is empty, try to extract from link
if not key and link:
if "/anime/stream/" in link:
key = link.split("/anime/stream/")[-1].split("/")[0]
elif link and "/" not in link:
# Link is just a slug (e.g., "attack-on-titan")
key = link
summaries.append(
AnimeSummary(
key=key,
name=title,
site=site,
folder=folder,
link=link,
missing_episodes=missing,
)
)
return summaries
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Search failed",
) from exc
@router.post("/add")
async def add_series(
request: AddSeriesRequest,
_auth: dict = Depends(require_auth),
series_app: Any = Depends(get_series_app),
db: Optional[AsyncSession] = Depends(get_optional_database_session),
anime_service: AnimeService = Depends(get_anime_service),
) -> dict:
"""Add a new series to the library with full initialization.
This endpoint performs the complete series addition flow:
1. Validates inputs and extracts the series key from the link URL
2. Creates a sanitized folder name from the display name
3. Saves the series to the database (if available)
4. Creates the folder on disk with the sanitized name
5. Triggers a targeted scan for missing episodes (only this series)
The `key` is the URL-safe identifier used for all lookups.
The `name` is stored as display metadata and used to derive
the filesystem folder name (sanitized for filesystem safety).
Args:
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)
series_app: Core `SeriesApp` instance provided via dependency
db: Optional database session for async operations
anime_service: AnimeService for scanning operations
Returns:
Dict[str, Any]: Status payload with:
- status: "success" or "exists"
- message: Human-readable status message
- key: Series unique identifier
- folder: Created folder path
- db_id: Database ID (if saved to DB)
- missing_episodes: Dict of missing episodes by season
- total_missing: Total count of missing episodes
Raises:
HTTPException: If adding the series fails or link is invalid
"""
try:
# Step A: Validate inputs
if not request.link or not request.link.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Series link cannot be empty",
)
if not request.name or not request.name.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Series name cannot be empty",
)
# 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",
)
# Step B: Create sanitized folder name from display name
name = request.name.strip()
try:
folder = sanitize_folder_name(name)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid series name for folder: {str(e)}",
)
db_id = None
missing_episodes: dict = {}
scan_error: Optional[str] = None
# Step C: Save to database if available
if db is not None:
# Check if series already exists in database
existing = await AnimeSeriesService.get_by_key(db, key)
if existing:
return {
"status": "exists",
"message": f"Series already exists: {name}",
"key": key,
"folder": existing.folder,
"db_id": existing.id,
"missing_episodes": {},
"total_missing": 0
}
# Save to database using AnimeSeriesService
anime_series = await AnimeSeriesService.create(
db=db,
key=key,
name=name,
site="aniworld.to",
folder=folder,
)
db_id = anime_series.id
logger.info(
"Added series to database: %s (key=%s, db_id=%d)",
name,
key,
db_id
)
# Step D: Add to SerieList (in-memory only, no folder creation)
if series_app and hasattr(series_app, "list"):
serie = Serie(
key=key,
name=name,
site="aniworld.to",
folder=folder,
episodeDict={}
)
# Add to in-memory cache without creating folder on disk
if hasattr(series_app.list, 'keyDict'):
series_app.list.keyDict[key] = serie
logger.info(
"Added series to in-memory cache: %s (key=%s, folder=%s)",
name,
key,
folder
)
# Step E: Trigger targeted scan for missing episodes
try:
if series_app and hasattr(series_app, "serie_scanner"):
missing_episodes = series_app.serie_scanner.scan_single_series(
key=key,
folder=folder
)
logger.info(
"Targeted scan completed for %s: found %d missing episodes",
key,
sum(len(eps) for eps in missing_episodes.values())
)
# Update the serie in keyDict with the missing episodes
if hasattr(series_app, "list") and hasattr(series_app.list, "keyDict"):
if key in series_app.list.keyDict:
series_app.list.keyDict[key].episodeDict = missing_episodes
else:
# Scanner not available - this shouldn't happen in normal operation
logger.warning(
"Scanner not available for targeted scan of %s",
key
)
except Exception as e:
# Scan failure is not critical - series was still added
scan_error = str(e)
logger.warning(
"Targeted scan failed for %s: %s (series still added)",
key,
e
)
# Convert missing episodes keys to strings for JSON serialization
missing_episodes_serializable = {
str(season): episodes
for season, episodes in missing_episodes.items()
}
# Calculate total missing
total_missing = sum(len(eps) for eps in missing_episodes.values())
# Step F: Return response
response = {
"status": "success",
"message": f"Successfully added series: {name}",
"key": key,
"folder": folder,
"db_id": db_id,
"missing_episodes": missing_episodes_serializable,
"total_missing": total_missing
}
if scan_error:
response["scan_warning"] = f"Scan partially failed: {scan_error}"
return response
except HTTPException:
raise
except Exception as exc:
logger.error("Failed to add series: %s", exc, exc_info=True)
# Attempt to rollback database entry if folder creation failed
# (This is a best-effort cleanup)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add series: {str(exc)}",
) from exc
@router.get("/{anime_id}", response_model=AnimeDetail)
async def get_anime(
anime_id: str,
series_app: Optional[Any] = Depends(get_series_app)
) -> AnimeDetail:
"""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:
anime_id: Series `key` (primary) or `folder` (deprecated fallback).
series_app: Optional SeriesApp instance provided via dependency.
Returns:
AnimeDetail: Detailed series metadata including episode list.
Response includes `key` as the primary identifier and
`folder` as metadata.
Raises:
HTTPException: If the anime cannot be located or retrieval fails.
"""
try:
# Check if series_app is available
if not series_app or not hasattr(series_app, "list"):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Series not found",
)
series = series_app.list.GetList()
found = None
# Primary lookup: search by key first (preferred)
for serie in series:
if getattr(serie, "key", None) == anime_id:
found = serie
break
# Fallback lookup: search by folder (backward compatibility)
if not found:
for serie in series:
if getattr(serie, "folder", None) == anime_id:
found = serie
# Log deprecation warning for folder-based lookup
key = getattr(serie, "key", "unknown")
logger.warning(
"Folder-based lookup for '%s' is deprecated. "
"Use series key '%s' instead. Folder-based lookups "
"will be removed in v3.0.0.",
anime_id,
key
)
warnings.warn(
f"Folder-based lookup for '{anime_id}' is deprecated. "
f"Use series key '{key}' instead.",
DeprecationWarning,
stacklevel=2
)
break
if not found:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Series not found",
)
episodes: List[str] = []
episode_dict = getattr(found, "episodeDict", {}) or {}
for season, episode_numbers in episode_dict.items():
for episode in episode_numbers:
episodes.append(f"{season}-{episode}")
# Return AnimeDetail with key as the primary identifier
return AnimeDetail(
key=getattr(found, "key", ""),
title=getattr(found, "name", ""),
folder=getattr(found, "folder", ""),
episodes=episodes,
description=getattr(found, "description", None),
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve series details",
) from exc
# Maximum allowed input size for security
MAX_INPUT_LENGTH = 100000 # 100KB