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
+3 -1
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` | | [DRM & CDM](#drm-cdm) | `cdm`, `remote_cdm`, `decryption` |
| [Network & proxy](#network-proxy) | `network`, `headers`, `proxy_providers` | | [Network & proxy](#network-proxy) | `network`, `headers`, `proxy_providers` |
| [Key vaults](#key-vaults) | `key_vaults`, `vault_timeout` | | [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` | | [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` | | [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` | | [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 | | 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. | | `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. | | `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. | | `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). | | `decrypt_labs_api_key` | str | `""` | Global Decrypt Labs API key (used by remote CDM / vault). |
@@ -872,6 +873,7 @@ proxy_providers:
basic: basic:
us: http://user:pass@1.2.3.4:8080 us: http://user:pass@1.2.3.4:8080
omdb_api_key: "your-omdb-key"
tmdb_api_key: "your-tmdb-key" tmdb_api_key: "your-tmdb-key"
title_cache_time: 3600 title_cache_time: 3600
redact_paths: true redact_paths: true
+1
View File
@@ -115,6 +115,7 @@ class Config:
self.tag_group_name: bool = kwargs.get("tag_group_name", True) self.tag_group_name: bool = kwargs.get("tag_group_name", True)
self.tag_imdb_tmdb: bool = kwargs.get("tag_imdb_tmdb", True) self.tag_imdb_tmdb: bool = kwargs.get("tag_imdb_tmdb", True)
self.imdb_api_enabled: bool = kwargs.get("imdb_api_enabled", False) 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.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
self.simkl_client_id: str = kwargs.get("simkl_client_id") 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.decrypt_labs_api_key: str = kwargs.get("decrypt_labs_api_key") or ""
+21 -2
View File
@@ -6,14 +6,15 @@ import requests
from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, fuzzy_match, log from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, fuzzy_match, log
from unshackle.core.providers.imdbapi import IMDBApiProvider 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.simkl import SimklProvider
from unshackle.core.providers.tmdb import TMDBProvider from unshackle.core.providers.tmdb import TMDBProvider
if TYPE_CHECKING: if TYPE_CHECKING:
from unshackle.core.title_cacher import TitleCacher from unshackle.core.title_cacher import TitleCacher
# Ordered by priority: IMDBApi (free), SIMKL, TMDB # Ordered by priority: IMDBApi (free), OMDb, SIMKL, TMDB
ALL_PROVIDERS: list[type[MetadataProvider]] = [IMDBApiProvider, SimklProvider, TMDBProvider] ALL_PROVIDERS: list[type[MetadataProvider]] = [IMDBApiProvider, OMDBProvider, SimklProvider, TMDBProvider]
def get_available_providers() -> list[MetadataProvider]: 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", source="simkl",
raw=cached, 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": elif provider_name == "imdbapi":
title = cached.get("primaryTitle") or cached.get("originalTitle") title = cached.get("primaryTitle") or cached.get("originalTitle")
year = cached.get("startYear") year = cached.get("startYear")
+130
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", "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))