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 '