Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 80 additions & 12 deletions mnamer/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -507,14 +518,23 @@ 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
)
if season is not None and season != meta.season:
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,
Expand All @@ -535,14 +555,23 @@ 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
)
if season != meta.season:
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,
Expand All @@ -562,14 +591,23 @@ 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
)
if season is not None and season != meta.season:
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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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"]]
150 changes: 140 additions & 10 deletions tests/network/test_providers__tvmaze.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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