diff --git a/api/video/downloads.py b/api/video/downloads.py
index ca4590ee..63971345 100644
--- a/api/video/downloads.py
+++ b/api/video/downloads.py
@@ -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})
diff --git a/database/video_database.py b/database/video_database.py
index e24d7cef..cec825d9 100644
--- a/database/video_database.py
+++ b/database/video_database.py
@@ -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."""
diff --git a/database/video_schema.sql b/database/video_schema.sql
index 67751393..2f5cac71 100644
--- a/database/video_schema.sql
+++ b/database/video_schema.sql
@@ -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',
diff --git a/webui/static/video/video-download-view.js b/webui/static/video/video-download-view.js
index d34ad2b4..d82ca3ad 100644
--- a/webui/static/video/video-download-view.js
+++ b/webui/static/video/video-download-view.js
@@ -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 =
'
Quality target
' +
diff --git a/webui/static/video/video-downloads-page.js b/webui/static/video/video-downloads-page.js
index 82a87fd9..483f5c4b 100644
--- a/webui/static/video/video-downloads-page.js
+++ b/webui/static/video/video-downloads-page.js
@@ -52,6 +52,7 @@
var X_SVG = '
';
var R_SVG = '
';
+ var OPEN_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 ? '
' + esc(d.quality_label) + '' : '';
+ var metaHTML = chip + '
' + esc(ctx) + '';
+ 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 ? '
' : '';
+ var stateBtn = active
? '
'
: isFail(d.status)
? '
'
: '';
+ 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]');
diff --git a/webui/static/video/video-get-modal.js b/webui/static/video/video-get-modal.js
index 97d073c1..9cc48d41 100644
--- a/webui/static/video/video-get-modal.js
+++ b/webui/static/video/video-get-modal.js
@@ -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;
diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css
index e0cf2ee7..d58c6acd 100644
--- a/webui/static/video/video-side.css
+++ b/webui/static/video/video-side.css
@@ -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;