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;