fix: ensure all NFO properties are written on creation
- Add showtitle and namedseason to mapper output - Add multi-language fallback (en-US, ja-JP) for empty overview - Use search result overview as last resort fallback - Add tests for new NFO creation behavior
This commit is contained in:
@@ -229,3 +229,79 @@ def test_generate_nfo_writes_mpaa_when_no_fsk() -> None:
|
||||
mpaa_elem = root.find(".//mpaa")
|
||||
assert mpaa_elem is not None
|
||||
assert mpaa_elem.text == "TV-14"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# showtitle and namedseason — new coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_showtitle(nfo_model: TVShowNFO) -> None:
|
||||
"""showtitle must equal the main title."""
|
||||
assert nfo_model.showtitle == "Test Show"
|
||||
|
||||
|
||||
def test_generate_nfo_writes_showtitle(nfo_model: TVShowNFO) -> None:
|
||||
xml_str = generate_tvshow_nfo(nfo_model)
|
||||
root = _parse_xml(xml_str)
|
||||
elem = root.find(".//showtitle")
|
||||
assert elem is not None
|
||||
assert elem.text == "Test Show"
|
||||
|
||||
|
||||
TMDB_WITH_SEASONS: Dict[str, Any] = {
|
||||
**MINIMAL_TMDB,
|
||||
"seasons": [
|
||||
{"season_number": 0, "name": "Specials"},
|
||||
{"season_number": 1, "name": "Season 1"},
|
||||
{"season_number": 2, "name": "Season 2"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_sets_namedseasons() -> None:
|
||||
model = tmdb_to_nfo_model(
|
||||
TMDB_WITH_SEASONS, CONTENT_RATINGS_DE_US, _fake_get_image_url,
|
||||
)
|
||||
assert len(model.namedseason) == 3
|
||||
assert model.namedseason[0].number == 0
|
||||
assert model.namedseason[0].name == "Specials"
|
||||
assert model.namedseason[1].number == 1
|
||||
|
||||
|
||||
def test_generate_nfo_writes_namedseasons() -> None:
|
||||
model = tmdb_to_nfo_model(
|
||||
TMDB_WITH_SEASONS, CONTENT_RATINGS_DE_US, _fake_get_image_url,
|
||||
)
|
||||
xml_str = generate_tvshow_nfo(model)
|
||||
root = _parse_xml(xml_str)
|
||||
elems = root.findall(".//namedseason")
|
||||
assert len(elems) == 3
|
||||
assert elems[0].get("number") == "0"
|
||||
assert elems[0].text == "Specials"
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_no_seasons_key() -> None:
|
||||
"""No 'seasons' key in TMDB data → namedseason list is empty."""
|
||||
model = tmdb_to_nfo_model(
|
||||
MINIMAL_TMDB, CONTENT_RATINGS_DE_US, _fake_get_image_url,
|
||||
)
|
||||
assert model.namedseason == []
|
||||
|
||||
|
||||
def test_tmdb_to_nfo_model_empty_overview_produces_none_plot() -> None:
|
||||
"""When overview is empty the plot field should be None."""
|
||||
data = {**MINIMAL_TMDB, "overview": ""}
|
||||
model = tmdb_to_nfo_model(
|
||||
data, CONTENT_RATINGS_DE_US, _fake_get_image_url,
|
||||
)
|
||||
assert model.plot is None
|
||||
|
||||
|
||||
def test_generate_nfo_always_writes_plot_tag_even_when_none() -> None:
|
||||
"""<plot> must always appear, even when plot is None."""
|
||||
nfo = TVShowNFO(title="No Plot Show")
|
||||
xml_str = generate_tvshow_nfo(nfo)
|
||||
root = _parse_xml(xml_str)
|
||||
plot_elem = root.find(".//plot")
|
||||
assert plot_elem is not None # tag exists (always_write=True)
|
||||
|
||||
@@ -1385,3 +1385,152 @@ class TestYearExtractionComprehensive:
|
||||
assert clean_name == "Series (12345)"
|
||||
assert year is None
|
||||
|
||||
|
||||
class TestEnrichFallbackLanguages:
|
||||
"""Tests for multi-language fallback and search overview fallback."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_japanese_fallback_when_english_also_empty(
|
||||
self, nfo_service, tmp_path,
|
||||
):
|
||||
"""ja-JP fallback is tried when both de-DE and en-US are empty."""
|
||||
series_folder = tmp_path / "Rare Anime"
|
||||
series_folder.mkdir()
|
||||
|
||||
de_data = {
|
||||
"id": 55555, "name": "Rare Anime",
|
||||
"original_name": "レアアニメ", "first_air_date": "2024-01-01",
|
||||
"overview": "",
|
||||
"vote_average": 7.0, "vote_count": 50,
|
||||
"status": "Continuing", "episode_run_time": [24],
|
||||
"genres": [], "networks": [], "production_countries": [],
|
||||
"poster_path": None, "backdrop_path": None,
|
||||
"external_ids": {}, "credits": {"cast": []},
|
||||
"images": {"logos": []},
|
||||
}
|
||||
en_data = {"id": 55555, "overview": ""}
|
||||
ja_data = {"id": 55555, "overview": "日本語のあらすじ"}
|
||||
|
||||
async def side_effect(tv_id, **kwargs):
|
||||
lang = kwargs.get("language")
|
||||
if lang == "ja-JP":
|
||||
return ja_data
|
||||
if lang == "en-US":
|
||||
return en_data
|
||||
return de_data
|
||||
|
||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
|
||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
||||
patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock):
|
||||
|
||||
mock_search.return_value = {
|
||||
"results": [{"id": 55555, "name": "Rare Anime", "first_air_date": "2024-01-01"}],
|
||||
}
|
||||
mock_details.side_effect = side_effect
|
||||
mock_ratings.return_value = {"results": []}
|
||||
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
"Rare Anime", "Rare Anime",
|
||||
download_poster=False, download_logo=False, download_fanart=False,
|
||||
)
|
||||
|
||||
content = nfo_path.read_text(encoding="utf-8")
|
||||
assert "<plot>日本語のあらすじ</plot>" in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_overview_fallback_when_all_languages_empty(
|
||||
self, nfo_service, tmp_path,
|
||||
):
|
||||
"""Search result overview is used as last resort."""
|
||||
series_folder = tmp_path / "Brand New Anime"
|
||||
series_folder.mkdir()
|
||||
|
||||
empty_data = {
|
||||
"id": 77777, "name": "Brand New Anime",
|
||||
"original_name": "新しいアニメ", "first_air_date": "2025-01-01",
|
||||
"overview": "",
|
||||
"vote_average": 0, "vote_count": 0,
|
||||
"status": "Continuing", "episode_run_time": [],
|
||||
"genres": [], "networks": [], "production_countries": [],
|
||||
"poster_path": None, "backdrop_path": None,
|
||||
"external_ids": {}, "credits": {"cast": []},
|
||||
"images": {"logos": []},
|
||||
}
|
||||
|
||||
async def side_effect(tv_id, **kwargs):
|
||||
return empty_data
|
||||
|
||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
|
||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
||||
patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock):
|
||||
|
||||
# Search result DOES have an overview
|
||||
mock_search.return_value = {
|
||||
"results": [{
|
||||
"id": 77777,
|
||||
"name": "Brand New Anime",
|
||||
"first_air_date": "2025-01-01",
|
||||
"overview": "Search result overview text.",
|
||||
}],
|
||||
}
|
||||
mock_details.side_effect = side_effect
|
||||
mock_ratings.return_value = {"results": []}
|
||||
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
"Brand New Anime", "Brand New Anime",
|
||||
download_poster=False, download_logo=False, download_fanart=False,
|
||||
)
|
||||
|
||||
content = nfo_path.read_text(encoding="utf-8")
|
||||
assert "<plot>Search result overview text.</plot>" in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_japanese_fallback_when_english_succeeds(
|
||||
self, nfo_service, tmp_path,
|
||||
):
|
||||
"""Stop after en-US if it provides the overview."""
|
||||
series_folder = tmp_path / "Test Anime"
|
||||
series_folder.mkdir()
|
||||
|
||||
de_data = {
|
||||
"id": 88888, "name": "Test Anime",
|
||||
"original_name": "テスト", "first_air_date": "2024-01-01",
|
||||
"overview": "",
|
||||
"vote_average": 7.0, "vote_count": 50,
|
||||
"status": "Continuing", "episode_run_time": [24],
|
||||
"genres": [], "networks": [], "production_countries": [],
|
||||
"poster_path": None, "backdrop_path": None,
|
||||
"external_ids": {}, "credits": {"cast": []},
|
||||
"images": {"logos": []},
|
||||
}
|
||||
en_data = {"id": 88888, "overview": "English overview."}
|
||||
|
||||
async def side_effect(tv_id, **kwargs):
|
||||
lang = kwargs.get("language")
|
||||
if lang == "en-US":
|
||||
return en_data
|
||||
return de_data
|
||||
|
||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
|
||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
||||
patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock):
|
||||
|
||||
mock_search.return_value = {
|
||||
"results": [{"id": 88888, "name": "Test Anime", "first_air_date": "2024-01-01"}],
|
||||
}
|
||||
mock_details.side_effect = side_effect
|
||||
mock_ratings.return_value = {"results": []}
|
||||
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
"Test Anime", "Test Anime",
|
||||
download_poster=False, download_logo=False, download_fanart=False,
|
||||
)
|
||||
|
||||
content = nfo_path.read_text(encoding="utf-8")
|
||||
assert "<plot>English overview.</plot>" in content
|
||||
# de-DE + en-US = 2 calls (no ja-JP needed)
|
||||
assert mock_details.call_count == 2
|
||||
|
||||
|
||||
Reference in New Issue
Block a user