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:
parent
458bf01599
commit
9a8550661b
4 changed files with 174 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue