From e1abf90c81cb32080e792ffd8cd7d47d0ddb36f3 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 22 Feb 2026 11:07:19 +0100 Subject: [PATCH] feat: write all required NFO tags on creation --- docs/ARCHITECTURE.md | 9 ++ docs/CHANGELOG.md | 24 +++ docs/NFO_GUIDE.md | 39 ++++- src/core/services/nfo_service.py | 198 ++--------------------- src/core/utils/nfo_mapper.py | 220 +++++++++++++++++++++++++ tests/unit/test_nfo_creation_tags.py | 231 +++++++++++++++++++++++++++ tests/unit/test_nfo_service.py | 79 ++++++--- 7 files changed, 592 insertions(+), 208 deletions(-) create mode 100644 src/core/utils/nfo_mapper.py create mode 100644 tests/unit/test_nfo_creation_tags.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ba69b7c..c1548c8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -204,6 +204,15 @@ src/core/ +-- entities/ # Domain entities | +-- series.py # Serie class with sanitized_folder property | +-- 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 | +-- base_provider.py # Loader interface | +-- provider_factory.py # Provider registry diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5355f6c..567ee6e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 `` 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 ### Added diff --git a/docs/NFO_GUIDE.md b/docs/NFO_GUIDE.md index 3257ba1..3d23829 100644 --- a/docs/NFO_GUIDE.md +++ b/docs/NFO_GUIDE.md @@ -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 diff --git a/src/core/services/nfo_service.py b/src/core/services/nfo_service.py index dd07924..6c239a6 100644 --- a/src/core/services/nfo_service.py +++ b/src/core/services/nfo_service.py @@ -15,16 +15,10 @@ from typing import Any, Dict, List, Optional, Tuple 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.utils.image_downloader import ImageDownloader 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__) @@ -176,7 +170,12 @@ class NFOService: 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, content_ratings) + nfo_model = tmdb_to_nfo_model( + details, + content_ratings, + self.tmdb_client.get_image_url, + self.image_size, + ) # Generate XML 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) # 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 nfo_xml = generate_tvshow_nfo(nfo_model) @@ -398,137 +402,7 @@ class NFOService: # Return first result (usually best match) 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( self, @@ -590,49 +464,7 @@ 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.""" diff --git a/src/core/utils/nfo_mapper.py b/src/core/utils/nfo_mapper.py new file mode 100644 index 0000000..59cef31 --- /dev/null +++ b/src/core/utils/nfo_mapper.py @@ -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"), + ) diff --git a/tests/unit/test_nfo_creation_tags.py b/tests/unit/test_nfo_creation_tags.py new file mode 100644 index 0000000..41994a0 --- /dev/null +++ b/tests/unit/test_nfo_creation_tags.py @@ -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 "Minimal Show" 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" diff --git a/tests/unit/test_nfo_service.py b/tests/unit/test_nfo_service.py index bc70d1b..09ec855 100644 --- a/tests/unit/test_nfo_service.py +++ b/tests/unit/test_nfo_service.py @@ -7,6 +7,7 @@ import pytest from src.core.services.nfo_service import NFOService from src.core.services.tmdb_client import TMDBAPIError +from src.core.utils.nfo_mapper import _extract_fsk_rating, tmdb_to_nfo_model @pytest.fixture @@ -86,22 +87,22 @@ class TestFSKRatingExtraction: def test_extract_fsk_rating_de(self, nfo_service, mock_content_ratings_de): """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" def test_extract_fsk_rating_no_de(self, nfo_service, mock_content_ratings_no_de): """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 def test_extract_fsk_rating_empty(self, nfo_service): """Test extraction with empty content ratings.""" - fsk = nfo_service._extract_fsk_rating({}) + fsk = _extract_fsk_rating({}) assert fsk is None def test_extract_fsk_rating_none(self, nfo_service): """Test extraction with None input.""" - fsk = nfo_service._extract_fsk_rating(None) + fsk = _extract_fsk_rating(None) assert fsk is None def test_extract_fsk_all_values(self, nfo_service): @@ -118,7 +119,7 @@ class TestFSKRatingExtraction: content_ratings = { "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 def test_extract_fsk_already_formatted(self, nfo_service): @@ -126,7 +127,7 @@ class TestFSKRatingExtraction: content_ratings = { "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" def test_extract_fsk_partial_match(self, nfo_service): @@ -134,7 +135,7 @@ class TestFSKRatingExtraction: content_ratings = { "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" def test_extract_fsk_unmapped_value(self, nfo_service): @@ -142,7 +143,7 @@ class TestFSKRatingExtraction: content_ratings = { "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 @@ -213,12 +214,15 @@ class TestYearExtraction: class TestTMDBToNFOModel: """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): """Test conversion includes FSK rating.""" 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.fsk == "FSK 16" @@ -227,7 +231,10 @@ class TestTMDBToNFOModel: def test_tmdb_to_nfo_without_content_ratings(self, nfo_service, mock_tmdb_data): """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.fsk is None @@ -235,7 +242,10 @@ class TestTMDBToNFOModel: def test_tmdb_to_nfo_basic_fields(self, nfo_service, mock_tmdb_data): """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.originaltitle == "進撃の巨人" @@ -247,7 +257,10 @@ class TestTMDBToNFOModel: def test_tmdb_to_nfo_ids(self, nfo_service, mock_tmdb_data): """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.imdbid == "tt2560140" @@ -256,7 +269,10 @@ class TestTMDBToNFOModel: def test_tmdb_to_nfo_genres_studios(self, nfo_service, mock_tmdb_data): """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 "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): """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 nfo_model.ratings[0].name == "themoviedb" @@ -274,7 +293,10 @@ class TestTMDBToNFOModel: def test_tmdb_to_nfo_cast(self, nfo_service, mock_tmdb_data): """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 nfo_model.actors[0].name == "Yuki Kaji" @@ -969,7 +991,10 @@ class TestTMDBToNFOModelEdgeCases: "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.originaltitle == "Original" @@ -978,7 +1003,10 @@ class TestTMDBToNFOModelEdgeCases: def test_tmdb_to_nfo_with_all_cast(self, nfo_service, mock_tmdb_data): """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 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): """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 "Sci-Fi & Fantasy" in nfo_model.genre @@ -1001,7 +1032,7 @@ class TestExtractFSKRatingEdgeCases: "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" 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"}] } - fsk = nfo_service._extract_fsk_rating(content_ratings) + fsk = _extract_fsk_rating(content_ratings) # Should find 12 first in the search order assert fsk == "FSK 12" @@ -1018,17 +1049,17 @@ class TestExtractFSKRatingEdgeCases: """Test extraction with empty results list.""" content_ratings = {"results": []} - fsk = nfo_service._extract_fsk_rating(content_ratings) + fsk = _extract_fsk_rating(content_ratings) assert fsk is None def test_extract_fsk_none_input(self, nfo_service): """Test extraction with None input.""" - fsk = nfo_service._extract_fsk_rating(None) + fsk = _extract_fsk_rating(None) assert fsk is None def test_extract_fsk_missing_results_key(self, nfo_service): """Test extraction when results key is missing.""" - fsk = nfo_service._extract_fsk_rating({}) + fsk = _extract_fsk_rating({}) assert fsk is None