"""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") description: Optional[str] = Field(None, description="Short series description") total_episodes: Optional[int] = Field(None, ge=0, description="Declared total episode count if known") 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="Short description or snippet") thumbnail: Optional[HttpUrl] = Field(None, description="Thumbnail image URL") score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Search relevance score (0-1)") @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