- Add optional fsk field to TVShowNFO model - Implement TMDB content ratings API integration - Add FSK extraction and mapping (FSK 0/6/12/16/18) - Update XML generation to prefer FSK over MPAA - Add nfo_prefer_fsk_rating config setting - Add 31 comprehensive tests for FSK functionality - All 112 NFO tests passing
336 lines
10 KiB
Python
336 lines
10 KiB
Python
"""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
|