341 lines
14 KiB
Python
341 lines
14 KiB
Python
"""TMDB API client: rate limiting, disk cache, episode metadata."""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from collections import deque
|
|
from pathlib import Path
|
|
from urllib.parse import urlencode
|
|
|
|
from .config import (
|
|
TMDB_API_BASE,
|
|
TMDB_LANGUAGE,
|
|
TMDB_CACHE_TTL_SEC,
|
|
TMDB_RATE_LIMIT_REQUESTS,
|
|
TMDB_RATE_LIMIT_WINDOW_SEC,
|
|
TMDB_CACHE_DIR,
|
|
TMDB_IMAGE_BASE,
|
|
TMDB_IMAGE_STILL_SIZE,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_EPISODE_PATTERN = re.compile(r"(?i)s(\d+)e(\d+)")
|
|
|
|
|
|
def parse_season_episode(stem: str) -> tuple[int, int] | None:
|
|
"""Parse S01E01-style from filename stem. Returns (season, episode) or None."""
|
|
m = _EPISODE_PATTERN.search(stem)
|
|
if m:
|
|
return int(m.group(1)), int(m.group(2))
|
|
return None
|
|
|
|
|
|
def _sanitize_key(name: str) -> str:
|
|
"""Sanitize a string for use as a cache filename."""
|
|
s = re.sub(r"[^\w\-.\s]", "", str(name))
|
|
return re.sub(r"\s+", "_", s).strip("_") or "unknown"
|
|
|
|
|
|
class TMDBClient:
|
|
"""TMDB API client with rate limiting and disk cache."""
|
|
|
|
def __init__(self):
|
|
self._series_cache: dict[str, tuple[int, str, float]] = {}
|
|
self._series_details_cache: dict[int, tuple[dict, float]] = {}
|
|
self._episode_cache: dict[tuple[int, int, int], tuple[dict, float]] = {}
|
|
self._episode_credits_cache: dict[tuple[int, int, int], tuple[dict, float]] = {}
|
|
self._request_times: deque = deque(maxlen=TMDB_RATE_LIMIT_REQUESTS + 10)
|
|
self._rate_limit_lock = threading.Lock()
|
|
self._ttl_sec = TMDB_CACHE_TTL_SEC
|
|
|
|
def _cache_path(self, subdir: str, key: str) -> Path:
|
|
"""Path to a cache file."""
|
|
TMDB_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
(TMDB_CACHE_DIR / subdir).mkdir(exist_ok=True)
|
|
return TMDB_CACHE_DIR / subdir / f"{key}.json"
|
|
|
|
def _read_disk_cache(self, subdir: str, key: str) -> dict | None:
|
|
path = self._cache_path(subdir, key)
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
|
|
def _write_disk_cache(self, subdir: str, key: str, data: dict) -> None:
|
|
path = self._cache_path(subdir, key)
|
|
try:
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, ensure_ascii=False, indent=0)
|
|
except OSError:
|
|
pass
|
|
|
|
def _wait_rate_limit(self) -> None:
|
|
with self._rate_limit_lock:
|
|
while True:
|
|
now = time.monotonic()
|
|
while self._request_times and now - self._request_times[0] >= TMDB_RATE_LIMIT_WINDOW_SEC:
|
|
self._request_times.popleft()
|
|
if len(self._request_times) < TMDB_RATE_LIMIT_REQUESTS:
|
|
break
|
|
wait = TMDB_RATE_LIMIT_WINDOW_SEC - (now - self._request_times[0])
|
|
if wait > 0:
|
|
logger.debug(
|
|
"TMDB rate limit: waiting %.1fs (%s/40 in window)",
|
|
wait,
|
|
len(self._request_times),
|
|
)
|
|
time.sleep(min(0.5, wait))
|
|
|
|
def _record_request(self) -> None:
|
|
with self._rate_limit_lock:
|
|
self._request_times.append(time.monotonic())
|
|
|
|
def _request(self, path: str, params: dict | None = None) -> dict | None:
|
|
api_key = os.environ.get("TMDB_API_KEY", "").strip()
|
|
if not api_key:
|
|
return None
|
|
self._wait_rate_limit()
|
|
q = {"api_key": api_key, "language": TMDB_LANGUAGE}
|
|
if params:
|
|
q.update(params)
|
|
url = f"{TMDB_API_BASE}{path}?{urlencode(q)}"
|
|
req = urllib.request.Request(url)
|
|
logger.debug("TMDB request: %s", path)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
data = json.load(resp)
|
|
self._record_request()
|
|
return data
|
|
except (
|
|
urllib.error.HTTPError,
|
|
urllib.error.URLError,
|
|
OSError,
|
|
json.JSONDecodeError,
|
|
) as e:
|
|
logger.debug("TMDB request failed %s: %s", path, e)
|
|
return None
|
|
|
|
def search_series(self, name: str) -> tuple[int, str] | None:
|
|
"""Search for a TV series by name. Returns (series_id, series_name_nl) or None."""
|
|
now = time.time()
|
|
if name in self._series_cache:
|
|
sid, sname, ts = self._series_cache[name]
|
|
if now - ts < self._ttl_sec:
|
|
logger.debug("TMDB series_search %s: memory hit -> %s", name, sid)
|
|
return (sid, sname)
|
|
key = _sanitize_key(name)
|
|
cached = self._read_disk_cache("series_search", key)
|
|
if cached is not None:
|
|
sid, sname = cached.get("series_id"), cached.get("series_name_nl")
|
|
if sid is not None and sname is not None:
|
|
self._series_cache[name] = (int(sid), sname, now)
|
|
logger.debug("TMDB series_search %s: disk hit -> %s", name, sid)
|
|
return (int(sid), sname)
|
|
logger.debug("TMDB series_search %s: API request", name)
|
|
data = self._request("/search/tv", {"query": name})
|
|
if not data:
|
|
return None
|
|
results = data.get("results") or []
|
|
if not results:
|
|
return None
|
|
first = results[0]
|
|
sid = first.get("id")
|
|
sname = (first.get("name") or name).strip() or name
|
|
if sid is not None:
|
|
self._series_cache[name] = (int(sid), sname, now)
|
|
self._write_disk_cache("series_search", key, {"series_id": sid, "series_name_nl": sname})
|
|
return (int(sid), sname)
|
|
return None
|
|
|
|
def get_series_details(self, series_id: int) -> dict | None:
|
|
"""Fetch series details in Dutch. Returns {genres, country, name} or None."""
|
|
now = time.time()
|
|
if series_id in self._series_details_cache:
|
|
details, ts = self._series_details_cache[series_id]
|
|
if now - ts < self._ttl_sec:
|
|
logger.debug("TMDB series_details %s: memory hit", series_id)
|
|
return details
|
|
key = str(series_id)
|
|
cached = self._read_disk_cache("series_details", key)
|
|
if cached is not None:
|
|
self._series_details_cache[series_id] = (cached, now)
|
|
logger.debug("TMDB series_details %s: disk hit", series_id)
|
|
return cached
|
|
logger.debug("TMDB series_details %s: API request", series_id)
|
|
data = self._request(f"/tv/{series_id}")
|
|
if not data:
|
|
return None
|
|
genres = [g.get("name") for g in (data.get("genres") or []) if g.get("name")]
|
|
countries = data.get("production_countries") or []
|
|
country = (countries[0].get("iso_3166_1") or "").upper() if countries else ""
|
|
name = (data.get("name") or "").strip() or ""
|
|
details = {"genres": genres, "country": country, "name": name}
|
|
self._series_details_cache[series_id] = (details, now)
|
|
self._write_disk_cache("series_details", key, details)
|
|
return details
|
|
|
|
def get_episode(self, series_id: int, season: int, episode: int) -> dict | None:
|
|
"""Fetch one episode in Dutch."""
|
|
now = time.time()
|
|
key_tuple = (series_id, season, episode)
|
|
if key_tuple in self._episode_cache:
|
|
meta, ts = self._episode_cache[key_tuple]
|
|
if now - ts < self._ttl_sec:
|
|
logger.debug("TMDB episode %s s%s e%s: memory hit", series_id, season, episode)
|
|
return meta
|
|
key = f"{series_id}_{season}_{episode}"
|
|
cached = self._read_disk_cache("episode", key)
|
|
if cached is not None:
|
|
self._episode_cache[key_tuple] = (cached, now)
|
|
logger.debug("TMDB episode %s s%s e%s: disk hit", series_id, season, episode)
|
|
return cached
|
|
logger.debug("TMDB episode %s s%s e%s: API request", series_id, season, episode)
|
|
data = self._request(f"/tv/{series_id}/season/{season}/episode/{episode}")
|
|
if not data:
|
|
return None
|
|
name = (data.get("name") or "").strip()
|
|
overview = (data.get("overview") or "").strip()
|
|
air_date = (data.get("air_date") or "").strip()
|
|
still_path = (data.get("still_path") or "").strip()
|
|
if still_path and not still_path.startswith("/"):
|
|
still_path = "/" + still_path
|
|
meta = {
|
|
"name": name,
|
|
"overview": overview,
|
|
"air_date": air_date,
|
|
"still_path": still_path or None,
|
|
}
|
|
self._episode_cache[key_tuple] = (meta, now)
|
|
self._write_disk_cache("episode", key, meta)
|
|
return meta
|
|
|
|
def get_episode_credits(self, series_id: int, season: int, episode: int) -> dict | None:
|
|
"""Fetch episode credits."""
|
|
now = time.time()
|
|
key_tuple = (series_id, season, episode)
|
|
if key_tuple in self._episode_credits_cache:
|
|
cred, ts = self._episode_credits_cache[key_tuple]
|
|
if now - ts < self._ttl_sec:
|
|
logger.debug("TMDB episode_credits %s s%s e%s: memory hit", series_id, season, episode)
|
|
return cred
|
|
key = f"{series_id}_{season}_{episode}"
|
|
cached = self._read_disk_cache("episode_credits", key)
|
|
if cached is not None:
|
|
self._episode_credits_cache[key_tuple] = (cached, now)
|
|
logger.debug("TMDB episode_credits %s s%s e%s: disk hit", series_id, season, episode)
|
|
return cached
|
|
logger.debug("TMDB episode_credits %s s%s e%s: API request", series_id, season, episode)
|
|
data = self._request(f"/tv/{series_id}/season/{season}/episode/{episode}/credits")
|
|
if not data:
|
|
return None
|
|
directors = []
|
|
producers = []
|
|
for c in data.get("crew") or []:
|
|
job = (c.get("job") or "").strip()
|
|
name = (c.get("name") or "").strip()
|
|
if not name:
|
|
continue
|
|
if job == "Director":
|
|
directors.append(name)
|
|
elif job in (
|
|
"Producer",
|
|
"Executive Producer",
|
|
"Co-Executive Producer",
|
|
"Supervising Producer",
|
|
"Co-Producer",
|
|
):
|
|
producers.append(name)
|
|
actors = [
|
|
(c.get("name") or "").strip()
|
|
for c in (data.get("cast") or [])
|
|
if (c.get("name") or "").strip()
|
|
]
|
|
directors = list(dict.fromkeys(directors))
|
|
producers = list(dict.fromkeys(producers))
|
|
cred = {"directors": directors, "actors": actors, "producers": producers}
|
|
self._episode_credits_cache[key_tuple] = (cred, now)
|
|
self._write_disk_cache("episode_credits", key, cred)
|
|
return cred
|
|
|
|
def get_episode_metadata(
|
|
self,
|
|
programme_name: str,
|
|
path: Path,
|
|
*,
|
|
display_name: str | None = None,
|
|
tmdb_search: str | None = None,
|
|
tmdb_id: int | None = None,
|
|
series_root: Path,
|
|
) -> dict | None:
|
|
"""
|
|
Return TMDB metadata for this series episode in Dutch.
|
|
Only for series under series_root; parses S01E01 from path.stem.
|
|
"""
|
|
if series_root not in path.parents:
|
|
return None
|
|
if not os.environ.get("TMDB_API_KEY", "").strip():
|
|
return None
|
|
se = parse_season_episode(path.stem)
|
|
if not se:
|
|
return None
|
|
season_num, episode_num = se
|
|
|
|
if tmdb_id is not None:
|
|
series_id = tmdb_id
|
|
details = self.get_series_details(series_id)
|
|
series_name_nl = (details.get("name") or programme_name).strip() or programme_name
|
|
else:
|
|
search_term = (tmdb_search or programme_name).strip() or programme_name
|
|
hit = self.search_series(search_term)
|
|
if not hit:
|
|
return None
|
|
series_id, series_name_nl = hit
|
|
|
|
ep = self.get_episode(series_id, season_num, episode_num)
|
|
if not ep:
|
|
return None
|
|
|
|
sub_title = ep.get("name") or path.stem
|
|
overview = (ep.get("overview") or "").strip() or None
|
|
air_date = ep.get("air_date") or ""
|
|
date_str = (air_date.replace("-", "")[:8]) if air_date and len(air_date) >= 10 else "20090101"
|
|
still_path = ep.get("still_path") or ""
|
|
icon_url = f"{TMDB_IMAGE_BASE}/{TMDB_IMAGE_STILL_SIZE}{still_path}" if still_path and still_path.startswith("/") else None
|
|
|
|
details = self.get_series_details(series_id)
|
|
genres = (details.get("genres") or []) if details else []
|
|
country = (details.get("country") or "") if details else ""
|
|
|
|
credits = self.get_episode_credits(series_id, season_num, episode_num)
|
|
directors = (credits.get("directors") or []) if credits else []
|
|
actors = (credits.get("actors") or []) if credits else []
|
|
producers = (credits.get("producers") or []) if credits else []
|
|
|
|
xmltv_ns = f"{season_num}.{episode_num}.0/1"
|
|
onscreen = f"S{season_num:02d}E{episode_num:02d}"
|
|
epg_title = (display_name or series_name_nl).strip() or series_name_nl
|
|
|
|
return {
|
|
"title": epg_title,
|
|
"sub_title": sub_title,
|
|
"desc": overview,
|
|
"lang": TMDB_LANGUAGE,
|
|
"icon": icon_url,
|
|
"directors": directors,
|
|
"actors": actors,
|
|
"producers": producers,
|
|
"date": date_str,
|
|
"categories": genres,
|
|
"country": country,
|
|
"episode_nums": {"xmltv_ns": xmltv_ns, "onscreen": onscreen},
|
|
}
|