Files
Aniworld/src/server/models/anime.py
2026-08-16 19:53:38 +02:00

350 lines
13 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
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")