// SEARCH FUNCTIONALITY
// ===============================
// `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 ---
const searchInput = document.getElementById('downloads-search-input');
const searchButton = document.getElementById('downloads-search-btn');
// Add this line to get the cancel button
const cancelButton = document.getElementById('downloads-cancel-btn');
if (searchButton && searchInput) {
searchButton.addEventListener('click', performDownloadsSearch);
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') performDownloadsSearch();
});
}
// Add this event listener for the cancel button
if (cancelButton) {
cancelButton.addEventListener('click', () => {
if (searchAbortController) {
searchAbortController.abort(); // This cancels the fetch request
console.log("Search cancelled by user.");
}
});
}
}
// ===============================
// SEARCH MODE TOGGLE
// ===============================
let searchModeToggleInitialized = false;
function initializeSearchModeToggle() {
// Only initialize once to prevent duplicate event listeners
if (searchModeToggleInitialized) {
console.log('Search mode toggle already initialized, skipping...');
return;
}
const sourceRow = document.getElementById('enh-source-row');
const basicSection = document.getElementById('basic-search-section');
const enhancedSection = document.getElementById('enhanced-search-section');
if (!sourceRow || !basicSection || !enhancedSection) {
console.warn('Search source picker elements not found');
return;
}
searchModeToggleInitialized = true;
console.log('✅ Initializing search source picker (first time only)');
// Active source — the icon the user is currently viewing. Initialized from
// the user's configured primary source via /api/settings (below). No 'auto'
// / fan-out mode: every search targets exactly one source.
let currentSearchSource = 'spotify';
// Per-query cache. `sources[src]` holds the result payload the last time
// `src` was fetched for the current query. `fallbacks[src]` records the
// source the backend actually served when it auto-fell-back (e.g. user
// clicked Spotify but got Deezer because Spotify is rate-limited).
// `loadingSources` drives per-icon spinners. Cleared whenever the query
// string changes — we never serve stale results across queries.
let _cachedData = {
query: '',
sources: {},
fallbacks: {},
loadingSources: new Set(),
};
// 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');
const emptyState = document.getElementById('enhanced-empty');
const resultsContainer = document.getElementById('enhanced-results-container');
let debounceTimer = null;
let abortController = null;
// SOURCE_LABELS + SOURCE_ORDER now live in shared-helpers.js.
// ── Source icon row ────────────────────────────────────────────────
function renderSourceRow() {
sourceRow.innerHTML = SOURCE_ORDER.map(src => {
const info = SOURCE_LABELS[src];
if (!info) return '';
const active = src === currentSearchSource;
const cached = !!_cachedData.sources[src];
const loading = _cachedData.loadingSources.has(src);
const fallback = _cachedData.fallbacks[src];
const classes = [
'enh-source-icon',
active ? 'active' : '',
cached ? 'cached' : '',
loading ? 'loading' : '',
fallback ? 'fallback-warning' : '',
].filter(Boolean).join(' ');
const title = fallback
? `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`
: info.text;
// Prefer the brand logo when available; fall back to the emoji.
// Spinner glyph overrides both while loading.
const glyph = loading
? '⏳'
: (info.logo
? ``
: info.icon);
return `
`;
}).join('');
sourceRow.querySelectorAll('.enh-source-icon').forEach(btn => {
btn.addEventListener('click', (e) => {
// Stop the outside-click document handler from dismissing the
// dropdown. `renderSourceRow` re-renders after a source switch,
// detaching this button from the DOM, so the bubbled handler's
// `closest('#enh-source-row')` would fail on the detached target.
e.stopPropagation();
setActiveSource(btn.dataset.source);
});
});
}
async function _initDefaultSource() {
try {
const resp = await fetch('/api/settings');
if (resp.ok) {
const settings = await resp.json();
const cfg = settings.metadata && settings.metadata.fallback_source;
if (cfg && SOURCE_LABELS[cfg]) {
currentSearchSource = cfg;
}
}
} catch (_) { /* settings fetch best-effort */ }
if (!SOURCE_LABELS[currentSearchSource]) currentSearchSource = 'spotify';
renderSourceRow();
}
_initDefaultSource();
// ── Source selection ───────────────────────────────────────────────
function setActiveSource(src) {
if (!SOURCE_LABELS[src]) return;
if (src === currentSearchSource) return;
currentSearchSource = src;
// Soulseek routes to the basic search UI (raw file results). We keep
// no cache for it — the basic search renders straight to the page.
if (src === 'soulseek') {
basicSection.classList.add('active');
enhancedSection.classList.remove('active');
hideDropdown();
renderSourceRow();
const basicInput = document.getElementById('downloads-search-input');
if (basicInput && _cachedData.query) {
basicInput.value = _cachedData.query;
if (typeof performDownloadsSearch === 'function') performDownloadsSearch();
}
return;
}
// Other sources use the enhanced dropdown.
basicSection.classList.remove('active');
enhancedSection.classList.add('active');
renderSourceRow();
if (!_cachedData.query) return;
if (_cachedData.sources[src]) {
_renderFromCache(src);
} else {
_fetchAndRenderSource(src);
}
}
// ── Render + fetch ─────────────────────────────────────────────────
function _renderFromCache(src) {
const cached = _cachedData.sources[src];
if (!cached) return;
loadingState.classList.add('hidden');
emptyState.classList.add('hidden');
resultsContainer.classList.remove('hidden');
showDropdown();
_renderFallbackBanner(src);
if (src === 'youtube_videos') {
['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => {
const el = document.getElementById(id);
if (el) el.classList.add('hidden');
});
const artistsWrapper = document.querySelector('.enh-artists-wrapper');
if (artistsWrapper) artistsWrapper.style.display = 'none';
_renderVideoResults(cached.videos || []);
return;
}
const videosSec = document.getElementById('enh-videos-section');
if (videosSec) videosSec.classList.add('hidden');
const artistsWrapper = document.querySelector('.enh-artists-wrapper');
if (artistsWrapper) artistsWrapper.style.display = '';
renderDropdownResults({
db_artists: cached.db_artists || [],
spotify_artists: cached.artists || [],
spotify_albums: cached.albums || [],
spotify_tracks: cached.tracks || [],
metadata_source: src,
});
}
function _renderFallbackBanner(src) {
const banner = document.getElementById('enh-fallback-banner');
if (!banner) return;
const actual = _cachedData.fallbacks[src];
if (actual && actual !== src) {
const clicked = (SOURCE_LABELS[src] || {}).text || src;
const served = (SOURCE_LABELS[actual] || {}).text || actual;
banner.textContent = `${clicked} unavailable — showing ${served}.`;
banner.classList.remove('hidden');
} else {
banner.classList.add('hidden');
}
}
async function _fetchAndRenderSource(src) {
const query = _cachedData.query;
if (!query) return;
_cachedData.loadingSources.add(src);
renderSourceRow();
showDropdown();
emptyState.classList.add('hidden');
resultsContainer.classList.add('hidden');
loadingState.classList.remove('hidden');
const loadingText = document.getElementById('enhanced-loading-text');
if (loadingText) {
const info = SOURCE_LABELS[src];
loadingText.textContent = `Searching ${(info && info.text) || src} and your library...`;
}
if (abortController) abortController.abort();
abortController = new AbortController();
try {
if (src === 'youtube_videos') {
await _fetchYouTubeVideos(query, abortController.signal);
} else {
const data = await enhancedSearchFetch(query, {
source: src,
signal: abortController.signal,
});
_cachedData.sources[src] = {
artists: data.spotify_artists || [],
albums: data.spotify_albums || [],
tracks: data.spotify_tracks || [],
videos: [],
available: true,
db_artists: data.db_artists || [],
};
const served = data.primary_source || data.metadata_source;
if (served && served !== src) {
_cachedData.fallbacks[src] = served;
}
}
_cachedData.loadingSources.delete(src);
renderSourceRow();
if (currentSearchSource === src) {
const cached = _cachedData.sources[src];
const total = src === 'youtube_videos'
? ((cached && cached.videos && cached.videos.length) || 0)
: ((cached && cached.db_artists && cached.db_artists.length) || 0)
+ ((cached && cached.artists && cached.artists.length) || 0)
+ ((cached && cached.albums && cached.albums.length) || 0)
+ ((cached && cached.tracks && cached.tracks.length) || 0);
loadingState.classList.add('hidden');
if (total === 0) {
emptyState.classList.remove('hidden');
} else {
_renderFromCache(src);
}
}
} catch (err) {
_cachedData.loadingSources.delete(src);
renderSourceRow();
if (err.name !== 'AbortError') {
console.error(`Source fetch failed for ${src}:`, err);
if (currentSearchSource === src) {
loadingState.classList.add('hidden');
emptyState.classList.remove('hidden');
}
}
}
}
async function _fetchYouTubeVideos(query, signal) {
const response = await fetch('/api/enhanced-search/source/youtube_videos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
signal,
});
if (!response.ok) throw new Error(`YouTube search failed: ${response.status}`);
_cachedData.sources['youtube_videos'] = {
artists: [], albums: [], tracks: [], videos: [],
available: true, db_artists: [],
};
const cache = _cachedData.sources['youtube_videos'];
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIdx;
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIdx).trim();
buffer = buffer.slice(newlineIdx + 1);
if (!line) continue;
try {
const chunk = JSON.parse(line);
if (chunk.type === 'videos') {
cache.videos = chunk.data;
// Live-render if still active — lets the user see results
// as soon as the chunk arrives.
if (currentSearchSource === 'youtube_videos') {
_renderFromCache('youtube_videos');
}
}
} catch (_) { /* best-effort NDJSON parse */ }
}
}
}
// Live search with debouncing
if (enhancedInput) {
enhancedInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
// Show/hide cancel button
if (enhancedCancelBtn) {
enhancedCancelBtn.classList.toggle('hidden', query.length === 0);
}
// Clear debounce timer
clearTimeout(debounceTimer);
// Hide dropdown if query too short
if (query.length < 2) {
hideDropdown();
return;
}
// Debounce search
debounceTimer = setTimeout(() => {
performEnhancedSearch(query);
}, 300);
});
enhancedInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
const query = e.target.value.trim();
if (query.length >= 2) {
clearTimeout(debounceTimer);
performEnhancedSearch(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 = '';
enhancedCancelBtn.classList.add('hidden');
hideDropdown();
});
}
// Close button inside dropdown (mobile)
const dropdownCloseBtn = document.getElementById('enhanced-dropdown-close');
if (dropdownCloseBtn) {
dropdownCloseBtn.addEventListener('click', (e) => {
e.stopPropagation();
hideDropdown();
});
}
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
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 && !isClickOnSourceRow && !isClickInModal) {
hideDropdown();
}
}
});
async function performEnhancedSearch(query) {
console.log('Enhanced search:', query, '→', currentSearchSource);
// Query changed? Drop the whole per-query cache so we never serve
// stale results across queries. Icon dots and fallback banner reset.
if (query !== _cachedData.query) {
_cachedData.query = query;
_cachedData.sources = {};
_cachedData.fallbacks = {};
_cachedData.loadingSources = new Set();
renderSourceRow();
}
// Soulseek → existing basic search flow, no cache here.
if (currentSearchSource === 'soulseek') {
const basicInput = document.getElementById('downloads-search-input');
if (basicInput) {
basicInput.value = query;
if (typeof performDownloadsSearch === 'function') performDownloadsSearch();
}
return;
}
// Same query + same source that's already cached → instant render.
if (_cachedData.sources[currentSearchSource]) {
_renderFromCache(currentSearchSource);
return;
}
await _fetchAndRenderSource(currentSearchSource);
}
function renderDropdownResults(data) {
// Music Videos tab — don't render regular sections
if (currentSearchSource === 'youtube_videos') return;
// Determine source badge from active tab (not just primary)
const displaySource = currentSearchSource || data.metadata_source || 'spotify';
const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify;
const sourceBadge = { text: sourceInfo.text, class: sourceInfo.badgeClass };
// Render DB Artists
renderCompactSection(
'enh-db-artists-section',
'enh-db-artists-list',
'enh-db-artists-count',
data.db_artists || [],
(artist) => ({
image: artist.image_url,
placeholder: '📚',
name: artist.name,
meta: 'In Your Library',
badge: { text: 'Library', class: 'enh-badge-library' },
onClick: () => {
console.log(`🎵 Opening library artist detail: ${artist.name} (ID: ${artist.id})`);
hideDropdown();
navigateToArtistDetail(artist.id, artist.name);
}
})
);
// Render Artists (source-aware badge)
renderCompactSection(
'enh-spotify-artists-section',
'enh-spotify-artists-list',
'enh-spotify-artists-count',
data.spotify_artists || [],
(artist) => ({
image: artist.image_url,
placeholder: '🎤',
name: artist.name,
meta: 'Artist',
badge: sourceBadge,
onClick: () => {
const sourceOverride = currentSearchSource;
console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`);
hideDropdown();
navigateToArtistDetail(artist.id, artist.name, sourceOverride || null);
}
})
);
// Split albums from singles/EPs (albums is the catch-all for unknown types)
const allAlbums = data.spotify_albums || [];
const singlesAndEPs = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep');
const albums = allAlbums.filter(a => a.album_type !== 'single' && a.album_type !== 'ep');
// Render Albums
renderCompactSection(
'enh-albums-section',
'enh-albums-list',
'enh-albums-count',
albums,
(album) => ({
image: album.image_url,
placeholder: '💿',
name: album.name,
meta: `${album.artist} • ${album.release_date ? album.release_date.substring(0, 4) : 'N/A'}`,
onClick: () => handleEnhancedSearchAlbumClick(album)
})
);
// Render Singles & EPs
renderCompactSection(
'enh-singles-section',
'enh-singles-list',
'enh-singles-count',
singlesAndEPs,
(album) => ({
image: album.image_url,
placeholder: '🎶',
name: album.name,
meta: `${album.artist} • ${album.release_date ? album.release_date.substring(0, 4) : 'N/A'}`,
onClick: () => handleEnhancedSearchAlbumClick(album)
})
);
// Render Tracks
renderCompactSection(
'enh-tracks-section',
'enh-tracks-list',
'enh-tracks-count',
data.spotify_tracks || [],
(track) => {
const duration = formatDuration(track.duration_ms);
return {
image: track.image_url,
placeholder: '🎵',
name: track.name,
meta: `${track.artist} • ${track.album}`,
duration: duration,
onClick: () => handleEnhancedSearchTrackClick(track),
onPlay: () => streamEnhancedSearchTrack(track)
};
}
);
// Lazy load artist images that are missing
lazyLoadEnhancedSearchArtistImages();
// Async library ownership check — doesn't block rendering
_checkSearchResultsLibraryOwnership(data);
}
async function _checkSearchResultsLibraryOwnership(data) {
try {
const allAlbums = data.spotify_albums || [];
const allTracks = data.spotify_tracks || [];
if (!allAlbums.length && !allTracks.length) return;
const resp = await fetch('/api/enhanced-search/library-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
albums: allAlbums.map(a => ({ name: a.name, artist: a.artist })),
tracks: allTracks.map(t => ({ name: t.name, artist: t.artist })),
}),
});
const result = await resp.json();
// Tag album cards with staggered animation
const albumCards = document.querySelectorAll('#enh-albums-list .enh-compact-item, #enh-singles-list .enh-compact-item');
const albumResults = result.albums || [];
let delay = 0;
albumCards.forEach((card, i) => {
if (albumResults[i]) {
setTimeout(() => {
const badge = document.createElement('div');
badge.className = 'enh-item-lib-badge';
badge.textContent = 'In Library';
card.appendChild(badge);
}, delay);
delay += 30;
}
});
// Tag track rows + wire up library playback
const trackCards = document.querySelectorAll('#enh-tracks-list .enh-compact-item');
const trackResults = result.tracks || [];
trackCards.forEach((card, i) => {
const tr = trackResults[i];
if (tr && tr.in_library) {
setTimeout(() => {
const badge = document.createElement('div');
badge.className = 'enh-item-lib-badge';
badge.textContent = 'In Library';
card.appendChild(badge);
// Replace stream button to play from library instead of searching
if (tr.file_path) {
const playBtn = card.querySelector('.enh-item-play-btn');
if (playBtn) {
const newBtn = playBtn.cloneNode(true);
newBtn.title = 'Play from library';
newBtn.textContent = '▶';
const trackInfo = tr;
newBtn.addEventListener('click', (e) => {
e.stopPropagation();
playLibraryTrack(
{ id: trackInfo.track_id, title: trackInfo.title, file_path: trackInfo.file_path, _stats_image: trackInfo.album_thumb_url || null },
trackInfo.album_title || '',
trackInfo.artist_name || ''
);
});
playBtn.replaceWith(newBtn);
}
}
}, delay);
delay += 30;
} else if (tr && tr.in_wishlist) {
setTimeout(() => {
if (!card.querySelector('.enh-item-wishlist-badge')) {
const badge = document.createElement('div');
badge.className = 'enh-item-wishlist-badge';
badge.textContent = 'In Wishlist';
card.appendChild(badge);
}
}, delay);
delay += 30;
}
});
} catch (e) {
console.debug('Library check failed:', e);
}
}
function _renderVideoResults(videos) {
let section = document.getElementById('enh-videos-section');
if (!section) {
// Create the section dynamically if it doesn't exist
const container = document.getElementById('enhanced-results-container');
if (!container) return;
section = document.createElement('div');
section.id = 'enh-videos-section';
section.className = 'enh-dropdown-section';
section.innerHTML = `
Searching for ${type === 'album' ? 'album' : 'track'}...
Search failed. Please try again.
No matches found for this ' + type + '.