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
This commit is contained in:
Andy
2026-02-28 12:51:14 -07:00
parent 5bd03c67cf
commit 572a894620
7 changed files with 208 additions and 56 deletions

View File

@@ -231,9 +231,9 @@ Start a download job. Returns immediately with a job ID (HTTP 202).
| `tag` | string | `null` | Override group tag | | `tag` | string | `null` | Override group tag |
| `repack` | boolean | `false` | Add REPACK tag to filename | | `repack` | boolean | `false` | Add REPACK tag to filename |
| `tmdb_id` | int | `null` | Use specific TMDB ID for tagging | | `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`) | | `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_folder` | boolean | `false` | Disable folder creation for TV shows |
| `no_source` | boolean | `false` | Remove source tag from filename | | `no_source` | boolean | `false` | Remove source tag from filename |
| `no_mux` | boolean | `false` | Do not mux tracks into container | | `no_mux` | boolean | `false` | Do not mux tracks into container |

View File

@@ -67,6 +67,7 @@ dependencies = [
"pycountry>=24.6.1", "pycountry>=24.6.1",
"language-data>=1.4.0", "language-data>=1.4.0",
"wasmtime>=41.0.0", "wasmtime>=41.0.0",
"animeapi-py>=0.6.0",
] ]
[project.urls] [project.urls]

View File

