diff --git a/src/core/services/nfo_service.py b/src/core/services/nfo_service.py index 3633647..67bb709 100644 --- a/src/core/services/nfo_service.py +++ b/src/core/services/nfo_service.py @@ -160,7 +160,7 @@ class NFOService: logger.info(f"Found match: {tv_show['name']} (ID: {tv_id})") - # Get detailed information + # Get detailed information with multi-language image support details = await self.tmdb_client.get_tv_show_details( tv_id, append_to_response="credits,external_ids,images" @@ -169,8 +169,12 @@ 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) + # Enrich with fallback languages for empty overview/tagline + # Pass search result overview as last resort fallback + search_overview = tv_show.get("overview") or None + details = await self._enrich_details_with_fallback( + details, search_overview=search_overview + ) # Convert TMDB data to TVShowNFO model nfo_model = tmdb_to_nfo_model( @@ -267,9 +271,8 @@ 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) - + # Enrich with fallback languages 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, @@ -381,20 +384,26 @@ class NFOService: async def _enrich_details_with_fallback( self, details: Dict[str, Any], + search_overview: Optional[str] = None, ) -> Dict[str, Any]: - """Enrich TMDB details with English fallback for empty fields. + """Enrich TMDB details with fallback languages 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. + method detects empty values and fills them from alternative + languages (``en-US``, then ``ja-JP``) so that NFO files always + contain a ``plot`` regardless of whether the German translation + exists. As a last resort, the overview from the search result + is used. Args: details: TMDB TV show details (language ``de-DE``). + search_overview: Overview text from the TMDB search result, + used as a final fallback if all language-specific + requests fail or return empty overviews. Returns: - The *same* dict, mutated in-place with English fallbacks + The *same* dict, mutated in-place with fallback values where needed. """ overview = details.get("overview") or "" @@ -403,32 +412,46 @@ class NFOService: # Overview already populated – nothing to do. return details - logger.debug( - "German overview empty for TMDB ID %s, fetching English fallback", - details.get("id"), - ) + tmdb_id = details.get("id") + fallback_languages = ["en-US", "ja-JP"] - try: - en_details = await self.tmdb_client.get_tv_show_details( - details["id"], - language="en-US", + for lang in fallback_languages: + if details.get("overview"): + break + + logger.debug( + "Trying %s fallback for TMDB ID %s", + lang, tmdb_id, ) - if en_details.get("overview"): - details["overview"] = en_details["overview"] - logger.info( - "Used English overview fallback for TMDB ID %s", - details.get("id"), + try: + lang_details = await self.tmdb_client.get_tv_show_details( + tmdb_id, + language=lang, ) - # 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, + if not details.get("overview") and lang_details.get("overview"): + details["overview"] = lang_details["overview"] + logger.info( + "Used %s overview fallback for TMDB ID %s", + lang, tmdb_id, + ) + + # Also fill tagline if missing + if not details.get("tagline") and lang_details.get("tagline"): + details["tagline"] = lang_details["tagline"] + except Exception as exc: # pylint: disable=broad-except + logger.warning( + "Failed to fetch %s fallback for TMDB ID %s: %s", + lang, tmdb_id, exc, + ) + + # Last resort: use search result overview + if not details.get("overview") and search_overview: + details["overview"] = search_overview + logger.info( + "Used search result overview fallback for TMDB ID %s", + tmdb_id, ) return details diff --git a/src/core/utils/nfo_mapper.py b/src/core/utils/nfo_mapper.py index 59cef31..4ff0ed0 100644 --- a/src/core/utils/nfo_mapper.py +++ b/src/core/utils/nfo_mapper.py @@ -14,6 +14,7 @@ from typing import Any, Callable, Dict, List, Optional from src.core.entities.nfo_models import ( ActorInfo, ImageInfo, + NamedSeason, RatingInfo, TVShowNFO, UniqueID, @@ -167,6 +168,17 @@ def tmdb_to_nfo_model( tmdbid=member["id"], )) + # --- Named seasons --- + named_seasons: List[NamedSeason] = [] + for season_info in tmdb_data.get("seasons", []): + season_name = season_info.get("name") + season_number = season_info.get("season_number") + if season_name and season_number is not None: + named_seasons.append(NamedSeason( + number=season_number, + name=season_name, + )) + # --- Unique IDs --- unique_ids: List[UniqueID] = [] if tmdb_data.get("id"): @@ -194,6 +206,7 @@ def tmdb_to_nfo_model( return TVShowNFO( title=title, originaltitle=original_title, + showtitle=title, sorttitle=title, year=year, plot=tmdb_data.get("overview") or None, @@ -215,6 +228,7 @@ def tmdb_to_nfo_model( thumb=thumb_images, fanart=fanart_images, actors=actors, + namedseason=named_seasons, watched=False, dateadded=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), ) diff --git a/tests/unit/test_nfo_creation_tags.py b/tests/unit/test_nfo_creation_tags.py index 41994a0..d64c6ce 100644 --- a/tests/unit/test_nfo_creation_tags.py +++ b/tests/unit/test_nfo_creation_tags.py @@ -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: + """ 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) diff --git a/tests/unit/test_nfo_service.py b/tests/unit/test_nfo_service.py index a0dee8e..20d2733 100644 --- a/tests/unit/test_nfo_service.py +++ b/tests/unit/test_nfo_service.py @@ -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 "日本語のあらすじ" 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 "Search result overview text." 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 "English overview." in content + # de-DE + en-US = 2 calls (no ja-JP needed) + assert mock_details.call_count == 2 +