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:
parent
f85564a2de
commit
a72810ce22
4 changed files with 426 additions and 334 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue