From 55658f15daec26e34c25363b9fefb088c1ec51aa Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 20 Jun 2026 00:03:11 -0700 Subject: [PATCH] video download modal: per-source 'Auto' button (search + grab the best release) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each source in the movie/YouTube download view had one 'Search' button (manual — you pick a release). Adds a second 'Auto' button beside it that runs the SAME search and then auto-grabs the best release for your quality profile; renamed the pair to 'Manual' / 'Auto' for clarity (+ a matching 'Auto all' beside 'Manual all'). How 'best' is chosen: the backend already returns hits ranked best-first (accepted → score → availability — see test_downloads_search_endpoint_ranks_and_filters), so Auto just waits for the search to settle, then takes the first accepted hit that has an uploader and grabs it. The chosen release card gets a ring + the row shows Auto-grabbing → Sent, so the pick is transparent. - searchInto/_pollSearch gain an onDone callback (fires when results settle); the immediate (mock) path fires it too. - doGrab refactored into shared buildGrabPayload + sendGrab so the manual button and _autoPick send an identical /grab request (incl. the auto-retry candidate pool). - searchInto now drops stale _rows on start so an empty Auto search can't grab a prior search's hit. Soulseek-grab-only for now (same as the manual button); non-soulseek sources say 'no release met your profile' until that grab path lands. TV show view (separate onShowClick, still stub searches) untouched. 7 wiring tests; node --check clean. --- tests/test_video_download_auto.py | 65 ++++++++++++++ webui/static/video/video-download-view.js | 102 ++++++++++++++++++---- webui/static/video/video-side.css | 14 +++ 3 files changed, 164 insertions(+), 17 deletions(-) create mode 100644 tests/test_video_download_auto.py diff --git a/tests/test_video_download_auto.py b/tests/test_video_download_auto.py new file mode 100644 index 00000000..06647d4d --- /dev/null +++ b/tests/test_video_download_auto.py @@ -0,0 +1,65 @@ +"""Video Download modal — per-source 'Auto' (search + auto-grab the best) wiring. + +The download view already has a Manual search (you pick a release). This adds an +Auto button per source that runs the SAME search and then grabs the best release +for the quality profile. The "best" comes for free: the backend returns hits +sorted accepted→score→availability (see test_video_api.py +::test_downloads_search_endpoint_ranks_and_filters asserting results[0].accepted), +so Auto just takes the first accepted hit that has an uploader. + +String-contract level (like tests/test_video_automations_builder.py) so a refactor +can't silently unwire the auto path or make manual + auto diverge. +""" + +from __future__ import annotations + +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_VIEW = (_ROOT / "webui" / "static" / "video" / "video-download-view.js").read_text(encoding="utf-8") +_CSS = (_ROOT / "webui" / "static" / "video" / "video-side.css").read_text(encoding="utf-8") + + +def test_each_source_has_manual_and_auto_buttons(): + assert 'data-vdl-search="' in _VIEW # Manual (pick yourself) + assert 'data-vdl-auto="' in _VIEW # Auto (best pick) + # Renamed for clarity: Manual vs Auto. + assert '⌕ Manual' in _VIEW and '⚡ Auto' in _VIEW + + +def test_search_all_gains_an_auto_all(): + assert 'data-vdl-auto-all' in _VIEW + assert 'Auto all' in _VIEW + + +def test_click_handler_routes_auto_before_manual(): + # Auto + auto-all must be handled (and auto checked before the plain search + # selector, since the markup nests differently). + assert "closest('[data-vdl-auto]')" in _VIEW + assert "closest('[data-vdl-auto-all]')" in _VIEW + + +def test_search_threads_a_done_callback(): + # Auto needs to act when the search SETTLES, not on the first tick. + assert 'function searchInto(container, resultsEl, params, triggerRows, onDone)' in _VIEW + assert 'function _pollSearch(resultsEl, params, id, triggerRows, pollMs, onDone)' in _VIEW + assert 'if (onDone) onDone();' in _VIEW + + +def test_autopick_takes_first_accepted_with_uploader(): + assert 'function _autoPick(' in _VIEW + # picks the first accepted hit that has an uploader (best, since pre-sorted) + assert 'rows[i].accepted && rows[i].username' in _VIEW + + +def test_manual_and_auto_share_one_grab_path(): + # Both go through buildGrabPayload + sendGrab so they can't diverge. + assert 'function buildGrabPayload(' in _VIEW + assert 'function sendGrab(' in _VIEW + assert _VIEW.count('sendGrab(buildGrabPayload(') >= 2 # doGrab + _autoPick + + +def test_auto_button_is_styled(): + assert '.vdl-src-auto' in _CSS + assert '.vdl-res--auto' in _CSS # the chosen card gets a ring + assert '.vdl-src-actions' in _CSS diff --git a/webui/static/video/video-download-view.js b/webui/static/video/video-download-view.js index 0f8fa9c2..0886c3af 100644 --- a/webui/static/video/video-download-view.js +++ b/webui/static/video/video-download-view.js @@ -55,7 +55,10 @@ '
' + '
' + '
Sources
' + - '' + + '' + + '' + + '' + + '' + '
' + '
Loading sources…
' + '
'; @@ -68,7 +71,10 @@ '' + m.emoji + '' + '' + esc(m.name) + '' + 'Ready' + - '' + + '' + + '' + + '' + + '' + ''; } function srcBlockHTML(s, mini) { @@ -78,20 +84,31 @@ ''; } - function _movieSearch(container, block) { + function _movieSearch(container, block, auto) { var o = container._opts || {}; var s = block.getAttribute('data-vdl-src-block'); - searchInto(container, block.querySelector('[data-vdl-results-for="' + s + '"]'), + var resultsEl = block.querySelector('[data-vdl-results-for="' + s + '"]'); + var statusRow = block.querySelector('.vdl-src'); + // In auto mode, when the search settles we grab the best accepted release. + var onDone = auto ? function () { _autoPick(resultsEl, statusRow); } : null; + searchInto(container, resultsEl, { scope: 'movie', title: o.title || '', year: o.year || null, source: s }, - [block.querySelector('.vdl-src')]); + [statusRow], onDone); } function onClick(e) { var container = e.currentTarget; var grab = e.target.closest('[data-vdl-grab]'); if (grab) { doGrab(grab); return; } + var ab = e.target.closest('[data-vdl-auto]'); + if (ab) { _movieSearch(container, ab.closest('[data-vdl-src-block]'), true); 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-auto-all]')) { + Array.prototype.forEach.call(container.querySelectorAll('[data-vdl-src-block]'), + function (block) { _movieSearch(container, block, true); }); + return; + } if (e.target.closest('[data-vdl-search-all]')) { Array.prototype.forEach.call(container.querySelectorAll('[data-vdl-src-block]'), function (block) { _movieSearch(container, block); }); @@ -202,6 +219,7 @@ if (row.matches && row.matches('button')) { row.disabled = on; row.classList.toggle('vdl-btn--busy', on); return; } row.classList.toggle('vdl-src--scanning', on); var b = row.querySelector('[data-vdl-search]'); if (b) b.disabled = on; + var a = row.querySelector('[data-vdl-auto]'); if (a) a.disabled = on; var s = row.querySelector('[data-vdl-status]'); if (s) { s.textContent = on ? 'Searching' : 'Ready'; s.className = 'vdl-src-status' + (on ? ' vdl-src-status--scanning' : ''); } }); @@ -235,10 +253,11 @@ // Start a search; for Soulseek, stream results in (poll like the music side — // results trickle in over ~30s, so a single short wait misses them). - function searchInto(container, resultsEl, params, triggerRows) { + function searchInto(container, resultsEl, params, triggerRows, onDone) { if (!resultsEl) return; triggerRows = (triggerRows || []).filter(Boolean); if (resultsEl._poll) { clearTimeout(resultsEl._poll); resultsEl._poll = null; } + resultsEl._rows = null; // drop any prior search's rows so Auto can't grab a stale hit _setScanning(triggerRows, true); resultsEl.hidden = false; resultsEl.classList.remove('vdl-res-noanim'); @@ -249,13 +268,14 @@ if (!d || !d.id) { // mock / immediate _setScanning(triggerRows, false); renderResults(resultsEl, params, d ? d.results : [], !!(d && d.live), true); + if (onDone) onDone(); return; } - _pollSearch(resultsEl, params, d.id, triggerRows, d.poll_ms); + _pollSearch(resultsEl, params, d.id, triggerRows, d.poll_ms, onDone); }); } - function _pollSearch(resultsEl, params, id, triggerRows, pollMs) { + function _pollSearch(resultsEl, params, id, triggerRows, pollMs, onDone) { // slskd keeps searching for the whole search_timeout (~60s) and results // trickle in over ~50s — poll that long (the music side does), streaming // results as they arrive. Stop early only once results clearly plateau. @@ -276,27 +296,25 @@ // done = full timeout, OR plenty of results, OR results plateaued after ≥20s. var done = elapsed >= MAX_MS || rows.length >= 25 || (rows.length > 0 && elapsed > 20000 && stable >= 6); renderResults(resultsEl, params, rows, true, done, total); - if (done) { _setScanning(triggerRows, false); resultsEl._poll = null; } + if (done) { _setScanning(triggerRows, false); resultsEl._poll = null; if (onDone) onDone(); } else { resultsEl._poll = setTimeout(tick, 1500); } }); } tick(); } - // 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; + // Build the /grab payload for a chosen release row `r` in `panel` (the results + // element). Shared by the manual grab button and the auto-pick path so both + // send an identical request (incl. the auto-retry candidate pool). + function buildGrabPayload(panel, r) { 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)) || {}; // the other accepted (live slskd) hits become the auto-retry pool var pool = (panel._rows || []).filter(function (x) { return x.accepted && x.username && x.filename !== r.filename; }) .map(function (x) { return { username: x.username, filename: x.filename, size_bytes: x.size_bytes, quality_label: x.quality_label, title: x.title }; }); - postJSON('/api/video/downloads/grab', { + return { 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, @@ -305,7 +323,18 @@ candidates: pool, search_ctx: { scope: p.scope || 'movie', title: p.title || '', year: o.year, season: p.season != null ? p.season : null, episode: p.episode != null ? p.episode : null } - }).then(function (res) { + }; + } + + function sendGrab(payload) { return postJSON('/api/video/downloads/grab', payload); } + + // 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; + btn.disabled = true; btn.classList.add('vdl-res-grab--busy'); btn.textContent = '…'; + sendGrab(buildGrabPayload(panel, r)).then(function (res) { btn.classList.remove('vdl-res-grab--busy'); if (res && res.ok) { btn.textContent = '✓'; btn.classList.add('vdl-res-grab--done'); @@ -318,6 +347,45 @@ }); } + // Auto-pick: after an auto-search settles, grab the BEST grabbable release. + // Results arrive already ranked best-first (server sorts by accepted → score → + // availability), so the first accepted hit with an uploader is the best pick. + function _autoPick(panel, statusRow) { + if (!panel || !panel.isConnected) return; + var rows = panel._rows || []; + var best = null, bestIdx = -1; + for (var i = 0; i < rows.length; i++) { + if (rows[i].accepted && rows[i].username) { best = rows[i]; bestIdx = i; break; } + } + var statusEl = statusRow && statusRow.querySelector('[data-vdl-status]'); + if (!best) { + if (statusEl) { statusEl.textContent = 'No match'; statusEl.className = 'vdl-src-status vdl-src-status--none'; } + toast('Auto: no release met your quality profile', 'error'); + return; + } + // Highlight the card we're auto-grabbing so the choice is transparent. + var cards = panel.querySelectorAll('.vdl-res'); + var card = cards[bestIdx]; + if (card) card.classList.add('vdl-res--auto'); + var gbtn = card && card.querySelector('[data-vdl-grab]'); + if (gbtn) { gbtn.disabled = true; gbtn.classList.add('vdl-res-grab--busy'); gbtn.textContent = '…'; } + if (statusEl) { statusEl.textContent = 'Auto-grabbing'; statusEl.className = 'vdl-src-status vdl-src-status--scanning'; } + toast('Auto-grabbing best: ' + (best.quality_label || best.title || 'release'), 'info'); + sendGrab(buildGrabPayload(panel, best)).then(function (res) { + if (gbtn) gbtn.classList.remove('vdl-res-grab--busy'); + if (res && res.ok) { + if (gbtn) { gbtn.textContent = '✓'; gbtn.classList.add('vdl-res-grab--done'); } + if (statusEl) { statusEl.textContent = 'Sent'; statusEl.className = 'vdl-src-status vdl-src-status--done'; } + toast('Sent to Downloads', 'success'); + document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); + } else { + if (gbtn) { gbtn.disabled = false; gbtn.textContent = '⤓'; } + if (statusEl) { statusEl.textContent = 'Ready'; statusEl.className = 'vdl-src-status'; } + toast((res && res.error) || 'Auto: 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'; diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index ae7efde4..8680e57d 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -3109,6 +3109,8 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vdl-src-status--soon { color: #fbcd7a; } .vdl-src-status--scanning { color: rgb(var(--src)); } .vdl-src-status--scanning::after { content: ''; animation: vdlDots 1.1s steps(1) infinite; } +.vdl-src-status--done { color: #58d39a; } +.vdl-src-status--none { color: #fbcd7a; } /* moving scan bar along the bottom of a row while "searching" — in the source brand */ .vdl-src--scanning::before { content: ''; position: absolute; left: 0; bottom: 0; height: 2px; width: 38%; border-radius: 2px; background: linear-gradient(90deg, transparent, rgb(var(--src)), transparent); @@ -3119,8 +3121,20 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vdl-src-search:hover { background: rgba(var(--src), 0.32); border-color: rgba(var(--src), 0.65); box-shadow: 0 5px 14px -5px rgb(var(--src)); transform: translateY(-1px); } .vdl-src-search:disabled { opacity: 0.55; cursor: default; transform: none; box-shadow: none; } +/* the two per-source actions sit together; Manual = outline, Auto = brand-filled */ +.vdl-src-actions { flex-shrink: 0; display: inline-flex; align-items: center; gap: 7px; } +.vdl-src-auto { flex-shrink: 0; padding: 7px 13px; border-radius: 9px; cursor: pointer; font-size: 12px; font-weight: 800; + color: #0a0f14; border: 1px solid transparent; transition: all 0.15s; + background: linear-gradient(135deg, rgb(var(--src)), rgba(var(--src), 0.74)); box-shadow: 0 4px 14px -6px rgb(var(--src)); } +.vdl-src-auto:hover { filter: brightness(1.08); transform: translateY(-1px); box-shadow: 0 7px 18px -6px rgb(var(--src)); } +.vdl-src-auto:disabled { opacity: 0.55; cursor: default; transform: none; box-shadow: none; filter: none; } +/* Auto-all mirrors the source-brand fill idea on the accent header button */ +.vdl-auto-all { animation: none; } .vdl-src-empty { font-size: 13px; color: rgba(255, 255, 255, 0.5); padding: 12px 14px; border-radius: 12px; background: rgba(255, 255, 255, 0.03); border: 1px dashed rgba(255, 255, 255, 0.12); } +/* the release card the Auto pick chose — a brand ring so the choice is obvious */ +.vdl-res--auto { border-color: rgba(var(--accent-rgb), 0.8) !important; + box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.5), 0 10px 26px -12px rgba(var(--accent-rgb), 0.8); } @media (max-width: 560px) { .vdl-src { flex-wrap: wrap; } }