From 6e526d77453a1b0e5c5374ed5b838f32edd5deb3 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 14 Jun 2026 21:42:41 -0700 Subject: [PATCH] =?UTF-8?q?video=20detail:=20deeper=20TMDB=20extras=20?= =?UTF-8?q?=E2=80=94=20trailer,=20where-to-watch,=20more=20like=20this?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4: dynamic extras fetched LIVE per view (providers change, so not cached) via GET /detail///extras → engine.item_extras → TMDB (videos + watch/providers + similar in one call). - Trailer: a '▶ Trailer' action that opens an in-app YouTube modal embed (Esc / click-away to close). - Where to Watch: provider logos for the region (JustWatch via TMDB). - More Like This: a poster row of similar titles linking out to TMDB. Both movie + show pages; all keyless (same TMDB key). Seam tests: extras parse (trailer priority, provider/similar shape), item_extras gating on tmdb_id, route registered, markup hooks. (RT/Metacritic via OMDb needs its own key — offered separately.) --- api/video/detail.py | 12 +++++ core/video/enrichment/clients.py | 52 ++++++++++++++++++ core/video/enrichment/engine.py | 16 ++++++ tests/test_video_api.py | 1 + tests/test_video_enrichment.py | 33 ++++++++++++ tests/test_video_side_shell.py | 3 +- webui/index.html | 16 ++++++ webui/static/video/video-detail.js | 84 +++++++++++++++++++++++++++++- webui/static/video/video-side.css | 52 ++++++++++++++++++ 9 files changed, 267 insertions(+), 2 deletions(-) diff --git a/api/video/detail.py b/api/video/detail.py index e19c6ede..30b85e8c 100644 --- a/api/video/detail.py +++ b/api/video/detail.py @@ -66,3 +66,15 @@ def register_routes(bp): logger.exception("refresh-art failed for movie %s", movie_id) res = {"ok": False, "reason": "error"} return jsonify(res) + + @bp.route("/detail///extras", methods=["GET"]) + def video_detail_extras(kind, item_id): + """Live TMDB extras (trailer / where-to-watch / similar) for the detail page.""" + if kind not in ("movie", "show"): + return jsonify({}), 400 + try: + from core.video.enrichment.engine import get_video_enrichment_engine + return jsonify(get_video_enrichment_engine().item_extras(kind, item_id)) + except Exception: + logger.exception("extras failed for %s %s", kind, item_id) + return jsonify({}) diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index c8d5c620..e532baa7 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -154,6 +154,58 @@ class TMDBClient: if crew: meta["crew"] = crew + POSTER_W = "https://image.tmdb.org/t/p/w300" + PROVIDER = "https://image.tmdb.org/t/p/original" + + def extras(self, kind, tmdb_id, region="US"): + """Live detail extras (not cached — providers change): a trailer, the + 'where to watch' providers for a region, and similar titles.""" + if not self.api_key or tmdb_id is None: + return {} + import requests + path = ("/movie/" if kind == "movie" else "/tv/") + str(tmdb_id) + r = requests.get(self.BASE + path, params={ + "api_key": self.api_key, "append_to_response": "videos,watch/providers,similar"}, timeout=15) + r.raise_for_status() + d = r.json() or {} + out = {} + + # Trailer — prefer a YouTube "Trailer", fall back to a teaser. + trailer = None + for v in (d.get("videos") or {}).get("results") or []: + if v.get("site") == "YouTube" and v.get("type") in ("Trailer", "Teaser") and v.get("key"): + trailer = {"key": v["key"], "name": v.get("name")} + if v.get("type") == "Trailer": + break + if trailer: + out["trailer"] = trailer + + # Where to watch (one region; JustWatch-powered). + wp = ((d.get("watch/providers") or {}).get("results") or {}).get(region) or {} + provs, seen = [], set() + for grp in ("flatrate", "free", "ads", "rent", "buy"): + for p in (wp.get(grp) or []): + name = p.get("provider_name") + if name and name not in seen: + seen.add(name) + provs.append({"name": name, + "logo": (self.PROVIDER + p["logo_path"]) if p.get("logo_path") else None}) + if provs: + out["providers"] = provs[:8] + out["providers_link"] = wp.get("link") + out["region"] = region + + # More like this. + sim = [] + for s in ((d.get("similar") or {}).get("results") or [])[:14]: + title = s.get("title") or s.get("name") + if title and s.get("id"): + sim.append({"title": title, "tmdb_id": s["id"], "kind": kind, + "poster": (self.POSTER_W + s["poster_path"]) if s.get("poster_path") else None}) + if sim: + out["similar"] = sim + return out + 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 diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index d26f9338..18a2df88 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -115,6 +115,22 @@ class VideoEnrichmentEngine: external_id=result["id"], metadata=result.get("metadata")) return {"ok": True} + def item_extras(self, kind, item_id) -> dict: + """Live TMDB extras (trailer / where-to-watch / similar) for the detail + page. Not cached — fetched per view so providers stay current.""" + w = self.workers.get("tmdb") + if not w or not w.enabled: + return {} + info = (self.db.movie_match_info(item_id) if kind == "movie" + else self.db.show_match_info(item_id)) + if not info or not info.get("tmdb_id"): + return {} + try: + return w.client.extras(kind, info["tmdb_id"]) or {} + except Exception: + logger.exception("item_extras failed for %s %s", kind, item_id) + return {} + def worker(self, service): return self.workers.get(service) diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 91edaf67..bac4d976 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -44,6 +44,7 @@ def test_blueprint_exposes_dashboard_route(): assert "/api/video/detail/movie/" in rules assert "/api/video/detail/show//refresh-art" in rules assert "/api/video/detail/movie//refresh-art" in rules + assert "/api/video/detail///extras" in rules assert any(r.startswith("/api/video/backdrop/") for r in rules) diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index dcc295ae..f01af3a0 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -205,6 +205,39 @@ def test_get_stats_excludes_episode_coverage_from_pending(db): assert stats["stats"]["pending"] == 0 # but it doesn't block "Complete" +def test_tmdb_extras_parse(monkeypatch): + class _Resp: + def __init__(self, b): self._b = b + def raise_for_status(self): pass + def json(self): return self._b + detail = { + "videos": {"results": [ + {"site": "YouTube", "type": "Teaser", "key": "tease"}, + {"site": "YouTube", "type": "Trailer", "key": "trail"}]}, + "watch/providers": {"results": {"US": {"link": "http://w", "flatrate": [ + {"provider_name": "Netflix", "logo_path": "/n.jpg"}]}}}, + "similar": {"results": [{"id": 5, "title": "Other", "poster_path": "/o.jpg"}]}} + monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail))) + ex = TMDBClient("KEY").extras("movie", 438631) + assert ex["trailer"]["key"] == "trail" # Trailer beats Teaser + assert ex["providers"][0] == {"name": "Netflix", "logo": "https://image.tmdb.org/t/p/original/n.jpg"} + assert ex["providers_link"] == "http://w" + assert ex["similar"][0]["title"] == "Other" and ex["similar"][0]["kind"] == "movie" + + +def test_item_extras_needs_tmdb_and_id(db): + sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) # no tmdb_id + + class C: + enabled = True + def extras(self, kind, tid, region="US"): return {"trailer": {"key": "x"}} + + eng = VideoEnrichmentEngine(db, {"tmdb": C()}) + assert eng.item_extras("show", sid) == {} # no tmdb_id → no call + sid2 = db.upsert_show_tree("plex", {"server_id": "s2", "title": "T", "tmdb_id": 1, "seasons": []}) + assert eng.item_extras("show", sid2) == {"trailer": {"key": "x"}} + + def test_tmdb_season_episodes_parses(monkeypatch): class _Resp: def __init__(self, b): self._b = b diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index 9890be12..5a329803 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -338,7 +338,8 @@ def test_show_detail_subpage_present(): # Netflix billboard + episodes containers the renderer fills. for hook in ('data-vd-backdrop', 'data-vd-poster', 'data-vd-title', 'data-vd-meta', 'data-vd-overview', 'data-vd-actions', 'data-vd-view-toggle', - 'data-vd-season-nav', 'data-vd-episodes', 'data-vd-cast', 'data-vd-crew'): + 'data-vd-season-nav', 'data-vd-episodes', 'data-vd-cast', 'data-vd-crew', + 'data-vd-logo', 'data-vd-providers', 'data-vd-similar'): assert hook in block, hook # Back button reuses the shared data-video-goto nav (no inline handler). assert 'data-video-goto="video-library"' in block diff --git a/webui/index.html b/webui/index.html index e9d8eac5..5595b1de 100644 --- a/webui/index.html +++ b/webui/index.html @@ -861,6 +861,14 @@
+ + @@ -894,6 +902,14 @@
+ + diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index cff404a1..c1b4308b 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -208,7 +208,12 @@ var a = q('[data-vd-actions]'); if (!a) return; var watching = !!d.monitored; - var html = + var html = ''; + if (d.trailer && d.trailer.key) { + html += ''; + } + html += '' + + ''; + ov.classList.add('vd-trailer-overlay--open'); + } + function closeTrailer() { + var ov = document.getElementById('vd-trailer-overlay'); + if (ov) { ov.classList.remove('vd-trailer-overlay--open'); ov.innerHTML = ''; } + } + // ── season selector (4 views) ───────────────────────────────────────────── function renderViewToggle() { var host = q('[data-vd-view-toggle]'); @@ -371,6 +445,7 @@ if (currentId !== id) artAttemptedFor = null; currentId = id; showLoading(true); + resetExtras(); var dh = q('[data-vd-details]'); if (dh) dh.innerHTML = ''; var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb'); fetch(DETAIL_URL + 'movie/' + id, { headers: { 'Accept': 'application/json' } }) @@ -384,6 +459,7 @@ var sub = document.querySelector('.video-subpage[data-video-subpage="video-movie-detail"]'); if (sub) sub.scrollTop = 0; maybeRefreshMovie(id); + loadExtras('movie', id); }) .catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load movie'); }); } @@ -415,6 +491,7 @@ if (currentId !== id) artAttemptedFor = null; currentId = id; showLoading(true); + resetExtras(); ['[data-vd-episodes]', '[data-vd-season-nav]'].forEach(function (s) { var n = q(s); if (n) n.innerHTML = ''; }); var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb'); fetch(DETAIL_URL + 'show/' + id, { headers: { 'Accept': 'application/json' } }) @@ -431,6 +508,7 @@ var sub = document.querySelector('.video-subpage[data-video-subpage="video-show-detail"]'); if (sub) sub.scrollTop = 0; maybeRefreshArt(id); + loadExtras('show', id); }) .catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load show'); }); } @@ -483,6 +561,7 @@ var which = act.getAttribute('data-vd-act'); if (which === 'watchlist') toggleWatchlist(); else if (which === 'missing') toggleMissing(); + else if (which === 'trailer' && data && data.trailer) openTrailer(data.trailer.key); return; } var mt = e.target.closest('[data-vd-missing-toggle]'); @@ -500,6 +579,9 @@ function init() { document.addEventListener('soulsync:video-open-detail', onOpen); document.addEventListener('click', onClick); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closeTrailer(); + }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index c8a57aa6..81f6d85b 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -727,3 +727,55 @@ body[data-side="video"] .dashboard-header-sweep { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +/* ── Detail extras: trailer button, providers, More Like This, trailer modal ── */ +.vd-trailer-btn { + display: inline-flex; align-items: center; gap: 8px; padding: 7px 18px; border-radius: 8px; + font-size: 12.5px; font-weight: 800; letter-spacing: 0.02em; cursor: pointer; + color: #fff; background: rgb(var(--vd-accent-rgb)); border: 1px solid transparent; + box-shadow: 0 6px 18px rgba(var(--vd-accent-rgb), 0.4); transition: all 0.2s ease; +} +.vd-trailer-btn:hover { transform: translateY(-1px); filter: brightness(1.08); + box-shadow: 0 8px 22px rgba(var(--vd-accent-rgb), 0.55); } +.vd-trailer-ic { font-size: 12px; } + +.vd-providers-section, .vd-similar-section { margin-top: 40px; } +.vd-providers { display: flex; flex-wrap: wrap; gap: 14px; } +.vd-prov { display: flex; flex-direction: column; align-items: center; gap: 7px; width: 78px; text-align: center; } +.vd-prov img, .vd-prov-ph { + width: 56px; height: 56px; border-radius: 14px; object-fit: cover; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4); border: 1px solid rgba(255, 255, 255, 0.1); +} +.vd-prov-ph { display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 800; + color: #fff; background: linear-gradient(135deg, rgba(var(--vd-accent-rgb), 0.4), rgba(var(--vd-accent-rgb), 0.1)); } +.vd-prov-name { font-size: 11.5px; color: rgba(255, 255, 255, 0.7); line-height: 1.25; + overflow: hidden; text-overflow: ellipsis; } + +.vd-similar { display: flex; gap: 16px; overflow-x: auto; padding-bottom: 12px; scroll-snap-type: x proximity; } +.vd-similar::-webkit-scrollbar { height: 8px; } +.vd-similar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; } +.vd-sim-card { flex: 0 0 130px; width: 130px; scroll-snap-align: start; text-decoration: none; color: inherit; + transition: transform 0.25s ease; } +.vd-sim-card:hover { transform: translateY(-4px); } +.vd-sim-poster { width: 130px; aspect-ratio: 2/3; border-radius: 10px; object-fit: cover; display: block; margin-bottom: 8px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); border: 1px solid rgba(255, 255, 255, 0.08); } +.vd-sim-poster--ph { display: flex; align-items: center; justify-content: center; font-size: 34px; + background: linear-gradient(135deg, rgba(var(--vd-accent-rgb), 0.28), rgba(0, 0, 0, 0.5)); } +.vd-sim-title { display: block; font-size: 12.5px; font-weight: 600; color: rgba(255, 255, 255, 0.85); line-height: 1.3; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + +.vd-trailer-overlay { + position: fixed; inset: 0; z-index: 9000; display: none; align-items: center; justify-content: center; + background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(6px); +} +.vd-trailer-overlay--open { display: flex; animation: vdFade 0.2s ease both; } +@keyframes vdFade { from { opacity: 0; } to { opacity: 1; } } +.vd-trailer-box { position: relative; width: min(1100px, 92vw); aspect-ratio: 16/9; + border-radius: 12px; overflow: hidden; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7); } +.vd-trailer-box iframe { width: 100%; height: 100%; border: 0; display: block; } +.vd-trailer-close { + position: absolute; top: -44px; right: 0; width: 36px; height: 36px; border-radius: 50%; + background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.2); color: #fff; + font-size: 22px; line-height: 1; cursor: pointer; transition: background 0.2s ease; +} +.vd-trailer-close:hover { background: rgba(255, 255, 255, 0.25); }