Merge pull request #371 from Nezreka/feature/search-source-picker

Feature/search source picker
This commit is contained in:
BoulderBadgeDad 2026-04-23 22:53:50 -07:00 committed by GitHub
commit 49e2833f1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1638 additions and 800 deletions

View file

@ -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'}
@ -4452,6 +4455,12 @@ SERVICE_CONFIG_REGISTRY = {
'acoustid': {'required': ['api_key']},
'listenbrainz': {'required': ['token']},
'hydrabase': {'required': ['url', 'api_key']},
# Soulseek (slskd) needs a base URL. Used by the search source picker
# to dim Soulseek and redirect to Settings when the user has no slskd
# configured — clicking it would otherwise fire searches that always
# fail. URL field lives on Settings → Downloads, gated behind the
# download-source-mode dropdown.
'soulseek': {'required': ['slskd_url']},
}

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">
@ -1988,10 +1975,6 @@
placeholder="Search for artists, albums, or tracks...">
<button id="enhanced-cancel-btn" class="enhanced-cancel-btn hidden"></button>
</div>
<button id="enhanced-search-btn" class="enhanced-search-btn">
<span class="btn-icon">👁️</span>
<span class="btn-text">Show Results</span>
</button>
</div>
<!-- Enhanced Search Dropdown (Overlay Panel) -->
@ -2017,8 +2000,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">
@ -2319,11 +2303,19 @@
<!-- Artist cards will be populated here -->
</div>
<!-- Empty State -->
<!-- Empty State — populated by showLibraryEmpty() in library.js.
Shows a generic "no artists" message by default; when the
user's search has no library matches, it switches to a
search-online CTA that hands off to the /search page. -->
<div class="library-empty hidden" id="library-empty">
<div class="empty-icon">🎵</div>
<div class="empty-title">No artists found</div>
<div class="empty-subtitle">Try adjusting your search or filters</div>
<div class="empty-icon" id="library-empty-icon">🎵</div>
<div class="empty-title" id="library-empty-title">No artists found</div>
<div class="empty-subtitle" id="library-empty-subtitle">Try adjusting your search or filters</div>
<button class="library-empty-search-cta hidden" id="library-empty-search-cta">
<span class="library-empty-search-cta-icon">🔍</span>
<span class="library-empty-search-cta-text">Search online for <span id="library-empty-search-cta-query"></span></span>
<span class="library-empty-search-cta-arrow"></span>
</button>
</div>
<!-- Pagination -->
@ -7890,6 +7882,11 @@
<script src="{{ url_for('static', filename='pages-extra.js') }}"></script>
<script src="{{ url_for('static', filename='init.js') }}"></script>
<!-- Notification bell + floating helper toggle — always accessible above modals -->
<!-- Ambient glow under the global search bar. Radial gradient, brightest
directly under the bar, tapering out toward the window corners.
Purely decorative (pointer-events: none). Visibility follows the bar
via _gsUpdateVisibility(). -->
<div class="gsearch-aura" id="gsearch-aura"></div>
<!-- Global Search Bar — Spotlight-style search from anywhere -->
<div class="gsearch-bar" id="gsearch-bar">
<div class="gsearch-icon">

View file

