-
-
+
+
diff --git a/webui/static/search.js b/webui/static/search.js
index 42f9b45a..e88287b7 100644
--- a/webui/static/search.js
+++ b/webui/static/search.js
@@ -43,11 +43,11 @@ function initializeSearchModeToggle() {
return;
}
- const sourceSelect = document.getElementById('search-source-select');
+ const sourceRow = document.getElementById('enh-source-row');
const basicSection = document.getElementById('basic-search-section');
const enhancedSection = document.getElementById('enhanced-search-section');
- if (!sourceSelect || !basicSection || !enhancedSection) {
+ if (!sourceRow || !basicSection || !enhancedSection) {
console.warn('Search source picker elements not found');
return;
}
@@ -55,28 +55,24 @@ function initializeSearchModeToggle() {
searchModeToggleInitialized = true;
console.log('✅ Initializing search source picker (first time only)');
- // Current source selection — 'auto' (fan-out) by default. Soulseek routes
- // to the raw-file basic search; everything else routes to enhanced.
- let currentSearchSource = sourceSelect.value || 'auto';
+ // Active source — the icon the user is currently viewing. Initialized from
+ // the user's configured primary source via /api/settings (below). No 'auto'
+ // / fan-out mode: every search targets exactly one source.
+ let currentSearchSource = 'spotify';
- const applySourceSelection = (value) => {
- currentSearchSource = value;
- if (value === 'soulseek') {
- basicSection.classList.add('active');
- enhancedSection.classList.remove('active');
- } else {
- basicSection.classList.remove('active');
- enhancedSection.classList.add('active');
- }
+ // Per-query cache. `sources[src]` holds the result payload the last time
+ // `src` was fetched for the current query. `fallbacks[src]` records the
+ // source the backend actually served when it auto-fell-back (e.g. user
+ // clicked Spotify but got Deezer because Spotify is rate-limited).
+ // `loadingSources` drives per-icon spinners. Cleared whenever the query
+ // string changes — we never serve stale results across queries.
+ let _cachedData = {
+ query: '',
+ sources: {},
+ fallbacks: {},
+ loadingSources: new Set(),
};
- applySourceSelection(currentSearchSource);
-
- sourceSelect.addEventListener('change', (e) => {
- applySourceSelection(e.target.value);
- console.log('Search source →', currentSearchSource);
- });
-
// Initialize enhanced search
const enhancedInput = document.getElementById('enhanced-search-input');
const enhancedSearchBtn = document.getElementById('enhanced-search-btn');
@@ -89,12 +85,257 @@ function initializeSearchModeToggle() {
let debounceTimer = null;
let abortController = null;
- // Multi-source search state
- let _enhancedSearchData = null; // Full response with all sources
- let _activeSearchSource = null; // Currently displayed source tab
- let _altSourceController = null; // AbortController for alternate source fetches
+ // SOURCE_LABELS + SOURCE_ORDER now live in shared-helpers.js.
- // SOURCE_LABELS now lives in shared-helpers.js.
+ // ── Source icon row ────────────────────────────────────────────────
+ function renderSourceRow() {
+ sourceRow.innerHTML = SOURCE_ORDER.map(src => {
+ const info = SOURCE_LABELS[src];
+ if (!info) return '';
+ const active = src === currentSearchSource;
+ const cached = !!_cachedData.sources[src];
+ const loading = _cachedData.loadingSources.has(src);
+ const fallback = _cachedData.fallbacks[src];
+ const classes = [
+ 'enh-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('');
+ sourceRow.querySelectorAll('.enh-source-icon').forEach(btn => {
+ btn.addEventListener('click', () => setActiveSource(btn.dataset.source));
+ });
+ }
+
+ async function _initDefaultSource() {
+ 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]) {
+ currentSearchSource = cfg;
+ }
+ }
+ } catch (_) { /* settings fetch best-effort */ }
+ if (!SOURCE_LABELS[currentSearchSource]) currentSearchSource = 'spotify';
+ renderSourceRow();
+ }
+ _initDefaultSource();
+
+ // ── Source selection ───────────────────────────────────────────────
+ function setActiveSource(src) {
+ if (!SOURCE_LABELS[src]) return;
+ if (src === currentSearchSource) return;
+ currentSearchSource = src;
+
+ // Soulseek routes to the basic search UI (raw file results). We keep
+ // no cache for it — the basic search renders straight to the page.
+ if (src === 'soulseek') {
+ basicSection.classList.add('active');
+ enhancedSection.classList.remove('active');
+ hideDropdown();
+ renderSourceRow();
+ const basicInput = document.getElementById('downloads-search-input');
+ if (basicInput && _cachedData.query) {
+ basicInput.value = _cachedData.query;
+ if (typeof performDownloadsSearch === 'function') performDownloadsSearch();
+ }
+ return;
+ }
+
+ // Other sources use the enhanced dropdown.
+ basicSection.classList.remove('active');
+ enhancedSection.classList.add('active');
+ renderSourceRow();
+
+ if (!_cachedData.query) return;
+
+ if (_cachedData.sources[src]) {
+ _renderFromCache(src);
+ } else {
+ _fetchAndRenderSource(src);
+ }
+ }
+
+ // ── Render + fetch ─────────────────────────────────────────────────
+ function _renderFromCache(src) {
+ const cached = _cachedData.sources[src];
+ if (!cached) return;
+
+ loadingState.classList.add('hidden');
+ emptyState.classList.add('hidden');
+ resultsContainer.classList.remove('hidden');
+ showDropdown();
+
+ _renderFallbackBanner(src);
+
+ if (src === 'youtube_videos') {
+ ['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => {
+ const el = document.getElementById(id);
+ if (el) el.classList.add('hidden');
+ });
+ const artistsWrapper = document.querySelector('.enh-artists-wrapper');
+ if (artistsWrapper) artistsWrapper.style.display = 'none';
+ _renderVideoResults(cached.videos || []);
+ return;
+ }
+
+ const videosSec = document.getElementById('enh-videos-section');
+ if (videosSec) videosSec.classList.add('hidden');
+ const artistsWrapper = document.querySelector('.enh-artists-wrapper');
+ if (artistsWrapper) artistsWrapper.style.display = '';
+
+ renderDropdownResults({
+ db_artists: cached.db_artists || [],
+ spotify_artists: cached.artists || [],
+ spotify_albums: cached.albums || [],
+ spotify_tracks: cached.tracks || [],
+ metadata_source: src,
+ });
+ }
+
+ function _renderFallbackBanner(src) {
+ const banner = document.getElementById('enh-fallback-banner');
+ if (!banner) return;
+ const actual = _cachedData.fallbacks[src];
+ if (actual && actual !== src) {
+ const clicked = (SOURCE_LABELS[src] || {}).text || src;
+ const served = (SOURCE_LABELS[actual] || {}).text || actual;
+ banner.textContent = `${clicked} unavailable — showing ${served}.`;
+ banner.classList.remove('hidden');
+ } else {
+ banner.classList.add('hidden');
+ }
+ }
+
+ async function _fetchAndRenderSource(src) {
+ const query = _cachedData.query;
+ if (!query) return;
+
+ _cachedData.loadingSources.add(src);
+ renderSourceRow();
+
+ showDropdown();
+ emptyState.classList.add('hidden');
+ resultsContainer.classList.add('hidden');
+ loadingState.classList.remove('hidden');
+ const loadingText = document.getElementById('enhanced-loading-text');
+ if (loadingText) {
+ const info = SOURCE_LABELS[src];
+ loadingText.textContent = `Searching ${(info && info.text) || src} and your library...`;
+ }
+
+ if (abortController) abortController.abort();
+ abortController = new AbortController();
+
+ try {
+ if (src === 'youtube_videos') {
+ await _fetchYouTubeVideos(query, abortController.signal);
+ } else {
+ const data = await enhancedSearchFetch(query, {
+ source: src,
+ signal: abortController.signal,
+ });
+ _cachedData.sources[src] = {
+ artists: data.spotify_artists || [],
+ albums: data.spotify_albums || [],
+ tracks: data.spotify_tracks || [],
+ videos: [],
+ available: true,
+ db_artists: data.db_artists || [],
+ };
+ const served = data.primary_source || data.metadata_source;
+ if (served && served !== src) {
+ _cachedData.fallbacks[src] = served;
+ }
+ }
+
+ _cachedData.loadingSources.delete(src);
+ renderSourceRow();
+
+ if (currentSearchSource === src) {
+ const cached = _cachedData.sources[src];
+ const total = src === 'youtube_videos'
+ ? ((cached && cached.videos && cached.videos.length) || 0)
+ : ((cached && cached.db_artists && cached.db_artists.length) || 0)
+ + ((cached && cached.artists && cached.artists.length) || 0)
+ + ((cached && cached.albums && cached.albums.length) || 0)
+ + ((cached && cached.tracks && cached.tracks.length) || 0);
+ loadingState.classList.add('hidden');
+ if (total === 0) {
+ emptyState.classList.remove('hidden');
+ } else {
+ _renderFromCache(src);
+ }
+ }
+ } catch (err) {
+ _cachedData.loadingSources.delete(src);
+ renderSourceRow();
+ if (err.name !== 'AbortError') {
+ console.error(`Source fetch failed for ${src}:`, err);
+ if (currentSearchSource === src) {
+ loadingState.classList.add('hidden');
+ emptyState.classList.remove('hidden');
+ }
+ }
+ }
+ }
+
+ async function _fetchYouTubeVideos(query, signal) {
+ const response = await fetch('/api/enhanced-search/source/youtube_videos', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query }),
+ signal,
+ });
+ if (!response.ok) throw new Error(`YouTube search failed: ${response.status}`);
+
+ _cachedData.sources['youtube_videos'] = {
+ artists: [], albums: [], tracks: [], videos: [],
+ available: true, db_artists: [],
+ };
+ const cache = _cachedData.sources['youtube_videos'];
+
+ const reader = response.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 newlineIdx;
+ while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
+ const line = buffer.slice(0, newlineIdx).trim();
+ buffer = buffer.slice(newlineIdx + 1);
+ if (!line) continue;
+ try {
+ const chunk = JSON.parse(line);
+ if (chunk.type === 'videos') {
+ cache.videos = chunk.data;
+ // Live-render if still active — lets the user see results
+ // as soon as the chunk arrives.
+ if (currentSearchSource === 'youtube_videos') {
+ _renderFromCache('youtube_videos');
+ }
+ }
+ } catch (_) { /* best-effort NDJSON parse */ }
+ }
+ }
+ }
// Live search with debouncing
if (enhancedInput) {
@@ -192,96 +433,43 @@ function initializeSearchModeToggle() {
});
async function performEnhancedSearch(query) {
- console.log('Enhanced search:', query);
- const searchId = Date.now() + Math.random();
+ console.log('Enhanced search:', query, '→', currentSearchSource);
- // Show loading state with correct source name
- showDropdown();
- const loadingText = document.getElementById('enhanced-loading-text');
- if (loadingText) {
- const _sourceLabelMap = {
- spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer',
- discogs: 'Discogs', hydrabase: 'Hydrabase', musicbrainz: 'MusicBrainz',
- };
- const _sourceName = currentSearchSource && currentSearchSource !== 'auto'
- ? (_sourceLabelMap[currentSearchSource] || currentSearchSource)
- : currentMusicSourceName;
- loadingText.textContent = `Searching across ${_sourceName} and your library...`;
+ // Query changed? Drop the whole per-query cache so we never serve
+ // stale results across queries. Icon dots and fallback banner reset.
+ if (query !== _cachedData.query) {
+ _cachedData.query = query;
+ _cachedData.sources = {};
+ _cachedData.fallbacks = {};
+ _cachedData.loadingSources = new Set();
+ renderSourceRow();
}
- loadingState.classList.remove('hidden');
- emptyState.classList.add('hidden');
- resultsContainer.classList.add('hidden');
- // Abort previous requests (primary + alternates)
- if (abortController) {
- abortController.abort();
- }
- if (_altSourceController) {
- _altSourceController.abort();
- }
- abortController = new AbortController();
- _altSourceController = new AbortController();
-
- // Initialize multi-source state early so alternate fetches can write to it
- _enhancedSearchData = { db_artists: [], primary_source: null, sources: {}, searchId, query };
-
- try {
- const data = await enhancedSearchFetch(query, {
- source: currentSearchSource,
- signal: abortController.signal,
- });
- console.log('Enhanced results:', data);
-
- // Store multi-source state
- const primarySource = data.primary_source || data.metadata_source || 'deezer';
- _activeSearchSource = primarySource;
- _enhancedSearchData = _enhancedSearchData || {};
- _enhancedSearchData.db_artists = data.db_artists;
- _enhancedSearchData.primary_source = primarySource;
- if (!_enhancedSearchData.sources) _enhancedSearchData.sources = {};
- _enhancedSearchData.sources[primarySource] = {
- artists: data.spotify_artists || [],
- albums: data.spotify_albums || [],
- tracks: data.spotify_tracks || [],
- available: true,
- };
-
- // Calculate total from primary source
- const total = (data.db_artists?.length || 0) +
- (data.spotify_artists?.length || 0) +
- (data.spotify_albums?.length || 0) +
- (data.spotify_tracks?.length || 0);
-
- // Hide loading
- loadingState.classList.add('hidden');
-
- if (total === 0) {
- emptyState.classList.remove('hidden');
- } else {
- renderSourceTabs(_enhancedSearchData);
- renderDropdownResults(data);
- resultsContainer.classList.remove('hidden');
- }
-
- // Alternate sources now start after the primary response has landed.
- // This avoids speculative fan-out for short or aborted searches.
- _queueAlternateSourceFetches(data.alternate_sources || [], query, searchId);
-
- } catch (error) {
- if (error.name !== 'AbortError') {
- console.error('Enhanced search error:', error);
- loadingState.classList.add('hidden');
- emptyState.classList.remove('hidden');
+ // Soulseek → existing basic search flow, no cache here.
+ if (currentSearchSource === 'soulseek') {
+ const basicInput = document.getElementById('downloads-search-input');
+ if (basicInput) {
+ basicInput.value = query;
+ if (typeof performDownloadsSearch === 'function') performDownloadsSearch();
}
+ return;
}
+
+ // Same query + same source that's already cached → instant render.
+ if (_cachedData.sources[currentSearchSource]) {
+ _renderFromCache(currentSearchSource);
+ return;
+ }
+
+ await _fetchAndRenderSource(currentSearchSource);
}
function renderDropdownResults(data) {
// Music Videos tab — don't render regular sections
- if (_activeSearchSource === 'youtube_videos') return;
+ if (currentSearchSource === 'youtube_videos') return;
// Determine source badge from active tab (not just primary)
- const displaySource = _activeSearchSource || data.metadata_source || 'spotify';
+ const displaySource = currentSearchSource || data.metadata_source || 'spotify';
const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify;
const sourceBadge = { text: sourceInfo.text, class: sourceInfo.badgeClass };
@@ -318,7 +506,7 @@ function initializeSearchModeToggle() {
meta: 'Artist',
badge: sourceBadge,
onClick: () => {
- const sourceOverride = _activeSearchSource;
+ const sourceOverride = currentSearchSource;
console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`);
hideDropdown();
navigateToArtistDetail(artist.id, artist.name, sourceOverride || null);
@@ -470,200 +658,6 @@ function initializeSearchModeToggle() {
}
}
- function _queueAlternateSourceFetches(alternateSources, query, searchId) {
- if (!Array.isArray(alternateSources) || alternateSources.length === 0) return;
-
- // Fetch metadata sources first, then YouTube last so it does not compete
- // with the primary artist/album/track results for early attention.
- const orderedSources = ['spotify', 'itunes', 'deezer', 'discogs', 'musicbrainz', 'hydrabase', 'youtube_videos']
- .filter(src => alternateSources.includes(src) && src !== _activeSearchSource);
-
- orderedSources.forEach((src, index) => {
- setTimeout(() => {
- if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
- _fetchAlternateSource(src, query, searchId);
- }, index * 150);
- });
- }
-
- async function _fetchAlternateSource(sourceName, query, searchId) {
- try {
- if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
-
- const response = await fetch(`/api/enhanced-search/source/${sourceName}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ query }),
- signal: _altSourceController?.signal,
- });
- if (!response.ok) return;
- if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
-
- // Stream NDJSON — render each search type (artists, albums, tracks) as it arrives
- if (!_enhancedSearchData.sources[sourceName]) {
- const loadingSet = sourceName === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']);
- _enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet };
- }
- const sourceData = _enhancedSearchData.sources[sourceName];
-
- const reader = response.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 newlineIdx;
- while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
- const line = buffer.slice(0, newlineIdx).trim();
- buffer = buffer.slice(newlineIdx + 1);
- if (!line) continue;
- if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
-
- 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'); }
- else if (chunk.type === 'done') { delete sourceData._loading; break; }
-
- // Re-render tabs + content if this is the active source
- if (_enhancedSearchData.primary_source) {
- renderSourceTabs(_enhancedSearchData);
- if (_activeSearchSource === sourceName) {
- window._switchEnhSourceTab(sourceName);
- }
- }
- } catch (parseErr) {
- console.debug(`NDJSON parse error for ${sourceName}:`, parseErr);
- }
- }
- }
-
- // Final render
- if (_enhancedSearchData && _enhancedSearchData.searchId === searchId && _enhancedSearchData.primary_source) {
- renderSourceTabs(_enhancedSearchData);
- }
- } catch (e) {
- if (e.name !== 'AbortError') {
- console.debug(`Alternate source ${sourceName} failed:`, e);
- }
- }
- }
-
- function renderSourceTabs(data) {
- const tabBar = document.getElementById('enh-source-tabs');
- if (!tabBar) return;
-
- const sources = data.sources || {};
- const primary = data.primary_source || 'spotify';
-
- // Build tab list: primary first, then alternates sorted alphabetically.
- // Hide completed zero-result sources so the bar stays focused.
- const sourceNames = Object.keys(sources).filter(s => sources[s].available);
- const visibleSources = sourceNames.filter(name => {
- const src = sources[name] || {};
- const count = name === 'youtube_videos'
- ? (src.videos?.length || 0)
- : (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0);
- const isLoading = !!(src._loading && src._loading.size > 0);
- return isLoading || count > 0 || name === _activeSearchSource;
- });
- if (visibleSources.length <= 1) {
- tabBar.classList.add('hidden');
- tabBar.innerHTML = '';
- return;
- }
-
- // Primary tab first, then others
- const ordered = [primary, ...visibleSources.filter(s => s !== primary).sort()];
-
- tabBar.innerHTML = ordered.map(name => {
- const info = SOURCE_LABELS[name] || { text: name, tabClass: '' };
- const src = sources[name] || {};
- const count = name === 'youtube_videos'
- ? (src.videos?.length || 0)
- : (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0);
- const isActive = name === _activeSearchSource;
- return `
`;
- }).join('');
-
- tabBar.classList.remove('hidden');
- }
-
- // Expose tab switch globally (onclick from HTML)
- window._switchEnhSourceTab = function (sourceName) {
- if (!_enhancedSearchData || !_enhancedSearchData.sources) return;
- const src = _enhancedSearchData.sources[sourceName];
- if (!src) return;
-
- _activeSearchSource = sourceName;
-
- // Update tab active states
- document.querySelectorAll('.enh-source-tab').forEach(tab => {
- tab.classList.toggle('active', tab.dataset.source === sourceName);
- });
-
- // Music Videos tab — render video cards instead of regular sections
- if (sourceName === 'youtube_videos') {
- // Hide ALL regular sections including wrappers
- ['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => {
- const el = document.getElementById(id);
- if (el) el.classList.add('hidden');
- });
- // Hide the artists wrapper div too
- const artistsWrapper = document.querySelector('.enh-artists-wrapper');
- if (artistsWrapper) artistsWrapper.style.display = 'none';
- _renderVideoResults(src.videos || []);
- resultsContainer.classList.remove('hidden');
- return;
- }
-
- // Hide videos section and restore regular layout when switching to a metadata tab
- const videosSec = document.getElementById('enh-videos-section');
- if (videosSec) videosSec.classList.add('hidden');
- const artistsWrapper = document.querySelector('.enh-artists-wrapper');
- if (artistsWrapper) artistsWrapper.style.display = '';
-
- // Build data in the shape renderDropdownResults expects
- const viewData = {
- db_artists: _enhancedSearchData.db_artists,
- spotify_artists: src.artists || [],
- spotify_albums: src.albums || [],
- spotify_tracks: src.tracks || [],
- metadata_source: sourceName,
- };
-
- renderDropdownResults(viewData);
- resultsContainer.classList.remove('hidden');
-
- // Show loading spinners for categories still streaming
- if (src._loading && src._loading.size > 0) {
- const loadingHtml = '
';
- if (src._loading.has('artists')) {
- const sec = document.getElementById('enh-spotify-artists-section');
- if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-spotify-artists-list').innerHTML = loadingHtml; }
- }
- if (src._loading.has('albums')) {
- const sec = document.getElementById('enh-albums-section');
- if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-albums-list').innerHTML = loadingHtml; }
- const sec2 = document.getElementById('enh-singles-section');
- if (sec2) { sec2.classList.remove('hidden'); document.getElementById('enh-singles-list').innerHTML = loadingHtml; }
- }
- if (src._loading.has('tracks')) {
- const sec = document.getElementById('enh-tracks-section');
- if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-tracks-list').innerHTML = loadingHtml; }
- }
- }
- };
-
function _renderVideoResults(videos) {
let section = document.getElementById('enh-videos-section');
if (!section) {
@@ -748,8 +742,8 @@ function initializeSearchModeToggle() {
if (!artistId) continue;
try {
- const imgUrl = _activeSearchSource && _activeSearchSource !== 'spotify'
- ? `/api/artist/${artistId}/image?source=${_activeSearchSource}`
+ const imgUrl = currentSearchSource && currentSearchSource !== 'spotify'
+ ? `/api/artist/${artistId}/image?source=${currentSearchSource}`
: `/api/artist/${artistId}/image`;
const response = await fetch(imgUrl);
const data = await response.json();
@@ -797,8 +791,8 @@ function initializeSearchModeToggle() {
try {
// Fetch full album data with tracks — pass source for correct routing
const albumParams = new URLSearchParams({ name: album.name || '', artist: album.artist || '' });
- if (_activeSearchSource && _activeSearchSource !== 'spotify') {
- albumParams.set('source', _activeSearchSource);
+ if (currentSearchSource && currentSearchSource !== 'spotify') {
+ albumParams.set('source', currentSearchSource);
}
// Pass Hydrabase plugin origin so server routes to correct client
if (album.external_urls?.hydrabase_plugin) {
@@ -864,7 +858,7 @@ function initializeSearchModeToggle() {
id: firstArtist.id || album.id?.split?.('_')?.[0] || '',
name: firstArtist.name || album.artist,
image_url: firstArtist.image_url || firstArtist.images?.[0]?.url || '',
- source: _activeSearchSource || '',
+ source: currentSearchSource || '',
};
// Prepare full album object for modal
diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js
index 1a565973..6b4b5796 100644
--- a/webui/static/shared-helpers.js
+++ b/webui/static/shared-helpers.js
@@ -31,19 +31,27 @@ async function enhancedSearchFetch(query, { source = null, signal = null } = {})
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.
+// Per-source labels + tab/badge CSS classes + icon glyph for the source
+// picker row. 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' },
+ spotify: { text: 'Spotify', icon: '🎵', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify' },
+ itunes: { text: 'Apple Music', icon: '🍎', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes' },
+ deezer: { text: 'Deezer', icon: '🎶', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' },
+ discogs: { text: 'Discogs', icon: '📀', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' },
+ hydrabase: { text: 'Hydrabase', icon: '💎', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' },
+ musicbrainz: { text: 'MusicBrainz', icon: '🧠', tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz' },
+ youtube_videos: { text: 'Music Videos', icon: '🎬', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' },
+ soulseek: { text: 'Soulseek', icon: '🎼', tabClass: 'enh-tab-soulseek', badgeClass: 'enh-badge-soulseek' },
};
+// Canonical display order for the source picker. Standard metadata sources
+// first, then YouTube Music Videos, then Soulseek (basic-file source).
+const SOURCE_ORDER = [
+ 'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz',
+ 'youtube_videos', '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.
diff --git a/webui/static/style.css b/webui/static/style.css
index d4b875bc..64bd36a6 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -33513,6 +33513,108 @@ div.artist-hero-badge {
.enh-source-tab.enh-tab-youtube.active { background: rgba(255, 0, 0, 0.2); color: #ff4444; }
.enh-source-tab.enh-tab-musicbrainz.active { background: rgba(186, 51, 88, 0.2); color: #BA3358; }
+/* ── Source picker icon row (replaces dropdown + post-search tabs) ── */
+.enh-source-row {
+ display: flex;
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ gap: 10px;
+ padding: 10px 4px 8px;
+ margin: 8px 0 12px;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
+}
+.enh-source-row::-webkit-scrollbar { height: 6px; }
+.enh-source-row::-webkit-scrollbar-track { background: transparent; }
+.enh-source-row::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 3px; }
+
+.enh-source-icon {
+ position: relative;
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ min-width: 72px;
+ padding: 10px 12px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.04);
+ color: rgba(255, 255, 255, 0.6);
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s, color 0.15s, transform 0.1s;
+ font-family: inherit;
+ font-size: 11px;
+ font-weight: 600;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+.enh-source-icon:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: rgba(255, 255, 255, 0.95);
+ border-color: rgba(255, 255, 255, 0.16);
+}
+.enh-source-icon:active { transform: scale(0.97); }
+
+.enh-source-icon-glyph {
+ font-size: 20px;
+ line-height: 1;
+}
+.enh-source-icon-label {
+ font-size: 11px;
+ letter-spacing: 0.02em;
+}
+
+/* Active per-source accent — reuses the tab color palette for consistency. */
+.enh-source-icon[data-source="spotify"].active { background: rgba(29, 185, 84, 0.18); color: #1db954; border-color: rgba(29, 185, 84, 0.45); }
+.enh-source-icon[data-source="itunes"].active { background: rgba(252, 60, 68, 0.18); color: #fc3c44; border-color: rgba(252, 60, 68, 0.45); }
+.enh-source-icon[data-source="deezer"].active { background: rgba(162, 56, 255, 0.18); color: #a238ff; border-color: rgba(162, 56, 255, 0.45); }
+.enh-source-icon[data-source="discogs"].active { background: rgba(212, 165, 116, 0.18); color: #D4A574; border-color: rgba(212, 165, 116, 0.45); }
+.enh-source-icon[data-source="hydrabase"].active { background: rgba(0, 180, 216, 0.18); color: #00b4d8; border-color: rgba(0, 180, 216, 0.45); }
+.enh-source-icon[data-source="musicbrainz"].active { background: rgba(186, 51, 88, 0.18); color: #BA3358; border-color: rgba(186, 51, 88, 0.45); }
+.enh-source-icon[data-source="youtube_videos"].active { background: rgba(255, 0, 0, 0.18); color: #ff4444; border-color: rgba(255, 0, 0, 0.45); }
+.enh-source-icon[data-source="soulseek"].active { background: rgba(255, 255, 255, 0.14); color: #fff; border-color: rgba(255, 255, 255, 0.3); }
+
+/* Cache dot — small indicator on icons that already have results for the
+ current query. Placed top-right to avoid clashing with the label. */
+.enh-source-icon.cached::after {
+ content: '';
+ position: absolute;
+ top: 6px;
+ right: 8px;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+ opacity: 0.8;
+}
+
+.enh-source-icon.loading .enh-source-icon-glyph {
+ animation: enh-source-loading-spin 1.2s linear infinite;
+ opacity: 0.7;
+}
+@keyframes enh-source-loading-spin {
+ from { transform: rotate(0); }
+ to { transform: rotate(360deg); }
+}
+
+.enh-source-icon.fallback-warning {
+ border-color: rgba(250, 176, 5, 0.5);
+}
+
+/* Rate-limit fallback banner above the enhanced results. */
+.enh-fallback-banner {
+ padding: 8px 12px;
+ margin-bottom: 10px;
+ border-radius: 8px;
+ background: rgba(250, 176, 5, 0.12);
+ border: 1px solid rgba(250, 176, 5, 0.3);
+ color: #fab005;
+ font-size: 12px;
+ font-weight: 500;
+}
+.enh-fallback-banner.hidden { display: none; }
+
/* Music Video Grid */
.enh-video-grid {
display: grid;
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 03/18] 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 = '
';
- 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 += ``;
- h += '
';
h += '
';
- if (isLoading) {
- h += '
';
- } 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 += '';
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 += ``;
- h += '
';
h += '
';
if (dbArtists.length) {
@@ -5309,12 +5415,8 @@ function _gsRender(data) {
h += `
`;
h += artists.map(a => `
${a.image_url ? `

` : '🎤'}
`).join('');
h += '
';
- } else if (loading.has('artists')) {
- h += `
`;
}
- const activeSrc = _gsState.activeSource || 'spotify';
-
if (albums.length) {
h += `
`;
h += albums.map(a => {
@@ -5326,10 +5428,6 @@ function _gsRender(data) {
h += '
';
}
- if (!albums.length && !singles.length && loading.has('albums')) {
- h += `
`;
- }
-
if (singles.length) {
h += `
`;
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 += `
`;
}
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;
From 553ede8de9209b770fd97ff6255ba280ae00f00d Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 23 Apr 2026 13:33:19 -0700
Subject: [PATCH 04/18] Update interactive help + What's New for the
source-picker redesign
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The existing 2.40 WHATS_NEW entry described the short-lived "Search
from" dropdown that preceded this redesign. Updated to describe the
icon row + per-query cache + rate-limit fallback banner + global widget
parity that actually ships.
Click-for-help annotations and the "First Download" tour now point at
`#enh-source-row` (the new icon container) instead of the deleted
`.search-source-picker-container` dropdown and the deleted
`.enh-source-tabs` post-search tab bar. Adjusted the enhanced-search
tips so "multi-source tabs compare results" doesn't mislead — the
icons above the bar are how you compare now.
Version stays at 2.39 — the 2.40 WHATS_NEW section is accumulating
under the "Search & Artists unification" umbrella and will publish
when the whole 2.40 cycle ships.
---
webui/static/helper.js | 39 ++++++++++++++++++---------------------
1 file changed, 18 insertions(+), 21 deletions(-)
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 62d17da5..ceb050c3 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -917,34 +917,31 @@ const HELPER_CONTENT = {
description: 'Search for music across your configured metadata sources and download from Soulseek, YouTube, Tidal, Qobuz, HiFi, or Deezer.',
docsId: 'search'
},
- '.search-source-picker-container': {
- title: 'Search From',
- description: 'Pick which metadata source to search. "All sources (Auto)" keeps the multi-source fan-out behavior; any specific source hits only that provider. "Soulseek (raw files)" switches to raw P2P file search with quality filters.',
+ '#enh-source-row': {
+ title: 'Search Source Icons',
+ description: 'Each icon is a metadata source. The highlighted one is what your next search will target — defaults to your configured primary source on page load. Click a different icon to search or switch to that source; a small dot on the icon marks sources that already have cached results for the current query.',
tips: [
- 'Auto: searches your configured primary source plus library matches',
- 'Spotify / Apple Music / Deezer / Discogs / Hydrabase / MusicBrainz: metadata-only results for that provider',
- 'Soulseek: raw file results with format, bitrate, size, uploader — same as the old Basic Search'
+ 'Typing searches only the highlighted source — no more silent fan-out across every provider',
+ 'Switching to an already-cached source is instant, no re-fetch',
+ 'The Soulseek icon routes to the raw-file search (same as the old Basic Search)',
+ 'Music Videos queries YouTube for downloadable music video files',
+ 'An amber border on a source means the backend fell back to a different provider for you (usually because Spotify is rate-limited)'
],
docsId: 'search-enhanced'
},
// Enhanced Search
'.enhanced-search-input-wrapper': {
- title: 'Enhanced Search',
- description: 'Type an artist, album, or track name. Results appear in categorized sections: Library Artists, Artists, Albums, Singles & EPs, and Tracks. Results come from your active metadata source.',
+ title: 'Search Bar',
+ description: 'Type an artist, album, or track name. Results appear in categorized sections: Library Artists, Artists, Albums, Singles & EPs, and Tracks. Only the source highlighted in the icon row above is queried — click another icon to switch.',
tips: [
'Click an album to open the download modal',
'Click a track to search your download source',
'Play button previews tracks from your download source',
- 'Multi-source tabs compare results across Spotify, iTunes, and Deezer'
+ 'Switch sources via the icon row above — results are cached per query'
],
docsId: 'search-enhanced'
},
- '.enh-source-tabs': {
- title: 'Source Tabs',
- description: 'Switch between metadata sources to see results from Spotify, iTunes, or Deezer. Each source has its own catalog — tracks missing on one may be found on another.',
- docsId: 'search-enhanced'
- },
'#enh-db-artists-section': {
title: 'Library Artists',
description: 'Artists from your local music library that match the search. Click to view their collection on the Library page.',
@@ -1291,11 +1288,8 @@ const HELPER_CONTENT = {
title: 'Similar Artist',
description: 'An artist similar to the one you\'re viewing. Click to load their discography and browse their releases.',
},
- '.search-source-picker-container': {
- title: 'Search Source',
- description: 'Pick which metadata source the Search page queries. "All sources (Auto)" fans out across configured providers (the legacy default); pick a specific source to constrain the lookup. "Soulseek (raw files)" routes to the file-search pipeline that used to be the Basic mode.',
- docsId: 'search'
- },
+ // (Search source picker annotation lives under `#enh-source-row` above —
+ // the old `.search-source-picker-container` dropdown is gone.)
// ─── AUTOMATIONS PAGE ─────────────────────────────────────────────
@@ -2408,7 +2402,7 @@ const HELPER_TOURS = {
description: 'Step-by-step guide to downloading your first album.',
icon: '⬇️',
steps: [
- { page: 'search', selector: '.search-source-picker-container', title: 'Pick a Search Source', description: '"All sources (Auto)" fans out across every provider. Pick a specific one (Spotify, Apple Music, Deezer, etc.) to get results from just that catalog. "Soulseek (raw files)" is the old Basic mode — raw P2P file results with quality filters.' },
+ { page: 'search', selector: '#enh-source-row', title: 'Pick a Search Source', description: 'Each icon is a metadata source. The highlighted one is where your next search goes — defaults to your configured primary source. Click a different icon to switch to Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, Music Videos, or Soulseek (raw P2P files). A small dot marks sources you\'ve already searched for the current query.' },
{ page: 'search', selector: '.enhanced-search-input-wrapper', title: 'Search for Music', description: 'Type an artist or album name here. Results appear in categorized sections — Artists, Albums, Singles/EPs, and Tracks. Try searching for your favorite artist now!' },
{ page: 'search', selector: '#enh-results-container', title: 'Search Results', description: 'After searching, results appear organized by type: Artists at the top as cards, then Albums, Singles/EPs, and individual Tracks. "In Library" badges mark items you already own.' },
{ page: 'search', selector: '.enhanced-search-input-wrapper', title: 'Downloading an Album', description: 'Click any album card to open the download modal. You\'ll see the tracklist, quality options, and a big "Download Album" button. Individual tracks have a play button to preview before downloading.' },
@@ -3449,7 +3443,10 @@ const WHATS_NEW = {
'2.40': [
// --- Search & Artists unification (in progress, not yet released) ---
{ date: 'Unreleased — Search & Artists unification', unreleased: true },
- { title: 'Search Source Picker', desc: 'The Search page\'s Enhanced/Basic toggle is replaced by a single "Search from" dropdown at the top — pick All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Auto keeps today\'s multi-source fan-out; picking a specific source hits only that provider so there are no more surprise Spotify rate-limit hits from flows that didn\'t need Spotify. "Soulseek" routes to the raw-file search (what "Basic" used to do), so one picker now covers both old modes. Loading text reflects the selected source', page: 'search', unreleased: true },
+ { title: 'Search Source Picker Icon Row', desc: 'The Search page now has a row of source icons above the search bar — one per source (Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, Music Videos, Soulseek). Typing searches only the currently-selected source instead of fanning out to every one by default. Click a different icon to switch; results come back on demand. The default icon on page load is your configured primary metadata source. Replaces the short-lived "Search from" dropdown that preceded this', page: 'search', unreleased: true },
+ { title: 'Per-Query Source Cache (No More Re-Fetching)', desc: 'Once you\'ve searched a source for a given query, switching back to it is instant — results are cached for the current query. A small dot on each source icon shows which ones already have cached results this query. Type a new query and the whole cache resets. Same behavior in the sidebar global search popover. Net effect: roughly 6-7x fewer API calls per search compared to the old default fan-out', page: 'search', unreleased: true },
+ { title: 'Global Search Widget Source Parity', desc: 'The sidebar Cmd+K / "/" search popover gained the same source icon row as the full Search page. Pick your source up front, see cache dots for already-fetched sources this query, and the rate-limit fallback banner appears if the backend substituted a different source than the one you clicked. Clicking the Soulseek icon hands off to the full Search page (raw file results need more room than the popover provides)', page: 'search', unreleased: true },
+ { title: 'Rate-Limit Fallback Banner', desc: 'If you click Spotify but the backend auto-fell back to Deezer because Spotify was rate-limited, the search results now lead with a small amber banner ("Spotify unavailable — showing Deezer.") and the Spotify icon gets an amber border. Previously results just silently showed as the fallback source with no signal that anything unusual happened', page: 'search', unreleased: true },
{ title: 'Explicit Source Selection on /api/enhanced-search', desc: 'The enhanced-search endpoint now accepts an optional `source` body param (spotify, itunes, deezer, discogs, hydrabase, musicbrainz, auto). When a specific source is chosen, only that provider is queried and db_artists (local library matches) still come back. Cache keys isolate per-source so single-source and multi-source results don\'t collide. Omitted or `auto` preserves the old multi-source fan-out behavior unchanged — nothing breaks for existing callers', page: 'search', unreleased: true },
{ title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js instead of duplicating the POST boilerplate. Zero UX change, but it means any future source-picker tweak only needs wiring in one place', page: 'search', unreleased: true },
{ title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check', page: 'search', unreleased: true },
From 86e6d8df49a3f16bf10e566ede8162bea4246492 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 23 Apr 2026 13:53:59 -0700
Subject: [PATCH 05/18] Fix source-picker review items: real logos, cached
click close, Soulseek clip
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three follow-up fixes after browser testing:
1. Clicking a source whose results are already cached was closing the
results dropdown. The outside-click handler treated the icon click
as "outside" because the icon row lives above the input wrapper, not
inside it. The icon click handler now calls stopPropagation so the
document handler never runs. Also added an `#enh-source-row`
whitelist to the search-page outside-click handler as a second
layer of defense.
2. The icon chips used generic emojis (🎵, 🍎, 🎶, etc.) which don't
convey brand identity. SOURCE_LABELS now carries a `logo` URL per
source (mirroring the existing constants in core.js): the real
Spotify / Apple Music / Deezer / Discogs / MusicBrainz / Hydrabase /
Soulseek brand logos render as
![]()
inside the chip. Music Videos
stays on emoji since the codebase has no YouTube-specific logo
constant. renderSourceRow (Search page) and _gsSourceRowHtml (global
widget) both honor the new field; loading state still overrides
with an hourglass.
3. When Soulseek was selected, the icon row appeared clipped at the
top of the page. Caused by the flex parent (.downloads-main-panel)
compressing the row when .search-section.active competes for space
with flex-grow:1. Added `flex-shrink: 0` + explicit `overflow-y: visible`
on both .enh-source-row and .gsearch-source-row so the row keeps
its natural height even under layout pressure. Logo
![]()
elements
got explicit 22x22 / 18x18 containers so they render at chip scale
without the inline font-size hack.
---
webui/static/downloads.js | 7 ++++-
webui/static/search.js | 24 +++++++++++++--
webui/static/shared-helpers.js | 53 +++++++++++++++++++++++++++-------
webui/static/style.css | 33 ++++++++++++++++++++-
4 files changed, 102 insertions(+), 15 deletions(-)
diff --git a/webui/static/downloads.js b/webui/static/downloads.js
index 5ae02166..b82e8c76 100644
--- a/webui/static/downloads.js
+++ b/webui/static/downloads.js
@@ -5304,8 +5304,13 @@ function _gsSourceRowHtml() {
const title = fallback
? `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`
: info.text;
+ const glyph = loading
+ ? '⏳'
+ : (info.logo
+ ? `
})
`
+ : info.icon);
return `
`;
}).join('') + '
';
diff --git a/webui/static/search.js b/webui/static/search.js
index e88287b7..e3fe134a 100644
--- a/webui/static/search.js
+++ b/webui/static/search.js
@@ -106,15 +106,29 @@ function initializeSearchModeToggle() {
const title = fallback
? `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`
: info.text;
+ // Prefer the brand logo when available; fall back to the emoji.
+ // Spinner glyph overrides both while loading.
+ const glyph = loading
+ ? '⏳'
+ : (info.logo
+ ? `
})
`
+ : info.icon);
return `
`;
}).join('');
sourceRow.querySelectorAll('.enh-source-icon').forEach(btn => {
- btn.addEventListener('click', () => setActiveSource(btn.dataset.source));
+ btn.addEventListener('click', (e) => {
+ // Stop the outside-click document handler from dismissing the
+ // dropdown. `renderSourceRow` re-renders after a source switch,
+ // detaching this button from the DOM, so the bubbled handler's
+ // `closest('#enh-source-row')` would fail on the detached target.
+ e.stopPropagation();
+ setActiveSource(btn.dataset.source);
+ });
});
}
@@ -424,9 +438,13 @@ function initializeSearchModeToggle() {
const dropdown = document.getElementById('enhanced-dropdown');
if (dropdown && !dropdown.classList.contains('hidden')) {
const isClickInside = e.target.closest('.enhanced-search-input-wrapper');
+ // Source icons live above the input, outside the dropdown — they
+ // control which cached source is shown, so don't dismiss when the
+ // user clicks them.
+ const isClickOnSourceRow = e.target.closest('#enh-source-row');
// Modal sits above the dropdown; closing it shouldn't dismiss results.
const isClickInModal = e.target.closest('.download-missing-modal');
- if (!isClickInside && !isClickInModal) {
+ if (!isClickInside && !isClickOnSourceRow && !isClickInModal) {
hideDropdown();
}
}
diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js
index 6b4b5796..662f881e 100644
--- a/webui/static/shared-helpers.js
+++ b/webui/static/shared-helpers.js
@@ -32,17 +32,50 @@ async function enhancedSearchFetch(query, { source = null, signal = null } = {})
}
// Per-source labels + tab/badge CSS classes + icon glyph for the source
-// picker row. Referenced by both the Search page and the global search
-// widget for consistent badge/icon rendering.
+// picker row. The `logo` URL (when present) renders as an
![]()
in the
+// source-picker chip; `icon` stays as the emoji fallback for sources
+// without a canonical logo. Logo URLs mirror the constants in core.js so
+// both places stay in sync.
const SOURCE_LABELS = {
- spotify: { text: 'Spotify', icon: '🎵', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify' },
- itunes: { text: 'Apple Music', icon: '🍎', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes' },
- deezer: { text: 'Deezer', icon: '🎶', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' },
- discogs: { text: 'Discogs', icon: '📀', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' },
- hydrabase: { text: 'Hydrabase', icon: '💎', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' },
- musicbrainz: { text: 'MusicBrainz', icon: '🧠', tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz' },
- youtube_videos: { text: 'Music Videos', icon: '🎬', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' },
- soulseek: { text: 'Soulseek', icon: '🎼', tabClass: 'enh-tab-soulseek', badgeClass: 'enh-badge-soulseek' },
+ spotify: {
+ text: 'Spotify', icon: '🎵',
+ logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png',
+ tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify',
+ },
+ itunes: {
+ text: 'Apple Music', icon: '🍎',
+ logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png',
+ tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes',
+ },
+ deezer: {
+ text: 'Deezer', icon: '🎶',
+ logo: 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610',
+ tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer',
+ },
+ discogs: {
+ text: 'Discogs', icon: '📀',
+ logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6b/Discogs_icon.svg/960px-Discogs_icon.svg.png',
+ tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs',
+ },
+ hydrabase: {
+ text: 'Hydrabase', icon: '💎',
+ logo: '/static/hydrabase.png',
+ tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase',
+ },
+ musicbrainz: {
+ text: 'MusicBrainz', icon: '🧠',
+ logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png',
+ tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz',
+ },
+ youtube_videos: {
+ text: 'Music Videos', icon: '🎬',
+ tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube',
+ },
+ soulseek: {
+ // No canonical brand logo available — stick with a basic music glyph.
+ text: 'Soulseek', icon: '🎼',
+ tabClass: 'enh-tab-soulseek', badgeClass: 'enh-badge-soulseek',
+ },
};
// Canonical display order for the source picker. Standard metadata sources
diff --git a/webui/static/style.css b/webui/static/style.css
index 307f0bef..4f3b54c0 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -5484,6 +5484,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
+ overflow-y: visible;
gap: 6px;
padding: 10px 14px 8px;
flex-shrink: 0;
@@ -5520,7 +5521,21 @@ body.helper-mode-active #dashboard-activity-feed:hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.14);
}
-.gsearch-source-icon-glyph { font-size: 16px; line-height: 1; }
+.gsearch-source-icon-glyph {
+ font-size: 16px;
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+}
+.gsearch-source-icon-glyph img {
+ width: 18px;
+ height: 18px;
+ object-fit: contain;
+ display: block;
+}
.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); }
@@ -33603,11 +33618,16 @@ div.artist-hero-badge {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
+ overflow-y: visible;
gap: 10px;
padding: 10px 4px 8px;
margin: 8px 0 12px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
+ /* Don't compress when the parent flex container is tight (e.g. when a
+ `.search-section.active` sibling has flex-grow:1 and the viewport
+ forces a layout squeeze — would otherwise clip the icons). */
+ flex-shrink: 0;
}
.enh-source-row::-webkit-scrollbar { height: 6px; }
.enh-source-row::-webkit-scrollbar-track { background: transparent; }
@@ -33644,6 +33664,17 @@ div.artist-hero-badge {
.enh-source-icon-glyph {
font-size: 20px;
line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+}
+.enh-source-icon-glyph img {
+ width: 22px;
+ height: 22px;
+ object-fit: contain;
+ display: block;
}
.enh-source-icon-label {
font-size: 11px;
From f6f8a3e96066824f568a8195e3e5e9566f306d11 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 23 Apr 2026 14:02:40 -0700
Subject: [PATCH 06/18] Source picker visual boost: glassy row, brand-glow
active, pulsing cache dot
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Dresses up the bare chip row so the picker reads as a deliberate piece of
UI rather than a utility bar. Both the Search page (.enh-source-*) and
the global widget (.gsearch-source-*) get the same treatment.
- The row itself is now a frosted-glass panel (subtle white gradient,
inner highlight, rounded 14px / 12px corners, outer shadow) so the
picker feels unified instead of a loose strip of buttons.
- Chips bumped: min-width 90px (row) / 72px (widget), bigger padding,
12px rounded corners, subtle linear gradient top-to-bottom, 30px icons
(up from 22px) with a drop-shadow for depth.
- Hover lifts the chip by 1px with a darker drop-shadow and brighter
border — cheap but effective microinteraction.
- Active state is brand-themed per source: the chip's background
becomes a top-weighted gradient in the service's colour, the border
matches, and an outer brand-coloured glow (6-22px blur) surrounds it.
scale(1.03) pops it above neighbours. Label bumps to 700 weight when
active. Same treatment for Spotify / Apple Music / Deezer / Discogs /
Hydrabase / MusicBrainz / Music Videos / Soulseek.
- Cache dot gets a brand-coloured glow and a subtle 2.4s pulse so the
"already fetched this query" hint is visible without being loud.
- Fallback-warning icons get an amber tint on both border and outer
ring to match the existing fallback banner colour.
---
webui/static/style.css | 264 ++++++++++++++++++++++++++++++-----------
1 file changed, 192 insertions(+), 72 deletions(-)
diff --git a/webui/static/style.css b/webui/static/style.css
index 4f3b54c0..11c0b2f3 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -5479,17 +5479,23 @@ 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) */
+/* Source icon row (replaces post-search tabs) — compact glassy version of
+ the Search-page row so both surfaces share the same language. */
.gsearch-source-row {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: visible;
gap: 6px;
- padding: 10px 14px 8px;
+ padding: 10px 14px 10px;
+ margin: 8px 12px 10px;
flex-shrink: 0;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.015) 100%);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 12px;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.gsearch-source-row::-webkit-scrollbar { height: 4px; }
.gsearch-source-row::-webkit-scrollbar-track { background: transparent; }
@@ -5501,62 +5507,110 @@ body.helper-mode-active #dashboard-activity-feed:hover {
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);
+ gap: 4px;
+ min-width: 72px;
+ padding: 8px 10px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 10px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
+ color: rgba(255, 255, 255, 0.6);
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;
+ transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease,
+ color 0.18s ease, box-shadow 0.18s ease;
}
.gsearch-source-icon:hover {
- background: rgba(255, 255, 255, 0.08);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.09) 0%, rgba(255, 255, 255, 0.04) 100%);
color: #fff;
- border-color: rgba(255, 255, 255, 0.14);
+ border-color: rgba(255, 255, 255, 0.18);
+ transform: translateY(-1px);
+ box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25);
}
+.gsearch-source-icon:active { transform: translateY(0) scale(0.97); }
+
.gsearch-source-icon-glyph {
- font-size: 16px;
+ font-size: 20px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
- width: 18px;
- height: 18px;
+ width: 22px;
+ height: 22px;
+ filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.25));
}
.gsearch-source-icon-glyph img {
- width: 18px;
- height: 18px;
+ width: 22px;
+ height: 22px;
object-fit: contain;
display: block;
}
-.gsearch-source-icon-label { font-size: 10px; letter-spacing: 0.02em; }
+.gsearch-source-icon-label { font-size: 10px; letter-spacing: 0.02em; font-weight: 600; }
+.gsearch-source-icon.active .gsearch-source-icon-label { font-weight: 700; }
-.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.active {
+ transform: scale(1.04);
+ border-color: currentColor;
+}
+.gsearch-source-icon[data-source="spotify"].active {
+ color: #1db954;
+ background: linear-gradient(180deg, rgba(29, 185, 84, 0.28) 0%, rgba(29, 185, 84, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(29, 185, 84, 0.35), 0 4px 16px rgba(29, 185, 84, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.gsearch-source-icon[data-source="itunes"].active {
+ color: #fc3c44;
+ background: linear-gradient(180deg, rgba(252, 60, 68, 0.28) 0%, rgba(252, 60, 68, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(252, 60, 68, 0.35), 0 4px 16px rgba(252, 60, 68, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.gsearch-source-icon[data-source="deezer"].active {
+ color: #a238ff;
+ background: linear-gradient(180deg, rgba(162, 56, 255, 0.28) 0%, rgba(162, 56, 255, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(162, 56, 255, 0.35), 0 4px 16px rgba(162, 56, 255, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.gsearch-source-icon[data-source="discogs"].active {
+ color: #D4A574;
+ background: linear-gradient(180deg, rgba(212, 165, 116, 0.28) 0%, rgba(212, 165, 116, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(212, 165, 116, 0.35), 0 4px 16px rgba(212, 165, 116, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.gsearch-source-icon[data-source="hydrabase"].active {
+ color: #00b4d8;
+ background: linear-gradient(180deg, rgba(0, 180, 216, 0.28) 0%, rgba(0, 180, 216, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(0, 180, 216, 0.35), 0 4px 16px rgba(0, 180, 216, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.gsearch-source-icon[data-source="musicbrainz"].active {
+ color: #BA3358;
+ background: linear-gradient(180deg, rgba(186, 51, 88, 0.28) 0%, rgba(186, 51, 88, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(186, 51, 88, 0.35), 0 4px 16px rgba(186, 51, 88, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.gsearch-source-icon[data-source="youtube_videos"].active {
+ color: #ff4444;
+ background: linear-gradient(180deg, rgba(255, 0, 0, 0.28) 0%, rgba(255, 0, 0, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(255, 0, 0, 0.35), 0 4px 16px rgba(255, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.gsearch-source-icon[data-source="soulseek"].active {
+ color: #fff;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.06) 100%);
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.32), 0 4px 16px rgba(255, 255, 255, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.18);
+}
.gsearch-source-icon.cached::after {
content: '';
position: absolute;
- top: 4px;
- right: 5px;
- width: 5px;
- height: 5px;
+ top: 5px;
+ right: 6px;
+ width: 6px;
+ height: 6px;
border-radius: 50%;
background: currentColor;
- opacity: 0.75;
+ box-shadow: 0 0 4px currentColor;
+ animation: gsearch-source-cache-pulse 2.4s ease-in-out infinite;
+}
+@keyframes gsearch-source-cache-pulse {
+ 0%, 100% { opacity: 0.6; transform: scale(1); }
+ 50% { opacity: 1; transform: scale(1.15); }
}
.gsearch-source-icon.loading .gsearch-source-icon-glyph {
animation: gsearch-source-loading-spin 1.2s linear infinite;
@@ -5566,7 +5620,10 @@ body.helper-mode-active #dashboard-activity-feed:hover {
from { transform: rotate(0); }
to { transform: rotate(360deg); }
}
-.gsearch-source-icon.fallback-warning { border-color: rgba(250, 176, 5, 0.5); }
+.gsearch-source-icon.fallback-warning {
+ border-color: rgba(250, 176, 5, 0.5);
+ box-shadow: 0 0 0 1px rgba(250, 176, 5, 0.25);
+}
.gsearch-fallback-banner {
padding: 6px 14px;
@@ -33620,13 +33677,18 @@ div.artist-hero-badge {
overflow-x: auto;
overflow-y: visible;
gap: 10px;
- padding: 10px 4px 8px;
- margin: 8px 0 12px;
+ padding: 12px 14px;
+ margin: 10px 0 14px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
- /* Don't compress when the parent flex container is tight (e.g. when a
- `.search-section.active` sibling has flex-grow:1 and the viewport
- forces a layout squeeze — would otherwise clip the icons). */
+ /* Frosted-glass panel that holds the icons together visually. */
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.015) 100%);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 14px;
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 2px 10px rgba(0, 0, 0, 0.15);
+ /* Don't compress when the parent flex container is tight. */
flex-shrink: 0;
}
.enh-source-row::-webkit-scrollbar { height: 6px; }
@@ -33639,70 +33701,127 @@ div.artist-hero-badge {
flex-direction: column;
align-items: center;
justify-content: center;
- gap: 4px;
- min-width: 72px;
- padding: 10px 12px;
+ gap: 6px;
+ min-width: 90px;
+ padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 10px;
- background: rgba(255, 255, 255, 0.04);
- color: rgba(255, 255, 255, 0.6);
+ border-radius: 12px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
+ color: rgba(255, 255, 255, 0.65);
cursor: pointer;
- transition: border-color 0.15s, background 0.15s, color 0.15s, transform 0.1s;
+ transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease,
+ color 0.18s ease, box-shadow 0.18s ease;
font-family: inherit;
- font-size: 11px;
+ font-size: 11.5px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.enh-source-icon:hover {
- background: rgba(255, 255, 255, 0.08);
- color: rgba(255, 255, 255, 0.95);
- border-color: rgba(255, 255, 255, 0.16);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.09) 0%, rgba(255, 255, 255, 0.04) 100%);
+ color: #fff;
+ border-color: rgba(255, 255, 255, 0.18);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
}
-.enh-source-icon:active { transform: scale(0.97); }
+.enh-source-icon:active { transform: translateY(0) scale(0.97); }
.enh-source-icon-glyph {
- font-size: 20px;
+ font-size: 26px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
- width: 22px;
- height: 22px;
+ width: 30px;
+ height: 30px;
+ filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.25));
}
.enh-source-icon-glyph img {
- width: 22px;
- height: 22px;
+ width: 30px;
+ height: 30px;
object-fit: contain;
display: block;
}
.enh-source-icon-label {
- font-size: 11px;
+ font-size: 11.5px;
letter-spacing: 0.02em;
+ font-weight: 600;
+}
+.enh-source-icon.active .enh-source-icon-label { font-weight: 700; }
+
+/* Active state — brand-coloured gradient + outer glow. Each source gets its
+ own palette. `transform: scale(1.03)` lifts the chip slightly above its
+ siblings. */
+.enh-source-icon.active {
+ transform: scale(1.03);
+ border-color: currentColor;
+}
+.enh-source-icon[data-source="spotify"].active {
+ color: #1db954;
+ background: linear-gradient(180deg, rgba(29, 185, 84, 0.28) 0%, rgba(29, 185, 84, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(29, 185, 84, 0.35), 0 6px 22px rgba(29, 185, 84, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.enh-source-icon[data-source="itunes"].active {
+ color: #fc3c44;
+ background: linear-gradient(180deg, rgba(252, 60, 68, 0.28) 0%, rgba(252, 60, 68, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(252, 60, 68, 0.35), 0 6px 22px rgba(252, 60, 68, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.enh-source-icon[data-source="deezer"].active {
+ color: #a238ff;
+ background: linear-gradient(180deg, rgba(162, 56, 255, 0.28) 0%, rgba(162, 56, 255, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(162, 56, 255, 0.35), 0 6px 22px rgba(162, 56, 255, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.enh-source-icon[data-source="discogs"].active {
+ color: #D4A574;
+ background: linear-gradient(180deg, rgba(212, 165, 116, 0.28) 0%, rgba(212, 165, 116, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(212, 165, 116, 0.35), 0 6px 22px rgba(212, 165, 116, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.enh-source-icon[data-source="hydrabase"].active {
+ color: #00b4d8;
+ background: linear-gradient(180deg, rgba(0, 180, 216, 0.28) 0%, rgba(0, 180, 216, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(0, 180, 216, 0.35), 0 6px 22px rgba(0, 180, 216, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.enh-source-icon[data-source="musicbrainz"].active {
+ color: #BA3358;
+ background: linear-gradient(180deg, rgba(186, 51, 88, 0.28) 0%, rgba(186, 51, 88, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(186, 51, 88, 0.35), 0 6px 22px rgba(186, 51, 88, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.enh-source-icon[data-source="youtube_videos"].active {
+ color: #ff4444;
+ background: linear-gradient(180deg, rgba(255, 0, 0, 0.28) 0%, rgba(255, 0, 0, 0.08) 100%);
+ box-shadow: 0 0 0 1px rgba(255, 0, 0, 0.35), 0 6px 22px rgba(255, 0, 0, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.12);
+}
+.enh-source-icon[data-source="soulseek"].active {
+ color: #fff;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.06) 100%);
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.32), 0 6px 22px rgba(255, 255, 255, 0.15),
+ inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
-/* Active per-source accent — reuses the tab color palette for consistency. */
-.enh-source-icon[data-source="spotify"].active { background: rgba(29, 185, 84, 0.18); color: #1db954; border-color: rgba(29, 185, 84, 0.45); }
-.enh-source-icon[data-source="itunes"].active { background: rgba(252, 60, 68, 0.18); color: #fc3c44; border-color: rgba(252, 60, 68, 0.45); }
-.enh-source-icon[data-source="deezer"].active { background: rgba(162, 56, 255, 0.18); color: #a238ff; border-color: rgba(162, 56, 255, 0.45); }
-.enh-source-icon[data-source="discogs"].active { background: rgba(212, 165, 116, 0.18); color: #D4A574; border-color: rgba(212, 165, 116, 0.45); }
-.enh-source-icon[data-source="hydrabase"].active { background: rgba(0, 180, 216, 0.18); color: #00b4d8; border-color: rgba(0, 180, 216, 0.45); }
-.enh-source-icon[data-source="musicbrainz"].active { background: rgba(186, 51, 88, 0.18); color: #BA3358; border-color: rgba(186, 51, 88, 0.45); }
-.enh-source-icon[data-source="youtube_videos"].active { background: rgba(255, 0, 0, 0.18); color: #ff4444; border-color: rgba(255, 0, 0, 0.45); }
-.enh-source-icon[data-source="soulseek"].active { background: rgba(255, 255, 255, 0.14); color: #fff; border-color: rgba(255, 255, 255, 0.3); }
-
-/* Cache dot — small indicator on icons that already have results for the
- current query. Placed top-right to avoid clashing with the label. */
+/* Cache dot — brand-coloured pulse on icons that have results cached for
+ the current query. */
.enh-source-icon.cached::after {
content: '';
position: absolute;
- top: 6px;
- right: 8px;
- width: 6px;
- height: 6px;
+ top: 7px;
+ right: 9px;
+ width: 7px;
+ height: 7px;
border-radius: 50%;
background: currentColor;
- opacity: 0.8;
+ box-shadow: 0 0 6px currentColor;
+ animation: enh-source-cache-pulse 2.4s ease-in-out infinite;
+}
+@keyframes enh-source-cache-pulse {
+ 0%, 100% { opacity: 0.6; transform: scale(1); }
+ 50% { opacity: 1; transform: scale(1.15); }
}
.enh-source-icon.loading .enh-source-icon-glyph {
@@ -33716,6 +33835,7 @@ div.artist-hero-badge {
.enh-source-icon.fallback-warning {
border-color: rgba(250, 176, 5, 0.5);
+ box-shadow: 0 0 0 1px rgba(250, 176, 5, 0.25);
}
/* Rate-limit fallback banner above the enhanced results. */
From ec0c425e714d421a4582170e86de7c83d9f2cb62 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 23 Apr 2026 14:07:52 -0700
Subject: [PATCH 07/18] Global widget: drop the double-panel look on the source
row
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The global search popover already draws its own frosted-glass panel
(via .gsearch-results), so putting another bordered/gradient container
around the source icons inside it read as "panel inside a panel" —
visually noisy and left a dark empty strip on the right when the row
didn't fill the popover width.
Strip the source row's own background/border, center-align the chips
(justify-content: center) so they stay grouped instead of drifting to
the left, and keep a subtle bottom divider so the icons still read as
a distinct control group above the results.
---
webui/static/style.css | 15 +++++++--------
1 file changed, 7 insertions(+), 8 deletions(-)
diff --git a/webui/static/style.css b/webui/static/style.css
index 11c0b2f3..f3aeefc1 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -5479,23 +5479,22 @@ 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) — compact glassy version of
- the Search-page row so both surfaces share the same language. */
+/* Source icon row in the global widget. The popover itself is already a
+ glass panel, so this row is just a transparent flex strip — no double
+ border/background. justify-content: center keeps the chips grouped
+ instead of drifting to the left when they don't fill the popover width. */
.gsearch-source-row {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: visible;
+ justify-content: center;
gap: 6px;
- padding: 10px 14px 10px;
- margin: 8px 12px 10px;
+ padding: 12px 14px 8px;
flex-shrink: 0;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
- background: linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.015) 100%);
- border: 1px solid rgba(255, 255, 255, 0.06);
- border-radius: 12px;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.gsearch-source-row::-webkit-scrollbar { height: 4px; }
.gsearch-source-row::-webkit-scrollbar-track { background: transparent; }
From c605904a5cc35352ab971d68d5881ca9bc8d7d4f Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 23 Apr 2026 14:18:07 -0700
Subject: [PATCH 08/18] Source picker: dim unconfigured sources, redirect to
Settings on click
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The picker used to render every source whether or not the user had
credentials for it. Clicking Discogs with no token, Hydrabase with no
URL, or Spotify with nothing saved would fire a doomed fetch — at best
a silent empty state, at worst a confusing fallback to another source.
Now the picker reads /api/settings/config-status (the same endpoint the
Settings → Connections page already uses for the green/yellow status
dot) on init and dims icons whose service isn't set up. Clicking a
dimmed icon navigates to Settings → Connections and scrolls to the
relevant service card with a brief accent-coloured pulse to orient
the user.
Sources the backend's SERVICE_CONFIG_REGISTRY doesn't cover
(musicbrainz, youtube_videos, soulseek) are permanently treated as
configured — they need no user credentials, so dimming them would
mislead.
Extra guard: if the user's configured primary metadata source is
itself unconfigured (Spotify saved as primary but no client_id yet),
`_initDefaultSource` falls forward to the first configured source so
the default active icon is never a "set up" chip.
Shared helpers:
- fetchSourceConfiguredMap() centralizes the config-status lookup for
both surfaces. Falls back permissively if the endpoint fails so the
picker never stops working over a network hiccup.
- openSettingsForSource(src) navigates to Settings → Connections and
scrolls to `[data-service=src]`, pulsing a 2.2s accent flash
(.stg-service-flash) so the user doesn't lose their place.
CSS:
- .unconfigured: 42% opacity, 0.7 grayscale filter, subdued hover
state with no transform/glow (feels "look but don't touch"),
defensive override to kill brand glow if somehow active.
- @keyframes stg-service-flash-anim for the scroll-to highlight.
---
webui/static/downloads.js | 36 ++++++++++++++++++---
webui/static/search.js | 43 +++++++++++++++++++++++--
webui/static/shared-helpers.js | 50 +++++++++++++++++++++++++++++
webui/static/style.css | 58 ++++++++++++++++++++++++++++++++++
4 files changed, 180 insertions(+), 7 deletions(-)
diff --git a/webui/static/downloads.js b/webui/static/downloads.js
index b82e8c76..a772d9e5 100644
--- a/webui/static/downloads.js
+++ b/webui/static/downloads.js
@@ -5022,10 +5022,14 @@ const _gsState = {
sources: {}, // src -> {artists, albums, tracks, videos, db_artists, ...}
fallbacks: {}, // src -> actual source served when backend fell back (rate-limit)
loadingSources: new Set(),
+ configuredSources: {}, // src -> bool, populated from /api/settings/config-status
abortCtrl: null,
debounceTimer: null,
_defaultSourceResolved: false,
};
+// Permissive initial map — replaced by the real config-status lookup on
+// first popover open. Prevents a flash of "all unconfigured" icons.
+for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true;
(function initGlobalSearch() {
// Defer init until DOM is ready
@@ -5160,6 +5164,16 @@ async function _gsInitDefaultSource() {
}
} catch (_) { /* best-effort */ }
if (!SOURCE_LABELS[_gsState.activeSource]) _gsState.activeSource = 'spotify';
+ // Fetch per-source configured state so unconfigured icons render dimmed.
+ try {
+ _gsState.configuredSources = await fetchSourceConfiguredMap();
+ } catch (_) { /* keep optimistic default */ }
+ // If the configured primary is itself unconfigured, fall forward to the
+ // first configured source so the default active icon is actually usable.
+ if (_gsState.configuredSources[_gsState.activeSource] === false) {
+ const firstConfigured = SOURCE_ORDER.find(s => _gsState.configuredSources[s] !== false);
+ if (firstConfigured) _gsState.activeSource = firstConfigured;
+ }
}
async function _gsPerformSearch(query) {
@@ -5296,14 +5310,21 @@ function _gsSourceRowHtml() {
const cached = !!_gsState.sources[src];
const loading = _gsState.loadingSources.has(src);
const fallback = _gsState.fallbacks[src];
+ const configured = _gsState.configuredSources[src] !== false;
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;
+ fallback ? 'fallback-warning' : '',
+ configured ? '' : 'unconfigured'].filter(Boolean).join(' ');
+ let title;
+ if (!configured) {
+ title = `${info.text} — set up in Settings`;
+ } else if (fallback) {
+ title = `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`;
+ } else {
+ title = info.text;
+ }
const glyph = loading
? '⏳'
: (info.logo
@@ -5487,6 +5508,13 @@ function _gsSetActiveSource(src) {
if (!SOURCE_LABELS[src]) return;
_gsState._lastInteraction = Date.now();
+ // Not configured — jump to Settings instead of firing a doomed search.
+ if (_gsState.configuredSources[src] === false) {
+ _gsDeactivate();
+ openSettingsForSource(src);
+ return;
+ }
+
// Soulseek — hand off to the Search page since its raw file results need
// more room than the popover provides.
if (src === 'soulseek') {
diff --git a/webui/static/search.js b/webui/static/search.js
index e3fe134a..73e781b8 100644
--- a/webui/static/search.js
+++ b/webui/static/search.js
@@ -73,6 +73,13 @@ function initializeSearchModeToggle() {
loadingSources: new Set(),
};
+ // Which sources have credentials saved. Populated asynchronously on init
+ // via /api/settings/config-status. Unconfigured sources render dimmed
+ // and clicking them redirects to Settings → Connections instead of
+ // firing a search.
+ let _configuredSources = {};
+ for (const src of SOURCE_ORDER) _configuredSources[src] = true; // optimistic default
+
// Initialize enhanced search
const enhancedInput = document.getElementById('enhanced-search-input');
const enhancedSearchBtn = document.getElementById('enhanced-search-btn');
@@ -96,16 +103,23 @@ function initializeSearchModeToggle() {
const cached = !!_cachedData.sources[src];
const loading = _cachedData.loadingSources.has(src);
const fallback = _cachedData.fallbacks[src];
+ const configured = _configuredSources[src] !== false;
const classes = [
'enh-source-icon',
active ? 'active' : '',
cached ? 'cached' : '',
loading ? 'loading' : '',
fallback ? 'fallback-warning' : '',
+ configured ? '' : 'unconfigured',
].filter(Boolean).join(' ');
- const title = fallback
- ? `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`
- : info.text;
+ let title;
+ if (!configured) {
+ title = `${info.text} — set up in Settings`;
+ } else if (fallback) {
+ title = `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`;
+ } else {
+ title = info.text;
+ }
// Prefer the brand logo when available; fall back to the emoji.
// Spinner glyph overrides both while loading.
const glyph = loading
@@ -144,6 +158,20 @@ function initializeSearchModeToggle() {
}
} catch (_) { /* settings fetch best-effort */ }
if (!SOURCE_LABELS[currentSearchSource]) currentSearchSource = 'spotify';
+ // Pull per-source configured state in parallel so the dimmed icons
+ // don't flash on first render. Errors fall through to the optimistic
+ // default set at init.
+ try {
+ _configuredSources = await fetchSourceConfiguredMap();
+ } catch (_) { /* keep optimistic default */ }
+ // If the configured primary turns out to be unconfigured (e.g.
+ // spotify saved as primary but the user never entered credentials),
+ // bump to the first source that is configured so the default click
+ // doesn't land on a dimmed "set up" icon.
+ if (_configuredSources[currentSearchSource] === false) {
+ const firstConfigured = SOURCE_ORDER.find(s => _configuredSources[s] !== false);
+ if (firstConfigured) currentSearchSource = firstConfigured;
+ }
renderSourceRow();
}
_initDefaultSource();
@@ -151,6 +179,15 @@ function initializeSearchModeToggle() {
// ── Source selection ───────────────────────────────────────────────
function setActiveSource(src) {
if (!SOURCE_LABELS[src]) return;
+
+ // Not configured — jump to the relevant card in Settings rather than
+ // firing a search that can't succeed. Don't swap activeSource so the
+ // user's previous pick stays current when they come back.
+ if (_configuredSources[src] === false) {
+ openSettingsForSource(src);
+ return;
+ }
+
if (src === currentSearchSource) return;
currentSearchSource = src;
diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js
index 662f881e..87062be1 100644
--- a/webui/static/shared-helpers.js
+++ b/webui/static/shared-helpers.js
@@ -85,6 +85,56 @@ const SOURCE_ORDER = [
'youtube_videos', 'soulseek',
];
+// 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']);
+
+// Fetch /api/settings/config-status and return a map { src -> bool }
+// covering every source in SOURCE_ORDER. Sources not present in the backend
+// registry (musicbrainz / youtube_videos / soulseek) are reported as
+// configured so the picker doesn't dim always-available sources.
+async function fetchSourceConfiguredMap() {
+ const map = {};
+ try {
+ const resp = await fetch('/api/settings/config-status');
+ if (resp.ok) {
+ const data = await resp.json();
+ for (const src of SOURCE_ORDER) {
+ if (_ALWAYS_CONFIGURED_SOURCES.has(src)) {
+ map[src] = true;
+ } else {
+ map[src] = !!(data[src] && data[src].configured);
+ }
+ }
+ return map;
+ }
+ } catch (_) { /* fall through to conservative default */ }
+ // Network / endpoint failure — be permissive rather than dim everything.
+ for (const src of SOURCE_ORDER) map[src] = true;
+ return map;
+}
+
+// Navigate to Settings → Connections tab and scroll to the service card that
+// matches the picker's source id. Called when a user clicks an unconfigured
+// source icon.
+function openSettingsForSource(src) {
+ if (typeof navigateToPage !== 'function') return;
+ navigateToPage('settings');
+ setTimeout(() => {
+ try {
+ if (typeof switchSettingsTab === 'function') switchSettingsTab('connections');
+ } 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);
+ }
+ }, 120);
+ }, 60);
+}
+
// 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.
diff --git a/webui/static/style.css b/webui/static/style.css
index f3aeefc1..ada5555e 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -5624,6 +5624,39 @@ body.helper-mode-active #dashboard-activity-feed:hover {
box-shadow: 0 0 0 1px rgba(250, 176, 5, 0.25);
}
+/* Same unconfigured treatment as the Search page icons. */
+.gsearch-source-icon.unconfigured {
+ opacity: 0.42;
+ filter: grayscale(0.7);
+ background: rgba(255, 255, 255, 0.02);
+ border-color: rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.5);
+}
+.gsearch-source-icon.unconfigured:hover {
+ opacity: 0.75;
+ filter: grayscale(0.35);
+ transform: none;
+ box-shadow: none;
+ border-color: rgba(255, 255, 255, 0.12);
+}
+.gsearch-source-icon.unconfigured.active {
+ background: rgba(255, 255, 255, 0.02);
+ box-shadow: none;
+ transform: none;
+}
+
+/* Flash highlight on the Settings service card after scrolling to it via
+ the picker. Two and a half seconds of gentle accent pulse so the user's
+ eye catches the card. */
+.stg-service.stg-service-flash {
+ animation: stg-service-flash-anim 2.2s ease-out;
+}
+@keyframes stg-service-flash-anim {
+ 0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.55); }
+ 35% { box-shadow: 0 0 0 6px rgba(var(--accent-rgb), 0.25); }
+ 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); }
+}
+
.gsearch-fallback-banner {
padding: 6px 14px;
margin: 0 12px 6px;
@@ -33837,6 +33870,31 @@ div.artist-hero-badge {
box-shadow: 0 0 0 1px rgba(250, 176, 5, 0.25);
}
+/* Unconfigured — no credentials saved for this source. The chip still
+ clicks (redirects to Settings → Connections), but looks muted so the
+ user's eye is drawn to the sources that actually work. */
+.enh-source-icon.unconfigured {
+ opacity: 0.42;
+ filter: grayscale(0.7);
+ background: rgba(255, 255, 255, 0.02);
+ border-color: rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.5);
+}
+.enh-source-icon.unconfigured:hover {
+ opacity: 0.75;
+ filter: grayscale(0.35);
+ transform: none;
+ box-shadow: none;
+ border-color: rgba(255, 255, 255, 0.12);
+}
+/* Kill brand glow / active gradient if an unconfigured source is somehow
+ marked active (defensive — setActiveSource bails before this normally). */
+.enh-source-icon.unconfigured.active {
+ background: rgba(255, 255, 255, 0.02);
+ box-shadow: none;
+ transform: none;
+}
+
/* Rate-limit fallback banner above the enhanced results. */
.enh-fallback-banner {
padding: 8px 12px;
From dd20298df4ef9598ea7511acc693392253c1e8f1 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 23 Apr 2026 14:27:29 -0700
Subject: [PATCH 09/18] Library page empty state: offer to search metadata
sources for the query
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When a user types an artist name into the library search and gets no
hits, the old empty state just said "No artists found — try adjusting
your search or filters." Dead end for the common case of "I searched
for someone I don't own yet."
The empty state now detects when libraryPageState.currentSearch is
non-empty and swaps in a CTA that hands the query off to /search:
"kendrick" isn't in your library
They might be available on a connected metadata source.
[🔍 Search online for "kendrick" →]
Clicking the button navigates to /search, pre-fills the enhanced search
input, and dispatches an input event so the existing debounced search
fires automatically. Uses the same hand-off pattern _gsNavigateToSearchPage
already uses for Soulseek, so the Search page's source-picker flow
picks up naturally from there.
No change to the generic empty state (no query active) or to any other
library page behaviour.
---
webui/index.html | 16 +++++++++---
webui/static/library.js | 57 +++++++++++++++++++++++++++++++++++++----
webui/static/style.css | 33 ++++++++++++++++++++++++
3 files changed, 97 insertions(+), 9 deletions(-)
diff --git a/webui/index.html b/webui/index.html
index 218b0005..310b8fca 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -2307,11 +2307,19 @@
diff --git a/webui/static/library.js b/webui/static/library.js
index b74a30ed..0cc1efc0 100644
--- a/webui/static/library.js
+++ b/webui/static/library.js
@@ -358,13 +358,60 @@ function showLibraryLoading(show) {
function showLibraryEmpty(show) {
const emptyElement = document.getElementById("library-empty");
- if (emptyElement) {
- if (show) {
- emptyElement.classList.remove("hidden");
- } else {
- emptyElement.classList.add("hidden");
+ if (!emptyElement) return;
+ if (!show) {
+ emptyElement.classList.add("hidden");
+ return;
+ }
+
+ // When a search query is active and returned zero library hits, swap the
+ // generic "no artists" copy for a CTA that hands the query off to /search
+ // so the user can look the artist up across metadata sources without
+ // retyping.
+ const query = (libraryPageState.currentSearch || '').trim();
+ const iconEl = document.getElementById('library-empty-icon');
+ const titleEl = document.getElementById('library-empty-title');
+ const subtitleEl = document.getElementById('library-empty-subtitle');
+ const ctaEl = document.getElementById('library-empty-search-cta');
+ const ctaQueryEl = document.getElementById('library-empty-search-cta-query');
+
+ if (query) {
+ if (iconEl) iconEl.textContent = '🔎';
+ if (titleEl) titleEl.textContent = `"${query}" isn't in your library`;
+ if (subtitleEl) subtitleEl.textContent = 'They might be available on a connected metadata source.';
+ if (ctaQueryEl) ctaQueryEl.textContent = `"${query}"`;
+ if (ctaEl) {
+ ctaEl.classList.remove('hidden');
+ // Rebind cleanly — onclick avoids duplicate listeners across renders.
+ ctaEl.onclick = () => _handoffLibrarySearchToEnhancedSearch(query);
+ }
+ } else {
+ if (iconEl) iconEl.textContent = '🎵';
+ if (titleEl) titleEl.textContent = 'No artists found';
+ if (subtitleEl) subtitleEl.textContent = 'Try adjusting your search or filters';
+ if (ctaEl) {
+ ctaEl.classList.add('hidden');
+ ctaEl.onclick = null;
}
}
+
+ emptyElement.classList.remove("hidden");
+}
+
+// Navigate to /search and pre-fill the enhanced search input with the query
+// the user had typed into the library search. Uses the same hand-off pattern
+// the global widget uses for Soulseek — navigate, then dispatch an `input`
+// event so the Search page's existing debounced search kicks in.
+function _handoffLibrarySearchToEnhancedSearch(query) {
+ if (typeof navigateToPage !== 'function') return;
+ navigateToPage('search');
+ setTimeout(() => {
+ const input = document.getElementById('enhanced-search-input');
+ if (input && query) {
+ input.value = query;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ }, 300);
}
async function openWatchAllUnwatchedModal() {
diff --git a/webui/static/style.css b/webui/static/style.css
index ada5555e..b2d70932 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -23054,6 +23054,39 @@ body.helper-mode-active #dashboard-activity-feed:hover {
max-width: 400px;
}
+/* Hand-off CTA shown in the library empty state when the user's search
+ returns no library matches — offers to run the same query against the
+ configured metadata source on the /search page. */
+.library-empty-search-cta {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 8px;
+ padding: 12px 20px;
+ background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.22), rgba(var(--accent-rgb), 0.08));
+ border: 1px solid rgba(var(--accent-rgb), 0.35);
+ border-radius: 10px;
+ color: rgb(var(--accent-light-rgb, var(--accent-rgb)));
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
+}
+.library-empty-search-cta:hover {
+ transform: translateY(-1px);
+ border-color: rgba(var(--accent-rgb), 0.55);
+ box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.22);
+}
+.library-empty-search-cta:active { transform: translateY(0); }
+.library-empty-search-cta-icon { font-size: 16px; }
+.library-empty-search-cta-arrow {
+ font-weight: 700;
+ transition: transform 0.15s ease;
+}
+.library-empty-search-cta:hover .library-empty-search-cta-arrow { transform: translateX(3px); }
+#library-empty-search-cta-query { color: #fff; }
+
/* Pagination */
.library-pagination {
display: flex;
From 30ab21c0e54f3b7ad4806d60e996c950e93a24e9 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 23 Apr 2026 15:00:32 -0700
Subject: [PATCH 10/18] Global search bar: ambient accent-glow aura under the
pill
Adds a subtle radial glow at the bottom of the viewport that emanates
from the floating search bar, fades outward toward both window corners,
and shrinks vertically as it moves away from the bar. Makes the bar
easier to spot at a glance without a heavy full-width bar or a chrome
strip.
- New `.gsearch-aura` fixed element, 260px tall, full width, pointer
events off. Radial-gradient with the accent color centered at the
bottom middle; colour stops taper 620x230px by default, ramping to
820x280px and brighter when the bar is focused/active.
- `_gsUpdateVisibility` hides the aura on /search alongside the bar
via a simple `.hidden` class.
- Focus handler adds `.active` to the aura in step with the bar;
`_gsDeactivate` removes it. z-index 99990 (below the bar at 99998,
above most page content).
---
webui/index.html | 5 +++++
webui/static/downloads.js | 6 ++++++
webui/static/style.css | 32 ++++++++++++++++++++++++++++++++
3 files changed, 43 insertions(+)
diff --git a/webui/index.html b/webui/index.html
index 310b8fca..cd2838b1 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -7886,6 +7886,11 @@
+
+
diff --git a/webui/static/downloads.js b/webui/static/downloads.js
index a772d9e5..ec55d38f 100644
--- a/webui/static/downloads.js
+++ b/webui/static/downloads.js
@@ -5043,6 +5043,8 @@ for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true;
input.addEventListener('focus', () => {
bar.classList.add('active');
+ const aura = document.getElementById('gsearch-aura');
+ if (aura) aura.classList.add('active');
_gsState.active = true;
const shortcut = document.getElementById('gsearch-shortcut');
if (shortcut) shortcut.style.display = 'none';
@@ -5125,18 +5127,22 @@ for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true;
function _gsUpdateVisibility() {
const bar = document.getElementById('gsearch-bar');
+ const aura = document.getElementById('gsearch-aura');
if (!bar) return;
// Hide on the Search page where the unified search already exists. Accept the
// legacy 'downloads' id for callers that predate the page rename.
const onSearchPage = typeof currentPage !== 'undefined' && (currentPage === 'search' || currentPage === 'downloads');
bar.style.display = onSearchPage ? 'none' : '';
+ if (aura) aura.classList.toggle('hidden', onSearchPage);
if (onSearchPage && _gsState.active) _gsDeactivate();
}
function _gsDeactivate() {
const bar = document.getElementById('gsearch-bar');
+ const aura = document.getElementById('gsearch-aura');
const shortcut = document.getElementById('gsearch-shortcut');
if (bar) bar.classList.remove('active');
+ if (aura) aura.classList.remove('active');
if (shortcut) shortcut.style.display = '';
_gsState.active = false;
_gsHideResults();
diff --git a/webui/static/style.css b/webui/static/style.css
index b2d70932..c7743e65 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -5336,6 +5336,38 @@ body.helper-mode-active #dashboard-activity-feed:hover {
GLOBAL SEARCH BAR — Spotlight-style search from anywhere
================================================================================== */
+/* Ambient glow under the global search bar — a radial gradient that emanates
+ from the bar's position and tapers out toward the window corners. Pointer
+ events disabled so it never intercepts clicks; hidden on /search where the
+ bar itself is hidden. */
+.gsearch-aura {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 260px;
+ pointer-events: none;
+ z-index: 99990; /* below the bar (99998) but above most page content */
+ opacity: 0.55;
+ transition: opacity 0.4s ease, background 0.4s ease;
+ background:
+ radial-gradient(ellipse 620px 230px at 50% 100%,
+ rgba(var(--accent-rgb), 0.22) 0%,
+ rgba(var(--accent-rgb), 0.10) 32%,
+ rgba(var(--accent-rgb), 0.03) 62%,
+ transparent 85%);
+}
+.gsearch-aura.hidden { display: none; }
+.gsearch-aura.active {
+ opacity: 1;
+ background:
+ radial-gradient(ellipse 820px 280px at 50% 100%,
+ rgba(var(--accent-rgb), 0.40) 0%,
+ rgba(var(--accent-rgb), 0.18) 28%,
+ rgba(var(--accent-rgb), 0.05) 58%,
+ transparent 85%);
+}
+
.gsearch-bar {
position: fixed;
bottom: 24px;
From 481d3d940f972c1c76a5505a1fce9db4d38f980e Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 23 Apr 2026 15:46:58 -0700
Subject: [PATCH 11/18] Mobile responsiveness for source picker, aura, and
library empty CTA
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The new components shipped this PR (source icon row, fallback banner,
glow aura, library-empty search CTA) had no responsive styling. On
phones the rows ran fine via horizontal scroll but the chips wasted a
lot of space per icon, the CTA could overflow on narrow screens, and
the aura kept its desktop-sized ellipse for no benefit.
At ≤768px (tablet/phone):
- Enhanced source row: tighter padding, 24x24 glyphs, 72px chip
min-width.
- Global widget source row: even tighter, 20x20 glyphs, 62px chips.
- Fallback banners scale down to match.
- Aura shrinks to a 440x160 / 540x200 ellipse and a 180px-tall strip
so it doesn't eat short mobile viewports.
- Library empty CTA allows text wrap + reduced padding so the
"Search online for "long artist name"" string doesn't break the
layout on narrow screens.
At ≤480px (phone):
- Enhanced chips drop to 44px min-width.
- Global widget chips drop to 40px.
- Both hide the source-name label, showing icon + tooltip only — the
full 8-source row now fits or scrolls minimally at that scale.
- Aura narrows further to 140px tall.
- Library CTA nudges down to 12px font.
---
webui/static/mobile.css | 115 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 115 insertions(+)
diff --git a/webui/static/mobile.css b/webui/static/mobile.css
index 38dba241..bb0428c5 100644
--- a/webui/static/mobile.css
+++ b/webui/static/mobile.css
@@ -3627,4 +3627,119 @@
width: calc(100vw - 16px);
bottom: 54px;
}
+}
+
+/* ─────────────────────────────────────────────────────────────
+ Source picker row — Search page + global widget responsive.
+ Chips shrink on tablet, labels drop on phone so the full row
+ of 8 sources remains scannable without hijacking the screen. */
+@media (max-width: 768px) {
+ .enh-source-row {
+ padding: 8px 8px;
+ margin: 8px 0 10px;
+ gap: 6px;
+ border-radius: 12px;
+ }
+ .enh-source-icon {
+ min-width: 72px;
+ padding: 8px 10px;
+ gap: 4px;
+ font-size: 10.5px;
+ }
+ .enh-source-icon-glyph,
+ .enh-source-icon-glyph img {
+ width: 24px;
+ height: 24px;
+ }
+ .enh-source-icon-label { font-size: 10.5px; }
+ .enh-fallback-banner {
+ font-size: 11px;
+ padding: 6px 10px;
+ }
+
+ /* Global widget source row (inside the popover) */
+ .gsearch-source-row {
+ padding: 10px 10px 6px;
+ gap: 4px;
+ }
+ .gsearch-source-icon {
+ min-width: 62px;
+ padding: 6px 8px;
+ gap: 3px;
+ }
+ .gsearch-source-icon-glyph,
+ .gsearch-source-icon-glyph img {
+ width: 20px;
+ height: 20px;
+ }
+ .gsearch-fallback-banner {
+ font-size: 10px;
+ padding: 5px 10px;
+ }
+
+ /* Ambient glow under the global search — trim size so it doesn't
+ dominate a short mobile viewport. */
+ .gsearch-aura {
+ height: 180px;
+ background:
+ radial-gradient(ellipse 440px 160px at 50% 100%,
+ rgba(var(--accent-rgb), 0.20) 0%,
+ rgba(var(--accent-rgb), 0.08) 35%,
+ rgba(var(--accent-rgb), 0.02) 65%,
+ transparent 85%);
+ }
+ .gsearch-aura.active {
+ background:
+ radial-gradient(ellipse 540px 200px at 50% 100%,
+ rgba(var(--accent-rgb), 0.34) 0%,
+ rgba(var(--accent-rgb), 0.14) 28%,
+ rgba(var(--accent-rgb), 0.04) 58%,
+ transparent 85%);
+ }
+
+ /* Library empty-state hand-off CTA — allow wrap on narrow screens
+ so long artist queries don't overflow. */
+ .library-empty-search-cta {
+ flex-wrap: wrap;
+ justify-content: center;
+ padding: 10px 14px;
+ font-size: 13px;
+ max-width: 100%;
+ }
+ .library-empty-search-cta-text {
+ white-space: normal;
+ text-align: center;
+ }
+}
+
+/* Very narrow phones — drop the icon labels entirely and show just the
+ glyphs. Tap target stays big enough, row compacts dramatically. */
+@media (max-width: 480px) {
+ .enh-source-row {
+ padding: 6px 6px;
+ gap: 4px;
+ border-radius: 10px;
+ }
+ .enh-source-icon {
+ min-width: 44px;
+ padding: 6px 8px;
+ }
+ .enh-source-icon-label { display: none; }
+
+ .gsearch-source-row {
+ padding: 8px 8px 5px;
+ gap: 4px;
+ }
+ .gsearch-source-icon {
+ min-width: 40px;
+ padding: 5px 7px;
+ }
+ .gsearch-source-icon-label { display: none; }
+
+ .gsearch-aura { height: 140px; }
+
+ .library-empty-search-cta {
+ font-size: 12px;
+ padding: 9px 12px;
+ }
}
\ No newline at end of file
From 9f632806773558017589b0dd83644a6ace37e805 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 23 Apr 2026 16:14:08 -0700
Subject: [PATCH 12/18] Extract source-picker into shared
createSearchController factory
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both the Search page and the global search widget ran the same source-
picker state machine (query, activeSource, per-query cache, fallbacks,
loading set, configured-source discovery, NDJSON streaming for YouTube,
default-source fall-forward). That was ~380 lines of near-duplicated
logic split across search.js and downloads.js, which meant every bug fix
or behavior tweak had to land twice and inevitably drifted.
createSearchController in shared-helpers.js now owns all of that. Each
surface passes per-surface wiring — a source-row DOM element, a CSS
class prefix, and callbacks for Soulseek handoff + unconfigured-source
redirect — and consumes the controller's state via an onStateChange
callback. The surface files shrink to their actual responsibilities:
results rendering, click handlers, and surface-specific visibility.
Zero UX change. Every keystroke, icon click, cache hit, rate-limit
fallback, and unconfigured-source redirect behaves identically to before
— verified via full pytest suite (395 passed) and node --check on all
three files.
WHATS_NEW entry added under the 2.40 unified-search bucket.
---
webui/static/downloads.js | 353 +++++++--------------------
webui/static/helper.js | 1 +
webui/static/search.js | 426 ++++++++-------------------------
webui/static/shared-helpers.js | 302 +++++++++++++++++++++++
4 files changed, 501 insertions(+), 581 deletions(-)
diff --git a/webui/static/downloads.js b/webui/static/downloads.js
index ec55d38f..75e1607d 100644
--- a/webui/static/downloads.js
+++ b/webui/static/downloads.js
@@ -5013,23 +5013,16 @@ function _gsClickVideo(cardEl) {
// GLOBAL SEARCH BAR — Spotlight-style search from anywhere
// ==================================================================================
+// Popover-only state. Query/source/cache/config all live in `_gsController`
+// (shared with the Search page via createSearchController in shared-helpers.js).
const _gsState = {
active: false,
- query: '',
- 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(),
- configuredSources: {}, // src -> bool, populated from /api/settings/config-status
- abortCtrl: null,
+ _lastInteraction: 0,
debounceTimer: null,
- _defaultSourceResolved: false,
};
-// Permissive initial map — replaced by the real config-status lookup on
-// first popover open. Prevents a flash of "all unconfigured" icons.
-for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true;
+
+// Shared source-picker controller — built on DOM-ready in `_doInit`.
+let _gsController = null;
(function initGlobalSearch() {
// Defer init until DOM is ready
@@ -5037,7 +5030,26 @@ for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true;
const bar = document.getElementById('gsearch-bar');
const input = document.getElementById('gsearch-input');
const results = document.getElementById('gsearch-results');
- if (!input || !bar) return;
+ if (!input || !bar || !results) return;
+
+ // Build the stable results-panel structure up front so the controller
+ // has a sourceRow element to render into on its first _notify().
+ results.innerHTML = `
+
+
+
+ `;
+
+ _gsController = createSearchController({
+ sourceRowElement: document.getElementById('gsearch-source-row'),
+ iconClassPrefix: 'gsearch',
+ onStateChange: _gsRenderFromState,
+ onSoulseekSelected: (query) => _gsNavigateToSearchPage(query, 'soulseek'),
+ onUnconfiguredClick: (src) => {
+ _gsDeactivate();
+ openSettingsForSource(src);
+ },
+ });
bar.addEventListener('click', () => input.focus());
@@ -5049,8 +5061,9 @@ for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true;
const shortcut = document.getElementById('gsearch-shortcut');
if (shortcut) shortcut.style.display = 'none';
// Always redraw on focus so the source icon row is current
- // (cache dots, active state, etc.).
- _gsInitDefaultSource().then(() => _gsRender());
+ // (cache dots, active state, etc.). init() is a no-op after the
+ // first call — safe to invoke on every focus.
+ _gsController.init().then(() => _gsRenderFromState(_gsController.state));
});
// No blur handler — closing is handled by click-outside and Escape only
@@ -5060,20 +5073,26 @@ for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true;
input.addEventListener('input', () => {
const q = input.value.trim();
- _gsState.query = q;
if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none';
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
if (q.length < 2) { _gsHideResults(); return; }
- _gsState.debounceTimer = setTimeout(() => _gsPerformSearch(q), 300);
+ _gsState.debounceTimer = setTimeout(() => _gsController.submitQuery(q), 300);
});
if (clearBtn) {
clearBtn.addEventListener('click', e => {
e.stopPropagation();
input.value = '';
- _gsState.query = '';
- _gsState.data = null;
clearBtn.style.display = 'none';
+ // Drop cache so the next search starts clean, but don't
+ // auto-fire a fetch for an empty query.
+ if (_gsController) {
+ _gsController.state.query = '';
+ _gsController.state.sources = {};
+ _gsController.state.fallbacks = {};
+ _gsController.state.loadingSources = new Set();
+ _gsController.renderSourceRow();
+ }
_gsHideResults();
input.focus();
});
@@ -5084,7 +5103,7 @@ for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true;
e.preventDefault();
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
const q = input.value.trim();
- if (q.length >= 2) _gsPerformSearch(q);
+ if (q.length >= 2) _gsController.submitQuery(q);
} else if (e.key === 'Escape') {
_gsDeactivate();
input.blur();
@@ -5158,140 +5177,6 @@ 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';
- // Fetch per-source configured state so unconfigured icons render dimmed.
- try {
- _gsState.configuredSources = await fetchSourceConfiguredMap();
- } catch (_) { /* keep optimistic default */ }
- // If the configured primary is itself unconfigured, fall forward to the
- // first configured source so the default active icon is actually usable.
- if (_gsState.configuredSources[_gsState.activeSource] === false) {
- const firstConfigured = SOURCE_ORDER.find(s => _gsState.configuredSources[s] !== false);
- if (firstConfigured) _gsState.activeSource = firstConfigured;
- }
-}
-
-async function _gsPerformSearch(query) {
- await _gsInitDefaultSource();
-
- // 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.fallbacks = {};
- _gsState.loadingSources = new Set();
- }
-
- // Render current state immediately (icon row + loading/empty message)
- _gsRender();
-
- // 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);
- return;
- }
-
- await _gsFetchSource(_gsState.activeSource);
-}
-
-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 {
- 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;
- }
-
- _gsState.loadingSources.delete(src);
- _gsRender();
-
- if (_gsState.activeSource === src) {
- setTimeout(() => _gsLibraryCheck(), 200);
- }
- } catch (err) {
- _gsState.loadingSources.delete(src);
- _gsRender();
- if (err.name !== 'AbortError') console.debug(`GS source ${src} failed:`, err);
- }
-}
-
-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;
@@ -5308,87 +5193,63 @@ function _gsNavigateToSearchPage(query, src) {
}, 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 configured = _gsState.configuredSources[src] !== false;
- const classes = ['gsearch-source-icon',
- active ? 'active' : '',
- cached ? 'cached' : '',
- loading ? 'loading' : '',
- fallback ? 'fallback-warning' : '',
- configured ? '' : 'unconfigured'].filter(Boolean).join(' ');
- let title;
- if (!configured) {
- title = `${info.text} — set up in Settings`;
- } else if (fallback) {
- title = `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`;
- } else {
- title = info.text;
- }
- const glyph = loading
- ? '⏳'
- : (info.logo
- ? `
})
`
- : info.icon);
- 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() {
+// Re-render the results body + fallback banner whenever the controller's
+// state changes (cache hit, fetch settle, query reset). The icon row itself
+// is rendered by the controller into `#gsearch-source-row`.
+function _gsRenderFromState(state) {
const results = document.getElementById('gsearch-results');
- if (!results) return;
+ const body = document.getElementById('gsearch-body');
+ if (!results || !body) return;
- const activeSrc = _gsState.activeSource;
- const cached = _gsState.sources[activeSrc];
- const isLoading = _gsState.loadingSources.has(activeSrc);
- const query = _gsState.query;
- const header = _gsSourceRowHtml() + _gsFallbackBannerHtml(activeSrc);
+ // Fallback banner — independent of body content.
+ const banner = document.getElementById('gsearch-fallback-banner');
+ const activeSrc = state.activeSource;
+ const actual = state.fallbacks[activeSrc];
+ if (banner) {
+ if (actual && actual !== activeSrc) {
+ const clicked = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc;
+ const served = (SOURCE_LABELS[actual] || {}).text || actual;
+ banner.textContent = `${clicked} unavailable — showing ${served}.`;
+ banner.classList.remove('hidden');
+ } else {
+ banner.classList.add('hidden');
+ }
+ }
- // No query yet — just show the icon row and an empty hint.
+ // Soulseek has its own dedicated handler (navigate to /search); there's
+ // nothing to render in the popover.
+ if (activeSrc === 'soulseek') return;
+
+ const cached = state.sources[activeSrc];
+ const isLoading = state.loadingSources.has(activeSrc);
+ const query = state.query;
+
+ // No query yet — prompt.
if (!query) {
- results.innerHTML = header + '
Type to search…
';
+ body.innerHTML = '
Type to search…
';
results.classList.add('visible');
return;
}
- // In-flight for this source, nothing cached yet — loading state.
+ // In-flight, nothing cached yet — loading state.
if (isLoading && !cached) {
const info = SOURCE_LABELS[activeSrc];
- results.innerHTML = header
- + `
Searching ${_escToast((info && info.text) || activeSrc)}...
`;
+ body.innerHTML = `
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).
+ // No cache, not loading — source switch before fetch fired (e.g. empty query).
if (!cached) {
- results.innerHTML = header + '
Click the source above to search.
';
+ body.innerHTML = '
Click the source above to search.
';
results.classList.add('visible');
return;
}
- // Music Videos — render video grid instead of regular sections.
+ // Music Videos — video grid instead of regular sections.
if (activeSrc === 'youtube_videos') {
const videos = cached.videos || [];
- let h = header;
- h += ``;
+ let h = ``;
h += '
';
if (videos.length === 0) {
h += `
No music videos found for "${_escToast(query)}"
`;
@@ -5410,12 +5271,12 @@ function _gsRender() {
h += '
';
}
h += '
';
- results.innerHTML = h;
+ body.innerHTML = h;
results.classList.add('visible');
return;
}
- // Standard metadata source — render library + artists + albums + singles + tracks.
+ // Standard metadata source — library + artists + albums + singles + tracks.
const dbArtists = cached.db_artists || [];
const artists = cached.artists || [];
const allAlbums = cached.albums || [];
@@ -5425,15 +5286,14 @@ function _gsRender() {
const total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length;
if (total === 0) {
- results.innerHTML = header
- + `
No results for "${_escToast(query)}"
Try different keywords or check spelling
`;
+ body.innerHTML = `
No results for "${_escToast(query)}"
Try different keywords or check spelling
`;
results.classList.add('visible');
return;
}
const srcLabel = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc || '';
- let h = header;
+ let h = '';
h += ``;
h += '
';
@@ -5481,11 +5341,16 @@ function _gsRender() {
}
h += '
';
- results.innerHTML = h;
+ body.innerHTML = h;
results.classList.add('visible');
- // Lazy load artist images for sources that don't provide them (iTunes/Deezer)
+ // Lazy load artist images for sources that don't provide them (iTunes/Deezer).
_gsLazyLoadArtistImages();
+
+ // Library ownership check — adds "In Library" badges + swaps play buttons.
+ // Idempotent enough to run on every render with a cache hit; the old flow
+ // also fired it on both cache-hit and fetch-settle.
+ setTimeout(() => _gsLibraryCheck(), 200);
}
async function _gsLazyLoadArtistImages() {
@@ -5493,7 +5358,7 @@ async function _gsLazyLoadArtistImages() {
if (!grid) return;
const cards = grid.querySelectorAll('[data-needs-image="true"]');
if (cards.length === 0) return;
- const activeSrc = _gsState.activeSource || 'spotify';
+ const activeSrc = (_gsController && _gsController.state.activeSource) || 'spotify';
for (const card of cards) {
const artistId = card.dataset.artistId;
@@ -5510,45 +5375,10 @@ async function _gsLazyLoadArtistImages() {
}
}
-function _gsSetActiveSource(src) {
- if (!SOURCE_LABELS[src]) return;
- _gsState._lastInteraction = Date.now();
-
- // Not configured — jump to Settings instead of firing a doomed search.
- if (_gsState.configuredSources[src] === false) {
- _gsDeactivate();
- openSettingsForSource(src);
- return;
- }
-
- // 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;
-
- // 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();
-}
-
function _gsClickArtist(id, name, isLibrary) {
_gsDeactivate();
- const source = isLibrary ? null : (_gsState.activeSource || null);
+ const activeSource = _gsController && _gsController.state.activeSource;
+ const source = isLibrary ? null : (activeSource || null);
navigateToArtistDetail(id, name, source);
}
@@ -5680,7 +5510,8 @@ async function _gsPlayTrack(trackName, artistName, albumName) {
// Async library check for global search results — adds badges + swaps play buttons
async function _gsLibraryCheck() {
try {
- const src = _gsState.sources[_gsState.activeSource] || {};
+ if (!_gsController) return;
+ const src = _gsController.state.sources[_gsController.state.activeSource] || {};
const allAlbums = src.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');
diff --git a/webui/static/helper.js b/webui/static/helper.js
index ceb050c3..03a6cc7a 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -3454,6 +3454,7 @@ const WHATS_NEW = {
{ title: 'Artists Sidebar Entry Retired — Use Search Instead', desc: 'Cin flagged that "Artists" in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone. New flow: Sidebar → Search → type artist name → click their result. "Browse Artists" on the empty Watchlist page and "View artist from Wishlist" now open Search pre-filled with the artist\'s name. Removed "Artists" from profile Home Page + Page Access options. Deep link to /artists still resolves so old bookmarks keep working — the page just isn\'t promoted anywhere', page: 'search', unreleased: true },
{ title: 'Artist Detail Back Button Fallback', desc: 'The back button on the Artists-page inline detail view used to dump users on an empty "Search for an artist..." screen when they arrived from outside the Artists page — a dead end now that Artists isn\'t in the sidebar. If you searched inside the Artists page, back still returns to your results list. Otherwise (arriving from Search, Discover, Watchlist, etc.), back uses the browser history to land you on whichever page you came from. Falls back to the Search page only when there\'s no browser history to go back to (the natural place to find another artist)', page: 'search', unreleased: true },
{ 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 },
],
'2.39': [
// --- April 22, 2026 ---
diff --git a/webui/static/search.js b/webui/static/search.js
index 73e781b8..a9d4c28a 100644
--- a/webui/static/search.js
+++ b/webui/static/search.js
@@ -55,32 +55,11 @@ function initializeSearchModeToggle() {
searchModeToggleInitialized = true;
console.log('✅ Initializing search source picker (first time only)');
- // Active source — the icon the user is currently viewing. Initialized from
- // the user's configured primary source via /api/settings (below). No 'auto'
- // / fan-out mode: every search targets exactly one source.
- let currentSearchSource = 'spotify';
+ // State + fetch dispatch + icon-row rendering live in the shared
+ // `createSearchController` factory (shared-helpers.js) so this page and
+ // the global search widget share one implementation. This closure wires
+ // the controller up with Search-page-specific DOM + callbacks.
- // Per-query cache. `sources[src]` holds the result payload the last time
- // `src` was fetched for the current query. `fallbacks[src]` records the
- // source the backend actually served when it auto-fell-back (e.g. user
- // clicked Spotify but got Deezer because Spotify is rate-limited).
- // `loadingSources` drives per-icon spinners. Cleared whenever the query
- // string changes — we never serve stale results across queries.
- let _cachedData = {
- query: '',
- sources: {},
- fallbacks: {},
- loadingSources: new Set(),
- };
-
- // Which sources have credentials saved. Populated asynchronously on init
- // via /api/settings/config-status. Unconfigured sources render dimmed
- // and clicking them redirects to Settings → Connections instead of
- // firing a search.
- let _configuredSources = {};
- for (const src of SOURCE_ORDER) _configuredSources[src] = true; // optimistic default
-
- // Initialize enhanced search
const enhancedInput = document.getElementById('enhanced-search-input');
const enhancedSearchBtn = document.getElementById('enhanced-search-btn');
const enhancedCancelBtn = document.getElementById('enhanced-cancel-btn');
@@ -90,148 +69,92 @@ function initializeSearchModeToggle() {
const resultsContainer = document.getElementById('enhanced-results-container');
let debounceTimer = null;
- let abortController = null;
- // SOURCE_LABELS + SOURCE_ORDER now live in shared-helpers.js.
-
- // ── Source icon row ────────────────────────────────────────────────
- function renderSourceRow() {
- sourceRow.innerHTML = SOURCE_ORDER.map(src => {
- const info = SOURCE_LABELS[src];
- if (!info) return '';
- const active = src === currentSearchSource;
- const cached = !!_cachedData.sources[src];
- const loading = _cachedData.loadingSources.has(src);
- const fallback = _cachedData.fallbacks[src];
- const configured = _configuredSources[src] !== false;
- const classes = [
- 'enh-source-icon',
- active ? 'active' : '',
- cached ? 'cached' : '',
- loading ? 'loading' : '',
- fallback ? 'fallback-warning' : '',
- configured ? '' : 'unconfigured',
- ].filter(Boolean).join(' ');
- let title;
- if (!configured) {
- title = `${info.text} — set up in Settings`;
- } else if (fallback) {
- title = `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`;
- } else {
- title = info.text;
- }
- // Prefer the brand logo when available; fall back to the emoji.
- // Spinner glyph overrides both while loading.
- const glyph = loading
- ? '⏳'
- : (info.logo
- ? `
})
`
- : info.icon);
- return `
-
`;
- }).join('');
- sourceRow.querySelectorAll('.enh-source-icon').forEach(btn => {
- btn.addEventListener('click', (e) => {
- // Stop the outside-click document handler from dismissing the
- // dropdown. `renderSourceRow` re-renders after a source switch,
- // detaching this button from the DOM, so the bubbled handler's
- // `closest('#enh-source-row')` would fail on the detached target.
- e.stopPropagation();
- setActiveSource(btn.dataset.source);
- });
- });
+ // ── Fallback banner ("Spotify unavailable — showing Deezer") ───────
+ function _renderFallbackBanner(state) {
+ const banner = document.getElementById('enh-fallback-banner');
+ if (!banner) return;
+ const src = state.activeSource;
+ const actual = state.fallbacks[src];
+ if (actual && actual !== src) {
+ const clicked = (SOURCE_LABELS[src] || {}).text || src;
+ const served = (SOURCE_LABELS[actual] || {}).text || actual;
+ banner.textContent = `${clicked} unavailable — showing ${served}.`;
+ banner.classList.remove('hidden');
+ } else {
+ banner.classList.add('hidden');
+ }
}
- async function _initDefaultSource() {
- 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]) {
- currentSearchSource = cfg;
- }
- }
- } catch (_) { /* settings fetch best-effort */ }
- if (!SOURCE_LABELS[currentSearchSource]) currentSearchSource = 'spotify';
- // Pull per-source configured state in parallel so the dimmed icons
- // don't flash on first render. Errors fall through to the optimistic
- // default set at init.
- try {
- _configuredSources = await fetchSourceConfiguredMap();
- } catch (_) { /* keep optimistic default */ }
- // If the configured primary turns out to be unconfigured (e.g.
- // spotify saved as primary but the user never entered credentials),
- // bump to the first source that is configured so the default click
- // doesn't land on a dimmed "set up" icon.
- if (_configuredSources[currentSearchSource] === false) {
- const firstConfigured = SOURCE_ORDER.find(s => _configuredSources[s] !== false);
- if (firstConfigured) currentSearchSource = firstConfigured;
- }
- renderSourceRow();
- }
- _initDefaultSource();
+ // Central re-render callback — called by the controller whenever state
+ // changes (cache hit, fetch settle, query reset). Drives the enhanced
+ // dropdown UI: loading state, empty state, results render, fallback
+ // banner.
+ function _renderFromState(state) {
+ const src = state.activeSource;
- // ── Source selection ───────────────────────────────────────────────
- function setActiveSource(src) {
- if (!SOURCE_LABELS[src]) return;
+ // Soulseek has its own surface (basic-section) — the controller fires
+ // onSoulseekSelected for that, so there's nothing to render here.
+ if (src === 'soulseek') return;
- // Not configured — jump to the relevant card in Settings rather than
- // firing a search that can't succeed. Don't swap activeSource so the
- // user's previous pick stays current when they come back.
- if (_configuredSources[src] === false) {
- openSettingsForSource(src);
- return;
- }
-
- if (src === currentSearchSource) return;
- currentSearchSource = src;
-
- // Soulseek routes to the basic search UI (raw file results). We keep
- // no cache for it — the basic search renders straight to the page.
- if (src === 'soulseek') {
- basicSection.classList.add('active');
- enhancedSection.classList.remove('active');
- hideDropdown();
- renderSourceRow();
- const basicInput = document.getElementById('downloads-search-input');
- if (basicInput && _cachedData.query) {
- basicInput.value = _cachedData.query;
- if (typeof performDownloadsSearch === 'function') performDownloadsSearch();
- }
- return;
- }
-
- // Other sources use the enhanced dropdown.
+ // Ensure the enhanced section is visible (may have been hidden if the
+ // user was previously on Soulseek).
basicSection.classList.remove('active');
enhancedSection.classList.add('active');
- renderSourceRow();
- if (!_cachedData.query) return;
+ _renderFallbackBanner(state);
- if (_cachedData.sources[src]) {
- _renderFromCache(src);
- } else {
- _fetchAndRenderSource(src);
+ const cached = state.sources[src];
+ const loading = state.loadingSources.has(src);
+
+ // Mid-fetch with no cache yet → loading state.
+ if (loading && !cached) {
+ emptyState.classList.add('hidden');
+ resultsContainer.classList.add('hidden');
+ loadingState.classList.remove('hidden');
+ const loadingText = document.getElementById('enhanced-loading-text');
+ if (loadingText) {
+ const info = SOURCE_LABELS[src];
+ loadingText.textContent = `Searching ${(info && info.text) || src} and your library...`;
+ }
+ showDropdown();
+ return;
}
- }
- // ── Render + fetch ─────────────────────────────────────────────────
- function _renderFromCache(src) {
- const cached = _cachedData.sources[src];
- if (!cached) return;
+ // No cache + no query → nothing to show; hide the dropdown.
+ if (!cached) {
+ if (!state.query) {
+ hideDropdown();
+ return;
+ }
+ // Fetch settled with no data — empty state.
+ loadingState.classList.add('hidden');
+ resultsContainer.classList.add('hidden');
+ emptyState.classList.remove('hidden');
+ showDropdown();
+ return;
+ }
+
+ const total = src === 'youtube_videos'
+ ? ((cached.videos && cached.videos.length) || 0)
+ : ((cached.db_artists && cached.db_artists.length) || 0)
+ + ((cached.artists && cached.artists.length) || 0)
+ + ((cached.albums && cached.albums.length) || 0)
+ + ((cached.tracks && cached.tracks.length) || 0);
loadingState.classList.add('hidden');
+
+ if (total === 0) {
+ resultsContainer.classList.add('hidden');
+ emptyState.classList.remove('hidden');
+ showDropdown();
+ return;
+ }
+
emptyState.classList.add('hidden');
resultsContainer.classList.remove('hidden');
showDropdown();
- _renderFallbackBanner(src);
-
if (src === 'youtube_videos') {
['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => {
const el = document.getElementById(id);
@@ -257,136 +180,27 @@ function initializeSearchModeToggle() {
});
}
- function _renderFallbackBanner(src) {
- const banner = document.getElementById('enh-fallback-banner');
- if (!banner) return;
- const actual = _cachedData.fallbacks[src];
- if (actual && actual !== src) {
- const clicked = (SOURCE_LABELS[src] || {}).text || src;
- const served = (SOURCE_LABELS[actual] || {}).text || actual;
- banner.textContent = `${clicked} unavailable — showing ${served}.`;
- banner.classList.remove('hidden');
- } else {
- banner.classList.add('hidden');
- }
- }
-
- async function _fetchAndRenderSource(src) {
- const query = _cachedData.query;
- if (!query) return;
-
- _cachedData.loadingSources.add(src);
- renderSourceRow();
-
- showDropdown();
- emptyState.classList.add('hidden');
- resultsContainer.classList.add('hidden');
- loadingState.classList.remove('hidden');
- const loadingText = document.getElementById('enhanced-loading-text');
- if (loadingText) {
- const info = SOURCE_LABELS[src];
- loadingText.textContent = `Searching ${(info && info.text) || src} and your library...`;
- }
-
- if (abortController) abortController.abort();
- abortController = new AbortController();
-
- try {
- if (src === 'youtube_videos') {
- await _fetchYouTubeVideos(query, abortController.signal);
- } else {
- const data = await enhancedSearchFetch(query, {
- source: src,
- signal: abortController.signal,
- });
- _cachedData.sources[src] = {
- artists: data.spotify_artists || [],
- albums: data.spotify_albums || [],
- tracks: data.spotify_tracks || [],
- videos: [],
- available: true,
- db_artists: data.db_artists || [],
- };
- const served = data.primary_source || data.metadata_source;
- if (served && served !== src) {
- _cachedData.fallbacks[src] = served;
+ const searchController = createSearchController({
+ sourceRowElement: sourceRow,
+ iconClassPrefix: 'enh',
+ onStateChange: _renderFromState,
+ onSoulseekSelected: (query) => {
+ // Soulseek returns raw file results, rendered by the basic-search
+ // UI — swap sections and re-fire the basic search with the
+ // current query.
+ basicSection.classList.add('active');
+ enhancedSection.classList.remove('active');
+ hideDropdown();
+ const basicInput = document.getElementById('downloads-search-input');
+ if (basicInput) {
+ if (query) basicInput.value = query;
+ if (basicInput.value && typeof performDownloadsSearch === 'function') {
+ performDownloadsSearch();
}
}
-
- _cachedData.loadingSources.delete(src);
- renderSourceRow();
-
- if (currentSearchSource === src) {
- const cached = _cachedData.sources[src];
- const total = src === 'youtube_videos'
- ? ((cached && cached.videos && cached.videos.length) || 0)
- : ((cached && cached.db_artists && cached.db_artists.length) || 0)
- + ((cached && cached.artists && cached.artists.length) || 0)
- + ((cached && cached.albums && cached.albums.length) || 0)
- + ((cached && cached.tracks && cached.tracks.length) || 0);
- loadingState.classList.add('hidden');
- if (total === 0) {
- emptyState.classList.remove('hidden');
- } else {
- _renderFromCache(src);
- }
- }
- } catch (err) {
- _cachedData.loadingSources.delete(src);
- renderSourceRow();
- if (err.name !== 'AbortError') {
- console.error(`Source fetch failed for ${src}:`, err);
- if (currentSearchSource === src) {
- loadingState.classList.add('hidden');
- emptyState.classList.remove('hidden');
- }
- }
- }
- }
-
- async function _fetchYouTubeVideos(query, signal) {
- const response = await fetch('/api/enhanced-search/source/youtube_videos', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ query }),
- signal,
- });
- if (!response.ok) throw new Error(`YouTube search failed: ${response.status}`);
-
- _cachedData.sources['youtube_videos'] = {
- artists: [], albums: [], tracks: [], videos: [],
- available: true, db_artists: [],
- };
- const cache = _cachedData.sources['youtube_videos'];
-
- const reader = response.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 newlineIdx;
- while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
- const line = buffer.slice(0, newlineIdx).trim();
- buffer = buffer.slice(newlineIdx + 1);
- if (!line) continue;
- try {
- const chunk = JSON.parse(line);
- if (chunk.type === 'videos') {
- cache.videos = chunk.data;
- // Live-render if still active — lets the user see results
- // as soon as the chunk arrives.
- if (currentSearchSource === 'youtube_videos') {
- _renderFromCache('youtube_videos');
- }
- }
- } catch (_) { /* best-effort NDJSON parse */ }
- }
- }
- }
+ },
+ });
+ searchController.init();
// Live search with debouncing
if (enhancedInput) {
@@ -409,7 +223,7 @@ function initializeSearchModeToggle() {
// Debounce search
debounceTimer = setTimeout(() => {
- performEnhancedSearch(query);
+ searchController.submitQuery(query);
}, 300);
});
@@ -418,7 +232,7 @@ function initializeSearchModeToggle() {
const query = e.target.value.trim();
if (query.length >= 2) {
clearTimeout(debounceTimer);
- performEnhancedSearch(query);
+ searchController.submitQuery(query);
}
}
});
@@ -487,44 +301,14 @@ function initializeSearchModeToggle() {
}
});
- async function performEnhancedSearch(query) {
- console.log('Enhanced search:', query, '→', currentSearchSource);
-
- // Query changed? Drop the whole per-query cache so we never serve
- // stale results across queries. Icon dots and fallback banner reset.
- if (query !== _cachedData.query) {
- _cachedData.query = query;
- _cachedData.sources = {};
- _cachedData.fallbacks = {};
- _cachedData.loadingSources = new Set();
- renderSourceRow();
- }
-
- // Soulseek → existing basic search flow, no cache here.
- if (currentSearchSource === 'soulseek') {
- const basicInput = document.getElementById('downloads-search-input');
- if (basicInput) {
- basicInput.value = query;
- if (typeof performDownloadsSearch === 'function') performDownloadsSearch();
- }
- return;
- }
-
- // Same query + same source that's already cached → instant render.
- if (_cachedData.sources[currentSearchSource]) {
- _renderFromCache(currentSearchSource);
- return;
- }
-
- await _fetchAndRenderSource(currentSearchSource);
- }
-
function renderDropdownResults(data) {
+ const activeSource = searchController.state.activeSource;
+
// Music Videos tab — don't render regular sections
- if (currentSearchSource === 'youtube_videos') return;
+ if (activeSource === 'youtube_videos') return;
// Determine source badge from active tab (not just primary)
- const displaySource = currentSearchSource || data.metadata_source || 'spotify';
+ const displaySource = activeSource || data.metadata_source || 'spotify';
const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify;
const sourceBadge = { text: sourceInfo.text, class: sourceInfo.badgeClass };
@@ -561,7 +345,7 @@ function initializeSearchModeToggle() {
meta: 'Artist',
badge: sourceBadge,
onClick: () => {
- const sourceOverride = currentSearchSource;
+ const sourceOverride = searchController.state.activeSource;
console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`);
hideDropdown();
navigateToArtistDetail(artist.id, artist.name, sourceOverride || null);
@@ -797,8 +581,9 @@ function initializeSearchModeToggle() {
if (!artistId) continue;
try {
- const imgUrl = currentSearchSource && currentSearchSource !== 'spotify'
- ? `/api/artist/${artistId}/image?source=${currentSearchSource}`
+ const activeSource = searchController.state.activeSource;
+ const imgUrl = activeSource && activeSource !== 'spotify'
+ ? `/api/artist/${artistId}/image?source=${activeSource}`
: `/api/artist/${artistId}/image`;
const response = await fetch(imgUrl);
const data = await response.json();
@@ -846,8 +631,9 @@ function initializeSearchModeToggle() {
try {
// Fetch full album data with tracks — pass source for correct routing
const albumParams = new URLSearchParams({ name: album.name || '', artist: album.artist || '' });
- if (currentSearchSource && currentSearchSource !== 'spotify') {
- albumParams.set('source', currentSearchSource);
+ const activeSource = searchController.state.activeSource;
+ if (activeSource && activeSource !== 'spotify') {
+ albumParams.set('source', activeSource);
}
// Pass Hydrabase plugin origin so server routes to correct client
if (album.external_urls?.hydrabase_plugin) {
@@ -913,7 +699,7 @@ function initializeSearchModeToggle() {
id: firstArtist.id || album.id?.split?.('_')?.[0] || '',
name: firstArtist.name || album.artist,
image_url: firstArtist.image_url || firstArtist.images?.[0]?.url || '',
- source: currentSearchSource || '',
+ source: activeSource || '',
};
// Prepare full album object for modal
diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js
index 87062be1..80ac7ecf 100644
--- a/webui/static/shared-helpers.js
+++ b/webui/static/shared-helpers.js
@@ -114,6 +114,308 @@ async function fetchSourceConfiguredMap() {
return map;
}
+// Shared source-picker controller used by both the unified Search page
+// and the global search widget. Owns all the query/active-source/per-query
+// cache state, fetch dispatch (enhanced-search for standard sources, NDJSON
+// for YouTube Music Videos), configured-source discovery, fallback tracking,
+// and icon-row rendering. Each surface passes per-surface wiring — DOM
+// elements, a CSS class prefix, and callbacks — and the controller takes
+// care of the rest.
+//
+// Config:
+// sourceRowElement — HTMLElement where the icon row is rendered
+// iconClassPrefix — 'enh' or 'gsearch' (drives CSS class names)
+// onStateChange(state) — called whenever the surface should re-render
+// results (cache hit, fetch settle, query reset)
+// onSoulseekSelected(q) — surface decides what happens when the user
+// clicks the Soulseek icon (basic-section swap
+// on the Search page, /search handoff on the
+// global widget)
+// onUnconfiguredClick(src)— override the default "open Settings" behaviour
+//
+// Returned methods:
+// init() — async; reads /api/settings + /api/settings/
+// config-status, seeds default source, falls
+// forward if primary is unconfigured, draws row
+// submitQuery(query) — user typed a new query (clears cache on change)
+// setActiveSource(src) — user clicked a different source icon
+// renderSourceRow() — re-draws the icon row (call after state edits)
+function createSearchController({
+ sourceRowElement,
+ iconClassPrefix = 'enh',
+ onStateChange,
+ onSoulseekSelected,
+ onUnconfiguredClick,
+} = {}) {
+ const iconClass = `${iconClassPrefix}-source-icon`;
+ const glyphClass = `${iconClassPrefix}-source-icon-glyph`;
+ const labelClass = `${iconClassPrefix}-source-icon-label`;
+
+ // Per-query cache. `sources[src]` holds the result payload the last
+ // time `src` was fetched for the current query. `fallbacks[src]`
+ // records the source the backend actually served when it auto-fell-
+ // back (e.g. user clicked Spotify but got Deezer because Spotify is
+ // rate-limited). `loadingSources` drives per-icon spinners. The whole
+ // cache is cleared whenever the query string changes — we never
+ // serve stale results across queries.
+ const state = {
+ query: '',
+ activeSource: 'spotify',
+ sources: {},
+ fallbacks: {},
+ loadingSources: new Set(),
+ configuredSources: {},
+ _initialized: false,
+ };
+ // Optimistic default — replaced by the real config-status lookup on
+ // init. Prevents a flash of "all unconfigured" icons.
+ for (const src of SOURCE_ORDER) state.configuredSources[src] = true;
+
+ let abortCtrl = null;
+
+ function _notify() { if (onStateChange) onStateChange(state); }
+
+ function renderSourceRow() {
+ if (!sourceRowElement) return;
+ sourceRowElement.innerHTML = SOURCE_ORDER.map(src => {
+ const info = SOURCE_LABELS[src];
+ if (!info) return '';
+ const active = src === state.activeSource;
+ const cached = !!state.sources[src];
+ const loading = state.loadingSources.has(src);
+ const fallback = state.fallbacks[src];
+ const configured = state.configuredSources[src] !== false;
+
+ const classes = [
+ iconClass,
+ active ? 'active' : '',
+ cached ? 'cached' : '',
+ loading ? 'loading' : '',
+ fallback ? 'fallback-warning' : '',
+ configured ? '' : 'unconfigured',
+ ].filter(Boolean).join(' ');
+
+ let title;
+ if (!configured) {
+ title = `${info.text} — set up in Settings`;
+ } else if (fallback) {
+ title = `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`;
+ } else {
+ title = info.text;
+ }
+
+ const glyph = loading
+ ? '⏳'
+ : (info.logo
+ ? `
})
`
+ : info.icon);
+
+ return `
+
`;
+ }).join('');
+
+ sourceRowElement.querySelectorAll(`.${iconClass}`).forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ // stopPropagation prevents surface-level outside-click handlers
+ // from dismissing the results while we re-render the icon row
+ // (which detaches the clicked button from the DOM).
+ e.stopPropagation();
+ setActiveSource(btn.dataset.source);
+ });
+ });
+ }
+
+ async function init() {
+ if (state._initialized) return;
+ state._initialized = true;
+
+ // Resolve the user's configured primary source.
+ 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]) state.activeSource = cfg;
+ }
+ } catch (_) { /* best-effort */ }
+ if (!SOURCE_LABELS[state.activeSource]) state.activeSource = 'spotify';
+
+ // Figure out which sources actually have credentials saved.
+ try {
+ state.configuredSources = await fetchSourceConfiguredMap();
+ } catch (_) { /* keep optimistic default */ }
+
+ // If the configured primary is itself unconfigured (Spotify saved
+ // as primary but no client_id yet), fall forward to the first
+ // configured source so the default active icon is usable.
+ if (state.configuredSources[state.activeSource] === false) {
+ const firstConfigured = SOURCE_ORDER.find(s => state.configuredSources[s] !== false);
+ if (firstConfigured) state.activeSource = firstConfigured;
+ }
+
+ renderSourceRow();
+ _notify();
+ }
+
+ function setActiveSource(src) {
+ if (!SOURCE_LABELS[src]) return;
+
+ // Unconfigured — jump to the relevant card in Settings rather than
+ // firing a search that can't succeed. Don't swap activeSource so the
+ // user's previous pick stays current when they come back.
+ if (state.configuredSources[src] === false) {
+ if (onUnconfiguredClick) onUnconfiguredClick(src);
+ else openSettingsForSource(src);
+ return;
+ }
+
+ // Clicking the already-active source is a no-op for normal sources,
+ // but for Soulseek we still re-fire the callback so the surface can
+ // re-issue the handoff (e.g. user typed and wants a fresh search).
+ if (src === state.activeSource) {
+ if (src === 'soulseek' && onSoulseekSelected) onSoulseekSelected(state.query);
+ return;
+ }
+
+ state.activeSource = src;
+ renderSourceRow();
+
+ // Soulseek — let the surface decide what to do (basic-section swap
+ // on Search page, /search handoff on global widget). We don't cache
+ // or auto-fetch soulseek results in the controller.
+ if (src === 'soulseek') {
+ if (onSoulseekSelected) onSoulseekSelected(state.query);
+ return;
+ }
+
+ if (state.sources[src]) {
+ _notify();
+ } else if (state.query) {
+ _fetchSource(src);
+ } else {
+ _notify();
+ }
+ }
+
+ async function _fetchSource(src) {
+ const query = state.query;
+ if (!query) return;
+
+ state.loadingSources.add(src);
+ renderSourceRow();
+ _notify();
+
+ if (abortCtrl) abortCtrl.abort();
+ abortCtrl = new AbortController();
+
+ try {
+ if (src === 'youtube_videos') {
+ await _fetchYouTubeVideos(query, abortCtrl.signal);
+ } else {
+ const data = await enhancedSearchFetch(query, {
+ source: src,
+ signal: abortCtrl.signal,
+ });
+ state.sources[src] = {
+ artists: data.spotify_artists || [],
+ albums: data.spotify_albums || [],
+ tracks: data.spotify_tracks || [],
+ videos: [],
+ db_artists: data.db_artists || [],
+ };
+ const served = data.primary_source || data.metadata_source;
+ if (served && served !== src) state.fallbacks[src] = served;
+ }
+
+ state.loadingSources.delete(src);
+ renderSourceRow();
+ _notify();
+ } catch (err) {
+ state.loadingSources.delete(src);
+ renderSourceRow();
+ _notify();
+ if (err.name !== 'AbortError') {
+ console.debug(`Source fetch failed for ${src}:`, err);
+ }
+ }
+ }
+
+ async function _fetchYouTubeVideos(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: ${res.status}`);
+
+ state.sources['youtube_videos'] = {
+ artists: [], albums: [], tracks: [], videos: [], db_artists: [],
+ };
+ const cache = state.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;
+ // Live-render if still the active source.
+ if (state.activeSource === 'youtube_videos') _notify();
+ }
+ } catch (_) { /* best-effort NDJSON parse */ }
+ }
+ }
+ }
+
+ function submitQuery(query) {
+ if (query !== state.query) {
+ state.query = query;
+ state.sources = {};
+ state.fallbacks = {};
+ state.loadingSources = new Set();
+ renderSourceRow();
+ }
+
+ // Soulseek — surface handles the full query handoff.
+ if (state.activeSource === 'soulseek') {
+ if (onSoulseekSelected) onSoulseekSelected(query);
+ return;
+ }
+
+ // Cache hit — instant re-render, no fetch.
+ if (state.sources[state.activeSource]) {
+ _notify();
+ return;
+ }
+
+ _fetchSource(state.activeSource);
+ }
+
+ return {
+ state,
+ init,
+ submitQuery,
+ setActiveSource,
+ renderSourceRow,
+ };
+}
+
+
// Navigate to Settings → Connections tab and scroll to the service card that
// matches the picker's source id. Called when a user clicks an unconfigured
// source icon.
From 77d20e9aa8ee29487ef9b9622637288b16e6e1af Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 23 Apr 2026 18:01:34 -0700
Subject: [PATCH 13/18] Fix Clean Search History automation AttributeError on
DownloadOrchestrator
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The hourly `clean_search_history` automation was crashing with
`'DownloadOrchestrator' object has no attribute 'base_url'`. The guard
was written before the orchestrator refactor — `soulseek_client` is now
a DownloadOrchestrator that wraps individual download clients, with the
real Soulseek client sitting at `.soulseek`.
Two other call sites in web_server.py (lines 2634, 3092) already used
the correct `soulseek_client.soulseek.base_url` pattern with a getattr
guard. This call site was missed during the refactor.
Fix: reach through the orchestrator the same way the other sites do.
---
web_server.py | 5 ++++-
webui/static/helper.js | 1 +
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/web_server.py b/web_server.py
index 32ba02dc..aa4ee8ea 100644
--- a/web_server.py
+++ b/web_server.py
@@ -1631,7 +1631,10 @@ def _register_automation_handlers():
hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
soulseek_active = (dl_mode == 'soulseek' or
(dl_mode == 'hybrid' and 'soulseek' in hybrid_order))
- if not soulseek_active or not soulseek_client or not soulseek_client.base_url:
+ # soulseek_client is a DownloadOrchestrator; the real client lives on
+ # .soulseek. Match the getattr pattern used at the other call sites.
+ slskd = getattr(soulseek_client, 'soulseek', None) if soulseek_client else None
+ if not soulseek_active or not slskd or not slskd.base_url:
_update_automation_progress(automation_id,
log_line='Soulseek not active — skipped', log_type='skip')
return {'status': 'skipped'}
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 03a6cc7a..de289e17 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -3455,6 +3455,7 @@ const WHATS_NEW = {
{ title: 'Artist Detail Back Button Fallback', desc: 'The back button on the Artists-page inline detail view used to dump users on an empty "Search for an artist..." screen when they arrived from outside the Artists page — a dead end now that Artists isn\'t in the sidebar. If you searched inside the Artists page, back still returns to your results list. Otherwise (arriving from Search, Discover, Watchlist, etc.), back uses the browser history to land you on whichever page you came from. Falls back to the Search page only when there\'s no browser history to go back to (the natural place to find another artist)', page: 'search', unreleased: true },
{ 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' },
],
'2.39': [
// --- April 22, 2026 ---
From 258644fd9f4c41a26139b16ccf1c9026aa535306 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 23 Apr 2026 22:11:49 -0700
Subject: [PATCH 14/18] Drop Show/Hide Results button + auto-restore cached
results on navigate-back
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cin flagged two related UX issues during PR review:
1. The "Show Results / Hide Results" toggle next to the search bar served
no real purpose — there was nothing else on the Search page worth seeing
instead of results, so toggling visibility was always pointless overhead.
2. Navigating away from /search via a sidebar link dismissed the dropdown
(the click was caught by the outside-click handler). Coming back left
the input populated but the results hidden, requiring a Show Results
click or a fresh search. The cached state was intact in the controller
the whole time — just not rendered.
Both fixed by the same direction: dropdown visibility becomes a pure
function of query state, never user-toggleable. The closure now exposes
`_searchPageRestoreOnEnter` so subsequent calls to `initializeSearchModeToggle`
re-render from the controller's cached state instead of early-returning.
Removes the button HTML, click handler, `updateToggleButtonState` function,
the desktop + responsive CSS for `.enhanced-search-btn`, and the orphaned
`.btn-icon` rule. Net -94 lines.
---
webui/index.html | 4 ---
webui/static/helper.js | 2 ++
webui/static/mobile.css | 5 ---
webui/static/search.js | 78 +++++++++--------------------------------
webui/static/style.css | 43 -----------------------
5 files changed, 19 insertions(+), 113 deletions(-)
diff --git a/webui/index.html b/webui/index.html
index cd2838b1..c73140d7 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -1975,10 +1975,6 @@
placeholder="Search for artists, albums, or tracks...">