mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-07-15 12:27:24 +00:00
feat(tags): add OMDb metadata provider with omdb_api_key config
This commit is contained in:
@@ -37,7 +37,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`, `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`, `simkl_client_id`, `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` |
|
||||
@@ -749,6 +749,7 @@ All default to an empty string and enable optional metadata/geolocation features
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `imdb_api_enabled` | bool | `false` | Use the free imdbapi.dev metadata provider, which needs no API key. Off by default since the site has been unreliable. |
|
||||
| `omdb_api_key` | str | `""` | [OMDb](https://www.omdbapi.com/) API key for IMDb metadata lookups; a more reliable alternative to imdbapi.dev. 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. |
|
||||
| `decrypt_labs_api_key` | str | `""` | Global Decrypt Labs API key (used by remote CDM / vault). |
|
||||
@@ -872,6 +873,7 @@ proxy_providers:
|
||||
basic:
|
||||
us: http://user:pass@1.2.3.4:8080
|
||||
|
||||
omdb_api_key: "your-omdb-key"
|
||||
tmdb_api_key: "your-tmdb-key"
|
||||
title_cache_time: 3600
|
||||
redact_paths: true
|
||||
|
||||
@@ -115,6 +115,7 @@ class Config:
|
||||
self.tag_group_name: bool = kwargs.get("tag_group_name", True)
|
||||
self.tag_imdb_tmdb: bool = kwargs.get("tag_imdb_tmdb", True)
|
||||
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.simkl_client_id: str = kwargs.get("simkl_client_id") or ""
|
||||
self.decrypt_labs_api_key: str = kwargs.get("decrypt_labs_api_key") or ""
|
||||
|
||||
@@ -6,14 +6,15 @@ import requests
|
||||
|
||||
from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, fuzzy_match, log
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from unshackle.core.title_cacher import TitleCacher
|
||||
|
||||
# Ordered by priority: IMDBApi (free), SIMKL, TMDB
|
||||
ALL_PROVIDERS: list[type[MetadataProvider]] = [IMDBApiProvider, SimklProvider, TMDBProvider]
|
||||
# Ordered by priority: IMDBApi (free), OMDb, SIMKL, TMDB
|
||||
ALL_PROVIDERS: list[type[MetadataProvider]] = [IMDBApiProvider, OMDBProvider, SimklProvider, TMDBProvider]
|
||||
|
||||
|
||||
def get_available_providers() -> list[MetadataProvider]:
|
||||
@@ -390,6 +391,24 @@ def _cached_to_result(cached: dict, provider_name: str, kind: str) -> Optional[M
|
||||
source="simkl",
|
||||
raw=cached,
|
||||
)
|
||||
elif provider_name == "omdb":
|
||||
title = cached.get("Title")
|
||||
year_str = cached.get("Year")
|
||||
year = int(year_str[:4]) if year_str and len(year_str) >= 4 and year_str[:4].isdigit() else None
|
||||
enriched = cached.get("_enriched_ids", {})
|
||||
return MetadataResult(
|
||||
title=title,
|
||||
year=year,
|
||||
kind=kind,
|
||||
external_ids=ExternalIds(
|
||||
imdb_id=cached.get("imdbID"),
|
||||
tmdb_id=enriched.get("tmdb_id"),
|
||||
tmdb_kind=enriched.get("tmdb_kind"),
|
||||
tvdb_id=enriched.get("tvdb_id"),
|
||||
),
|
||||
source="omdb",
|
||||
raw=cached,
|
||||
)
|
||||
elif provider_name == "imdbapi":
|
||||
title = cached.get("primaryTitle") or cached.get("originalTitle")
|
||||
year = cached.get("startYear")
|
||||
|
||||
130
unshackle/core/providers/omdb.py
Normal file
130
unshackle/core/providers/omdb.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Optional, Union
|
||||
|
||||
import requests
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, _clean, fuzzy_match
|
||||
|
||||
# Mapping from our kind ("movie"/"tv") to OMDb title types
|
||||
KIND_TO_TYPE: dict[str, str] = {
|
||||
"movie": "movie",
|
||||
"tv": "series",
|
||||
}
|
||||
|
||||
|
||||
def _parse_year(value: Optional[str]) -> Optional[int]:
|
||||
# OMDb years look like "2017", "2008–2013" or "2023–"
|
||||
if value and len(value) >= 4 and value[:4].isdigit():
|
||||
return int(value[:4])
|
||||
return None
|
||||
|
||||
|
||||
class OMDBProvider(MetadataProvider):
|
||||
"""OMDb metadata provider (omdbapi.com). Native IDs are IMDb IDs."""
|
||||
|
||||
NAME = "omdb"
|
||||
REQUIRES_KEY = True
|
||||
BASE_URL = "https://www.omdbapi.com/"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(config.omdb_api_key)
|
||||
|
||||
@property
|
||||
def _api_key(self) -> str:
|
||||
return config.omdb_api_key
|
||||
|
||||
def _get(self, params: dict[str, str]) -> Optional[dict]:
|
||||
try:
|
||||
r = self.session.get(self.BASE_URL, params={"apikey": self._api_key, **params}, timeout=30)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
self.log.debug("OMDb request failed: %s", exc)
|
||||
return None
|
||||
if data.get("Response") != "True":
|
||||
self.log.debug("OMDb error: %s", data.get("Error"))
|
||||
return None
|
||||
return data
|
||||
|
||||
def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]:
|
||||
self.log.debug("Searching OMDb for %r (%s, %s)", title, kind, year)
|
||||
|
||||
params: dict[str, str] = {"s": title}
|
||||
type_filter = KIND_TO_TYPE.get(kind)
|
||||
if type_filter:
|
||||
params["type"] = type_filter
|
||||
if year is not None:
|
||||
params["y"] = str(year)
|
||||
|
||||
data = self._get(params)
|
||||
if not data and year is not None:
|
||||
# OMDb's year filter is exact; retry without it for off-by-one metadata
|
||||
del params["y"]
|
||||
data = self._get(params)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
results = data.get("Search") or []
|
||||
|
||||
best_match: Optional[dict] = None
|
||||
best_ratio = 0.0
|
||||
for candidate in results:
|
||||
name = candidate.get("Title") or ""
|
||||
if not name:
|
||||
continue
|
||||
ratio = SequenceMatcher(None, _clean(title), _clean(name)).ratio()
|
||||
if ratio > best_ratio:
|
||||
candidate_year = _parse_year(candidate.get("Year"))
|
||||
if year and candidate_year and abs(year - candidate_year) > 1:
|
||||
continue
|
||||
best_ratio = ratio
|
||||
best_match = candidate
|
||||
|
||||
if not best_match:
|
||||
self.log.debug("No matching result found in OMDb for %r", title)
|
||||
return None
|
||||
|
||||
result_title = best_match.get("Title")
|
||||
if not result_title or not fuzzy_match(result_title, title):
|
||||
self.log.debug("OMDb title mismatch: searched %r, got %r", title, result_title)
|
||||
return None
|
||||
|
||||
imdb_id = best_match.get("imdbID")
|
||||
self.log.debug("OMDb -> %s (ID %s)", result_title, imdb_id)
|
||||
|
||||
# Fetch full detail so raw carries ratings, genre, rating cert, etc.
|
||||
detail = self._get({"i": imdb_id}) if imdb_id else None
|
||||
|
||||
return MetadataResult(
|
||||
title=result_title,
|
||||
year=_parse_year(best_match.get("Year")),
|
||||
kind=kind,
|
||||
external_ids=ExternalIds(imdb_id=imdb_id),
|
||||
source="omdb",
|
||||
raw=detail or best_match,
|
||||
)
|
||||
|
||||
def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]:
|
||||
"""Fetch metadata by IMDb ID (e.g. 'tt1375666')."""
|
||||
imdb_id = str(provider_id)
|
||||
self.log.debug("Fetching OMDb title %s", imdb_id)
|
||||
|
||||
data = self._get({"i": imdb_id})
|
||||
if not data:
|
||||
return None
|
||||
|
||||
return MetadataResult(
|
||||
title=data.get("Title"),
|
||||
year=_parse_year(data.get("Year")),
|
||||
kind=kind,
|
||||
external_ids=ExternalIds(imdb_id=data.get("imdbID")),
|
||||
source="omdb",
|
||||
raw=data,
|
||||
)
|
||||
|
||||
def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds:
|
||||
"""Return external IDs. For OMDb, the provider_id IS the IMDb ID."""
|
||||
return ExternalIds(imdb_id=str(provider_id))
|
||||
Reference in New Issue
Block a user