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,
|
||||
"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})
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>' +
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue