feat: write all required NFO tags on creation

This commit is contained in:
2026-02-22 11:07:19 +01:00
parent 228964e928
commit e1abf90c81
7 changed files with 592 additions and 208 deletions

View File

@@ -204,6 +204,15 @@ src/core/
+-- entities/ # Domain entities +-- entities/ # Domain entities
| +-- series.py # Serie class with sanitized_folder property | +-- series.py # Serie class with sanitized_folder property
| +-- SerieList.py # SerieList collection with sanitized folder support | +-- SerieList.py # SerieList collection with sanitized folder support
| +-- nfo_models.py # Pydantic models for tvshow.nfo (TVShowNFO, ActorInfo…)
+-- services/ # Domain services
| +-- nfo_service.py # NFO lifecycle: create / update tvshow.nfo
| +-- tmdb_client.py # Async TMDB API client
+-- utils/ # Utility helpers (no side-effects)
| +-- nfo_generator.py # TVShowNFO → XML serialiser
| +-- nfo_mapper.py # TMDB API dict → TVShowNFO (tmdb_to_nfo_model,
| | # _extract_rating_by_country, _extract_fsk_rating)
| +-- image_downloader.py # TMDB image downloader
+-- providers/ # External provider adapters +-- providers/ # External provider adapters
| +-- base_provider.py # Loader interface | +-- base_provider.py # Loader interface
| +-- provider_factory.py # Provider registry | +-- provider_factory.py # Provider registry

View File

@@ -37,6 +37,30 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
--- ---
## [Unreleased] - 2026-02-22
### Added
- **NFO tag completeness (`nfo_mapper.py`)**: All 17 required NFO tags are now
explicitly populated during creation: `originaltitle`, `sorttitle`, `year`,
`plot`, `outline`, `tagline`, `runtime`, `premiered`, `status`, `imdbid`,
`genre`, `studio`, `country`, `actor`, `watched`, `dateadded`, `mpaa`.
- **`src/core/utils/nfo_mapper.py`**: New module containing
`tmdb_to_nfo_model()`, `_extract_rating_by_country()`, and
`_extract_fsk_rating()`. Extracted from `NFOService` to keep files under
500 lines and isolate pure mapping logic.
- **US MPAA rating**: `_extract_rating_by_country(ratings, "US")` now maps the
US TMDB content rating to the `<mpaa>` NFO tag.
### Changed
- `NFOService._tmdb_to_nfo_model()` and `NFOService._extract_fsk_rating()` moved
to `src/core/utils/nfo_mapper.py` as module-level functions
`tmdb_to_nfo_model()` and `_extract_fsk_rating()`.
- `src/core/services/nfo_service.py` reduced from 640 → 471 lines.
---
## [Unreleased] - 2026-01-18 ## [Unreleased] - 2026-01-18
### Added ### Added

View File

@@ -636,7 +636,44 @@ curl -X POST "http://127.0.0.1:8000/api/scheduler/config" \
--- ---
## 10. Support ## 10. Tag Reference
The table below lists every XML tag written to `tvshow.nfo` and its source in
the TMDB API response. All tags are written whenever the NFO is created or
updated via `create_tvshow_nfo()` / `update_tvshow_nfo()`.
| NFO tag | TMDB source field | Required |
| --------------- | ---------------------------------------------- | -------- |
| `title` | `name` | ✅ |
| `originaltitle` | `original_name` | ✅ |
| `showtitle` | `name` (same as `title`) | ✅ |
| `sorttitle` | `name` (same as `title`) | ✅ |
| `year` | First 4 chars of `first_air_date` | ✅ |
| `plot` | `overview` | ✅ |
| `outline` | `overview` (same as `plot`) | ✅ |
| `tagline` | `tagline` | optional |
| `runtime` | `episode_run_time[0]` | ✅ |
| `premiered` | `first_air_date` | ✅ |
| `status` | `status` | ✅ |
| `mpaa` | US content rating from `content_ratings.results`| optional |
| `fsk` | DE content rating (written as `mpaa` when preferred) | optional |
| `imdbid` | `external_ids.imdb_id` | ✅ |
| `tmdbid` | `id` | ✅ |
| `tvdbid` | `external_ids.tvdb_id` | optional |
| `genre` | `genres[].name` (one element per genre) | ✅ |
| `studio` | `networks[].name` (one element per network) | ✅ |
| `country` | `origin_country[]` or `production_countries[].name` | ✅ |
| `actor` | `credits.cast[]` (top 10, with name/role/thumb)| ✅ |
| `watched` | Always `false` on creation | ✅ |
| `dateadded` | System clock at creation time (`YYYY-MM-DD HH:MM:SS`) | ✅ |
The mapping logic lives in `src/core/utils/nfo_mapper.py` (`tmdb_to_nfo_model`).
The XML serialisation lives in `src/core/utils/nfo_generator.py`
(`generate_tvshow_nfo`).
---
## 11. Support
### Getting Help ### Getting Help

View File

@@ -15,16 +15,10 @@ from typing import Any, Dict, List, Optional, Tuple
from lxml import etree from lxml import etree
from src.core.entities.nfo_models import (
ActorInfo,
ImageInfo,
RatingInfo,
TVShowNFO,
UniqueID,
)
from src.core.services.tmdb_client import TMDBAPIError, TMDBClient from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
from src.core.utils.image_downloader import ImageDownloader from src.core.utils.image_downloader import ImageDownloader
from src.core.utils.nfo_generator import generate_tvshow_nfo from src.core.utils.nfo_generator import generate_tvshow_nfo
from src.core.utils.nfo_mapper import tmdb_to_nfo_model
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -176,7 +170,12 @@ class NFOService:
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id) content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
# Convert TMDB data to TVShowNFO model # Convert TMDB data to TVShowNFO model
nfo_model = self._tmdb_to_nfo_model(details, content_ratings) nfo_model = tmdb_to_nfo_model(
details,
content_ratings,
self.tmdb_client.get_image_url,
self.image_size,
)
# Generate XML # Generate XML
nfo_xml = generate_tvshow_nfo(nfo_model) nfo_xml = generate_tvshow_nfo(nfo_model)
@@ -266,7 +265,12 @@ class NFOService:
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tmdb_id) content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tmdb_id)
# Convert TMDB data to TVShowNFO model # Convert TMDB data to TVShowNFO model
nfo_model = self._tmdb_to_nfo_model(details, content_ratings) nfo_model = tmdb_to_nfo_model(
details,
content_ratings,
self.tmdb_client.get_image_url,
self.image_size,
)
# Generate XML # Generate XML
nfo_xml = generate_tvshow_nfo(nfo_model) nfo_xml = generate_tvshow_nfo(nfo_model)
@@ -398,137 +402,7 @@ class NFOService:
# Return first result (usually best match) # Return first result (usually best match)
return results[0] return results[0]
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
"""
# Extract basic info
title = tmdb_data["name"]
original_title = tmdb_data.get("original_name", title)
year = None
if tmdb_data.get("first_air_date"):
year = int(tmdb_data["first_air_date"][:4])
# Extract ratings
ratings = []
if tmdb_data.get("vote_average"):
ratings.append(RatingInfo(
name="themoviedb",
value=float(tmdb_data["vote_average"]),
votes=tmdb_data.get("vote_count", 0),
max_rating=10,
default=True
))
# Extract external IDs
external_ids = tmdb_data.get("external_ids", {})
imdb_id = external_ids.get("imdb_id")
tvdb_id = external_ids.get("tvdb_id")
# Extract images
thumb_images = []
fanart_images = []
# Poster
if tmdb_data.get("poster_path"):
poster_url = self.tmdb_client.get_image_url(
tmdb_data["poster_path"],
self.image_size
)
thumb_images.append(ImageInfo(url=poster_url, aspect="poster"))
# Backdrop/Fanart
if tmdb_data.get("backdrop_path"):
fanart_url = self.tmdb_client.get_image_url(
tmdb_data["backdrop_path"],
self.image_size
)
fanart_images.append(ImageInfo(url=fanart_url))
# Logo from images if available
images_data = tmdb_data.get("images", {})
logos = images_data.get("logos", [])
if logos:
logo_url = self.tmdb_client.get_image_url(
logos[0]["file_path"],
self.image_size
)
thumb_images.append(ImageInfo(url=logo_url, aspect="clearlogo"))
# Extract cast
actors = []
credits = tmdb_data.get("credits", {})
for cast_member in credits.get("cast", [])[:10]: # Top 10 actors
actor_thumb = None
if cast_member.get("profile_path"):
actor_thumb = self.tmdb_client.get_image_url(
cast_member["profile_path"],
"h632"
)
actors.append(ActorInfo(
name=cast_member["name"],
role=cast_member.get("character"),
thumb=actor_thumb,
tmdbid=cast_member["id"]
))
# Create unique IDs
unique_ids = []
if tmdb_data.get("id"):
unique_ids.append(UniqueID(
type="tmdb",
value=str(tmdb_data["id"]),
default=False
))
if imdb_id:
unique_ids.append(UniqueID(
type="imdb",
value=imdb_id,
default=False
))
if tvdb_id:
unique_ids.append(UniqueID(
type="tvdb",
value=str(tvdb_id),
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,
originaltitle=original_title,
year=year,
plot=tmdb_data.get("overview"),
runtime=tmdb_data.get("episode_run_time", [None])[0] if tmdb_data.get("episode_run_time") else None,
premiered=tmdb_data.get("first_air_date"),
status=tmdb_data.get("status"),
genre=[g["name"] for g in tmdb_data.get("genres", [])],
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,
uniqueid=unique_ids,
thumb=thumb_images,
fanart=fanart_images,
actors=actors
)
async def _download_media_files( async def _download_media_files(
self, self,
@@ -590,49 +464,7 @@ class NFOService:
logger.info(f"Media download results: {results}") logger.info(f"Media download results: {results}")
return 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): async def close(self):
"""Clean up resources.""" """Clean up resources."""

View File

@@ -0,0 +1,220 @@
"""TMDB to NFO model mapper.
This module converts TMDB API data to TVShowNFO Pydantic models,
keeping the mapping logic separate from the service orchestration.
Example:
>>> model = tmdb_to_nfo_model(tmdb_data, content_ratings, get_image_url, "original")
"""
import logging
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from src.core.entities.nfo_models import (
ActorInfo,
ImageInfo,
RatingInfo,
TVShowNFO,
UniqueID,
)
logger = logging.getLogger(__name__)
def _extract_rating_by_country(
content_ratings: Dict[str, Any],
country_code: str,
) -> Optional[str]:
"""Extract content rating for a specific country from TMDB content ratings.
Args:
content_ratings: TMDB content ratings response dict with "results" list.
country_code: ISO 3166-1 alpha-2 country code (e.g., "DE", "US").
Returns:
Raw rating string for the requested country, or None if not found.
Example:
>>> _extract_rating_by_country({"results": [{"iso_3166_1": "US", "rating": "TV-14"}]}, "US")
'TV-14'
"""
if not content_ratings or "results" not in content_ratings:
return None
for rating in content_ratings["results"]:
if rating.get("iso_3166_1") == country_code:
return rating.get("rating") or None
return None
def _extract_fsk_rating(content_ratings: Dict[str, Any]) -> Optional[str]:
"""Extract German FSK rating from TMDB content ratings.
Delegates to :func:`_extract_rating_by_country` and then normalises the
raw TMDB string into the 'FSK XX' format expected by Kodi/Jellyfin.
Args:
content_ratings: TMDB content ratings response.
Returns:
Formatted FSK string (e.g., 'FSK 12') or None.
"""
raw = _extract_rating_by_country(content_ratings, "DE")
if raw is None:
return None
fsk_mapping: Dict[str, str] = {
"0": "FSK 0",
"6": "FSK 6",
"12": "FSK 12",
"16": "FSK 16",
"18": "FSK 18",
}
if raw in fsk_mapping:
return fsk_mapping[raw]
# Try to extract numeric part (ordered high→low to avoid partial matches)
for key in ["18", "16", "12", "6", "0"]:
if key in raw:
return fsk_mapping[key]
if raw.startswith("FSK"):
return raw
logger.debug("Unmapped German rating: %s", raw)
return None
def tmdb_to_nfo_model(
tmdb_data: Dict[str, Any],
content_ratings: Optional[Dict[str, Any]],
get_image_url: Callable[[str, str], str],
image_size: str = "original",
) -> TVShowNFO:
"""Convert TMDB API data to a fully-populated TVShowNFO model.
All required NFO tags are explicitly set in this function so that newly
created files are complete without a subsequent repair pass.
Args:
tmdb_data: TMDB TV show details (with credits, external_ids, images
appended via ``append_to_response``).
content_ratings: TMDB content ratings response, or None.
get_image_url: Callable ``(path, size) -> url`` for TMDB images.
image_size: TMDB image size parameter (e.g., ``"original"``, ``"w500"``).
Returns:
TVShowNFO Pydantic model with all available fields populated.
"""
title: str = tmdb_data["name"]
original_title: str = tmdb_data.get("original_name") or title
# --- Year and dates ---
first_air_date: Optional[str] = tmdb_data.get("first_air_date") or None
year: Optional[int] = int(first_air_date[:4]) if first_air_date else None
# --- Ratings ---
ratings: List[RatingInfo] = []
if tmdb_data.get("vote_average"):
ratings.append(RatingInfo(
name="themoviedb",
value=float(tmdb_data["vote_average"]),
votes=tmdb_data.get("vote_count", 0),
max_rating=10,
default=True,
))
# --- External IDs ---
external_ids: Dict[str, Any] = tmdb_data.get("external_ids", {})
imdb_id: Optional[str] = external_ids.get("imdb_id")
tvdb_id: Optional[int] = external_ids.get("tvdb_id")
# --- Images ---
thumb_images: List[ImageInfo] = []
fanart_images: List[ImageInfo] = []
if tmdb_data.get("poster_path"):
thumb_images.append(ImageInfo(
url=get_image_url(tmdb_data["poster_path"], image_size),
aspect="poster",
))
if tmdb_data.get("backdrop_path"):
fanart_images.append(ImageInfo(
url=get_image_url(tmdb_data["backdrop_path"], image_size),
))
logos: List[Dict[str, Any]] = tmdb_data.get("images", {}).get("logos", [])
if logos:
thumb_images.append(ImageInfo(
url=get_image_url(logos[0]["file_path"], image_size),
aspect="clearlogo",
))
# --- Cast (top 10) ---
actors: List[ActorInfo] = []
for member in tmdb_data.get("credits", {}).get("cast", [])[:10]:
actor_thumb: Optional[str] = None
if member.get("profile_path"):
actor_thumb = get_image_url(member["profile_path"], "h632")
actors.append(ActorInfo(
name=member["name"],
role=member.get("character"),
thumb=actor_thumb,
tmdbid=member["id"],
))
# --- Unique IDs ---
unique_ids: List[UniqueID] = []
if tmdb_data.get("id"):
unique_ids.append(UniqueID(type="tmdb", value=str(tmdb_data["id"]), default=False))
if imdb_id:
unique_ids.append(UniqueID(type="imdb", value=imdb_id, default=False))
if tvdb_id:
unique_ids.append(UniqueID(type="tvdb", value=str(tvdb_id), default=True))
# --- Content ratings ---
fsk_rating: Optional[str] = _extract_fsk_rating(content_ratings) if content_ratings else None
mpaa_rating: Optional[str] = (
_extract_rating_by_country(content_ratings, "US") if content_ratings else None
)
# --- Country: prefer origin_country codes; fall back to production_countries names ---
country_list: List[str] = list(tmdb_data.get("origin_country", []))
if not country_list:
country_list = [c["name"] for c in tmdb_data.get("production_countries", [])]
# --- Runtime ---
runtime_list: List[int] = tmdb_data.get("episode_run_time", [])
runtime: Optional[int] = runtime_list[0] if runtime_list else None
return TVShowNFO(
title=title,
originaltitle=original_title,
sorttitle=title,
year=year,
plot=tmdb_data.get("overview") or None,
outline=tmdb_data.get("overview") or None,
tagline=tmdb_data.get("tagline") or None,
runtime=runtime,
premiered=first_air_date,
status=tmdb_data.get("status"),
genre=[g["name"] for g in tmdb_data.get("genres", [])],
studio=[n["name"] for n in tmdb_data.get("networks", [])],
country=country_list,
ratings=ratings,
fsk=fsk_rating,
mpaa=mpaa_rating,
tmdbid=tmdb_data.get("id"),
imdbid=imdb_id,
tvdbid=tvdb_id,
uniqueid=unique_ids,
thumb=thumb_images,
fanart=fanart_images,
actors=actors,
watched=False,
dateadded=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)

View File

@@ -0,0 +1,231 @@
"""Unit tests for NFO tag creation — Task 0.
Verifies that ``tmdb_to_nfo_model`` populates every required NFO tag and
that ``generate_tvshow_nfo`` writes all of them to the XML output.
"""
from datetime import datetime
from typing import Any, Dict, Optional
from unittest.mock import patch
import pytest
from lxml import etree
from src.core.entities.nfo_models import TVShowNFO
from src.core.utils.nfo_generator import generate_tvshow_nfo
from src.core.utils.nfo_mapper import _extract_rating_by_country, tmdb_to_nfo_model
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
def _fake_get_image_url(path: str, size: str) -> str:
"""Minimal stand-in for TMDBClient.get_image_url used in tests."""
return f"https://image.tmdb.org/t/p/{size}{path}"
MINIMAL_TMDB: Dict[str, Any] = {
"id": 12345,
"name": "Test Show",
"original_name": "テストショー",
"overview": "A great overview.",
"tagline": "The best tagline.",
"first_air_date": "2023-04-01",
"status": "Continuing",
"episode_run_time": [24],
"vote_average": 8.5,
"vote_count": 200,
"genres": [{"id": 1, "name": "Animation"}, {"id": 2, "name": "Action"}],
"networks": [{"id": 10, "name": "AT-X"}],
"origin_country": ["JP"],
"production_countries": [],
"external_ids": {"imdb_id": "tt1234567", "tvdb_id": 99999},
"poster_path": "/poster.jpg",
"backdrop_path": "/backdrop.jpg",
"images": {"logos": [{"file_path": "/logo.png"}]},
"credits": {
"cast": [
{
"id": 1,
"name": "Actor One",
"character": "Hero",
"profile_path": "/actor1.jpg",
}
]
},
}
CONTENT_RATINGS_DE_US: Dict[str, Any] = {
"results": [
{"iso_3166_1": "DE", "rating": "12"},
{"iso_3166_1": "US", "rating": "TV-PG"},
]
}
@pytest.fixture()
def nfo_model() -> TVShowNFO:
"""Return a fully-populated TVShowNFO from MINIMAL_TMDB data."""
return tmdb_to_nfo_model(MINIMAL_TMDB, CONTENT_RATINGS_DE_US, _fake_get_image_url)
# ---------------------------------------------------------------------------
# tmdb_to_nfo_model — field mapping tests
# ---------------------------------------------------------------------------
def test_tmdb_to_nfo_model_sets_originaltitle(nfo_model: TVShowNFO) -> None:
assert nfo_model.originaltitle == "テストショー"
def test_tmdb_to_nfo_model_sets_year_from_first_air_date(nfo_model: TVShowNFO) -> None:
assert nfo_model.year == 2023
def test_tmdb_to_nfo_model_sets_plot_from_overview(nfo_model: TVShowNFO) -> None:
assert nfo_model.plot == "A great overview."
def test_tmdb_to_nfo_model_sets_runtime(nfo_model: TVShowNFO) -> None:
assert nfo_model.runtime == 24
def test_tmdb_to_nfo_model_sets_premiered(nfo_model: TVShowNFO) -> None:
assert nfo_model.premiered == "2023-04-01"
def test_tmdb_to_nfo_model_sets_status(nfo_model: TVShowNFO) -> None:
assert nfo_model.status == "Continuing"
def test_tmdb_to_nfo_model_sets_imdbid(nfo_model: TVShowNFO) -> None:
assert nfo_model.imdbid == "tt1234567"
def test_tmdb_to_nfo_model_sets_genres(nfo_model: TVShowNFO) -> None:
assert "Animation" in nfo_model.genre
assert "Action" in nfo_model.genre
def test_tmdb_to_nfo_model_sets_studios_from_networks(nfo_model: TVShowNFO) -> None:
assert "AT-X" in nfo_model.studio
def test_tmdb_to_nfo_model_sets_country(nfo_model: TVShowNFO) -> None:
assert "JP" in nfo_model.country
def test_tmdb_to_nfo_model_sets_actors(nfo_model: TVShowNFO) -> None:
assert len(nfo_model.actors) == 1
assert nfo_model.actors[0].name == "Actor One"
assert nfo_model.actors[0].role == "Hero"
def test_tmdb_to_nfo_model_sets_watched_false(nfo_model: TVShowNFO) -> None:
assert nfo_model.watched is False
def test_tmdb_to_nfo_model_sets_tagline(nfo_model: TVShowNFO) -> None:
assert nfo_model.tagline == "The best tagline."
def test_tmdb_to_nfo_model_sets_outline_from_overview(nfo_model: TVShowNFO) -> None:
assert nfo_model.outline == "A great overview."
def test_tmdb_to_nfo_model_sets_sorttitle_from_name(nfo_model: TVShowNFO) -> None:
assert nfo_model.sorttitle == "Test Show"
def test_tmdb_to_nfo_model_sets_dateadded(nfo_model: TVShowNFO) -> None:
assert nfo_model.dateadded is not None
# Must match YYYY-MM-DD HH:MM:SS
datetime.strptime(nfo_model.dateadded, "%Y-%m-%d %H:%M:%S")
def test_tmdb_to_nfo_model_sets_mpaa_from_content_ratings(nfo_model: TVShowNFO) -> None:
assert nfo_model.mpaa == "TV-PG"
# ---------------------------------------------------------------------------
# _extract_rating_by_country
# ---------------------------------------------------------------------------
def test_extract_rating_by_country_returns_us_rating() -> None:
ratings = {"results": [{"iso_3166_1": "US", "rating": "TV-14"}]}
assert _extract_rating_by_country(ratings, "US") == "TV-14"
def test_extract_rating_by_country_returns_none_when_no_match() -> None:
ratings = {"results": [{"iso_3166_1": "DE", "rating": "12"}]}
assert _extract_rating_by_country(ratings, "US") is None
def test_extract_rating_by_country_handles_empty_results() -> None:
assert _extract_rating_by_country({"results": []}, "US") is None
assert _extract_rating_by_country({}, "US") is None
assert _extract_rating_by_country(None, "US") is None # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# generate_tvshow_nfo — XML output tests
# ---------------------------------------------------------------------------
def _parse_xml(xml_str: str) -> etree._Element:
return etree.fromstring(xml_str.encode("utf-8"))
def test_generate_nfo_includes_all_required_tags(nfo_model: TVShowNFO) -> None:
xml_str = generate_tvshow_nfo(nfo_model)
root = _parse_xml(xml_str)
required = [
"title", "originaltitle", "year", "plot", "runtime",
"premiered", "status", "imdbid", "genre", "studio",
"country", "actor", "watched", "tagline", "outline",
"sorttitle", "dateadded",
]
for tag in required:
elements = root.findall(f".//{tag}")
assert elements, f"Missing required tag: <{tag}>"
# At least one element must have non-empty text
assert any(e.text for e in elements), f"Tag <{tag}> is empty"
def test_generate_nfo_writes_watched_false(nfo_model: TVShowNFO) -> None:
xml_str = generate_tvshow_nfo(nfo_model)
root = _parse_xml(xml_str)
watched = root.find(".//watched")
assert watched is not None
assert watched.text == "false"
def test_generate_nfo_minimal_model_does_not_crash() -> None:
minimal = TVShowNFO(title="Minimal Show")
xml_str = generate_tvshow_nfo(minimal)
assert "<title>Minimal Show</title>" in xml_str
def test_generate_nfo_writes_fsk_over_mpaa_when_prefer_fsk() -> None:
nfo = TVShowNFO(title="Test", fsk="FSK 16", mpaa="TV-MA")
with patch("src.core.utils.nfo_generator.settings") as mock_settings:
mock_settings.nfo_prefer_fsk_rating = True
xml_str = generate_tvshow_nfo(nfo)
root = _parse_xml(xml_str)
mpaa_elem = root.find(".//mpaa")
assert mpaa_elem is not None
assert mpaa_elem.text == "FSK 16"
def test_generate_nfo_writes_mpaa_when_no_fsk() -> None:
nfo = TVShowNFO(title="Test", fsk=None, mpaa="TV-14")
with patch("src.core.utils.nfo_generator.settings") as mock_settings:
mock_settings.nfo_prefer_fsk_rating = True
xml_str = generate_tvshow_nfo(nfo)
root = _parse_xml(xml_str)
mpaa_elem = root.find(".//mpaa")
assert mpaa_elem is not None
assert mpaa_elem.text == "TV-14"

View File

@@ -7,6 +7,7 @@ import pytest
from src.core.services.nfo_service import NFOService from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBAPIError from src.core.services.tmdb_client import TMDBAPIError
from src.core.utils.nfo_mapper import _extract_fsk_rating, tmdb_to_nfo_model
@pytest.fixture @pytest.fixture
@@ -86,22 +87,22 @@ class TestFSKRatingExtraction:
def test_extract_fsk_rating_de(self, nfo_service, mock_content_ratings_de): def test_extract_fsk_rating_de(self, nfo_service, mock_content_ratings_de):
"""Test extraction of German FSK rating.""" """Test extraction of German FSK rating."""
fsk = nfo_service._extract_fsk_rating(mock_content_ratings_de) fsk = _extract_fsk_rating(mock_content_ratings_de)
assert fsk == "FSK 16" assert fsk == "FSK 16"
def test_extract_fsk_rating_no_de(self, nfo_service, mock_content_ratings_no_de): def test_extract_fsk_rating_no_de(self, nfo_service, mock_content_ratings_no_de):
"""Test extraction when no German rating available.""" """Test extraction when no German rating available."""
fsk = nfo_service._extract_fsk_rating(mock_content_ratings_no_de) fsk = _extract_fsk_rating(mock_content_ratings_no_de)
assert fsk is None assert fsk is None
def test_extract_fsk_rating_empty(self, nfo_service): def test_extract_fsk_rating_empty(self, nfo_service):
"""Test extraction with empty content ratings.""" """Test extraction with empty content ratings."""
fsk = nfo_service._extract_fsk_rating({}) fsk = _extract_fsk_rating({})
assert fsk is None assert fsk is None
def test_extract_fsk_rating_none(self, nfo_service): def test_extract_fsk_rating_none(self, nfo_service):
"""Test extraction with None input.""" """Test extraction with None input."""
fsk = nfo_service._extract_fsk_rating(None) fsk = _extract_fsk_rating(None)
assert fsk is None assert fsk is None
def test_extract_fsk_all_values(self, nfo_service): def test_extract_fsk_all_values(self, nfo_service):
@@ -118,7 +119,7 @@ class TestFSKRatingExtraction:
content_ratings = { content_ratings = {
"results": [{"iso_3166_1": "DE", "rating": rating_value}] "results": [{"iso_3166_1": "DE", "rating": rating_value}]
} }
fsk = nfo_service._extract_fsk_rating(content_ratings) fsk = _extract_fsk_rating(content_ratings)
assert fsk == expected_fsk assert fsk == expected_fsk
def test_extract_fsk_already_formatted(self, nfo_service): def test_extract_fsk_already_formatted(self, nfo_service):
@@ -126,7 +127,7 @@ class TestFSKRatingExtraction:
content_ratings = { content_ratings = {
"results": [{"iso_3166_1": "DE", "rating": "FSK 12"}] "results": [{"iso_3166_1": "DE", "rating": "FSK 12"}]
} }
fsk = nfo_service._extract_fsk_rating(content_ratings) fsk = _extract_fsk_rating(content_ratings)
assert fsk == "FSK 12" assert fsk == "FSK 12"
def test_extract_fsk_partial_match(self, nfo_service): def test_extract_fsk_partial_match(self, nfo_service):
@@ -134,7 +135,7 @@ class TestFSKRatingExtraction:
content_ratings = { content_ratings = {
"results": [{"iso_3166_1": "DE", "rating": "Ab 16 Jahren"}] "results": [{"iso_3166_1": "DE", "rating": "Ab 16 Jahren"}]
} }
fsk = nfo_service._extract_fsk_rating(content_ratings) fsk = _extract_fsk_rating(content_ratings)
assert fsk == "FSK 16" assert fsk == "FSK 16"
def test_extract_fsk_unmapped_value(self, nfo_service): def test_extract_fsk_unmapped_value(self, nfo_service):
@@ -142,7 +143,7 @@ class TestFSKRatingExtraction:
content_ratings = { content_ratings = {
"results": [{"iso_3166_1": "DE", "rating": "Unknown"}] "results": [{"iso_3166_1": "DE", "rating": "Unknown"}]
} }
fsk = nfo_service._extract_fsk_rating(content_ratings) fsk = _extract_fsk_rating(content_ratings)
assert fsk is None assert fsk is None
@@ -213,12 +214,15 @@ class TestYearExtraction:
class TestTMDBToNFOModel: class TestTMDBToNFOModel:
"""Test conversion of TMDB data to NFO model.""" """Test conversion of TMDB data to NFO model."""
@patch.object(NFOService, '_extract_fsk_rating') @patch('src.core.utils.nfo_mapper._extract_fsk_rating')
def test_tmdb_to_nfo_with_fsk(self, mock_extract_fsk, nfo_service, mock_tmdb_data, mock_content_ratings_de): def test_tmdb_to_nfo_with_fsk(self, mock_extract_fsk, nfo_service, mock_tmdb_data, mock_content_ratings_de):
"""Test conversion includes FSK rating.""" """Test conversion includes FSK rating."""
mock_extract_fsk.return_value = "FSK 16" mock_extract_fsk.return_value = "FSK 16"
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data, mock_content_ratings_de) nfo_model = tmdb_to_nfo_model(
mock_tmdb_data, mock_content_ratings_de,
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
)
assert nfo_model.title == "Attack on Titan" assert nfo_model.title == "Attack on Titan"
assert nfo_model.fsk == "FSK 16" assert nfo_model.fsk == "FSK 16"
@@ -227,7 +231,10 @@ class TestTMDBToNFOModel:
def test_tmdb_to_nfo_without_content_ratings(self, nfo_service, mock_tmdb_data): def test_tmdb_to_nfo_without_content_ratings(self, nfo_service, mock_tmdb_data):
"""Test conversion without content ratings.""" """Test conversion without content ratings."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data, None) nfo_model = tmdb_to_nfo_model(
mock_tmdb_data, None,
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
)
assert nfo_model.title == "Attack on Titan" assert nfo_model.title == "Attack on Titan"
assert nfo_model.fsk is None assert nfo_model.fsk is None
@@ -235,7 +242,10 @@ class TestTMDBToNFOModel:
def test_tmdb_to_nfo_basic_fields(self, nfo_service, mock_tmdb_data): def test_tmdb_to_nfo_basic_fields(self, nfo_service, mock_tmdb_data):
"""Test that all basic fields are correctly mapped.""" """Test that all basic fields are correctly mapped."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) nfo_model = tmdb_to_nfo_model(
mock_tmdb_data, None,
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
)
assert nfo_model.title == "Attack on Titan" assert nfo_model.title == "Attack on Titan"
assert nfo_model.originaltitle == "進撃の巨人" assert nfo_model.originaltitle == "進撃の巨人"
@@ -247,7 +257,10 @@ class TestTMDBToNFOModel:
def test_tmdb_to_nfo_ids(self, nfo_service, mock_tmdb_data): def test_tmdb_to_nfo_ids(self, nfo_service, mock_tmdb_data):
"""Test that all IDs are correctly mapped.""" """Test that all IDs are correctly mapped."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) nfo_model = tmdb_to_nfo_model(
mock_tmdb_data, None,
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
)
assert nfo_model.tmdbid == 1429 assert nfo_model.tmdbid == 1429
assert nfo_model.imdbid == "tt2560140" assert nfo_model.imdbid == "tt2560140"
@@ -256,7 +269,10 @@ class TestTMDBToNFOModel:
def test_tmdb_to_nfo_genres_studios(self, nfo_service, mock_tmdb_data): def test_tmdb_to_nfo_genres_studios(self, nfo_service, mock_tmdb_data):
"""Test that genres and studios are correctly mapped.""" """Test that genres and studios are correctly mapped."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) nfo_model = tmdb_to_nfo_model(
mock_tmdb_data, None,
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
)
assert "Animation" in nfo_model.genre assert "Animation" in nfo_model.genre
assert "Sci-Fi & Fantasy" in nfo_model.genre assert "Sci-Fi & Fantasy" in nfo_model.genre
@@ -265,7 +281,10 @@ class TestTMDBToNFOModel:
def test_tmdb_to_nfo_ratings(self, nfo_service, mock_tmdb_data): def test_tmdb_to_nfo_ratings(self, nfo_service, mock_tmdb_data):
"""Test that ratings are correctly mapped.""" """Test that ratings are correctly mapped."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) nfo_model = tmdb_to_nfo_model(
mock_tmdb_data, None,
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
)
assert len(nfo_model.ratings) == 1 assert len(nfo_model.ratings) == 1
assert nfo_model.ratings[0].name == "themoviedb" assert nfo_model.ratings[0].name == "themoviedb"
@@ -274,7 +293,10 @@ class TestTMDBToNFOModel:
def test_tmdb_to_nfo_cast(self, nfo_service, mock_tmdb_data): def test_tmdb_to_nfo_cast(self, nfo_service, mock_tmdb_data):
"""Test that cast is correctly mapped.""" """Test that cast is correctly mapped."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) nfo_model = tmdb_to_nfo_model(
mock_tmdb_data, None,
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
)
assert len(nfo_model.actors) == 1 assert len(nfo_model.actors) == 1
assert nfo_model.actors[0].name == "Yuki Kaji" assert nfo_model.actors[0].name == "Yuki Kaji"
@@ -969,7 +991,10 @@ class TestTMDBToNFOModelEdgeCases:
"original_name": "Original" "original_name": "Original"
} }
nfo_model = nfo_service._tmdb_to_nfo_model(minimal_data) nfo_model = tmdb_to_nfo_model(
minimal_data, None,
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
)
assert nfo_model.title == "Series" assert nfo_model.title == "Series"
assert nfo_model.originaltitle == "Original" assert nfo_model.originaltitle == "Original"
@@ -978,7 +1003,10 @@ class TestTMDBToNFOModelEdgeCases:
def test_tmdb_to_nfo_with_all_cast(self, nfo_service, mock_tmdb_data): def test_tmdb_to_nfo_with_all_cast(self, nfo_service, mock_tmdb_data):
"""Test conversion includes cast members.""" """Test conversion includes cast members."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) nfo_model = tmdb_to_nfo_model(
mock_tmdb_data, None,
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
)
assert len(nfo_model.actors) >= 1 assert len(nfo_model.actors) >= 1
assert nfo_model.actors[0].name == "Yuki Kaji" assert nfo_model.actors[0].name == "Yuki Kaji"
@@ -986,7 +1014,10 @@ class TestTMDBToNFOModelEdgeCases:
def test_tmdb_to_nfo_multiple_genres(self, nfo_service, mock_tmdb_data): def test_tmdb_to_nfo_multiple_genres(self, nfo_service, mock_tmdb_data):
"""Test conversion with multiple genres.""" """Test conversion with multiple genres."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data) nfo_model = tmdb_to_nfo_model(
mock_tmdb_data, None,
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
)
assert "Animation" in nfo_model.genre assert "Animation" in nfo_model.genre
assert "Sci-Fi & Fantasy" in nfo_model.genre assert "Sci-Fi & Fantasy" in nfo_model.genre
@@ -1001,7 +1032,7 @@ class TestExtractFSKRatingEdgeCases:
"results": [{"iso_3166_1": "DE", "rating": "Ab 16 Jahren"}] "results": [{"iso_3166_1": "DE", "rating": "Ab 16 Jahren"}]
} }
fsk = nfo_service._extract_fsk_rating(content_ratings) fsk = _extract_fsk_rating(content_ratings)
assert fsk == "FSK 16" assert fsk == "FSK 16"
def test_extract_fsk_multiple_numbers(self, nfo_service): def test_extract_fsk_multiple_numbers(self, nfo_service):
@@ -1010,7 +1041,7 @@ class TestExtractFSKRatingEdgeCases:
"results": [{"iso_3166_1": "DE", "rating": "Rating 6 or 12"}] "results": [{"iso_3166_1": "DE", "rating": "Rating 6 or 12"}]
} }
fsk = nfo_service._extract_fsk_rating(content_ratings) fsk = _extract_fsk_rating(content_ratings)
# Should find 12 first in the search order # Should find 12 first in the search order
assert fsk == "FSK 12" assert fsk == "FSK 12"
@@ -1018,17 +1049,17 @@ class TestExtractFSKRatingEdgeCases:
"""Test extraction with empty results list.""" """Test extraction with empty results list."""
content_ratings = {"results": []} content_ratings = {"results": []}
fsk = nfo_service._extract_fsk_rating(content_ratings) fsk = _extract_fsk_rating(content_ratings)
assert fsk is None assert fsk is None
def test_extract_fsk_none_input(self, nfo_service): def test_extract_fsk_none_input(self, nfo_service):
"""Test extraction with None input.""" """Test extraction with None input."""
fsk = nfo_service._extract_fsk_rating(None) fsk = _extract_fsk_rating(None)
assert fsk is None assert fsk is None
def test_extract_fsk_missing_results_key(self, nfo_service): def test_extract_fsk_missing_results_key(self, nfo_service):
"""Test extraction when results key is missing.""" """Test extraction when results key is missing."""
fsk = nfo_service._extract_fsk_rating({}) fsk = _extract_fsk_rating({})
assert fsk is None assert fsk is None