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:
parent
595ea2bfbd
commit
6fcebe7c4b
3 changed files with 168 additions and 9 deletions
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}}
|
||||
|
|
|
|||
Loading…
Reference in a new issue