Add German FSK rating support for NFO files

- 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
This commit is contained in:
2026-01-17 22:13:34 +01:00
parent fd5e85d5ea
commit 22a41ba93f
10 changed files with 756 additions and 111 deletions

View File

@@ -175,6 +175,10 @@ class TVShowNFO(BaseModel):
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"

View File

@@ -22,7 +22,7 @@ from src.core.entities.nfo_models import (
UniqueID,
)
from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
from src.core.utils.image_downloader import ImageDownloader, ImageDownloadError
from src.core.utils.image_downloader import ImageDownloader
from src.core.utils.nfo_generator import generate_tvshow_nfo
logger = logging.getLogger(__name__)
@@ -122,8 +122,11 @@ class NFOService:
append_to_response="credits,external_ids,images"
)
# Get content ratings for FSK
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
# Convert TMDB data to TVShowNFO model
nfo_model = self._tmdb_to_nfo_model(details)
nfo_model = self._tmdb_to_nfo_model(details, content_ratings)
# Generate XML
nfo_xml = generate_tvshow_nfo(nfo_model)
@@ -209,8 +212,11 @@ class NFOService:
append_to_response="credits,external_ids,images"
)
# Get content ratings for FSK
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tmdb_id)
# Convert TMDB data to TVShowNFO model
nfo_model = self._tmdb_to_nfo_model(details)
nfo_model = self._tmdb_to_nfo_model(details, content_ratings)
# Generate XML
nfo_xml = generate_tvshow_nfo(nfo_model)
@@ -261,11 +267,16 @@ class NFOService:
# Return first result (usually best match)
return results[0]
def _tmdb_to_nfo_model(self, tmdb_data: Dict[str, Any]) -> TVShowNFO:
def _tmdb_to_nfo_model(
self,
tmdb_data: Dict[str, Any],
content_ratings: Optional[Dict[str, Any]] = None
) -> TVShowNFO:
"""Convert TMDB API data to TVShowNFO model.
Args:
tmdb_data: TMDB TV show details
content_ratings: TMDB content ratings data
Returns:
TVShowNFO Pydantic model
@@ -362,6 +373,9 @@ class NFOService:
default=True
))
# Extract FSK rating from content ratings
fsk_rating = self._extract_fsk_rating(content_ratings) if content_ratings else None
# Create NFO model
return TVShowNFO(
title=title,
@@ -375,6 +389,7 @@ class NFOService:
studio=[n["name"] for n in tmdb_data.get("networks", [])],
country=[c["name"] for c in tmdb_data.get("production_countries", [])],
ratings=ratings,
fsk=fsk_rating,
tmdbid=tmdb_data.get("id"),
imdbid=imdb_id,
tvdbid=tvdb_id,
@@ -444,6 +459,50 @@ class NFOService:
logger.info(f"Media download results: {results}")
return results
def _extract_fsk_rating(self, content_ratings: Dict[str, Any]) -> Optional[str]:
"""Extract German FSK rating from TMDB content ratings.
Args:
content_ratings: TMDB content ratings response
Returns:
FSK rating string (e.g., 'FSK 12') or None
"""
if not content_ratings or "results" not in content_ratings:
return None
# Find German rating (iso_3166_1: "DE")
for rating in content_ratings["results"]:
if rating.get("iso_3166_1") == "DE":
rating_value = rating.get("rating", "")
# Map TMDB German ratings to FSK format
fsk_mapping = {
"0": "FSK 0",
"6": "FSK 6",
"12": "FSK 12",
"16": "FSK 16",
"18": "FSK 18",
}
# Try direct mapping
if rating_value in fsk_mapping:
return fsk_mapping[rating_value]
# Try to extract number from rating string (ordered from highest to lowest to avoid partial matches)
for key in ["18", "16", "12", "6", "0"]:
if key in rating_value:
return fsk_mapping[key]
# Return as-is if it already starts with FSK
if rating_value.startswith("FSK"):
return rating_value
logger.debug(f"Unmapped German rating: {rating_value}")
return None
return None
async def close(self):
"""Clean up resources."""
await self.tmdb_client.close()

View File

@@ -191,6 +191,17 @@ class TMDBClient:
return await self._request(f"tv/{tv_id}", params)
async def get_tv_show_content_ratings(self, tv_id: int) -> Dict[str, Any]:
"""Get content ratings for a TV show.
Args:
tv_id: TMDB TV show ID
Returns:
Content ratings by country
"""
return await self._request(f"tv/{tv_id}/content_ratings")
async def get_tv_show_external_ids(self, tv_id: int) -> Dict[str, Any]:
"""Get external IDs (IMDB, TVDB) for a TV show.

View File

@@ -14,6 +14,7 @@ from typing import Optional
from lxml import etree
from src.config.settings import settings
from src.core.entities.nfo_models import TVShowNFO
logger = logging.getLogger(__name__)
@@ -49,7 +50,13 @@ def generate_tvshow_nfo(tvshow: TVShowNFO, pretty_print: bool = True) -> str:
# Technical details
_add_element(root, "runtime", str(tvshow.runtime) if tvshow.runtime else None)
_add_element(root, "mpaa", tvshow.mpaa)
# Content rating - prefer FSK if available and configured
if getattr(settings, 'nfo_prefer_fsk_rating', True) and tvshow.fsk:
_add_element(root, "mpaa", tvshow.fsk)
else:
_add_element(root, "mpaa", tvshow.mpaa)
_add_element(root, "certification", tvshow.certification)
# Status and dates