- Add NFO API endpoints: diagnostics, repair, validate, needs-repair - Create /settings/nfo page with full NFO management UI - Add NFO status section to edit modal with repair functionality - Add anime details API for edit modal pre-fill data - Fix auth test fixtures in test_nfo_diagnostics_repair.py Implements NFO diagnostics when editing anime series via right-click menu. Users can now check NFO status, see missing tags, and repair NFOs directly from the edit modal or the dedicated NFO settings page.
218 lines
8.7 KiB
Python
218 lines
8.7 KiB
Python
"""Anime Pydantic models for the Aniworld web application.
|
|
|
|
This module defines request/response models used by the anime API
|
|
and services. Models are focused on serialization, validation,
|
|
and OpenAPI documentation.
|
|
|
|
Note on identifiers:
|
|
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
|
|
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime
|
|
from typing import List, Optional
|
|
|
|
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
|
|
|
# Regex pattern for valid series keys (URL-safe, lowercase with hyphens)
|
|
KEY_PATTERN = re.compile(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$')
|
|
|
|
|
|
class EpisodeInfo(BaseModel):
|
|
"""Information about a single episode."""
|
|
|
|
episode_number: int = Field(..., ge=1, description="Episode index (1-based)")
|
|
title: Optional[str] = Field(None, description="Optional episode title")
|
|
aired_at: Optional[datetime] = Field(None, description="Air date/time if known")
|
|
duration_seconds: Optional[int] = Field(None, ge=0, description="Duration in seconds")
|
|
available: bool = Field(True, description="Whether the episode is available for download")
|
|
sources: List[HttpUrl] = Field(default_factory=list, description="List of known streaming/download source URLs")
|
|
|
|
|
|
class MissingEpisodeInfo(BaseModel):
|
|
"""Represents a gap in the episode list for a series."""
|
|
|
|
from_episode: int = Field(..., ge=1, description="Starting missing episode number")
|
|
to_episode: int = Field(..., ge=1, description="Ending missing episode number (inclusive)")
|
|
reason: Optional[str] = Field(None, description="Optional explanation why episodes are missing")
|
|
|
|
@property
|
|
def count(self) -> int:
|
|
"""Number of missing episodes in the range."""
|
|
return max(0, self.to_episode - self.from_episode + 1)
|
|
|
|
|
|
class AnimeSeriesResponse(BaseModel):
|
|
"""Response model for a series with metadata and episodes.
|
|
|
|
Note on identifiers:
|
|
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
|
|
This is the unique key used for all lookups and operations.
|
|
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
|
|
Used only for display and filesystem operations.
|
|
"""
|
|
|
|
key: str = Field(
|
|
...,
|
|
description=(
|
|
"Series key (primary identifier) - provider-assigned URL-safe "
|
|
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
|
|
)
|
|
)
|
|
title: str = Field(..., description="Series title")
|
|
folder: Optional[str] = Field(
|
|
None,
|
|
description=(
|
|
"Series folder name on disk (metadata only) "
|
|
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
|
|
)
|
|
)
|
|
alt_titles: List[str] = Field(default_factory=list, description="Alternative titles")
|
|
episodes: List[EpisodeInfo] = Field(default_factory=list, description="Known episodes information")
|
|
missing_episodes: List[MissingEpisodeInfo] = Field(default_factory=list, description="Detected missing episode ranges")
|
|
thumbnail: Optional[HttpUrl] = Field(None, description="Optional thumbnail image URL")
|
|
|
|
@field_validator('key', mode='before')
|
|
@classmethod
|
|
def normalize_key(cls, v: str) -> str:
|
|
"""Normalize key to lowercase."""
|
|
if isinstance(v, str):
|
|
return v.lower().strip()
|
|
return v
|
|
|
|
|
|
class AnimeMetadataUpdate(BaseModel):
|
|
"""Request model for updating anime metadata (key, tmdb_id, tvdb_id)."""
|
|
|
|
key: Optional[str] = Field(None, description="New series key (URL-safe, lowercase)")
|
|
tmdb_id: Optional[int] = Field(None, ge=1, description="TMDB ID (positive integer)")
|
|
tvdb_id: Optional[int] = Field(None, ge=1, description="TVDB ID (positive integer)")
|
|
|
|
@field_validator('key', mode='before')
|
|
@classmethod
|
|
def validate_key_format(cls, v: Optional[str]) -> Optional[str]:
|
|
"""Validate key is URL-safe lowercase with hyphens only."""
|
|
if v is None:
|
|
return v
|
|
v = v.strip().lower()
|
|
if not v:
|
|
raise ValueError("Key cannot be empty")
|
|
if not KEY_PATTERN.match(v):
|
|
raise ValueError(
|
|
"Key must contain only lowercase letters, numbers, and hyphens. "
|
|
"Cannot start or end with a hyphen."
|
|
)
|
|
return v
|
|
|
|
|
|
class SearchRequest(BaseModel):
|
|
"""Request payload for searching series."""
|
|
|
|
query: str = Field(..., min_length=1, description="Search query string")
|
|
limit: int = Field(10, ge=1, le=100, description="Maximum number of results")
|
|
include_adult: bool = Field(False, description="Include adult content in results")
|
|
|
|
|
|
class SearchResult(BaseModel):
|
|
"""Search result item for a series discovery endpoint.
|
|
|
|
Note on identifiers:
|
|
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
|
|
This is the unique key used for all lookups and operations.
|
|
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
|
|
Used only for display and filesystem operations.
|
|
"""
|
|
|
|
key: str = Field(
|
|
...,
|
|
description=(
|
|
"Series key (primary identifier) - provider-assigned URL-safe "
|
|
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
|
|
)
|
|
)
|
|
title: str = Field(..., description="Series title")
|
|
|
|
|
|
class AnimeDetailsResponse(BaseModel):
|
|
"""Detailed response model for a single anime series with all metadata.
|
|
|
|
Used by the edit modal to pre-fill form fields with existing data.
|
|
|
|
Attributes:
|
|
key: Unique series identifier
|
|
name: Display name
|
|
folder: Filesystem folder name
|
|
year: Release year
|
|
status: Show status (Continuing, Ended)
|
|
plot: Plot description
|
|
genres: List of genres
|
|
studio: List of studios
|
|
premiered: Premiere date
|
|
rating: Rating value (0-10)
|
|
rating_votes: Number of votes
|
|
tmdb_id: TMDB ID
|
|
tvdb_id: TVDB ID
|
|
has_nfo: Whether NFO file exists
|
|
nfo_created_at: NFO creation timestamp
|
|
nfo_updated_at: NFO update timestamp
|
|
"""
|
|
|
|
key: str = Field(..., description="Unique series identifier")
|
|
name: str = Field(..., description="Display name")
|
|
folder: Optional[str] = Field(None, description="Filesystem folder name")
|
|
year: Optional[int] = Field(None, ge=1900, le=2100, description="Release year")
|
|
status: Optional[str] = Field(None, description="Show status (Continuing, Ended)")
|
|
plot: Optional[str] = Field(None, description="Plot description")
|
|
genres: List[str] = Field(default_factory=list, description="List of genres")
|
|
studio: List[str] = Field(default_factory=list, description="List of studios")
|
|
premiered: Optional[str] = Field(None, description="Premiere date (YYYY-MM-DD)")
|
|
rating: Optional[float] = Field(None, ge=0, le=10, description="Rating value (0-10)")
|
|
rating_votes: Optional[int] = Field(None, ge=0, description="Number of votes")
|
|
tmdb_id: Optional[int] = Field(None, description="TMDB ID")
|
|
tvdb_id: Optional[int] = Field(None, description="TVDB ID")
|
|
has_nfo: bool = Field(False, description="Whether NFO file exists")
|
|
nfo_created_at: Optional[str] = Field(None, description="NFO creation timestamp")
|
|
nfo_updated_at: Optional[str] = Field(None, description="NFO update timestamp")
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"key": "attack-on-titan",
|
|
"name": "Attack on Titan",
|
|
"folder": "Attack on Titan (2013)",
|
|
"year": 2013,
|
|
"status": "Ended",
|
|
"plot": "Humans fight against giant humanoid Titans.",
|
|
"genres": ["Animation", "Action", "Drama"],
|
|
"studio": ["Wit Studio", "MAPPA"],
|
|
"premiered": "2013-04-07",
|
|
"rating": 9.0,
|
|
"rating_votes": 500000,
|
|
"tmdb_id": 1429,
|
|
"tvdb_id": 267440,
|
|
"has_nfo": True,
|
|
"nfo_created_at": "2025-01-15T10:30:00Z",
|
|
"nfo_updated_at": "2025-01-15T10:30:00Z",
|
|
}
|
|
}
|
|
|
|
|
|
class TMDBSearchResult(BaseModel):
|
|
"""TMDB search result for auto-lookup functionality.
|
|
|
|
Attributes:
|
|
tmdb_id: TMDB ID of the matched series
|
|
title: Title from TMDB
|
|
year: Release year
|
|
overview: Short description
|
|
vote_average: TMDB rating
|
|
"""
|
|
|
|
tmdb_id: int = Field(..., description="TMDB ID")
|
|
title: str = Field(..., description="Title from TMDB")
|
|
year: Optional[int] = Field(None, description="Release year")
|
|
overview: Optional[str] = Field(None, description="Short description")
|
|
vote_average: Optional[float] = Field(None, description="TMDB rating")
|