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
+2 -2
View File
@@ -392,9 +392,9 @@ Create a download job. Requires `service` and `title_id`; every other field is a
| `best_available` | boolean | `false` | Fall back to best available. |
| `repack` | boolean | `false` | Add REPACK tag. |
| `tag` | string | `null` | Release group tag. |
| `tmdb_id` / `imdb_id` / `tvdb_id` / `animeapi_id` | - | `null` | External ID overrides. Each resolves its metadata directly instead of by a title search, and is used for tagging. Set `enrich` to also take the title, year and original language. Give at most one of `tmdb_id`, `imdb_id` and `tvdb_id`, since unshackle resolves the others from it. Sending two returns `400`. `animeapi_id` still combines with one of them. `tmdb_id` and `tvdb_id` must be positive integers and `imdb_id` must look like `tt1375666`, or the request returns `400`. An ID whose provider is unconfigured, such as `tmdb_id` with no `tmdb_api_key`, fails the job rather than returning `400`. |
| `tmdb_id` / `imdb_id` / `tvdb_id` / `anilist_id` | - | `null` | External ID overrides. Each resolves its metadata directly instead of by a title search, and is used for tagging. Set `enrich` to also take the title, year and original language. Give at most one of `tmdb_id`, `imdb_id` and `tvdb_id`, since unshackle resolves the others from it. Sending two returns `400`. `anilist_id` still combines with one of them. `tmdb_id` and `tvdb_id` must be positive integers, `imdb_id` must look like `tt1375666`, and `anilist_id` must be a positive integer or a string like `mal:12345`, or the request returns `400`. An ID whose provider is unconfigured, such as `tmdb_id` with no `tmdb_api_key`, fails the job rather than returning `400`. `anilist_id` needs no key. |
| `tvdb_order` | `official`, `dvd`, `absolute`, `alternate`, `regional` | `null` | Renumber episodes to a TVDB season order. Falls back to the `tvdb_order` config option. |
| `enrich` | boolean | `false` | Overwrite title, year and original language with the external source's. Needs one of `tmdb_id`, `imdb_id`, `tvdb_id` or `animeapi_id`. Without one the job fails instead of returning `400`. |
| `enrich` | boolean | `false` | Overwrite title, year and original language with the external source's. Needs one of `tmdb_id`, `imdb_id`, `tvdb_id` or `anilist_id`. Without one the job fails instead of returning `400`. |
| `output_dir` | string | `null` | Override output directory. |
| `no_cache` / `reset_cache` | boolean | `false` | Title cache controls. |
+5 -4
View File
@@ -153,15 +153,16 @@ Keep only certain track types, or skip certain track types. Attachments are alwa
| `--tmdb` | TMDB ID (integer). Used for the tags. Skips the title search. `--enrich` reads it too. Needs `tmdb_api_key`. |
| `--imdb` | IMDb ID, e.g. `tt1375666`. Used for the tags. Skips the title search. `--enrich` reads it too. Needs no key. |
| `--tvdb` | TVDB ID (integer). Used for the tags. Skips the series lookup that `--tvdb-order` would otherwise do. `--enrich` reads it too. Needs `tvdb_api_key`. |
| `--animeapi` | AnimeAPI ID, e.g. `mal:12345` or `anilist:98765` (defaults to MAL). Back-fills TMDB/IMDb/TVDB. |
| `--enrich` | Overwrite show title, year and original language with the external source's. **Requires** one of `--tmdb`, `--imdb`, `--tvdb`, or `--animeapi`. |
| `--anilist` | AniList ID (integer), e.g. `--anilist 21`. A MyAnimeList ID is accepted as `mal:12345` and resolved to the AniList entry. Used for the tags. Skips the title search. `--enrich` reads it too. Needs no key. |
| `--enrich` | Overwrite show title, year and original language with the external source's. **Requires** one of `--tmdb`, `--imdb`, `--tvdb`, or `--anilist`. |
| `--tvdb-order` | Renumber episodes to a TVDB season order: `official` (aired), `dvd`, `absolute`, `alternate`, or `regional`. Needs `tvdb_api_key`. |
!!! note "One ID at a time"
`--tmdb`, `--imdb` and `--tvdb` cannot be combined. Give one and unshackle resolves the
others from it, writing all three to the tags. `--animeapi` still pairs with one of them.
others from it, writing all three to the tags. `--anilist` is outside that rule and pairs
with one of them, which is how you attach a western ID to an anime title.
An ID with no provider that can resolve it, such as `--tmdb` with no `tmdb_api_key`, fails
before the download starts.
before the download starts. `--anilist` needs no key, so it never fails this check.
#### Episode ordering
+19 -9
View File
@@ -633,8 +633,8 @@ lookups but keep the IDs you give, set
| `--tmdb` | `--tmdb 27205` | Use this TMDB ID instead of an automatic search. Needs `tmdb_api_key`. |
| `--imdb` | `--imdb tt1375666` | Use this IMDb ID instead of an automatic search. Needs no key. |
| `--tvdb` | `--tvdb 73871` | Use this TVDB ID instead of looking the series up. Needs `tvdb_api_key`. |
| `--animeapi` | `--animeapi mal:12345` | Resolve via AnimeAPI (`mal:`/`anilist:` prefix; defaults to MAL). |
| `--enrich` | - | Overwrite the show title, year and original language with the external source's. **Requires** one of `--tmdb`, `--imdb`, `--tvdb`, or `--animeapi`. |
| `--anilist` | `--anilist 21` | Use this AniList ID instead of an automatic search. `mal:12345` is also accepted. Needs no key. |
| `--enrich` | - | Overwrite the show title, year and original language with the external source's. **Requires** one of `--tmdb`, `--imdb`, `--tvdb`, or `--anilist`. |
| `--tvdb-order` | `--tvdb-order dvd` | Renumber episodes to a TVDB season order. Needs `tvdb_api_key`. |
```shell title="Force the right IMDb match, and keep the service's own naming"
@@ -657,10 +657,21 @@ which ID you gave. Every provider except `imdb` only runs when its key is config
| OMDb | `omdb_api_key` | yes | yes, as an English name such as `Korean` | IMDb |
| IMDb | *(no key needed)* | yes | yes, alpha-2 such as `ko` | IMDb |
| SIMKL | `simkl_client_id` | yes | no, it publishes a country and no language | IMDb, TMDB, TVDB |
| AniList | *(no key needed)* | yes | yes, worked out from the country of origin, such as `ja` | AniList |
Whatever the tag looks like, unshackle normalises it before use, so `ko`, `kor` and `Korean`
all end up as the same language.
!!! note "AniList only answers for anime"
It is last in the default provider order and returns nothing for a title that is not
anime. The only cost is one search that misses. Put `anilist` earlier in
[`metadata_providers`](../reference/configuration/misc.md#external-api-keys) if you mostly
download anime. Which of the three AniList title variants is used is set by
[`anilist_title_language`](../reference/configuration/misc.md#external-api-keys).
A service can also mark its titles as anime. For those titles unshackle tries AniList
first. When AniList has no match, it falls through to the normal order.
Each ID goes to the providers that read that kind of ID, in
[`metadata_providers`](../reference/configuration/misc.md#external-api-keys) order:
@@ -668,15 +679,14 @@ Each ID goes to the providers that read that kind of ID, in
- `--imdb` reads IMDb, then falls back to OMDb. IMDb needs no key, so this path works out of
the box, and an OMDb key only matters when IMDb has no answer.
- `--tvdb` reads TVDB. It needs `tvdb_api_key`.
- `--animeapi` supplies a title, plus whichever IDs AnimeAPI knows for the show. If that
includes a TMDB, IMDb or TVDB ID, the year and language come from that provider. If it
does not, pair it with `--tmdb`, `--imdb` or `--tvdb`.
- `--anilist` reads AniList, which needs no key. It supplies the title, the year and the
original language, and writes an AniList tag. It knows no TMDB, IMDB or TVDB ID, so pair it
with `--tmdb`, `--imdb` or `--tvdb` when you want those tags as well.
Give **one** of `--tmdb`, `--imdb` and `--tvdb`. They cannot be combined, because one ID does
the job on its own: unshackle resolves the other two from it and writes all three to the tags.
Passing two is an error rather than a silent choice between them. `--animeapi` is the exception
and still pairs with one of the three, which is what to do when AnimeAPI knows no IDs for the
show.
Passing two is an error rather than a silent choice between them. `--anilist` is the exception
and still pairs with one of the three, which is how you tag an anime title with a western ID.
The ID you give must have a provider that can actually resolve it, so unshackle checks that
before it downloads anything. `--tmdb` without `tmdb_api_key`, or an ID whose providers your
@@ -760,5 +770,5 @@ authoritative list.
| `--list` / `--list-titles` / `--skip-dl` | | Dry runs. |
| `--cdm-only` / `--vaults-only` | | Key source control. |
| `--export` | | Export track info and keys to JSON. |
| `--tmdb` / `--imdb` / `--tvdb` / `--animeapi` / `--enrich` | | Metadata overrides. |
| `--tmdb` / `--imdb` / `--tvdb` / `--anilist` / `--enrich` | | Metadata overrides. |
| `--tvdb-order` | | Renumber episodes to a TVDB season order. |
+1 -1
View File
@@ -18,7 +18,7 @@ after the flag's internal destination rather than its visible name; set these ex
| `--list` | `list` |
| `--tmdb` | `tmdb_id` |
| `--imdb` | `imdb_id` |
| `--animeapi` | `animeapi_id` |
| `--anilist` | `anilist_id` |
| `-naa` / `--noatmos` | `no_atmos` |
| `-o` / `--output` | `output_dir` |
| `--cdm-only` / `--vaults-only` | `cdm_only` |
+1 -1
View File
@@ -36,7 +36,7 @@ Where the file lives, how unshackle discovers it, and how to edit it:
| [DRM & CDM](drm.md) | `cdm`, `remote_cdm`, `decryption` |
| [Network & proxy](network.md) | `network`, `headers`, `proxy_providers` |
| [Key vaults](vaults.md) | `key_vaults`, `vault_timeout` |
| [External API keys](misc.md#external-api-keys) | `omdb_api_key`, `tmdb_api_key`, `tvdb_api_key`, `tvdb_pin`, `tvdb_order`, `simkl_client_id`, `metadata_providers`, `disable_metadata`, `decrypt_labs_api_key`, `ipinfo_api_key` |
| [External API keys](misc.md#external-api-keys) | `omdb_api_key`, `tmdb_api_key`, `tvdb_api_key`, `tvdb_pin`, `tvdb_order`, `simkl_client_id`, `metadata_providers`, `anilist_title_language`, `disable_metadata`, `decrypt_labs_api_key`, `ipinfo_api_key` |
| [Caching & updates](misc.md#caching-updates) | `title_cache_enabled`, `title_cache_time`, `title_cache_max_retention`, `update_checks`, `update_check_interval` |
| [Logging, privacy & debug](misc.md#logging-privacy-debug) | `redact_paths`, `debug`, `debug_keys`, `debug_requests`, `set_terminal_bg` |
| [Deprecated & removed](misc.md#deprecated-removed-keys) | `curl_impersonate`, `downloader`, `scene_naming` |
+27 -10
View File
@@ -17,6 +17,7 @@ each metadata provider can actually answer with, see
| `tvdb_pin` | str | `""` | Subscriber PIN, only needed for a user-supported TVDB key. Leave empty for a normal project key. |
| `tvdb_order` | str | `""` | Default for `--tvdb-order`: renumber episodes to a TVDB season order (`official`, `dvd`, `absolute`, `alternate`, `regional`). Empty keeps the numbering the service gives. |
| `metadata_providers` | list or map | *(see below)* | Metadata providers to use, in the order they are tried. |
| `anilist_title_language` | str | `english` | Which AniList title variant to use: `english`, `romaji`, or `native`. |
| `disable_metadata` | bool | `false` | Set to `true` to stop all automatic metadata lookups. An ID you give yourself is still looked up. |
| `decrypt_labs_api_key` | str | `""` | Global Decrypt Labs API key (used by remote CDM / vault). |
| `ipinfo_api_key` | str | `""` | ipinfo.io API key for IP/region lookups. |
@@ -33,16 +34,32 @@ each metadata provider can actually answer with, see
ignored with a warning. Leave it unset for the default order:
```yaml
metadata_providers: [imdb, omdb, simkl, tmdb, tvdb]
metadata_providers: [imdb, omdb, simkl, tmdb, tvdb, anilist]
```
Whatever the order, a provider is skipped when its API key is missing. `imdb` needs no
key, so it is always available. Available names are `imdb`, `omdb`, `simkl`, `tmdb`, and
`tvdb`. The old name `imdbapi` still works and is read as `imdb`.
Whatever the order, unshackle skips a provider that has no API key. `imdb` and
`anilist` need no key, so they are always available. Available names are `imdb`, `omdb`,
`simkl`, `tmdb`, `tvdb`, and `anilist`. The old name `imdbapi` still works and is read as
`imdb`.
`anilist` is last in the default order. It only answers for anime and returns nothing for
anything else, so it is harmless there. Move it earlier if you mostly download anime.
The order applies to a title search and to a direct lookup of an ID you give with
`--tmdb`, `--imdb`, or `--tvdb`. A supplied ID always goes to the providers that read
that kind of ID, and it never falls back to a title search.
`--tmdb`, `--imdb`, `--tvdb`, or `--anilist`. A supplied ID always goes to the providers
that read that kind of ID, and it never falls back to a title search.
!!! note "Which AniList title `anilist_title_language` picks"
AniList holds up to three titles for a work: the English one, the romaji transliteration,
and the native one. This option says which of them unshackle uses:
```yaml
anilist_title_language: romaji
```
A work does not always have all three. When the one you asked for is unset on AniList's
side, unshackle falls back in the order english → romaji → native. It takes the first one
that is set, so you always get a title.
!!! note "What `disable_metadata` turns off"
With `disable_metadata: true`, unshackle never contacts a metadata provider on its own.
@@ -53,10 +70,10 @@ each metadata provider can actually answer with, see
disable_metadata: true
```
An ID you give with `--tmdb`, `--imdb`, `--tvdb`, or `--animeapi` is your permission to use
that ID, so it still works: the ID is looked up directly, the other IDs are cross
referenced from it, and `--enrich` still reads that source. Only the lookups you did not
ask for are stopped.
An ID you give with `--tmdb`, `--imdb`, `--tvdb`, or `--anilist` is your permission to use
that ID. That lookup still works. unshackle looks the ID up directly, cross references the other
IDs from it, and `--enrich` still reads that source. Only the lookups you did not ask for
are stopped.
!!! note "The `imdb` provider needs no key"
It replaces the earlier `imdbapi` provider, which read a third-party mirror
-1
View File
@@ -64,7 +64,6 @@ dependencies = [
"pycountry>=24.6.1",
"language-data>=1.4.0",
"wasmtime>=41.0.0",
"animeapi-py>=0.6.0",
"rnet>=2.4.2",
"bandit>=1.9.4",
"jsonpath-ng>=1.8.0",
+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
+50 -34
View File
@@ -59,6 +59,7 @@ from unshackle.core.music import (
write_music_manifest,
write_music_metadata,
)
from unshackle.core.providers.anilist import parse_anilist_ref
from unshackle.core.providers.tvdb import SEASON_TYPES
from unshackle.core.proxies import Basic, ExpressVPN, Gluetun, Hola, NordVPN, ProtonVPN, SurfsharkVPN, WindscribeVPN
from unshackle.core.service import Service
@@ -137,11 +138,20 @@ def normalize_dl_config(dl_config: dict[str, Any]) -> dict[str, Any]:
ID_PROVIDER_KEYS = {"tmdb": "tmdb_api_key", "tvdb": "tvdb_api_key", "omdb": "omdb_api_key"}
def validate_metadata_ids(tmdb_id: Optional[int], imdb_id: Optional[str], tvdb_id: Optional[int]) -> None:
def validate_metadata_ids(
tmdb_id: Optional[int],
imdb_id: Optional[str],
tvdb_id: Optional[int],
anilist_id: Optional[Union[int, str]] = None,
) -> None:
"""Reject ID flags that conflict, or that no configured provider could ever resolve.
Only ever called with the IDs the user gave, never with the ones --animeapi back-fills.
--anilist stays outside the mutual exclusion: AniList knows no western IDs, so pairing
it with one of the other three is how an anime title gets both.
"""
if anilist_id and not parse_anilist_ref(anilist_id):
raise click.UsageError("--anilist takes an AniList ID like 21, or a MyAnimeList ID like mal:21.")
supplied = [
(flag, value) for flag, value in (("--tmdb", tmdb_id), ("--imdb", imdb_id), ("--tvdb", tvdb_id)) if value
]
@@ -673,12 +683,13 @@ class dl:
"a series' Streaming Order is 'alternate'. Defaults to the tvdb_order config option.",
)
@click.option(
"--animeapi",
"animeapi_id",
"--anilist",
"anilist_id",
type=str,
default=None,
help="Anime database ID via AnimeAPI (e.g. mal:12345, anilist:98765). Defaults to MAL if no prefix. "
"Back-fills the TMDB, IMDB and TVDB IDs it knows for the show.",
help="Use this AniList ID for tagging instead of an automatic search, or a MyAnimeList ID as mal:12345. "
"Add --enrich to also take its title, year and original language. Combines with one of "
"--tmdb, --imdb or --tvdb, which AniList does not know.",
)
@click.option(
"--enrich",
@@ -686,7 +697,7 @@ class dl:
default=False,
help=(
"Overwrite the show title, year and original language with the external source's. "
"Requires --tmdb, --imdb, --tvdb, or --animeapi."
"Requires --tmdb, --imdb, --tvdb, or --anilist."
),
)
@click.option(
@@ -852,7 +863,7 @@ class dl:
imdb_id: Optional[str] = None,
tvdb_id: Optional[int] = None,
tvdb_order: Optional[str] = None,
animeapi_id: Optional[str] = None,
anilist_id: Optional[Union[int, str]] = None,
enrich: bool = False,
output_dir: Optional[Path] = None,
*_: Any,
@@ -892,38 +903,27 @@ class dl:
imdb_id = ctx.params.get("imdb_id", imdb_id)
tvdb_id = ctx.params.get("tvdb_id", tvdb_id)
tvdb_order = ctx.params.get("tvdb_order", tvdb_order)
animeapi_id = ctx.params.get("animeapi_id", animeapi_id)
anilist_id = ctx.params.get("anilist_id", anilist_id)
enrich = ctx.params.get("enrich", enrich)
output_dir = ctx.params.get("output_dir", output_dir)
self.profile = profile
self.proxy_requested = bool(proxy)
validate_metadata_ids(tmdb_id, imdb_id, tvdb_id)
validate_metadata_ids(tmdb_id, imdb_id, tvdb_id, anilist_id)
self.tmdb_id = tmdb_id
self.imdb_id = imdb_id
self.tvdb_id = tvdb_id
self.anilist_id = anilist_id
self.tvdb_order = (tvdb_order or config.tvdb_order or "").lower()
if self.tvdb_order and self.tvdb_order not in SEASON_TYPES:
raise click.UsageError(f"tvdb_order must be one of {', '.join(SEASON_TYPES)}, not {self.tvdb_order!r}")
self.enrich = enrich
self.animeapi_title: Optional[str] = None
self.output_dir = output_dir
self.service_anime = False
if animeapi_id:
from unshackle.core.utils.animeapi import resolve_animeapi
anime_title, anime_ids = resolve_animeapi(animeapi_id)
self.animeapi_title = anime_title
if not self.tmdb_id and anime_ids.tmdb_id:
self.tmdb_id = anime_ids.tmdb_id
if not self.imdb_id and anime_ids.imdb_id:
self.imdb_id = anime_ids.imdb_id
if not self.tvdb_id and anime_ids.tvdb_id:
self.tvdb_id = anime_ids.tvdb_id
if self.enrich and not (self.tmdb_id or self.imdb_id or self.tvdb_id or self.animeapi_title):
if self.enrich and not (self.tmdb_id or self.imdb_id or self.tvdb_id or self.anilist_id):
raise click.UsageError(
"--enrich requires --tmdb, --imdb, --tvdb, or --animeapi to provide a metadata source."
"--enrich requires --tmdb, --imdb, --tvdb, or --anilist to provide a metadata source."
)
# Initialize debug logger with service name if debug logging is enabled
@@ -949,7 +949,7 @@ class dl:
"tag": tag,
"tmdb_id": self.tmdb_id,
"imdb_id": self.imdb_id,
"animeapi_id": animeapi_id,
"anilist_id": anilist_id,
"enrich": enrich,
"cli_params": {
k: v
@@ -961,7 +961,7 @@ class dl:
"tag",
"tmdb_id",
"imdb_id",
"animeapi_id",
"anilist_id",
"enrich",
]
},
@@ -1386,6 +1386,7 @@ class dl:
) -> None:
self.tmdb_searched = False
self.search_source = None
self.service_anime = bool(getattr(service, "ANIME", False))
self.server_cdm = getattr(service, "_server_cdm", False)
self._remote_service = service if self.server_cdm else None
start_time = time.time()
@@ -1615,22 +1616,20 @@ class dl:
enrich_year: Optional[int] = None
enrich_lang: Optional[Language] = None
if self.animeapi_title:
enrich_title = self.animeapi_title
enrich_result = providers.resolve_by_ids(
self.tmdb_id,
self.imdb_id,
self.tvdb_id,
self.anilist_id,
kind=kind,
title_cacher=title_cacher,
cache_title_id=cache_title_id,
cache_region=cache_region,
cache_account_hash=cache_account_hash,
anime=self.anime_hint(sample_title),
)
if enrich_result:
if not enrich_title:
enrich_title = enrich_result.title
enrich_title = enrich_result.title
enrich_year = enrich_result.year
enrich_lang = parse_language(enrich_result.original_language)
@@ -2358,11 +2357,12 @@ class dl:
if isinstance(title, Episode) and not self.tmdb_searched:
kind = "tv"
tmdb_title: Optional[str] = None
has_ids = bool(self.tmdb_id or self.imdb_id or self.tvdb_id)
has_ids = bool(self.tmdb_id or self.imdb_id or self.tvdb_id or self.anilist_id)
result = providers.resolve_by_ids(
self.tmdb_id,
self.imdb_id,
self.tvdb_id,
self.anilist_id,
title=title.title,
year=title.year,
kind=kind,
@@ -2370,6 +2370,7 @@ class dl:
cache_title_id=cache_title_id,
cache_region=cache_region,
cache_account_hash=cache_account_hash,
anime=self.anime_hint(title),
)
if result:
if has_ids:
@@ -2397,6 +2398,7 @@ class dl:
None,
self.imdb_id,
self.tvdb_id,
self.anilist_id,
title=title.name,
year=title.year,
kind="movie",
@@ -2404,6 +2406,7 @@ class dl:
cache_title_id=cache_title_id,
cache_region=cache_region,
cache_account_hash=cache_account_hash,
anime=self.anime_hint(title),
)
if movie_result and movie_result.external_ids.tmdb_id:
console.print(
@@ -3869,7 +3872,15 @@ class dl:
shutil.move(muxed_path, final_path)
used_final_paths.add(final_path)
self.completed_files.append(final_path)
tags.tag_file(final_path, title, self.tmdb_id, self.imdb_id, self.tvdb_id)
tags.tag_file(
final_path,
title,
self.tmdb_id,
self.imdb_id,
self.tvdb_id,
self.anilist_id,
self.anime_hint(title),
)
title_dl_time = time_elapsed_since(dl_start_time)
downloaded_label = "Track" if music_mode and isinstance(title, Song) else "Title"
@@ -3895,6 +3906,11 @@ class dl:
console.print(Padding(f"Processed all titles in [progress.elapsed]{dl_time}", (0, 5, 1, 5)))
def anime_hint(self, title: Optional[Title_T] = None) -> bool:
"""Whether metadata lookups for this title should prefer AniList."""
per_title = getattr(title, "anime", None)
return self.service_anime if per_title is None else bool(per_title)
def apply_tvdb_order(
self,
titles: Series,
+2 -2
View File
@@ -420,7 +420,7 @@ def _perform_download(
"imdb_id": params.get("imdb_id"),
"tvdb_id": params.get("tvdb_id"),
"tvdb_order": params.get("tvdb_order"),
"animeapi_id": params.get("animeapi_id"),
"anilist_id": params.get("anilist_id"),
"enrich": params.get("enrich", False),
"output_dir": Path(params["output_dir"]) if params.get("output_dir") else None,
"no_cache": params.get("no_cache", False),
@@ -452,7 +452,7 @@ def _perform_download(
imdb_id=params.get("imdb_id"),
tvdb_id=params.get("tvdb_id"),
tvdb_order=params.get("tvdb_order"),
animeapi_id=params.get("animeapi_id"),
anilist_id=params.get("anilist_id"),
enrich=params.get("enrich", False),
output_dir=Path(params["output_dir"]) if params.get("output_dir") else None,
)
+9 -1
View File
@@ -14,6 +14,7 @@ from unshackle.core.api.input_bridge import AuthStatus, InputBridge
from unshackle.core.api.sanitize import safe_cache_key, sanitize_log
from unshackle.core.config import config
from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
from unshackle.core.providers.anilist import parse_anilist_ref
from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_proxy
from unshackle.core.services import Services
from unshackle.core.titles import Episode, Movie, Title_T
@@ -77,7 +78,7 @@ DEFAULT_DOWNLOAD_PARAMS = {
"imdb_id": None,
"tvdb_id": None,
"tvdb_order": None,
"animeapi_id": None,
"anilist_id": None,
"enrich": False,
"output_dir": None,
"no_cache": False,
@@ -936,6 +937,13 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
if not isinstance(data["imdb_id"], str) or not re.fullmatch(r"tt\d+", data["imdb_id"]):
return "imdb_id must be an IMDB ID like 'tt1375666'"
if data.get("anilist_id") is not None:
value = data["anilist_id"]
# same parser as --anilist so both surfaces accept and reject the same inputs
valid = not isinstance(value, bool) and isinstance(value, (int, str)) and parse_anilist_ref(value)
if not valid:
return "anilist_id must be a positive integer, or a MyAnimeList ID like 'mal:21'"
supplied_ids = [name for name in ("tmdb_id", "imdb_id", "tvdb_id") if data.get(name)]
if len(supplied_ids) > 1:
return (
+6 -4
View File
@@ -759,12 +759,14 @@ async def download(request: web.Request) -> web.Response:
tmdb_id:
type: integer
description: Use this TMDB ID for tagging instead of a title search. Set enrich to also take its title, year and original language. Mutually exclusive with imdb_id and tvdb_id. Needs tmdb_api_key (default - None)
animeapi_id:
type: string
description: Anime database ID via AnimeAPI, e.g. mal:12345 (default - None)
anilist_id:
oneOf:
- type: integer
- type: string
description: AniList ID for tagging instead of a title search, or a MyAnimeList ID as the string mal:21. Combines with one of tmdb_id, imdb_id and tvdb_id, which AniList does not know (default - None)
enrich:
type: boolean
description: Overwrite show title, year and original language with the external source's. Requires one of tmdb_id, imdb_id, tvdb_id or animeapi_id (default - false)
description: Overwrite show title, year and original language with the external source's. Requires one of tmdb_id, imdb_id, tvdb_id or anilist_id (default - false)
no_folder:
type: boolean
description: Disable folder creation for TV shows (default - false)
+1
View File
@@ -120,6 +120,7 @@ class Config:
self.tvdb_pin: str = kwargs.get("tvdb_pin") or ""
self.tvdb_order: str = (kwargs.get("tvdb_order") or "").lower()
self.metadata_providers: Union[list, dict] = kwargs.get("metadata_providers") or []
self.anilist_title_language: str = (kwargs.get("anilist_title_language") or "english").lower()
self.disable_metadata: bool = kwargs.get("disable_metadata", False)
self.simkl_client_id: str = kwargs.get("simkl_client_id") or ""
self.decrypt_labs_api_key: str = kwargs.get("decrypt_labs_api_key") or ""
+26 -7
View File
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Optional, Union
import requests
from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, fuzzy_match, log
from unshackle.core.providers.anilist import AniListProvider, parse_anilist_ref
from unshackle.core.providers.imdb import IMDBProvider
from unshackle.core.providers.omdb import OMDBProvider
from unshackle.core.providers.simkl import SimklProvider
@@ -15,21 +16,24 @@ if TYPE_CHECKING:
from unshackle.core.title_cacher import TitleCacher
REGISTRY: dict[str, type[MetadataProvider]] = {
cls.NAME: cls for cls in (IMDBProvider, OMDBProvider, SimklProvider, TMDBProvider, TVDBProvider)
cls.NAME: cls for cls in (AniListProvider, IMDBProvider, OMDBProvider, SimklProvider, TMDBProvider, TVDBProvider)
}
# legacy `metadata_providers` names, still accepted
ALIASES: dict[str, str] = {"imdbapi": "imdb"}
# used when `metadata_providers` is unset
DEFAULT_ORDER: tuple[str, ...] = ("imdb", "omdb", "simkl", "tmdb", "tvdb")
# used when `metadata_providers` is unset; anilist answers for anime only, so it costs
# nothing at the end of the order
DEFAULT_ORDER: tuple[str, ...] = ("imdb", "omdb", "simkl", "tmdb", "tvdb", "anilist")
def provider_order(kind: Optional[str] = None) -> list[type[MetadataProvider]]:
def provider_order(kind: Optional[str] = None, anime: bool = False) -> list[type[MetadataProvider]]:
"""Provider classes in the order configured by `metadata_providers`.
`metadata_providers` is either a flat list applying to both kinds, or a mapping of
kind ("tv"/"movie") to its own list. A kind the mapping omits uses `DEFAULT_ORDER`.
An `anime` title puts anilist first; the rest of the order stays behind it to
fall back on.
"""
from unshackle.core.config import config
@@ -37,6 +41,8 @@ def provider_order(kind: Optional[str] = None) -> list[type[MetadataProvider]]:
selected = (configured.get(kind) if kind else None) if isinstance(configured, dict) else configured
names = [ALIASES.get(str(n).lower(), str(n).lower()) for n in (selected or DEFAULT_ORDER)]
if anime and AniListProvider.NAME in names:
names = [AniListProvider.NAME, *names]
unknown = [n for n in names if n not in REGISTRY]
if unknown:
log.warning("Ignoring unknown metadata_providers entries: %s", ", ".join(unknown))
@@ -68,6 +74,7 @@ def search_metadata(
cache_title_id: Optional[str] = None,
cache_region: Optional[str] = None,
cache_account_hash: Optional[str] = None,
anime: bool = False,
) -> Optional[MetadataResult]:
"""Search all available providers for metadata. Returns best match."""
from unshackle.core.config import config
@@ -78,7 +85,7 @@ def search_metadata(
log.debug("Metadata lookups are disabled by config; not searching for %r", title)
return None
ordered = provider_order(kind)
ordered = provider_order(kind, anime)
# Check cache first
if title_cacher and cache_title_id:
@@ -290,6 +297,7 @@ def resolve_by_ids(
tmdb_id: Optional[int] = None,
imdb_id: Optional[str] = None,
tvdb_id: Optional[int] = None,
anilist_id: Optional[Union[int, str]] = None,
*,
title: Optional[str] = None,
year: Optional[int] = None,
@@ -298,6 +306,7 @@ def resolve_by_ids(
cache_title_id: Optional[str] = None,
cache_region: Optional[str] = None,
cache_account_hash: Optional[str] = None,
anime: bool = False,
) -> Optional[MetadataResult]:
"""Resolve metadata from user-supplied external IDs, falling back to search only without them.
@@ -313,14 +322,16 @@ def resolve_by_ids(
supplied["imdb"] = imdb_id
if tvdb_id is not None:
supplied["tvdb"] = tvdb_id
if anilist_id is not None:
supplied["anilist"] = anilist_id
if not supplied:
if not title:
return None
return search_metadata(title, year, kind, title_cacher, cache_title_id, cache_region, cache_account_hash)
return search_metadata(title, year, kind, title_cacher, cache_title_id, cache_region, cache_account_hash, anime)
result: Optional[MetadataResult] = None
for cls in provider_order(kind):
for cls in provider_order(kind, anime):
provider_id = supplied.get(cls.ID_KIND or "")
if provider_id is None:
continue
@@ -346,6 +357,10 @@ def resolve_by_ids(
ids.imdb_id = imdb_id
if tvdb_id is not None:
ids.tvdb_id = tvdb_id
if anilist_id is not None and ids.anilist_id is None:
ref = parse_anilist_ref(anilist_id)
if ref and ref[0] == "id":
ids.anilist_id = ref[1]
if ids.tmdb_id and not (ids.imdb_id and ids.tvdb_id):
ext = fetch_external_ids(ids.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash)
@@ -480,6 +495,8 @@ def _external_ids_to_dict(ext: ExternalIds) -> dict:
result["tmdb_kind"] = ext.tmdb_kind
if ext.tvdb_id:
result["tvdb_id"] = ext.tvdb_id
if ext.anilist_id:
result["anilist_id"] = ext.anilist_id
return result
@@ -567,6 +584,8 @@ def _cached_to_result(cached: dict, provider_name: str, kind: str) -> Optional[M
source="tvdb",
raw=cached,
)
elif provider_name == "anilist":
return AniListProvider()._to_result(cached)
elif provider_name == "imdb":
from unshackle.core.providers.imdb import primary_language
+1
View File
@@ -26,6 +26,7 @@ class ExternalIds:
tmdb_id: Optional[int] = None
tmdb_kind: Optional[str] = None # "movie" or "tv"
tvdb_id: Optional[int] = None
anilist_id: Optional[int] = None
@dataclass
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
from difflib import SequenceMatcher
from typing import Optional, Union
import requests
from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, _clean
GRAPHQL_URL = "https://graphql.anilist.co"
MEDIA_FIELDS = "id idMal title { romaji english native } format startDate { year } countryOfOrigin"
SEARCH_QUERY = (
"query UnshackleAniListSearch($search: String) { Page(perPage: 10) "
"{ media(search: $search, type: ANIME, sort: SEARCH_MATCH) { %s } } }" % MEDIA_FIELDS
)
MEDIA_QUERY = "query UnshackleAniListMedia($id: Int) { Media(id: $id, type: ANIME) { %s } }" % MEDIA_FIELDS
MAL_QUERY = "query UnshackleAniListMal($idMal: Int) { Media(idMal: $idMal, type: ANIME) { %s } }" % MEDIA_FIELDS
TITLE_LANGUAGES: tuple[str, ...] = ("english", "romaji", "native")
# AniList records where a title was produced, not what it is spoken in; these are the
# production countries whose anime output is reliably in the country's own language
COUNTRY_TO_LANGUAGE: dict[str, str] = {"JP": "ja", "KR": "ko", "CN": "zh", "TW": "zh", "HK": "zh"}
def parse_anilist_ref(value: Union[int, str]) -> Optional[tuple[str, int]]:
"""Split an AniList reference into its namespace and number.
A bare number is an AniList ID; ``mal:12345`` is a MyAnimeList ID, which AniList
resolves natively. Returns None for anything else.
"""
text = str(value).strip().lower()
namespace = "id"
if ":" in text:
prefix, _, text = text.partition(":")
if prefix != "mal":
return None
namespace = "mal"
# isdecimal, not isdigit: superscripts pass isdigit but crash int()
if not text.isdecimal():
return None
number = int(text)
return (namespace, number) if number > 0 else None
class AniListProvider(MetadataProvider):
"""Anime metadata provider using AniList's public GraphQL API (no API key)."""
NAME = "anilist"
REQUIRES_KEY = False
ID_KIND = "anilist"
def is_available(self) -> bool:
return True
def _graphql(self, query: str, variables: dict) -> Optional[dict]:
try:
r = self.session.post(GRAPHQL_URL, json={"query": query, "variables": variables}, timeout=30)
body = r.json()
except (requests.RequestException, ValueError) as exc:
self.log.debug("AniList GraphQL request failed: %s", exc)
return None
if body.get("errors"):
# a miss is a 404 with an error body, not an empty result
self.log.debug("AniList GraphQL errors: %s", [e.get("message") for e in body["errors"]][:3])
return None
return body.get("data")
def _title_variant(self, titles: dict) -> Optional[str]:
from unshackle.core.config import config
configured = (config.anilist_title_language or "english").lower()
for variant in [configured, *(v for v in TITLE_LANGUAGES if v != configured)]:
value = titles.get(variant)
if value:
return str(value)
return None
def _to_result(self, node: dict) -> Optional[MetadataResult]:
title = self._title_variant(node.get("title") or {})
if not title:
return None
return MetadataResult(
title=title,
year=(node.get("startDate") or {}).get("year"),
kind="movie" if node.get("format") == "MOVIE" else "tv",
external_ids=ExternalIds(anilist_id=node.get("id")),
original_language=COUNTRY_TO_LANGUAGE.get(node.get("countryOfOrigin") or ""),
source="anilist",
raw=node,
)
def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]:
self.log.debug("Searching AniList for %r (%s, %s)", title, kind, year)
data = self._graphql(SEARCH_QUERY, {"search": title})
nodes = ((data or {}).get("Page") or {}).get("media") or []
if not nodes:
self.log.debug("AniList returned no results for %r", title)
return None
# soft filters with fallback: other providers search kind-scoped endpoints, here
# one ANIME search covers both kinds, so a TV hit must not shadow a movie search
matching = [n for n in nodes if (n.get("format") == "MOVIE") == (kind == "movie")]
nodes = matching or nodes
if year:
dated = [n for n in nodes if (n.get("startDate") or {}).get("year") == year]
nodes = dated or nodes
best = max(nodes, key=lambda n: _match_ratio(n, title))
result = self._to_result(best)
if result:
self.log.debug("AniList -> %s (ID %s)", result.title, result.external_ids.anilist_id)
return result
def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]:
"""Fetch metadata by AniList ID, or by MyAnimeList ID when given as 'mal:12345'."""
ref = parse_anilist_ref(provider_id)
if not ref:
self.log.debug("Not an AniList reference: %r", provider_id)
return None
namespace, number = ref
self.log.debug("Fetching AniList media %s:%d", namespace, number)
if namespace == "mal":
data = self._graphql(MAL_QUERY, {"idMal": number})
else:
data = self._graphql(MEDIA_QUERY, {"id": number})
node = (data or {}).get("Media")
if not node:
return None
return self._to_result(node)
def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds:
"""Return external IDs. AniList knows no western IDs, so only its own."""
ref = parse_anilist_ref(provider_id)
if not ref:
return ExternalIds()
namespace, number = ref
if namespace == "id":
return ExternalIds(anilist_id=number)
result = self.get_by_id(provider_id, kind)
return result.external_ids if result else ExternalIds()
def _match_ratio(node: dict, title: str) -> float:
names = (node.get("title") or {}).values()
return max((SequenceMatcher(None, _clean(title), _clean(n)).ratio() for n in names if n), default=0.0)
+1
View File
@@ -93,6 +93,7 @@ class Service(metaclass=ABCMeta):
# Abstract class variables
ALIASES: tuple[str, ...] = () # list of aliases for the service; alternatives to the service tag.
GEOFENCE: tuple[str, ...] = () # list of ip regions required to use the service. empty list == no specific region.
ANIME: bool = False # service catalogue is anime; metadata lookups prefer AniList. Title.anime overrides per title.
# vault namespace override; when set, key vault read/write uses this tag instead of the service's own.
VAULT_TAG: Optional[str] = None
# Auth methods the service accepts ("cookies"/"credentials"); when None the REST /services
+1
View File
@@ -47,6 +47,7 @@ class Title:
self.service = service
self.language = language
self.data = data
self.anime: Optional[bool] = None
self.tracks = Tracks()
-92
View File
@@ -1,92 +0,0 @@
from __future__ import annotations
import logging
from typing import Optional
from unshackle.core.providers._base import ExternalIds
log = logging.getLogger("ANIMEAPI")
PLATFORM_MAP: dict[str, str] = {
"mal": "myanimelist",
"anilist": "anilist",
"kitsu": "kitsu",
"tmdb": "themoviedb",
"trakt": "trakt",
"tvdb": "thetvdb",
}
def resolve_animeapi(value: str) -> tuple[Optional[str], ExternalIds]:
"""Resolve an anime database ID via AnimeAPI to a title and external IDs.
Accepts formats like 'mal:12345', 'anilist:98765', or just '12345' (defaults to MAL).
Returns (anime_title, ExternalIds) with any TMDB/IMDB/TVDB IDs found.
"""
import animeapi
platform_str, id_str = _parse_animeapi_value(value)
platform_enum = _get_platform(platform_str)
if platform_enum is None:
log.warning("Unknown AnimeAPI platform: %s (supported: %s)", platform_str, ", ".join(PLATFORM_MAP))
return None, ExternalIds()
log.info("Resolving AnimeAPI %s:%s", platform_str, id_str)
try:
with animeapi.AnimeAPI() as api:
relation = api.get_anime_relations(id_str, platform_enum)
except Exception as exc:
log.warning("AnimeAPI lookup failed for %s:%s: %s", platform_str, id_str, exc)
return None, ExternalIds()
title = getattr(relation, "title", None)
tmdb_id = getattr(relation, "themoviedb", None)
tmdb_type = getattr(relation, "themoviedb_type", None)
imdb_id = getattr(relation, "imdb", None)
tvdb_id = getattr(relation, "thetvdb", None)
tmdb_kind: Optional[str] = None
if tmdb_type is not None:
tmdb_kind = tmdb_type.value if hasattr(tmdb_type, "value") else str(tmdb_type).lower()
if tmdb_kind not in ("movie", "tv"):
tmdb_kind = "tv"
external_ids = ExternalIds(
tmdb_id=int(tmdb_id) if tmdb_id is not None else None,
tmdb_kind=tmdb_kind,
imdb_id=str(imdb_id) if imdb_id is not None else None,
tvdb_id=int(tvdb_id) if tvdb_id is not None else None,
)
log.info(
"AnimeAPI resolved: title=%r, tmdb=%s, imdb=%s, tvdb=%s",
title,
external_ids.tmdb_id,
external_ids.imdb_id,
external_ids.tvdb_id,
)
return title, external_ids
def _parse_animeapi_value(value: str) -> tuple[str, str]:
"""Parse 'platform:id' format. Defaults to 'mal' if no prefix."""
if ":" in value:
platform, _, id_str = value.partition(":")
return platform.lower().strip(), id_str.strip()
return "mal", value.strip()
def _get_platform(platform_str: str) -> object | None:
"""Map a platform string to an animeapi.Platform enum value."""
import animeapi
canonical = PLATFORM_MAP.get(platform_str)
if canonical is None:
return None
platform_name = canonical.upper()
return getattr(animeapi.Platform, platform_name, None)
+9 -3
View File
@@ -5,7 +5,7 @@ import subprocess
import tempfile
import time
from pathlib import Path
from typing import Optional
from typing import Optional, Union
from xml.sax.saxutils import escape
from unshackle.core import binaries
@@ -74,6 +74,8 @@ def _build_tags_from_ids(ids: ExternalIds, kind: str) -> dict[str, str]:
if ids.tvdb_id:
prefix = "movies" if kind == "movie" else "series"
tags["TVDB2"] = f"{prefix}/{ids.tvdb_id}"
if ids.anilist_id:
tags["ANILIST"] = str(ids.anilist_id)
return tags
@@ -83,6 +85,8 @@ def tag_file(
tmdb_id: Optional[int] = None,
imdb_id: Optional[str] = None,
tvdb_id: Optional[int] = None,
anilist_id: Optional[Union[int, str]] = None,
anime: bool = False,
) -> None:
log.debug("Tagging file %s with title %r", path, title)
custom_tags: dict[str, str] = {}
@@ -114,13 +118,15 @@ def tag_file(
if config.tag_imdb_tmdb:
try:
has_ids = tmdb_id is not None or imdb_id is not None or tvdb_id is not None
has_ids = any(value is not None for value in (tmdb_id, imdb_id, tvdb_id, anilist_id))
if not get_available_providers() and not has_ids:
log.debug("No metadata providers available; skipping tag lookup")
apply_tags(path, custom_tags)
return
result = resolve_by_ids(tmdb_id, imdb_id, tvdb_id, title=name, year=year, kind=kind)
result = resolve_by_ids(
tmdb_id, imdb_id, tvdb_id, anilist_id, title=name, year=year, kind=kind, anime=anime
)
if result and result.external_ids:
standard_tags = _build_tags_from_ids(result.external_ids, kind)
+6
View File
@@ -72,6 +72,9 @@ class EXAMPLE(Service):
TITLE_RE = r"^(?:https?://(?:www\.)?domain\.com/details/)?(?P<title_id>[^/?#]+)"
# NO_SUBTITLES: service-level idiom telling the pipeline subs are handled in-band.
NO_SUBTITLES = False
# ANIME: this catalogue is anime, so metadata lookups ask AniList first. Set it False (the
# default) on a mixed catalogue and flag the anime titles individually in get_titles().
ANIME = True
# VAULT_TAG: store/read keys under a different vault namespace than this service's tag.
# Lets sibling services share one key vault. Omit to use the service's own tag.
VAULT_TAG = "DIFFERENT_NAME"
@@ -287,6 +290,9 @@ class EXAMPLE(Service):
# air_date=ep.get("airDate"),
)
)
# Per-title override of the ANIME class attr, for a mixed catalogue. Leave it
# None (the default) and the title inherits the service's ANIME value.
episodes[-1].anime = metadata.get("category") == "anime"
return Series(episodes)
# DEFAULT (shown live): this service needs a SEPARATE manifest per codec/range,
Generated
-25
View File
@@ -158,20 +158,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "animeapi-py"
version = "3.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "dacite" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/a2/f868bba3b6399e2c953f8796fa90c8de91f797ad0fe19c7e2c536cd0092f/animeapi_py-3.8.1.tar.gz", hash = "sha256:9ed10f8207f1ccd7949ee3728285f4aced9a5ec57f71d211594ca5dc356fdea5", size = 25663, upload-time = "2026-02-25T15:29:17.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/98/6775d71cf7d88d554e8394f5ce5cda90041c99fdf1b2b60af02001e8c790/animeapi_py-3.8.1-py3-none-any.whl", hash = "sha256:c29f6e633d17bb613f459aa6514c0baab7ae325881f8a109eb6e4b3be5c22827", size = 26983, upload-time = "2026-02-25T15:29:16.685Z" },
]
[[package]]
name = "appdirs"
version = "1.4.4"
@@ -541,15 +527,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747, upload-time = "2024-06-04T15:51:37.499Z" },
]
[[package]]
name = "dacite"
version = "1.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/55/a0/7ca79796e799a3e782045d29bf052b5cde7439a2bbb17f15ff44f7aacc63/dacite-1.9.2.tar.gz", hash = "sha256:6ccc3b299727c7aa17582f0021f6ae14d5de47c7227932c47fec4cdfefd26f09", size = 22420, upload-time = "2025-02-05T09:27:29.757Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" },
]
[[package]]
name = "defusedxml"
version = "0.7.1"
@@ -2148,7 +2125,6 @@ source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "aiohttp-swagger3" },
{ name = "animeapi-py" },
{ name = "appdirs" },
{ name = "bandit" },
{ name = "brotli" },
@@ -2216,7 +2192,6 @@ test = [
requires-dist = [
{ name = "aiohttp", specifier = ">=3.14.0,<4" },
{ name = "aiohttp-swagger3", specifier = ">=0.9.0,<1" },
{ name = "animeapi-py", specifier = ">=0.6.0" },
{ name = "appdirs", specifier = ">=1.4.4,<2" },
{ name = "bandit", specifier = ">=1.9.4" },
{ name = "brotli", specifier = ">=1.1.0,<2" },