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

@@ -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.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