@ -5013,33 +5013,57 @@ 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,
sources: {},
activeSource: null,
abortCtrl: null,
altAbortCtrl: null,
_lastInteraction: 0,
debounceTimer: null,
};
// Shared source-picker controller — built on DOM-ready in `_doInit`.
let _gsController = null;
(function initGlobalSearch() {
// Defer init until DOM is ready
const _doInit = () => {
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 = `
<div class="gsearch-source-row" id="gsearch-source-row"></div>
<div class="gsearch-fallback-banner hidden" id="gsearch-fallback-banner"></div>
<div id="gsearch-body"></div>
`;
_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());
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';
if (_gsState.data && _gsState.query) _gsShowResults();
// Always redraw on focus so the source icon row is current
// (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
@ -5049,20 +5073,26 @@ const _gsState = {
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();
});
@ -5073,7 +5103,7 @@ const _gsState = {
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();
@ -5116,18 +5146,22 @@ const _gsState = {
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();
@ -5143,113 +5177,116 @@ function _gsShowResults() {
if (r && r.innerHTML.trim()) r.classList.add('visible');
}
async function _gsPerformSearch(query) {
if (_gsState.abortCtrl) _gsState.abortCtrl.abort();
if (_gsState.altAbortCtrl) _gsState.altAbortCtrl.abort();
_gsState.abortCtrl = new AbortController();
_gsState.altAbortCtrl = new AbortController();
function _gsNavigateToSearchPage(query, src) {
_gsDeactivate();
if (typeof navigateToPage !== 'function') return;
navigateToPage('search');
// After the page mounts, mirror the query into whichever input drives the
// requested source. Soulseek goes through the basic-search file flow, not
// the enhanced metadata flow — without this branch the Search page would
// run /api/enhanced-search instead of /api/search and the user would get
// metadata results when they clicked the Soulseek icon.
setTimeout(() => {
if (src === 'soulseek') {
const basicInput = document.getElementById('downloads-search-input');
if (basicInput && query) basicInput.value = query;
const results = document.getElementById('gsearch-results');
if (!results) return;
results.innerHTML = '<div class="gsearch-loading"><div class="server-search-spinner"></div>Searching...</div>';
results.classList.add('visible');
try {
const data = await enhancedSearchFetch(query, { signal: _gsState.abortCtrl.signal });
_gsState.data = data;
_gsState.activeSource = data.primary_source || 'spotify';
_gsState.sources = {};
_gsState.sources[_gsState.activeSource] = {
artists: data.spotify_artists || [],
albums: data.spotify_albums || [],
tracks: data.spotify_tracks || [],
};
_gsRender(data);
// Async library ownership check — adds badges + swaps play buttons for library tracks
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 = '<div class="gsearch-empty">Search failed</div>';
}
}
async function _gsFetchSourceStream(src, query) {
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 };
}
const sourceData = _gsState.sources[src];
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 === '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) { }
// Sync the Search page controller's state.query to the widget's
// query BEFORE clicking the Soulseek icon. Otherwise the icon
// click fires onSoulseekSelected(state.query) where state.query
// is whatever the user last typed on /search (often stale), and
// the callback would overwrite basicInput.value with that stale
// value before running performDownloadsSearch.
if (typeof _searchPageController !== 'undefined' && _searchPageController) {
_searchPageController.state.query = query || '';
}
const soulseekIcon = document.querySelector('#enh-source-row [data-source="soulseek"]');
if (soulseekIcon) {
soulseekIcon.click();
return;
}
// Fallback: controller hasn't initialized yet (slow /api/settings
// fetches at first /search visit). Run the search directly + swap
// sections so the user still gets results. Icon row will catch up
// visually on the next render.
const basicSection = document.getElementById('basic-search-section');
const enhancedSection = document.getElementById('enhanced-search-section');
if (basicSection) basicSection.classList.add('active');
if (enhancedSection) enhancedSection.classList.remove('active');
if (basicInput && basicInput.value && typeof performDownloadsSearch === 'function') {
performDownloadsSearch();
}
return;
}
_gsRenderTabs();
} catch (e) {
if (e.name !== 'AbortError') console.debug(`GS alt source ${src} failed:`, e);
}
const input = document.getElementById('enhanced-search-input');
if (input && query) {
input.value = query;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}, 300);
}
function _gsRender(data) {
// 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;
// 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 = '';
h += `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${videos.length} videos</span></div>`;
h += '<div class="gsearch-tabs" id="gsearch-tabs"></div>';
// 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');
}
}
// 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) {
body.innerHTML = '<div class="gsearch-empty">Type to search…</div>';
results.classList.add('visible');
return;
}
// In-flight, nothing cached yet — loading state.
if (isLoading && !cached) {
const info = SOURCE_LABELS[activeSrc];
body.innerHTML = `<div class="gsearch-loading"><div class="server-search-spinner"></div>Searching ${_escToast((info && info.text) || activeSrc)}...</div>`;
results.classList.add('visible');
return;
}
// No cache, not loading — source switch before fetch fired (e.g. empty query).
if (!cached) {
body.innerHTML = '<div class="gsearch-empty">Click the source above to search.</div>';
results.classList.add('visible');
return;
}
// Music Videos — video grid instead of regular sections.
if (activeSrc === 'youtube_videos') {
const videos = cached.videos || [];
let h = `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${videos.length} videos</span></div>`;
h += '<div class="gsearch-results-body">';
if (isLoading) {
h += '<div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Searching YouTube...</div>';
} else if (videos.length === 0) {
h += `<div class="gsearch-empty">No music videos found for "${_escToast(_gsState.query)}"</div>`;
if (videos.length === 0) {
h += `<div class="gsearch-empty">No music videos found for "${_escToast(query)}"</div>`;
} else {
h += '<div class="gsearch-section-header">🎬 Music Videos</div>';
h += '<div class="enh-video-grid">';
@ -5268,35 +5305,30 @@ function _gsRender(data) {
h += '</div>';
}
h += '</div>';
results.innerHTML = h;
body.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 — 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 = `<div class="gsearch-empty">No results for "${_escToast(_gsState.query)}"<br><span style="font-size:10px;opacity:0.5">Try different keywords or check spelling</span></div>`;
if (total === 0) {
body.innerHTML = `<div class="gsearch-empty">No results for "${_escToast(query)}"<br><span style="font-size:10px;opacity:0.5">Try different keywords or check spelling</span></div>`;
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 = '';
h += `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${total} items</span></div>`;
h += '<div class="gsearch-tabs" id="gsearch-tabs"></div>';
h += '<div class="gsearch-results-body">';
if (dbArtists.length) {
@ -5309,12 +5341,8 @@ function _gsRender(data) {
h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid" id="gsearch-artists-grid">`;
h += artists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', false)" ${!a.image_url ? `data-artist-id="${a.id}" data-needs-image="true"` : ''}><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div></div></div>`).join('');
h += '</div>';
} else if (loading.has('artists')) {
h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Loading artists...</div>`;
}
const activeSrc = _gsState.activeSource || 'spotify';
if (albums.length) {
h += `<div class="gsearch-section-header">💿 Albums <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
h += albums.map(a => {
@ -5326,10 +5354,6 @@ function _gsRender(data) {
h += '</div>';
}
if (!albums.length && !singles.length && loading.has('albums')) {
h += `<div class="gsearch-section-header">💿 Albums <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Loading albums...</div>`;
}
if (singles.length) {
h += `<div class="gsearch-section-header">🎶 Singles & EPs <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
h += singles.map(a => {
@ -5348,17 +5372,19 @@ function _gsRender(data) {
return `<div class="gsearch-track" onclick="_gsClickTrack('${_escAttr(ar)}', '${_escAttr(t.name)}', '${_escAttr(t.album || '')}', '${_escAttr(t.id || '')}', '${_escAttr(t.image_url || '')}', ${t.duration_ms || 0})"><div class="gsearch-item-art" style="width:32px;height:32px;border-radius:6px">${t.image_url ? `<img src="${t.image_url}" loading="lazy">` : '🎵'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(t.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}</div></div><div class="gsearch-track-dur">${dur}</div><button class="gsearch-play-btn" onclick="event.stopPropagation(); _gsPlayTrack('${_escAttr(t.name)}', '${_escAttr(ar)}', '${_escAttr(t.album || '')}')" title="Stream">▶</button></div>`;
}).join('');
h += '</div>';
} else if (loading.has('tracks')) {
h += `<div class="gsearch-section-header">🎵 Tracks <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Loading tracks...</div>`;
}
h += '</div>';
results.innerHTML = h;
body.innerHTML = h;
results.classList.add('visible');
_gsRenderTabs();
// 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() {
@ -5366,7 +5392,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;
@ -5383,49 +5409,10 @@ 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 `<button class="gsearch-tab${s === _gsState.activeSource ? ' active' : ''}" onclick="_gsSwitchSource('${s}')">${labels[s] || s} (${c})</button>`;
}).join('');
}
function _gsSwitchSource(src) {
_gsState._lastInteraction = Date.now();
_gsState.activeSource = src;
_gsRender(_gsState.data);
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);
}
@ -5557,7 +5544,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');

View file

@ -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 },
@ -3457,6 +3454,13 @@ 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 },
{ 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' },
{ title: 'Search Results Always Visible — Show/Hide Button Removed', desc: 'The "Show Results / Hide Results" toggle next to the search bar is gone. There was nothing else on the page worth seeing instead of results, so toggling visibility never made sense. Cin flagged it during PR review. Dropdown visibility is now a pure function of query state — empty input hides it, results show it', page: 'search', unreleased: true },
{ title: 'Cached Search Results Restore on Navigate-Back', desc: 'Previously, navigating away from /search via a sidebar link dismissed the dropdown (the click registered as outside-click). When you came back, the input still held your query but the results were hidden until you typed again or clicked Show Results. Now the per-query cache renders automatically when you re-enter /search, so your results are right where you left them. Cin flagged the round-trip during PR review', page: 'search', unreleased: true },
{ title: 'Fix Soulseek Handoff from Global Search Going Through Metadata Flow', desc: 'When you clicked the Soulseek icon in the sidebar global search popover, it navigated to /search and wrote the query into the enhanced-search input — which then ran the metadata flow against whatever your default source was (Spotify, Deezer, etc.) instead of the raw Soulseek file search you actually wanted. Cin flagged it during PR review. Now the handoff pre-fills the basic-search input directly and clicks the Search page\'s Soulseek icon so the controller\'s onSoulseekSelected callback owns the section swap and runs performDownloadsSearch with the right query', page: 'search', unreleased: true },
{ title: 'Stale Search Requests No Longer Flash Empty Results on Fast Retype', desc: 'Cin flagged a race in createSearchController: when you typed a query then quickly re-typed before the first fetch returned, the first fetch\'s catch block (firing on AbortError after the second submitQuery aborted it) cleared loadingSources and notified the UI, causing a brief flash of empty/error state while the new query\'s fetch was still mid-flight. Added a monotonic _requestSeq token — each fetch captures the next value, and stale completions bail before mutating shared state. The controller still aborts in-flight fetches on supersession; this just keeps the abort-cleanup of the old request from clobbering the new one\'s spinner', page: 'search', unreleased: true },
{ title: 'Source Picker Dims Soulseek When slskd Isn\'t Configured', desc: 'Cin pointed out that the Soulseek icon was always rendered as configured, so users without slskd set up could click it and fire searches that would never succeed. Soulseek is now in the backend config-status registry as `required: [slskd_url]` and removed from the frontend\'s always-configured set. Without slskd, the icon dims and clicking it routes to Settings → Downloads tab (where the slskd URL field lives, gated behind the download-source dropdown) instead of Settings → Connections', page: 'search', unreleased: true },
],
'2.39': [
// --- April 22, 2026 ---

View file

@ -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() {

View file

@ -329,11 +329,6 @@
width: 100%;
}
.enhanced-search-bar-container button {
width: 100%;
min-height: 44px;
}
.filter-group {
flex-wrap: wrap;
}
@ -3627,4 +3622,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;
}
}

View file

@ -1,21 +1,8 @@
// SEARCH FUNCTIONALITY
// ===============================
// Shared enhanced-search fetch used by the Search page and the global widget.
// Pass source to restrict results to a single metadata provider; omit or pass
// null/'auto' to let the backend fan out across all configured sources.
async function enhancedSearchFetch(query, { source = null, signal = null } = {}) {
const body = { query };
if (source && source !== 'auto') body.source = source;
const res = await fetch('/api/enhanced-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: signal || undefined,
});
if (!res.ok) throw new Error(`Enhanced search failed: ${res.status}`);
return res.json();
}
// `enhancedSearchFetch`, `SOURCE_LABELS`, and `renderCompactSection` live in
// shared-helpers.js so the Search page and the global widget share the same
// implementations.
function initializeSearch() {
// --- FIX: Corrected the element IDs to match the HTML ---
@ -48,19 +35,31 @@ function initializeSearch() {
// ===============================
let searchModeToggleInitialized = false;
// Set by the closure on first init; called by subsequent invocations to
// re-display the search dropdown from the controller's cached state.
// Solves the "results vanish on navigate-back" UX issue — a sidebar nav
// click is treated as outside-click and dismisses the dropdown, so when
// the user returns to /search we need to re-render whatever was cached.
let _searchPageRestoreOnEnter = null;
// Exposed so the global-search widget's Soulseek handoff can sync the
// controller's state.query to the widget's query before clicking the
// Soulseek icon — otherwise onSoulseekSelected fires with whatever the
// user last typed on /search and overwrites the basic input.
let _searchPageController = null;
function initializeSearchModeToggle() {
// Only initialize once to prevent duplicate event listeners
// Subsequent invocations: just re-display cached results so they don't
// vanish on navigate-back. Skip the duplicate-listener setup.
if (searchModeToggleInitialized) {
console.log('Search mode toggle already initialized, skipping...');
if (_searchPageRestoreOnEnter) _searchPageRestoreOnEnter();
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;
}
@ -68,31 +67,12 @@ 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';
// 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.
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');
}
};
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');
const enhancedCancelBtn = document.getElementById('enhanced-cancel-btn');
const enhancedDropdown = document.getElementById('enhanced-dropdown');
const loadingState = document.getElementById('enhanced-loading');
@ -100,21 +80,148 @@ function initializeSearchModeToggle() {
const resultsContainer = document.getElementById('enhanced-results-container');
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
// ── 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');
}
}
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' },
// 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;
// Soulseek has its own surface (basic-section) — the controller fires
// onSoulseekSelected for that, so there's nothing to render here.
if (src === 'soulseek') return;
// 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');
_renderFallbackBanner(state);
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;
}
// 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();
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,
});
}
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();
}
}
},
});
searchController.init();
_searchPageController = searchController;
// Expose a re-render hook so navigate-back to /search restores cached
// results instead of leaving the dropdown hidden. Deferred to the next
// tick so the render happens AFTER the nav-button click finishes
// bubbling to the document outside-click handler — otherwise that
// handler sees the just-shown dropdown and immediately dismisses it.
_searchPageRestoreOnEnter = () => {
if (!searchController.state.query) return;
setTimeout(() => _renderFromState(searchController.state), 0);
};
// Live search with debouncing
@ -138,7 +245,7 @@ function initializeSearchModeToggle() {
// Debounce search
debounceTimer = setTimeout(() => {
performEnhancedSearch(query);
searchController.submitQuery(query);
}, 300);
});
@ -147,41 +254,12 @@ function initializeSearchModeToggle() {
const query = e.target.value.trim();
if (query.length >= 2) {
clearTimeout(debounceTimer);
performEnhancedSearch(query);
searchController.submitQuery(query);
}
}
});
}
if (enhancedSearchBtn) {
enhancedSearchBtn.addEventListener('click', (e) => {
// Prevent click from bubbling to document (which would close the dropdown)
e.stopPropagation();
// Get fresh references (in case we navigated away and back)
const dropdown = document.getElementById('enhanced-dropdown');
const results = document.getElementById('enhanced-results-container');
if (!dropdown) return;
// Toggle the dropdown visibility to show/hide previous search results
if (dropdown.classList.contains('hidden')) {
// Check if there are results to show by looking for actual content
const hasResults = results &&
!results.classList.contains('hidden') &&
results.children.length > 0;
if (hasResults) {
showDropdown();
} else {
showToast('No previous results to show. Type to search!', 'info');
}
} else {
hideDropdown();
}
});
}
if (enhancedCancelBtn) {
enhancedCancelBtn.addEventListener('click', () => {
enhancedInput.value = '';
@ -204,105 +282,26 @@ 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();
}
}
});
async function performEnhancedSearch(query) {
console.log('Enhanced search:', query);
const searchId = Date.now() + Math.random();
// 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...`;
}
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');
}
}
}
function renderDropdownResults(data) {
const activeSource = searchController.state.activeSource;
// Music Videos tab — don't render regular sections
if (_activeSearchSource === 'youtube_videos') return;
if (activeSource === 'youtube_videos') return;
// Determine source badge from active tab (not just primary)
const displaySource = _activeSearchSource || 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 };
@ -339,7 +338,7 @@ function initializeSearchModeToggle() {
meta: 'Artist',
badge: sourceBadge,
onClick: () => {
const sourceOverride = _activeSearchSource;
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);
@ -491,200 +490,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) {
@ -769,8 +574,9 @@ function initializeSearchModeToggle() {
if (!artistId) continue;
try {
const imgUrl = _activeSearchSource && _activeSearchSource !== 'spotify'
? `/api/artist/${artistId}/image?source=${_activeSearchSource}`
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();
@ -808,118 +614,7 @@ function initializeSearchModeToggle() {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function renderCompactSection(sectionId, listId, countId, items, mapItem) {
const section = document.getElementById(sectionId);
const list = document.getElementById(listId);
const count = document.getElementById(countId);
if (!list) return;
list.innerHTML = '';
if (!items || items.length === 0) {
section.classList.add('hidden');
return;
}
section.classList.remove('hidden');
count.textContent = items.length;
// Determine type based on section ID
const isArtist = sectionId.includes('artists');
const isAlbum = sectionId.includes('albums') || sectionId.includes('singles');
const isTrack = sectionId.includes('tracks');
// Add appropriate grid class to list
if (isArtist) {
list.classList.add('enh-artists-grid');
} else if (isAlbum) {
list.classList.add('enh-albums-grid');
} else if (isTrack) {
list.classList.add('enh-tracks-list');
}
items.forEach(item => {
const config = mapItem(item);
const elem = document.createElement('div');
// Add appropriate card class
if (isArtist) {
elem.className = 'enh-compact-item artist-card';
// Add data attributes for lazy loading
if (item.id) {
elem.dataset.artistId = item.id;
elem.dataset.needsImage = config.image ? 'false' : 'true';
}
} else if (isAlbum) {
elem.className = 'enh-compact-item album-card';
} else if (isTrack) {
elem.className = 'enh-compact-item track-item';
}
// Build image HTML with type-specific classes
let imageClass = 'enh-item-image';
let placeholderClass = 'enh-item-image-placeholder';
if (isArtist) {
imageClass += ' artist-image';
placeholderClass += ' artist-placeholder';
} else if (isAlbum) {
imageClass += ' album-cover';
placeholderClass += ' album-placeholder';
} else if (isTrack) {
imageClass += ' track-cover';
placeholderClass += ' track-placeholder';
}
const imageHtml = config.image
? `<img src="${escapeHtml(config.image)}" class="${imageClass}" alt="${escapeHtml(config.name)}">`
: `<div class="${placeholderClass}" data-lazy-image="true">${config.placeholder}</div>`;
const badgeHtml = config.badge
? `<div class="enh-item-badge ${config.badge.class}">${config.badge.text}</div>`
: '';
const durationHtml = config.duration && isTrack
? `<div class="enh-item-duration">
${escapeHtml(config.duration)}
<button class="enh-item-play-btn" title="Stream this track"></button>
</div>`
: '';
elem.innerHTML = `
${imageHtml}
<div class="enh-item-info">
<div class="enh-item-name">${escapeHtml(config.name)}</div>
<div class="enh-item-meta">${escapeHtml(config.meta)}</div>
</div>
${durationHtml}
${badgeHtml}
`;
elem.addEventListener('click', config.onClick);
// Add play button handler for tracks
if (isTrack && config.onPlay) {
const playBtn = elem.querySelector('.enh-item-play-btn');
if (playBtn) {
playBtn.addEventListener('click', (e) => {
e.stopPropagation(); // Don't trigger main onClick
config.onPlay();
});
}
}
list.appendChild(elem);
// Extract colors from image for dynamic glow effect
if (config.image) {
extractImageColors(config.image, (colors) => {
applyDynamicGlow(elem, colors);
});
}
});
}
// renderCompactSection now lives in shared-helpers.js.
async function handleEnhancedSearchAlbumClick(album) {
console.log(`💿 Enhanced search album clicked: ${album.name} by ${album.artist}`);
@ -929,8 +624,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 (_activeSearchSource && _activeSearchSource !== 'spotify') {
albumParams.set('source', _activeSearchSource);
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) {
@ -996,7 +692,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: activeSource || '',
};
// Prepare full album object for modal
@ -1314,10 +1010,7 @@ function initializeSearchModeToggle() {
function showDropdown() {
const dropdown = document.getElementById('enhanced-dropdown');
if (dropdown) {
dropdown.classList.remove('hidden');
updateToggleButtonState();
}
if (dropdown) dropdown.classList.remove('hidden');
// Hide the page header + source picker to reclaim space
const header = document.querySelector('#search-page .downloads-header');
const modeToggle = document.querySelector('.search-source-picker-container');
@ -1329,10 +1022,7 @@ function initializeSearchModeToggle() {
function hideDropdown() {
const dropdown = document.getElementById('enhanced-dropdown');
if (dropdown) {
dropdown.classList.add('hidden');
updateToggleButtonState();
}
if (dropdown) dropdown.classList.add('hidden');
// Restore hidden elements
const header = document.querySelector('#search-page .downloads-header');
const modeToggle = document.querySelector('.search-source-picker-container');
@ -1341,27 +1031,6 @@ function initializeSearchModeToggle() {
if (modeToggle) modeToggle.classList.remove('enh-results-active-hide');
if (slskdPlaceholder) slskdPlaceholder.classList.remove('enh-results-active-hide');
}
function updateToggleButtonState() {
// Get fresh references
const btn = document.getElementById('enhanced-search-btn');
const dropdown = document.getElementById('enhanced-dropdown');
if (!btn || !dropdown) return;
const btnIcon = btn.querySelector('.btn-icon');
const btnText = btn.querySelector('.btn-text');
if (dropdown.classList.contains('hidden')) {
// Dropdown is hidden - button should say "Show Results"
if (btnIcon) btnIcon.textContent = '👁️';
if (btnText) btnText.textContent = 'Show Results';
} else {
// Dropdown is visible - button should say "Hide Results"
if (btnIcon) btnIcon.textContent = '🙈';
if (btnText) btnText.textContent = 'Hide Results';
}
}
}
async function performSearch() {

View file

@ -12,6 +12,603 @@
// ============================================================================
// ----------------------------------------------------------------------------
// Enhanced search shared utilities (used by Search page + global widget)
// ----------------------------------------------------------------------------
// Pass source to restrict results to a single metadata provider; omit or pass
// null/'auto' to let the backend fan out across all configured sources.
async function enhancedSearchFetch(query, { source = null, signal = null } = {}) {
const body = { query };
if (source && source !== 'auto') body.source = source;
const res = await fetch('/api/enhanced-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: signal || undefined,
});
if (!res.ok) throw new Error(`Enhanced search failed: ${res.status}`);
return res.json();
}
// Per-source labels + tab/badge CSS classes + icon glyph for the source
// picker row. The `logo` URL (when present) renders as an <img> 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: '🎵',
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
// first, then YouTube Music Videos, then Soulseek (basic-file source).
const SOURCE_ORDER = [
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz',
'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.
// Soulseek IS configurable (needs slskd URL), so it's intentionally not here:
// /api/settings/config-status reports its real state and the picker dims it
// when no slskd is set up, redirecting clicks to Settings → Downloads.
const _ALWAYS_CONFIGURED_SOURCES = new Set(['musicbrainz', 'youtube_videos']);
// 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;
}
// 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;
// Per-source request tokens. Each _fetchSource call increments the
// monotonic _requestSeq and stamps it into _sourceRequestIds[src].
// Settle/error blocks bail before mutating shared state if their
// requestId no longer matches the latest id for THAT source —
// protecting against the fast-retype race (same-source supersession)
// without dropping cleanup for cross-source supersession.
//
// A single global token would mishandle cross-source: switching
// Spotify → Deezer aborts Spotify's fetch, but Spotify's catch needs
// to clear 'spotify' from loadingSources (Deezer's request hasn't
// touched it). Per-source tracking lets each source's catch own its
// own loadingSources entry.
let _requestSeq = 0;
const _sourceRequestIds = Object.create(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
? `<img src="${escapeHtml(info.logo)}" alt="" loading="lazy">`
: info.icon);
return `
<button class="${classes}" data-source="${src}" role="tab"
aria-selected="${active}" title="${escapeHtml(title)}">
<span class="${glyphClass}">${glyph}</span>
<span class="${labelClass}">${escapeHtml(info.text)}</span>
</button>`;
}).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;
const requestId = ++_requestSeq;
_sourceRequestIds[src] = requestId;
state.loadingSources.add(src);
renderSourceRow();
_notify();
if (abortCtrl) abortCtrl.abort();
abortCtrl = new AbortController();
try {
if (src === 'youtube_videos') {
await _fetchYouTubeVideos(query, abortCtrl.signal, requestId);
} else {
const data = await enhancedSearchFetch(query, {
source: src,
signal: abortCtrl.signal,
});
// Bail without writing if a newer request for THIS source
// has superseded us. Cross-source supersession (different
// src entirely) is handled by the loadingSources cleanup
// below — each source's catch owns its own entry.
if (_sourceRequestIds[src] !== requestId) return;
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;
}
if (_sourceRequestIds[src] !== requestId) return;
state.loadingSources.delete(src);
renderSourceRow();
_notify();
} catch (err) {
// Only clear loadingSources if no newer request for THIS source
// is in flight. Cross-source supersession (e.g. user switched
// Spotify → Deezer) still falls through here so Spotify's
// spinner gets cleared on its own AbortError.
if (_sourceRequestIds[src] === requestId) {
state.loadingSources.delete(src);
renderSourceRow();
_notify();
}
if (err.name !== 'AbortError') {
console.debug(`Source fetch failed for ${src}:`, err);
}
}
}
async function _fetchYouTubeVideos(query, signal, requestId) {
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}`);
// Bail before allocating cache entry if a newer YouTube request
// has superseded us.
if (_sourceRequestIds['youtube_videos'] !== requestId) return;
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;
if (_sourceRequestIds['youtube_videos'] !== requestId) return;
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 (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();
// Invalidate every in-flight per-source token. Without this, a
// settle that arrives AFTER a query reset (e.g. user typed 'a',
// fetch started, then user cleared the input) would still
// pass the per-source token check and write stale data back
// into the just-cleared state.sources. Setting fresh tokens
// when each new _fetchSource fires re-stamps as needed.
for (const k in _sourceRequestIds) delete _sourceRequestIds[k];
// Abort the active fetch — its results are useless now.
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
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 → relevant tab and scroll to the service card that
// matches the picker's source id. Called when a user clicks an unconfigured
// source icon. Soulseek is special-cased to land on the Downloads tab where
// its slskd URL field lives (gated behind the download-source-mode select);
// every other source has a card on Connections.
function openSettingsForSource(src) {
if (typeof navigateToPage !== 'function') return;
navigateToPage('settings');
const targetTab = src === 'soulseek' ? 'downloads' : 'connections';
setTimeout(() => {
try {
if (typeof switchSettingsTab === 'function') switchSettingsTab(targetTab);
} catch (_) { /* best-effort */ }
setTimeout(() => {
// Soulseek doesn't have a .stg-service card — scroll to the
// slskd URL input instead so the user lands on the right field.
const target = src === 'soulseek'
? document.querySelector('#settings-page #soulseek-url')
: document.querySelector(`#settings-page .stg-service[data-service="${src}"]`);
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (src === 'soulseek') {
try { target.focus(); } catch (_) { /* best-effort */ }
} else {
target.classList.add('stg-service-flash');
setTimeout(() => target.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.
function renderCompactSection(sectionId, listId, countId, items, mapItem) {
const section = document.getElementById(sectionId);
const list = document.getElementById(listId);
const count = document.getElementById(countId);
if (!list) return;
list.innerHTML = '';
if (!items || items.length === 0) {
section.classList.add('hidden');
return;
}
section.classList.remove('hidden');
count.textContent = items.length;
// Determine type based on section ID
const isArtist = sectionId.includes('artists');
const isAlbum = sectionId.includes('albums') || sectionId.includes('singles');
const isTrack = sectionId.includes('tracks');
// Add appropriate grid class to list
if (isArtist) {
list.classList.add('enh-artists-grid');
} else if (isAlbum) {
list.classList.add('enh-albums-grid');
} else if (isTrack) {
list.classList.add('enh-tracks-list');
}
items.forEach(item => {
const config = mapItem(item);
const elem = document.createElement('div');
// Add appropriate card class
if (isArtist) {
elem.className = 'enh-compact-item artist-card';
// Add data attributes for lazy loading
if (item.id) {
elem.dataset.artistId = item.id;
elem.dataset.needsImage = config.image ? 'false' : 'true';
}
} else if (isAlbum) {
elem.className = 'enh-compact-item album-card';
} else if (isTrack) {
elem.className = 'enh-compact-item track-item';
}
// Build image HTML with type-specific classes
let imageClass = 'enh-item-image';
let placeholderClass = 'enh-item-image-placeholder';
if (isArtist) {
imageClass += ' artist-image';
placeholderClass += ' artist-placeholder';
} else if (isAlbum) {
imageClass += ' album-cover';
placeholderClass += ' album-placeholder';
} else if (isTrack) {
imageClass += ' track-cover';
placeholderClass += ' track-placeholder';
}
const imageHtml = config.image
? `<img src="${escapeHtml(config.image)}" class="${imageClass}" alt="${escapeHtml(config.name)}">`
: `<div class="${placeholderClass}" data-lazy-image="true">${config.placeholder}</div>`;
const badgeHtml = config.badge
? `<div class="enh-item-badge ${config.badge.class}">${config.badge.text}</div>`
: '';
const durationHtml = config.duration && isTrack
? `<div class="enh-item-duration">
${escapeHtml(config.duration)}
<button class="enh-item-play-btn" title="Stream this track"></button>
</div>`
: '';
elem.innerHTML = `
${imageHtml}
<div class="enh-item-info">
<div class="enh-item-name">${escapeHtml(config.name)}</div>
<div class="enh-item-meta">${escapeHtml(config.meta)}</div>
</div>
${durationHtml}
${badgeHtml}
`;
elem.addEventListener('click', config.onClick);
// Add play button handler for tracks
if (isTrack && config.onPlay) {
const playBtn = elem.querySelector('.enh-item-play-btn');
if (playBtn) {
playBtn.addEventListener('click', (e) => {
e.stopPropagation(); // Don't trigger main onClick
config.onPlay();
});
}
}
list.appendChild(elem);
// Extract colors from image for dynamic glow effect
if (config.image) {
extractImageColors(config.image, (colors) => {
applyDynamicGlow(elem, colors);
});
}
});
}
// ----------------------------------------------------------------------------
// Discography completion checking (for artist-detail pages, library page)
// ----------------------------------------------------------------------------

View file

@ -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;
@ -5479,6 +5511,195 @@ 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 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: 12px 14px 8px;
flex-shrink: 0;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
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; }
.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: 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: 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: 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 3px 10px rgba(0, 0, 0, 0.25);
}
.gsearch-source-icon:active { transform: translateY(0) scale(0.97); }
.gsearch-source-icon-glyph {
font-size: 20px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.25));
}
.gsearch-source-icon-glyph img {
width: 22px;
height: 22px;
object-fit: contain;
display: block;
}
.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.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: 5px;
right: 6px;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
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;
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);
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;
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;
@ -22865,6 +23086,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;
@ -33084,38 +33338,6 @@ div.artist-hero-badge {
color: #fff;
}
.enhanced-search-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 10px 20px;
color: rgba(255, 255, 255, 0.8);
font-family: 'Segoe UI', sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
flex-shrink: 0;
}
.enhanced-search-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
transform: none;
box-shadow: none;
}
.enhanced-search-btn:active {
transform: scale(0.97);
}
.btn-icon {
font-size: 14px;
}
/* Enhanced Search Status */
.enhanced-search-status {
display: flex;
@ -33322,12 +33544,6 @@ div.artist-hero-badge {
gap: 8px;
}
.enhanced-search-btn {
width: 100%;
justify-content: center;
padding: 10px 16px;
}
#enhanced-search-input {
font-size: 14px;
}
@ -33348,11 +33564,6 @@ div.artist-hero-badge {
margin-right: 6px;
}
.enhanced-search-btn {
padding: 9px 14px;
font-size: 12px;
}
/* Better album/track results on mobile */
.album-result-item {
margin-bottom: 8px;
@ -33513,6 +33724,212 @@ 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;
overflow-y: visible;
gap: 10px;
padding: 12px 14px;
margin: 10px 0 14px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
/* 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; }
.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: 6px;
min-width: 90px;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
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: 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: 11.5px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.enh-source-icon:hover {
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: translateY(0) scale(0.97); }
.enh-source-icon-glyph {
font-size: 26px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.25));
}
.enh-source-icon-glyph img {
width: 30px;
height: 30px;
object-fit: contain;
display: block;
}
.enh-source-icon-label {
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);
}
/* Cache dot brand-coloured pulse on icons that have results cached for
the current query. */
.enh-source-icon.cached::after {
content: '';
position: absolute;
top: 7px;
right: 9px;
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
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 {
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);
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;
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;