video Downloads cards: poster + movie details + Open-page button

Grabs now carry the movie's identity so the Downloads cards are rich, not anonymous:
- video_downloads gains media_id / media_source / year / poster_url (schema v15 +
  migration); grab stores them (passed from the get-modal → download view → grab).
- Cards show the POSTER in the art tile (emoji fallback), 'Title (Year)', a quality
  chip (1080p · BluRay · X265), and an ↗ Open button that jumps to the movie/show
  detail page (dispatches soulsync:video-open-detail). Cancel/retry unchanged.
16 tests green, ruff + balance clean.
This commit is contained in:
BoulderBadgeDad 2026-06-19 16:29:27 -07:00
parent 1c3f255a66
commit 48f6cc5e3c
7 changed files with 75 additions and 26 deletions

View file

@ -264,6 +264,9 @@ def register_routes(bp):
"source": "soulseek", "username": username, "filename": filename,
"size_bytes": int(body.get("size_bytes") or 0), "quality_label": body.get("quality_label"),
"target_dir": target, "status": "downloading",
"media_id": (str(body.get("media_id")) if body.get("media_id") is not None else None),
"media_source": body.get("media_source"), "year": body.get("year"),
"poster_url": body.get("poster_url"),
})
ensure_started(get_video_db)
return jsonify({"ok": True, "id": dl_id})

View file

