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:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
92
unshackle/core/utils/animeapi.py
Normal file
92
unshackle/core/utils/animeapi.py
Normal 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)
|
||||
Reference in New Issue
Block a user