video: Movie detail page (Netflix flat layout), movies now clickable

- Movie cards in the library now drill into a movie-detail page (both kinds use
  the same open-detail event / video-side navigation).
- New video-movie-detail subpage reuses the .vd-* hooks; video-detail.js is now
  kind-aware (root() targets the active page by kind, billboard/links/actions
  branch on movie vs show). Flat layout: billboard + a details strip (released /
  runtime / studio / status / critic score / quality) + the shared Cast & Crew row.
- Lazy on-view backfill for movies too: engine.refresh_movie_art re-fetches TMDB
  (cast/genres/backdrop/ratings) when missing, regardless of match status, via
  POST /detail/movie/<id>/refresh-art. movie_match_info added.

Seam tests: movie refresh backfills cast/genres, movie_match_info, route
registered, movie subpage markup, cards clickable for both kinds.
This commit is contained in:
BoulderBadgeDad 2026-06-14 21:31:51 -07:00
parent b9b3b9eed3
commit 59c88fa0db
10 changed files with 221 additions and 24 deletions

View file

@ -55,3 +55,14 @@ def register_routes(bp):
logger.exception("refresh-art failed for show %s", show_id)
res = {"ok": False, "reason": "error"}
return jsonify(res)
@bp.route("/detail/movie/<int:movie_id>/refresh-art", methods=["POST"])
def video_movie_refresh_art(movie_id):
"""Lazy on-view backfill for a movie (cast / genres / backdrop / ratings)."""
try:
from core.video.enrichment.engine import get_video_enrichment_engine
res = get_video_enrichment_engine().refresh_movie_art(movie_id)
except Exception:
logger.exception("refresh-art failed for movie %s", movie_id)
res = {"ok": False, "reason": "error"}
return jsonify(res)

View file

@ -93,6 +93,28 @@ class VideoEnrichmentEngine:
logger.exception("refresh_show_art: episode cascade failed for show %s", show_id)
return {"ok": True}
def refresh_movie_art(self, movie_id) -> dict:
"""On-demand (lazy) backfill of a movie's cast / genres / backdrop / ratings
from TMDB when the detail page is opened and they're missing. Works
regardless of match status; caches the result."""
w = self.workers.get("tmdb")
if not w or not w.enabled:
return {"ok": False, "reason": "tmdb_not_configured"}
info = self.db.movie_match_info(movie_id)
if not info:
return {"ok": False, "reason": "not_found"}
try:
result = w.client.match("movie", info.get("title"), info.get("year"),
known_id=info.get("tmdb_id"))
except Exception:
logger.exception("refresh_movie_art: match failed for movie %s", movie_id)
return {"ok": False, "reason": "match_error"}
if not result or not result.get("id"):
return {"ok": False, "reason": "no_match"}
self.db.enrichment_apply("tmdb", "movie", movie_id, matched=True,
external_id=result["id"], metadata=result.get("metadata"))
return {"ok": True}
def worker(self, service):
return self.workers.get(service)

View file

@ -315,6 +315,16 @@ class VideoDatabase:
finally:
conn.close()
def movie_match_info(self, movie_id: int) -> dict | None:
"""Title/year/tmdb_id for one movie — for on-demand (lazy) refresh."""
conn = self._get_connection()
try:
row = conn.execute("SELECT title, year, tmdb_id FROM movies WHERE id=?",
(movie_id,)).fetchone()
return dict(row) if row else None
finally:
conn.close()
def show_season_numbers(self, show_id: int) -> list:
conn = self._get_connection()
try:

View file

@ -43,6 +43,7 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/detail/show/<int:show_id>" in rules
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 any(r.startswith("/api/video/backdrop/") for r in rules)

View file

@ -137,6 +137,27 @@ def test_show_match_info(db):
assert db.show_match_info(999999) is None
def test_refresh_movie_art_backfills_cast_and_genres(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "tmdb_id": 438631})
class C:
enabled = True
def match(self, kind, title, year, known_id=None):
assert kind == "movie" and known_id == 438631
return {"id": 438631, "metadata": {"genres": ["Sci-Fi"],
"cast": [{"name": "Timothee", "tmdb_id": 1}]}}
assert VideoEnrichmentEngine(db, {"tmdb": C()}).refresh_movie_art(mid)["ok"] is True
d = db.movie_detail(mid)
assert d["genres"] == ["Sci-Fi"] and d["cast"][0]["name"] == "Timothee"
def test_movie_match_info(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "year": 2021, "tmdb_id": 438631})
assert db.movie_match_info(mid) == {"title": "Dune", "year": 2021, "tmdb_id": 438631}
assert db.movie_match_info(999999) is None
def test_enrichment_next_priority_pins_kind_first(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "M"})
db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []})

