feat: write all required NFO tags on creation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
220
src/core/utils/nfo_mapper.py
Normal file
220
src/core/utils/nfo_mapper.py
Normal 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"),
|
||||
)
|
||||
231
tests/unit/test_nfo_creation_tags.py
Normal file
231
tests/unit/test_nfo_creation_tags.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user