soulsync/core/video/enrichment/clients.py
BoulderBadgeDad b9b3b9eed3 video 'capture everything': cast & crew (people + credits) + cast row UI
Last capture piece, schema v3 (new people + credits tables; CREATE IF NOT EXISTS
migrates existing DBs on restart, no wipe):
- people deduped by tmdb_id; credits link to exactly one movie OR show (separate
  nullable FKs + CHECK, no polymorphic id) with department/job/character/order.
- TMDB client appends credits to the detail call (free) and parses cast (name,
  character, photo, billing order) + headline crew (directors/writers/creators).
- enrichment_apply backfills cast/crew gap-only (never clobbers); show/movie
  detail return cast + crew. Populates on view via the existing lazy refresh-art.
- Cast & Crew section on the detail page: grouped crew line + a horizontal
  cast row with circular TMDB headshots, names, characters (accent hover).

Seam tests: TMDB credit parse (+ job filtering, created_by), backfill + people
dedup across titles, gap-only no-clobber, payload shape.
2026-06-14 21:20:08 -07:00

250 lines
10 KiB
Python

"""TMDB / TVDB match clients for the video enrichment workers.
Thin adapters: ``.enabled`` (an API key is configured) and ``.match(kind, title,
year) -> {"id", "metadata"} | None``. These talk to real TMDB/TVDB APIs and are
validated against the live services; the worker LOGIC is unit-tested with a fake
client. Keys come from video_settings.
"""
from __future__ import annotations
from utils.logging_config import get_logger
logger = get_logger("video_enrichment.clients")
def _int(val):
try:
return int(val)
except (TypeError, ValueError):
return None
class TMDBClient:
BASE = "https://api.themoviedb.org/3"
IMG = "https://image.tmdb.org/t/p/original"
def __init__(self, api_key):
self.api_key = api_key or None
@property
def enabled(self):
return bool(self.api_key)
def test(self):
if not self.api_key:
return False, "No TMDB API key set"
import requests
try:
r = requests.get(self.BASE + "/configuration", params={"api_key": self.api_key}, timeout=12)
if r.status_code == 200:
return True, "TMDB connection OK"
if r.status_code == 401:
return False, "Invalid TMDB API key"
return False, "TMDB returned HTTP " + str(r.status_code)
except Exception:
logger.exception("TMDB test failed")
return False, "Could not reach TMDB"
def match(self, kind, title, year, known_id=None):
if not self.api_key:
return None
import requests
# The server already knows the TMDB id → go straight to the details
# fetch (accurate, one call). Otherwise fall back to a title/year search.
tmdb_id = _int(known_id)
meta = {}
if tmdb_id is None:
if not title:
return None
path = "/search/movie" if kind == "movie" else "/search/tv"
params = {"api_key": self.api_key, "query": title}
if year:
params["year" if kind == "movie" else "first_air_date_year"] = year
resp = requests.get(self.BASE + path, params=params, timeout=15)
# A non-200 (429 rate-limit, 5xx, timeout-as-error) is a FAILED call,
# not "no match" — raise so the worker records 'error' (retried later)
# instead of burning the item to 'not_found'.
resp.raise_for_status()
results = (resp.json() or {}).get("results") or []
if not results:
return None
tmdb_id = results[0].get("id")
meta["overview"] = results[0].get("overview")
if tmdb_id is None:
return None
try:
detail_path = "/movie/" if kind == "movie" else "/tv/"
dr = requests.get(self.BASE + detail_path + str(tmdb_id),
params={"api_key": self.api_key,
"append_to_response": "external_ids,credits"},
timeout=15).json() or {}
meta["overview"] = dr.get("overview") or meta.get("overview")
if dr.get("backdrop_path"):
meta["backdrop_url"] = self.IMG + dr["backdrop_path"]
ext = dr.get("external_ids") or {}
meta["imdb_id"] = ext.get("imdb_id") or dr.get("imdb_id")
# Everything TMDB offers (same call) — the worker backfills only the
# gaps the server left.
meta["tagline"] = dr.get("tagline")
meta["status"] = dr.get("status")
if dr.get("vote_average"):
meta["rating"] = dr.get("vote_average")
gs = [g.get("name") for g in (dr.get("genres") or []) if g.get("name")]
if gs:
meta["genres"] = gs
if kind == "movie":
meta["release_date"] = dr.get("release_date")
meta["runtime_minutes"] = dr.get("runtime")
else:
meta["first_air_date"] = dr.get("first_air_date")
meta["last_air_date"] = dr.get("last_air_date")
ert = dr.get("episode_run_time") or []
if ert:
meta["runtime_minutes"] = ert[0]
meta["tvdb_id"] = _int(ext.get("tvdb_id"))
# Per-season posters — the reliable source of distinct season art
# (the media server usually lacks it). Backfilled into seasons.
seasons = []
for s in (dr.get("seasons") or []):
pp, sn = s.get("poster_path"), s.get("season_number")
if pp and sn is not None:
seasons.append({"season_number": sn, "poster_url": self.IMG + pp})
if seasons:
meta["seasons"] = seasons
self._add_credits(meta, dr.get("credits") or {}, dr.get("created_by") or [])
except Exception:
logger.exception("TMDB details fetch failed for %s", title or tmdb_id)
return {"id": tmdb_id, "metadata": {k: v for k, v in meta.items() if v}}
PROFILE = "https://image.tmdb.org/t/p/w185"
def _person(self, c, job=None, character=None):
return {"name": c["name"], "tmdb_id": c.get("id"), "job": job, "character": character,
"photo_url": (self.PROFILE + c["profile_path"]) if c.get("profile_path") else None}
def _add_credits(self, meta, credits, created_by):
"""Parse TMDB cast/crew into meta['cast'] / meta['crew']."""
cast = [self._person(c, character=c.get("character"))
for c in (credits.get("cast") or [])[:20] if c.get("name")]
if cast:
meta["cast"] = cast
# Crew: headline jobs only (directors / writers); plus TV creators, which
# live in the top-level created_by, not the crew list.
wanted = {"Director", "Writer", "Screenplay"}
crew = [self._person(c, job=c.get("job")) for c in (credits.get("crew") or [])
if c.get("name") and c.get("job") in wanted]
crew += [self._person(c, job="Creator") for c in created_by if c.get("name")]
if crew:
meta["crew"] = crew
def season_episodes(self, tv_id, season_number):
"""Episode-level data for one season (still/overview/rating) — the show
worker cascades over a show's seasons to backfill episodes the media
server lacked. Returns {'overview', 'episodes': [...]} or None."""
if not self.api_key or tv_id is None or season_number is None:
return None
import requests
r = requests.get(self.BASE + "/tv/" + str(tv_id) + "/season/" + str(season_number),
params={"api_key": self.api_key}, timeout=15)
r.raise_for_status()
data = r.json() or {}
out = []
for e in (data.get("episodes") or []):
en = e.get("episode_number")
if en is None:
continue
ep = {"episode_number": en, "overview": e.get("overview"),
"rating": e.get("vote_average") or None}
if e.get("still_path"):
ep["still_url"] = self.IMG + e["still_path"]
out.append(ep)
return {"overview": data.get("overview"), "episodes": out}
class TVDBClient:
BASE = "https://api4.thetvdb.com/v4"
def __init__(self, api_key):
self.api_key = api_key or None
self._token = None
@property
def enabled(self):
return bool(self.api_key)
def test(self):
if not self.api_key:
return False, "No TVDB API key set"
try:
token = self._auth()
if token:
return True, "TVDB connection OK"
return False, "TVDB login failed — check the key"
except Exception:
logger.exception("TVDB test failed")
return False, "Could not reach TVDB"
def _auth(self, force=False):
if self._token and not force:
return self._token
import requests
self._token = None
r = requests.post(self.BASE + "/login", json={"apikey": self.api_key}, timeout=15).json() or {}
self._token = (r.get("data") or {}).get("token")
return self._token
def _authed_get(self, path, params=None):
"""GET with the bearer token, transparently re-authenticating once if the
cached token has expired (401). Raises on any other non-200 so the worker
records 'error' rather than a false 'not_found'."""
import requests
token = self._auth()
if not token:
return None
r = requests.get(self.BASE + path, headers={"Authorization": "Bearer " + token},
params=params, timeout=15)
if r.status_code == 401 and self._auth(force=True): # token expired → refresh once
r = requests.get(self.BASE + path, headers={"Authorization": "Bearer " + self._token},
params=params, timeout=15)
r.raise_for_status()
return r.json() or {}
def match(self, kind, title, year, known_id=None):
if kind != "show" or not self.api_key:
return None
tvdb_id = _int(known_id)
meta = {}
if tvdb_id is None:
if not title:
return None
r = self._authed_get("/search", {"query": title, "type": "series"})
results = (r or {}).get("data") or []
if not results:
return None
top = results[0]
tvdb_id = _int(top.get("tvdb_id") or top.get("id"))
meta["overview"] = top.get("overview")
else:
# Known id from the server → fetch the extended record (overview +
# genres + everything TVDB offers).
try:
dr = self._authed_get("/series/" + str(tvdb_id) + "/extended")
sd = (dr or {}).get("data") or {}
meta["overview"] = sd.get("overview")
gs = [g.get("name") for g in (sd.get("genres") or []) if g.get("name")]
if gs:
meta["genres"] = gs
except Exception:
logger.exception("TVDB details fetch failed for %s", title or tvdb_id)
if tvdb_id is None:
return None
return {"id": tvdb_id, "metadata": {k: v for k, v in meta.items() if v}}
def build_clients(db) -> dict:
"""Construct the source clients from the saved API keys (in video_settings)."""
return {
"tmdb": TMDBClient(db.get_setting("tmdb_api_key")),
"tvdb": TVDBClient(db.get_setting("tvdb_api_key")),
}