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 = '
' + 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 + '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 += '';
- 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;