mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
merge: TheTVDB v4 metadata provider
This commit is contained in:
@@ -384,7 +384,8 @@ 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` / `animeapi_id` | - | `null` | External ID overrides for tagging. |
|
||||
| `tmdb_id` / `imdb_id` / `tvdb_id` / `animeapi_id` | - | `null` | External ID overrides for tagging. |
|
||||
| `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` | Override title/year from external source. |
|
||||
| `output_dir` | string | `null` | Override output directory. |
|
||||
| `no_cache` / `reset_cache` | boolean | `false` | Title cache controls. |
|
||||
|
||||
@@ -85,7 +85,7 @@ The top-level keys group loosely into these areas:
|
||||
| Key vaults | `key_vaults`, `vault_timeout` |
|
||||
| Proxies & remote | `proxy_providers`, `remote_services`, `serve`, `services` |
|
||||
| Naming & tagging | `tag`, `output_template`, `chapter_fallback_name` |
|
||||
| External API keys | `tmdb_api_key`, `simkl_client_id`, `ipinfo_api_key` |
|
||||
| External API keys | `tmdb_api_key`, `tvdb_api_key`, `simkl_client_id`, `ipinfo_api_key`, `metadata_providers` |
|
||||
| Behavior & logging | `update_checks`, `redact_paths`, `debug`, `unicode_filenames` |
|
||||
| Paths | `directories`, `filenames` |
|
||||
|
||||
@@ -125,7 +125,7 @@ services:
|
||||
|
||||
!!! tip "A few keys are named after the flag's internal name"
|
||||
Most keys are obvious, but set these exact ones: `range` (`-r`), `list` (`--list`),
|
||||
`tmdb_id` (`--tmdb`), `imdb_id` (`--imdb`), `no_atmos` (`--noatmos`), and `output_dir`
|
||||
`tmdb_id` (`--tmdb`), `imdb_id` (`--imdb`), `tvdb_id` (`--tvdb`), `no_atmos` (`--noatmos`), and `output_dir`
|
||||
(`-o`). The [Configuration Reference](../reference/configuration.md#dl) has the full list
|
||||
and every available key.
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ unshackle dl -q 1080 -o /mnt/media/incoming EXAMPLE 81234567
|
||||
- unshackle writes **movies** as a single `.mkv` file named from your `movies` template.
|
||||
- **TV episodes** are grouped into a per-show / per-season folder (from your
|
||||
`series` template) unless you pass `--no-folder`.
|
||||
- unshackle builds the filename from your `output_template` and adds IMDb/TMDB IDs to
|
||||
- unshackle builds the filename from your `output_template` and adds IMDb/TMDB/TVDB IDs to
|
||||
the file's metadata tags when available.
|
||||
|
||||
To change the default output location permanently, set it in your config:
|
||||
|
||||
@@ -145,8 +145,22 @@ Keep only certain track types, or skip certain track types. Attachments are alwa
|
||||
|---|---|
|
||||
| `--tmdb` | TMDB ID (integer). |
|
||||
| `--imdb` | IMDb ID, e.g. `tt1375666`. |
|
||||
| `--animeapi` | AnimeAPI ID, e.g. `mal:12345` or `anilist:98765` (defaults to MAL). Back-fills TMDB/IMDb. |
|
||||
| `--tvdb` | TVDB ID (integer). Skips the series lookup that `--tvdb-order` would otherwise do. |
|
||||
| `--animeapi` | AnimeAPI ID, e.g. `mal:12345` or `anilist:98765` (defaults to MAL). Back-fills TMDB/IMDb/TVDB. |
|
||||
| `--enrich` | Override show title/year from the external source. **Requires** one of `--tmdb`, `--imdb`, or `--animeapi`. |
|
||||
| `--tvdb-order` | Renumber episodes to a TVDB season order: `official` (aired), `dvd`, `absolute`, `alternate`, or `regional`. Needs `tvdb_api_key`. |
|
||||
|
||||
#### Episode ordering
|
||||
|
||||
A service does not always number a series the way TVDB's aired order does. Some services list
|
||||
Futurama in TVDB's `alternate` (Streaming) order, for example. `--tvdb-order` works out which
|
||||
order the service used, then renumbers the episodes into the order you asked for.
|
||||
|
||||
!!! note "Orders that do not cover the whole series"
|
||||
An order can omit episodes the service carries; TVDB's `dvd` order for Futurama leaves out
|
||||
the four movies. Those episodes keep their original numbering. If that would give two
|
||||
episodes the same season/episode slot, and so the same filename, unshackle logs an error
|
||||
and keeps the service's numbering unchanged. Pick an order that covers the whole series.
|
||||
|
||||
### DRM, keys & decryption
|
||||
|
||||
|
||||
@@ -519,13 +519,35 @@ tagging and naming:
|
||||
| --- | --- | --- |
|
||||
| `--tmdb` | `--tmdb 27205` | Use this TMDB ID instead of automatic lookup. |
|
||||
| `--imdb` | `--imdb tt1375666` | Use this IMDb ID. |
|
||||
| `--tvdb` | `--tvdb 73871` | Use this TVDB ID instead of looking the series up. |
|
||||
| `--animeapi` | `--animeapi mal:12345` | Resolve via AnimeAPI (`mal:`/`anilist:` prefix; defaults to MAL). |
|
||||
| `--enrich` | - | Override the show title and year from an external source. **Requires** one of `--tmdb`, `--imdb`, or `--animeapi`. |
|
||||
| `--tvdb-order` | `--tvdb-order dvd` | Renumber episodes to a TVDB season order. Needs `tvdb_api_key`. |
|
||||
|
||||
```shell title="Force the right IMDb match and enrich the title"
|
||||
unshackle dl --imdb tt1375666 --enrich EXAMPLE 81234567
|
||||
```
|
||||
|
||||
### Episode ordering
|
||||
|
||||
A service does not always number a series the way TVDB's aired order does. Some services list
|
||||
Futurama in TVDB's `alternate` (Streaming) order, for example, while TVDB's `official` order
|
||||
holds back four season-one episodes to the start of season two. `--tvdb-order` works out which
|
||||
order the service used, then renumbers the episodes into the order you asked for:
|
||||
|
||||
```shell title="Download Futurama in DVD order"
|
||||
unshackle dl --tvdb-order dvd EXAMPLE 81234567
|
||||
```
|
||||
|
||||
Available orders are `official` (aired), `dvd`, `absolute`, `alternate`, and `regional`. Set
|
||||
`tvdb_order` in your config to apply one by default.
|
||||
|
||||
!!! warning "Orders that do not cover the whole series"
|
||||
An order can leave out episodes the service carries. TVDB's `dvd` order does not list
|
||||
Futurama's four movies. Those episodes keep their original numbering. If that would give
|
||||
two episodes the same season/episode slot, and so the same filename, unshackle logs an
|
||||
error and leaves the numbering untouched. Pick an order that covers the whole series.
|
||||
|
||||
## Configuration defaults
|
||||
|
||||
Every flag on this page can be set as a default in your configuration file so you don't
|
||||
@@ -571,4 +593,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` / `--animeapi` / `--enrich` | | Metadata overrides. |
|
||||
| `--tmdb` / `--imdb` / `--tvdb` / `--animeapi` / `--enrich` | | Metadata overrides. |
|
||||
| `--tvdb-order` | | Renumber episodes to a TVDB season order. |
|
||||
|
||||
@@ -245,7 +245,11 @@ tag_imdb_tmdb: true
|
||||
|---|---|---|---|
|
||||
| `tag` | str | `""` | The release-group tag used for `{tag}`. |
|
||||
| `tag_group_name` | bool | `true` | Write the group name (`config.tag`) into the MKV `Group` metadata tag. |
|
||||
| `tag_imdb_tmdb` | bool | `true` | Look up and embed IMDb / TMDB / TVDB external-ID tags in the MKV metadata (uses `tmdb_api_key` / `simkl_client_id` when available). |
|
||||
| `tag_imdb_tmdb` | bool | `true` | Look up and embed IMDb / TMDB / TVDB external-ID tags in the MKV metadata (uses `tmdb_api_key` / `tvdb_api_key` / `simkl_client_id` when available). |
|
||||
|
||||
The IDs are written as the Matroska `IMDB`, `TMDB`, and `TVDB2` tags. `TVDB2` values carry the
|
||||
entity prefix the [Matroska tagging spec](https://www.matroska.org/technical/tagging.html)
|
||||
requires: `series/73871` for a show, `movies/113` for a film.
|
||||
|
||||
You can override the tag per run without editing config:
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ This page assumes you already know the basics and want precise details.
|
||||
| [DRM & CDM](#drm-cdm) | `cdm`, `remote_cdm`, `decryption` |
|
||||
| [Network & proxy](#network-proxy) | `network`, `headers`, `proxy_providers` |
|
||||
| [Key vaults](#key-vaults) | `key_vaults`, `vault_timeout` |
|
||||
| [External API keys](#external-api-keys) | `imdb_api_enabled`, `omdb_api_key`, `tmdb_api_key`, `simkl_client_id`, `decrypt_labs_api_key`, `ipinfo_api_key` |
|
||||
| [External API keys](#external-api-keys) | `imdb_api_enabled`, `omdb_api_key`, `tmdb_api_key`, `tvdb_api_key`, `tvdb_pin`, `tvdb_order`, `simkl_client_id`, `metadata_providers`, `decrypt_labs_api_key`, `ipinfo_api_key` |
|
||||
| [Caching & updates](#caching-updates) | `title_cache_enabled`, `title_cache_time`, `title_cache_max_retention`, `update_checks`, `update_check_interval` |
|
||||
| [Logging, privacy & debug](#logging-privacy-debug) | `redact_paths`, `debug`, `debug_keys`, `debug_requests`, `set_terminal_bg` |
|
||||
| [Deprecated & removed](#deprecated-removed-keys) | `curl_impersonate`, `downloader`, `scene_naming` |
|
||||
@@ -784,13 +784,31 @@ All default to an empty string and enable optional metadata/geolocation features
|
||||
| `omdb_api_key` | str | `""` | [OMDb](https://www.omdbapi.com/) API key for IMDb metadata lookups; a more reliable alternative to IMDxAPI. Free keys are available on the OMDb site. |
|
||||
| `tmdb_api_key` | str | `""` | TMDB API key for metadata enrichment and external-ID tags. |
|
||||
| `simkl_client_id` | str | `""` | SIMKL client ID for metadata lookups; an alternative/fallback source to TMDB. |
|
||||
| `tvdb_api_key` | str | `""` | [TheTVDB v4](https://thetvdb.com/api-information) API key; a fallback source to TMDB, strongest on TV series. Free keys are available on the TVDB site. |
|
||||
| `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 | *(see below)* | Metadata providers to use, in the order they are tried. |
|
||||
| `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. |
|
||||
|
||||
!!! tip "SIMKL is a fallback for TMDB"
|
||||
SIMKL is an alternative metadata source. Use it when you have no `tmdb_api_key`
|
||||
configured; it improves title matching and tagging in that case rather than replacing
|
||||
TMDB.
|
||||
!!! tip "Choosing and ordering metadata providers"
|
||||
A lookup goes to each provider in turn and stops at the first good match, so the order
|
||||
decides which source wins. `metadata_providers` sets that order:
|
||||
|
||||
```yaml
|
||||
metadata_providers: [tvdb, tmdb, simkl]
|
||||
```
|
||||
|
||||
Names not in the list are never used, so this both orders and filters. Unknown names are
|
||||
ignored with a warning. Leave it unset for the default order:
|
||||
|
||||
```yaml
|
||||
metadata_providers: [imdbapi, omdb, simkl, tmdb, tvdb]
|
||||
```
|
||||
|
||||
A provider is skipped when its API key is missing, whatever the order. Available names are
|
||||
`imdbapi`, `omdb`, `simkl`, `tmdb`, and `tvdb`. Put `tvdb` first to make TheTVDB your
|
||||
primary source; leave it last to use it only as a fallback for TMDB.
|
||||
|
||||
!!! note "`ipinfo_api_key` never touches your service sessions"
|
||||
The token is only ever sent to `api.ipinfo.io` as a per-request `Authorization` header; it
|
||||
@@ -907,6 +925,7 @@ proxy_providers:
|
||||
|
||||
omdb_api_key: "your-omdb-key"
|
||||
tmdb_api_key: "your-tmdb-key"
|
||||
tvdb_api_key: "your-tvdb-key"
|
||||
title_cache_time: 3600
|
||||
redact_paths: true
|
||||
```
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""TVDB provider ordering and season-order remapping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from unshackle.core import providers
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.providers.tvdb import TVDBProvider, _ids_from_remote, _pick_match
|
||||
from unshackle.core.utils.tags import _build_tags_from_ids
|
||||
|
||||
REMOTE_IDS = [
|
||||
{"id": "tt0149460", "type": 2, "sourceName": "IMDB"},
|
||||
{"id": "http://example.com", "type": 4, "sourceName": "Official Website"},
|
||||
{"id": "615", "type": 12, "sourceName": "TheMovieDB.com"},
|
||||
]
|
||||
|
||||
# Futurama: aired S1 stops at E9, the four held-back episodes open aired S2.
|
||||
# DVD order folds them back into S1 as E10-E13.
|
||||
AIRED = [
|
||||
{"id": 1, "seasonNumber": 1, "number": 9, "name": "Hell Is Other Robots"},
|
||||
{"id": 2, "seasonNumber": 2, "number": 1, "name": "A Flight to Remember"},
|
||||
{"id": 3, "seasonNumber": 2, "number": 2, "name": "Mars University"},
|
||||
{"id": 99, "seasonNumber": 2, "number": 3, "name": "Only In Aired"},
|
||||
]
|
||||
DVD = [
|
||||
{"id": 1, "seasonNumber": 1, "number": 9, "name": "Hell Is Other Robots"},
|
||||
{"id": 2, "seasonNumber": 1, "number": 10, "name": "A Flight to Remember"},
|
||||
{"id": 3, "seasonNumber": 1, "number": 11, "name": "Mars University"},
|
||||
]
|
||||
|
||||
|
||||
def test_ids_from_remote() -> None:
|
||||
ext = _ids_from_remote(REMOTE_IDS, 73871)
|
||||
assert ext.imdb_id == "tt0149460"
|
||||
assert ext.tmdb_id == 615
|
||||
assert ext.tvdb_id == 73871
|
||||
|
||||
|
||||
def _stub(p: TVDBProvider, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(p, "get_episodes", lambda _id, order: {"official": AIRED, "dvd": DVD}.get(order, []))
|
||||
|
||||
|
||||
def test_order_map_joins_on_episode_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
p = TVDBProvider()
|
||||
_stub(p, monkeypatch)
|
||||
|
||||
mapping = p.get_order_map(73871, "dvd")
|
||||
assert mapping[(1, 9)] == (1, 9, "Hell Is Other Robots")
|
||||
assert mapping[(2, 1)] == (1, 10, "A Flight to Remember")
|
||||
assert mapping[(2, 2)] == (1, 11, "Mars University")
|
||||
# an episode absent from the target order is left out, so it keeps its own numbering
|
||||
assert (2, 3) not in mapping
|
||||
|
||||
|
||||
def test_order_map_reverses_with_source_order(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
p = TVDBProvider()
|
||||
_stub(p, monkeypatch)
|
||||
|
||||
mapping = p.get_order_map(73871, "official", source_order="dvd")
|
||||
assert mapping[(1, 10)] == (2, 1, "A Flight to Remember")
|
||||
|
||||
|
||||
def test_order_map_is_noop_when_orders_match() -> None:
|
||||
assert TVDBProvider().get_order_map(73871, "official") == {}
|
||||
|
||||
|
||||
def test_order_map_empty_when_order_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
p = TVDBProvider()
|
||||
_stub(p, monkeypatch)
|
||||
assert p.get_order_map(73871, "alternate") == {}
|
||||
|
||||
|
||||
def test_detect_order_picks_the_order_the_numbering_matches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
p = TVDBProvider()
|
||||
_stub(p, monkeypatch)
|
||||
# a service may number a series in a non-aired order, so detection must not assume aired
|
||||
assert p.detect_order(73871, [(1, 10), (1, 11)]) == "dvd"
|
||||
assert p.detect_order(73871, [(2, 1), (2, 2), (2, 3)]) == "official"
|
||||
|
||||
|
||||
def test_detect_order_falls_back_to_aired(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
p = TVDBProvider()
|
||||
monkeypatch.setattr(p, "get_episodes", lambda _id, order: [])
|
||||
assert p.detect_order(73871, [(1, 1)]) == "official"
|
||||
|
||||
|
||||
# TVDB holds both Battlestar Galactica series; the 2003 revival is filed under year 2005
|
||||
BSG = [
|
||||
{"name": "Battlestar Galactica", "year": "1978", "tvdb_id": "71173"},
|
||||
{"name": "Battlestar Galactica (2003)", "year": "2005", "tvdb_id": "73545"},
|
||||
]
|
||||
|
||||
|
||||
def test_pick_match_prefers_the_right_era() -> None:
|
||||
# exact title match on the 1978 series must not beat the era the caller asked for
|
||||
best, _, _ = _pick_match(BSG, "Battlestar Galactica", 2004)
|
||||
assert best["tvdb_id"] == "73545"
|
||||
best, _, _ = _pick_match(BSG, "Battlestar Galactica", 1978)
|
||||
assert best["tvdb_id"] == "71173"
|
||||
|
||||
|
||||
def test_pick_match_without_a_year_scores_on_title_alone() -> None:
|
||||
best, _, _ = _pick_match(BSG, "Battlestar Galactica", None)
|
||||
assert best["tvdb_id"] == "71173"
|
||||
|
||||
|
||||
def test_pick_match_returns_nothing_when_no_year_fits() -> None:
|
||||
assert _pick_match(BSG, "Battlestar Galactica", 1995)[0] is None
|
||||
|
||||
|
||||
def test_tvdb2_tag_follows_the_matroska_spec() -> None:
|
||||
# TVDB2 IDs are prefixed by entity type: matroska.org/technical/tagging.html
|
||||
from unshackle.core.providers import ExternalIds
|
||||
|
||||
assert _build_tags_from_ids(ExternalIds(tvdb_id=73871), "tv")["TVDB2"] == "series/73871"
|
||||
assert _build_tags_from_ids(ExternalIds(tvdb_id=113), "movie")["TVDB2"] == "movies/113"
|
||||
|
||||
|
||||
def test_provider_order_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(config, "metadata_providers", [])
|
||||
assert [c.NAME for c in providers.provider_order()] == list(providers.DEFAULT_ORDER)
|
||||
|
||||
|
||||
def test_provider_order_is_configurable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(config, "metadata_providers", ["TVDB", "tmdb", "nope", "tvdb"])
|
||||
# unknown entries dropped, duplicates collapsed, case ignored, order kept
|
||||
assert [c.NAME for c in providers.provider_order()] == ["tvdb", "tmdb"]
|
||||
|
||||
|
||||
def test_enrichment_authority_is_fixed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# search order must not demote TMDB below SIMKL for cross-validation tie-breaks
|
||||
monkeypatch.setattr(config, "metadata_providers", [])
|
||||
assert providers._enrichment_providers() == ["tmdb", "simkl", "tvdb"]
|
||||
# `metadata_providers` filters the set but does not re-rank the trust order
|
||||
monkeypatch.setattr(config, "metadata_providers", ["tvdb", "simkl"])
|
||||
assert providers._enrichment_providers() == ["simkl", "tvdb"]
|
||||
|
||||
|
||||
def test_cached_tvdb_result_keeps_enriched_ids() -> None:
|
||||
cached = {
|
||||
"tvdb_id": "73871",
|
||||
"name": "Futurama",
|
||||
"year": "1999",
|
||||
"_enriched_ids": {"imdb_id": "tt0149460", "tmdb_id": 615},
|
||||
}
|
||||
result = providers._cached_to_result(cached, "tvdb", "tv")
|
||||
assert result is not None
|
||||
assert result.external_ids.tvdb_id == 73871
|
||||
assert result.external_ids.imdb_id == "tt0149460"
|
||||
assert result.external_ids.tmdb_id == 615
|
||||
# remote_ids stay authoritative over the enriched copies
|
||||
cached["remote_ids"] = REMOTE_IDS
|
||||
cached["_enriched_ids"] = {"imdb_id": "tt9999999", "tmdb_id": 1}
|
||||
result = providers._cached_to_result(cached, "tvdb", "tv")
|
||||
assert result.external_ids.imdb_id == "tt0149460"
|
||||
assert result.external_ids.tmdb_id == 615
|
||||
|
||||
|
||||
def test_get_episodes_fails_closed_on_a_truncated_listing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
p = TVDBProvider()
|
||||
pages: dict[int, object] = {0: {"episodes": [{"id": n} for n in range(500)]}, 1: None}
|
||||
monkeypatch.setattr(p, "_get", lambda _path, params: pages[params["page"]])
|
||||
# page 1 fails mid-listing: a partial list would renumber wrongly
|
||||
assert p.get_episodes(73871, "official") == []
|
||||
assert p._episodes == {}
|
||||
|
||||
|
||||
def test_get_episodes_does_not_cache_an_empty_listing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
p = TVDBProvider()
|
||||
monkeypatch.setattr(p, "_get", lambda _path, params: None)
|
||||
assert p.get_episodes(73871, "official") == []
|
||||
assert p._episodes == {}
|
||||
+103
-1
@@ -11,6 +11,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from collections.abc import Iterable
|
||||
from concurrent import futures
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -58,6 +59,7 @@ from unshackle.core.music import (
|
||||
write_music_manifest,
|
||||
write_music_metadata,
|
||||
)
|
||||
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
|
||||
from unshackle.core.services import Services
|
||||
@@ -587,6 +589,20 @@ class dl:
|
||||
default=None,
|
||||
help="Use this TMDB ID for tagging instead of automatic lookup.",
|
||||
)
|
||||
@click.option(
|
||||
"--tvdb",
|
||||
"tvdb_id",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Use this TVDB ID for --tvdb-order instead of looking the series up automatically.",
|
||||
)
|
||||
@click.option(
|
||||
"--tvdb-order",
|
||||
type=click.Choice(SEASON_TYPES, case_sensitive=False),
|
||||
default=None,
|
||||
help="Renumber episodes to a TVDB season order. Services normally use 'official' (aired) order; "
|
||||
"a series' Streaming Order is 'alternate'. Defaults to the tvdb_order config option.",
|
||||
)
|
||||
@click.option(
|
||||
"--animeapi",
|
||||
"animeapi_id",
|
||||
@@ -760,6 +776,8 @@ class dl:
|
||||
tag: Optional[str] = None,
|
||||
tmdb_id: Optional[int] = None,
|
||||
imdb_id: Optional[str] = None,
|
||||
tvdb_id: Optional[int] = None,
|
||||
tvdb_order: Optional[str] = None,
|
||||
animeapi_id: Optional[str] = None,
|
||||
enrich: bool = False,
|
||||
output_dir: Optional[Path] = None,
|
||||
@@ -798,6 +816,8 @@ class dl:
|
||||
tag = ctx.params.get("tag", tag)
|
||||
tmdb_id = ctx.params.get("tmdb_id", tmdb_id)
|
||||
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)
|
||||
enrich = ctx.params.get("enrich", enrich)
|
||||
output_dir = ctx.params.get("output_dir", output_dir)
|
||||
@@ -806,6 +826,10 @@ class dl:
|
||||
self.proxy_requested = bool(proxy)
|
||||
self.tmdb_id = tmdb_id
|
||||
self.imdb_id = imdb_id
|
||||
self.tvdb_id = tvdb_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
|
||||
@@ -819,6 +843,8 @@ class dl:
|
||||
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.animeapi_title):
|
||||
raise click.UsageError("--enrich requires --tmdb, --imdb, or --animeapi to provide a metadata source.")
|
||||
@@ -1501,6 +1527,9 @@ class dl:
|
||||
if enrich_year and not titles.year:
|
||||
titles.year = enrich_year
|
||||
|
||||
if self.tvdb_order and isinstance(titles, Series):
|
||||
titles = self.apply_tvdb_order(titles, title_cacher, cache_title_id, cache_region, cache_account_hash)
|
||||
|
||||
music_mode = isinstance(titles, Music)
|
||||
music_collection_mode = (
|
||||
isinstance(titles, list) and bool(titles) and all(isinstance(title, Music) for title in titles)
|
||||
@@ -3622,7 +3651,7 @@ 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)
|
||||
tags.tag_file(final_path, title, self.tmdb_id, self.imdb_id, self.tvdb_id)
|
||||
|
||||
title_dl_time = time_elapsed_since(dl_start_time)
|
||||
downloaded_label = "Track" if music_mode and isinstance(title, Song) else "Title"
|
||||
@@ -3648,6 +3677,79 @@ class dl:
|
||||
|
||||
console.print(Padding(f"Processed all titles in [progress.elapsed]{dl_time}", (0, 5, 1, 5)))
|
||||
|
||||
def apply_tvdb_order(
|
||||
self,
|
||||
titles: Series,
|
||||
title_cacher: Any = None,
|
||||
cache_title_id: Optional[str] = None,
|
||||
cache_region: Optional[str] = None,
|
||||
cache_account_hash: Optional[str] = None,
|
||||
) -> Series:
|
||||
"""Renumber episodes from the service's own numbering into a TVDB season order."""
|
||||
tvdb = providers.get_provider("tvdb")
|
||||
if not tvdb:
|
||||
self.log.warning("--tvdb-order needs a tvdb_api_key in your config, skipping renumber.")
|
||||
return titles
|
||||
|
||||
tvdb_id = self.tvdb_id
|
||||
if not tvdb_id:
|
||||
show = titles[0]
|
||||
result = providers.search_metadata(
|
||||
show.title, show.year, "tv", title_cacher, cache_title_id, cache_region, cache_account_hash
|
||||
)
|
||||
tvdb_id = result.external_ids.tvdb_id if result else None
|
||||
if not tvdb_id:
|
||||
self.log.warning("Could not resolve a TVDB ID for %r, skipping %s renumber.", str(titles), self.tvdb_order)
|
||||
return titles
|
||||
self.tvdb_id = tvdb_id # keep the mux tag on the series the renumbering used
|
||||
|
||||
keys = [(t.season, t.number) for t in titles]
|
||||
source_order = tvdb.detect_order(tvdb_id, keys) # type: ignore[attr-defined]
|
||||
if source_order == self.tvdb_order:
|
||||
self.log.info("%s already lists this series in TVDB %s order.", self.service, self.tvdb_order)
|
||||
return titles
|
||||
|
||||
mapping = tvdb.get_order_map(tvdb_id, self.tvdb_order, source_order) # type: ignore[attr-defined]
|
||||
if not mapping:
|
||||
return titles
|
||||
|
||||
# an unmapped episode keeps its own numbering and can land on a remapped episode's slot
|
||||
renumbered = [mapping.get(key, (*key, None))[:2] for key in keys]
|
||||
unmapped = sum(1 for key in keys if key not in mapping)
|
||||
collisions = [slot for slot, count in Counter(renumbered).items() if count > 1]
|
||||
if collisions:
|
||||
self.log.error(
|
||||
"TVDB's %s order does not cover %d of this series' episodes, and renumbering the rest "
|
||||
"would give %d season/episode slot(s) two episodes each. Keeping the %s numbering "
|
||||
"%s uses. Pick an order that covers the whole series.",
|
||||
self.tvdb_order,
|
||||
unmapped,
|
||||
len(collisions),
|
||||
source_order,
|
||||
self.service,
|
||||
)
|
||||
return titles
|
||||
|
||||
for title in titles:
|
||||
entry = mapping.get((title.season, title.number))
|
||||
if not entry:
|
||||
continue
|
||||
title.season, title.number, tvdb_name = entry
|
||||
if tvdb_name and not title.name:
|
||||
title.name = tvdb_name
|
||||
|
||||
if unmapped:
|
||||
self.log.warning(
|
||||
"%d episode(s) are not in TVDB's %s order and keep their %s numbering.",
|
||||
unmapped,
|
||||
self.tvdb_order,
|
||||
source_order,
|
||||
)
|
||||
self.log.info("Renumbered episodes from TVDB %s to %s order (ID %s).", source_order, self.tvdb_order, tvdb_id)
|
||||
|
||||
# Series is a SortedKeyList; renumbering in place does not re-sort it
|
||||
return type(titles)(list(titles))
|
||||
|
||||
@staticmethod
|
||||
def title_to_meta(title: Title_T) -> dict[str, Any]:
|
||||
"""Capture the title fields needed to rebuild a Title for output naming on import."""
|
||||
|
||||
@@ -417,6 +417,8 @@ def _perform_download(
|
||||
"tag": params.get("tag"),
|
||||
"tmdb_id": params.get("tmdb_id"),
|
||||
"imdb_id": params.get("imdb_id"),
|
||||
"tvdb_id": params.get("tvdb_id"),
|
||||
"tvdb_order": params.get("tvdb_order"),
|
||||
"animeapi_id": params.get("animeapi_id"),
|
||||
"enrich": params.get("enrich", False),
|
||||
"output_dir": Path(params["output_dir"]) if params.get("output_dir") else None,
|
||||
@@ -447,6 +449,8 @@ def _perform_download(
|
||||
tag=params.get("tag"),
|
||||
tmdb_id=params.get("tmdb_id"),
|
||||
imdb_id=params.get("imdb_id"),
|
||||
tvdb_id=params.get("tvdb_id"),
|
||||
tvdb_order=params.get("tvdb_order"),
|
||||
animeapi_id=params.get("animeapi_id"),
|
||||
enrich=params.get("enrich", False),
|
||||
output_dir=Path(params["output_dir"]) if params.get("output_dir") else None,
|
||||
|
||||
@@ -74,6 +74,8 @@ DEFAULT_DOWNLOAD_PARAMS = {
|
||||
"tag": None,
|
||||
"tmdb_id": None,
|
||||
"imdb_id": None,
|
||||
"tvdb_id": None,
|
||||
"tvdb_order": None,
|
||||
"animeapi_id": None,
|
||||
"enrich": False,
|
||||
"output_dir": None,
|
||||
|
||||
@@ -787,6 +787,13 @@ async def download(request: web.Request) -> web.Response:
|
||||
imdb_id:
|
||||
type: string
|
||||
description: Use this IMDB ID (e.g. tt1375666) for tagging (default - None)
|
||||
tvdb_id:
|
||||
type: integer
|
||||
description: Use this TVDB ID for tagging and episode ordering (default - None)
|
||||
tvdb_order:
|
||||
type: string
|
||||
enum: [official, dvd, absolute, alternate, regional]
|
||||
description: Renumber episodes to a TVDB season order (default - the tvdb_order config option)
|
||||
output_dir:
|
||||
type: string
|
||||
description: Override the output directory for this download (default - None)
|
||||
|
||||
@@ -117,6 +117,10 @@ class Config:
|
||||
self.imdb_api_enabled: bool = kwargs.get("imdb_api_enabled", False)
|
||||
self.omdb_api_key: str = kwargs.get("omdb_api_key") or ""
|
||||
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
|
||||
self.tvdb_api_key: str = kwargs.get("tvdb_api_key") or ""
|
||||
self.tvdb_pin: str = kwargs.get("tvdb_pin") or ""
|
||||
self.tvdb_order: str = (kwargs.get("tvdb_order") or "").lower()
|
||||
self.metadata_providers: list = kwargs.get("metadata_providers") or []
|
||||
self.simkl_client_id: str = kwargs.get("simkl_client_id") or ""
|
||||
self.decrypt_labs_api_key: str = kwargs.get("decrypt_labs_api_key") or ""
|
||||
self.ipinfo_api_key: str = kwargs.get("ipinfo_api_key") or ""
|
||||
|
||||
@@ -9,26 +9,42 @@ from unshackle.core.providers.imdbapi import IMDBApiProvider
|
||||
from unshackle.core.providers.omdb import OMDBProvider
|
||||
from unshackle.core.providers.simkl import SimklProvider
|
||||
from unshackle.core.providers.tmdb import TMDBProvider
|
||||
from unshackle.core.providers.tvdb import TVDBProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from unshackle.core.title_cacher import TitleCacher
|
||||
|
||||
# Ordered by priority: IMDBApi (free), OMDb, SIMKL, TMDB
|
||||
ALL_PROVIDERS: list[type[MetadataProvider]] = [IMDBApiProvider, OMDBProvider, SimklProvider, TMDBProvider]
|
||||
REGISTRY: dict[str, type[MetadataProvider]] = {
|
||||
cls.NAME: cls for cls in (IMDBApiProvider, OMDBProvider, SimklProvider, TMDBProvider, TVDBProvider)
|
||||
}
|
||||
|
||||
# used when `metadata_providers` is unset
|
||||
DEFAULT_ORDER: tuple[str, ...] = ("imdbapi", "omdb", "simkl", "tmdb", "tvdb")
|
||||
|
||||
|
||||
def provider_order() -> list[type[MetadataProvider]]:
|
||||
"""Provider classes in the order configured by `metadata_providers`."""
|
||||
from unshackle.core.config import config
|
||||
|
||||
names = [str(n).lower() for n in (config.metadata_providers or DEFAULT_ORDER)]
|
||||
unknown = [n for n in names if n not in REGISTRY]
|
||||
if unknown:
|
||||
log.warning("Ignoring unknown metadata_providers entries: %s", ", ".join(unknown))
|
||||
return [REGISTRY[n] for n in dict.fromkeys(names) if n in REGISTRY]
|
||||
|
||||
|
||||
def get_available_providers() -> list[MetadataProvider]:
|
||||
"""Return instantiated providers that have valid credentials."""
|
||||
return [cls() for cls in ALL_PROVIDERS if cls().is_available()]
|
||||
return [cls() for cls in provider_order() if cls().is_available()]
|
||||
|
||||
|
||||
def get_provider(name: str) -> Optional[MetadataProvider]:
|
||||
"""Get a specific provider by name."""
|
||||
for cls in ALL_PROVIDERS:
|
||||
if cls.NAME == name:
|
||||
p = cls()
|
||||
return p if p.is_available() else None
|
||||
return None
|
||||
cls = REGISTRY.get(name)
|
||||
if not cls:
|
||||
return None
|
||||
p = cls()
|
||||
return p if p.is_available() else None
|
||||
|
||||
|
||||
# -- Public API (replaces tags.py functions) --
|
||||
@@ -44,9 +60,11 @@ def search_metadata(
|
||||
cache_account_hash: Optional[str] = None,
|
||||
) -> Optional[MetadataResult]:
|
||||
"""Search all available providers for metadata. Returns best match."""
|
||||
ordered = provider_order()
|
||||
|
||||
# Check cache first
|
||||
if title_cacher and cache_title_id:
|
||||
for cls in ALL_PROVIDERS:
|
||||
for cls in ordered:
|
||||
p = cls()
|
||||
if not p.is_available():
|
||||
continue
|
||||
@@ -58,7 +76,7 @@ def search_metadata(
|
||||
return result
|
||||
|
||||
# Search providers in priority order
|
||||
for cls in ALL_PROVIDERS:
|
||||
for cls in ordered:
|
||||
p = cls()
|
||||
if not p.is_available():
|
||||
continue
|
||||
@@ -230,9 +248,15 @@ def fetch_external_ids(
|
||||
# -- Internal helpers --
|
||||
|
||||
|
||||
# Provider authority ranking for tie-breaking (lower index = more authoritative)
|
||||
_ENRICHMENT_PROVIDERS = ("tmdb", "simkl")
|
||||
_ENRICHMENT_AUTHORITY: dict[str, int] = {name: i for i, name in enumerate(_ENRICHMENT_PROVIDERS)}
|
||||
# trust ranking for cross-validating enrichments; `metadata_providers` filters this
|
||||
# set but sets search order only, not the ranking
|
||||
_ENRICHMENT_AUTHORITY: tuple[str, ...] = ("tmdb", "simkl", "tvdb")
|
||||
|
||||
|
||||
def _enrichment_providers() -> list[str]:
|
||||
"""Names of configured providers that can resolve an IMDB ID, most trusted first."""
|
||||
configured = {cls.NAME for cls in provider_order() if hasattr(cls, "find_by_imdb_id")}
|
||||
return [name for name in _ENRICHMENT_AUTHORITY if name in configured]
|
||||
|
||||
|
||||
def enrich_ids(result: MetadataResult) -> None:
|
||||
@@ -251,13 +275,14 @@ def enrich_ids(result: MetadataResult) -> None:
|
||||
kind = result.kind or "movie"
|
||||
|
||||
# Step 1: Collect enrichment results from all available providers
|
||||
authority = {name: i for i, name in enumerate(_enrichment_providers())}
|
||||
enrichments: list[tuple[str, ExternalIds]] = []
|
||||
for provider_name in _ENRICHMENT_PROVIDERS:
|
||||
for provider_name in authority:
|
||||
p = get_provider(provider_name)
|
||||
if not p:
|
||||
continue
|
||||
try:
|
||||
enriched = p.find_by_imdb_id(ids.imdb_id, kind) # type: ignore[union-attr]
|
||||
enriched = p.find_by_imdb_id(ids.imdb_id, kind) # type: ignore[attr-defined]
|
||||
except Exception as exc:
|
||||
log.debug("Enrichment via %s failed: %s", provider_name, exc)
|
||||
continue
|
||||
@@ -268,7 +293,7 @@ def enrich_ids(result: MetadataResult) -> None:
|
||||
return
|
||||
|
||||
# Step 2: Cross-validate using tmdb_id as anchor — drop providers that disagree
|
||||
validated = _validate_enrichments(enrichments)
|
||||
validated = _validate_enrichments(enrichments, authority)
|
||||
|
||||
# Step 3: Merge validated data (fill gaps only)
|
||||
for _provider_name, ext in validated:
|
||||
@@ -281,6 +306,7 @@ def enrich_ids(result: MetadataResult) -> None:
|
||||
|
||||
def _validate_enrichments(
|
||||
enrichments: list[tuple[str, ExternalIds]],
|
||||
authority: dict[str, int],
|
||||
) -> list[tuple[str, ExternalIds]]:
|
||||
"""Drop providers whose tmdb_id conflicts with the authoritative value.
|
||||
|
||||
@@ -309,7 +335,7 @@ def _validate_enrichments(
|
||||
# No majority — pick the most authoritative provider
|
||||
best_provider = min(
|
||||
tmdb_votes.keys(),
|
||||
key=lambda name: _ENRICHMENT_AUTHORITY.get(name, 99),
|
||||
key=lambda name: authority.get(name, 99),
|
||||
)
|
||||
anchor_tmdb_id = tmdb_votes[best_provider]
|
||||
|
||||
@@ -409,6 +435,25 @@ def _cached_to_result(cached: dict, provider_name: str, kind: str) -> Optional[M
|
||||
source="omdb",
|
||||
raw=cached,
|
||||
)
|
||||
elif provider_name == "tvdb":
|
||||
from unshackle.core.providers.tvdb import _ids_from_remote, _parse_int
|
||||
|
||||
tvdb_id = _parse_int(cached.get("tvdb_id") or cached.get("id"))
|
||||
ext = _ids_from_remote(cached.get("remote_ids") or cached.get("remoteIds"), tvdb_id)
|
||||
# restore IDs that enrichment filled in beyond the raw remote_ids
|
||||
enriched = cached.get("_enriched_ids", {})
|
||||
ext.imdb_id = ext.imdb_id or enriched.get("imdb_id")
|
||||
ext.tmdb_id = ext.tmdb_id or enriched.get("tmdb_id")
|
||||
if ext.tmdb_id:
|
||||
ext.tmdb_kind = kind
|
||||
return MetadataResult(
|
||||
title=cached.get("name"),
|
||||
year=_parse_int(cached.get("year")),
|
||||
kind=kind,
|
||||
external_ids=ext,
|
||||
source="tvdb",
|
||||
raw=cached,
|
||||
)
|
||||
elif provider_name == "imdbapi":
|
||||
title = cached.get("primaryTitle") or cached.get("originalTitle")
|
||||
year = cached.get("startYear")
|
||||
@@ -432,7 +477,8 @@ def _cached_to_result(cached: dict, provider_name: str, kind: str) -> Optional[M
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ALL_PROVIDERS",
|
||||
"DEFAULT_ORDER",
|
||||
"REGISTRY",
|
||||
"ExternalIds",
|
||||
"MetadataProvider",
|
||||
"MetadataResult",
|
||||
@@ -443,5 +489,6 @@ __all__ = [
|
||||
"get_provider",
|
||||
"get_title_by_id",
|
||||
"get_year_by_id",
|
||||
"provider_order",
|
||||
"search_metadata",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import requests
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, _clean, _strip_year
|
||||
|
||||
KIND_TO_TYPE: dict[str, str] = {"movie": "movie", "tv": "series"}
|
||||
KIND_TO_PATH: dict[str, str] = {"movie": "movies", "tv": "series"}
|
||||
|
||||
# a series' Streaming Order is published as "alternate"
|
||||
SEASON_TYPES: tuple[str, ...] = ("official", "dvd", "absolute", "alternate", "regional")
|
||||
AIRED_ORDER = "official"
|
||||
|
||||
# cached per-process; providers are re-instantiated per lookup
|
||||
_token: Optional[str] = None
|
||||
|
||||
|
||||
def _parse_int(value: Optional[str]) -> Optional[int]:
|
||||
try:
|
||||
return int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _ids_from_remote(remote_ids: Optional[list], tvdb_id: Optional[int]) -> ExternalIds:
|
||||
"""Build ExternalIds from a TVDB remote_ids/remoteIds list."""
|
||||
ext = ExternalIds(tvdb_id=tvdb_id)
|
||||
for entry in remote_ids or []:
|
||||
source = (entry.get("sourceName") or "").lower()
|
||||
value = entry.get("id")
|
||||
if source == "imdb":
|
||||
ext.imdb_id = value
|
||||
elif source.startswith("themoviedb"):
|
||||
ext.tmdb_id = _parse_int(value)
|
||||
return ext
|
||||
|
||||
|
||||
def _pick_match(
|
||||
results: list[dict], search_title: str, year: Optional[int]
|
||||
) -> tuple[Optional[dict], Optional[str], float]:
|
||||
"""Best title match among results, ignoring any whose year is more than a year out."""
|
||||
best: Optional[dict] = None
|
||||
best_title: Optional[str] = None
|
||||
best_ratio = 0.0
|
||||
for result in results:
|
||||
result_year = _parse_int(result.get("year"))
|
||||
if year and result_year and abs(result_year - year) > 1:
|
||||
continue
|
||||
for candidate in [result.get("name"), *(result.get("aliases") or [])]:
|
||||
if not candidate:
|
||||
continue
|
||||
ratio = SequenceMatcher(None, _clean(search_title), _clean(candidate)).ratio()
|
||||
if ratio > best_ratio:
|
||||
best, best_title, best_ratio = result, candidate, ratio
|
||||
return best, best_title, best_ratio
|
||||
|
||||
|
||||
class TVDBProvider(MetadataProvider):
|
||||
"""TheTVDB v4 metadata provider. Native IDs are TVDB IDs."""
|
||||
|
||||
NAME = "tvdb"
|
||||
REQUIRES_KEY = True
|
||||
BASE_URL = "https://api4.thetvdb.com/v4"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._episodes: dict[tuple[int, str], list[dict]] = {}
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(config.tvdb_api_key)
|
||||
|
||||
def _login(self) -> Optional[str]:
|
||||
global _token
|
||||
payload: dict[str, str] = {"apikey": config.tvdb_api_key}
|
||||
if config.tvdb_pin:
|
||||
payload["pin"] = config.tvdb_pin
|
||||
try:
|
||||
r = self.session.post(f"{self.BASE_URL}/login", json=payload, timeout=30)
|
||||
r.raise_for_status()
|
||||
_token = r.json()["data"]["token"]
|
||||
except (requests.RequestException, ValueError, KeyError) as exc:
|
||||
self.log.warning("Failed to authenticate with TVDB: %s", exc)
|
||||
_token = None
|
||||
return _token
|
||||
|
||||
def _get(self, path: str, params: Optional[dict] = None) -> Any:
|
||||
"""The `data` payload of a TVDB response: a dict for entities, a list for searches."""
|
||||
global _token
|
||||
token = _token or self._login()
|
||||
if not token:
|
||||
return None
|
||||
for _ in range(2):
|
||||
try:
|
||||
r = self.session.get(
|
||||
f"{self.BASE_URL}{path}",
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
if r.status_code == 401:
|
||||
_token = None
|
||||
token = self._login()
|
||||
if not token:
|
||||
return None
|
||||
continue
|
||||
r.raise_for_status()
|
||||
return r.json().get("data")
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
self.log.debug("TVDB request %s failed: %s", path, exc)
|
||||
return None
|
||||
return None
|
||||
|
||||
def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]:
|
||||
search_title = _strip_year(title)
|
||||
self.log.debug("Searching TVDB for %r (%s, %s)", search_title, kind, year)
|
||||
|
||||
params: dict[str, str | int] = {"query": search_title, "limit": 10}
|
||||
entity_type = KIND_TO_TYPE.get(kind)
|
||||
if entity_type:
|
||||
params["type"] = entity_type
|
||||
if year is not None:
|
||||
params["year"] = year
|
||||
|
||||
results = self._get("/search", params)
|
||||
if not results and year is not None:
|
||||
del params["year"]
|
||||
results = self._get("/search", params)
|
||||
if not results:
|
||||
self.log.debug("No TVDB results for %r", search_title)
|
||||
return None
|
||||
|
||||
# year first, so an identically named remake cannot win on title alone
|
||||
best, best_title, best_ratio = _pick_match(results, search_title, year)
|
||||
if best is None:
|
||||
best, best_title, best_ratio = _pick_match(results, search_title, None)
|
||||
if best is None:
|
||||
best, best_title = results[0], results[0].get("name")
|
||||
|
||||
tvdb_id = _parse_int(best.get("tvdb_id"))
|
||||
ext = _ids_from_remote(best.get("remote_ids"), tvdb_id)
|
||||
if ext.tmdb_id:
|
||||
ext.tmdb_kind = kind
|
||||
|
||||
self.log.debug("TVDB -> %r (ID %s, ratio %.2f)", best_title, tvdb_id, best_ratio)
|
||||
|
||||
return MetadataResult(
|
||||
title=best_title or best.get("name"),
|
||||
year=_parse_int(best.get("year")),
|
||||
kind=kind,
|
||||
external_ids=ext,
|
||||
source="tvdb",
|
||||
raw=best,
|
||||
)
|
||||
|
||||
def _fetch_extended(self, tvdb_id: int, kind: str) -> Optional[dict]:
|
||||
path = KIND_TO_PATH.get(kind, "series")
|
||||
return self._get(f"/{path}/{tvdb_id}/extended", {"short": "true"})
|
||||
|
||||
def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]:
|
||||
tvdb_id = int(provider_id)
|
||||
detail = self._fetch_extended(tvdb_id, kind)
|
||||
if not detail:
|
||||
return None
|
||||
|
||||
ext = _ids_from_remote(detail.get("remoteIds"), tvdb_id)
|
||||
if ext.tmdb_id:
|
||||
ext.tmdb_kind = kind
|
||||
|
||||
return MetadataResult(
|
||||
title=detail.get("name"),
|
||||
year=_parse_int(detail.get("year")),
|
||||
kind=kind,
|
||||
external_ids=ext,
|
||||
source="tvdb",
|
||||
raw=detail,
|
||||
)
|
||||
|
||||
def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds:
|
||||
tvdb_id = int(provider_id)
|
||||
detail = self._fetch_extended(tvdb_id, kind)
|
||||
if not detail:
|
||||
return ExternalIds(tvdb_id=tvdb_id)
|
||||
ext = _ids_from_remote(detail.get("remoteIds"), tvdb_id)
|
||||
if ext.tmdb_id:
|
||||
ext.tmdb_kind = kind
|
||||
return ext
|
||||
|
||||
def get_episodes(self, tvdb_id: int, order: str) -> list[dict]:
|
||||
"""All episodes of a series in the given season order."""
|
||||
cached = self._episodes.get((tvdb_id, order))
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
episodes: list[dict] = []
|
||||
for page in range(20): # ponytail: hard page cap, 500 eps/page covers any real series
|
||||
data = self._get(f"/series/{tvdb_id}/episodes/{order}", {"page": page})
|
||||
if data is None and page:
|
||||
# a partial listing would renumber episodes wrongly
|
||||
self.log.warning("TVDB %s order listing for series %s failed at page %d", order, tvdb_id, page)
|
||||
return []
|
||||
batch = (data or {}).get("episodes") or []
|
||||
episodes.extend(batch)
|
||||
if len(batch) < 500:
|
||||
break
|
||||
|
||||
if episodes: # an empty listing may be a transient failure
|
||||
self._episodes[(tvdb_id, order)] = episodes
|
||||
return episodes
|
||||
|
||||
def detect_order(self, tvdb_id: int, keys: Collection[tuple[int, int]]) -> str:
|
||||
"""Guess which TVDB season order a service's (season, episode) numbering follows.
|
||||
|
||||
A service does not always number a series in aired order, so the numbering is
|
||||
scored against every order rather than assumed.
|
||||
"""
|
||||
best, best_score = AIRED_ORDER, -1
|
||||
for order in SEASON_TYPES:
|
||||
episodes = self.get_episodes(tvdb_id, order)
|
||||
if not episodes:
|
||||
continue
|
||||
slots = {(_parse_int(e.get("seasonNumber")), _parse_int(e.get("number"))) for e in episodes}
|
||||
score = sum(1 for key in keys if key in slots)
|
||||
self.log.debug("TVDB order %s matches %d/%d of the service's episodes", order, score, len(keys))
|
||||
if score > best_score:
|
||||
best, best_score = order, score
|
||||
if order == AIRED_ORDER and score == len(keys):
|
||||
break # aired order already accounts for every episode
|
||||
return best
|
||||
|
||||
def get_order_map(
|
||||
self, tvdb_id: int, order: str, source_order: str = AIRED_ORDER
|
||||
) -> dict[tuple[int, int], tuple[int, int, Optional[str]]]:
|
||||
"""Map (season, episode) in `source_order` to (season, episode, name) in `order`.
|
||||
|
||||
Every order lists the same episodes under the same TVDB episode ID, which is what
|
||||
the two listings join on.
|
||||
"""
|
||||
if order == source_order:
|
||||
return {}
|
||||
|
||||
source = self.get_episodes(tvdb_id, source_order)
|
||||
target = self.get_episodes(tvdb_id, order)
|
||||
if not source or not target:
|
||||
self.log.warning("TVDB has no %s order for series %s", order, tvdb_id)
|
||||
return {}
|
||||
|
||||
by_id = {ep.get("id"): ep for ep in target}
|
||||
mapping: dict[tuple[int, int], tuple[int, int, Optional[str]]] = {}
|
||||
for ep in source:
|
||||
match = by_id.get(ep.get("id"))
|
||||
if not match:
|
||||
continue
|
||||
src_season, src_number = _parse_int(ep.get("seasonNumber")), _parse_int(ep.get("number"))
|
||||
dst_season, dst_number = _parse_int(match.get("seasonNumber")), _parse_int(match.get("number"))
|
||||
if src_season is None or src_number is None or dst_season is None or dst_number is None:
|
||||
continue
|
||||
mapping[(src_season, src_number)] = (dst_season, dst_number, match.get("name"))
|
||||
|
||||
self.log.debug("TVDB %s -> %s order: mapped %d/%d episodes", source_order, order, len(mapping), len(source))
|
||||
return mapping
|
||||
|
||||
def find_by_imdb_id(self, imdb_id: str, kind: str) -> Optional[ExternalIds]:
|
||||
"""Resolve a TVDB ID from an IMDb ID via /search/remoteid."""
|
||||
results = self._get(f"/search/remoteid/{imdb_id}")
|
||||
if not results:
|
||||
self.log.debug("No TVDB results for IMDB ID %s", imdb_id)
|
||||
return None
|
||||
|
||||
# each hit wraps a single entity, e.g. {"series": {...}} or {"movie": {...}}
|
||||
preferred = KIND_TO_TYPE.get(kind, "series")
|
||||
entity = None
|
||||
for hit in results:
|
||||
entity = hit.get(preferred) or entity or next(iter(hit.values()), None)
|
||||
if hit.get(preferred):
|
||||
break
|
||||
if not entity:
|
||||
return None
|
||||
|
||||
tvdb_id = _parse_int(entity.get("id"))
|
||||
self.log.debug("TVDB find -> TVDB %s for IMDB %s", tvdb_id, imdb_id)
|
||||
return ExternalIds(imdb_id=imdb_id, tvdb_id=tvdb_id)
|
||||
@@ -86,6 +86,7 @@ def tag_file(
|
||||
title: Title,
|
||||
tmdb_id: Optional[int] = None,
|
||||
imdb_id: Optional[str] = None,
|
||||
tvdb_id: Optional[int] = None,
|
||||
) -> None:
|
||||
log.debug("Tagging file %s with title %r", path, title)
|
||||
custom_tags: dict[str, str] = {}
|
||||
@@ -118,7 +119,7 @@ def tag_file(
|
||||
if config.tag_imdb_tmdb:
|
||||
try:
|
||||
providers = get_available_providers()
|
||||
if not providers:
|
||||
if not providers and tvdb_id is None:
|
||||
log.debug("No metadata providers available; skipping tag lookup")
|
||||
apply_tags(path, custom_tags)
|
||||
return
|
||||
@@ -130,9 +131,11 @@ def tag_file(
|
||||
imdbapi = get_provider("imdbapi")
|
||||
if imdbapi:
|
||||
result = imdbapi.get_by_id(imdb_id, kind)
|
||||
if result:
|
||||
result.external_ids.imdb_id = imdb_id
|
||||
enrich_ids(result)
|
||||
if not result:
|
||||
# IMDxAPI is off or had no answer; the ID alone still resolves the others
|
||||
result = MetadataResult(title=name, year=year, kind=kind)
|
||||
result.external_ids.imdb_id = imdb_id
|
||||
enrich_ids(result)
|
||||
elif tmdb_id is not None:
|
||||
tmdb = get_provider("tmdb")
|
||||
if tmdb:
|
||||
@@ -140,17 +143,28 @@ def tag_file(
|
||||
if result:
|
||||
ext = tmdb.get_external_ids(tmdb_id, kind)
|
||||
result.external_ids = ext
|
||||
enrich_ids(result)
|
||||
else:
|
||||
# Search across providers in priority order
|
||||
result = search_metadata(name, year, kind)
|
||||
|
||||
# If we got a TMDB ID from search but no full external IDs, fetch them
|
||||
if result and result.external_ids.tmdb_id and not result.external_ids.imdb_id:
|
||||
# If we got a TMDB ID from search but not the other IDs, fetch them
|
||||
if (
|
||||
result
|
||||
and result.external_ids.tmdb_id
|
||||
and not (result.external_ids.imdb_id and result.external_ids.tvdb_id)
|
||||
):
|
||||
ext = fetch_external_ids(result.external_ids.tmdb_id, kind)
|
||||
if ext.imdb_id:
|
||||
if ext.imdb_id and not result.external_ids.imdb_id:
|
||||
result.external_ids.imdb_id = ext.imdb_id
|
||||
if ext.tvdb_id:
|
||||
if ext.tvdb_id and not result.external_ids.tvdb_id:
|
||||
result.external_ids.tvdb_id = ext.tvdb_id
|
||||
enrich_ids(result)
|
||||
|
||||
if tvdb_id is not None:
|
||||
if result is None:
|
||||
result = MetadataResult(kind=kind)
|
||||
result.external_ids.tvdb_id = tvdb_id
|
||||
|
||||
if result and result.external_ids:
|
||||
standard_tags = _build_tags_from_ids(result.external_ids, kind)
|
||||
|
||||
Reference in New Issue
Block a user