diff --git a/mnamer/providers.py b/mnamer/providers.py index 7a11900..2a5d8e7 100644 --- a/mnamer/providers.py +++ b/mnamer/providers.py @@ -364,7 +364,6 @@ def _search_series( series_data = tvdb_search_series( self.token, series, language=language, cache=self.cache ) - for series_id in [str(entry["id"]) for entry in series_data["data"][:5]]: try: for data in self._search_id(series_id, season, episode, language): @@ -456,9 +455,15 @@ def _lookup_with_tmaze_id_and_season_and_episode( series_data = tvmaze_show(id_tvmaze, cache=self.cache) episode_data = tvmaze_episode_by_number(id_tvmaze, season, episode) id_tvdb = self._opt_str(series_data["externals"].get("thetvdb")) - yield self._transform_meta( - id_tvmaze, id_tvdb, series_data, episode_data, language - ) + for name in self._candidate_names(series_data, language): + yield self._transform_meta( + id_tvmaze, + id_tvdb, + series_data, + episode_data, + language, + name_override=name, + ) def _lookup_with_id_and_date( self, @@ -481,9 +486,15 @@ def _lookup_with_id_and_date( ) # re-fetch with AKAs embedded episode_data = tvmaze_episodes_by_date(query_id_tvmaze, air_date) for episode_entry in episode_data: - yield self._transform_meta( - query_id_tvmaze, query_id_tvdb, series_data, episode_entry, language - ) + for name in self._candidate_names(series_data, language): + yield self._transform_meta( + query_id_tvmaze, + query_id_tvdb, + series_data, + episode_entry, + language, + name_override=name, + ) def _lookup_with_id( self, @@ -507,6 +518,7 @@ def _lookup_with_id( ) # re-fetch with AKAs embedded episode_data = tvmaze_show_episodes_list(query_id_tvmaze) for episode_entry in episode_data: + # Filter using primary name first, then yield all candidates meta = self._transform_meta( query_id_tvmaze, query_id_tvdb, series_data, episode_entry, language ) @@ -514,7 +526,15 @@ def _lookup_with_id( continue if episode is not None and episode != meta.episode: continue - yield meta + for name in self._candidate_names(series_data, language): + yield self._transform_meta( + query_id_tvmaze, + query_id_tvdb, + series_data, + episode_entry, + language, + name_override=name, + ) def _search_with_season_and_episode( self, @@ -535,6 +555,7 @@ def _search_with_season_and_episode( episode_entry = tvmaze_episode_by_number(id_tvmaze, season, episode) except MnamerNotFoundException: continue + # Filter using primary name first, then yield all candidates meta = self._transform_meta( id_tvmaze, None, series_entry, episode_entry, language ) @@ -542,7 +563,15 @@ def _search_with_season_and_episode( continue if episode is not None and episode != meta.episode: continue - yield meta + for name in self._candidate_names(series_entry, language): + yield self._transform_meta( + id_tvmaze, + None, + series_entry, + episode_entry, + language, + name_override=name, + ) def _search( self, @@ -562,6 +591,7 @@ def _search( episode_data = tvmaze_show_episodes_list(id_tvmaze) for episode_entry in episode_data: id_tvdb = self._opt_str(series_entry["externals"].get("thetvdb")) + # Filter using primary name first, then yield all candidates meta = self._transform_meta( id_tvmaze, id_tvdb, series_entry, episode_entry, language ) @@ -569,7 +599,15 @@ def _search( continue if episode is not None and episode != meta.episode: continue - yield meta + for name in self._candidate_names(series_entry, language): + yield self._transform_meta( + id_tvmaze, + id_tvdb, + series_entry, + episode_entry, + language, + name_override=name, + ) @staticmethod def _transform_meta( @@ -578,6 +616,7 @@ def _transform_meta( series_entry: TvMazeShow, episode_entry: TvMazeEpisode, language: Language | None = None, + name_override: str | None = None, ) -> MetadataEpisode: airdate = episode_entry["airdate"] return MetadataEpisode( @@ -586,13 +625,15 @@ def _transform_meta( id_tvdb=id_tvdb or None, id_tvmaze=id_tvmaze or None, season=episode_entry["season"], - series=TvMaze._preferred_name(series_entry, language), + series=name_override or TvMaze._preferred_name(series_entry, language), synopsis=episode_entry["summary"] or None, title=episode_entry["name"] or None, ) @staticmethod def _preferred_name(series_entry: TvMazeShow, language: Language | None) -> str: + """Returns the single best name for a series given the requested language. + Used for season/episode filtering before candidate selection.""" if language: if language.a2 not in _LANGUAGE_COUNTRIES: warn( @@ -606,7 +647,6 @@ def _preferred_name(series_entry: TvMazeShow, language: Language | None) -> str: else: akas = series_entry.get("_embedded", {}).get("akas", []) or [] target_countries = _LANGUAGE_COUNTRIES[language.a2] - for aka in akas: country = aka.get("country") or {} if country.get("code") in target_countries: @@ -617,3 +657,31 @@ def _preferred_name(series_entry: TvMazeShow, language: Language | None) -> str: # See: https://github.com/jkwill87/mnamer/pull/375 return series_entry["name"] + + @staticmethod + def _candidate_names( + series_entry: TvMazeShow, language: Language | None + ) -> list[str]: + """Returns all candidate series names for user selection. + + When a language is requested and an exact country-coded AKA match is + found, returns only that match (unambiguous). When no exact match exists + but country=null AKAs are present, returns the primary name followed by + each null-country AKA so the user can choose the correct title. + """ + if language and language.a2 in _LANGUAGE_COUNTRIES: + akas = series_entry.get("_embedded", {}).get("akas", []) or [] + target_countries = _LANGUAGE_COUNTRIES[language.a2] + + # Exact match — single unambiguous result + for aka in akas: + country = aka.get("country") or {} + if country.get("code") in target_countries: + return [aka["name"]] + + # No exact match — surface country=null AKAs alongside primary + null_akas = [aka["name"] for aka in akas if aka.get("country") is None] + if null_akas: + return [series_entry["name"]] + null_akas + + return [series_entry["name"]] diff --git a/tests/network/test_providers__tvmaze.py b/tests/network/test_providers__tvmaze.py index 2a1cbbc..f3056a7 100644 --- a/tests/network/test_providers__tvmaze.py +++ b/tests/network/test_providers__tvmaze.py @@ -1,5 +1,9 @@ + +from typing import cast + import pytest +from mnamer.endpoints import TvMazeShow from mnamer.exceptions import MnamerNotFoundException from mnamer.language import Language from mnamer.metadata import MetadataEpisode @@ -260,18 +264,17 @@ def test_search__show_with_no_akas_returns_primary_name(): def test_search__unsupported_language_warns_and_returns_primary(): - """Requesting a language with no _LANGUAGE_COUNTRIES mapping warns and returns primary name.""" - provider = TvMaze(cache=False) - query = MetadataEpisode( - id_tvmaze="50", # The Lottery — simple English show, no AKAs - season=1, - episode=1, - language=Language.parse("ar"), # Arabic — not in _LANGUAGE_COUNTRIES + """_preferred_name warns when language has no country mapping.""" + series = cast( + TvMazeShow, + { + "name": "The Lottery", + "_embedded": {"akas": []}, + }, ) with pytest.warns(UserWarning, match="No TVMaze country mapping for language 'ar'"): - results = list(provider.search(query)) - assert results - assert results[0].series == "The Lottery" + result = TvMaze._preferred_name(series, language=Language.parse("ar")) + assert result == "The Lottery" def test_search__no_matching_language_aka_returns_primary(): @@ -300,3 +303,130 @@ def test_search__multiple_akas_same_country_returns_first(): results = list(provider.search(query)) assert results assert results[0].series == "В Поле Зрения" # first RU AKA wins + + +# --------------------------------------------------------------------------- +# TvMaze._candidate_names — unit tests (no network) +# --------------------------------------------------------------------------- + + +class TestCandidateNames: + """Tests for TvMaze._candidate_names static method.""" + + def _make_series(self, series_name: str, akas: list[dict]) -> TvMazeShow: + return cast( + TvMazeShow, + { + "name": series_name, + "_embedded": {"akas": akas}, + }, + ) + + def test_no_language_returns_primary_only(self): + series = self._make_series( + "Schneller Als Die Angst", + [ + {"name": "Faster Than Fear", "country": None}, + ], + ) + result = TvMaze._candidate_names(series, language=None) + assert result == ["Schneller Als Die Angst"] + + def test_exact_country_match_returns_single_result(self): + """Exact country-coded match — no ambiguity, no extra choices.""" + series = self._make_series( + "Morfeusz", + [ + {"name": "Morpheus", "country": {"code": "GB"}}, + {"name": "Morphée", "country": {"code": "FR"}}, + ], + ) + result = TvMaze._candidate_names(series, language=Language.parse("en")) + assert result == ["Morpheus"] + + def test_no_match_with_null_akas_returns_primary_plus_nulls(self): + """No country-coded match but country=null AKAs exist — offer choice.""" + series = self._make_series( + "Schneller Als Die Angst", + [ + {"name": "Faster Than Fear", "country": None}, + {"name": "Peur Bleue", "country": None}, + ], + ) + result = TvMaze._candidate_names(series, language=Language.parse("en")) + assert result == [ + "Schneller Als Die Angst", + "Faster Than Fear", + "Peur Bleue", + ] + + def test_no_match_no_null_akas_returns_primary_only(self): + """No country-coded match, no null AKAs — silently return primary.""" + series = self._make_series( + "어게인 마이 라이프", + [ + {"name": "다시 내 인생", "country": {"code": "KR"}}, + ], + ) + result = TvMaze._candidate_names(series, language=Language.parse("en")) + assert result == ["어게인 마이 라이프"] + + def test_empty_akas_returns_primary_only(self): + series = self._make_series("Some Show", []) + result = TvMaze._candidate_names(series, language=Language.parse("en")) + assert result == ["Some Show"] + + def test_missing_embedded_returns_primary_only(self): + series = cast(TvMazeShow, {"name": "Some Show"}) + result = TvMaze._candidate_names(series, language=Language.parse("en")) + assert result == ["Some Show"] + + def test_unsupported_language_returns_primary_only(self): + """Language not in _LANGUAGE_COUNTRIES — falls through to primary.""" + series = self._make_series( + "какое-то шоу", + [ + {"name": "Some Show", "country": None}, + ], + ) + # "ar" is a valid ISO code but not mapped in _LANGUAGE_COUNTRIES + result = TvMaze._candidate_names(series, language=Language.parse("ar")) + assert result == ["какое-то шоу"] + + def test_null_aka_not_returned_when_exact_match_exists(self): + """If exact country match found, null AKAs must not be included.""" + series = self._make_series( + "Schneller Als Die Angst", + [ + {"name": "Faster Than Fear", "country": None}, + {"name": "Fear", "country": {"code": "US"}}, + ], + ) + result = TvMaze._candidate_names(series, language=Language.parse("en")) + assert result == ["Fear"] + assert "Faster Than Fear" not in result + assert "Schneller Als Die Angst" not in result + + +# --------------------------------------------------------------------------- +# Integration test — network +# --------------------------------------------------------------------------- + + +@pytest.mark.network +@pytest.mark.tvmaze +@pytest.mark.flaky(reruns=1) +def test_search__null_aka_show_yields_multiple_series_name_candidates(): + """Schneller Als Die Angst (id=59772) has country=null AKA 'Faster Than Fear'. + With --language=en, both names should appear as separate results.""" + provider = TvMaze(cache=False) + query = MetadataEpisode( + id_tvmaze="59772", + season=1, + episode=1, + language=Language.parse("en"), + ) + results = list(provider.search(query)) + series_names = [r.series for r in results] + assert "Schneller Als Die Angst" in series_names + assert "Faster Than Fear" in series_names