downloads page: click-to-expand detail drawer (synopsis + cast + facts)

click a card → it expands inline into a detail drawer (open state survives the
in-place re-patches via _expanded):
- big backdrop + synopsis + genres + a cast strip (photos/names/characters),
  lazily fetched from TMDB by the grab's tmdb id (new /downloads/meta/<kind>/<id>
  endpoint → engine.tmdb_full_detail). youtube shows channel + description instead.
- a facts grid: status, quality target, release, format, source+queue, size,
  attempts, copyable dest path, full error.
- big actions: open in library / open on youtube / copy path / cancel / retry.
all type-themed (Cinema palette) and scoped to .vdpg-card. contract-tested + the
meta route is registered.
This commit is contained in:
BoulderBadgeDad 2026-06-26 11:30:36 -07:00
parent 458bf01599
commit 9a8550661b
4 changed files with 174 additions and 2 deletions

View file

@ -170,6 +170,23 @@ def register_routes(bp):
n = get_video_db().clear_download_history(kind=body.get("kind"))
return jsonify({"success": True, "removed": n})
@bp.route("/downloads/meta/<kind>/<int:tmdb_id>", methods=["GET"])
def video_download_meta(kind, tmdb_id):
"""Lazy TMDB detail (overview + cast + backdrop) for a download's expand drawer,
keyed by the grabbed title's TMDB id. Best-effort — the drawer still shows the
download facts without it."""
if kind not in ("movie", "show"):
return jsonify({}), 400
try:
from core.video.enrichment.engine import get_video_enrichment_engine
data = get_video_enrichment_engine().tmdb_full_detail(kind, tmdb_id) or {}
return jsonify({k: data.get(k) for k in
("title", "overview", "tagline", "backdrop_url", "poster_url",
"genres", "rating", "cast", "year", "runtime", "status")})
except Exception:
logger.exception("download meta failed for %s %s", kind, tmdb_id)
return jsonify({})
@bp.route("/downloads/quality", methods=["GET"])
def video_quality_profile():
from . import get_video_db

View file

@ -31,3 +31,22 @@ def test_sidebar_has_a_live_downloads_count():
assert "data-video-downloads-badge" in _INDEX # the nav badge element
assert "function setDownloadsBadge(" in _JS
assert "function badgePoll(" in _JS # stays live off-page too
def test_cards_expand_into_a_detail_drawer():
assert "function drawerHTML(" in _JS and "function renderDrawer(" in _JS
assert "_expanded" in _JS # open state survives re-patches
assert "vdpg-dr-cast" in _JS and "vdpg-dr-syn" in _JS # cast + synopsis sections
assert "data-vdpg-copy" in _JS # copy-path action
# the lazy TMDB detail endpoint the drawer fetches synopsis/cast from
assert "/downloads/meta/" in _JS
def test_download_meta_route_is_registered():
import api.video as videoapi
from flask import Flask
app = Flask(__name__)
app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video")
rules = {r.rule for r in app.url_map.iter_rules()}
assert "/api/video/downloads/meta/<kind>/<int:tmdb_id>" in rules

View file

