132 lines
5.3 KiB
Python
132 lines
5.3 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")
|
|
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
|