diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 5ba18f5c..e760a03b 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -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 {} diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index 2cc6b4b9..a78baf68 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -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: diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index ecf1d81e..387f3c5b 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -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"}}