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; }
}