video: detail extras data layer — gallery, all videos, keywords, facts, full cast (+ caching)

One TMDB call (append_to_response) now also returns: image gallery (backdrops +
posters, thumb+full), all YouTube videos (ordered trailer→teaser→clip→…), keyword
tags, facts (budget/revenue/language/country), and the FULL cast (tv via
aggregate_credits with per-actor episode counts). Shared by item_extras (owned)
and full_detail/tmdb_detail (preview).

Caching: the engine memoizes the live TMDB extras + preview payloads (30-min TTL)
so re-opening a title is instant instead of re-hitting TMDB. Tests added.
This commit is contained in:
BoulderBadgeDad 2026-06-15 11:17:33 -07:00
parent 595ea2bfbd
commit 6fcebe7c4b
3 changed files with 168 additions and 9 deletions

View file

@ -158,6 +158,7 @@ class TMDBClient:
meta["crew"] = crew
POSTER_W = "https://image.tmdb.org/t/p/w300"
BACKDROP_W = "https://image.tmdb.org/t/p/w780"
PROVIDER = "https://image.tmdb.org/t/p/original"
def extras(self, kind, tmdb_id, region="US"):
@ -167,9 +168,13 @@ class TMDBClient:
return {}
import requests
path = ("/movie/" if kind == "movie" else "/tv/") + str(tmdb_id)
# TV uses aggregate_credits (it carries per-actor episode counts); movies
# use credits. One call (append_to_response) fetches everything.
creds = "aggregate_credits" if kind == "show" else "credits"
r = requests.get(self.BASE + path, params={
"api_key": self.api_key,
"append_to_response": "videos,watch/providers,similar,recommendations"}, timeout=15)
"api_key": self.api_key, "include_image_language": "en,null",
"append_to_response": "videos,watch/providers,similar,recommendations,images,keywords," + creds},
timeout=15)
r.raise_for_status()
out = self._parse_extras(kind, r.json() or {}, region)
self._fill_collection(out)
@ -236,8 +241,83 @@ class TMDBClient:
out["next_episode"] = self._episode_stub(d["next_episode_to_air"])
if d.get("last_episode_to_air"):
out["last_episode"] = self._episode_stub(d["last_episode_to_air"])
# Photos gallery (backdrops + posters) — thumb for the grid, full for the
# lightbox.
imgs = d.get("images") or {}
gallery = {}
backs = [{"thumb": self.BACKDROP_W + b["file_path"], "full": self.IMG + b["file_path"]}
for b in (imgs.get("backdrops") or [])[:14] if b.get("file_path")]
posts = [{"thumb": self.POSTER_W + p["file_path"], "full": self.IMG + p["file_path"]}
for p in (imgs.get("posters") or [])[:14] if p.get("file_path")]
if backs:
gallery["backdrops"] = backs
if posts:
gallery["posters"] = posts
if gallery:
out["gallery"] = gallery
# All videos (YouTube) — trailers / teasers / featurettes / clips / BTS.
vids = []
for v in (d.get("videos") or {}).get("results") or []:
if v.get("site") == "YouTube" and v.get("key") and v.get("type"):
vids.append({"key": v["key"], "name": v.get("name"), "type": v.get("type")})
if vids:
out["videos"] = self._order_videos(vids)
# Keywords / tags.
kw = d.get("keywords") or {}
kwlist = kw.get("keywords") or kw.get("results") or []
keywords = [k.get("name") for k in kwlist if k.get("name")][:14]
if keywords:
out["keywords"] = keywords
# Facts / box office.
facts = {}
if kind == "movie":
if d.get("budget"):
facts["budget"] = d["budget"]
if d.get("revenue"):
facts["revenue"] = d["revenue"]
if d.get("original_language"):
facts["original_language"] = d["original_language"]
countries = [c.get("name") for c in (d.get("production_countries") or []) if c.get("name")]
if not countries and d.get("origin_country"):
countries = list(d.get("origin_country") or [])
if countries:
facts["countries"] = countries[:3]
if facts:
out["facts"] = facts
# Full cast (for the "view all" expansion) — tv carries episode counts.
out["cast_full"] = self._full_cast(d, kind)
if not out["cast_full"]:
out.pop("cast_full")
return out
_VIDEO_ORDER = {"Trailer": 0, "Teaser": 1, "Clip": 2, "Featurette": 3, "Behind the Scenes": 4}
def _order_videos(self, vids):
return sorted(vids, key=lambda v: self._VIDEO_ORDER.get(v.get("type"), 9))[:18]
def _full_cast(self, d, kind):
if kind == "show":
src = (d.get("aggregate_credits") or {}).get("cast") \
or (d.get("credits") or {}).get("cast") or []
else:
src = (d.get("credits") or {}).get("cast") or []
out = []
for c in src:
if not c.get("name"):
continue
char = c.get("character")
if not char and c.get("roles"):
char = (c["roles"][0] or {}).get("character")
out.append({"name": c["name"], "character": char or None, "tmdb_id": c.get("id"),
"photo": (self.PROFILE + c["profile_path"]) if c.get("profile_path") else None,
"episode_count": c.get("total_episode_count")})
return out[:80]
def _title_list(self, results, kind):
out = []
for s in (results or [])[:14]:
@ -366,9 +446,11 @@ class TMDBClient:
return None
import requests
path = ("/movie/" if kind == "movie" else "/tv/") + str(tmdb_id)
agg = ",aggregate_credits" if kind == "show" else ""
r = requests.get(self.BASE + path, params={
"api_key": self.api_key,
"append_to_response": "external_ids,credits,images,videos,watch/providers,similar,recommendations",
"append_to_response": "external_ids,credits,images,videos,watch/providers,similar,"
"recommendations,keywords" + agg,
"include_image_language": "en,null"}, timeout=15)
r.raise_for_status()
dr = r.json() or {}