@ -15,6 +15,8 @@
var URL_RETRY = '/api/video/downloads/retry';
var _timer = null, _wired = false, _filter = 'all';
var _cards = {};
var _expanded = {}; // id -> true while a card's detail drawer is open (survives re-patches)
var _meta = {}; // id -> TMDB detail (overview/cast) once lazily fetched (null = in flight)
function esc(s) {
return String(s == null ? '' : s)
@ -82,11 +84,13 @@
'<div class="vdpg-prog" data-f="bar" style="display:none"><div class="vdpg-prog-fill" data-f="fill"></div></div>' +
'</div>' +
'<div class="adl-row-status" data-f="status"><span class="adl-status-dot" data-f="dot"></span><span data-f="label"></span></div>' +
'<div class="vdpg-rowact" data-f="actions"></div>';
'<div class="vdpg-rowact" data-f="actions"></div>' +
'<div class="vdpg-drawer" data-f="drawer" hidden></div>';
return el;
}
function patchCard(el, d) {
el._d = d; // remember the row data so the expand toggle can re-render its drawer
var info = STATUS[d.status] || STATUS.downloading;
var cls = info.cls, active = isActive(d.status);
var showBar = active; // downloading/queued/searching/importing all get a bar
@ -160,6 +164,84 @@
: '';
var actHTML = openBtn + stateBtn;
if (act.innerHTML !== actHTML) act.innerHTML = actHTML;
renderDrawer(el, d); // keep the expand drawer in sync (and open across re-patches)
}
// ── expand drawer ─────────────────────────────────────────────────────────────
function ytCtx(d) {
try { return d.search_ctx ? (typeof d.search_ctx === 'string' ? JSON.parse(d.search_ctx) : d.search_ctx) : {}; }
catch (e) { return {}; }
}
function fact(k, v) {
return v ? '<div class="vdpg-f"><span class="vdpg-fk">' + esc(k) + '</span><span class="vdpg-fv">' + esc(v) + '</span></div>' : '';
}
function drawerHTML(d, meta) {
var isYt = dlType(d.kind) === 'youtube', ctx = isYt ? ytCtx(d) : {};
var overview = (meta && meta.overview) || ctx.description || '';
var loading = meta === null && !isYt;
var back = (meta && meta.backdrop_url)
? '<div class="vdpg-dr-back" style="background-image:url(\'' + esc(meta.backdrop_url) + '\')"></div>' : '';
var genres = (meta && (meta.genres || []).slice(0, 4).join(' · ')) || '';
var cast = (meta && meta.cast || []).slice(0, 8);
var castHTML = cast.length ? '<div class="vdpg-dr-st">Cast</div><div class="vdpg-dr-cast">' + cast.map(function (c) {
var pic = c.profile_url
? '<span class="vdpg-cast-pic" style="background-image:url(\'' + esc(c.profile_url) + '\')"></span>'
: '<span class="vdpg-cast-pic vdpg-cast-none">' + esc((c.name || '?').charAt(0)) + '</span>';
return '<div class="vdpg-cast">' + pic + '<span class="vdpg-cast-nm">' + esc(c.name) +
'</span>' + (c.character ? '<span class="vdpg-cast-ch">' + esc(c.character) + '</span>' : '') + '</div>';
}).join('') + '</div>' : '';
// download facts (only the fields that exist render)
var facts = '';
facts += fact('Status', (STATUS[d.status] || {}).label);
if (isYt) { facts += fact('Channel', ctx.channel || ctx.channel_title); facts += fact('Quality', d.quality_label); }
else {
facts += fact('Quality target', d.quality_label);
facts += fact('Release', d.release_title);
facts += fact('Format', [d.resolution, d.source, d.codec].filter(Boolean).join(' · '));
facts += fact('Source', d.username ? ('👤 ' + d.username + (d.queue != null ? (' · queue ' + d.queue) : '')) : '');
}
facts += fact('Size', d.size_bytes ? fmtSize(d.size_bytes) : '');
facts += fact('Attempts', d.attempts > 1 ? (d.attempts + 'x') : '');
if (d.dest_path) facts += '<div class="vdpg-f vdpg-f-wide"><span class="vdpg-fk">Path</span>' +
'<span class="vdpg-fv vdpg-mono">' + esc(d.dest_path) + '</span>' +
'<button class="vdpg-copy" type="button" data-vdpg-copy="' + esc(d.dest_path) + '" title="Copy path">⧉</button></div>';
if (isFail(d.status) && d.error) facts += '<div class="vdpg-f vdpg-f-wide vdpg-f-err"><span class="vdpg-fk">Error</span><span class="vdpg-fv">' + esc(d.error) + '</span></div>';
// big actions
var btns = [];
if (d.media_id && !isYt) btns.push('<button class="vdpg-dr-btn" type="button" data-vdpg-open="' + esc(d.media_id) +
'" data-kind="' + esc(d.kind || 'movie') + '" data-source="' + esc(d.media_source || 'library') + '">Open in library</button>');
if (isYt && d.media_id) btns.push('<a class="vdpg-dr-btn" href="https://www.youtube.com/watch?v=' + encodeURIComponent(d.media_id) + '" target="_blank" rel="noopener">Open on YouTube</a>');
if (isActive(d.status)) btns.push('<button class="vdpg-dr-btn vdpg-dr-danger" type="button" data-vdpg-cancel="' + d.id + '">Cancel</button>');
else if (isFail(d.status)) btns.push('<button class="vdpg-dr-btn vdpg-dr-accent" type="button" data-vdpg-retry="' + d.id + '">Retry</button>');
var actions = btns.length ? '<div class="vdpg-dr-actions">' + btns.join('') + '</div>' : '';
var syn = loading ? '<p class="vdpg-dr-syn vdpg-dr-muted">Loading…</p>'
: (overview ? '<p class="vdpg-dr-syn">' + esc(overview) + '</p>'
: (isYt ? '' : '<p class="vdpg-dr-syn vdpg-dr-muted">No synopsis available.</p>'));
return back + '<div class="vdpg-dr-body">' + syn +
(genres ? '<div class="vdpg-dr-genres">' + esc(genres) + '</div>' : '') +
castHTML + '<div class="vdpg-dr-st">Download</div><div class="vdpg-dr-facts">' + facts + '</div>' +
actions + '</div>';
}
function renderDrawer(el, d) {
var dr = el.querySelector('[data-f="drawer"]'); if (!dr) return;
var open = !!_expanded[d.id];
el.classList.toggle('vdpg-card-open', open);
dr.hidden = !open;
if (!open) { dr.innerHTML = ''; return; }
dr.innerHTML = drawerHTML(d, _meta[d.id]);
// lazily fetch TMDB detail for movie/TV (skip youtube + owned library re-grabs)
if (_meta[d.id] === undefined && d.media_id && dlType(d.kind) !== 'youtube' && d.media_source !== 'library') {
_meta[d.id] = null;
var k = dlType(d.kind) === 'movie' ? 'movie' : 'show';
getJSON('/api/video/downloads/meta/' + k + '/' + encodeURIComponent(d.media_id)).then(function (m) {
_meta[d.id] = m || {};
if (_expanded[d.id]) { var dr2 = el.querySelector('[data-f="drawer"]'); if (dr2) dr2.innerHTML = drawerHTML(d, _meta[d.id]); }
});
}
}
function render(list) {
@ -260,11 +342,26 @@
}));
return;
}
var cp = e.target.closest('[data-vdpg-copy]');
if (cp) {
var path = cp.getAttribute('data-vdpg-copy');
if (navigator.clipboard) navigator.clipboard.writeText(path).then(function () { toast('Path copied', 'success'); }, function () {});
else toast('Copy not supported here', 'info');
return;
}
var c = e.target.closest('[data-vdpg-cancel]');
if (c) { c.disabled = true; c.classList.add('adl-row-cancel-pending'); postJSON(URL_CANCEL, { id: +c.getAttribute('data-vdpg-cancel') }).then(function () { poll(); }); return; }
var r = e.target.closest('[data-vdpg-retry]');
if (r) { r.disabled = true; postJSON(URL_RETRY, { id: +r.getAttribute('data-vdpg-retry') }).then(function (res) {
if (res && res.ok) toast('Retrying', 'info'); else toast((res && res.error) || 'Retry failed', 'error'); poll(); }); }
if (res && res.ok) toast('Retrying', 'info'); else toast((res && res.error) || 'Retry failed', 'error'); poll(); }); return; }
// click anywhere on the row (but not the drawer body or a control) → toggle the detail drawer
if (e.target.closest('[data-f="drawer"]') || e.target.closest('button, a')) return;
var card = e.target.closest('.adl-row[data-dl-id]');
if (card && card._d) {
var cid = card.getAttribute('data-dl-id');
_expanded[cid] = !_expanded[cid];
renderDrawer(card, card._d);
}
});
}

