// 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; return ` `; }).join(''); sourceRow.querySelectorAll('.enh-source-icon').forEach(btn => { btn.addEventListener('click', () => setActiveSource(btn.dataset.source)); }); } async function _initDefaultSource() { try { const resp = await fetch('/api/settings'); if (resp.ok) { const settings = await resp.json(); const cfg = settings.metadata && settings.metadata.fallback_source; if (cfg && SOURCE_LABELS[cfg]) { currentSearchSource = cfg; } } } catch (_) { /* settings fetch best-effort */ } if (!SOURCE_LABELS[currentSearchSource]) currentSearchSource = 'spotify'; renderSourceRow(); } _initDefaultSource(); // ── Source selection ─────────────────────────────────────────────── function setActiveSource(src) { if (!SOURCE_LABELS[src]) return; if (src === currentSearchSource) return; currentSearchSource = src; // Soulseek routes to the basic search UI (raw file results). We keep // no cache for it — the basic search renders straight to the page. if (src === 'soulseek') { basicSection.classList.add('active'); enhancedSection.classList.remove('active'); hideDropdown(); renderSourceRow(); const basicInput = document.getElementById('downloads-search-input'); if (basicInput && _cachedData.query) { basicInput.value = _cachedData.query; if (typeof performDownloadsSearch === 'function') performDownloadsSearch(); } return; } // Other sources use the enhanced dropdown. basicSection.classList.remove('active'); enhancedSection.classList.add('active'); renderSourceRow(); if (!_cachedData.query) return; if (_cachedData.sources[src]) { _renderFromCache(src); } else { _fetchAndRenderSource(src); } } // ── Render + fetch ───────────────────────────────────────────────── function _renderFromCache(src) { const cached = _cachedData.sources[src]; if (!cached) return; loadingState.classList.add('hidden'); emptyState.classList.add('hidden'); resultsContainer.classList.remove('hidden'); showDropdown(); _renderFallbackBanner(src); if (src === 'youtube_videos') { ['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => { const el = document.getElementById(id); if (el) el.classList.add('hidden'); }); const artistsWrapper = document.querySelector('.enh-artists-wrapper'); if (artistsWrapper) artistsWrapper.style.display = 'none'; _renderVideoResults(cached.videos || []); return; } const videosSec = document.getElementById('enh-videos-section'); if (videosSec) videosSec.classList.add('hidden'); const artistsWrapper = document.querySelector('.enh-artists-wrapper'); if (artistsWrapper) artistsWrapper.style.display = ''; renderDropdownResults({ db_artists: cached.db_artists || [], spotify_artists: cached.artists || [], spotify_albums: cached.albums || [], spotify_tracks: cached.tracks || [], metadata_source: src, }); } function _renderFallbackBanner(src) { const banner = document.getElementById('enh-fallback-banner'); if (!banner) return; const actual = _cachedData.fallbacks[src]; if (actual && actual !== src) { const clicked = (SOURCE_LABELS[src] || {}).text || src; const served = (SOURCE_LABELS[actual] || {}).text || actual; banner.textContent = `${clicked} unavailable — showing ${served}.`; banner.classList.remove('hidden'); } else { banner.classList.add('hidden'); } } async function _fetchAndRenderSource(src) { const query = _cachedData.query; if (!query) return; _cachedData.loadingSources.add(src); renderSourceRow(); showDropdown(); emptyState.classList.add('hidden'); resultsContainer.classList.add('hidden'); loadingState.classList.remove('hidden'); const loadingText = document.getElementById('enhanced-loading-text'); if (loadingText) { const info = SOURCE_LABELS[src]; loadingText.textContent = `Searching ${(info && info.text) || src} and your library...`; } if (abortController) abortController.abort(); abortController = new AbortController(); try { if (src === 'youtube_videos') { await _fetchYouTubeVideos(query, abortController.signal); } else { const data = await enhancedSearchFetch(query, { source: src, signal: abortController.signal, }); _cachedData.sources[src] = { artists: data.spotify_artists || [], albums: data.spotify_albums || [], tracks: data.spotify_tracks || [], videos: [], available: true, db_artists: data.db_artists || [], }; const served = data.primary_source || data.metadata_source; if (served && served !== src) { _cachedData.fallbacks[src] = served; } } _cachedData.loadingSources.delete(src); renderSourceRow(); if (currentSearchSource === src) { const cached = _cachedData.sources[src]; const total = src === 'youtube_videos' ? ((cached && cached.videos && cached.videos.length) || 0) : ((cached && cached.db_artists && cached.db_artists.length) || 0) + ((cached && cached.artists && cached.artists.length) || 0) + ((cached && cached.albums && cached.albums.length) || 0) + ((cached && cached.tracks && cached.tracks.length) || 0); loadingState.classList.add('hidden'); if (total === 0) { emptyState.classList.remove('hidden'); } else { _renderFromCache(src); } } } catch (err) { _cachedData.loadingSources.delete(src); renderSourceRow(); if (err.name !== 'AbortError') { console.error(`Source fetch failed for ${src}:`, err); if (currentSearchSource === src) { loadingState.classList.add('hidden'); emptyState.classList.remove('hidden'); } } } } async function _fetchYouTubeVideos(query, signal) { const response = await fetch('/api/enhanced-search/source/youtube_videos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }), signal, }); if (!response.ok) throw new Error(`YouTube search failed: ${response.status}`); _cachedData.sources['youtube_videos'] = { artists: [], albums: [], tracks: [], videos: [], available: true, db_artists: [], }; const cache = _cachedData.sources['youtube_videos']; const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let newlineIdx; while ((newlineIdx = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, newlineIdx).trim(); buffer = buffer.slice(newlineIdx + 1); if (!line) continue; try { const chunk = JSON.parse(line); if (chunk.type === 'videos') { cache.videos = chunk.data; // Live-render if still active — lets the user see results // as soon as the chunk arrives. if (currentSearchSource === 'youtube_videos') { _renderFromCache('youtube_videos'); } } } catch (_) { /* best-effort NDJSON parse */ } } } } // Live search with debouncing if (enhancedInput) { 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'); // Modal sits above the dropdown; closing it shouldn't dismiss results. const isClickInModal = e.target.closest('.download-missing-modal'); if (!isClickInside && !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 + '.