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:
@@ -169,6 +169,9 @@ class NFOService:
|
||||
# Get content ratings for FSK
|
||||
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
|
||||
|
||||
# Enrich with English fallback for empty overview/tagline
|
||||
details = await self._enrich_details_with_fallback(details)
|
||||
|
||||
# Convert TMDB data to TVShowNFO model
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
details,
|
||||
@@ -264,6 +267,9 @@ class NFOService:
|
||||
# Get content ratings for FSK
|
||||
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tmdb_id)
|
||||
|
||||
# Enrich with English fallback for empty overview/tagline
|
||||
details = await self._enrich_details_with_fallback(details)
|
||||
|
||||
# Convert TMDB data to TVShowNFO model
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
details,
|
||||
@@ -372,6 +378,61 @@ class NFOService:
|
||||
|
||||
return result
|
||||
|
||||
async def _enrich_details_with_fallback(
|
||||
self,
|
||||
details: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Enrich TMDB details with English fallback for empty fields.
|
||||
|
||||
When requesting details in ``de-DE``, some anime have an empty
|
||||
``overview`` (and potentially other translatable fields). This
|
||||
method detects empty values and fills them from the English
|
||||
(``en-US``) endpoint so that NFO files always contain a ``plot``
|
||||
regardless of whether the German translation exists.
|
||||
|
||||
Args:
|
||||
details: TMDB TV show details (language ``de-DE``).
|
||||
|
||||
Returns:
|
||||
The *same* dict, mutated in-place with English fallbacks
|
||||
where needed.
|
||||
"""
|
||||
overview = details.get("overview") or ""
|
||||
|
||||
if overview:
|
||||
# Overview already populated – nothing to do.
|
||||
return details
|
||||
|
||||
logger.debug(
|
||||
"German overview empty for TMDB ID %s, fetching English fallback",
|
||||
details.get("id"),
|
||||
)
|
||||
|
||||
try:
|
||||
en_details = await self.tmdb_client.get_tv_show_details(
|
||||
details["id"],
|
||||
language="en-US",
|
||||
)
|
||||
|
||||
if en_details.get("overview"):
|
||||
details["overview"] = en_details["overview"]
|
||||
logger.info(
|
||||
"Used English overview fallback for TMDB ID %s",
|
||||
details.get("id"),
|
||||
)
|
||||
|
||||
# Also fill tagline if missing
|
||||
if not details.get("tagline") and en_details.get("tagline"):
|
||||
details["tagline"] = en_details["tagline"]
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(
|
||||
"Failed to fetch English fallback for TMDB ID %s: %s",
|
||||
details.get("id"),
|
||||
exc,
|
||||
)
|
||||
|
||||
return details
|
||||
|
||||
def _find_best_match(
|
||||
self,
|
||||
results: List[Dict[str, Any]],
|
||||
|
||||
@@ -43,8 +43,10 @@ def generate_tvshow_nfo(tvshow: TVShowNFO, pretty_print: bool = True) -> str:
|
||||
_add_element(root, "sorttitle", tvshow.sorttitle)
|
||||
_add_element(root, "year", str(tvshow.year) if tvshow.year else None)
|
||||
|
||||
# Plot and description
|
||||
_add_element(root, "plot", tvshow.plot)
|
||||
# Plot and description – always write <plot> even when empty so that
|
||||
# all NFO files have a consistent set of tags regardless of whether they
|
||||
# were produced by create or update.
|
||||
_add_element(root, "plot", tvshow.plot, always_write=True)
|
||||
_add_element(root, "outline", tvshow.outline)
|
||||
_add_element(root, "tagline", tvshow.tagline)
|
||||
|
||||
@@ -164,13 +166,23 @@ def generate_tvshow_nfo(tvshow: TVShowNFO, pretty_print: bool = True) -> str:
|
||||
return xml_declaration + xml_str
|
||||
|
||||
|
||||
def _add_element(parent: etree.Element, tag: str, text: Optional[str]) -> Optional[etree.Element]:
|
||||
def _add_element(
|
||||
parent: etree.Element,
|
||||
tag: str,
|
||||
text: Optional[str],
|
||||
always_write: bool = False,
|
||||
) -> Optional[etree.Element]:
|
||||
"""Add a child element to parent if text is not None or empty.
|
||||
|
||||
Args:
|
||||
parent: Parent XML element
|
||||
tag: Tag name for child element
|
||||
text: Text content (None or empty strings are skipped)
|
||||
text: Text content (None or empty strings are skipped
|
||||
unless *always_write* is True)
|
||||
always_write: When True the element is created even when
|
||||
*text* is None/empty (the element will have
|
||||
no text content). Useful for tags like
|
||||
``<plot>`` that should always be present.
|
||||
|
||||
Returns:
|
||||
Created element or None if skipped
|
||||
@@ -179,6 +191,8 @@ def _add_element(parent: etree.Element, tag: str, text: Optional[str]) -> Option
|
||||
elem = etree.SubElement(parent, tag)
|
||||
elem.text = text
|
||||
return elem
|
||||
if always_write:
|
||||
return etree.SubElement(parent, tag)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user