From 9ddfcf254fc485f9b363e652c47f05f66b58ac54 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:29:53 -0700 Subject: [PATCH] Global search widget: same source-picker icon row + per-source cache Matches the Search page redesign so both surfaces behave identically. The sidebar popover previously always fan-out-fetched all sources on every keystroke (via _gsFetchSourceStream streaming NDJSON for every alternate) and exposed a post-search tab bar to switch views. Now: - The popover renders an always-visible source icon row at the top, one icon per source (Spotify, iTunes, Deezer, Discogs, Hydrabase, MusicBrainz, Music Videos, Soulseek). - Typing fetches only the currently-selected source. No fan-out. - Clicking a different icon: cache hit -> instant re-render; cache miss -> single-source fetch + render. - Per-query cache cleared on query change; cache dots on icons show which sources already have results for the current query. - Default active source read from /api/settings (metadata.fallback_source) on first focus; falls back to Spotify. - Fallback banner shown when the backend served a different source than the one clicked (rate-limit auto-fallback). - Soulseek icon click navigates to /search with the query pre-filled, since the raw file list doesn't fit the popover. The Search page takes over rendering from there. Gone: _gsFetchSourceStream (fan-out), _gsRenderTabs, _gsSwitchSource, _gsState.altAbortCtrl, per-section _loading sets. Added: _gsInitDefaultSource, _gsFetchSource, _gsFetchYouTubeVideos, _gsSourceRowHtml, _gsFallbackBannerHtml, _gsSetActiveSource, _gsNavigateToSearchPage. --- webui/static/downloads.js | 382 +++++++++++++++++++++++--------------- webui/static/style.css | 85 +++++++++ 2 files changed, 318 insertions(+), 149 deletions(-) diff --git a/webui/static/downloads.js b/webui/static/downloads.js index f13bfb2e..5ae02166 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -5016,12 +5016,15 @@ function _gsClickVideo(cardEl) { const _gsState = { active: false, query: '', - data: null, - sources: {}, - activeSource: null, + data: null, // most recent payload {db_artists: [...]} — used as library baseline + activeSource: 'spotify', // current source icon (seeded from /api/settings on init) + // Per-query cache for all sources. Cleared whenever `query` changes. + sources: {}, // src -> {artists, albums, tracks, videos, db_artists, ...} + fallbacks: {}, // src -> actual source served when backend fell back (rate-limit) + loadingSources: new Set(), abortCtrl: null, - altAbortCtrl: null, debounceTimer: null, + _defaultSourceResolved: false, }; (function initGlobalSearch() { @@ -5039,7 +5042,9 @@ const _gsState = { _gsState.active = true; const shortcut = document.getElementById('gsearch-shortcut'); if (shortcut) shortcut.style.display = 'none'; - if (_gsState.data && _gsState.query) _gsShowResults(); + // Always redraw on focus so the source icon row is current + // (cache dots, active state, etc.). + _gsInitDefaultSource().then(() => _gsRender()); }); // No blur handler — closing is handled by click-outside and Escape only @@ -5143,113 +5148,218 @@ function _gsShowResults() { if (r && r.innerHTML.trim()) r.classList.add('visible'); } +async function _gsInitDefaultSource() { + if (_gsState._defaultSourceResolved) return; + _gsState._defaultSourceResolved = true; + try { + const resp = await fetch('/api/settings'); + if (resp.ok) { + const settings = await resp.json(); + const cfg = settings.metadata && settings.metadata.fallback_source; + if (cfg && SOURCE_LABELS[cfg]) _gsState.activeSource = cfg; + } + } catch (_) { /* best-effort */ } + if (!SOURCE_LABELS[_gsState.activeSource]) _gsState.activeSource = 'spotify'; +} + async function _gsPerformSearch(query) { - if (_gsState.abortCtrl) _gsState.abortCtrl.abort(); - if (_gsState.altAbortCtrl) _gsState.altAbortCtrl.abort(); - _gsState.abortCtrl = new AbortController(); - _gsState.altAbortCtrl = new AbortController(); + await _gsInitDefaultSource(); - const results = document.getElementById('gsearch-results'); - if (!results) return; - - results.innerHTML = '
Searching...
'; - results.classList.add('visible'); - - try { - const data = await enhancedSearchFetch(query, { signal: _gsState.abortCtrl.signal }); - _gsState.data = data; - _gsState.activeSource = data.primary_source || 'spotify'; + // Query changed? Wipe the per-query cache so we never serve stale results + // across different queries. + if (query !== _gsState.query) { + _gsState.query = query; _gsState.sources = {}; - _gsState.sources[_gsState.activeSource] = { - artists: data.spotify_artists || [], - albums: data.spotify_albums || [], - tracks: data.spotify_tracks || [], - }; + _gsState.fallbacks = {}; + _gsState.loadingSources = new Set(); + } - _gsRender(data); + // Render current state immediately (icon row + loading/empty message) + _gsRender(); - // Async library ownership check — adds badges + swaps play buttons for library tracks + // Soulseek needs a full page to render raw file results — hand off to /search. + if (_gsState.activeSource === 'soulseek') { + _gsNavigateToSearchPage(query, 'soulseek'); + return; + } + + // Cache hit? No fetch needed. + if (_gsState.sources[_gsState.activeSource]) { setTimeout(() => _gsLibraryCheck(), 200); - - // Fetch alternate sources — stream NDJSON so slow sources render incrementally - const alts = data.alternate_sources || []; - for (const src of alts) { - if (src === _gsState.activeSource) continue; - _gsFetchSourceStream(src, query); - } - } catch (e) { - if (e.name !== 'AbortError') results.innerHTML = '
Search failed
'; + return; } + + await _gsFetchSource(_gsState.activeSource); } -async function _gsFetchSourceStream(src, query) { +async function _gsFetchSource(src) { + const query = _gsState.query; + if (!query) return; + + _gsState.loadingSources.add(src); + _gsRender(); + + if (_gsState.abortCtrl) _gsState.abortCtrl.abort(); + _gsState.abortCtrl = new AbortController(); + try { - const res = await fetch(`/api/enhanced-search/source/${src}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - signal: _gsState.altAbortCtrl.signal, - }); - if (!res.ok) return; - - if (!_gsState.sources[src]) { - const loadingSet = src === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']); - _gsState.sources[src] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet }; + if (src === 'youtube_videos') { + await _gsFetchYouTubeVideos(query, _gsState.abortCtrl.signal); + } else { + const data = await enhancedSearchFetch(query, { + source: src, + signal: _gsState.abortCtrl.signal, + }); + _gsState.sources[src] = { + artists: data.spotify_artists || [], + albums: data.spotify_albums || [], + tracks: data.spotify_tracks || [], + videos: [], + db_artists: data.db_artists || [], + }; + _gsState.data = data; // baseline for library-check helper + const served = data.primary_source || data.metadata_source; + if (served && served !== src) _gsState.fallbacks[src] = served; } - const sourceData = _gsState.sources[src]; - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; + _gsState.loadingSources.delete(src); + _gsRender(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - let idx; - while ((idx = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, idx).trim(); - buffer = buffer.slice(idx + 1); - if (!line) continue; - try { - const chunk = JSON.parse(line); - if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); } - else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); } - else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); } - else if (chunk.type === 'videos') { sourceData.videos = chunk.data; if (sourceData._loading) sourceData._loading.delete('videos'); } - if (chunk.type === 'done') delete sourceData._loading; - _gsRenderTabs(); - // Re-render content if this is the active source tab - if (_gsState.activeSource === src && _gsState.data) { - _gsRender(_gsState.data); - } - } catch (e) { } - } + if (_gsState.activeSource === src) { + setTimeout(() => _gsLibraryCheck(), 200); } - _gsRenderTabs(); - } catch (e) { - if (e.name !== 'AbortError') console.debug(`GS alt source ${src} failed:`, e); + } catch (err) { + _gsState.loadingSources.delete(src); + _gsRender(); + if (err.name !== 'AbortError') console.debug(`GS source ${src} failed:`, err); } } -function _gsRender(data) { +async function _gsFetchYouTubeVideos(query, signal) { + const res = await fetch('/api/enhanced-search/source/youtube_videos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + signal, + }); + if (!res.ok) throw new Error('YouTube search failed'); + + _gsState.sources['youtube_videos'] = { + artists: [], albums: [], tracks: [], videos: [], db_artists: [], + }; + const cache = _gsState.sources['youtube_videos']; + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx; + while ((idx = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + try { + const chunk = JSON.parse(line); + if (chunk.type === 'videos') { + cache.videos = chunk.data; + if (_gsState.activeSource === 'youtube_videos') _gsRender(); + } + } catch (_) { /* best-effort */ } + } + } +} + +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. + setTimeout(() => { + const input = document.getElementById('enhanced-search-input'); + if (input && query) { + input.value = query; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + }, 300); +} + +function _gsSourceRowHtml() { + return '
' + SOURCE_ORDER.map(src => { + const info = SOURCE_LABELS[src]; + if (!info) return ''; + const active = src === _gsState.activeSource; + const cached = !!_gsState.sources[src]; + const loading = _gsState.loadingSources.has(src); + const fallback = _gsState.fallbacks[src]; + const classes = ['gsearch-source-icon', + active ? 'active' : '', + cached ? 'cached' : '', + loading ? 'loading' : '', + fallback ? 'fallback-warning' : ''].filter(Boolean).join(' '); + const title = fallback + ? `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}` + : info.text; + return ``; + }).join('') + '
'; +} + +function _gsFallbackBannerHtml(src) { + const actual = _gsState.fallbacks[src]; + if (!actual || actual === src) return ''; + const clicked = (SOURCE_LABELS[src] || {}).text || src; + const served = (SOURCE_LABELS[actual] || {}).text || actual; + return `
${_escToast(clicked)} unavailable — showing ${_escToast(served)}.
`; +} + +function _gsRender() { const results = document.getElementById('gsearch-results'); if (!results) return; - // Music Videos tab — render video grid instead of regular results - if (_gsState.activeSource === 'youtube_videos') { - const src = _gsState.sources['youtube_videos'] || {}; - const videos = src.videos || []; - const isLoading = src._loading && src._loading.size > 0; - let h = ''; + const activeSrc = _gsState.activeSource; + const cached = _gsState.sources[activeSrc]; + const isLoading = _gsState.loadingSources.has(activeSrc); + const query = _gsState.query; + const header = _gsSourceRowHtml() + _gsFallbackBannerHtml(activeSrc); + + // No query yet — just show the icon row and an empty hint. + if (!query) { + results.innerHTML = header + '
Type to search…
'; + results.classList.add('visible'); + return; + } + + // In-flight for this source, nothing cached yet — loading state. + if (isLoading && !cached) { + const info = SOURCE_LABELS[activeSrc]; + results.innerHTML = header + + `
Searching ${_escToast((info && info.text) || activeSrc)}...
`; + results.classList.add('visible'); + return; + } + + // No cache, not loading — user hasn't triggered a fetch (e.g. just switched sources). + if (!cached) { + results.innerHTML = header + '
Click the source above to search.
'; + results.classList.add('visible'); + return; + } + + // Music Videos — render video grid instead of regular sections. + if (activeSrc === 'youtube_videos') { + const videos = cached.videos || []; + let h = header; h += `
Results${videos.length} videos
`; - h += '
'; h += '
'; - if (isLoading) { - h += '
Searching YouTube...
'; - } else if (videos.length === 0) { - h += `
No music videos found for "${_escToast(_gsState.query)}"
`; + if (videos.length === 0) { + h += `
No music videos found for "${_escToast(query)}"
`; } else { h += '
🎬 Music Videos
'; h += '
'; @@ -5270,33 +5380,29 @@ function _gsRender(data) { h += '
'; results.innerHTML = h; results.classList.add('visible'); - _gsRenderTabs(); return; } - const src = _gsState.sources[_gsState.activeSource] || {}; - const loading = src._loading || new Set(); - const dbArtists = data?.db_artists || []; - const artists = src.artists || []; - const allAlbums = src.albums || []; + // Standard metadata source — render library + artists + albums + singles + tracks. + const dbArtists = cached.db_artists || []; + const artists = cached.artists || []; + const allAlbums = cached.albums || []; const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation'); const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep'); - const tracks = src.tracks || []; + const tracks = cached.tracks || []; const total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length; - const isLoading = loading.size > 0; - if (total === 0 && !isLoading) { - results.innerHTML = `
No results for "${_escToast(_gsState.query)}"
Try different keywords or check spelling
`; + if (total === 0) { + results.innerHTML = header + + `
No results for "${_escToast(query)}"
Try different keywords or check spelling
`; results.classList.add('visible'); return; } - const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos', musicbrainz: 'MusicBrainz' }; - const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || ''; + const srcLabel = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc || ''; - let h = ''; + let h = header; h += `
Results${total} items
`; - h += '
'; h += '
'; if (dbArtists.length) { @@ -5309,12 +5415,8 @@ function _gsRender(data) { h += `
🎤 Artists ${srcLabel}
`; h += artists.map(a => `
${a.image_url ? `` : '🎤'}
${_escToast(a.name)}
`).join(''); h += '
'; - } else if (loading.has('artists')) { - h += `
🎤 Artists ${srcLabel}
Loading artists...
`; } - const activeSrc = _gsState.activeSource || 'spotify'; - if (albums.length) { h += `
💿 Albums ${srcLabel}
`; h += albums.map(a => { @@ -5326,10 +5428,6 @@ function _gsRender(data) { h += '
'; } - if (!albums.length && !singles.length && loading.has('albums')) { - h += `
💿 Albums ${srcLabel}
Loading albums...
`; - } - if (singles.length) { h += `
🎶 Singles & EPs ${srcLabel}
`; h += singles.map(a => { @@ -5348,14 +5446,11 @@ function _gsRender(data) { return `
${t.image_url ? `` : '🎵'}
${_escToast(t.name)}
${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}
${dur}
`; }).join(''); h += '
'; - } else if (loading.has('tracks')) { - h += `
🎵 Tracks ${srcLabel}
Loading tracks...
`; } h += '
'; results.innerHTML = h; results.classList.add('visible'); - _gsRenderTabs(); // Lazy load artist images for sources that don't provide them (iTunes/Deezer) _gsLazyLoadArtistImages(); @@ -5383,42 +5478,31 @@ async function _gsLazyLoadArtistImages() { } } -function _gsRenderTabs() { - const el = document.getElementById('gsearch-tabs'); - if (!el) return; - const sources = Object.keys(_gsState.sources); - const labels = { - spotify: 'Spotify', - itunes: 'Apple Music', - deezer: 'Deezer', - discogs: 'Discogs', - hydrabase: 'Hydrabase', - youtube_videos: 'Music Videos', - musicbrainz: 'MusicBrainz', - }; - const visibleSources = sources.filter(s => { - const d = _gsState.sources[s] || {}; - const count = s === 'youtube_videos' - ? (d.videos?.length || 0) - : (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0); - const isLoading = !!(d._loading && d._loading.size > 0); - return isLoading || count > 0 || s === _gsState.activeSource; - }); - if (visibleSources.length < 2) { el.style.display = 'none'; return; } - el.style.display = 'flex'; - el.innerHTML = visibleSources.map(s => { - const d = _gsState.sources[s]; - const c = s === 'youtube_videos' - ? (d.videos?.length || 0) - : (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0); - return ``; - }).join(''); -} - -function _gsSwitchSource(src) { +function _gsSetActiveSource(src) { + if (!SOURCE_LABELS[src]) return; _gsState._lastInteraction = Date.now(); + + // Soulseek — hand off to the Search page since its raw file results need + // more room than the popover provides. + if (src === 'soulseek') { + _gsNavigateToSearchPage(_gsState.query, 'soulseek'); + return; + } + + if (src === _gsState.activeSource) return; _gsState.activeSource = src; - _gsRender(_gsState.data); + + // Cache hit — render from cached payload. Cache miss — fetch, which + // will re-render on completion. + if (_gsState.sources[src]) { + _gsRender(); + } else if (_gsState.query) { + _gsFetchSource(src); + } else { + // No query yet — just redraw the icon row so the active state changes. + _gsRender(); + } + const input = document.getElementById('gsearch-input'); if (input) input.focus(); } diff --git a/webui/static/style.css b/webui/static/style.css index 64bd36a6..307f0bef 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -5479,6 +5479,91 @@ body.helper-mode-active #dashboard-activity-feed:hover { .gsearch-tab.active { background: rgba(var(--accent-rgb), 0.12); color: var(--accent); border-color: rgba(var(--accent-rgb), 0.2); } .gsearch-tab:hover:not(.active) { background: rgba(255,255,255,0.06); } +/* Source icon row (replaces post-search tabs) */ +.gsearch-source-row { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + gap: 6px; + padding: 10px 14px 8px; + flex-shrink: 0; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; +} +.gsearch-source-row::-webkit-scrollbar { height: 4px; } +.gsearch-source-row::-webkit-scrollbar-track { background: transparent; } +.gsearch-source-row::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 2px; } + +.gsearch-source-icon { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + min-width: 56px; + padding: 6px 8px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + color: rgba(255, 255, 255, 0.55); + cursor: pointer; + font-family: inherit; + font-size: 10px; + font-weight: 600; + white-space: nowrap; + flex-shrink: 0; + transition: border-color 0.15s, background 0.15s, color 0.15s; +} +.gsearch-source-icon:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; + border-color: rgba(255, 255, 255, 0.14); +} +.gsearch-source-icon-glyph { font-size: 16px; line-height: 1; } +.gsearch-source-icon-label { font-size: 10px; letter-spacing: 0.02em; } + +.gsearch-source-icon[data-source="spotify"].active { background: rgba(29, 185, 84, 0.18); color: #1db954; border-color: rgba(29, 185, 84, 0.4); } +.gsearch-source-icon[data-source="itunes"].active { background: rgba(252, 60, 68, 0.18); color: #fc3c44; border-color: rgba(252, 60, 68, 0.4); } +.gsearch-source-icon[data-source="deezer"].active { background: rgba(162, 56, 255, 0.18); color: #a238ff; border-color: rgba(162, 56, 255, 0.4); } +.gsearch-source-icon[data-source="discogs"].active { background: rgba(212, 165, 116, 0.18); color: #D4A574; border-color: rgba(212, 165, 116, 0.4); } +.gsearch-source-icon[data-source="hydrabase"].active { background: rgba(0, 180, 216, 0.18); color: #00b4d8; border-color: rgba(0, 180, 216, 0.4); } +.gsearch-source-icon[data-source="musicbrainz"].active { background: rgba(186, 51, 88, 0.18); color: #BA3358; border-color: rgba(186, 51, 88, 0.4); } +.gsearch-source-icon[data-source="youtube_videos"].active { background: rgba(255, 0, 0, 0.18); color: #ff4444; border-color: rgba(255, 0, 0, 0.4); } +.gsearch-source-icon[data-source="soulseek"].active { background: rgba(255, 255, 255, 0.14); color: #fff; border-color: rgba(255, 255, 255, 0.3); } + +.gsearch-source-icon.cached::after { + content: ''; + position: absolute; + top: 4px; + right: 5px; + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + opacity: 0.75; +} +.gsearch-source-icon.loading .gsearch-source-icon-glyph { + animation: gsearch-source-loading-spin 1.2s linear infinite; + opacity: 0.7; +} +@keyframes gsearch-source-loading-spin { + from { transform: rotate(0); } + to { transform: rotate(360deg); } +} +.gsearch-source-icon.fallback-warning { border-color: rgba(250, 176, 5, 0.5); } + +.gsearch-fallback-banner { + padding: 6px 14px; + margin: 0 12px 6px; + border-radius: 6px; + background: rgba(250, 176, 5, 0.12); + border: 1px solid rgba(250, 176, 5, 0.3); + color: #fab005; + font-size: 10.5px; + font-weight: 500; +} + /* Section headers */ .gsearch-section-header { font-size: 10px;