POST /api/anime/{key}/regenerate-nfo and PUT /api/anime/{key}/settings
(both of which can regenerate NFO when apply_to_nfo=True) were calling
_create_or_update_nfo without the required anime_service argument,
producing HTTP 500 with 'missing 1 required positional argument:
anime_service' on the anime-settings page.
Also drops the unused series_data parameter from
_create_or_update_nfo's signature — the value was constructed at every
call site but never read inside the function body. All four callers
in anime.py and nfo.py are updated to match.
1828 lines
63 KiB
Python
1828 lines
63 KiB
Python
import logging
|
|
import os
|
|
import re
|
|
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.config.settings import settings
|
|
from src.server.database.models import AnimeSeries
|
|
from src.server.database.service import AnimeSeriesService
|
|
from src.server.exceptions import (
|
|
BadRequestError,
|
|
NotFoundError,
|
|
ServerError,
|
|
ValidationError,
|
|
)
|
|
from src.server.models.anime import (
|
|
AnimeDetailsResponse,
|
|
AnimeSettingsRegenerateNfoResponse,
|
|
AnimeSettingsResponse,
|
|
AnimeSettingsUpdateRequest,
|
|
DeleteSeriesRequest,
|
|
DeleteSeriesResult,
|
|
TMDBSearchResult,
|
|
)
|
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
|
from src.server.services.background_loader_service import BackgroundLoaderService
|
|
from src.server.utils.dependencies import (
|
|
get_anime_service,
|
|
get_background_loader_service,
|
|
get_database_session,
|
|
get_optional_database_session,
|
|
get_series_app,
|
|
require_auth,
|
|
)
|
|
from src.server.utils.filesystem import is_safe_path, sanitize_folder_name
|
|
from src.server.utils.key_utils import generate_key_from_folder, is_valid_key
|
|
from src.server.utils.validators import validate_filter_value, validate_search_query
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/anime", tags=["anime"])
|
|
|
|
|
|
def _compute_folder_name(name: str, year: Optional[int]) -> str:
|
|
"""Compute sanitized folder name from display name and year.
|
|
|
|
If year is provided, strips any existing year in (YYYY) format to avoid
|
|
duplicates, then appends the new year. If year is None, preserves the
|
|
original name (with any existing year).
|
|
|
|
Args:
|
|
name: Display name of the series
|
|
year: Release year from provider, or None
|
|
|
|
Returns:
|
|
Sanitized folder name in format "Name (YYYY)" or just "Name"
|
|
"""
|
|
if year:
|
|
# Strip any existing year in (YYYY) format before adding new year
|
|
clean_name = re.sub(r'\s*\(\d{4}\)\s*$', '', name).strip()
|
|
folder_name_with_year = f"{clean_name} ({year})"
|
|
else:
|
|
# No new year provided, preserve original name (with any existing year)
|
|
folder_name_with_year = name
|
|
|
|
return sanitize_folder_name(folder_name_with_year)
|
|
|
|
|
|
@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 DuplicateFolderGroup(BaseModel):
|
|
"""Placeholder - duplicates functionality removed."""
|
|
key: str = Field(..., description="Series key (unique identifier)")
|
|
folders: List[str] = Field(..., description="List of duplicate folder names")
|
|
folder_count: int = Field(..., description="Number of duplicate folders")
|
|
|
|
|
|
class DuplicateFoldersResponse(BaseModel):
|
|
"""Placeholder - duplicates functionality removed."""
|
|
total_groups: int = Field(..., description="Total number of duplicate groups")
|
|
duplicate_groups: List[DuplicateFolderGroup] = Field(
|
|
..., description="List of duplicate folder groups"
|
|
)
|
|
message: str = Field(..., description="Human-readable summary")
|
|
|
|
|
|
@router.get("/duplicate-folders", response_model=DuplicateFoldersResponse)
|
|
async def get_duplicate_folders(
|
|
_auth: dict = Depends(require_auth),
|
|
) -> DuplicateFoldersResponse:
|
|
"""List all pre-existing duplicate folder groups.
|
|
|
|
Note: Duplicate folder scanning has been removed. Returns empty response.
|
|
"""
|
|
return DuplicateFoldersResponse(
|
|
total_groups=0,
|
|
duplicate_groups=[],
|
|
message="Duplicate folder scanning has been removed.",
|
|
)
|
|
|
|
|
|
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)
|
|
has_nfo: Whether the series has NFO metadata
|
|
nfo_created_at: ISO timestamp when NFO was created
|
|
nfo_updated_at: ISO timestamp when NFO was last updated
|
|
tmdb_id: The Movie Database (TMDB) ID
|
|
tvdb_id: TheTVDB ID
|
|
"""
|
|
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)"
|
|
)
|
|
has_nfo: bool = Field(
|
|
default=False,
|
|
description="Whether the series has NFO metadata"
|
|
)
|
|
nfo_created_at: Optional[str] = Field(
|
|
default=None,
|
|
description="ISO timestamp when NFO was created"
|
|
)
|
|
nfo_updated_at: Optional[str] = Field(
|
|
default=None,
|
|
description="ISO timestamp when NFO was last updated"
|
|
)
|
|
tmdb_id: Optional[int] = Field(
|
|
default=None,
|
|
description="The Movie Database (TMDB) ID"
|
|
)
|
|
tvdb_id: Optional[int] = Field(
|
|
default=None,
|
|
description="TheTVDB ID"
|
|
)
|
|
|
|
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",
|
|
"has_nfo": True,
|
|
"nfo_created_at": "2025-01-15T10:30:00Z",
|
|
"nfo_updated_at": "2025-01-15T10:30:00Z",
|
|
"tmdb_id": 12345,
|
|
"tvdb_id": 67890
|
|
}
|
|
}
|
|
|
|
|
|
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),
|
|
anime_service: AnimeService = Depends(get_anime_service),
|
|
) -> 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. Allowed values:
|
|
- "missing_episodes": Show only series that have any missing episodes
|
|
- "no_episodes": Show only series that have no downloaded episodes
|
|
_auth: Ensures the caller is authenticated (value unused)
|
|
anime_service: AnimeService 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:
|
|
try:
|
|
allowed_filters = ["missing_episodes", "no_episodes"]
|
|
validate_filter_value(filter, allowed_filters)
|
|
except ValueError as e:
|
|
raise ValidationError(message=str(e))
|
|
|
|
try:
|
|
# Use AnimeService to get series with metadata from database
|
|
series_list = await anime_service.list_series_with_filters(
|
|
filter_type=filter
|
|
)
|
|
|
|
summaries: List[AnimeSummary] = []
|
|
for series_dict in series_list:
|
|
# Convert episode dict keys to strings for JSON serialization
|
|
episode_dict = series_dict.get("episodeDict", {}) or {}
|
|
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=series_dict["key"],
|
|
name=series_dict["name"],
|
|
site=series_dict["site"],
|
|
folder=series_dict["folder"],
|
|
missing_episodes=missing_episodes,
|
|
has_missing=has_missing,
|
|
has_nfo=series_dict.get("has_nfo", False),
|
|
nfo_created_at=series_dict.get("nfo_created_at"),
|
|
nfo_updated_at=series_dict.get("nfo_updated_at"),
|
|
tmdb_id=series_dict.get("tmdb_id"),
|
|
tvdb_id=series_dict.get("tvdb_id"),
|
|
)
|
|
)
|
|
|
|
# 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=str(e)
|
|
) from e
|
|
except Exception as exc:
|
|
raise ServerError(
|
|
message=f"Failed to start rescan: {exc}"
|
|
) 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_extended(query: str) -> str:
|
|
"""Validate and sanitize search query with additional checks.
|
|
|
|
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)"
|
|
)
|
|
|
|
# Validate and normalize the search query using utility function
|
|
try:
|
|
normalized = validate_search_query(query)
|
|
return normalized
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=str(e)
|
|
)
|
|
|
|
|
|
|
|
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_extended(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", status_code=status.HTTP_202_ACCEPTED)
|
|
async def add_series(
|
|
request: AddSeriesRequest,
|
|
_auth: dict = Depends(require_auth),
|
|
series_app: Any = Depends(get_series_app),
|
|
anime_service: AnimeService = Depends(get_anime_service),
|
|
db: Optional[AsyncSession] = Depends(get_optional_database_session),
|
|
background_loader: BackgroundLoaderService = Depends(get_background_loader_service),
|
|
) -> dict:
|
|
"""Add a new series to the library with asynchronous data loading.
|
|
|
|
This endpoint performs immediate series addition and queues background loading:
|
|
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 with loading_status="pending"
|
|
4. Creates the folder on disk with the sanitized name
|
|
5. Queues background loading task for episodes, NFO, and images
|
|
6. Returns immediately (202 Accepted) without waiting for data loading
|
|
|
|
Data loading happens asynchronously in the background, with real-time
|
|
status updates via WebSocket.
|
|
|
|
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
|
|
background_loader: BackgroundLoaderService for async data loading
|
|
|
|
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)
|
|
- loading_status: Current loading status
|
|
- loading_progress: Dict of what data is being loaded
|
|
|
|
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: Fetch year from provider and create folder name with year
|
|
name = request.name.strip()
|
|
|
|
# Fetch year from provider
|
|
year = None
|
|
if series_app and hasattr(series_app, 'loader'):
|
|
try:
|
|
year = series_app.loader.get_year(key)
|
|
logger.info("Fetched year for %s: %s", key, year)
|
|
except Exception as e:
|
|
logger.warning("Could not fetch year for %s: %s", key, e)
|
|
|
|
# Step B: Compute sanitized folder name with year (deduplicates if year already in name)
|
|
try:
|
|
folder = _compute_folder_name(name, year)
|
|
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
|
|
|
|
# Step C: Create folder on disk if it doesn't exist, and rename if needed
|
|
# Determine the anime directory path
|
|
anime_dir = settings.anime_directory if hasattr(settings, 'anime_directory') else None
|
|
current_folder_on_disk = None
|
|
|
|
if anime_dir:
|
|
import os
|
|
anime_path = os.path.join(anime_dir, folder)
|
|
|
|
# Check if an existing folder (without year) needs renaming
|
|
# Look for folder that matches name without year
|
|
if year:
|
|
potential_old_name = sanitize_folder_name(name)
|
|
potential_old_path = os.path.join(anime_dir, potential_old_name)
|
|
if potential_old_path != anime_path and os.path.exists(potential_old_path):
|
|
current_folder_on_disk = potential_old_name
|
|
logger.info(
|
|
"Found existing folder without year for %s: %s, renaming to %s",
|
|
key,
|
|
potential_old_name,
|
|
folder
|
|
)
|
|
elif not os.path.exists(anime_path):
|
|
# No existing folder to rename, create new one
|
|
os.makedirs(anime_path, exist_ok=True)
|
|
else:
|
|
# No year, just ensure folder exists
|
|
if not os.path.exists(anime_path):
|
|
os.makedirs(anime_path, exist_ok=True)
|
|
|
|
# Step D: 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,
|
|
"loading_status": existing.loading_status,
|
|
"loading_progress": {
|
|
"episodes": existing.episodes_loaded,
|
|
"nfo": existing.has_nfo,
|
|
"logo": existing.logo_loaded,
|
|
"images": existing.images_loaded
|
|
}
|
|
}
|
|
|
|
# Save to database using AnimeSeriesService with loading status
|
|
anime_series = await AnimeSeriesService.create(
|
|
db=db,
|
|
key=key,
|
|
name=name,
|
|
site="aniworld.to",
|
|
folder=folder,
|
|
year=year,
|
|
loading_status="pending",
|
|
episodes_loaded=False,
|
|
logo_loaded=False,
|
|
images_loaded=False,
|
|
loading_started_at=None,
|
|
)
|
|
db_id = anime_series.id
|
|
|
|
logger.info(
|
|
"Added series to database: %s (key=%s, db_id=%d, year=%s, loading=pending)",
|
|
name,
|
|
key,
|
|
db_id,
|
|
year
|
|
)
|
|
|
|
# Step D: Add to SerieList (in-memory only, no folder creation)
|
|
if series_app and hasattr(series_app, "list"):
|
|
from src.server.database.models import AnimeSeries
|
|
anime = AnimeSeries(
|
|
key=key,
|
|
name=name,
|
|
site="aniworld.to",
|
|
folder=folder,
|
|
year=year
|
|
)
|
|
|
|
# Add to in-memory cache without creating folder on disk
|
|
if hasattr(series_app.list, 'keyDict'):
|
|
series_app.list.keyDict[key] = anime
|
|
logger.info(
|
|
"Added series to in-memory cache: %s (key=%s, folder=%s, year=%s)",
|
|
name,
|
|
key,
|
|
folder,
|
|
year
|
|
)
|
|
|
|
# Step E: Rename existing folder if needed (e.g., folder existed without year)
|
|
if current_folder_on_disk:
|
|
try:
|
|
renamed = await anime_service.rename_folder_if_needed(
|
|
key=key,
|
|
current_folder=current_folder_on_disk,
|
|
target_folder=folder,
|
|
db=db
|
|
)
|
|
if renamed:
|
|
logger.info(
|
|
"Successfully renamed folder for %s: %s -> %s",
|
|
key,
|
|
current_folder_on_disk,
|
|
folder
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Failed to rename folder for %s: %s -> %s: %s",
|
|
key,
|
|
current_folder_on_disk,
|
|
folder,
|
|
e
|
|
)
|
|
|
|
# Step F: Queue background loading task for episodes, NFO, and images
|
|
try:
|
|
await background_loader.add_series_loading_task(
|
|
key=key,
|
|
folder=folder,
|
|
name=name,
|
|
year=year
|
|
)
|
|
logger.info(
|
|
"Queued background loading for %s (key=%s)",
|
|
name,
|
|
key
|
|
)
|
|
except Exception as e:
|
|
# Background loading queue failure is not critical - series was still added
|
|
logger.warning(
|
|
"Failed to queue background loading for %s: %s",
|
|
key,
|
|
e
|
|
)
|
|
|
|
# Step G: Scan missing episodes immediately
|
|
# Always scan synchronously to ensure episodes are available when
|
|
# get_anime is called right after add_series returns.
|
|
# Background loader handles any additional work and rescan logic.
|
|
try:
|
|
if (
|
|
series_app
|
|
and hasattr(series_app, "serie_scanner")
|
|
):
|
|
missing_episodes = series_app.serie_scanner.scan_single_series(
|
|
key=key,
|
|
folder=folder
|
|
)
|
|
total_missing = sum(
|
|
len(eps) for eps in missing_episodes.values()
|
|
)
|
|
logger.info(
|
|
"Scanned %d missing episodes for %s",
|
|
total_missing,
|
|
key
|
|
)
|
|
|
|
# Persist scan results to database (includes episodes)
|
|
# scan_single_series updates serie_scanner.keyDict with episodeDict
|
|
# sync_single_series_after_scan retrieves from there and saves to DB
|
|
await anime_service.sync_single_series_after_scan(key)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Failed to scan missing episodes for %s: %s",
|
|
key,
|
|
e
|
|
)
|
|
|
|
# Step G: Return immediate response (202 Accepted)
|
|
response = {
|
|
"status": "success",
|
|
"message": f"Series added successfully: {name}. Data will be loaded in background.",
|
|
"key": key,
|
|
"folder": folder,
|
|
"db_id": db_id,
|
|
"loading_status": "pending",
|
|
"loading_progress": {
|
|
"episodes": False,
|
|
"nfo": False,
|
|
"logo": False,
|
|
"images": False
|
|
}
|
|
}
|
|
|
|
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_key}/loading-status")
|
|
async def get_loading_status(
|
|
anime_key: str,
|
|
_auth: dict = Depends(require_auth),
|
|
db: Optional[AsyncSession] = Depends(get_optional_database_session),
|
|
) -> dict:
|
|
"""Get current loading status for a series.
|
|
|
|
Returns the current background loading status including what data
|
|
has been loaded and what is still pending.
|
|
|
|
Args:
|
|
anime_key: Series unique identifier (key)
|
|
_auth: Ensures the caller is authenticated
|
|
db: Optional database session
|
|
|
|
Returns:
|
|
Dict with loading status information:
|
|
- key: Series identifier
|
|
- loading_status: Current status (pending, loading_*, completed, failed)
|
|
- progress: Dict of what data is loaded
|
|
- started_at: When loading started
|
|
- completed_at: When loading completed (if done)
|
|
- message: Human-readable status message
|
|
- error: Error message if failed
|
|
|
|
Raises:
|
|
HTTPException: If series not found or database unavailable
|
|
"""
|
|
if db is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Database not available"
|
|
)
|
|
|
|
try:
|
|
from src.server.database.service import AnimeSeriesService
|
|
|
|
# Get series from database
|
|
series = await AnimeSeriesService.get_by_key(db, anime_key)
|
|
|
|
if not series:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Series not found: {anime_key}"
|
|
)
|
|
|
|
# Build status message
|
|
message = ""
|
|
if series.loading_status == "pending":
|
|
message = "Queued for loading..."
|
|
elif series.loading_status == "loading_episodes":
|
|
message = "Loading episodes..."
|
|
elif series.loading_status == "loading_nfo":
|
|
message = "Generating NFO file..."
|
|
elif series.loading_status == "loading_logo":
|
|
message = "Downloading logo..."
|
|
elif series.loading_status == "loading_images":
|
|
message = "Downloading images..."
|
|
elif series.loading_status == "completed":
|
|
message = "All data loaded successfully"
|
|
elif series.loading_status == "failed":
|
|
message = f"Loading failed: {series.loading_error}"
|
|
else:
|
|
message = "Loading..."
|
|
|
|
return {
|
|
"key": series.key,
|
|
"loading_status": series.loading_status,
|
|
"progress": {
|
|
"episodes": series.episodes_loaded,
|
|
"nfo": series.has_nfo,
|
|
"logo": series.logo_loaded,
|
|
"images": series.images_loaded
|
|
},
|
|
"started_at": series.loading_started_at.isoformat() if series.loading_started_at else None,
|
|
"completed_at": series.loading_completed_at.isoformat() if series.loading_completed_at else None,
|
|
"message": message,
|
|
"error": series.loading_error
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
logger.error("Failed to get loading status: %s", exc, exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to get loading status: {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),
|
|
db: Optional[AsyncSession] = Depends(get_optional_database_session),
|
|
) -> 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 {}
|
|
|
|
# If in-memory episodeDict is empty, try fetching from database directly
|
|
if not episode_dict and db is not None:
|
|
try:
|
|
db_series = await AnimeSeriesService.get_by_key(
|
|
db, anime_id, with_episodes=True
|
|
)
|
|
if db_series:
|
|
episode_dict = db_series.episodeDict or {}
|
|
except Exception as db_exc:
|
|
logger.warning(
|
|
"Failed to fetch episodes from DB for '%s': %s",
|
|
anime_id,
|
|
db_exc,
|
|
)
|
|
|
|
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:
|
|
logger.error(
|
|
"Failed to retrieve series details for '%s': %s",
|
|
anime_id,
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to retrieve series details",
|
|
) from exc
|
|
|
|
|
|
@router.get("/{anime_key}/details", response_model=AnimeDetailsResponse)
|
|
async def get_anime_details(
|
|
anime_key: str,
|
|
_auth: dict = Depends(require_auth),
|
|
db: AsyncSession = Depends(get_database_session),
|
|
) -> AnimeDetailsResponse:
|
|
"""Get detailed information about a specific anime series for the edit modal.
|
|
|
|
Returns comprehensive series metadata including TMDB/TVDB IDs, NFO status,
|
|
and other details needed to pre-fill the edit form.
|
|
|
|
Args:
|
|
anime_key: Series key (primary identifier)
|
|
_auth: Authentication dependency
|
|
db: Database session
|
|
|
|
Returns:
|
|
AnimeDetailsResponse: Full series details for edit modal
|
|
|
|
Raises:
|
|
HTTPException 404: Series not found
|
|
"""
|
|
# Get series from database (authoritative source for IDs and NFO status)
|
|
series = await AnimeSeriesService.get_by_key(db, anime_key)
|
|
if not series:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Series with key '{anime_key}' not found",
|
|
)
|
|
|
|
# Format timestamps
|
|
nfo_created = None
|
|
nfo_updated = None
|
|
if series.nfo_created_at:
|
|
nfo_created = series.nfo_created_at.isoformat()
|
|
if series.nfo_updated_at:
|
|
nfo_updated = series.nfo_updated_at.isoformat()
|
|
|
|
return AnimeDetailsResponse(
|
|
key=series.key,
|
|
name=series.name,
|
|
folder=series.folder,
|
|
year=series.year,
|
|
status=None, # Status not stored in DB, only in NFO/TMDB
|
|
plot=None, # Plot not stored in DB, only in NFO/TMDB
|
|
genres=[],
|
|
studio=[],
|
|
premiered=None,
|
|
rating=None,
|
|
rating_votes=None,
|
|
tmdb_id=series.tmdb_id,
|
|
tvdb_id=series.tvdb_id,
|
|
has_nfo=series.has_nfo,
|
|
nfo_created_at=nfo_created,
|
|
nfo_updated_at=nfo_updated,
|
|
)
|
|
|
|
|
|
@router.get("/{anime_key}/tmdb-search", response_model=List[TMDBSearchResult])
|
|
async def search_tmdb_for_series(
|
|
anime_key: str,
|
|
_auth: dict = Depends(require_auth),
|
|
db: AsyncSession = Depends(get_database_session),
|
|
) -> List[TMDBSearchResult]:
|
|
"""Search TMDB for a series by its name to find matching metadata.
|
|
|
|
Used by the edit modal's "Fetch from TMDB" feature when no TMDB ID
|
|
is set. Searches TMDB using the series name and returns matches.
|
|
|
|
Args:
|
|
anime_key: Series key to look up
|
|
_auth: Authentication dependency
|
|
db: Database session
|
|
|
|
Returns:
|
|
List[TMDBSearchResult]: Matching TMDB results
|
|
|
|
Raises:
|
|
HTTPException 404: Series not found
|
|
HTTPException 400: TMDB not configured
|
|
"""
|
|
from src.server.nfo.tmdb_client import TMDBClient
|
|
|
|
# Get series from database
|
|
series = await AnimeSeriesService.get_by_key(db, anime_key)
|
|
if not series:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Series with key '{anime_key}' not found",
|
|
)
|
|
|
|
# Check if TMDB is configured
|
|
if not settings.tmdb_api_key:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="TMDB API key not configured",
|
|
)
|
|
|
|
# Search TMDB by series name
|
|
tmdb_client = TMDBClient(api_key=settings.tmdb_api_key)
|
|
results = await tmdb_client.search_tv_series(series.name)
|
|
|
|
return [
|
|
TMDBSearchResult(
|
|
tmdb_id=r["id"],
|
|
title=r.get("name", ""),
|
|
year=int(r.get("first_air_date", "0000")[:4]) if r.get("first_air_date") else None,
|
|
overview=r.get("overview"),
|
|
vote_average=r.get("vote_average"),
|
|
)
|
|
for r in results
|
|
]
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
# Anime Settings endpoints (rename of NFO Diagnostic page)
|
|
# ============================================================================
|
|
|
|
|
|
async def _build_anime_settings_payload(
|
|
anime_key: str,
|
|
db: AsyncSession,
|
|
anime_service: AnimeService,
|
|
) -> AnimeSettingsResponse:
|
|
"""Build the AnimeSettingsResponse payload for a given series.
|
|
|
|
Combines data from the in-memory SeriesApp (folder/name/site/year) with
|
|
the authoritative database row (tmdb_id, tvdb_id, has_nfo, nfo_*,
|
|
loading_status) and episode counts.
|
|
|
|
Args:
|
|
anime_key: Series unique key
|
|
db: Database session
|
|
anime_service: AnimeService for in-memory fallback
|
|
|
|
Returns:
|
|
AnimeSettingsResponse with all editable fields populated
|
|
|
|
Raises:
|
|
HTTPException 404: If series not found
|
|
"""
|
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
|
|
|
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
|
|
if not db_series:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Series not found: {anime_key}",
|
|
)
|
|
|
|
# Episode counts (authoritative DB source)
|
|
episodes = await EpisodeService.get_by_series(db, db_series.id)
|
|
episode_count = len(episodes)
|
|
missing_episode_count = sum(
|
|
1 for ep in episodes if not ep.is_downloaded
|
|
)
|
|
|
|
# In-memory fallback for folder/name/site/year (DB is authoritative)
|
|
name = db_series.name
|
|
site = db_series.site
|
|
folder = db_series.folder
|
|
year = db_series.year
|
|
if hasattr(anime_service, "_app") and hasattr(anime_service._app, "list"):
|
|
try:
|
|
for serie in anime_service._app.list.GetList():
|
|
if getattr(serie, "key", None) == anime_key:
|
|
name = getattr(serie, "name", name) or name
|
|
site = getattr(serie, "site", site) or site
|
|
folder = getattr(serie, "folder", folder) or folder
|
|
year = getattr(serie, "year", year) or year
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
nfo_created = (
|
|
db_series.nfo_created_at.isoformat()
|
|
if db_series.nfo_created_at else None
|
|
)
|
|
nfo_updated = (
|
|
db_series.nfo_updated_at.isoformat()
|
|
if db_series.nfo_updated_at else None
|
|
)
|
|
|
|
return AnimeSettingsResponse(
|
|
key=db_series.key,
|
|
name=name,
|
|
site=site,
|
|
folder=folder,
|
|
year=year,
|
|
tmdb_id=db_series.tmdb_id,
|
|
tvdb_id=db_series.tvdb_id,
|
|
has_nfo=bool(db_series.has_nfo),
|
|
nfo_path=db_series.nfo_path,
|
|
nfo_created_at=nfo_created,
|
|
nfo_updated_at=nfo_updated,
|
|
loading_status=db_series.loading_status,
|
|
episode_count=episode_count,
|
|
missing_episode_count=missing_episode_count,
|
|
)
|
|
|
|
|
|
@router.get("/{anime_key}/settings", response_model=AnimeSettingsResponse)
|
|
async def get_anime_settings(
|
|
anime_key: str,
|
|
_auth: dict = Depends(require_auth),
|
|
db: AsyncSession = Depends(get_database_session),
|
|
anime_service: AnimeService = Depends(get_anime_service),
|
|
) -> AnimeSettingsResponse:
|
|
"""Return the full Anime Settings payload for a single series.
|
|
|
|
Powers the per-anime settings page reached from the right-click context
|
|
menu. Returns every field the user can view or edit, plus episode counts.
|
|
|
|
Args:
|
|
anime_key: Series unique key
|
|
_auth: Authentication dependency
|
|
db: Database session
|
|
anime_service: AnimeService dependency
|
|
|
|
Returns:
|
|
AnimeSettingsResponse with key, name, site, folder, year, tmdb_id,
|
|
tvdb_id, NFO status and episode counts.
|
|
|
|
Raises:
|
|
HTTPException 404: If series not found.
|
|
"""
|
|
return await _build_anime_settings_payload(anime_key, db, anime_service)
|
|
|
|
|
|
def _validate_folder_value(folder: str, anime_dir: Optional[str]) -> str:
|
|
"""Validate and sanitize a folder name.
|
|
|
|
Raises HTTPException(422) on empty / invalid folder, 422 on path
|
|
traversal, 422 if folder escapes anime_dir.
|
|
"""
|
|
if not folder or not folder.strip():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Folder cannot be empty",
|
|
)
|
|
try:
|
|
sanitized = sanitize_folder_name(folder)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Invalid folder name: {exc}",
|
|
)
|
|
if anime_dir:
|
|
full_path = os.path.join(anime_dir, sanitized)
|
|
if not is_safe_path(anime_dir, full_path):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Folder path is not safe",
|
|
)
|
|
return sanitized
|
|
|
|
|
|
def _validate_tmdb_id(tmdb_id: Optional[int]) -> None:
|
|
"""Validate TMDB ID is positive and within 10 digits."""
|
|
if tmdb_id is None:
|
|
return
|
|
if tmdb_id <= 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="TMDB ID must be a positive integer",
|
|
)
|
|
if tmdb_id > 9999999999:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="TMDB ID exceeds maximum length (10 digits)",
|
|
)
|
|
|
|
|
|
def _validate_tvdb_id(tvdb_id: Optional[int]) -> None:
|
|
"""Validate TVDB ID is positive and within 10 digits."""
|
|
if tvdb_id is None:
|
|
return
|
|
if tvdb_id <= 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="TVDB ID must be a positive integer",
|
|
)
|
|
if tvdb_id > 9999999999:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="TVDB ID exceeds maximum length (10 digits)",
|
|
)
|
|
|
|
|
|
@router.put("/{anime_key}/settings", response_model=AnimeSettingsResponse)
|
|
async def update_anime_settings(
|
|
anime_key: str,
|
|
request: AnimeSettingsUpdateRequest,
|
|
_auth: dict = Depends(require_auth),
|
|
db: AsyncSession = Depends(get_database_session),
|
|
anime_service: AnimeService = Depends(get_anime_service),
|
|
) -> AnimeSettingsResponse:
|
|
"""Update editable fields for a single anime series.
|
|
|
|
Performs validation on each supplied field, writes the changes to the
|
|
database (and optionally to tvshow.nfo when ``apply_to_nfo`` is true),
|
|
then returns the fresh payload.
|
|
|
|
Args:
|
|
anime_key: Series unique key (path param)
|
|
request: Update payload. All fields optional except as documented
|
|
in AnimeSettingsUpdateRequest.
|
|
_auth: Authentication dependency
|
|
db: Database session
|
|
anime_service: AnimeService for disk rename + NFO regeneration
|
|
|
|
Returns:
|
|
AnimeSettingsResponse: Updated payload reflecting new values.
|
|
|
|
Raises:
|
|
HTTPException 404: Series not found.
|
|
HTTPException 422: Validation failure (empty name, invalid folder,
|
|
non-positive tmdb_id/tvdb_id, oversized id, path traversal).
|
|
"""
|
|
from src.server.database.service import AnimeSeriesService
|
|
|
|
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
|
|
if not db_series:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Series not found: {anime_key}",
|
|
)
|
|
|
|
# Field-level validation
|
|
anime_dir = (
|
|
settings.anime_directory
|
|
if hasattr(settings, "anime_directory") else None
|
|
)
|
|
|
|
update_fields: dict = {}
|
|
|
|
if request.name is not None:
|
|
new_name = request.name.strip()
|
|
if not new_name:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Name cannot be empty",
|
|
)
|
|
if len(new_name) > 500:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Name exceeds 500 characters",
|
|
)
|
|
update_fields["name"] = new_name
|
|
|
|
if request.folder is not None:
|
|
update_fields["folder"] = _validate_folder_value(
|
|
request.folder, anime_dir
|
|
)
|
|
|
|
_validate_tmdb_id(request.tmdb_id)
|
|
if request.tmdb_id is not None:
|
|
update_fields["tmdb_id"] = request.tmdb_id
|
|
|
|
_validate_tvdb_id(request.tvdb_id)
|
|
if request.tvdb_id is not None:
|
|
update_fields["tvdb_id"] = request.tvdb_id
|
|
|
|
if request.site is not None:
|
|
update_fields["site"] = request.site
|
|
|
|
if not update_fields and not request.apply_to_nfo and not request.rename_disk:
|
|
# Nothing to do — return current state
|
|
return await _build_anime_settings_payload(anime_key, db, anime_service)
|
|
|
|
# Apply DB update
|
|
if update_fields:
|
|
await AnimeSeriesService.update(
|
|
db, db_series.id, **update_fields
|
|
)
|
|
await db.commit()
|
|
await db.refresh(db_series)
|
|
logger.info(
|
|
"Updated anime settings for %s: %s",
|
|
anime_key,
|
|
sorted(update_fields.keys()),
|
|
)
|
|
|
|
# Update in-memory SerieList so the UI sees the changes immediately
|
|
if hasattr(anime_service, "_app") and hasattr(anime_service._app, "list"):
|
|
try:
|
|
in_mem = anime_service._app.list.keyDict.get(anime_key)
|
|
if in_mem is not None:
|
|
if "name" in update_fields:
|
|
in_mem.name = update_fields["name"]
|
|
if "folder" in update_fields:
|
|
in_mem.folder = update_fields["folder"]
|
|
if "site" in update_fields:
|
|
in_mem.site = update_fields["site"]
|
|
except Exception as exc:
|
|
logger.debug("Could not update in-memory serie: %s", exc)
|
|
|
|
# Optionally rename the on-disk folder
|
|
if request.rename_disk and "folder" in update_fields:
|
|
try:
|
|
await anime_service.rename_folder_if_needed(
|
|
key=anime_key,
|
|
current_folder=db_series.folder,
|
|
target_folder=update_fields["folder"],
|
|
db=db,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Folder rename failed for %s: %s",
|
|
anime_key,
|
|
exc,
|
|
)
|
|
|
|
# Optionally regenerate tvshow.nfo with the new values
|
|
if request.apply_to_nfo:
|
|
if not db_series.tmdb_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"Cannot regenerate NFO without a TMDB ID. "
|
|
"Set tmdb_id first or use the Repair flow."
|
|
),
|
|
)
|
|
try:
|
|
# Lazy-import to avoid heavy deps when not used
|
|
from src.server.api.nfo import _create_or_update_nfo
|
|
|
|
await _create_or_update_nfo(
|
|
key=anime_key,
|
|
folder=db_series.folder,
|
|
tmdb_id=db_series.tmdb_id,
|
|
anime_service=anime_service,
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
logger.error(
|
|
"NFO regeneration failed for %s: %s",
|
|
anime_key,
|
|
exc,
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"NFO regeneration failed: {exc}",
|
|
)
|
|
|
|
return await _build_anime_settings_payload(anime_key, db, anime_service)
|
|
|
|
|
|
@router.post(
|
|
"/{anime_key}/regenerate-nfo",
|
|
response_model=AnimeSettingsRegenerateNfoResponse,
|
|
)
|
|
async def regenerate_anime_nfo(
|
|
anime_key: str,
|
|
_auth: dict = Depends(require_auth),
|
|
db: AsyncSession = Depends(get_database_session),
|
|
anime_service: AnimeService = Depends(get_anime_service),
|
|
) -> AnimeSettingsRegenerateNfoResponse:
|
|
"""Regenerate tvshow.nfo for a single anime using TMDB.
|
|
|
|
Thin wrapper around the existing NFO repair flow, exposed under
|
|
/api/anime/{key}/ for symmetry with the settings page UI.
|
|
|
|
Args:
|
|
anime_key: Series unique key
|
|
_auth: Authentication dependency
|
|
db: Database session
|
|
anime_service: AnimeService dependency
|
|
|
|
Returns:
|
|
AnimeSettingsRegenerateNfoResponse with success flag, message,
|
|
regenerated nfo_path and the tags that were missing before.
|
|
|
|
Raises:
|
|
HTTPException 404: Series not found.
|
|
HTTPException 400: No TMDB ID configured.
|
|
HTTPException 500: TMDB / NFO regeneration failure.
|
|
"""
|
|
from src.server.database.service import AnimeSeriesService
|
|
|
|
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
|
|
if not db_series:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Series not found: {anime_key}",
|
|
)
|
|
|
|
tmdb_id = db_series.tmdb_id
|
|
if not tmdb_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Series has no TMDB ID — set one before regenerating NFO",
|
|
)
|
|
|
|
try:
|
|
from src.server.api.nfo import _create_or_update_nfo
|
|
|
|
repaired_tags = await _create_or_update_nfo(
|
|
key=anime_key,
|
|
folder=db_series.folder,
|
|
tmdb_id=tmdb_id,
|
|
anime_service=anime_service,
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
logger.error("NFO regeneration failed for %s: %s", anime_key, exc)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"NFO regeneration failed: {exc}",
|
|
)
|
|
|
|
return AnimeSettingsRegenerateNfoResponse(
|
|
success=True,
|
|
message=(
|
|
f"NFO regenerated. {len(repaired_tags)} tags updated."
|
|
if repaired_tags else "NFO already complete."
|
|
),
|
|
nfo_path=db_series.nfo_path,
|
|
repaired_tags=repaired_tags,
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/{anime_key}",
|
|
response_model=DeleteSeriesResult,
|
|
)
|
|
async def delete_anime(
|
|
anime_key: str,
|
|
request: DeleteSeriesRequest,
|
|
_auth: dict = Depends(require_auth),
|
|
anime_service: AnimeService = Depends(get_anime_service),
|
|
) -> DeleteSeriesResult:
|
|
"""Delete an anime series from database, filesystem, or both.
|
|
|
|
Requires typing exactly 'delete' in the confirm_text field to prevent
|
|
accidental deletions. Users can choose to remove the series from the
|
|
database only, delete the folder only, or both.
|
|
|
|
Args:
|
|
anime_key: Series key from URL path (primary identifier)
|
|
request: DeleteSeriesRequest with delete options and confirmation
|
|
_auth: Ensures the caller is authenticated
|
|
anime_service: AnimeService dependency
|
|
|
|
Returns:
|
|
DeleteSeriesResult with outcome details
|
|
|
|
Raises:
|
|
HTTPException(400): confirm_text != "delete" or no options selected
|
|
HTTPException(404): Series not found
|
|
HTTPException(500): Unexpected error
|
|
"""
|
|
# Validate confirm_text
|
|
if request.confirm_text != "delete":
|
|
logger.warning(
|
|
"Delete anime rejected - invalid confirm_text: key=%s",
|
|
anime_key,
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"Confirmation text must be exactly 'delete'. "
|
|
f"Got '{request.confirm_text}'."
|
|
),
|
|
)
|
|
|
|
# Validate at least one option is selected
|
|
if not request.delete_database and not request.delete_folder:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="At least one of delete_database or delete_folder must be True.",
|
|
)
|
|
|
|
try:
|
|
result = await anime_service.delete_series(
|
|
key=anime_key,
|
|
delete_database=request.delete_database,
|
|
delete_folder=request.delete_folder,
|
|
)
|
|
|
|
if not result.success and not result.deleted_from_database:
|
|
# This means series was not found (key="" was passed as name)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=result.message,
|
|
)
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Delete anime failed: key=%s error=%s",
|
|
anime_key, str(exc),
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Delete failed: {exc}",
|
|
) from exc
|