View file

@ -345,6 +345,21 @@ def test_show_detail_subpage_present():
assert "onclick" not in block
def test_movie_detail_subpage_present():
block = _block(
_INDEX, r'<section class="video-subpage" data-video-subpage="video-movie-detail"', "</section>")
assert 'data-video-detail="movie"' in block
for hook in ('data-vd-backdrop', 'data-vd-title', 'data-vd-details', 'data-vd-cast'):
assert hook in block, hook
assert 'data-video-goto="video-library"' in block and "onclick" not in block
def test_library_movie_cards_are_clickable():
# Both kinds drill in now (no show-only gate).
assert 'data-video-card-open="' in _LIB_JS
assert "kind === 'show' ?" not in _LIB_JS # the old show-only gate is gone
def test_video_detail_module_referenced_and_isolated():
assert "video/video-detail.js" in _INDEX
src = (_ROOT / "webui" / "static" / "video" / "video-detail.js").read_text(encoding="utf-8")

View file

@ -863,6 +863,38 @@
</div>
</div>
</section>
<!-- Movie detail (flat Netflix layout; shares the .vd-* hooks with the
show page — the renderer targets the active page by kind). -->
<section class="video-subpage" data-video-subpage="video-movie-detail" hidden>
<div class="vd-page" data-video-detail="movie">
<button class="vd-back" type="button" data-video-goto="video-library">
<span aria-hidden="true">&larr;</span> Library
</button>
<div class="vd-billboard">
<div class="vd-bb-bg" data-vd-backdrop aria-hidden="true"></div>
<div class="vd-bb-fade" aria-hidden="true"></div>
<div class="vd-bb-content">
<h1 class="vd-title" data-vd-title></h1>
<div class="vd-tagline" data-vd-tagline hidden></div>
<div class="vd-meta" data-vd-meta></div>
<div class="artist-hero-badges vd-links" data-vd-links></div>
<p class="vd-overview" data-vd-overview></p>
<div class="vd-actions artist-hero-actions" data-vd-actions></div>
<div class="vd-genres" data-vd-genres></div>
</div>
<img data-vd-poster crossorigin="anonymous" alt="" style="display:none" />
</div>
<div class="vd-loading" data-vd-loading hidden>Loading…</div>
<div class="vd-body">
<div class="vd-details" data-vd-details></div>
<div class="vd-cast-section" data-vd-cast-section hidden>
<h2 class="vd-section-h">Cast &amp; Crew</h2>
<div class="vd-crew" data-vd-crew></div>
<div class="vd-cast" data-vd-cast></div>
</div>
</div>
</div>
</section>
<section class="video-subpage" data-video-subpage="video-tools" hidden>
<div class="page-shell tools-page-container">
<div class="dashboard-header"><div class="dashboard-header-sweep" aria-hidden="true"><span></span></div>

View file

