mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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:
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -47,6 +47,7 @@ class Title:
|
||||
self.service = service
|
||||
self.language = language
|
||||
self.data = data
|
||||
self.anime: Optional[bool] = None
|
||||
|
||||
self.tracks = Tracks()
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user