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