@ -31,7 +31,8 @@
var menuOpen = false;
var missingOnly = false;
var currentId = null;
var artAttemptedFor = null; // lazy art refresh runs once per show view
var currentKind = 'show';
var artAttemptedFor = null; // lazy art refresh runs once per detail view
try { var sv = localStorage.getItem(VIEW_KEY); if (sv) seasonView = sv; } catch (e) { /* ignore */ }
@ -40,7 +41,7 @@
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function root() { return document.querySelector('[data-video-detail="show"]'); }
function root() { return document.querySelector('[data-video-detail="' + currentKind + '"]'); }
function q(sel) { var r = root(); return r ? r.querySelector(sel) : null; }
function setText(sel, t) { var n = q(sel); if (n) n.textContent = t || ''; }
function runtimeLabel(m) {
@ -98,10 +99,11 @@
setText('[data-vd-title]', d.title);
setText('[data-vd-overview]', d.overview);
var art = '/' + d.kind + '/' + d.id;
var bg = q('[data-vd-backdrop]');
if (bg) {
var url = d.has_backdrop ? '/api/video/backdrop/show/' + d.id
: (d.has_poster ? '/api/video/poster/show/' + d.id : '');
var url = d.has_backdrop ? '/api/video/backdrop' + art
: (d.has_poster ? '/api/video/poster' + art : '');
bg.style.backgroundImage = url ? "url('" + url + "')" : '';
bg.classList.toggle('vd-bb-bg--poster', !d.has_backdrop && !!d.has_poster);
bg.classList.toggle('vd-bb-bg--empty', !d.has_backdrop && !d.has_poster);
@ -109,24 +111,32 @@
var poster = q('[data-vd-poster]');
if (poster && d.has_poster) {
poster.onload = function () { applyAccent(poster); };
poster.src = '/api/video/poster/show/' + d.id;
poster.src = '/api/video/poster' + art;
}
var tl = q('[data-vd-tagline]');
if (tl) { tl.textContent = d.tagline || ''; tl.hidden = !d.tagline; }
var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0;
var meta = [];
meta.push('<span class="vd-match">' + ownedPct + '% in library</span>');
if (d.kind === 'show') {
var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0;
meta.push('<span class="vd-match">' + ownedPct + '% in library</span>');
} else {
meta.push(d.owned ? '<span class="vd-match">In library</span>'
: '<span class="vd-status">Wanted</span>');
}
if (d.rating) meta.push('<span class="vd-score">★ ' + (Math.round(d.rating * 10) / 10) + '</span>');
if (d.year) meta.push('<span>' + esc(d.year) + '</span>');
if (d.content_rating) meta.push('<span class="vd-meta-rating">' + esc(d.content_rating) + '</span>');
meta.push('<span>' + d.season_count + ' Season' + (d.season_count === 1 ? '' : 's') + '</span>');
meta.push('<span>' + d.episode_total + ' Episodes</span>');
if (d.kind === 'show') {
meta.push('<span>' + d.season_count + ' Season' + (d.season_count === 1 ? '' : 's') + '</span>');
meta.push('<span>' + d.episode_total + ' Episodes</span>');
}
var rt = runtimeLabel(d.runtime_minutes);
if (rt) meta.push('<span>' + esc(rt) + '</span>');
if (d.status) meta.push('<span class="vd-status">' + esc(statusLabel(d.status)) + '</span>');
if (d.kind === 'show' && d.status) meta.push('<span class="vd-status">' + esc(statusLabel(d.status)) + '</span>');
if (d.network) meta.push('<span>' + esc(d.network) + '</span>');
if (d.kind === 'movie' && d.studio) meta.push('<span>' + esc(d.studio) + '</span>');
var m = q('[data-vd-meta]'); if (m) m.innerHTML = meta.join('');
renderActions(d);
@ -135,7 +145,8 @@
if (l) {
var badges = [];
if (d.imdb_id) badges.push(badge('', 'IMDb', 'IMDb', 'https://www.imdb.com/title/' + d.imdb_id + '/'));
if (d.tmdb_id) badges.push(badge(TMDB_LOGO, 'TMDB', 'TMDB', 'https://www.themoviedb.org/tv/' + d.tmdb_id));
if (d.tmdb_id) badges.push(badge(TMDB_LOGO, 'TMDB', 'TMDB',
'https://www.themoviedb.org/' + (d.kind === 'movie' ? 'movie' : 'tv') + '/' + d.tmdb_id));
if (d.tvdb_id) badges.push(badge(TVDB_LOGO, 'TVDB', 'TVDB', 'https://thetvdb.com/?id=' + d.tvdb_id + '&tab=series'));
l.innerHTML = badges.join('');
}
@ -183,14 +194,35 @@
var a = q('[data-vd-actions]');
if (!a) return;
var watching = !!d.monitored;
a.innerHTML =
var html =
'<button class="library-artist-watchlist-btn' + (watching ? ' watching' : '') +
'" type="button" data-vd-act="watchlist">' +
'<span class="watchlist-icon">' + (watching ? '✓' : '') + '</span>' +
'<span class="watchlist-text">' + (watching ? 'In Watchlist' : 'Watchlist') + '</span></button>' +
'<button class="discog-download-btn discog-btn-compact" type="button" data-vd-act="missing">' +
'<span class="discog-btn-icon">⭳</span><span class="discog-btn-text">Get Missing</span>' +
'<span class="discog-btn-shimmer"></span></button>';
'<span class="watchlist-text">' + (watching ? 'In Watchlist' : 'Watchlist') + '</span></button>';
if (d.kind === 'show') { // "Get Missing" filters the episode list (show-only)
html += '<button class="discog-download-btn discog-btn-compact" type="button" data-vd-act="missing">' +
'<span class="discog-btn-icon">⭳</span><span class="discog-btn-text">Get Missing</span>' +
'<span class="discog-btn-shimmer"></span></button>';
}
a.innerHTML = html;
}
function renderDetails(d) {
var host = q('[data-vd-details]');
if (!host) return;
var rows = [];
if (d.release_date) rows.push(['Released', d.release_date]);
if (d.runtime_minutes) rows.push(['Runtime', runtimeLabel(d.runtime_minutes)]);
if (d.studio) rows.push(['Studio', d.studio]);
if (d.status) rows.push(['Status', statusLabel(d.status)]);
if (d.rating_critic) rows.push(['Critic score', Math.round(d.rating_critic) + '%']);
if (d.file && d.file.resolution) rows.push(['Quality', String(d.file.resolution).toUpperCase()]);
host.innerHTML = rows.length
? '<div class="vd-detail-grid">' + rows.map(function (r) {
return '<div class="vd-detail-row"><span class="vd-detail-k">' + esc(r[0]) +
'</span><span class="vd-detail-v">' + esc(r[1]) + '</span></div>';
}).join('') + '</div>'
: '';
}
// ── season selector (4 views) ─────────────────────────────────────────────
@ -311,14 +343,59 @@
var next = data.monitored ? 0 : 1;
fetch('/api/video/monitor', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ kind: 'show', id: data.id, monitored: next }),
body: JSON.stringify({ kind: data.kind, id: data.id, monitored: next }),
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (res) {
if (res && !res.error) { data.monitored = !!next; renderActions(data); }
}).catch(function () { /* ignore */ });
}
// ── movie detail (flat) ───────────────────────────────────────────────────
function loadMovie(id) {
currentKind = 'movie';
if (!root()) return;
if (currentId !== id) artAttemptedFor = null;
currentId = id;
showLoading(true);
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' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
showLoading(false);
if (!d || d.error) { setText('[data-vd-title]', 'Not found'); return; }
data = d;
renderBillboard(d);
renderDetails(d);
var sub = document.querySelector('.video-subpage[data-video-subpage="video-movie-detail"]');
if (sub) sub.scrollTop = 0;
maybeRefreshMovie(id);
})
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load movie'); });
}
// Lazy: backfill a movie's cast/genres/art from TMDB on view if missing.
function maybeRefreshMovie(id) {
if (artAttemptedFor === id || !data || data.id !== id) return;
var needs = !(data.cast && data.cast.length) || !(data.genres && data.genres.length) || !data.has_backdrop;
if (!needs) return;
artAttemptedFor = id;
fetch(DETAIL_URL + 'movie/' + id + '/refresh-art',
{ method: 'POST', headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (res) {
if (res && res.ok && currentId === id && currentKind === 'movie') {
fetch(DETAIL_URL + 'movie/' + id, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && !d.error && currentId === id) { data = d; renderBillboard(d); renderDetails(d); }
});
}
}).catch(function () { /* best-effort */ });
}
function loadShow(id) {
currentKind = 'show';
if (!root()) return;
if (currentId !== id) artAttemptedFor = null;
currentId = id;
@ -371,7 +448,11 @@
}
// ── events ────────────────────────────────────────────────────────────────
function onOpen(e) { if (e && e.detail && e.detail.kind === 'show') loadShow(e.detail.id); }
function onOpen(e) {
if (!e || !e.detail) return;
if (e.detail.kind === 'movie') loadMovie(e.detail.id);
else if (e.detail.kind === 'show') loadShow(e.detail.id);
}
function onClick(e) {
var r = root(); if (!r) return;

View file

@ -76,12 +76,9 @@
if (kind === 'movie') meta.push(it.has_file ? 'Owned' : 'Wanted');
else meta.push((it.owned_count || 0) + '/' + (it.episode_count || 0) + ' eps');
// Shows drill into the detail page; movies aren't clickable until the
// movie-detail page lands (then this opens kind === 'movie' too).
var clickable = kind === 'show' ? ' video-card--clickable' : '';
var hook = kind === 'show'
? ' data-video-card-open="show" data-video-card-id="' + it.id + '"' : '';
return '<div class="library-artist-card' + clickable + '"' + hook + '>' + img + badge +
// Both movies and shows drill into their detail page.
var hook = ' data-video-card-open="' + kind + '" data-video-card-id="' + it.id + '"';
return '<div class="library-artist-card video-card--clickable"' + hook + '>' + img + badge +
'<div class="library-artist-info">' +
'<h3 class="library-artist-name" title="' + esc(it.title) + '">' + esc(it.title) + '</h3>' +
'<div class="library-artist-stats"><span class="library-artist-stat">' +

View file

@ -707,3 +707,10 @@ body[data-side="video"] .dashboard-header-sweep {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vd-cast-char { display: block; font-size: 12px; color: rgba(255, 255, 255, 0.55); line-height: 1.3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── Movie detail: details strip ─────────────────────────────────────────── */
.vd-details { margin-bottom: 8px; }
.vd-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px 28px; max-width: 760px; }
.vd-detail-row { display: flex; flex-direction: column; gap: 3px; }
.vd-detail-k { font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: rgba(255, 255, 255, 0.45); }
.vd-detail-v { font-size: 14.5px; font-weight: 600; color: #fff; }