From 9a8550661b0465e7091fa40ce6a7d7f12f445afb Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 26 Jun 2026 11:30:36 -0700 Subject: [PATCH] downloads page: click-to-expand detail drawer (synopsis + cast + facts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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// 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. --- api/video/downloads.py | 17 ++++ tests/test_video_downloads_page.py | 19 ++++ webui/static/video/video-downloads-page.js | 101 ++++++++++++++++++++- webui/static/video/video-side.css | 39 ++++++++ 4 files changed, 174 insertions(+), 2 deletions(-) diff --git a/api/video/downloads.py b/api/video/downloads.py index bd54eafb..dff5ffec 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -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//", 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 diff --git a/tests/test_video_downloads_page.py b/tests/test_video_downloads_page.py index 9bc0405f..c96dfa38 100644 --- a/tests/test_video_downloads_page.py +++ b/tests/test_video_downloads_page.py @@ -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//" in rules + diff --git a/webui/static/video/video-downloads-page.js b/webui/static/video/video-downloads-page.js index 86fc502a..467ef6ab 100644 --- a/webui/static/video/video-downloads-page.js +++ b/webui/static/video/video-downloads-page.js @@ -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 @@ '' + '' + '
' + - '
'; + '
' + + ''; 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 ? '
' + esc(k) + '' + esc(v) + '
' : ''; + } + 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) + ? '
' : ''; + var genres = (meta && (meta.genres || []).slice(0, 4).join(' · ')) || ''; + var cast = (meta && meta.cast || []).slice(0, 8); + var castHTML = cast.length ? '
Cast
' + cast.map(function (c) { + var pic = c.profile_url + ? '' + : '' + esc((c.name || '?').charAt(0)) + ''; + return '
' + pic + '' + esc(c.name) + + '' + (c.character ? '' + esc(c.character) + '' : '') + '
'; + }).join('') + '
' : ''; + + // 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 += '
Path' + + '' + esc(d.dest_path) + '' + + '
'; + if (isFail(d.status) && d.error) facts += '
Error' + esc(d.error) + '
'; + + // big actions + var btns = []; + if (d.media_id && !isYt) btns.push(''); + if (isYt && d.media_id) btns.push('Open on YouTube'); + if (isActive(d.status)) btns.push(''); + else if (isFail(d.status)) btns.push(''); + var actions = btns.length ? '
' + btns.join('') + '
' : ''; + + var syn = loading ? '

Loading…

' + : (overview ? '

' + esc(overview) + '

' + : (isYt ? '' : '

No synopsis available.

')); + return back + '
' + syn + + (genres ? '
' + esc(genres) + '
' : '') + + castHTML + '
Download
' + facts + '
' + + actions + '
'; + } + 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); + } }); } diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 6cc63561..06f1a730 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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;