From 9a3ca6ba4e73d92de4b51d57199194c312796e7d Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 20 Jun 2026 10:48:55 -0700 Subject: [PATCH] video downloads: header 'Auto' picks ONE best across all sources; fix YT playlist leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto: replaced 'Manual all' + 'Auto all' (which fired auto on every source = up to one download PER source = duplicate copies) with a single header 'Auto' button. It searches every source, waits for them all to settle, compares the accepted+grabbable hits across ALL sources by quality-profile score (tie-break on availability), and grabs exactly ONE winner — the chosen row gets the auto ring + live tracker. Per-source Manual/Auto buttons are unchanged. Bug fix: viewing a YouTube channel populated the playlist section in the SHARED show-detail DOM; opening a real movie/show afterward still showed those playlists, because ytResetPlaylists() used the kind-scoped q() (pointing at the wrong root on a movie load) and wasn't called on normal loads. Now it targets the show subpage directly and runs from resetExtras() (every detail load), so stale playlists are always cleared. node --check clean; 20 wiring/regression tests green; all video-only. --- tests/test_video_detail_yt_reset.py | 42 ++++++++++++ tests/test_video_download_auto.py | 28 +++++--- webui/static/video/video-detail.js | 11 ++- webui/static/video/video-download-view.js | 84 ++++++++++++++++++----- 4 files changed, 140 insertions(+), 25 deletions(-) create mode 100644 tests/test_video_detail_yt_reset.py diff --git a/tests/test_video_detail_yt_reset.py b/tests/test_video_detail_yt_reset.py new file mode 100644 index 00000000..8675c2eb --- /dev/null +++ b/tests/test_video_detail_yt_reset.py @@ -0,0 +1,42 @@ +"""Regression: a YouTube channel's playlists must not leak onto the next movie/show. + +YouTube channels/playlists render into the SHOW-detail DOM (they reuse that page). +The playlist section is only in the show DOM, but a later movie/show load runs with +q() scoped to a different root — so the reset has to target the show subpage +directly AND run on every detail load (via resetExtras), or stale playlists show. +""" + +from __future__ import annotations + +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_DETAIL = (_ROOT / "webui" / "static" / "video" / "video-detail.js").read_text(encoding="utf-8") +_INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8") + + +def test_playlist_section_lives_only_in_show_detail(): + # The section markup is in the show subpage; the movie subpage must not have it. + show_start = _INDEX.index('data-video-detail="show"') + movie_start = _INDEX.index('data-video-detail="movie"') + show_block = _INDEX[show_start:movie_start] + movie_block = _INDEX[movie_start:movie_start + 4000] + assert 'data-vd-yt-pl-section' in show_block + assert 'data-vd-yt-pl-section' not in movie_block + + +def test_reset_targets_the_show_dom_directly(): + # ytResetPlaylists must NOT rely on the kind-scoped q() (which points at the + # movie root during a movie load) — it queries the show subpage explicitly. + i = _DETAIL.index('function ytResetPlaylists(') + body = _DETAIL[i:i + 700] + assert "document.querySelector('[data-video-detail=\"show\"]')" in body + assert 'data-vd-yt-playlists' in body + + +def test_every_detail_load_clears_stale_playlists(): + # resetExtras() runs on loadMovie/loadShow/loadChannel/loadPlaylist, so wiring + # the clear there covers all navigations into a non-YouTube detail. + i = _DETAIL.index('function resetExtras(') + body = _DETAIL[i:i + 700] + assert 'ytResetPlaylists()' in body diff --git a/tests/test_video_download_auto.py b/tests/test_video_download_auto.py index 56e2c594..f99a5728 100644 --- a/tests/test_video_download_auto.py +++ b/tests/test_video_download_auto.py @@ -30,16 +30,28 @@ def test_each_source_has_manual_and_auto_buttons(): assert 'vdl-btn-ic--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_header_has_single_auto_best_button(): + # The old "Manual all" + "Auto all" pair is replaced by ONE header "Auto" that + # searches every source and grabs the single best release across all of them. + assert 'data-vdl-auto-best' in _VIEW + assert 'data-vdl-auto-all' not in _VIEW # the per-source-grab-all footgun is gone + assert 'data-vdl-search-all' not in _VIEW + assert 'Manual all' not in _VIEW and 'Auto all' not 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_auto_best_picks_one_winner_across_all_sources(): + assert 'function _autoBest(' in _VIEW + assert 'function _grabBestAcross(' in _VIEW + # it compares accepted+grabbable hits across every source by profile score… + assert "r.accepted && r.username" in _VIEW + assert '(r.score || 0) > (best.score || 0)' in _VIEW + # …and grabs exactly ONE (no per-source loop of grabs) + assert _VIEW.count('beginTracking(') >= 3 # def + doGrab + _autoPick (+ _grabBestAcross) + + +def test_click_handler_routes_auto_best_and_per_source(): + assert "closest('[data-vdl-auto-best]')" in _VIEW + assert "closest('[data-vdl-auto]')" in _VIEW # per-source auto still works def test_search_threads_a_done_callback(): diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index d201dff6..59e550b7 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -531,6 +531,9 @@ '[data-vd-review-section]', '[data-vd-cast-all]'].forEach(function (s) { var n = q(s); if (n) n.hidden = true; }); + // Clear any YouTube-channel playlists from the show DOM so they don't leak + // onto the next movie/show you open (the section is reused across loads). + ytResetPlaylists(); galleryImages = []; stopBillboardTrailer(); } @@ -1797,7 +1800,13 @@ // playlists as collapsible rows below the episodes (channel-only section) function ytResetPlaylists() { - var sec = q('[data-vd-yt-pl-section]'), host = q('[data-vd-yt-playlists]'); + // The playlist section lives ONLY in the show-detail DOM, but a movie/show + // load runs with q() scoped to a DIFFERENT root — so query the show subpage + // directly. Otherwise a channel's playlists leak onto the next show you open. + var showRoot = document.querySelector('[data-video-detail="show"]'); + if (!showRoot) return; + var sec = showRoot.querySelector('[data-vd-yt-pl-section]'); + var host = showRoot.querySelector('[data-vd-yt-playlists]'); if (host) host.innerHTML = ''; if (sec) sec.hidden = true; } diff --git a/webui/static/video/video-download-view.js b/webui/static/video/video-download-view.js index be2b8463..8b7adbc3 100644 --- a/webui/static/video/video-download-view.js +++ b/webui/static/video/video-download-view.js @@ -57,10 +57,8 @@ '
' + '
Sources
' + '' + - '' + - '' + + '' + '' + '
' + '
Loading sources…
' + @@ -89,35 +87,89 @@ ''; } - function _movieSearch(container, block, auto) { + function _movieSearch(container, block, auto, onSettled) { var o = container._opts || {}; var s = block.getAttribute('data-vdl-src-block'); 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; + // In per-source auto mode, grab this source's best when it settles; an + // optional onSettled lets a coordinator (header Auto) wait for every source. + var onDone = (auto || onSettled) ? function () { + if (auto) _autoPick(resultsEl, statusRow); + if (onSettled) onSettled(resultsEl); + } : null; searchInto(container, resultsEl, { scope: 'movie', title: o.title || '', year: o.year || null, source: s }, [statusRow], onDone); } + // Header "Auto": search EVERY source, then download the single best release + // across all of them (NOT one per source). Each source's results still show so + // the pick is transparent; the winner gets the auto ring + live tracker. + function _autoBest(container) { + var blocks = Array.prototype.slice.call(container.querySelectorAll('[data-vdl-src-block]')); + if (!blocks.length) return; + var hb = container.querySelector('[data-vdl-auto-best]'); + if (hb) hb.disabled = true; + var pending = blocks.length, done = false; + blocks.forEach(function (block) { + _movieSearch(container, block, false, function () { + if (done) return; + if (--pending > 0) return; + done = true; + _grabBestAcross(container, blocks, hb); + }); + }); + } + + function _grabBestAcross(container, blocks, hb) { + var best = null, bestPanel = null, bestIdx = -1; + blocks.forEach(function (block) { + var s = block.getAttribute('data-vdl-src-block'); + var panel = block.querySelector('[data-vdl-results-for="' + s + '"]'); + var rows = (panel && panel._rows) || []; + for (var i = 0; i < rows.length; i++) { + var r = rows[i]; + if (!(r.accepted && r.username)) continue; // grabbable (Soulseek) + meets profile + var avail = r.peers || r.seeders || 0; + var bAvail = best ? (best.peers || best.seeders || 0) : -1; + if (!best || (r.score || 0) > (best.score || 0) || + ((r.score || 0) === (best.score || 0) && avail > bAvail)) { + best = r; bestPanel = panel; bestIdx = i; + } + } + }); + if (hb) hb.disabled = false; + if (!best) { toast('Auto: no release met your quality profile on any source', 'error'); return; } + var card = bestPanel.querySelector('[data-vdl-card="' + bestIdx + '"]'); + if (card) { + card.classList.add('vdl-res--auto'); + if (card.scrollIntoView) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + var gbtn = card && card.querySelector('[data-vdl-grab]'); + if (gbtn) { gbtn.disabled = true; gbtn.classList.add('vdl-res-grab--busy'); } + toast('Auto-picked best across sources: ' + (best.quality_label || best.title || 'release'), 'info'); + sendGrab(buildGrabPayload(bestPanel, best)).then(function (res) { + if (res && res.ok) { + toast('Sent to Downloads', 'success'); + beginTracking(card, res.id); + document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); + } else { + if (gbtn) { gbtn.disabled = false; gbtn.classList.remove('vdl-res-grab--busy'); } + toast((res && res.error) || 'Auto: couldn’t start the download', 'error'); + } + }); + } + function onClick(e) { var container = e.currentTarget; var grab = e.target.closest('[data-vdl-grab]'); if (grab) { doGrab(grab); return; } + if (e.target.closest('[data-vdl-auto-best]')) { _autoBest(container); 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); }); - } } // Render the download view into `container`. Re-callable (resets each time).