feat: add English fallback for empty German TMDB overview in NFO creation

When TMDB returns an empty German (de-DE) overview for anime (e.g.
Basilisk), the NFO plot tag was missing. Now both create and update
paths call _enrich_details_with_fallback() which fetches the English
(en-US) overview as a fallback.

Additionally, the <plot> XML element is always written (even when
empty) via the always_write parameter on _add_element(), ensuring
consistent NFO structure regardless of creation path.

Changes:
- nfo_service.py: add _enrich_details_with_fallback() method, call it
  in create_tvshow_nfo and update_tvshow_nfo
- nfo_generator.py: add always_write param to _add_element(), use it
  for <plot> tag
- test_nfo_service.py: add TestEnrichDetailsWithFallback with 4 tests
This commit is contained in:
2026-02-26 20:48:47 +01:00
parent fc8cdc538d
commit e6d9f9f342
3 changed files with 280 additions and 4 deletions

View File

@@ -524,6 +524,207 @@ class TestCreateTVShowNFO:
mock_ratings.assert_called_once_with(1429)
class TestEnrichDetailsWithFallback:
"""Tests for English fallback when German overview is empty."""
@pytest.mark.asyncio
async def test_create_nfo_uses_english_fallback_for_empty_overview(
self, nfo_service, tmp_path
):
"""When the German overview is empty, create_tvshow_nfo should
fetch the English overview from TMDB and include it as <plot>."""
series_folder = tmp_path / "Basilisk"
series_folder.mkdir()
# German TMDB data with empty overview
de_data = {
"id": 35014, "name": "Basilisk",
"original_name": "甲賀忍法帖", "first_air_date": "2005-04-13",
"overview": "", # <-- empty German overview
"vote_average": 7.2, "vote_count": 200,
"status": "Ended", "episode_run_time": [24],
"genres": [{"id": 16, "name": "Animation"}],
"networks": [{"id": 1, "name": "MBS"}],
"production_countries": [{"name": "Japan"}],
"poster_path": "/poster.jpg", "backdrop_path": "/backdrop.jpg",
"external_ids": {"imdb_id": "tt0464064", "tvdb_id": 79604},
"credits": {"cast": []},
"images": {"logos": []},
}
# English TMDB data with overview
en_data = {
"id": 35014,
"overview": "The year is 1614 and two warring ninja clans collide.",
"tagline": "Blood spills when ninja clans clash.",
}
async def side_effect(tv_id, **kwargs):
if kwargs.get("language") == "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": 35014, "name": "Basilisk", "first_air_date": "2005-04-13"}]
}
mock_details.side_effect = side_effect
mock_ratings.return_value = {"results": []}
nfo_path = await nfo_service.create_tvshow_nfo(
"Basilisk", "Basilisk", year=2005,
download_poster=False, download_logo=False, download_fanart=False,
)
content = nfo_path.read_text(encoding="utf-8")
assert "<plot>The year is 1614" in content
# Details called twice: once for de-DE, once for en-US fallback
assert mock_details.call_count == 2
@pytest.mark.asyncio
async def test_update_nfo_uses_english_fallback_for_empty_overview(
self, nfo_service, tmp_path
):
"""update_tvshow_nfo should also use the English fallback."""
series_folder = tmp_path / "Basilisk"
series_folder.mkdir()
nfo_path = series_folder / "tvshow.nfo"
nfo_path.write_text(
'<?xml version="1.0"?>\n<tvshow><title>Basilisk</title>'
"<tmdbid>35014</tmdbid></tvshow>",
encoding="utf-8",
)
de_data = {
"id": 35014, "name": "Basilisk",
"original_name": "甲賀忍法帖", "first_air_date": "2005-04-13",
"overview": "",
"vote_average": 7.2, "vote_count": 200,
"status": "Ended", "episode_run_time": [24],
"genres": [{"id": 16, "name": "Animation"}],
"networks": [{"id": 1, "name": "MBS"}],
"production_countries": [{"name": "Japan"}],
"poster_path": "/poster.jpg", "backdrop_path": "/backdrop.jpg",
"external_ids": {"imdb_id": "tt0464064", "tvdb_id": 79604},
"credits": {"cast": []},
"images": {"logos": []},
}
en_data = {
"id": 35014,
"overview": "English fallback overview for Basilisk.",
}
async def side_effect(tv_id, **kwargs):
if kwargs.get("language") == "en-US":
return en_data
return de_data
with 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_details.side_effect = side_effect
mock_ratings.return_value = {"results": []}
updated_path = await nfo_service.update_tvshow_nfo(
"Basilisk", download_media=False,
)
content = updated_path.read_text(encoding="utf-8")
assert "<plot>English fallback overview" in content
assert mock_details.call_count == 2
@pytest.mark.asyncio
async def test_no_fallback_when_german_overview_exists(
self, nfo_service, tmp_path
):
"""No English fallback call when German overview is present."""
series_folder = tmp_path / "Attack on Titan"
series_folder.mkdir()
de_data = {
"id": 1429, "name": "Attack on Titan",
"original_name": "進撃の巨人", "first_air_date": "2013-04-07",
"overview": "Vor mehreren hundert Jahren...",
"vote_average": 8.6, "vote_count": 5000,
"status": "Ended", "episode_run_time": [24],
"genres": [], "networks": [], "production_countries": [],
"poster_path": None, "backdrop_path": None,
"external_ids": {}, "credits": {"cast": []},
"images": {"logos": []},
}
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": 1429, "name": "Attack on Titan", "first_air_date": "2013-04-07"}]
}
mock_details.return_value = de_data
mock_ratings.return_value = {"results": []}
nfo_path = await nfo_service.create_tvshow_nfo(
"Attack on Titan", "Attack on Titan", year=2013,
download_poster=False, download_logo=False, download_fanart=False,
)
content = nfo_path.read_text(encoding="utf-8")
assert "<plot>Vor mehreren hundert Jahren...</plot>" in content
# Only one detail call (German), no fallback needed
mock_details.assert_called_once_with(1429, append_to_response="credits,external_ids,images")
@pytest.mark.asyncio
async def test_plot_tag_always_present_even_if_empty(
self, nfo_service, tmp_path
):
"""<plot> tag should always be present, even when overview is missing
from both German and English TMDB data."""
series_folder = tmp_path / "Unknown Show"
series_folder.mkdir()
empty_data = {
"id": 99999, "name": "Unknown Show",
"original_name": "Unknown", "first_air_date": "2020-01-01",
"overview": "",
"vote_average": 0, "vote_count": 0,
"status": "Ended", "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):
# English also empty
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):
mock_search.return_value = {
"results": [{"id": 99999, "name": "Unknown Show", "first_air_date": "2020-01-01"}]
}
mock_details.side_effect = side_effect
mock_ratings.return_value = {"results": []}
nfo_path = await nfo_service.create_tvshow_nfo(
"Unknown Show", "Unknown Show",
download_poster=False, download_logo=False, download_fanart=False,
)
content = nfo_path.read_text(encoding="utf-8")
# <plot/> (self-closing) or <plot></plot> should be present
assert "<plot" in content
class TestNFOServiceEdgeCases:
"""Test edge cases in NFO service."""