@@ -416,18 +416,17 @@ class dl:
help="Use this TMDB ID for tagging instead of automatic lookup.", help="Use this TMDB ID for tagging instead of automatic lookup.",
) )
@click.option( @click.option(
"--tmdb-name", "--animeapi",
"tmdb_name", "animeapi_id",
is_flag=True, type=str,
default=False, default=None,
help="Rename titles using the name returned from TMDB lookup.", help="Anime database ID via AnimeAPI (e.g. mal:12345, anilist:98765). Defaults to MAL if no prefix.",
) )
@click.option( @click.option(
"--tmdb-year", "--enrich",
"tmdb_year",
is_flag=True, is_flag=True,
default=False, 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( @click.option(
"--imdb", "--imdb",
@@ -528,9 +527,9 @@ class dl:
repack: bool = False, repack: bool = False,
tag: Optional[str] = None, tag: Optional[str] = None,
tmdb_id: Optional[int] = None, tmdb_id: Optional[int] = None,
tmdb_name: bool = False,
tmdb_year: bool = False,
imdb_id: Optional[str] = None, imdb_id: Optional[str] = None,
animeapi_id: Optional[str] = None,
enrich: bool = False,
output_dir: Optional[Path] = None, output_dir: Optional[Path] = None,
*_: Any, *_: Any,
**__: Any, **__: Any,
@@ -575,11 +574,24 @@ class dl:
self.profile = profile self.profile = profile
self.tmdb_id = tmdb_id self.tmdb_id = tmdb_id
self.tmdb_name = tmdb_name
self.tmdb_year = tmdb_year
self.imdb_id = imdb_id self.imdb_id = imdb_id
self.enrich = enrich
self.animeapi_title: Optional[str] = None
self.output_dir = output_dir 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 # Initialize debug logger with service name if debug logging is enabled
if config.debug or logging.root.level == logging.DEBUG: if config.debug or logging.root.level == logging.DEBUG:
from collections import defaultdict from collections import defaultdict
@@ -601,14 +613,23 @@ class dl:
"profile": profile, "profile": profile,
"proxy": proxy, "proxy": proxy,
"tag": tag, "tag": tag,
"tmdb_id": tmdb_id, "tmdb_id": self.tmdb_id,
"tmdb_name": tmdb_name, "imdb_id": self.imdb_id,
"tmdb_year": tmdb_year, "animeapi_id": animeapi_id,
"imdb_id": imdb_id, "enrich": enrich,
"cli_params": { "cli_params": {
k: v k: v
for k, v in ctx.params.items() 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_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 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 sample_title = titles[0] if hasattr(titles, "__getitem__") else titles
kind = "tv" if isinstance(sample_title, Episode) else "movie" kind = "tv" if isinstance(sample_title, Episode) else "movie"
tmdb_year_val = None enrich_title: Optional[str] = None
tmdb_name_val = None enrich_year: Optional[int] = None
if self.tmdb_year: if self.animeapi_title:
tmdb_year_val = providers.get_year_by_id( 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 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: if enrich_title or enrich_year:
tmdb_name_val = providers.get_title_by_id( if isinstance(titles, (Series, Movies)):
self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash for t in titles:
) if enrich_title:
if isinstance(t, Episode):
if isinstance(titles, (Series, Movies)): t.title = enrich_title
for t in titles: else:
if tmdb_year_val: t.name = enrich_title
t.year = tmdb_year_val if enrich_year and not t.year:
if tmdb_name_val: t.year = enrich_year
if isinstance(t, Episode): else:
t.title = tmdb_name_val if enrich_title:
if isinstance(titles, Episode):
titles.title = enrich_title
else: else:
t.name = tmdb_name_val titles.name = enrich_title
else: if enrich_year and not titles.year:
if tmdb_year_val: titles.year = enrich_year
titles.year = tmdb_year_val
if tmdb_name_val:
if isinstance(titles, Episode):
titles.title = tmdb_name_val
else:
titles.name = tmdb_name_val
console.print(Padding(Rule(f"[rule.text]{titles.__class__.__name__}: {titles}"), (1, 2))) console.print(Padding(Rule(f"[rule.text]{titles.__class__.__name__}: {titles}"), (1, 2)))
@@ -1181,7 +1213,7 @@ class dl:
page_size=8, page_size=8,
return_indices=True, return_indices=True,
dependencies=dependencies, dependencies=dependencies,
collapse_on_start=multiple_seasons collapse_on_start=multiple_seasons,
) )
if not selected_ui_idx: if not selected_ui_idx:
@@ -2399,7 +2431,9 @@ class dl:
final_dir.mkdir(parents=True, exist_ok=True) final_dir.mkdir(parents=True, exist_ok=True)
final_path = final_dir / f"{final_filename}{muxed_path.suffix}" 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) sep = config.get_template_separator(template_type)
if final_path.exists() and audio_codec_suffix and append_audio_codec_suffix: if final_path.exists() and audio_codec_suffix and append_audio_codec_suffix:

View File

@@ -164,9 +164,9 @@ def _perform_download(
"repack": params.get("repack", False), "repack": params.get("repack", False),
"tag": params.get("tag"), "tag": params.get("tag"),
"tmdb_id": params.get("tmdb_id"), "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"), "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, "output_dir": Path(params["output_dir"]) if params.get("output_dir") else None,
"no_cache": params.get("no_cache", False), "no_cache": params.get("no_cache", False),
"reset_cache": params.get("reset_cache", False), "reset_cache": params.get("reset_cache", False),
@@ -180,9 +180,9 @@ def _perform_download(
repack=params.get("repack", False), repack=params.get("repack", False),
tag=params.get("tag"), tag=params.get("tag"),
tmdb_id=params.get("tmdb_id"), 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"), 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, output_dir=Path(params["output_dir"]) if params.get("output_dir") else None,
) )

View File

@@ -623,12 +623,12 @@ async def download(request: web.Request) -> web.Response:
tmdb_id: tmdb_id:
type: integer type: integer
description: Use this TMDB ID for tagging (default - None) 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 type: boolean
description: Rename titles using TMDB name (default - false) description: Override show title and year from external source (default - false)
tmdb_year:
type: boolean
description: Use release year from TMDB (default - false)
no_folder: no_folder:
type: boolean type: boolean
description: Disable folder creation for TV shows (default - false) description: Disable folder creation for TV shows (default - false)

View File

@@ -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)

25
uv.lock generated
View File

@@ -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" }, { 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]] [[package]]
name = "anyio" name = "anyio"
version = "4.12.1" 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" }, { 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]] [[package]]
name = "distlib" name = "distlib"
version = "0.4.0" version = "0.4.0"
@@ -1632,6 +1655,7 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "aiohttp-swagger3" }, { name = "aiohttp-swagger3" },
{ name = "animeapi-py" },
{ name = "appdirs" }, { name = "appdirs" },
{ name = "brotli" }, { name = "brotli" },
{ name = "chardet" }, { name = "chardet" },
@@ -1690,6 +1714,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.3,<4" }, { name = "aiohttp", specifier = ">=3.13.3,<4" },
{ name = "aiohttp-swagger3", specifier = ">=0.9.0,<1" }, { name = "aiohttp-swagger3", specifier = ">=0.9.0,<1" },
{ name = "animeapi-py", specifier = ">=0.6.0" },
{ name = "appdirs", specifier = ">=1.4.4,<2" }, { name = "appdirs", specifier = ">=1.4.4,<2" },
{ name = "brotli", specifier = ">=1.1.0,<2" }, { name = "brotli", specifier = ">=1.1.0,<2" },
{ name = "chardet", specifier = ">=5.2.0,<6" }, { name = "chardet", specifier = ">=5.2.0,<6" },