"""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 from datetime import datetime from typing import List, Optional from pydantic import BaseModel, Field, HttpUrl, field_validator 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 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") folder: Optional[str] = Field( None, description=( "Series folder name on disk (metadata only) " "(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only." ) ) snippet: Optional[str] = Field( None, description="Search result snippet or description" ) score: Optional[float] = Field( None, description="Search relevance score (0.0 to 1.0)" ) @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 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") class AnimeSettingsResponse(BaseModel): """Response payload for the Anime Settings page. Surfaces every anime_series field that can be viewed or edited by the user. Used by GET /api/anime/{key}/settings and the PUT response. """ key: str = Field(..., description="Series unique key (primary identifier)") name: str = Field(..., description="Series display name") site: str = Field(..., description="Provider site URL") folder: str = Field(..., description="Filesystem folder name") year: Optional[int] = Field(None, description="Release year") 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 tvshow.nfo exists") nfo_path: Optional[str] = Field(None, description="Path to tvshow.nfo file") nfo_created_at: Optional[str] = Field(None, description="ISO timestamp when NFO created") nfo_updated_at: Optional[str] = Field(None, description="ISO timestamp when NFO updated") loading_status: Optional[str] = Field( None, description="Current loading status of the series" ) episode_count: int = Field(0, description="Total number of episodes tracked") missing_episode_count: int = Field(0, description="Number of missing episodes") class AnimeSettingsUpdateRequest(BaseModel): """Request payload for PUT /api/anime/{key}/settings. All fields are optional. Only the fields that are provided will be updated. Field-level validation happens in the API endpoint (e.g. folder sanitization, TMDB ID format). """ name: Optional[str] = Field( None, min_length=1, max_length=500, description="Series display name", ) folder: Optional[str] = Field( None, min_length=1, max_length=1000, description="Filesystem folder name", ) tmdb_id: Optional[int] = Field( None, ge=1, le=9999999999, description="TMDB ID (positive integer, max 10 digits)", ) tvdb_id: Optional[int] = Field( None, ge=1, le=9999999999, description="TVDB ID (positive integer, max 10 digits)", ) site: Optional[str] = Field( None, max_length=500, description="Provider site URL", ) apply_to_nfo: bool = Field( False, description="If true, regenerate tvshow.nfo with the new values", ) rename_disk: bool = Field( False, description="If true and folder changed, rename the folder on disk", ) class AnimeSettingsRegenerateNfoResponse(BaseModel): """Response payload for POST /api/anime/{key}/regenerate-nfo.""" success: bool = Field(..., description="Whether regeneration succeeded") message: str = Field(..., description="Human-readable result message") nfo_path: Optional[str] = Field(None, description="Path to regenerated NFO file") repaired_tags: List[str] = Field( default_factory=list, description="Tags that were missing before regeneration", ) class DeleteSeriesRequest(BaseModel): """Request payload for DELETE /api/anime/{key}. Requires typing exactly 'delete' in confirm_text to prevent accidental deletions. """ delete_database: bool = Field( default=True, description="Whether to remove the series from the database (default: True)" ) delete_folder: bool = Field( default=False, description="Whether to delete the series folder from filesystem (default: False)" ) confirm_text: str = Field( ..., description="Must be exactly 'delete' to confirm the operation" ) class DeleteSeriesResult(BaseModel): """Result of a delete operation on a series. Tracks what was successfully deleted and any errors encountered. """ success: bool = Field(..., description="Whether the operation succeeded") key: str = Field(..., description="Series key that was deleted") name: str = Field(..., description="Series name at time of deletion") deleted_from_database: bool = Field( default=False, description="Whether the series was removed from the database" ) deleted_folder: bool = Field( default=False, description="Whether the folder was deleted from filesystem" ) folder_path: Optional[str] = Field( None, description="Path to the folder that was (or would be) deleted" ) database_error: Optional[str] = Field( None, description="Error message if database deletion failed" ) folder_error: Optional[str] = Field( None, description="Error message if folder deletion failed" ) message: str = Field(..., description="Human-readable outcome message")