View file

@ -28,10 +28,26 @@ class VideoEnrichmentEngine:
# OMDb ratings (IMDb/RT/Metacritic) — not a matcher, so not a worker;
# backfilled on the lazy detail refresh.
self.ratings_client = ratings_client
# Short-TTL cache for live TMDB detail extras / preview payloads, so
# re-opening a title is instant instead of re-hitting TMDB.
self._tmdb_cache = {}
# Restore each worker's persisted pause state (survives restart).
for w in self.workers.values():
w.restore_paused()
def _cache_get(self, key):
import time
hit = self._tmdb_cache.get(key)
if hit and hit[0] > time.monotonic():
return hit[1]
return None
def _cache_put(self, key, data, ttl=1800):
import time
if len(self._tmdb_cache) > 256: # cheap bound — clear wholesale
self._tmdb_cache.clear()
self._tmdb_cache[key] = (time.monotonic() + ttl, data)
def _backfill_ratings(self, kind, item_id):
# The OMDb worker owns the ratings client (fallback to an injected one
# for tests that don't build a worker).
@ -159,10 +175,16 @@ class VideoEnrichmentEngine:
info = (self.db.movie_match_info(item_id) if kind == "movie"
else self.db.show_match_info(item_id))
if info and info.get("tmdb_id"):
try:
out = w.client.extras(kind, info["tmdb_id"]) or {}
except Exception:
logger.exception("item_extras failed for %s %s", kind, item_id)
key = ("extras", kind, info["tmdb_id"])
cached = self._cache_get(key)
if cached is None:
try:
cached = w.client.extras(kind, info["tmdb_id"]) or {}
self._cache_put(key, cached)
except Exception:
logger.exception("item_extras failed for %s %s", kind, item_id)
cached = {}
out = dict(cached) # copy — the per-item server link isn't cached
srv = self._server_watch_link(kind, item_id)
if srv:
out["server"] = srv
@ -276,6 +298,9 @@ class VideoEnrichmentEngine:
lib_id = self.db.library_id_for_tmdb(kind, tmdb_id)
if lib_id:
return {"redirect": {"source": "library", "kind": kind, "id": lib_id}}
cached = self._cache_get(("detail", kind, tmdb_id))
if cached is not None:
return dict(cached)
try:
d = w.client.full_detail(kind, tmdb_id)
except Exception:
@ -289,7 +314,10 @@ class VideoEnrichmentEngine:
d.update({"trailer": ex.get("trailer"), "providers": ex.get("providers") or [],
"providers_link": ex.get("providers_link"), "similar": ex.get("similar") or [],
"recommendations": ex.get("recommendations") or [], "collection": ex.get("collection"),
"next_episode": ex.get("next_episode"), "last_episode": ex.get("last_episode")})
"next_episode": ex.get("next_episode"), "last_episode": ex.get("last_episode"),
"gallery": ex.get("gallery"), "videos": ex.get("videos") or [],
"keywords": ex.get("keywords") or [], "facts": ex.get("facts"),
"cast_full": ex.get("cast_full") or []})
if kind == "show":
seasons = d.pop("_seasons", []) or []
for s in seasons:
@ -302,6 +330,7 @@ class VideoEnrichmentEngine:
d["episode_total"] = sum(s["episode_total"] for s in seasons)
d["episode_owned"] = 0
self._fill_tmdb_ratings(d)
self._cache_put(("detail", kind, tmdb_id), d)
return d
def _fill_tmdb_ratings(self, d) -> None:

