From 572a8946208690de3dbe59dadbf1d4c1b5d8f387 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 28 Feb 2026 12:51:14 -0700 Subject: [PATCH] feat(dl): add --animeapi and --enrich options for anime metadata and tagging Add AnimeAPI integration to resolve anime database IDs (MAL, AniList, Kitsu, etc.) to TMDB/IMDB/TVDB for MKV tagging. The --enrich flag overrides show title and fills in year when missing from the service. - Add animeapi-py dependency for cross-platform anime ID resolution - Add --animeapi option (e.g. mal:12345, anilist:98765, defaults to MAL) - Add --enrich flag to override title/year from external sources - Remove --tmdb-name and --tmdb-year in favor of unified --enrich - Update REST API params and docs to match --- docs/API.md | 4 +- pyproject.toml | 1 + unshackle/commands/dl.py | 124 ++++++++++++++++--------- unshackle/core/api/download_manager.py | 8 +- unshackle/core/api/routes.py | 10 +- unshackle/core/utils/animeapi.py | 92 ++++++++++++++++++ uv.lock | 25 +++++ 7 files changed, 208 insertions(+), 56 deletions(-) create mode 100644 unshackle/core/utils/animeapi.py diff --git a/docs/API.md b/docs/API.md index 3da5061..34b8cdd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -231,9 +231,9 @@ Start a download job. Returns immediately with a job ID (HTTP 202). | `tag` | string | `null` | Override group tag | | `repack` | boolean | `false` | Add REPACK tag to filename | | `tmdb_id` | int | `null` | Use specific TMDB ID for tagging | -| `tmdb_name` | boolean | `false` | Rename titles using TMDB name | -| `tmdb_year` | boolean | `false` | Use TMDB release year | | `imdb_id` | string | `null` | Use specific IMDB ID (e.g., `tt1375666`) | +| `animeapi_id` | string | `null` | Anime database ID via AnimeAPI (e.g., `mal:12345`) | +| `enrich` | boolean | `false` | Override show title and year from external source | | `no_folder` | boolean | `false` | Disable folder creation for TV shows | | `no_source` | boolean | `false` | Remove source tag from filename | | `no_mux` | boolean | `false` | Do not mux tracks into container | diff --git a/pyproject.toml b/pyproject.toml index efb360e..42892af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dependencies = [ "pycountry>=24.6.1", "language-data>=1.4.0", "wasmtime>=41.0.0", + "animeapi-py>=0.6.0", ] [project.urls] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 3e8ee97..954c3f7 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -416,18 +416,17 @@ class dl: help="Use this TMDB ID for tagging instead of automatic lookup.", ) @click.option( - "--tmdb-name", - "tmdb_name", - is_flag=True, - default=False, - help="Rename titles using the name returned from TMDB lookup.", + "--animeapi", + "animeapi_id", + type=str, + default=None, + help="Anime database ID via AnimeAPI (e.g. mal:12345, anilist:98765). Defaults to MAL if no prefix.", ) @click.option( - "--tmdb-year", - "tmdb_year", + "--enrich", is_flag=True, default=False, - help="Use the release year from TMDB for naming and tagging.", + help="Override show title and year from external source. Requires --tmdb, --imdb, or --animeapi.", ) @click.option( "--imdb", @@ -528,9 +527,9 @@ class dl: repack: bool = False, tag: Optional[str] = None, tmdb_id: Optional[int] = None, - tmdb_name: bool = False, - tmdb_year: bool = False, imdb_id: Optional[str] = None, + animeapi_id: Optional[str] = None, + enrich: bool = False, output_dir: Optional[Path] = None, *_: Any, **__: Any, @@ -575,11 +574,24 @@ class dl: self.profile = profile self.tmdb_id = tmdb_id - self.tmdb_name = tmdb_name - self.tmdb_year = tmdb_year self.imdb_id = imdb_id + self.enrich = enrich + self.animeapi_title: Optional[str] = None self.output_dir = output_dir + 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 self.enrich and not (self.tmdb_id or self.imdb_id or self.animeapi_title): + raise click.UsageError("--enrich requires --tmdb, --imdb, or --animeapi to provide a metadata source.") + # Initialize debug logger with service name if debug logging is enabled if config.debug or logging.root.level == logging.DEBUG: from collections import defaultdict @@ -601,14 +613,23 @@ class dl: "profile": profile, "proxy": proxy, "tag": tag, - "tmdb_id": tmdb_id, - "tmdb_name": tmdb_name, - "tmdb_year": tmdb_year, - "imdb_id": imdb_id, + "tmdb_id": self.tmdb_id, + "imdb_id": self.imdb_id, + "animeapi_id": animeapi_id, + "enrich": enrich, "cli_params": { k: v for k, v in ctx.params.items() - if k not in ["profile", "proxy", "tag", "tmdb_id", "tmdb_name", "tmdb_year", "imdb_id"] + if k + not in [ + "profile", + "proxy", + "tag", + "tmdb_id", + "imdb_id", + "animeapi_id", + "enrich", + ] }, }, ) @@ -1089,40 +1110,51 @@ class dl: cache_region = service.current_region if hasattr(service, "current_region") else None cache_account_hash = get_account_hash(service.credential) if hasattr(service, "credential") else None - if (self.tmdb_year or self.tmdb_name) and self.tmdb_id: + if self.enrich: sample_title = titles[0] if hasattr(titles, "__getitem__") else titles kind = "tv" if isinstance(sample_title, Episode) else "movie" - tmdb_year_val = None - tmdb_name_val = None + enrich_title: Optional[str] = None + enrich_year: Optional[int] = None - if self.tmdb_year: - tmdb_year_val = providers.get_year_by_id( + if self.animeapi_title: + enrich_title = self.animeapi_title + + if self.tmdb_id: + if not enrich_title: + enrich_title = providers.get_title_by_id( + self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash + ) + enrich_year = providers.get_year_by_id( self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash ) + elif self.imdb_id: + imdbapi = providers.get_provider("imdbapi") + if imdbapi: + imdb_result = imdbapi.get_by_id(self.imdb_id, kind) + if imdb_result: + if not enrich_title: + enrich_title = imdb_result.title + enrich_year = imdb_result.year - if self.tmdb_name: - tmdb_name_val = providers.get_title_by_id( - self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash - ) - - if isinstance(titles, (Series, Movies)): - for t in titles: - if tmdb_year_val: - t.year = tmdb_year_val - if tmdb_name_val: - if isinstance(t, Episode): - t.title = tmdb_name_val + if enrich_title or enrich_year: + if isinstance(titles, (Series, Movies)): + for t in titles: + if enrich_title: + if isinstance(t, Episode): + t.title = enrich_title + else: + t.name = enrich_title + if enrich_year and not t.year: + t.year = enrich_year + else: + if enrich_title: + if isinstance(titles, Episode): + titles.title = enrich_title else: - t.name = tmdb_name_val - else: - if tmdb_year_val: - titles.year = tmdb_year_val - if tmdb_name_val: - if isinstance(titles, Episode): - titles.title = tmdb_name_val - else: - titles.name = tmdb_name_val + titles.name = enrich_title + if enrich_year and not titles.year: + titles.year = enrich_year console.print(Padding(Rule(f"[rule.text]{titles.__class__.__name__}: {titles}"), (1, 2))) @@ -1181,7 +1213,7 @@ class dl: page_size=8, return_indices=True, dependencies=dependencies, - collapse_on_start=multiple_seasons + collapse_on_start=multiple_seasons, ) if not selected_ui_idx: @@ -2399,7 +2431,9 @@ class dl: final_dir.mkdir(parents=True, exist_ok=True) final_path = final_dir / f"{final_filename}{muxed_path.suffix}" - template_type = "series" if isinstance(title, Episode) else "songs" if isinstance(title, Song) else "movies" + template_type = ( + "series" if isinstance(title, Episode) else "songs" if isinstance(title, Song) else "movies" + ) sep = config.get_template_separator(template_type) if final_path.exists() and audio_codec_suffix and append_audio_codec_suffix: diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index a733ec5..beadf4e 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -164,9 +164,9 @@ def _perform_download( "repack": params.get("repack", False), "tag": params.get("tag"), "tmdb_id": params.get("tmdb_id"), - "tmdb_name": params.get("tmdb_name", False), - "tmdb_year": params.get("tmdb_year", False), "imdb_id": params.get("imdb_id"), + "animeapi_id": params.get("animeapi_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), "reset_cache": params.get("reset_cache", False), @@ -180,9 +180,9 @@ def _perform_download( repack=params.get("repack", False), tag=params.get("tag"), tmdb_id=params.get("tmdb_id"), - tmdb_name=params.get("tmdb_name", False), - tmdb_year=params.get("tmdb_year", False), imdb_id=params.get("imdb_id"), + animeapi_id=params.get("animeapi_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/routes.py b/unshackle/core/api/routes.py index b907135..c7f9a28 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -623,12 +623,12 @@ async def download(request: web.Request) -> web.Response: tmdb_id: type: integer description: Use this TMDB ID for tagging (default - None) - tmdb_name: + animeapi_id: + type: string + description: Anime database ID via AnimeAPI, e.g. mal:12345 (default - None) + enrich: type: boolean - description: Rename titles using TMDB name (default - false) - tmdb_year: - type: boolean - description: Use release year from TMDB (default - false) + description: Override show title and year from external source (default - false) no_folder: type: boolean description: Disable folder creation for TV shows (default - false) diff --git a/unshackle/core/utils/animeapi.py b/unshackle/core/utils/animeapi.py new file mode 100644 index 0000000..e16df7f --- /dev/null +++ b/unshackle/core/utils/animeapi.py @@ -0,0 +1,92 @@ +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/uv.lock b/uv.lock index 1a46698..ed0b7e5 100644 --- a/uv.lock +++ b/uv.lock @@ -109,6 +109,20 @@ 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 = "anyio" version = "4.12.1" @@ -449,6 +463,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" }, ] +[[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 = "distlib" version = "0.4.0" @@ -1632,6 +1655,7 @@ source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "aiohttp-swagger3" }, + { name = "animeapi-py" }, { name = "appdirs" }, { name = "brotli" }, { name = "chardet" }, @@ -1690,6 +1714,7 @@ dev = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.13.3,<4" }, { name = "aiohttp-swagger3", specifier = ">=0.9.0,<1" }, + { name = "animeapi-py", specifier = ">=0.6.0" }, { name = "appdirs", specifier = ">=1.4.4,<2" }, { name = "brotli", specifier = ">=1.1.0,<2" }, { name = "chardet", specifier = ">=5.2.0,<6" },