From fff6448a14069652eef0c81d13e9d00ea565af60 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 19 Jun 2026 14:59:22 -0700 Subject: [PATCH] video downloads: wire Grab + build the live Downloads page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 β€” the UX: - Grab button now actually grabs (Soulseek results only β€” they carry the slskd username/filename): POSTs /downloads/grab with the result + search context, shows βœ“ on success, toasts 'Sent to Downloads', fires a refresh event. Endpoint returns size_bytes for the payload. - New Downloads PAGE (video-downloads-page.js, .vdpg-*): every grab lands here. Polls /downloads/active while open and shows live status β€” type icon (🎬/πŸ“Ί/▢️), title + release, a pulsing 'Downloading' pill with a shimmering progress bar, then 'Completed' with the file's library destination (β†’ /media/movies/…), or 'Failed' with the reason. Clear-finished button, empty state, staggered entrance, vibes. Reduced-motion honored. JS/CSS/HTML balance clean (verified w/ a real tokenizer). --- api/video/downloads.py | 2 +- webui/index.html | 19 ++++ webui/static/video/video-download-view.js | 35 +++++- webui/static/video/video-downloads-page.js | 121 +++++++++++++++++++++ webui/static/video/video-side.css | 58 ++++++++++ 5 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 webui/static/video/video-downloads-page.js diff --git a/api/video/downloads.py b/api/video/downloads.py index 359ccefb..f99b92db 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -160,7 +160,7 @@ def register_routes(bp): want_episode=want_episode, size_gb=size_gb) avail = hit.get("seeders") if hit.get("seeders") is not None else (hit.get("peers") or 0) results.append({ - "title": hit.get("title"), "size_gb": size_gb, + "title": hit.get("title"), "size_gb": size_gb, "size_bytes": hit.get("size_bytes") or 0, "seeders": hit.get("seeders"), "peers": hit.get("peers"), "username": hit.get("username"), "slots": hit.get("slots"), "filename": hit.get("filename"), "_avail": avail, diff --git a/webui/index.html b/webui/index.html index 1da6651c..832fc0e5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1273,6 +1273,23 @@ + + @@ -10222,6 +10239,8 @@ + + diff --git a/webui/static/video/video-download-view.js b/webui/static/video/video-download-view.js index 6fe8e09b..2f5471e8 100644 --- a/webui/static/video/video-download-view.js +++ b/webui/static/video/video-download-view.js @@ -88,7 +88,8 @@ function onClick(e) { var container = e.currentTarget; - if (e.target.closest('[data-vdl-grab]')) { toast('Grabbing isn’t wired up yet β€” coming soon', 'info'); return; } + var grab = e.target.closest('[data-vdl-grab]'); + if (grab) { doGrab(grab); return; } var sb = e.target.closest('[data-vdl-search]'); if (sb) { _movieSearch(container, sb.closest('[data-vdl-src-block]')); return; } if (e.target.closest('[data-vdl-search-all]')) { @@ -221,6 +222,7 @@ if (d && d.error) { resultsEl.innerHTML = '
⚠ ' + esc(d.error) + '
'; return; } var rows = (d && d.results) || []; if (!rows.length) { resultsEl.innerHTML = '
No matching releases found.
'; return; } + resultsEl._rows = rows; resultsEl._search = params; // for the Grab button var okN = rows.filter(function (r) { return r.accepted; }).length; var live = d && d.live ? '● live' : 'demo data'; resultsEl.innerHTML = @@ -230,6 +232,30 @@ }); } + // Grab β†’ start a real download (Soulseek only for now), then it lives on the + // Downloads page. Reads the card's row + the panel's search context. + function doGrab(btn) { + var panel = btn.closest('.vdl-results'); if (!panel || !panel._rows) return; + 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 = '…'; + 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 + }).then(function (res) { + btn.classList.remove('vdl-res-grab--busy'); + if (res && res.ok) { + btn.textContent = 'βœ“'; btn.classList.add('vdl-res-grab--done'); + toast('Sent to Downloads', 'success'); + document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); + } else { + btn.disabled = false; btn.textContent = '–'; + toast((res && res.error) || 'Couldn’t start the download', 'error'); + } + }); + } + function scopeWord(s) { return s === 'season' ? 'for the season pack' : s === 'series' ? 'for the full series' : s === 'episode' ? 'this episode' : 'for the movie'; @@ -250,7 +276,7 @@ // Readable card: a big resolution tile anchors it, a plain-English quality summary // leads, the raw release name is demoted to a muted one-liner, then size/seeders. - function resultCardHTML(r) { + function resultCardHTML(r, i) { var summary = [SRC_LABEL[r.source] || r.source, r.codec ? String(r.codec).toUpperCase() : '', r.audio ? String(r.audio).toUpperCase().replace('-', ' ') : ''].filter(Boolean).join(' Β· '); @@ -274,7 +300,7 @@ (r.group ? '' + esc(r.group) + '' : '') + '' + '' + - (r.accepted ? '' : '') + + (r.accepted && r.username ? '' : '') + ''; } @@ -412,7 +438,8 @@ function onShowClick(e) { var container = e.currentTarget; var st = container._dl; if (!st) return; - if (e.target.closest('[data-vdl-grab]')) { toast('Grabbing isn’t wired up yet β€” coming soon', 'info'); return; } + var grab = e.target.closest('[data-vdl-grab]'); + if (grab) { doGrab(grab); return; } // Episode-scope search (a source row inside an expanded episode β†’ its own panel). var srch = e.target.closest('[data-vdl-search]'); if (srch) { diff --git a/webui/static/video/video-downloads-page.js b/webui/static/video/video-downloads-page.js new file mode 100644 index 00000000..bdcce8b3 --- /dev/null +++ b/webui/static/video/video-downloads-page.js @@ -0,0 +1,121 @@ +/* + * SoulSync β€” Video Downloads page. + * + * Every grab from the video side lands here. Polls /downloads/active while the page + * is open and renders live status: a progress bar while downloading, then the file's + * final library destination when it completes. Self-contained; isolated .vdpg-* styling. + */ +(function () { + 'use strict'; + + var URL_ACTIVE = '/api/video/downloads/active'; + var URL_CLEAR = '/api/video/downloads/clear'; + var _timer = null, _wired = false; + + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + function toast(m, t) { if (typeof showToast === 'function') showToast(m, t); } + function getJSON(u) { + return fetch(u, { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); + } + + var KIND_ICON = { movie: '🎬', show: 'πŸ“Ί', episode: 'πŸ“Ί', season: 'πŸ“Ί', series: 'πŸ“Ί', youtube: '▢️' }; + var STATUS = { + downloading: { label: 'Downloading', cls: 'dl' }, + queued: { label: 'Queued', cls: 'q' }, + completed: { label: 'Completed', cls: 'ok' }, + failed: { label: 'Failed', cls: 'fail' } + }; + + function fmtGB(bytes) { + var gb = (bytes || 0) / (1024 * 1024 * 1024); + return gb >= 0.1 ? (Math.round(gb * 10) / 10) + ' GB' : Math.round((bytes || 0) / (1024 * 1024)) + ' MB'; + } + + function itemHTML(d) { + var st = STATUS[d.status] || STATUS.downloading; + var icon = KIND_ICON[(d.kind || '').toLowerCase()] || '🎬'; + var pct = Math.max(0, Math.min(100, d.progress || 0)); + var name = d.title || d.release_title || 'Download'; + var bar = (d.status === 'downloading' || d.status === 'queued') + ? '
' + + '
' + (Math.round(pct)) + '%
' + : ''; + var meta = []; + meta.push('' + fmtGB(d.size_bytes) + ''); + if (d.username) meta.push('πŸ‘€ ' + esc(d.username) + ''); + if (d.status === 'completed' && d.dest_path) meta.push('β†’ ' + esc(d.dest_path) + ''); + if (d.status === 'failed' && d.error) meta.push('' + esc(d.error) + ''); + return '
' + + '
' + icon + '
' + + '
' + + '
' + esc(name) + '' + + '' + st.label + '
' + + (d.release_title && d.release_title !== name ? '
' + esc(d.release_title) + '
' : '') + + bar + + '
' + meta.join('') + '
' + + '
' + + '
'; + } + + function render(list) { + var host = document.querySelector('[data-vdpg-list]'); if (!host) return; + list = list || []; + var clearBtn = document.querySelector('[data-vdpg-clear]'); + var finished = list.filter(function (d) { return d.status === 'completed' || d.status === 'failed'; }).length; + if (clearBtn) clearBtn.hidden = finished === 0; + var active = list.filter(function (d) { return d.status === 'downloading' || d.status === 'queued'; }).length; + var sub = document.querySelector('[data-vdpg-sub]'); + if (sub) sub.textContent = list.length + ? (active + ' active Β· ' + finished + ' finished') + : "Everything you've grabbed from the video side"; + if (!list.length) { + host.innerHTML = '
–
' + + '
No downloads yet
' + + '
Hit Grab on a search result and it\'ll show up here.
'; + return; + } + host.innerHTML = list.map(itemHTML).join(''); + } + + function load() { + getJSON(URL_ACTIVE).then(function (d) { if (d) render(d.downloads || []); }); + } + + function anyActive() { + return !!document.querySelector('.vdpg-item--dl, .vdpg-item--q'); + } + + function start() { + load(); + if (_timer) clearInterval(_timer); + // Poll while open; the monitor moves files server-side regardless. + _timer = setInterval(function () { load(); }, 2500); + wire(); + } + function stop() { if (_timer) { clearInterval(_timer); _timer = null; } } + + function wire() { + if (_wired) return; _wired = true; + var clearBtn = document.querySelector('[data-vdpg-clear]'); + if (clearBtn) clearBtn.addEventListener('click', function () { + fetch(URL_CLEAR, { method: 'POST', headers: { Accept: 'application/json' } }) + .then(function () { toast('Cleared finished downloads', 'success'); load(); }) + .catch(function () { /* ignore */ }); + }); + } + + document.addEventListener('soulsync:video-page-shown', function (e) { + if (e.detail === 'video-downloads') start(); else stop(); + }); + // A fresh grab elsewhere β†’ if the page is open, reflect it quickly. + document.addEventListener('soulsync:video-download-started', function () { + if (document.querySelector('[data-video-subpage="video-downloads"]:not([hidden])')) setTimeout(load, 400); + }); + // Keep polling lively only while something is in flight (handled by the 2.5s timer); + // anyActive() is exposed for future adaptive intervals. + window._vdpgAnyActive = anyActive; +})(); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index f60bb62e..d02273da 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -3310,3 +3310,61 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vdl-src-block { display: flex; flex-direction: column; } .vdl-src-block .vdl-results { margin: 8px 0 2px; } @media (prefers-reduced-motion: reduce) { .vdl-res, .vdl-res-spin { animation: none !important; } } + +/* ── Downloads page (.vdpg-*) ─────────────────────────────────────────────── */ +.vdpg-page { max-width: 1000px; margin: 0 auto; padding: 8px 4px 40px; } +.vdpg-head { position: relative; margin-bottom: 22px; padding: 6px 2px; overflow: hidden; } +.vdpg-head-glow { position: absolute; inset: -40% -10% auto -10%; height: 200px; pointer-events: none; + background: radial-gradient(60% 100% at 30% 0%, rgba(var(--accent-rgb), 0.18), transparent 70%); } +.vdpg-head-row { position: relative; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; } +.vdpg-title { font-size: 34px; font-weight: 900; letter-spacing: -0.03em; margin: 0; color: #fff; } +.vdpg-sub { margin: 6px 0 0; font-size: 13.5px; font-weight: 600; color: rgba(255, 255, 255, 0.5); } +.vdpg-clear { flex-shrink: 0; padding: 9px 16px; border-radius: 10px; cursor: pointer; font-size: 12.5px; font-weight: 800; + background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14); color: rgba(255, 255, 255, 0.85); + transition: all 0.15s ease; } +.vdpg-clear:hover { background: rgba(239, 68, 68, 0.16); border-color: rgba(239, 68, 68, 0.45); color: #fecaca; } +.vdpg-clear[hidden] { display: none; } +.vdpg-list { display: flex; flex-direction: column; gap: 11px; } + +.vdpg-item { display: flex; gap: 15px; padding: 15px 17px; border-radius: 16px; align-items: center; + background: rgba(255, 255, 255, 0.035); border: 1px solid rgba(255, 255, 255, 0.08); + animation: vdlRise 0.45s both; transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease; } +.vdpg-item:hover { transform: translateY(-2px); border-color: rgba(255, 255, 255, 0.16); background: rgba(255, 255, 255, 0.055); } +.vdpg-item--dl { border-left: 3px solid rgb(var(--accent-rgb)); } +.vdpg-item--ok { border-left: 3px solid #6cd391; } +.vdpg-item--fail { border-left: 3px solid #ef4444; } +.vdpg-item--q { border-left: 3px solid rgba(255, 255, 255, 0.3); } +.vdpg-ic { flex-shrink: 0; width: 50px; height: 50px; border-radius: 13px; display: grid; place-items: center; + font-size: 24px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.08); } +.vdpg-body { flex: 1; min-width: 0; } +.vdpg-row1 { display: flex; align-items: center; gap: 10px; } +.vdpg-name { font-size: 15.5px; font-weight: 800; color: #fff; letter-spacing: -0.01em; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vdpg-pill { flex-shrink: 0; font-size: 10px; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase; + padding: 3px 9px; border-radius: 999px; } +.vdpg-pill--dl { background: rgba(var(--accent-rgb), 0.18); color: rgb(var(--accent-rgb)); animation: vdpgPulse 1.6s ease-in-out infinite; } +.vdpg-pill--q { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.6); } +.vdpg-pill--ok { background: rgba(108, 211, 145, 0.18); color: #8fe7af; } +.vdpg-pill--fail { background: rgba(239, 68, 68, 0.18); color: #fca5a5; } +.vdpg-rel { font: 500 11px/1.3 'JetBrains Mono', ui-monospace, monospace; color: rgba(255, 255, 255, 0.4); + margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vdpg-bar { margin-top: 10px; height: 7px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); overflow: hidden; } +.vdpg-bar-fill { height: 100%; border-radius: 999px; transition: width 0.6s cubic-bezier(0.2, 0.7, 0.2, 1); + background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.7), rgb(var(--accent-rgb))); position: relative; } +.vdpg-bar-fill::after { content: ''; position: absolute; inset: 0; border-radius: 999px; + background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.35) 50%, transparent 70%); + animation: vdpgShimmer 1.4s linear infinite; } +.vdpg-pct { margin-top: 4px; font-size: 10.5px; font-weight: 800; color: rgba(255, 255, 255, 0.5); } +.vdpg-meta { display: flex; flex-wrap: wrap; gap: 13px; margin-top: 8px; font-size: 11.5px; font-weight: 600; + color: rgba(255, 255, 255, 0.55); } +.vdpg-dest { color: #8fe7af; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11px; } +.vdpg-err { color: #fca5a5; } +.vdpg-empty { text-align: center; padding: 70px 20px; color: rgba(255, 255, 255, 0.45); } +.vdpg-empty-ic { font-size: 46px; opacity: 0.5; } +.vdpg-empty-t { font-size: 17px; font-weight: 800; color: rgba(255, 255, 255, 0.7); margin-top: 12px; } +.vdpg-empty-s { font-size: 13px; margin-top: 6px; } +@keyframes vdpgPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } } +@keyframes vdpgShimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } } +@media (prefers-reduced-motion: reduce) { + .vdpg-item, .vdpg-pill--dl, .vdpg-bar-fill::after { animation: none !important; } +}