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.
This commit is contained in:
imSp4rky
2026-08-11 16:42:05 -06:00
parent ebb4e74318
commit 72581ba5d2
24 changed files with 794 additions and 196 deletions
+278
View File
@@ -0,0 +1,278 @@
"""AniList provider: search, ID lookup, title-variant config and derived fields."""
from __future__ import annotations
from typing import Any, Optional
import pytest
from unshackle.core.config import config
from unshackle.core.providers.anilist import AniListProvider, parse_anilist_ref
ONE_PIECE = {
"id": 21,
"idMal": 21,
"title": {"romaji": "ONE PIECE", "english": "One Piece", "native": "ONE PIECE"},
"format": "TV",
"startDate": {"year": 1999},
"countryOfOrigin": "JP",
}
ONE_PIECE_MOVIE = {
"id": 459,
"idMal": 459,
"title": {"romaji": "ONE PIECE (Movie)", "english": "One Piece: The Movie", "native": "ONE PIECE (Movie)"},
"format": "MOVIE",
"startDate": {"year": 2000},
"countryOfOrigin": "JP",
}
class FakeResponse:
def __init__(self, body: dict) -> None:
self._body = body
def json(self) -> dict:
return self._body
def _provider(monkeypatch: pytest.MonkeyPatch, body: dict) -> tuple[AniListProvider, list[dict]]:
"""An AniListProvider whose every POST answers `body`; returns the sent payloads."""
provider = AniListProvider()
sent: list[dict] = []
def fake_post(url: str, json: Optional[dict] = None, **kwargs: Any) -> FakeResponse:
sent.append(json or {})
return FakeResponse(body)
monkeypatch.setattr(provider, "_session", type("S", (), {"post": staticmethod(fake_post)})())
return provider, sent
@pytest.fixture(autouse=True)
def _default_title_language(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "anilist_title_language", "english")
# ---------- reference parsing ----------
@pytest.mark.parametrize(
("value", "expected"),
[
(21, ("id", 21)),
("21", ("id", 21)),
(" 21 ", ("id", 21)),
("mal:21", ("mal", 21)),
("MAL:21", ("mal", 21)),
("mal:007", ("mal", 7)),
("tt1375666", None),
("anilist:21", None),
("mal:", None),
("", None),
(0, None),
("0", None),
("mal:0", None),
(-3, None),
("21.5", None),
("²", None), # superscript two passes isdigit() but crashes int()
],
)
def test_parse_anilist_ref(value: Any, expected: Optional[tuple[str, int]]) -> None:
assert parse_anilist_ref(value) == expected
# ---------- search ----------
def test_search_returns_the_best_match(monkeypatch: pytest.MonkeyPatch) -> None:
provider, sent = _provider(monkeypatch, {"data": {"Page": {"media": [ONE_PIECE, ONE_PIECE_MOVIE]}}})
result = provider.search("one piece", None, "tv")
assert result is not None
assert result.title == "One Piece"
assert result.year == 1999
assert result.kind == "tv"
assert result.original_language == "ja"
assert result.external_ids.anilist_id == 21
assert result.source == "anilist"
assert sent[0]["variables"] == {"search": "one piece"}
def test_search_prefers_the_requested_year(monkeypatch: pytest.MonkeyPatch) -> None:
provider, _ = _provider(monkeypatch, {"data": {"Page": {"media": [ONE_PIECE, ONE_PIECE_MOVIE]}}})
result = provider.search("one piece", 2000, "movie")
assert result is not None
assert result.year == 2000
assert result.kind == "movie"
assert result.external_ids.anilist_id == 459
def test_search_prefers_the_requested_kind_without_a_year(monkeypatch: pytest.MonkeyPatch) -> None:
provider, _ = _provider(monkeypatch, {"data": {"Page": {"media": [ONE_PIECE, ONE_PIECE_MOVIE]}}})
result = provider.search("one piece", None, "movie")
assert result is not None
assert result.kind == "movie"
assert result.external_ids.anilist_id == 459
def test_search_for_tv_is_not_shadowed_by_a_movie(monkeypatch: pytest.MonkeyPatch) -> None:
provider, _ = _provider(monkeypatch, {"data": {"Page": {"media": [ONE_PIECE_MOVIE, ONE_PIECE]}}})
result = provider.search("one piece", None, "tv")
assert result is not None
assert result.kind == "tv"
assert result.external_ids.anilist_id == 21
def test_search_falls_back_when_no_node_matches_the_kind(monkeypatch: pytest.MonkeyPatch) -> None:
provider, _ = _provider(monkeypatch, {"data": {"Page": {"media": [ONE_PIECE]}}})
result = provider.search("one piece", None, "movie")
assert result is not None
assert result.external_ids.anilist_id == 21
def test_search_of_a_non_anime_title_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
provider, _ = _provider(monkeypatch, {"data": {"Page": {"media": []}}})
assert provider.search("The Walking Dead", None, "tv") is None
def test_graphql_errors_are_a_miss_not_a_raise(monkeypatch: pytest.MonkeyPatch) -> None:
provider, _ = _provider(monkeypatch, {"errors": [{"message": "Not Found."}], "data": {"Media": None}})
assert provider.search("one piece", None, "tv") is None
assert provider.get_by_id(21, "tv") is None
# ---------- id lookup ----------
def test_get_by_id_queries_the_anilist_id(monkeypatch: pytest.MonkeyPatch) -> None:
provider, sent = _provider(monkeypatch, {"data": {"Media": ONE_PIECE}})
result = provider.get_by_id(21, "tv")
assert result is not None
assert result.title == "One Piece"
assert sent[0]["variables"] == {"id": 21}
def test_get_by_id_resolves_a_mal_reference_natively(monkeypatch: pytest.MonkeyPatch) -> None:
provider, sent = _provider(monkeypatch, {"data": {"Media": ONE_PIECE}})
result = provider.get_by_id("mal:21", "tv")
assert result is not None
assert sent[0]["variables"] == {"idMal": 21}
assert result.external_ids.anilist_id == 21
def test_get_by_id_rejects_a_foreign_reference(monkeypatch: pytest.MonkeyPatch) -> None:
provider, sent = _provider(monkeypatch, {"data": {"Media": ONE_PIECE}})
assert provider.get_by_id("tt1375666", "tv") is None
assert sent == []
def test_get_external_ids_needs_no_lookup_for_a_bare_id(monkeypatch: pytest.MonkeyPatch) -> None:
provider, sent = _provider(monkeypatch, {"data": {"Media": ONE_PIECE}})
assert provider.get_external_ids(21, "tv").anilist_id == 21
assert sent == []
assert provider.get_external_ids("mal:21", "tv").anilist_id == 21
assert sent[0]["variables"] == {"idMal": 21}
# ---------- title variant ----------
@pytest.mark.parametrize(
("configured", "expected"),
[("english", "One Piece"), ("romaji", "ONE PIECE"), ("native", "ONE PIECE"), ("klingon", "One Piece")],
)
def test_title_language_config(monkeypatch: pytest.MonkeyPatch, configured: str, expected: str) -> None:
monkeypatch.setattr(config, "anilist_title_language", configured)
provider, _ = _provider(monkeypatch, {"data": {"Media": ONE_PIECE}})
result = provider.get_by_id(21, "tv")
assert result is not None
assert result.title == expected
def test_title_language_falls_back_when_the_variant_is_unset(monkeypatch: pytest.MonkeyPatch) -> None:
node = {**ONE_PIECE, "title": {"romaji": "Koisuru ONE PIECE", "english": None, "native": "恋するワンピース"}}
provider, _ = _provider(monkeypatch, {"data": {"Media": node}})
result = provider.get_by_id(21, "tv")
assert result is not None
assert result.title == "Koisuru ONE PIECE"
def test_a_title_with_no_variant_at_all_is_a_miss(monkeypatch: pytest.MonkeyPatch) -> None:
node = {**ONE_PIECE, "title": {"romaji": None, "english": None, "native": None}}
provider, _ = _provider(monkeypatch, {"data": {"Media": node}})
assert provider.get_by_id(21, "tv") is None
# ---------- derived fields ----------
@pytest.mark.parametrize(
("country", "language"),
[("JP", "ja"), ("KR", "ko"), ("CN", "zh"), ("TW", "zh"), ("US", None), (None, None)],
)
def test_country_of_origin_maps_to_a_language(
monkeypatch: pytest.MonkeyPatch, country: Optional[str], language: Optional[str]
) -> None:
provider, _ = _provider(monkeypatch, {"data": {"Media": {**ONE_PIECE, "countryOfOrigin": country}}})
result = provider.get_by_id(21, "tv")
assert result is not None
assert result.original_language == language
@pytest.mark.parametrize(
("format_", "kind"), [("MOVIE", "movie"), ("TV", "tv"), ("ONA", "tv"), ("SPECIAL", "tv"), (None, "tv")]
)
def test_format_maps_to_kind(monkeypatch: pytest.MonkeyPatch, format_: Optional[str], kind: str) -> None:
provider, _ = _provider(monkeypatch, {"data": {"Media": {**ONE_PIECE, "format": format_}}})
result = provider.get_by_id(21, "tv")
assert result is not None
assert result.kind == kind
def test_the_provider_needs_no_api_key() -> None:
assert AniListProvider().is_available() is True
assert AniListProvider.REQUIRES_KEY is False
assert AniListProvider.ID_KIND == "anilist"
# ---------- cache round-trip ----------
def test_a_cached_node_rebuilds_with_the_read_time_title_language(monkeypatch: pytest.MonkeyPatch) -> None:
"""The raw node is cached; which title variant it yields follows the config at read time."""
from unshackle.core.providers import _cached_to_result
node = {**ONE_PIECE, "title": {"romaji": "WAN PIISU", "english": "One Piece", "native": "ワンピース"}}
monkeypatch.setattr(config, "anilist_title_language", "native")
result = _cached_to_result(node, "anilist", "tv")
assert result is not None
assert result.title == "ワンピース"
assert result.year == 1999
assert result.kind == "tv"
assert result.external_ids.anilist_id == 21
assert result.original_language == "ja"
+69
View File
@@ -112,3 +112,72 @@ def test_api_accepts_one_id(payload: dict) -> None:
def test_api_still_reports_a_malformed_id_first() -> None:
err = validate_download_parameters({"tmdb_id": "12345", "imdb_id": "tt1"})
assert err and "positive integer" in err
# ---------- --anilist stays outside the mutual exclusion ----------
@pytest.mark.parametrize("value", [21, "21", "mal:21"])
def test_anilist_alone_passes_without_any_key(monkeypatch: pytest.MonkeyPatch, value: object) -> None:
_with_keys(monkeypatch)
assert validate_metadata_ids(None, None, None, value) is None
def test_anilist_pairs_with_one_other_id(monkeypatch: pytest.MonkeyPatch) -> None:
_with_keys(monkeypatch)
assert validate_metadata_ids(None, "tt1375666", None, 21) is None
def test_anilist_does_not_excuse_two_western_ids(monkeypatch: pytest.MonkeyPatch) -> None:
_with_keys(monkeypatch)
with pytest.raises(click.UsageError) as exc:
validate_metadata_ids(27205, "tt1375666", None, 21)
assert "cannot be used together" in str(exc.value)
@pytest.mark.parametrize("value", ["tt1375666", "anilist:21", "mal:", "abc", "-3"])
def test_a_malformed_anilist_value_is_refused(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
_with_keys(monkeypatch)
with pytest.raises(click.UsageError) as exc:
validate_metadata_ids(None, None, None, value)
assert "--anilist" in str(exc.value)
@pytest.mark.parametrize("payload", [{"anilist_id": 21}, {"anilist_id": "mal:21"}, {"anilist_id": "21"}])
def test_api_accepts_an_anilist_id(payload: dict) -> None:
assert validate_download_parameters(payload) is None
def test_api_accepts_anilist_alongside_one_western_id() -> None:
assert validate_download_parameters({"imdb_id": "tt1375666", "anilist_id": 21}) is None
def test_api_still_refuses_two_western_ids_with_anilist() -> None:
err = validate_download_parameters({"tmdb_id": 27205, "imdb_id": "tt1375666", "anilist_id": 21})
assert err and "multiple external IDs" in err
@pytest.mark.parametrize("value", [0, -3, True, "mal:", "tt1375666", "anilist:21", 1.5])
def test_api_refuses_a_malformed_anilist_id(value: object) -> None:
err = validate_download_parameters({"anilist_id": value})
assert err and "anilist_id" in err
@pytest.mark.parametrize("value", ["0", "mal:0", "²", "21.5"])
def test_a_non_positive_or_non_decimal_anilist_value_is_refused(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
_with_keys(monkeypatch)
with pytest.raises(click.UsageError) as exc:
validate_metadata_ids(None, None, None, value)
assert "--anilist" in str(exc.value)
@pytest.mark.parametrize("payload", [{"anilist_id": "MAL:21"}, {"anilist_id": "007"}, {"anilist_id": " 21 "}])
def test_api_accepts_what_the_cli_accepts(payload: dict) -> None:
"""Both surfaces share parse_anilist_ref, so their accept sets cannot drift."""
assert validate_download_parameters(payload) is None
@pytest.mark.parametrize("value", ["0", "mal:0", "²"])
def test_api_refuses_what_the_cli_refuses(value: str) -> None:
err = validate_download_parameters({"anilist_id": value})
assert err and "anilist_id" in err
+128
View File
@@ -129,3 +129,131 @@ def test_flat_list_config_applies_to_both_kinds(monkeypatch: pytest.MonkeyPatch)
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