View file

@ -208,7 +208,8 @@ def test_tmdb_full_detail_movie(monkeypatch):
assert d["cast"][0] == {"name": "Timothée", "character": "Paul",
"photo": "https://image.tmdb.org/t/p/w185/t.jpg", "tmdb_id": 11}
assert d["imdb_id"] == "tt1160419" and d["logo"].endswith("/logo.png")
assert d["_extras"] == {} # no videos/providers/similar here
assert "videos" not in d["_extras"] and "similar" not in d["_extras"] # none in this body
assert d["_extras"]["cast_full"][0]["character"] == "Paul" # full cast parsed
def test_engine_tmdb_detail_redirects_when_owned(db):
@ -573,6 +574,53 @@ def test_tmdb_extras_recommendations_and_collection(monkeypatch):
assert [c["title"] for c in ex["collection"]["items"]] == ["First", "Second"] # release order
def test_tmdb_extras_gallery_videos_keywords_facts(monkeypatch):
detail = {
"budget": 63000000, "revenue": 463517383, "original_language": "en",
"production_countries": [{"name": "United States"}],
"images": {"backdrops": [{"file_path": "/b.jpg"}], "posters": [{"file_path": "/p.jpg"}]},
"videos": {"results": [
{"site": "YouTube", "type": "Featurette", "key": "f1", "name": "Making of"},
{"site": "YouTube", "type": "Trailer", "key": "t1", "name": "Trailer"}]},
"keywords": {"keywords": [{"name": "hacker"}, {"name": "dystopia"}]},
"credits": {"cast": [{"id": 1, "name": "Keanu", "character": "Neo", "profile_path": "/k.jpg"}]}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
ex = TMDBClient("KEY").extras("movie", 603)
assert ex["facts"]["budget"] == 63000000 and ex["facts"]["countries"] == ["United States"]
assert ex["gallery"]["backdrops"][0]["thumb"].endswith("/w780/b.jpg")
assert ex["gallery"]["backdrops"][0]["full"].endswith("/original/b.jpg")
assert [v["type"] for v in ex["videos"]] == ["Trailer", "Featurette"] # trailer ordered first
assert ex["keywords"] == ["hacker", "dystopia"]
assert ex["cast_full"][0]["character"] == "Neo"
def test_tmdb_extras_tv_full_cast_episode_counts(monkeypatch):
detail = {"aggregate_credits": {"cast": [
{"id": 1, "name": "Actor", "total_episode_count": 42, "roles": [{"character": "Lead"}]}]}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
ex = TMDBClient("KEY").extras("show", 1399)
c = ex["cast_full"][0]
assert c["character"] == "Lead" and c["episode_count"] == 42
def test_item_extras_caches_tmdb_call(db, monkeypatch):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 603})
import config.settings as cs
class CM:
def get_plex_config(self): return {}
def get_jellyfin_config(self): return {}
monkeypatch.setattr(cs, "config_manager", CM())
calls = []
class Tmdb:
enabled = True
def extras(self, kind, tid): calls.append(tid); return {"keywords": ["x"]}
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
a = eng.item_extras("movie", mid)
b = eng.item_extras("movie", mid)
assert a == b == {"keywords": ["x"]}
assert calls == [603] # second view served from cache
def test_tmdb_extras_tv_next_episode(monkeypatch):
detail = {"next_episode_to_air": {"season_number": 3, "episode_number": 4,
"name": "Finale", "air_date": "2026-12-25"}}