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:
parent
1c3f255a66
commit
48f6cc5e3c
7 changed files with 75 additions and 26 deletions
|
|
@ -264,6 +264,9 @@ def register_routes(bp):
|
||||||
"source": "soulseek", "username": username, "filename": filename,
|
"source": "soulseek", "username": username, "filename": filename,
|
||||||
"size_bytes": int(body.get("size_bytes") or 0), "quality_label": body.get("quality_label"),
|
"size_bytes": int(body.get("size_bytes") or 0), "quality_label": body.get("quality_label"),
|
||||||
"target_dir": target, "status": "downloading",
|
"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)
|
ensure_started(get_video_db)
|
||||||
return jsonify({"ok": True, "id": dl_id})
|
return jsonify({"ok": True, "id": dl_id})
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ logger = get_logger("video_database")
|
||||||
|
|
||||||
# Bump when video_schema.sql changes in a way worth recording. Stored in
|
# 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).
|
# 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"
|
_DEFAULT_DB_PATH = "database/video_library.db"
|
||||||
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
|
_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).
|
# Columns ensured on existing DBs (ALTER TABLE ADD COLUMN; idempotent).
|
||||||
_COLUMN_MIGRATIONS = [
|
_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_match_status", "TEXT"),
|
||||||
("movies", "tmdb_last_attempted", "TEXT"),
|
("movies", "tmdb_last_attempted", "TEXT"),
|
||||||
("shows", "tmdb_match_status", "TEXT"),
|
("shows", "tmdb_match_status", "TEXT"),
|
||||||
|
|
@ -1128,7 +1133,8 @@ class VideoDatabase:
|
||||||
|
|
||||||
# ── video downloads (the grab → transfer pipeline) ────────────────────────
|
# ── video downloads (the grab → transfer pipeline) ────────────────────────
|
||||||
_DL_FIELDS = ("kind", "title", "release_title", "source", "username", "filename",
|
_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:
|
def add_video_download(self, rec: dict) -> int:
|
||||||
"""Insert a download row (status defaults to 'downloading'); returns its id."""
|
"""Insert a download row (status defaults to 'downloading'); returns its id."""
|
||||||
|
|
|
||||||
|
|
@ -530,6 +530,10 @@ CREATE TABLE IF NOT EXISTS video_downloads (
|
||||||
filename TEXT, -- slskd remote filename (full path)
|
filename TEXT, -- slskd remote filename (full path)
|
||||||
size_bytes INTEGER DEFAULT 0,
|
size_bytes INTEGER DEFAULT 0,
|
||||||
quality_label TEXT,
|
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
|
target_dir TEXT, -- destination library folder
|
||||||
dest_path TEXT, -- final moved path (set on completion)
|
dest_path TEXT, -- final moved path (set on completion)
|
||||||
status TEXT NOT NULL DEFAULT 'downloading',
|
status TEXT NOT NULL DEFAULT 'downloading',
|
||||||
|
|
|
||||||
|
|
@ -279,10 +279,14 @@
|
||||||
var r = panel._rows[parseInt(btn.getAttribute('data-vdl-grab'), 10)]; if (!r) return;
|
var r = panel._rows[parseInt(btn.getAttribute('data-vdl-grab'), 10)]; if (!r) return;
|
||||||
var p = panel._search || {};
|
var p = panel._search || {};
|
||||||
btn.disabled = true; btn.classList.add('vdl-res-grab--busy'); btn.textContent = '…';
|
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', {
|
postJSON('/api/video/downloads/grab', {
|
||||||
kind: p.scope || 'movie', title: p.title || '', release_title: r.title,
|
kind: p.scope || 'movie', title: p.title || '', release_title: r.title,
|
||||||
source: 'soulseek', username: r.username, filename: r.filename,
|
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) {
|
}).then(function (res) {
|
||||||
btn.classList.remove('vdl-res-grab--busy');
|
btn.classList.remove('vdl-res-grab--busy');
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
|
|
@ -366,7 +370,8 @@
|
||||||
sel: new Set(), today: isoToday(),
|
sel: new Set(), today: isoToday(),
|
||||||
tvId: opts.tvId || d.tmdb_id || null, source: opts.source || 'library',
|
tvId: opts.tvId || d.tmdb_id || null, source: opts.source || 'library',
|
||||||
sources: ['soulseek'], epMeta: {},
|
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 =
|
container.innerHTML =
|
||||||
'<div class="vdl-section"><div class="vdl-sec-label">Quality target</div>' +
|
'<div class="vdl-section"><div class="vdl-sec-label">Quality target</div>' +
|
||||||
|
|
|
||||||
|
|
@ -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 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 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) {
|
function makeCard(d) {
|
||||||
var el = document.createElement('div');
|
var el = document.createElement('div');
|
||||||
|
|
@ -78,25 +79,29 @@
|
||||||
|
|
||||||
var want = 'adl-row adl-row-' + cls;
|
var want = 'adl-row adl-row-' + cls;
|
||||||
if (el.className !== want) el.className = want;
|
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;
|
// poster art tile (falls back to the kind emoji)
|
||||||
var name = d.title || d.release_title || 'Download';
|
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;
|
var nm = q('name'); if (nm.textContent !== name) nm.textContent = name;
|
||||||
|
|
||||||
// one compact meta line (music-style), context-dependent
|
// meta: quality chip + a context line (release / size·user·pct / dest)
|
||||||
var meta;
|
var ctx;
|
||||||
if (d.status === 'completed' && d.dest_path) meta = '→ ' + d.dest_path;
|
if (d.status === 'completed' && d.dest_path) ctx = '→ ' + d.dest_path;
|
||||||
else {
|
else if (active) ctx = [fmtSize(d.size_bytes), d.username ? ('👤 ' + d.username) : '', Math.round(pct) + '%'].filter(Boolean).join(' · ');
|
||||||
var bits = [];
|
else ctx = (d.release_title && d.release_title !== (d.title || '')) ? d.release_title : fmtSize(d.size_bytes);
|
||||||
if (d.release_title && d.release_title !== name) bits.push(d.release_title);
|
var chip = d.quality_label ? '<span class="vdpg-qchip">' + esc(d.quality_label) + '</span>' : '';
|
||||||
else bits.push(fmtSize(d.size_bytes));
|
var metaHTML = chip + '<span class="vdpg-mctx' + (d.status === 'completed' && d.dest_path ? ' vdpg-dest' : '') + '">' + esc(ctx) + '</span>';
|
||||||
if (d.username) bits.push('👤 ' + d.username);
|
var mt = q('meta'); if (mt.innerHTML !== metaHTML) mt.innerHTML = metaHTML;
|
||||||
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);
|
|
||||||
|
|
||||||
var err = q('error');
|
var err = q('error');
|
||||||
var errTxt = isFail(d.status) && d.error ? d.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 lab = q('label'); if (lab.textContent !== info.label) lab.textContent = info.label;
|
||||||
|
|
||||||
var act = q('actions');
|
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>'
|
? '<button class="adl-row-cancel" type="button" data-vdpg-cancel="' + d.id + '" title="Cancel">' + X_SVG + '</button>'
|
||||||
: isFail(d.status)
|
: isFail(d.status)
|
||||||
? '<button class="vdpg-row-retry" type="button" data-vdpg-retry="' + d.id + '" title="Retry">' + R_SVG + '</button>'
|
? '<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;
|
if (act.innerHTML !== actHTML) act.innerHTML = actHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,6 +210,15 @@
|
||||||
});
|
});
|
||||||
var list = document.querySelector('[data-vdpg-list]');
|
var list = document.querySelector('[data-vdpg-list]');
|
||||||
if (list) list.addEventListener('click', function (e) {
|
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]');
|
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; }
|
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]');
|
var r = e.target.closest('[data-vdpg-retry]');
|
||||||
|
|
|
||||||
|
|
@ -87,12 +87,14 @@
|
||||||
if (o.kind === 'show') {
|
if (o.kind === 'show') {
|
||||||
// Shows get a wider modal + a season/episode picker (not the movie layout).
|
// 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',
|
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');
|
ov.classList.add('vgm-mode-dl-show');
|
||||||
} else {
|
} else {
|
||||||
var file = (modalState && modalState.kind === 'movie' && modalState.owned) ? (modalState.file || null) : null;
|
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,
|
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);
|
setDownloadMode(ov, true);
|
||||||
dl.hidden = false;
|
dl.hidden = false;
|
||||||
|
|
|
||||||
|
|
@ -3320,13 +3320,24 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
||||||
.vdpg-wrap { padding: 4px 0 40px; }
|
.vdpg-wrap { padding: 4px 0 40px; }
|
||||||
.adl-controls-right { display: flex; align-items: center; gap: 10px; }
|
.adl-controls-right { display: flex; align-items: center; gap: 10px; }
|
||||||
.vdpg-art { font-size: 22px; } /* emoji inside the .adl-row-art tile */
|
.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) */
|
/* 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 { 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;
|
.vdpg-prog-fill { height: 100%; width: 0; border-radius: 999px;
|
||||||
background: linear-gradient(90deg, rgba(var(--accent-rgb),0.7), rgb(var(--accent-rgb)));
|
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); }
|
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 */
|
/* 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;
|
.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;
|
align-items: center; justify-content: center; border-radius: 50%; cursor: pointer; color: #fff;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue