From 72581ba5d2e5a3d862c6457038d3568bb34b1429 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 11 Aug 2026 16:42:05 -0600 Subject: [PATCH] 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. --- docs/dev/rest-api/endpoints.md | 4 +- docs/guide/cli-reference.md | 9 +- docs/guide/downloading.md | 28 ++- docs/reference/configuration/download.md | 2 +- docs/reference/configuration/index.md | 2 +- docs/reference/configuration/misc.md | 37 ++- pyproject.toml | 1 - tests/core/test_anilist_provider.py | 278 ++++++++++++++++++++++ tests/core/test_metadata_id_validation.py | 69 ++++++ tests/core/test_resolve_by_ids.py | 128 ++++++++++ unshackle/commands/dl.py | 84 ++++--- unshackle/core/api/download_manager.py | 4 +- unshackle/core/api/handlers.py | 10 +- unshackle/core/api/routes.py | 10 +- unshackle/core/config.py | 1 + unshackle/core/providers/__init__.py | 33 ++- unshackle/core/providers/_base.py | 1 + unshackle/core/providers/anilist.py | 152 ++++++++++++ unshackle/core/service.py | 1 + unshackle/core/titles/title.py | 1 + unshackle/core/utils/animeapi.py | 92 ------- unshackle/core/utils/tags.py | 12 +- unshackle/services/EXAMPLE/__init__.py | 6 + uv.lock | 25 -- 24 files changed, 794 insertions(+), 196 deletions(-) create mode 100644 tests/core/test_anilist_provider.py create mode 100644 unshackle/core/providers/anilist.py delete mode 100644 unshackle/core/utils/animeapi.py diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 5a16df43..1603b2ba 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -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. | diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 9c273e22..13b176a1 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -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 diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index bb20384a..0b283302 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -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. | diff --git a/docs/reference/configuration/download.md b/docs/reference/configuration/download.md index 26bd375b..c66547c0 100644 --- a/docs/reference/configuration/download.md +++ b/docs/reference/configuration/download.md @@ -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` | diff --git a/docs/reference/configuration/index.md b/docs/reference/configuration/index.md index bf8f4768..2c389669 100644 --- a/docs/reference/configuration/index.md +++ b/docs/reference/configuration/index.md @@ -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` | diff --git a/docs/reference/configuration/misc.md b/docs/reference/configuration/misc.md index 8bdccf1d..33c82b49 100644 --- a/docs/reference/configuration/misc.md +++ b/docs/reference/configuration/misc.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index a78d0fc4..564beb96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/core/test_anilist_provider.py b/tests/core/test_anilist_provider.py new file mode 100644 index 00000000..99e0fea7 --- /dev/null +++ b/tests/core/test_anilist_provider.py @@ -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" diff --git a/tests/core/test_metadata_id_validation.py b/tests/core/test_metadata_id_validation.py index 4e6149ce..909b57f6 100644 --- a/tests/core/test_metadata_id_validation.py +++ b/tests/core/test_metadata_id_validation.py @@ -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 diff --git a/tests/core/test_resolve_by_ids.py b/tests/core/test_resolve_by_ids.py index 15519760..009548e4 100644 --- a/tests/core/test_resolve_by_ids.py +++ b/tests/core/test_resolve_by_ids.py @@ -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 diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 8eaea681..28f17d46 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -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, diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index b250e543..66c594a3 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -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, ) diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index ed263f2a..5baad3f5 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -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 ( diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index f1e44280..bfb2b716 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -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) diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 5edfb134..df5b5f19 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -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 "" diff --git a/unshackle/core/providers/__init__.py b/unshackle/core/providers/__init__.py index 43b4fea1..19ad1dcc 100644 --- a/unshackle/core/providers/__init__.py +++ b/unshackle/core/providers/__init__.py @@ -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 diff --git a/unshackle/core/providers/_base.py b/unshackle/core/providers/_base.py index 7401f757..38770347 100644 --- a/unshackle/core/providers/_base.py +++ b/unshackle/core/providers/_base.py @@ -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 diff --git a/unshackle/core/providers/anilist.py b/unshackle/core/providers/anilist.py new file mode 100644 index 00000000..aed3c680 --- /dev/null +++ b/unshackle/core/providers/anilist.py @@ -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) diff --git a/unshackle/core/service.py b/unshackle/core/service.py index a5d78f3e..ca7eab6c 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -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 diff --git a/unshackle/core/titles/title.py b/unshackle/core/titles/title.py index 91224e3a..ce4716b7 100644 --- a/unshackle/core/titles/title.py +++ b/unshackle/core/titles/title.py @@ -47,6 +47,7 @@ class Title: self.service = service self.language = language self.data = data + self.anime: Optional[bool] = None self.tracks = Tracks() diff --git a/unshackle/core/utils/animeapi.py b/unshackle/core/utils/animeapi.py deleted file mode 100644 index e16df7ff..00000000 --- a/unshackle/core/utils/animeapi.py +++ /dev/null @@ -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) diff --git a/unshackle/core/utils/tags.py b/unshackle/core/utils/tags.py index e8d23718..db2cd1db 100644 --- a/unshackle/core/utils/tags.py +++ b/unshackle/core/utils/tags.py @@ -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) diff --git a/unshackle/services/EXAMPLE/__init__.py b/unshackle/services/EXAMPLE/__init__.py index 703608f3..c5aa0897 100644 --- a/unshackle/services/EXAMPLE/__init__.py +++ b/unshackle/services/EXAMPLE/__init__.py @@ -72,6 +72,9 @@ class EXAMPLE(Service): TITLE_RE = r"^(?:https?://(?:www\.)?domain\.com/details/)?(?P[^/?#]+)" # 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, diff --git a/uv.lock b/uv.lock index d7674e55..76deca2d 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },