Files
unshackle/tests/core/test_resolve_by_ids.py
imSp4rky 72581ba5d2 feat(metadata)!: replace animeapi with a first-class anilist provider
--anilist supersedes --animeapi (REST: anilist_id); the keyless AniList
GraphQL provider joins the registry with title search, mal: ID lookup,
an ANILIST mkv tag, and the anilist_title_language config option.
Services mark anime via Service.ANIME / Title.anime for anilist-first
metadata resolution with fall-through.
2026-08-11 16:42:05 -06:00

260 lines
10 KiB
Python

"""Precedence of user-supplied external IDs in providers.resolve_by_ids."""
from __future__ import annotations
from typing import Optional, Union
import pytest
from unshackle.core import providers
from unshackle.core.config import config
from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult
class FakeProvider(MetadataProvider):
"""Stands in for a real provider; records the ID it was asked for."""
def __init__(self, name: str, result: Optional[MetadataResult] = None, raises: bool = False) -> None:
self.NAME = name
self._result = result
self._raises = raises
self.asked: list[Union[int, str]] = []
super().__init__()
def is_available(self) -> bool:
return True
def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]:
raise AssertionError("resolve_by_ids must not search when an ID was supplied")
def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]:
self.asked.append(provider_id)
if self._raises:
raise ValueError("provider is down")
return self._result
def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds:
return ExternalIds()
@pytest.fixture(autouse=True)
def _isolate(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep every test off the network and on the default provider order."""
monkeypatch.setattr(config, "metadata_providers", [])
monkeypatch.setattr(providers, "fetch_external_ids", lambda *a, **kw: ExternalIds())
monkeypatch.setattr(providers, "enrich_ids", lambda result: None)
monkeypatch.setattr(
providers,
"search_metadata",
lambda *a, **kw: pytest.fail("search_metadata must not run when an ID was supplied"),
)
def _patch_providers(monkeypatch: pytest.MonkeyPatch, available: dict[str, FakeProvider]) -> None:
monkeypatch.setattr(providers, "get_provider", lambda name: available.get(name))
def test_supplied_ids_survive_a_providers_own_answer(monkeypatch: pytest.MonkeyPatch) -> None:
# tmdb answers with a different imdb id; the one the user gave must win
tmdb = FakeProvider(
"tmdb",
MetadataResult(title="Inception", year=2010, kind="movie", external_ids=ExternalIds(imdb_id="tt9999999")),
)
_patch_providers(monkeypatch, {"tmdb": tmdb})
result = providers.resolve_by_ids(27205, "tt1375666", kind="movie")
assert result is not None
assert result.external_ids.tmdb_id == 27205
assert result.external_ids.tmdb_kind == "movie"
assert result.external_ids.imdb_id == "tt1375666"
def test_imdb_falls_through_to_omdb(monkeypatch: pytest.MonkeyPatch) -> None:
imdb = FakeProvider("imdb", raises=True)
omdb = FakeProvider("omdb", MetadataResult(title="Inception", year=2010, kind="movie"))
_patch_providers(monkeypatch, {"imdb": imdb, "omdb": omdb})
result = providers.resolve_by_ids(imdb_id="tt1375666", kind="movie")
assert result is not None
assert result.title == "Inception"
assert imdb.asked == ["tt1375666"]
assert omdb.asked == ["tt1375666"]
def test_all_providers_down_synthesizes_a_result(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_providers(monkeypatch, {})
result = providers.resolve_by_ids(tvdb_id=73871, title="Futurama", year=1999, kind="tv")
assert result is not None
assert result.title == "Futurama"
assert result.year == 1999
assert result.external_ids.tvdb_id == 73871
def test_no_ids_searches(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple] = []
monkeypatch.setattr(providers, "search_metadata", lambda *a, **kw: calls.append(a) or None)
_patch_providers(monkeypatch, {})
providers.resolve_by_ids(title="Futurama", year=1999, kind="tv")
assert calls and calls[0][:3] == ("Futurama", 1999, "tv")
def test_no_ids_and_no_title_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_providers(monkeypatch, {})
assert providers.resolve_by_ids(kind="tv") is None
def test_per_kind_order_puts_tvdb_first_for_tv(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "metadata_providers", {"tv": ["tvdb", "tmdb", "imdb", "omdb"]})
tvdb = FakeProvider("tvdb", MetadataResult(title="Futurama", year=1999, kind="tv"))
tmdb = FakeProvider("tmdb", MetadataResult(title="Wrong Show", year=2020, kind="tv"))
_patch_providers(monkeypatch, {"tvdb": tvdb, "tmdb": tmdb})
result = providers.resolve_by_ids(615, tvdb_id=73871, kind="tv")
assert result is not None
assert result.title == "Futurama"
assert tmdb.asked == []
# movies keep the default order, so tmdb is consulted before tvdb there
assert [cls.NAME for cls in providers.provider_order("movie")] == list(providers.DEFAULT_ORDER)
def test_flat_list_config_applies_to_both_kinds(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "metadata_providers", ["tvdb", "tmdb"])
assert [cls.NAME for cls in providers.provider_order("tv")] == ["tvdb", "tmdb"]
assert [cls.NAME for cls in providers.provider_order("movie")] == ["tvdb", "tmdb"]
assert [cls.NAME for cls in providers.provider_order()] == ["tvdb", "tmdb"]
# ---------- the anilist namespace ----------
def test_an_anilist_id_reaches_the_anilist_provider(monkeypatch: pytest.MonkeyPatch) -> None:
anilist = FakeProvider(
"anilist",
MetadataResult(title="One Piece", year=1999, kind="tv", external_ids=ExternalIds(anilist_id=21)),
)
tmdb = FakeProvider("tmdb", MetadataResult(title="Wrong Show", year=2020, kind="tv"))
_patch_providers(monkeypatch, {"anilist": anilist, "tmdb": tmdb})
result = providers.resolve_by_ids(anilist_id=21, kind="tv")
assert result is not None
assert result.title == "One Piece"
assert result.external_ids.anilist_id == 21
assert anilist.asked == [21]
assert tmdb.asked == []
def test_a_supplied_anilist_id_survives_a_provider_miss(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_providers(monkeypatch, {})
result = providers.resolve_by_ids(anilist_id="21", title="One Piece", kind="tv")
assert result is not None
assert result.external_ids.anilist_id == 21
def test_a_mal_reference_carries_only_what_the_lookup_answered(monkeypatch: pytest.MonkeyPatch) -> None:
anilist = FakeProvider("anilist", MetadataResult(title="One Piece", external_ids=ExternalIds(anilist_id=21)))
_patch_providers(monkeypatch, {"anilist": anilist})
result = providers.resolve_by_ids(anilist_id="mal:21", kind="tv")
assert result is not None
assert anilist.asked == ["mal:21"]
assert result.external_ids.anilist_id == 21
def test_a_mal_reference_that_misses_leaves_no_anilist_id(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_providers(monkeypatch, {})
result = providers.resolve_by_ids(anilist_id="mal:21", title="One Piece", kind="tv")
assert result is not None
assert result.external_ids.anilist_id is None
def test_anilist_combines_with_a_western_id(monkeypatch: pytest.MonkeyPatch) -> None:
tmdb = FakeProvider("tmdb", MetadataResult(title="One Piece", year=1999, kind="tv"))
anilist = FakeProvider("anilist", MetadataResult(title="One Piece", external_ids=ExternalIds(anilist_id=21)))
_patch_providers(monkeypatch, {"tmdb": tmdb, "anilist": anilist})
result = providers.resolve_by_ids(37854, anilist_id=21, kind="tv")
assert result is not None
assert result.external_ids.tmdb_id == 37854
assert result.external_ids.anilist_id == 21
# ---------- the anime hint ----------
def test_anime_puts_anilist_first_with_the_rest_behind_it() -> None:
ordered = [cls.NAME for cls in providers.provider_order("tv", anime=True)]
assert ordered[0] == "anilist"
assert ordered[1:] == [name for name in providers.DEFAULT_ORDER if name != "anilist"]
def test_without_the_hint_anilist_stays_last() -> None:
assert [cls.NAME for cls in providers.provider_order("tv")] == list(providers.DEFAULT_ORDER)
assert providers.DEFAULT_ORDER[-1] == "anilist"
def test_the_hint_cannot_add_a_provider_the_config_left_out(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "metadata_providers", ["tmdb", "tvdb"])
assert [cls.NAME for cls in providers.provider_order("tv", anime=True)] == ["tmdb", "tvdb"]
def test_an_anime_hint_falls_through_to_the_normal_order_on_a_miss(monkeypatch: pytest.MonkeyPatch) -> None:
"""anilist is asked first but knows no imdb ids, so imdb still answers."""
anilist = FakeProvider("anilist", None)
imdb = FakeProvider("imdb", MetadataResult(title="One Piece", year=1999, kind="tv"))
_patch_providers(monkeypatch, {"anilist": anilist, "imdb": imdb})
result = providers.resolve_by_ids(imdb_id="tt0388629", kind="tv", anime=True)
assert result is not None
assert result.title == "One Piece"
# the imdb id is in no namespace anilist consumes, so it is never asked
assert anilist.asked == []
assert imdb.asked == ["tt0388629"]
def test_the_anime_hint_reaches_search(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[dict] = []
monkeypatch.setattr(providers, "search_metadata", lambda *a, **kw: calls.append({"args": a}) or None)
_patch_providers(monkeypatch, {})
providers.resolve_by_ids(title="One Piece", year=1999, kind="tv", anime=True)
assert calls and calls[0]["args"][-1] is True
def test_all_supplied_ids_survive_together(monkeypatch: pytest.MonkeyPatch) -> None:
"""anilist_id combined with a western trio member: every supplied ID reaches external_ids."""
tvdb = FakeProvider(
"tvdb",
MetadataResult(title="One Piece", year=1999, kind="tv", external_ids=ExternalIds(tvdb_id=81797)),
)
_patch_providers(monkeypatch, {"tvdb": tvdb})
result = providers.resolve_by_ids(tvdb_id=81797, anilist_id="mal:21", imdb_id="tt0388629", kind="tv")
assert result is not None
assert result.external_ids.tvdb_id == 81797
assert result.external_ids.imdb_id == "tt0388629"
# a mal: ref the provider never resolved contributes no anilist_id; a bare id
# would have been forced back in
assert result.external_ids.anilist_id is None
result = providers.resolve_by_ids(tvdb_id=81797, anilist_id=21, kind="tv")
assert result is not None
assert result.external_ids.tvdb_id == 81797
assert result.external_ids.anilist_id == 21