Search page: replace fan-out with source-picker icon row + per-source cache

The Search page previously fired a primary /api/enhanced-search request
plus a fan-out loop (_queueAlternateSourceFetches / _fetchAlternateSource)
that streamed NDJSON from /api/enhanced-search/source/<src> for every
other configured source. One search = 7 API calls across Spotify, iTunes,
Deezer, Discogs, Hydrabase, MusicBrainz, and YouTube Music Videos. The
post-search tab bar then let users switch views between the results that
had already been fetched.

This changes the default to explicit per-source selection:

- The old <select id="search-source-select"> dropdown and the
  <div id="enh-source-tabs"> post-search tab bar are replaced by a
  single always-visible icon row (#enh-source-row) above the search
  bar. One button per source, horizontal-scroll on narrow screens.
- Typing fetches only the currently-selected source. No fan-out.
- Clicking a different icon switches to that source and fetches it
  on demand, unless results for this query are already cached.
- Per-query cache (Map keyed by source) is cleared whenever the query
  changes; cached icons show a small dot, loading icons show a spinner.
- Soulseek is a first-class icon in the row — selecting it routes to
  the existing raw-file basic search, no change to that renderer.
- YouTube Music Videos is its own icon, still uses the NDJSON stream
  endpoint for incremental rendering.
- Default active icon reads metadata.fallback_source from /api/settings
  on init; falls back to Spotify.
- Rate-limit fallback (backend serves Deezer when Spotify is banned)
  surfaces as an amber banner above results plus an amber border on the
  clicked icon, so users understand why the returned results don't
  match the source they picked.

SOURCE_LABELS in shared-helpers.js gains an 'icon' field per source and
a new SOURCE_ORDER constant for the canonical picker order. The fan-out
functions (_queueAlternateSourceFetches, _fetchAlternateSource,
renderSourceTabs, window._switchEnhSourceTab) are gone.

Backend untouched — POST /api/enhanced-search already supported a
`source` param for single-source mode; we were just never using it by
default. Global widget redesign to match is the next commit.
This commit is contained in:
Broque Thomas 2026-04-23 13:23:17 -07:00
parent f85564a2de
commit a72810ce22
4 changed files with 426 additions and 334 deletions

View file

@ -1859,23 +1859,10 @@
</div>
</div>
<!-- Search Source Picker (replaces Enhanced/Basic toggle) -->
<div class="search-source-picker-container">
<label for="search-source-select" class="search-source-picker-label">Search from</label>
<div class="search-source-picker-wrapper">
<select id="search-source-select" class="search-source-picker-select">
<option value="auto" selected>All sources (Auto)</option>
<option value="spotify">Spotify</option>
<option value="itunes">Apple Music</option>
<option value="deezer">Deezer</option>
<option value="discogs">Discogs</option>
<option value="hydrabase">Hydrabase</option>
<option value="musicbrainz">MusicBrainz</option>
<option value="soulseek">Soulseek (raw files)</option>
</select>
<span class="search-source-picker-caret"></span>
</div>
</div>
<!-- Search source picker — icon row populated by search.js.
Each icon triggers a single-source fetch (no fan-out);
results are cached per (query, source) pair. -->
<div id="enh-source-row" class="enh-source-row" role="tablist" aria-label="Search source"></div>
<!-- Basic Search Section (Current) -->
<div id="basic-search-section" class="search-section">
@ -2017,8 +2004,9 @@
<!-- Results Container -->
<div id="enhanced-results-container" class="enhanced-results-container hidden">
<!-- Source Tabs -->
<div id="enh-source-tabs" class="enh-source-tabs hidden"></div>
<!-- Fallback banner — shown when user clicked Spotify but backend
served Deezer due to rate-limit, etc. Populated by search.js. -->
<div id="enh-fallback-banner" class="enh-fallback-banner hidden"></div>
<!-- Artists Container (Side by Side) -->
<div class="enh-artists-wrapper">

View file

@ -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 `
<button class="${classes}" data-source="${src}" role="tab"
aria-selected="${active}" title="${escapeHtml(title)}">
<span class="enh-source-icon-glyph">${loading ? '⏳' : info.icon}</span>
<span class="enh-source-icon-label">${escapeHtml(info.text)}</span>
</button>`;
}).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 `<button class="enh-source-tab ${info.tabClass} ${isActive ? 'active' : ''}"
onclick="window._switchEnhSourceTab('${name}')"
data-source="${name}">
${info.text}<span class="enh-tab-count">(${count})</span>
</button>`;
}).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 = '<div class="enh-section-loading"><div class="server-search-spinner" style="width:16px;height:16px"></div><span>Loading...</span></div>';
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

View file

@ -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.

View file

@ -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;