@ -31,7 +31,7 @@ logger = get_logger("video_database")
# Bump when video_schema.sql changes in a way worth recording. Stored in
# PRAGMA user_version as a backstop indicator (nothing gates on it yet).
SCHEMA_VERSION = 14
SCHEMA_VERSION = 15
_DEFAULT_DB_PATH = "database/video_library.db"
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
@ -118,6 +118,11 @@ _BACKFILL_COLS = {
# Columns ensured on existing DBs (ALTER TABLE ADD COLUMN; idempotent).
_COLUMN_MIGRATIONS = [
# video_downloads — media identity for the Downloads page cards (poster + open).
("video_downloads", "media_id", "TEXT"),
("video_downloads", "media_source", "TEXT"),
("video_downloads", "year", "INTEGER"),
("video_downloads", "poster_url", "TEXT"),
("movies", "tmdb_match_status", "TEXT"),
("movies", "tmdb_last_attempted", "TEXT"),
("shows", "tmdb_match_status", "TEXT"),
@ -1128,7 +1133,8 @@ class VideoDatabase:
# ── video downloads (the grab → transfer pipeline) ────────────────────────
_DL_FIELDS = ("kind", "title", "release_title", "source", "username", "filename",
"size_bytes", "quality_label", "target_dir", "status")
"size_bytes", "quality_label", "target_dir", "status",
"media_id", "media_source", "year", "poster_url")
def add_video_download(self, rec: dict) -> int:
"""Insert a download row (status defaults to 'downloading'); returns its id."""

View file

@ -530,6 +530,10 @@ CREATE TABLE IF NOT EXISTS video_downloads (
filename TEXT, -- slskd remote filename (full path)
size_bytes INTEGER DEFAULT 0,
quality_label TEXT,
media_id TEXT, -- the movie/show id (for the detail-page link)
media_source TEXT, -- library | tmdb
year INTEGER,
poster_url TEXT, -- poster for the Downloads card
target_dir TEXT, -- destination library folder
dest_path TEXT, -- final moved path (set on completion)
status TEXT NOT NULL DEFAULT 'downloading',

View file

@ -279,10 +279,14 @@
var r = panel._rows[parseInt(btn.getAttribute('data-vdl-grab'), 10)]; if (!r) return;
var p = panel._search || {};
btn.disabled = true; btn.classList.add('vdl-res-grab--busy'); btn.textContent = '…';
var container = panel.closest('[data-vgm-dl-content]');
var o = (container && (container._opts || container._dl)) || {};
postJSON('/api/video/downloads/grab', {
kind: p.scope || 'movie', title: p.title || '', release_title: r.title,
source: 'soulseek', username: r.username, filename: r.filename,
size_bytes: r.size_bytes, quality_label: r.quality_label
size_bytes: r.size_bytes, quality_label: r.quality_label,
media_id: o.id || o.mediaId, media_source: o.source || o.mediaSource,
year: o.year, poster_url: o.poster
}).then(function (res) {
btn.classList.remove('vdl-res-grab--busy');
if (res && res.ok) {
@ -366,7 +370,8 @@
sel: new Set(), today: isoToday(),
tvId: opts.tvId || d.tmdb_id || null, source: opts.source || 'library',
sources: ['soulseek'], epMeta: {},
title: d.title || opts.title || '', maxSeason: maxSeason
title: d.title || opts.title || '', maxSeason: maxSeason,
mediaId: opts.id, mediaSource: opts.source, poster: opts.poster || null, year: d.year || null
};
container.innerHTML =
'<div class="vdl-section"><div class="vdl-sec-label">Quality target</div>' +

View file

@ -52,6 +52,7 @@
var X_SVG = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
var R_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
var OPEN_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
function makeCard(d) {
var el = document.createElement('div');
@ -78,25 +79,29 @@
var want = 'adl-row adl-row-' + cls;
if (el.className !== want) el.className = want;
var ic = q('ic'); var icon = KIND_ICON[(d.kind || '').toLowerCase()] || '🎬';
if (ic.textContent !== icon) ic.textContent = icon;
var name = d.title || d.release_title || 'Download';
// poster art tile (falls back to the kind emoji)
var ic = q('ic');
if (d.poster_url) {
if (ic._p !== d.poster_url) { ic._p = d.poster_url; ic.style.backgroundImage = "url('" + d.poster_url + "')"; }
ic.classList.add('vdpg-has-poster'); ic.textContent = '';
} else {
ic.classList.remove('vdpg-has-poster'); if (ic._p) { ic.style.backgroundImage = ''; ic._p = null; }
var icon = KIND_ICON[(d.kind || '').toLowerCase()] || '🎬';
if (ic.textContent !== icon) ic.textContent = icon;
}
var name = (d.title || d.release_title || 'Download') + (d.year ? ' (' + d.year + ')' : '');
var nm = q('name'); if (nm.textContent !== name) nm.textContent = name;
// one compact meta line (music-style), context-dependent
var meta;
if (d.status === 'completed' && d.dest_path) meta = '→ ' + d.dest_path;
else {
var bits = [];
if (d.release_title && d.release_title !== name) bits.push(d.release_title);
else bits.push(fmtSize(d.size_bytes));
if (d.username) bits.push('👤 ' + d.username);
if (active) bits.push(Math.round(pct) + '%');
meta = bits.join(' · ');
}
var mt = q('meta');
if (mt.textContent !== meta) mt.textContent = meta;
mt.classList.toggle('vdpg-dest', d.status === 'completed' && !!d.dest_path);
// meta: quality chip + a context line (release / size·user·pct / dest)
var ctx;
if (d.status === 'completed' && d.dest_path) ctx = '→ ' + d.dest_path;
else if (active) ctx = [fmtSize(d.size_bytes), d.username ? ('👤 ' + d.username) : '', Math.round(pct) + '%'].filter(Boolean).join(' · ');
else ctx = (d.release_title && d.release_title !== (d.title || '')) ? d.release_title : fmtSize(d.size_bytes);
var chip = d.quality_label ? '<span class="vdpg-qchip">' + esc(d.quality_label) + '</span>' : '';
var metaHTML = chip + '<span class="vdpg-mctx' + (d.status === 'completed' && d.dest_path ? ' vdpg-dest' : '') + '">' + esc(ctx) + '</span>';
var mt = q('meta'); if (mt.innerHTML !== metaHTML) mt.innerHTML = metaHTML;
var err = q('error');
var errTxt = isFail(d.status) && d.error ? d.error : '';
@ -114,11 +119,15 @@
var lab = q('label'); if (lab.textContent !== info.label) lab.textContent = info.label;
var act = q('actions');
var actHTML = active
var openBtn = d.media_id ? '<button class="vdpg-open" type="button" data-vdpg-open="' + esc(d.media_id) +
'" data-kind="' + esc(d.kind || 'movie') + '" data-source="' + esc(d.media_source || 'library') +
'" title="Open ' + (d.kind === 'movie' ? 'movie' : 'show') + ' page">' + OPEN_SVG + '</button>' : '';
var stateBtn = active
? '<button class="adl-row-cancel" type="button" data-vdpg-cancel="' + d.id + '" title="Cancel">' + X_SVG + '</button>'
: isFail(d.status)
? '<button class="vdpg-row-retry" type="button" data-vdpg-retry="' + d.id + '" title="Retry">' + R_SVG + '</button>'
: '';
var actHTML = openBtn + stateBtn;
if (act.innerHTML !== actHTML) act.innerHTML = actHTML;
}
@ -201,6 +210,15 @@
});
var list = document.querySelector('[data-vdpg-list]');
if (list) list.addEventListener('click', function (e) {
var op = e.target.closest('[data-vdpg-open]');
if (op) {
var kind = op.getAttribute('data-kind') === 'movie' ? 'movie' : 'show';
var id = op.getAttribute('data-vdpg-open');
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: kind, id: parseInt(id, 10) || id, source: op.getAttribute('data-source') || 'library' }
}));
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]');

