"""Pydantic models for NFO metadata based on Kodi/XBMC standard. This module provides data models for tvshow.nfo files that are compatible with media center applications like Kodi, Plex, and Jellyfin. Example: >>> nfo = TVShowNFO( ... title="Attack on Titan", ... year=2013, ... tmdbid=1429 ... ) >>> nfo.premiered = "2013-04-07" """ from datetime import datetime from typing import List, Optional from pydantic import BaseModel, Field, HttpUrl, field_validator class RatingInfo(BaseModel): """Rating information from various sources. Attributes: name: Source of the rating (e.g., 'themoviedb', 'imdb') value: Rating value (typically 0-10) votes: Number of votes max_rating: Maximum possible rating (default: 10) default: Whether this is the default rating to display """ name: str = Field(..., description="Rating source name") value: float = Field(..., ge=0, description="Rating value") votes: Optional[int] = Field(None, ge=0, description="Number of votes") max_rating: int = Field(10, ge=1, description="Maximum rating value") default: bool = Field(False, description="Is this the default rating") @field_validator('value') @classmethod def validate_value(cls, v: float, info) -> float: """Ensure rating value doesn't exceed max_rating.""" # Note: max_rating is not available yet during validation, # so we use a reasonable default check if v > 10: raise ValueError("Rating value cannot exceed 10") return v class ActorInfo(BaseModel): """Actor/cast member information. Attributes: name: Actor's name role: Character name/role thumb: URL to actor's photo profile: URL to actor's profile page tmdbid: TMDB ID for the actor """ name: str = Field(..., description="Actor's name") role: Optional[str] = Field(None, description="Character role") thumb: Optional[HttpUrl] = Field(None, description="Actor photo URL") profile: Optional[HttpUrl] = Field(None, description="Actor profile URL") tmdbid: Optional[int] = Field(None, description="TMDB actor ID") class ImageInfo(BaseModel): """Image information for posters, fanart, and logos. Attributes: url: URL to the image aspect: Image aspect/type (e.g., 'poster', 'clearlogo', 'logo') season: Season number for season-specific images type: Image type (e.g., 'season') """ url: HttpUrl = Field(..., description="Image URL") aspect: Optional[str] = Field( None, description="Image aspect (poster, clearlogo, logo)" ) season: Optional[int] = Field(None, ge=-1, description="Season number") type: Optional[str] = Field(None, description="Image type") class NamedSeason(BaseModel): """Named season information. Attributes: number: Season number name: Season name/title """ number: int = Field(..., ge=0, description="Season number") name: str = Field(..., description="Season name") class UniqueID(BaseModel): """Unique identifier from various sources. Attributes: type: ID source type (tmdb, imdb, tvdb) value: The ID value default: Whether this is the default ID """ type: str = Field(..., description="ID type (tmdb, imdb, tvdb)") value: str = Field(..., description="ID value") default: bool = Field(False, description="Is default ID") class TVShowNFO(BaseModel): """Main tvshow.nfo structure following Kodi/XBMC standard. This model represents the complete metadata for a TV show that can be serialized to XML for use with media center applications. Attributes: title: Main title of the show originaltitle: Original title (e.g., in original language) showtitle: Show title (often same as title) sorttitle: Title used for sorting year: Release year plot: Full plot description outline: Short plot summary tagline: Show tagline/slogan runtime: Episode runtime in minutes mpaa: Content rating (e.g., TV-14, TV-MA) certification: Additional certification info premiered: Premiere date (YYYY-MM-DD format) status: Show status (e.g., 'Continuing', 'Ended') studio: List of production studios genre: List of genres country: List of countries tag: List of tags/keywords ratings: List of ratings from various sources userrating: User's personal rating watched: Whether the show has been watched playcount: Number of times watched tmdbid: TMDB ID imdbid: IMDB ID tvdbid: TVDB ID uniqueid: List of unique IDs thumb: List of thumbnail/poster images fanart: List of fanart/backdrop images actors: List of cast members namedseason: List of named seasons trailer: Trailer URL dateadded: Date when added to library """ # Required fields title: str = Field(..., description="Show title", min_length=1) # Basic information (optional) originaltitle: Optional[str] = Field(None, description="Original title") showtitle: Optional[str] = Field(None, description="Show title") sorttitle: Optional[str] = Field(None, description="Sort title") year: Optional[int] = Field( None, ge=1900, le=2100, description="Release year" ) # Plot and description plot: Optional[str] = Field(None, description="Full plot description") outline: Optional[str] = Field(None, description="Short plot summary") tagline: Optional[str] = Field(None, description="Show tagline") # Technical details runtime: Optional[int] = Field( None, ge=0, description="Episode runtime in minutes" ) mpaa: Optional[str] = Field(None, description="Content rating") fsk: Optional[str] = Field( None, description="German FSK rating (e.g., 'FSK 12', 'FSK 16')" ) certification: Optional[str] = Field( None, description="Certification info" ) # Status and dates premiered: Optional[str] = Field( None, description="Premiere date (YYYY-MM-DD)" ) status: Optional[str] = Field(None, description="Show status") dateadded: Optional[str] = Field( None, description="Date added to library" ) # Multi-value fields studio: List[str] = Field( default_factory=list, description="Production studios" ) genre: List[str] = Field( default_factory=list, description="Genres" ) country: List[str] = Field( default_factory=list, description="Countries" ) tag: List[str] = Field( default_factory=list, description="Tags/keywords" ) # IDs tmdbid: Optional[int] = Field(None, description="TMDB ID") imdbid: Optional[str] = Field(None, description="IMDB ID") tvdbid: Optional[int] = Field(None, description="TVDB ID") uniqueid: List[UniqueID] = Field( default_factory=list, description="Unique IDs" ) # Ratings and viewing info ratings: List[RatingInfo] = Field( default_factory=list, description="Ratings" ) userrating: Optional[float] = Field( None, ge=0, le=10, description="User rating" ) watched: bool = Field(False, description="Watched status") playcount: Optional[int] = Field( None, ge=0, description="Play count" ) # Media thumb: List[ImageInfo] = Field( default_factory=list, description="Thumbnail images" ) fanart: List[ImageInfo] = Field( default_factory=list, description="Fanart images" ) # Cast and crew actors: List[ActorInfo] = Field( default_factory=list, description="Cast members" ) # Seasons namedseason: List[NamedSeason] = Field( default_factory=list, description="Named seasons" ) # Additional trailer: Optional[HttpUrl] = Field(None, description="Trailer URL") @field_validator('premiered') @classmethod def validate_premiered_date(cls, v: Optional[str]) -> Optional[str]: """Validate premiered date format (YYYY-MM-DD).""" if v is None: return v # Check format strictly: YYYY-MM-DD if len(v) != 10 or v[4] != '-' or v[7] != '-': raise ValueError( "Premiered date must be in YYYY-MM-DD format" ) try: datetime.strptime(v, '%Y-%m-%d') except ValueError as exc: raise ValueError( "Premiered date must be in YYYY-MM-DD format" ) from exc return v @field_validator('dateadded') @classmethod def validate_dateadded(cls, v: Optional[str]) -> Optional[str]: """Validate dateadded format (YYYY-MM-DD HH:MM:SS).""" if v is None: return v # Check format strictly: YYYY-MM-DD HH:MM:SS if len(v) != 19 or v[4] != '-' or v[7] != '-' or v[10] != ' ' or v[13] != ':' or v[16] != ':': raise ValueError( "Dateadded must be in YYYY-MM-DD HH:MM:SS format" ) try: datetime.strptime(v, '%Y-%m-%d %H:%M:%S') except ValueError as exc: raise ValueError( "Dateadded must be in YYYY-MM-DD HH:MM:SS format" ) from exc return v @field_validator('imdbid') @classmethod def validate_imdbid(cls, v: Optional[str]) -> Optional[str]: """Validate IMDB ID format (should start with 'tt').""" if v is None: return v if not v.startswith('tt'): raise ValueError("IMDB ID must start with 'tt'") if not v[2:].isdigit(): raise ValueError("IMDB ID must be 'tt' followed by digits") return v def model_post_init(self, __context) -> None: """Set default values after initialization.""" # Set showtitle to title if not provided if self.showtitle is None: self.showtitle = self.title # Set originaltitle to title if not provided if self.originaltitle is None: self.originaltitle = self.title