feat(tags): add OMDb metadata provider with omdb_api_key config

This commit is contained in:
imSp4rky
2026-07-11 11:46:58 -06:00
parent e7b716d79c
commit c47aad6fa1
4 changed files with 155 additions and 3 deletions

View File

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

View File

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

View File

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

View 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", "20082013" 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))