View file

@ -87,12 +87,14 @@
if (o.kind === 'show') {
// Shows get a wider modal + a season/episode picker (not the movie layout).
VideoDownload.render(content, { kind: 'show', id: o.id, source: o.source || 'library',
detail: (modalState && modalState._detail) || null, tvId: (modalState && modalState._tvId) || null });
detail: (modalState && modalState._detail) || null, tvId: (modalState && modalState._tvId) || null,
poster: (modalState && modalState.poster) || null });
ov.classList.add('vgm-mode-dl-show');
} else {
var file = (modalState && modalState.kind === 'movie' && modalState.owned) ? (modalState.file || null) : null;
VideoDownload.render(content, { kind: o.kind, id: o.id, source: o.source || 'library', isYt: false, file: file,
title: (modalState && modalState.title) || o.title || '', year: (modalState && modalState.year) || null });
title: (modalState && modalState.title) || o.title || '', year: (modalState && modalState.year) || null,
poster: (modalState && modalState.poster) || null });
}
setDownloadMode(ov, true);
dl.hidden = false;

View file

@ -3320,13 +3320,24 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vdpg-wrap { padding: 4px 0 40px; }
.adl-controls-right { display: flex; align-items: center; gap: 10px; }
.vdpg-art { font-size: 22px; } /* emoji inside the .adl-row-art tile */
.vdpg-rowact { flex-shrink: 0; display: flex; align-items: center; }
.vdpg-art.vdpg-has-poster { background-size: cover; background-position: center; background-repeat: no-repeat; }
.vdpg-rowact { flex-shrink: 0; display: flex; align-items: center; gap: 2px; }
/* slim progress line inside the row (music rows have none; video files are big/slow) */
.vdpg-prog { margin-top: 7px; height: 4px; border-radius: 999px; background: rgba(255,255,255,0.08); overflow: hidden; }
.vdpg-prog-fill { height: 100%; width: 0; border-radius: 999px;
background: linear-gradient(90deg, rgba(var(--accent-rgb),0.7), rgb(var(--accent-rgb)));
transition: width 1.4s cubic-bezier(0.3,0.8,0.3,1); }
.adl-row-meta.vdpg-dest { color: #8fe7af; font-family: 'JetBrains Mono', ui-monospace, monospace; }
/* quality chip + context in the meta line */
.vdpg-qchip { display: inline-block; font-size: 9.5px; font-weight: 800; letter-spacing: 0.02em; text-transform: uppercase;
padding: 2px 7px; border-radius: 5px; vertical-align: middle; margin-right: 8px; color: #fff;
background: rgba(var(--accent-rgb), 0.16); border: 1px solid rgba(var(--accent-rgb), 0.32); }
.vdpg-mctx { vertical-align: middle; }
.vdpg-dest { color: #8fe7af; font-family: 'JetBrains Mono', ui-monospace, monospace; }
/* open the movie/show page */
.vdpg-open { flex-shrink: 0; width: 30px; height: 30px; display: inline-grid; place-items: center;
border-radius: 8px; 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); transition: all 0.15s ease; }
.vdpg-open:hover { background: rgba(var(--accent-rgb), 0.2); border-color: rgba(var(--accent-rgb), 0.5); color: #fff; transform: translateY(-1px); }
/* retry button — mirrors .adl-row-cancel's hover-reveal, accent themed */
.vdpg-row-retry { flex-shrink: 0; width: 28px; height: 28px; margin-left: 8px; padding: 0; display: inline-flex;
align-items: center; justify-content: center; border-radius: 50%; cursor: pointer; color: #fff;