video detail: deeper TMDB extras — trailer, where-to-watch, more like this

Phase 4: dynamic extras fetched LIVE per view (providers change, so not cached)
via GET /detail/<kind>/<id>/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.)
This commit is contained in:
BoulderBadgeDad 2026-06-14 21:42:41 -07:00
parent efa0632883
commit 6e526d7745
9 changed files with 267 additions and 2 deletions

View file

@ -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/<kind>/<int:item_id>/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({})

View file

@ -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

View file

@ -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)

View file

@ -44,6 +44,7 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/detail/movie/<int:movie_id>" in rules
assert "/api/video/detail/show/<int:show_id>/refresh-art" in rules
assert "/api/video/detail/movie/<int:movie_id>/refresh-art" in rules
assert "/api/video/detail/<kind>/<int:item_id>/extras" in rules
assert any(r.startswith("/api/video/backdrop/") for r in rules)

View file

@ -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

View file

@ -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

View file

@ -861,6 +861,14 @@
<div class="vd-crew" data-vd-crew></div>
<div class="vd-cast" data-vd-cast></div>
</div>
<div class="vd-providers-section" data-vd-providers-section hidden>
<h2 class="vd-section-h">Where to Watch</h2>
<div class="vd-providers" data-vd-providers></div>
</div>
<div class="vd-similar-section" data-vd-similar-section hidden>
<h2 class="vd-section-h">More Like This</h2>
<div class="vd-similar" data-vd-similar></div>
</div>
</div>
</div>
</section>
@ -894,6 +902,14 @@
<div class="vd-crew" data-vd-crew></div>
<div class="vd-cast" data-vd-cast></div>
</div>
<div class="vd-providers-section" data-vd-providers-section hidden>
<h2 class="vd-section-h">Where to Watch</h2>
<div class="vd-providers" data-vd-providers></div>
</div>
<div class="vd-similar-section" data-vd-similar-section hidden>
<h2 class="vd-section-h">More Like This</h2>
<div class="vd-similar" data-vd-similar></div>
</div>
</div>
</div>
</section>

View file

@ -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 += '<button class="vd-trailer-btn" type="button" data-vd-act="trailer">' +
'<span class="vd-trailer-ic">▶</span> Trailer</button>';
}
html +=
'<button class="library-artist-watchlist-btn' + (watching ? ' watching' : '') +
'" type="button" data-vd-act="watchlist">' +
'<span class="watchlist-icon">' + (watching ? '✓' : '') + '</span>' +
@ -239,6 +244,75 @@
: '';
}
// ── live TMDB extras (trailer / where-to-watch / similar) ─────────────────
function resetExtras() {
['[data-vd-providers-section]', '[data-vd-similar-section]'].forEach(function (s) {
var n = q(s); if (n) n.hidden = true;
});
}
function loadExtras(kind, id) {
fetch(DETAIL_URL + kind + '/' + id + '/extras', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (ex) { if (ex) renderExtras(kind, id, ex); })
.catch(function () { /* best-effort */ });
}
function renderExtras(kind, id, ex) {
if (!data || data.id !== id || currentKind !== kind) return;
data.trailer = ex.trailer || null;
renderActions(data);
var ps = q('[data-vd-providers-section]'), ph = q('[data-vd-providers]');
if (ps && ph) {
if (ex.providers && ex.providers.length) {
ps.hidden = false;
ph.innerHTML = ex.providers.map(function (p) {
var img = p.logo ? '<img src="' + esc(p.logo) + '" alt="' + esc(p.name) + '" loading="lazy">'
: '<span class="vd-prov-ph">' + esc((p.name || '?').charAt(0)) + '</span>';
return '<div class="vd-prov" title="' + esc(p.name) + '">' + img +
'<span class="vd-prov-name">' + esc(p.name) + '</span></div>';
}).join('');
} else { ps.hidden = true; }
}
var ss = q('[data-vd-similar-section]'), sh = q('[data-vd-similar]');
if (ss && sh) {
if (ex.similar && ex.similar.length) {
ss.hidden = false;
sh.innerHTML = ex.similar.map(function (s) {
var poster = s.poster
? '<img class="vd-sim-poster" src="' + esc(s.poster) + '" alt="" loading="lazy">'
: '<span class="vd-sim-poster vd-sim-poster--ph">🎬</span>';
var url = 'https://www.themoviedb.org/' + (s.kind === 'movie' ? 'movie' : 'tv') + '/' + s.tmdb_id;
return '<a class="vd-sim-card" href="' + url + '" target="_blank" rel="noopener">' +
poster + '<span class="vd-sim-title">' + esc(s.title) + '</span></a>';
}).join('');
} else { ss.hidden = true; }
}
}
// ── trailer modal (YouTube embed) ─────────────────────────────────────────
function openTrailer(key) {
if (!key) return;
var ov = document.getElementById('vd-trailer-overlay');
if (!ov) {
ov = document.createElement('div');
ov.id = 'vd-trailer-overlay';
ov.className = 'vd-trailer-overlay';
ov.addEventListener('click', function (e) {
if (e.target === ov || e.target.closest('[data-vd-trailer-close]')) closeTrailer();
});
document.body.appendChild(ov);
}
ov.innerHTML = '<div class="vd-trailer-box">' +
'<button class="vd-trailer-close" type="button" data-vd-trailer-close aria-label="Close">&times;</button>' +
'<iframe src="https://www.youtube.com/embed/' + encodeURIComponent(key) +
'?autoplay=1&rel=0" allow="autoplay; encrypted-media; fullscreen" allowfullscreen></iframe></div>';
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);

View file

@ -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); }