diff --git a/src/core/services/nfo_service.py b/src/core/services/nfo_service.py index 6c239a6..3633647 100644 --- a/src/core/services/nfo_service.py +++ b/src/core/services/nfo_service.py @@ -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]], diff --git a/src/core/utils/nfo_generator.py b/src/core/utils/nfo_generator.py index 87fb1b2..f683d01 100644 --- a/src/core/utils/nfo_generator.py +++ b/src/core/utils/nfo_generator.py @@ -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 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 + ```` 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 diff --git a/tests/unit/test_nfo_service.py b/tests/unit/test_nfo_service.py index 09ec855..a0dee8e 100644 --- a/tests/unit/test_nfo_service.py +++ b/tests/unit/test_nfo_service.py @@ -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 .""" + 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 "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( + '\nBasilisk' + "35014", + 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 "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 "Vor mehreren hundert Jahren..." 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 + ): + """ 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") + # (self-closing) or should be present + assert "