Files
unshackle/tests/core/test_tvdb.py
T
imSp4rky 4ac34069ec feat(providers): add TheTVDB v4 as a metadata provider
TVDB covers TV far better than the existing sources, so add it as a
provider and let users decide where it sits in the lookup chain.

- new TVDB v4 provider: search, get_by_id, get_external_ids and
  find_by_imdb_id, with a process-level token cache and 401 re-auth
- metadata_providers config both orders and filters the providers used,
  so TVDB can be primary rather than a TMDB fallback. Cross-validation
  keeps its own fixed trust ranking, unchanged by search order
- --tvdb-order renumbers episodes into a TVDB season order. A service
  does not always use aired order, so the source order is detected
  rather than assumed, and the renumber is refused when an order that
  omits episodes would give two of them the same filename
- --tvdb sets the series ID directly, skipping the lookup
- the Matroska TVDB2 tag now populates on every lookup path, including
  --imdb, which previously produced no ID tags when IMDxAPI was off

Search matches on year as well as title so an identically named remake
cannot win on title alone.
2026-08-02 10:48:32 -06:00

174 lines
7.0 KiB
Python

"""TVDB provider ordering and season-order remapping."""
from __future__ import annotations
import pytest
from unshackle.core import providers
from unshackle.core.config import config
from unshackle.core.providers.tvdb import TVDBProvider, _ids_from_remote, _pick_match
from unshackle.core.utils.tags import _build_tags_from_ids
REMOTE_IDS = [
{"id": "tt0149460", "type": 2, "sourceName": "IMDB"},
{"id": "http://example.com", "type": 4, "sourceName": "Official Website"},
{"id": "615", "type": 12, "sourceName": "TheMovieDB.com"},
]
# Futurama: aired S1 stops at E9, the four held-back episodes open aired S2.
# DVD order folds them back into S1 as E10-E13.
AIRED = [
{"id": 1, "seasonNumber": 1, "number": 9, "name": "Hell Is Other Robots"},
{"id": 2, "seasonNumber": 2, "number": 1, "name": "A Flight to Remember"},
{"id": 3, "seasonNumber": 2, "number": 2, "name": "Mars University"},
{"id": 99, "seasonNumber": 2, "number": 3, "name": "Only In Aired"},
]
DVD = [
{"id": 1, "seasonNumber": 1, "number": 9, "name": "Hell Is Other Robots"},
{"id": 2, "seasonNumber": 1, "number": 10, "name": "A Flight to Remember"},
{"id": 3, "seasonNumber": 1, "number": 11, "name": "Mars University"},
]
def test_ids_from_remote() -> None:
ext = _ids_from_remote(REMOTE_IDS, 73871)
assert ext.imdb_id == "tt0149460"
assert ext.tmdb_id == 615
assert ext.tvdb_id == 73871
def _stub(p: TVDBProvider, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(p, "get_episodes", lambda _id, order: {"official": AIRED, "dvd": DVD}.get(order, []))
def test_order_map_joins_on_episode_id(monkeypatch: pytest.MonkeyPatch) -> None:
p = TVDBProvider()
_stub(p, monkeypatch)
mapping = p.get_order_map(73871, "dvd")
assert mapping[(1, 9)] == (1, 9, "Hell Is Other Robots")
assert mapping[(2, 1)] == (1, 10, "A Flight to Remember")
assert mapping[(2, 2)] == (1, 11, "Mars University")
# an episode absent from the target order is left out, so it keeps its own numbering
assert (2, 3) not in mapping
def test_order_map_reverses_with_source_order(monkeypatch: pytest.MonkeyPatch) -> None:
p = TVDBProvider()
_stub(p, monkeypatch)
mapping = p.get_order_map(73871, "official", source_order="dvd")
assert mapping[(1, 10)] == (2, 1, "A Flight to Remember")
def test_order_map_is_noop_when_orders_match() -> None:
assert TVDBProvider().get_order_map(73871, "official") == {}
def test_order_map_empty_when_order_missing(monkeypatch: pytest.MonkeyPatch) -> None:
p = TVDBProvider()
_stub(p, monkeypatch)
assert p.get_order_map(73871, "alternate") == {}
def test_detect_order_picks_the_order_the_numbering_matches(monkeypatch: pytest.MonkeyPatch) -> None:
p = TVDBProvider()
_stub(p, monkeypatch)
# a service may number a series in a non-aired order, so detection must not assume aired
assert p.detect_order(73871, [(1, 10), (1, 11)]) == "dvd"
assert p.detect_order(73871, [(2, 1), (2, 2), (2, 3)]) == "official"
def test_detect_order_falls_back_to_aired(monkeypatch: pytest.MonkeyPatch) -> None:
p = TVDBProvider()
monkeypatch.setattr(p, "get_episodes", lambda _id, order: [])
assert p.detect_order(73871, [(1, 1)]) == "official"
# TVDB holds both Battlestar Galactica series; the 2003 revival is filed under year 2005
BSG = [
{"name": "Battlestar Galactica", "year": "1978", "tvdb_id": "71173"},
{"name": "Battlestar Galactica (2003)", "year": "2005", "tvdb_id": "73545"},
]
def test_pick_match_prefers_the_right_era() -> None:
# exact title match on the 1978 series must not beat the era the caller asked for
best, _, _ = _pick_match(BSG, "Battlestar Galactica", 2004)
assert best["tvdb_id"] == "73545"
best, _, _ = _pick_match(BSG, "Battlestar Galactica", 1978)
assert best["tvdb_id"] == "71173"
def test_pick_match_without_a_year_scores_on_title_alone() -> None:
best, _, _ = _pick_match(BSG, "Battlestar Galactica", None)
assert best["tvdb_id"] == "71173"
def test_pick_match_returns_nothing_when_no_year_fits() -> None:
assert _pick_match(BSG, "Battlestar Galactica", 1995)[0] is None
def test_tvdb2_tag_follows_the_matroska_spec() -> None:
# TVDB2 IDs are prefixed by entity type: matroska.org/technical/tagging.html
from unshackle.core.providers import ExternalIds
assert _build_tags_from_ids(ExternalIds(tvdb_id=73871), "tv")["TVDB2"] == "series/73871"
assert _build_tags_from_ids(ExternalIds(tvdb_id=113), "movie")["TVDB2"] == "movies/113"
def test_provider_order_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "metadata_providers", [])
assert [c.NAME for c in providers.provider_order()] == list(providers.DEFAULT_ORDER)
def test_provider_order_is_configurable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "metadata_providers", ["TVDB", "tmdb", "nope", "tvdb"])
# unknown entries dropped, duplicates collapsed, case ignored, order kept
assert [c.NAME for c in providers.provider_order()] == ["tvdb", "tmdb"]
def test_enrichment_authority_is_fixed(monkeypatch: pytest.MonkeyPatch) -> None:
# search order must not demote TMDB below SIMKL for cross-validation tie-breaks
monkeypatch.setattr(config, "metadata_providers", [])
assert providers._enrichment_providers() == ["tmdb", "simkl", "tvdb"]
# `metadata_providers` filters the set but does not re-rank the trust order
monkeypatch.setattr(config, "metadata_providers", ["tvdb", "simkl"])
assert providers._enrichment_providers() == ["simkl", "tvdb"]
def test_cached_tvdb_result_keeps_enriched_ids() -> None:
cached = {
"tvdb_id": "73871",
"name": "Futurama",
"year": "1999",
"_enriched_ids": {"imdb_id": "tt0149460", "tmdb_id": 615},
}
result = providers._cached_to_result(cached, "tvdb", "tv")
assert result is not None
assert result.external_ids.tvdb_id == 73871
assert result.external_ids.imdb_id == "tt0149460"
assert result.external_ids.tmdb_id == 615
# remote_ids stay authoritative over the enriched copies
cached["remote_ids"] = REMOTE_IDS
cached["_enriched_ids"] = {"imdb_id": "tt9999999", "tmdb_id": 1}
result = providers._cached_to_result(cached, "tvdb", "tv")
assert result.external_ids.imdb_id == "tt0149460"
assert result.external_ids.tmdb_id == 615
def test_get_episodes_fails_closed_on_a_truncated_listing(monkeypatch: pytest.MonkeyPatch) -> None:
p = TVDBProvider()
pages: dict[int, object] = {0: {"episodes": [{"id": n} for n in range(500)]}, 1: None}
monkeypatch.setattr(p, "_get", lambda _path, params: pages[params["page"]])
# page 1 fails mid-listing: a partial list would renumber wrongly
assert p.get_episodes(73871, "official") == []
assert p._episodes == {}
def test_get_episodes_does_not_cache_an_empty_listing(monkeypatch: pytest.MonkeyPatch) -> None:
p = TVDBProvider()
monkeypatch.setattr(p, "_get", lambda _path, params: None)
assert p.get_episodes(73871, "official") == []
assert p._episodes == {}