View file

@ -3797,6 +3797,45 @@ body[data-side="video"] #soulsync-toggle { display: none; }
/* quality chip follows the type color */
.vdpg-card .vdpg-qchip { background: rgba(var(--vt), 0.16); border-color: rgba(var(--vt), 0.36); }
/* ── click-to-expand detail drawer (wraps full-width below the row) ── */
.vdpg-card { flex-wrap: wrap; cursor: pointer; }
.vdpg-drawer { flex: 0 0 100%; width: 100%; margin-top: 12px; position: relative; cursor: default;
border-top: 1px solid rgba(255, 255, 255, 0.08); border-radius: 10px; overflow: hidden; background: rgba(0, 0, 0, 0.2); }
.vdpg-dr-back { position: absolute; inset: 0; background-size: cover; background-position: center 20%; opacity: 0.16;
-webkit-mask-image: linear-gradient(180deg, #000, transparent 78%); mask-image: linear-gradient(180deg, #000, transparent 78%); }
.vdpg-dr-body { position: relative; padding: 16px 18px 18px; }
.vdpg-dr-syn { font-size: 13px; line-height: 1.55; color: rgba(255, 255, 255, 0.82); margin: 0 0 10px; max-width: 80ch; }
.vdpg-dr-muted { color: rgba(255, 255, 255, 0.4); font-style: italic; }
.vdpg-dr-genres { font-size: 11px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: rgb(var(--vt)); }
.vdpg-dr-st { font-size: 10.5px; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase;
color: rgba(255, 255, 255, 0.4); margin: 16px 0 9px; }
.vdpg-dr-cast { display: flex; flex-wrap: wrap; gap: 12px; }
.vdpg-cast { width: 82px; text-align: center; }
.vdpg-cast-pic { display: block; width: 56px; height: 56px; margin: 0 auto 5px; border-radius: 50%; background-size: cover;
background-position: center; background-color: rgba(255, 255, 255, 0.08); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); }
.vdpg-cast-none { display: grid; place-items: center; font-size: 20px; font-weight: 800; color: rgba(255, 255, 255, 0.5); }
.vdpg-cast-nm { display: block; font-size: 11.5px; font-weight: 700; color: #fff; line-height: 1.25;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vdpg-cast-ch { display: block; font-size: 10.5px; color: rgba(255, 255, 255, 0.45); line-height: 1.2;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vdpg-dr-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 26px; }
.vdpg-f { display: flex; gap: 10px; font-size: 12.5px; min-width: 0; align-items: baseline; }
.vdpg-f-wide { grid-column: 1 / -1; }
.vdpg-fk { flex: 0 0 92px; color: rgba(255, 255, 255, 0.42); font-weight: 600; }
.vdpg-fv { color: rgba(255, 255, 255, 0.88); min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.vdpg-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11.5px; word-break: break-all; }
.vdpg-f-err .vdpg-fv { color: #f8a0a0; }
.vdpg-copy { flex: 0 0 auto; margin-left: auto; width: 24px; height: 22px; border-radius: 6px; cursor: pointer;
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14); color: rgba(255, 255, 255, 0.7); }
.vdpg-copy:hover { background: rgba(var(--vt), 0.22); border-color: rgba(var(--vt), 0.5); color: #fff; }
.vdpg-dr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.vdpg-dr-btn { padding: 8px 16px; border-radius: 9px; cursor: pointer; font-size: 12.5px; font-weight: 700; text-decoration: none;
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14); color: rgba(255, 255, 255, 0.85); transition: all 0.15s; }
.vdpg-dr-btn:hover { background: rgba(var(--vt), 0.18); border-color: rgba(var(--vt), 0.5); color: #fff; }
.vdpg-dr-accent { background: rgba(var(--vt), 0.18); border-color: rgba(var(--vt), 0.5); color: #fff; }
.vdpg-dr-danger:hover { background: rgba(239, 68, 68, 0.85); border-color: transparent; color: #fff; }
@media (max-width: 620px) { .vdpg-dr-facts { grid-template-columns: 1fr; } }
/* Automation Hub panes that are music-specific — emptied on the video side for now. */
.vauto-hub-soon { padding: 36px 20px; text-align: center; font-size: 13.5px; font-weight: 600;