From f85564a2deb8a6150350f4a783830d8f8d1211b9 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:13:26 -0700 Subject: [PATCH 01/18] Move enhancedSearchFetch, SOURCE_LABELS, renderCompactSection to shared-helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These three utilities lived inside search.js — the fetch helper at module scope, and SOURCE_LABELS plus renderCompactSection as closures inside initializeSearchModeToggle. The global search widget in downloads.js already depends on enhancedSearchFetch via global scope and re-implements the rendering inline. Hoist all three to shared-helpers.js so both surfaces share the same implementations. No behavior change — this is the refactor step that precedes the source-picker redesign. Also adds a 'soulseek' entry to SOURCE_LABELS for the upcoming icon row. --- webui/static/search.js | 142 ++----------------------------- webui/static/shared-helpers.js | 149 +++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 137 deletions(-) diff --git a/webui/static/search.js b/webui/static/search.js index 0cae5787..42f9b45a 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -1,21 +1,8 @@ // SEARCH FUNCTIONALITY // =============================== - -// Shared enhanced-search fetch used by the Search page and the global widget. -// Pass source to restrict results to a single metadata provider; omit or pass -// null/'auto' to let the backend fan out across all configured sources. -async function enhancedSearchFetch(query, { source = null, signal = null } = {}) { - const body = { query }; - if (source && source !== 'auto') body.source = source; - const res = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal: signal || undefined, - }); - if (!res.ok) throw new Error(`Enhanced search failed: ${res.status}`); - return res.json(); -} +// `enhancedSearchFetch`, `SOURCE_LABELS`, and `renderCompactSection` live in +// shared-helpers.js so the Search page and the global widget share the same +// implementations. function initializeSearch() { // --- FIX: Corrected the element IDs to match the HTML --- @@ -107,15 +94,7 @@ function initializeSearchModeToggle() { let _activeSearchSource = null; // Currently displayed source tab let _altSourceController = null; // AbortController for alternate source fetches - const SOURCE_LABELS = { - spotify: { text: 'Spotify', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify' }, - itunes: { text: 'Apple Music', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes' }, - deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' }, - discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' }, - hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' }, - youtube_videos: { text: 'Music Videos', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' }, - musicbrainz: { text: 'MusicBrainz', tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz' }, - }; + // SOURCE_LABELS now lives in shared-helpers.js. // Live search with debouncing if (enhancedInput) { @@ -808,118 +787,7 @@ function initializeSearchModeToggle() { return `${minutes}:${seconds.toString().padStart(2, '0')}`; } - function renderCompactSection(sectionId, listId, countId, items, mapItem) { - const section = document.getElementById(sectionId); - const list = document.getElementById(listId); - const count = document.getElementById(countId); - - if (!list) return; - - list.innerHTML = ''; - - if (!items || items.length === 0) { - section.classList.add('hidden'); - return; - } - - section.classList.remove('hidden'); - count.textContent = items.length; - - // Determine type based on section ID - const isArtist = sectionId.includes('artists'); - const isAlbum = sectionId.includes('albums') || sectionId.includes('singles'); - const isTrack = sectionId.includes('tracks'); - - // Add appropriate grid class to list - if (isArtist) { - list.classList.add('enh-artists-grid'); - } else if (isAlbum) { - list.classList.add('enh-albums-grid'); - } else if (isTrack) { - list.classList.add('enh-tracks-list'); - } - - items.forEach(item => { - const config = mapItem(item); - const elem = document.createElement('div'); - - // Add appropriate card class - if (isArtist) { - elem.className = 'enh-compact-item artist-card'; - // Add data attributes for lazy loading - if (item.id) { - elem.dataset.artistId = item.id; - elem.dataset.needsImage = config.image ? 'false' : 'true'; - } - } else if (isAlbum) { - elem.className = 'enh-compact-item album-card'; - } else if (isTrack) { - elem.className = 'enh-compact-item track-item'; - } - - // Build image HTML with type-specific classes - let imageClass = 'enh-item-image'; - let placeholderClass = 'enh-item-image-placeholder'; - - if (isArtist) { - imageClass += ' artist-image'; - placeholderClass += ' artist-placeholder'; - } else if (isAlbum) { - imageClass += ' album-cover'; - placeholderClass += ' album-placeholder'; - } else if (isTrack) { - imageClass += ' track-cover'; - placeholderClass += ' track-placeholder'; - } - - const imageHtml = config.image - ? `${escapeHtml(config.name)}` - : `
${config.placeholder}
`; - - const badgeHtml = config.badge - ? `
${config.badge.text}
` - : ''; - - const durationHtml = config.duration && isTrack - ? `
- ${escapeHtml(config.duration)} - -
` - : ''; - - elem.innerHTML = ` - ${imageHtml} -
-
${escapeHtml(config.name)}
-
${escapeHtml(config.meta)}
-
- ${durationHtml} - ${badgeHtml} - `; - - elem.addEventListener('click', config.onClick); - - // Add play button handler for tracks - if (isTrack && config.onPlay) { - const playBtn = elem.querySelector('.enh-item-play-btn'); - if (playBtn) { - playBtn.addEventListener('click', (e) => { - e.stopPropagation(); // Don't trigger main onClick - config.onPlay(); - }); - } - } - - list.appendChild(elem); - - // Extract colors from image for dynamic glow effect - if (config.image) { - extractImageColors(config.image, (colors) => { - applyDynamicGlow(elem, colors); - }); - } - }); - } + // renderCompactSection now lives in shared-helpers.js. async function handleEnhancedSearchAlbumClick(album) { console.log(`💿 Enhanced search album clicked: ${album.name} by ${album.artist}`); diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 312278fb..1a565973 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -12,6 +12,155 @@ // ============================================================================ +// ---------------------------------------------------------------------------- +// Enhanced search shared utilities (used by Search page + global widget) +// ---------------------------------------------------------------------------- + +// Pass source to restrict results to a single metadata provider; omit or pass +// null/'auto' to let the backend fan out across all configured sources. +async function enhancedSearchFetch(query, { source = null, signal = null } = {}) { + const body = { query }; + if (source && source !== 'auto') body.source = source; + const res = await fetch('/api/enhanced-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: signal || undefined, + }); + if (!res.ok) throw new Error(`Enhanced search failed: ${res.status}`); + return res.json(); +} + +// Per-source labels + tab/badge CSS classes. Referenced by both the Search +// page and the global search widget for consistent badge/icon rendering. +const SOURCE_LABELS = { + spotify: { text: 'Spotify', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify' }, + itunes: { text: 'Apple Music', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes' }, + deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' }, + discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' }, + hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' }, + youtube_videos: { text: 'Music Videos', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' }, + musicbrainz: { text: 'MusicBrainz', tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz' }, + soulseek: { text: 'Soulseek', tabClass: 'enh-tab-soulseek', badgeClass: 'enh-badge-soulseek' }, +}; + +// Render a single enhanced-search result section (artists / albums / tracks). +// Shared between the Search page and the global widget. The mapItem callback +// projects each backend item to the card config consumed here. +function renderCompactSection(sectionId, listId, countId, items, mapItem) { + const section = document.getElementById(sectionId); + const list = document.getElementById(listId); + const count = document.getElementById(countId); + + if (!list) return; + + list.innerHTML = ''; + + if (!items || items.length === 0) { + section.classList.add('hidden'); + return; + } + + section.classList.remove('hidden'); + count.textContent = items.length; + + // Determine type based on section ID + const isArtist = sectionId.includes('artists'); + const isAlbum = sectionId.includes('albums') || sectionId.includes('singles'); + const isTrack = sectionId.includes('tracks'); + + // Add appropriate grid class to list + if (isArtist) { + list.classList.add('enh-artists-grid'); + } else if (isAlbum) { + list.classList.add('enh-albums-grid'); + } else if (isTrack) { + list.classList.add('enh-tracks-list'); + } + + items.forEach(item => { + const config = mapItem(item); + const elem = document.createElement('div'); + + // Add appropriate card class + if (isArtist) { + elem.className = 'enh-compact-item artist-card'; + // Add data attributes for lazy loading + if (item.id) { + elem.dataset.artistId = item.id; + elem.dataset.needsImage = config.image ? 'false' : 'true'; + } + } else if (isAlbum) { + elem.className = 'enh-compact-item album-card'; + } else if (isTrack) { + elem.className = 'enh-compact-item track-item'; + } + + // Build image HTML with type-specific classes + let imageClass = 'enh-item-image'; + let placeholderClass = 'enh-item-image-placeholder'; + + if (isArtist) { + imageClass += ' artist-image'; + placeholderClass += ' artist-placeholder'; + } else if (isAlbum) { + imageClass += ' album-cover'; + placeholderClass += ' album-placeholder'; + } else if (isTrack) { + imageClass += ' track-cover'; + placeholderClass += ' track-placeholder'; + } + + const imageHtml = config.image + ? `${escapeHtml(config.name)}` + : `
${config.placeholder}
`; + + const badgeHtml = config.badge + ? `
${config.badge.text}
` + : ''; + + const durationHtml = config.duration && isTrack + ? `
+ ${escapeHtml(config.duration)} + +
` + : ''; + + elem.innerHTML = ` + ${imageHtml} +
+
${escapeHtml(config.name)}
+
${escapeHtml(config.meta)}
+
+ ${durationHtml} + ${badgeHtml} + `; + + elem.addEventListener('click', config.onClick); + + // Add play button handler for tracks + if (isTrack && config.onPlay) { + const playBtn = elem.querySelector('.enh-item-play-btn'); + if (playBtn) { + playBtn.addEventListener('click', (e) => { + e.stopPropagation(); // Don't trigger main onClick + config.onPlay(); + }); + } + } + + list.appendChild(elem); + + // Extract colors from image for dynamic glow effect + if (config.image) { + extractImageColors(config.image, (colors) => { + applyDynamicGlow(elem, colors); + }); + } + }); +} + + // ---------------------------------------------------------------------------- // Discography completion checking (for artist-detail pages, library page) // ---------------------------------------------------------------------------- From a72810ce22d22267a563680c7a155e24f26cabec Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:23:17 -0700 Subject: [PATCH 02/18] Search page: replace fan-out with source-picker icon row + per-source cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Search page previously fired a primary /api/enhanced-search request plus a fan-out loop (_queueAlternateSourceFetches / _fetchAlternateSource) that streamed NDJSON from /api/enhanced-search/source/ for every other configured source. One search = 7 API calls across Spotify, iTunes, Deezer, Discogs, Hydrabase, MusicBrainz, and YouTube Music Videos. The post-search tab bar then let users switch views between the results that had already been fetched. This changes the default to explicit per-source selection: - The old - - - - - - - - - - - - + +
@@ -2017,8 +2004,9 @@ diff --git a/webui/static/helper.js b/webui/static/helper.js index de289e17..37d89cf3 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3456,6 +3456,8 @@ const WHATS_NEW = { { title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help', unreleased: true }, { title: 'Unified Source-Picker Controller (Search Page + Global Widget)', desc: 'Internal refactor — the source picker state machine (query, active source, per-query cache, fallbacks, loading state, configured-source discovery) is now a single createSearchController factory in shared-helpers.js. Both the full Search page and the sidebar global search popover consume the same controller with per-surface wiring (DOM elements, Soulseek handoff, unconfigured-source click). About 380 lines of near-duplicated state + fetch + render code consolidated into one implementation, so a bug fix or behavior tweak to the picker lands everywhere at once. Zero UX change — every keystroke, icon click, cache hit, rate-limit fallback, and unconfigured-source redirect behaves identically to before', page: 'search', unreleased: true }, { title: 'Fix Clean Search History Automation Failing with AttributeError', desc: 'The hourly Clean Search History maintenance automation was crashing with "DownloadOrchestrator object has no attribute base_url". Root cause: the check `soulseek_client.base_url` was written before the orchestrator refactor — `soulseek_client` is now a DownloadOrchestrator that wraps individual download clients, with the real Soulseek client at `.soulseek`. Two other call sites in web_server.py already used the correct `soulseek_client.soulseek.base_url` pattern; this one was missed. Now matches the same getattr-guarded pattern and the hourly cleanup runs again', page: 'stats' }, + { title: 'Search Results Always Visible — Show/Hide Button Removed', desc: 'The "Show Results / Hide Results" toggle next to the search bar is gone. There was nothing else on the page worth seeing instead of results, so toggling visibility never made sense. Cin flagged it during PR review. Dropdown visibility is now a pure function of query state — empty input hides it, results show it', page: 'search', unreleased: true }, + { title: 'Cached Search Results Restore on Navigate-Back', desc: 'Previously, navigating away from /search via a sidebar link dismissed the dropdown (the click registered as outside-click). When you came back, the input still held your query but the results were hidden until you typed again or clicked Show Results. Now the per-query cache renders automatically when you re-enter /search, so your results are right where you left them. Cin flagged the round-trip during PR review', page: 'search', unreleased: true }, ], '2.39': [ // --- April 22, 2026 --- diff --git a/webui/static/mobile.css b/webui/static/mobile.css index bb0428c5..75ff8337 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -329,11 +329,6 @@ width: 100%; } - .enhanced-search-bar-container button { - width: 100%; - min-height: 44px; - } - .filter-group { flex-wrap: wrap; } diff --git a/webui/static/search.js b/webui/static/search.js index a9d4c28a..f9c3e9d1 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -35,11 +35,18 @@ function initializeSearch() { // =============================== let searchModeToggleInitialized = false; +// Set by the closure on first init; called by subsequent invocations to +// re-display the search dropdown from the controller's cached state. +// Solves the "results vanish on navigate-back" UX issue — a sidebar nav +// click is treated as outside-click and dismisses the dropdown, so when +// the user returns to /search we need to re-render whatever was cached. +let _searchPageRestoreOnEnter = null; function initializeSearchModeToggle() { - // Only initialize once to prevent duplicate event listeners + // Subsequent invocations: just re-display cached results so they don't + // vanish on navigate-back. Skip the duplicate-listener setup. if (searchModeToggleInitialized) { - console.log('Search mode toggle already initialized, skipping...'); + if (_searchPageRestoreOnEnter) _searchPageRestoreOnEnter(); return; } @@ -61,7 +68,6 @@ function initializeSearchModeToggle() { // the controller up with Search-page-specific DOM + callbacks. const enhancedInput = document.getElementById('enhanced-search-input'); - const enhancedSearchBtn = document.getElementById('enhanced-search-btn'); const enhancedCancelBtn = document.getElementById('enhanced-cancel-btn'); const enhancedDropdown = document.getElementById('enhanced-dropdown'); const loadingState = document.getElementById('enhanced-loading'); @@ -202,6 +208,12 @@ function initializeSearchModeToggle() { }); searchController.init(); + // Expose a re-render hook so navigate-back to /search restores cached + // results instead of leaving the dropdown hidden. + _searchPageRestoreOnEnter = () => { + if (searchController.state.query) _renderFromState(searchController.state); + }; + // Live search with debouncing if (enhancedInput) { enhancedInput.addEventListener('input', (e) => { @@ -238,35 +250,6 @@ function initializeSearchModeToggle() { }); } - if (enhancedSearchBtn) { - enhancedSearchBtn.addEventListener('click', (e) => { - // Prevent click from bubbling to document (which would close the dropdown) - e.stopPropagation(); - - // Get fresh references (in case we navigated away and back) - const dropdown = document.getElementById('enhanced-dropdown'); - const results = document.getElementById('enhanced-results-container'); - - if (!dropdown) return; - - // Toggle the dropdown visibility to show/hide previous search results - if (dropdown.classList.contains('hidden')) { - // Check if there are results to show by looking for actual content - const hasResults = results && - !results.classList.contains('hidden') && - results.children.length > 0; - - if (hasResults) { - showDropdown(); - } else { - showToast('No previous results to show. Type to search!', 'info'); - } - } else { - hideDropdown(); - } - }); - } - if (enhancedCancelBtn) { enhancedCancelBtn.addEventListener('click', () => { enhancedInput.value = ''; @@ -1017,10 +1000,7 @@ function initializeSearchModeToggle() { function showDropdown() { const dropdown = document.getElementById('enhanced-dropdown'); - if (dropdown) { - dropdown.classList.remove('hidden'); - updateToggleButtonState(); - } + if (dropdown) dropdown.classList.remove('hidden'); // Hide the page header + source picker to reclaim space const header = document.querySelector('#search-page .downloads-header'); const modeToggle = document.querySelector('.search-source-picker-container'); @@ -1032,10 +1012,7 @@ function initializeSearchModeToggle() { function hideDropdown() { const dropdown = document.getElementById('enhanced-dropdown'); - if (dropdown) { - dropdown.classList.add('hidden'); - updateToggleButtonState(); - } + if (dropdown) dropdown.classList.add('hidden'); // Restore hidden elements const header = document.querySelector('#search-page .downloads-header'); const modeToggle = document.querySelector('.search-source-picker-container'); @@ -1044,27 +1021,6 @@ function initializeSearchModeToggle() { if (modeToggle) modeToggle.classList.remove('enh-results-active-hide'); if (slskdPlaceholder) slskdPlaceholder.classList.remove('enh-results-active-hide'); } - - function updateToggleButtonState() { - // Get fresh references - const btn = document.getElementById('enhanced-search-btn'); - const dropdown = document.getElementById('enhanced-dropdown'); - - if (!btn || !dropdown) return; - - const btnIcon = btn.querySelector('.btn-icon'); - const btnText = btn.querySelector('.btn-text'); - - if (dropdown.classList.contains('hidden')) { - // Dropdown is hidden - button should say "Show Results" - if (btnIcon) btnIcon.textContent = '👁️'; - if (btnText) btnText.textContent = 'Show Results'; - } else { - // Dropdown is visible - button should say "Hide Results" - if (btnIcon) btnIcon.textContent = '🙈'; - if (btnText) btnText.textContent = 'Hide Results'; - } - } } async function performSearch() { diff --git a/webui/static/style.css b/webui/static/style.css index c7743e65..fedf54cd 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -33338,38 +33338,6 @@ div.artist-hero-badge { color: #fff; } -.enhanced-search-btn { - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 10px; - padding: 10px 20px; - color: rgba(255, 255, 255, 0.8); - font-family: 'Segoe UI', sans-serif; - font-size: 13px; - font-weight: 600; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - transition: all 0.2s ease; - flex-shrink: 0; -} - -.enhanced-search-btn:hover { - background: rgba(255, 255, 255, 0.12); - color: #fff; - transform: none; - box-shadow: none; -} - -.enhanced-search-btn:active { - transform: scale(0.97); -} - -.btn-icon { - font-size: 14px; -} - /* Enhanced Search Status */ .enhanced-search-status { display: flex; @@ -33576,12 +33544,6 @@ div.artist-hero-badge { gap: 8px; } - .enhanced-search-btn { - width: 100%; - justify-content: center; - padding: 10px 16px; - } - #enhanced-search-input { font-size: 14px; } @@ -33602,11 +33564,6 @@ div.artist-hero-badge { margin-right: 6px; } - .enhanced-search-btn { - padding: 9px 14px; - font-size: 12px; - } - /* Better album/track results on mobile */ .album-result-item { margin-bottom: 8px; From ab7aeb302c626299ac7273e4187d92e29b2cb35a Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:17:33 -0700 Subject: [PATCH 15/18] Defer search-restore render so it survives nav-button click bubble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navigate-back fix from the previous commit was being immediately undone by the document outside-click handler. Race: 1. Click on sidebar nav-button → button handler runs synchronously, eventually calling _searchPageRestoreOnEnter → _renderFromState → showDropdown removes `hidden` class 2. Click event bubbles up to document 3. Document outside-click handler sees dropdown is now visible, sees the click target is a nav-button (not inside the search wrapper or the source row), calls hideDropdown → instantly hidden again Fix: defer the _renderFromState call to setTimeout(0). The macrotask runs AFTER the click event finishes propagating, so by the time the dropdown becomes visible, the document outside-click handler has already short-circuited (it saw the dropdown still hidden). User reported having to delete + retype the last character of the query to force a re-render — which worked because the input event listener fires submitQuery, which routes through the controller without going through the deferred path. --- webui/static/search.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/webui/static/search.js b/webui/static/search.js index f9c3e9d1..e8d93f52 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -209,9 +209,13 @@ function initializeSearchModeToggle() { searchController.init(); // Expose a re-render hook so navigate-back to /search restores cached - // results instead of leaving the dropdown hidden. + // results instead of leaving the dropdown hidden. Deferred to the next + // tick so the render happens AFTER the nav-button click finishes + // bubbling to the document outside-click handler — otherwise that + // handler sees the just-shown dropdown and immediately dismisses it. _searchPageRestoreOnEnter = () => { - if (searchController.state.query) _renderFromState(searchController.state); + if (!searchController.state.query) return; + setTimeout(() => _renderFromState(searchController.state), 0); }; // Live search with debouncing From 005c6ad73aef34c166b7b576a3a103f3599202ac Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:24:46 -0700 Subject: [PATCH 16/18] Fix Soulseek handoff routing + stale-request flash on fast retype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two AI-review findings from Cin (kettui) on the source-picker PR: 1. Soulseek handoff from global widget went through metadata flow _gsNavigateToSearchPage(query, 'soulseek') wrote the query into #enhanced-search-input and dispatched an input event. The Search page controller's activeSource was whatever its default was (spotify, deezer, etc.), so the debounced submitQuery ran the enhanced /api/enhanced-search flow instead of the raw Soulseek file search. The `src` parameter was effectively ignored. Fix: when src === 'soulseek', pre-fill #downloads-search-input directly and click the Search page's Soulseek icon. The icon click triggers the controller's onSoulseekSelected callback, which owns the section swap and re-runs performDownloadsSearch against the value we just wrote to the basic input. 2. Stale in-flight requests cleared loadingSources after fast retype createSearchController._fetchSource awaits the fetch result, then unconditionally mutates state.loadingSources / state.sources in the settle and catch blocks. When a user typed "abc" → fetch started → typed "abcd" before the first fetch returned, the second submitQuery aborted the first fetch and started its own. The first fetch's catch (AbortError) then ran and cleared loadingSources for that source — wiping the spinner the new request had just set, and causing a brief flash of empty/error state while the new fetch was still in flight. Fix: monotonic _requestSeq token. Each _fetchSource call captures the next value (++_requestSeq). Settle / catch blocks (and the YouTube NDJSON streaming loop) bail before mutating shared state if requestId !== _requestSeq. Existing abortCtrl behavior unchanged — this is a layered defense for the catch-clobber pattern that abort alone can't prevent. --- webui/static/downloads.js | 20 +++++++++++++++++--- webui/static/helper.js | 2 ++ webui/static/shared-helpers.js | 26 ++++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 75e1607d..2ad5aa49 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -5181,10 +5181,24 @@ function _gsNavigateToSearchPage(query, src) { _gsDeactivate(); if (typeof navigateToPage !== 'function') return; navigateToPage('search'); - // After the page mounts, mirror the query into the enhanced input so the - // user doesn't have to retype it. The Search page's source picker will - // pick up `src` via its own default-source flow. + // After the page mounts, mirror the query into whichever input drives the + // requested source. Soulseek goes through the basic-search file flow, not + // the enhanced metadata flow — without this branch the Search page would + // run /api/enhanced-search instead of /api/search and the user would get + // metadata results when they clicked the Soulseek icon. setTimeout(() => { + if (src === 'soulseek') { + // Pre-fill the basic input first, then click the Search page's + // Soulseek icon. The icon's click triggers the controller's + // onSoulseekSelected callback, which owns the section swap and + // re-runs performDownloadsSearch with whatever's in the basic + // input (i.e., the value we just wrote). + const basicInput = document.getElementById('downloads-search-input'); + if (basicInput && query) basicInput.value = query; + const soulseekIcon = document.querySelector('#enh-source-row [data-source="soulseek"]'); + if (soulseekIcon) soulseekIcon.click(); + return; + } const input = document.getElementById('enhanced-search-input'); if (input && query) { input.value = query; diff --git a/webui/static/helper.js b/webui/static/helper.js index 37d89cf3..7d46ddce 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3458,6 +3458,8 @@ const WHATS_NEW = { { title: 'Fix Clean Search History Automation Failing with AttributeError', desc: 'The hourly Clean Search History maintenance automation was crashing with "DownloadOrchestrator object has no attribute base_url". Root cause: the check `soulseek_client.base_url` was written before the orchestrator refactor — `soulseek_client` is now a DownloadOrchestrator that wraps individual download clients, with the real Soulseek client at `.soulseek`. Two other call sites in web_server.py already used the correct `soulseek_client.soulseek.base_url` pattern; this one was missed. Now matches the same getattr-guarded pattern and the hourly cleanup runs again', page: 'stats' }, { title: 'Search Results Always Visible — Show/Hide Button Removed', desc: 'The "Show Results / Hide Results" toggle next to the search bar is gone. There was nothing else on the page worth seeing instead of results, so toggling visibility never made sense. Cin flagged it during PR review. Dropdown visibility is now a pure function of query state — empty input hides it, results show it', page: 'search', unreleased: true }, { title: 'Cached Search Results Restore on Navigate-Back', desc: 'Previously, navigating away from /search via a sidebar link dismissed the dropdown (the click registered as outside-click). When you came back, the input still held your query but the results were hidden until you typed again or clicked Show Results. Now the per-query cache renders automatically when you re-enter /search, so your results are right where you left them. Cin flagged the round-trip during PR review', page: 'search', unreleased: true }, + { title: 'Fix Soulseek Handoff from Global Search Going Through Metadata Flow', desc: 'When you clicked the Soulseek icon in the sidebar global search popover, it navigated to /search and wrote the query into the enhanced-search input — which then ran the metadata flow against whatever your default source was (Spotify, Deezer, etc.) instead of the raw Soulseek file search you actually wanted. Cin flagged it during PR review. Now the handoff pre-fills the basic-search input directly and clicks the Search page\'s Soulseek icon so the controller\'s onSoulseekSelected callback owns the section swap and runs performDownloadsSearch with the right query', page: 'search', unreleased: true }, + { title: 'Stale Search Requests No Longer Flash Empty Results on Fast Retype', desc: 'Cin flagged a race in createSearchController: when you typed a query then quickly re-typed before the first fetch returned, the first fetch\'s catch block (firing on AbortError after the second submitQuery aborted it) cleared loadingSources and notified the UI, causing a brief flash of empty/error state while the new query\'s fetch was still mid-flight. Added a monotonic _requestSeq token — each fetch captures the next value, and stale completions bail before mutating shared state. The controller still aborts in-flight fetches on supersession; this just keeps the abort-cleanup of the old request from clobbering the new one\'s spinner', page: 'search', unreleased: true }, ], '2.39': [ // --- April 22, 2026 --- diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 80ac7ecf..ece7efca 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -172,6 +172,13 @@ function createSearchController({ for (const src of SOURCE_ORDER) state.configuredSources[src] = true; let abortCtrl = null; + // Monotonic request token. Each _fetchSource call captures the next + // value; settle/error blocks bail before mutating shared state if a + // newer request has superseded them. Without this, a fast retype lets + // the in-flight fetch's catch (or settle) clear loadingSources / write + // stale data into state.sources, causing a flash of empty/error UI + // while the new query's fetch is still running. + let _requestSeq = 0; function _notify() { if (onStateChange) onStateChange(state); } @@ -305,6 +312,8 @@ function createSearchController({ const query = state.query; if (!query) return; + const requestId = ++_requestSeq; + state.loadingSources.add(src); renderSourceRow(); _notify(); @@ -314,12 +323,14 @@ function createSearchController({ try { if (src === 'youtube_videos') { - await _fetchYouTubeVideos(query, abortCtrl.signal); + await _fetchYouTubeVideos(query, abortCtrl.signal, requestId); } else { const data = await enhancedSearchFetch(query, { source: src, signal: abortCtrl.signal, }); + // Bail without writing if a newer query has superseded us. + if (requestId !== _requestSeq) return; state.sources[src] = { artists: data.spotify_artists || [], albums: data.spotify_albums || [], @@ -331,10 +342,15 @@ function createSearchController({ if (served && served !== src) state.fallbacks[src] = served; } + // Only the latest request gets to clear loadingSources + notify. + // A stale completion would otherwise wipe the spinner the new + // request just set. + if (requestId !== _requestSeq) return; state.loadingSources.delete(src); renderSourceRow(); _notify(); } catch (err) { + if (requestId !== _requestSeq) return; state.loadingSources.delete(src); renderSourceRow(); _notify(); @@ -344,7 +360,7 @@ function createSearchController({ } } - async function _fetchYouTubeVideos(query, signal) { + async function _fetchYouTubeVideos(query, signal, requestId) { const res = await fetch('/api/enhanced-search/source/youtube_videos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -353,6 +369,9 @@ function createSearchController({ }); if (!res.ok) throw new Error(`YouTube search failed: ${res.status}`); + // Bail before allocating cache entry if superseded by a newer request. + if (requestId !== _requestSeq) return; + state.sources['youtube_videos'] = { artists: [], albums: [], tracks: [], videos: [], db_artists: [], }; @@ -364,6 +383,9 @@ function createSearchController({ while (true) { const { done, value } = await reader.read(); if (done) break; + // Mid-stream supersession check — abort cleanly without writing + // additional chunks into stale cache. + if (requestId !== _requestSeq) return; buffer += decoder.decode(value, { stream: true }); let idx; while ((idx = buffer.indexOf('\n')) !== -1) { From 325292ce5a167100513f7663b672eb4a3c7b16fe Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:28:47 -0700 Subject: [PATCH 17/18] Treat Soulseek as configurable in source picker (require slskd_url) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cin flagged that Soulseek was always rendered as configured in the source picker, even on dev instances with no slskd set up — letting users click it and fire searches that could never succeed. Three coordinated changes: 1. web_server.py SERVICE_CONFIG_REGISTRY: add Soulseek entry requiring `slskd_url`. /api/settings/config-status now reports its real state alongside every other service. 2. shared-helpers.js _ALWAYS_CONFIGURED_SOURCES: drop 'soulseek'. The set is now just MusicBrainz + YouTube Music Videos (sources that genuinely don't need user creds). Soulseek goes through the normal config-status code path. 3. shared-helpers.js openSettingsForSource: special-case Soulseek to route to Settings → Downloads tab (where slskd URL field lives, gated behind the download-source-mode dropdown) and scroll to the #soulseek-url input. Every other source still routes to Connections and scrolls to its .stg-service card. Without this, Soulseek's "click to configure" landed on a Connections card that doesn't exist (Soulseek's URL/key fields are scoped to the download-source selection on the Downloads tab). --- web_server.py | 6 ++++++ webui/static/helper.js | 1 + webui/static/shared-helpers.js | 31 ++++++++++++++++++++++--------- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/web_server.py b/web_server.py index aa4ee8ea..c835715d 100644 --- a/web_server.py +++ b/web_server.py @@ -4455,6 +4455,12 @@ SERVICE_CONFIG_REGISTRY = { 'acoustid': {'required': ['api_key']}, 'listenbrainz': {'required': ['token']}, 'hydrabase': {'required': ['url', 'api_key']}, + # Soulseek (slskd) needs a base URL. Used by the search source picker + # to dim Soulseek and redirect to Settings when the user has no slskd + # configured — clicking it would otherwise fire searches that always + # fail. URL field lives on Settings → Downloads, gated behind the + # download-source-mode dropdown. + 'soulseek': {'required': ['slskd_url']}, } diff --git a/webui/static/helper.js b/webui/static/helper.js index 7d46ddce..411bbe1b 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3460,6 +3460,7 @@ const WHATS_NEW = { { title: 'Cached Search Results Restore on Navigate-Back', desc: 'Previously, navigating away from /search via a sidebar link dismissed the dropdown (the click registered as outside-click). When you came back, the input still held your query but the results were hidden until you typed again or clicked Show Results. Now the per-query cache renders automatically when you re-enter /search, so your results are right where you left them. Cin flagged the round-trip during PR review', page: 'search', unreleased: true }, { title: 'Fix Soulseek Handoff from Global Search Going Through Metadata Flow', desc: 'When you clicked the Soulseek icon in the sidebar global search popover, it navigated to /search and wrote the query into the enhanced-search input — which then ran the metadata flow against whatever your default source was (Spotify, Deezer, etc.) instead of the raw Soulseek file search you actually wanted. Cin flagged it during PR review. Now the handoff pre-fills the basic-search input directly and clicks the Search page\'s Soulseek icon so the controller\'s onSoulseekSelected callback owns the section swap and runs performDownloadsSearch with the right query', page: 'search', unreleased: true }, { title: 'Stale Search Requests No Longer Flash Empty Results on Fast Retype', desc: 'Cin flagged a race in createSearchController: when you typed a query then quickly re-typed before the first fetch returned, the first fetch\'s catch block (firing on AbortError after the second submitQuery aborted it) cleared loadingSources and notified the UI, causing a brief flash of empty/error state while the new query\'s fetch was still mid-flight. Added a monotonic _requestSeq token — each fetch captures the next value, and stale completions bail before mutating shared state. The controller still aborts in-flight fetches on supersession; this just keeps the abort-cleanup of the old request from clobbering the new one\'s spinner', page: 'search', unreleased: true }, + { title: 'Source Picker Dims Soulseek When slskd Isn\'t Configured', desc: 'Cin pointed out that the Soulseek icon was always rendered as configured, so users without slskd set up could click it and fire searches that would never succeed. Soulseek is now in the backend config-status registry as `required: [slskd_url]` and removed from the frontend\'s always-configured set. Without slskd, the icon dims and clicking it routes to Settings → Downloads tab (where the slskd URL field lives, gated behind the download-source dropdown) instead of Settings → Connections', page: 'search', unreleased: true }, ], '2.39': [ // --- April 22, 2026 --- diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index ece7efca..d9206ec8 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -87,7 +87,10 @@ const SOURCE_ORDER = [ // Sources the config-status endpoint doesn't cover because they don't need // user-supplied credentials — they always render as "configured" in the picker. -const _ALWAYS_CONFIGURED_SOURCES = new Set(['musicbrainz', 'youtube_videos', 'soulseek']); +// Soulseek IS configurable (needs slskd URL), so it's intentionally not here: +// /api/settings/config-status reports its real state and the picker dims it +// when no slskd is set up, redirecting clicks to Settings → Downloads. +const _ALWAYS_CONFIGURED_SOURCES = new Set(['musicbrainz', 'youtube_videos']); // Fetch /api/settings/config-status and return a map { src -> bool } // covering every source in SOURCE_ORDER. Sources not present in the backend @@ -438,22 +441,32 @@ function createSearchController({ } -// Navigate to Settings → Connections tab and scroll to the service card that +// Navigate to Settings → relevant tab and scroll to the service card that // matches the picker's source id. Called when a user clicks an unconfigured -// source icon. +// source icon. Soulseek is special-cased to land on the Downloads tab where +// its slskd URL field lives (gated behind the download-source-mode select); +// every other source has a card on Connections. function openSettingsForSource(src) { if (typeof navigateToPage !== 'function') return; navigateToPage('settings'); + const targetTab = src === 'soulseek' ? 'downloads' : 'connections'; setTimeout(() => { try { - if (typeof switchSettingsTab === 'function') switchSettingsTab('connections'); + if (typeof switchSettingsTab === 'function') switchSettingsTab(targetTab); } catch (_) { /* best-effort */ } setTimeout(() => { - const card = document.querySelector(`#settings-page .stg-service[data-service="${src}"]`); - if (card) { - card.scrollIntoView({ behavior: 'smooth', block: 'center' }); - card.classList.add('stg-service-flash'); - setTimeout(() => card.classList.remove('stg-service-flash'), 2200); + // Soulseek doesn't have a .stg-service card — scroll to the + // slskd URL input instead so the user lands on the right field. + const target = src === 'soulseek' + ? document.querySelector('#settings-page #soulseek-url') + : document.querySelector(`#settings-page .stg-service[data-service="${src}"]`); + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if (src === 'soulseek') { + try { target.focus(); } catch (_) { /* best-effort */ } + } else { + target.classList.add('stg-service-flash'); + setTimeout(() => target.classList.remove('stg-service-flash'), 2200); } }, 120); }, 60); From 527b51d69bd3991c8f9de4f172eee99e474a791c Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:37:07 -0700 Subject: [PATCH 18/18] Tighten Soulseek handoff + per-source request tokens after self-audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the previous review-fix commits, found during a Cin-standard re-audit: A) Soulseek handoff stale state.query overrode the global widget's query The previous fix pre-set basicInput.value before clicking the Search page's Soulseek icon. But the click triggers onSoulseekSelected with the controller's CURRENT state.query — which is whatever the user last typed on /search, not the global widget's query. The Search page callback then ran `if (query) basicInput.value = query;` and overwrote the just-set value with the stale one before firing performDownloadsSearch. Fix: expose searchController as `_searchPageController` (mirrors `_searchPageRestoreOnEnter` already at module scope). Global widget's _gsNavigateToSearchPage syncs `_searchPageController.state.query` to its own query before clicking the icon. Also added a fallback for the case where the icon doesn't exist yet (controller still mid-init): swap sections + run performDownloadsSearch directly. B) Single _requestSeq token leaked loadingSources across sources The earlier "stale request" fix used one global _requestSeq. But when the user switched Spotify → Deezer mid-fetch, the Spotify abort's catch block bailed (1 !== 2), leaving 'spotify' in loadingSources forever — permanent spinner on the Spotify icon even though no fetch was running for it. Fix: per-source `_sourceRequestIds[src]` map. Same-source supersession bails (correct), cross-source supersession still clears the old source's loadingSources entry (correct). Bonus defensive: submitQuery now invalidates every per-source token and aborts the in-flight fetch when the query string changes. Catches the residual edge case where user clears the input — the in-flight fetch's settle would otherwise write stale data into the just-cleared state.sources. --- webui/static/downloads.js | 32 +++++++++++++---- webui/static/search.js | 6 ++++ webui/static/shared-helpers.js | 64 ++++++++++++++++++++++------------ 3 files changed, 74 insertions(+), 28 deletions(-) diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 2ad5aa49..ce20e659 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -5188,15 +5188,35 @@ function _gsNavigateToSearchPage(query, src) { // metadata results when they clicked the Soulseek icon. setTimeout(() => { if (src === 'soulseek') { - // Pre-fill the basic input first, then click the Search page's - // Soulseek icon. The icon's click triggers the controller's - // onSoulseekSelected callback, which owns the section swap and - // re-runs performDownloadsSearch with whatever's in the basic - // input (i.e., the value we just wrote). const basicInput = document.getElementById('downloads-search-input'); if (basicInput && query) basicInput.value = query; + + // Sync the Search page controller's state.query to the widget's + // query BEFORE clicking the Soulseek icon. Otherwise the icon + // click fires onSoulseekSelected(state.query) where state.query + // is whatever the user last typed on /search (often stale), and + // the callback would overwrite basicInput.value with that stale + // value before running performDownloadsSearch. + if (typeof _searchPageController !== 'undefined' && _searchPageController) { + _searchPageController.state.query = query || ''; + } + const soulseekIcon = document.querySelector('#enh-source-row [data-source="soulseek"]'); - if (soulseekIcon) soulseekIcon.click(); + if (soulseekIcon) { + soulseekIcon.click(); + return; + } + // Fallback: controller hasn't initialized yet (slow /api/settings + // fetches at first /search visit). Run the search directly + swap + // sections so the user still gets results. Icon row will catch up + // visually on the next render. + const basicSection = document.getElementById('basic-search-section'); + const enhancedSection = document.getElementById('enhanced-search-section'); + if (basicSection) basicSection.classList.add('active'); + if (enhancedSection) enhancedSection.classList.remove('active'); + if (basicInput && basicInput.value && typeof performDownloadsSearch === 'function') { + performDownloadsSearch(); + } return; } const input = document.getElementById('enhanced-search-input'); diff --git a/webui/static/search.js b/webui/static/search.js index e8d93f52..4d47436e 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -41,6 +41,11 @@ let searchModeToggleInitialized = false; // click is treated as outside-click and dismisses the dropdown, so when // the user returns to /search we need to re-render whatever was cached. let _searchPageRestoreOnEnter = null; +// Exposed so the global-search widget's Soulseek handoff can sync the +// controller's state.query to the widget's query before clicking the +// Soulseek icon — otherwise onSoulseekSelected fires with whatever the +// user last typed on /search and overwrites the basic input. +let _searchPageController = null; function initializeSearchModeToggle() { // Subsequent invocations: just re-display cached results so they don't @@ -207,6 +212,7 @@ function initializeSearchModeToggle() { }, }); searchController.init(); + _searchPageController = searchController; // Expose a re-render hook so navigate-back to /search restores cached // results instead of leaving the dropdown hidden. Deferred to the next diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index d9206ec8..660ef01c 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -175,13 +175,20 @@ function createSearchController({ for (const src of SOURCE_ORDER) state.configuredSources[src] = true; let abortCtrl = null; - // Monotonic request token. Each _fetchSource call captures the next - // value; settle/error blocks bail before mutating shared state if a - // newer request has superseded them. Without this, a fast retype lets - // the in-flight fetch's catch (or settle) clear loadingSources / write - // stale data into state.sources, causing a flash of empty/error UI - // while the new query's fetch is still running. + // Per-source request tokens. Each _fetchSource call increments the + // monotonic _requestSeq and stamps it into _sourceRequestIds[src]. + // Settle/error blocks bail before mutating shared state if their + // requestId no longer matches the latest id for THAT source — + // protecting against the fast-retype race (same-source supersession) + // without dropping cleanup for cross-source supersession. + // + // A single global token would mishandle cross-source: switching + // Spotify → Deezer aborts Spotify's fetch, but Spotify's catch needs + // to clear 'spotify' from loadingSources (Deezer's request hasn't + // touched it). Per-source tracking lets each source's catch own its + // own loadingSources entry. let _requestSeq = 0; + const _sourceRequestIds = Object.create(null); function _notify() { if (onStateChange) onStateChange(state); } @@ -316,6 +323,7 @@ function createSearchController({ if (!query) return; const requestId = ++_requestSeq; + _sourceRequestIds[src] = requestId; state.loadingSources.add(src); renderSourceRow(); @@ -332,8 +340,11 @@ function createSearchController({ source: src, signal: abortCtrl.signal, }); - // Bail without writing if a newer query has superseded us. - if (requestId !== _requestSeq) return; + // Bail without writing if a newer request for THIS source + // has superseded us. Cross-source supersession (different + // src entirely) is handled by the loadingSources cleanup + // below — each source's catch owns its own entry. + if (_sourceRequestIds[src] !== requestId) return; state.sources[src] = { artists: data.spotify_artists || [], albums: data.spotify_albums || [], @@ -345,18 +356,20 @@ function createSearchController({ if (served && served !== src) state.fallbacks[src] = served; } - // Only the latest request gets to clear loadingSources + notify. - // A stale completion would otherwise wipe the spinner the new - // request just set. - if (requestId !== _requestSeq) return; + if (_sourceRequestIds[src] !== requestId) return; state.loadingSources.delete(src); renderSourceRow(); _notify(); } catch (err) { - if (requestId !== _requestSeq) return; - state.loadingSources.delete(src); - renderSourceRow(); - _notify(); + // Only clear loadingSources if no newer request for THIS source + // is in flight. Cross-source supersession (e.g. user switched + // Spotify → Deezer) still falls through here so Spotify's + // spinner gets cleared on its own AbortError. + if (_sourceRequestIds[src] === requestId) { + state.loadingSources.delete(src); + renderSourceRow(); + _notify(); + } if (err.name !== 'AbortError') { console.debug(`Source fetch failed for ${src}:`, err); } @@ -372,8 +385,9 @@ function createSearchController({ }); if (!res.ok) throw new Error(`YouTube search failed: ${res.status}`); - // Bail before allocating cache entry if superseded by a newer request. - if (requestId !== _requestSeq) return; + // Bail before allocating cache entry if a newer YouTube request + // has superseded us. + if (_sourceRequestIds['youtube_videos'] !== requestId) return; state.sources['youtube_videos'] = { artists: [], albums: [], tracks: [], videos: [], db_artists: [], @@ -386,9 +400,7 @@ function createSearchController({ while (true) { const { done, value } = await reader.read(); if (done) break; - // Mid-stream supersession check — abort cleanly without writing - // additional chunks into stale cache. - if (requestId !== _requestSeq) return; + if (_sourceRequestIds['youtube_videos'] !== requestId) return; buffer += decoder.decode(value, { stream: true }); let idx; while ((idx = buffer.indexOf('\n')) !== -1) { @@ -399,7 +411,6 @@ function createSearchController({ const chunk = JSON.parse(line); if (chunk.type === 'videos') { cache.videos = chunk.data; - // Live-render if still the active source. if (state.activeSource === 'youtube_videos') _notify(); } } catch (_) { /* best-effort NDJSON parse */ } @@ -413,6 +424,15 @@ function createSearchController({ state.sources = {}; state.fallbacks = {}; state.loadingSources = new Set(); + // Invalidate every in-flight per-source token. Without this, a + // settle that arrives AFTER a query reset (e.g. user typed 'a', + // fetch started, then user cleared the input) would still + // pass the per-source token check and write stale data back + // into the just-cleared state.sources. Setting fresh tokens + // when each new _fetchSource fires re-stamps as needed. + for (const k in _sourceRequestIds) delete _sourceRequestIds[k]; + // Abort the active fetch — its results are useless now. + if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; } renderSourceRow(); }