video downloads: header 'Auto' picks ONE best across all sources; fix YT playlist leak

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.
This commit is contained in:
BoulderBadgeDad 2026-06-20 10:48:55 -07:00
parent f6f5561d0b
commit 9a3ca6ba4e
4 changed files with 140 additions and 25 deletions

View file

@ -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

View file

@ -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():

View file

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

View file

@ -57,10 +57,8 @@
'<div class="vdl-sec-head">' +
'<div class="vdl-sec-label">Sources</div>' +
'<span class="vdl-src-actions vdl-src-actions--head">' +
'<button class="vdl-search-all" type="button" data-vdl-search-all title="Search every source — pick releases yourself">' +
'<span class="vdl-btn-ic" aria-hidden="true">⌕</span><span>Manual all</span></button>' +
'<button class="vdl-search-all vdl-auto-all" type="button" data-vdl-auto-all title="Search every source and auto-grab the best release on each">' +
'<span class="vdl-btn-ic vdl-btn-ic--auto" aria-hidden="true">✦</span><span>Auto all</span></button>' +
'<button class="vdl-search-all vdl-auto-all" type="button" data-vdl-auto-best title="Search every source, then download the single best release for your quality profile">' +
'<span class="vdl-btn-ic vdl-btn-ic--auto" aria-hidden="true">✦</span><span>Auto</span></button>' +
'</span>' +
'</div>' +
'<div class="vdl-sources" data-vdl-sources><div class="vdl-src-empty">Loading sources…</div></div>' +
@ -89,35 +87,89 @@
'</div>';
}
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: couldnt 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).