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:
parent
efa0632883
commit
6e526d7745
9 changed files with 267 additions and 2 deletions
|
|
@ -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({})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">×</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);
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue