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