// SEARCH FUNCTIONALITY // =============================== 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 toggleContainer = document.querySelector('.search-mode-toggle'); const modeBtns = document.querySelectorAll('.search-mode-btn'); const basicSection = document.getElementById('basic-search-section'); const enhancedSection = document.getElementById('enhanced-search-section'); if (!toggleContainer || !modeBtns.length || !basicSection || !enhancedSection) { console.warn('Search mode toggle elements not found'); return; } searchModeToggleInitialized = true; console.log('โœ… Initializing search mode toggle (first time only)'); modeBtns.forEach(btn => { btn.addEventListener('click', () => { const mode = btn.dataset.mode; // Update button active states modeBtns.forEach(b => b.classList.remove('active')); btn.classList.add('active'); // Update toggle slider position toggleContainer.setAttribute('data-active', mode); // Toggle sections if (mode === 'basic') { basicSection.classList.add('active'); enhancedSection.classList.remove('active'); console.log('Switched to basic search mode'); } else { basicSection.classList.remove('active'); enhancedSection.classList.add('active'); console.log('Switched to enhanced search mode'); } }); }); // 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; // 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 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' }, }; // 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'); if (!isClickInside) { 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) { loadingText.textContent = `Searching across ${currentMusicSourceName} 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 response = await fetch('/api/enhanced-search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }), signal: abortController.signal }); if (!response.ok) throw new Error('Search failed'); const data = await response.json(); 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) { // Music Videos tab โ€” don't render regular sections if (_activeSearchSource === 'youtube_videos') return; // Determine source badge from active tab (not just primary) const displaySource = _activeSearchSource || 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: async () => { const sourceOverride = _activeSearchSource; console.log(`๐ŸŽต Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`); hideDropdown(); // Navigate to Artists page navigateToPage('artists'); // Small delay to let the page load await new Promise(resolve => setTimeout(resolve, 100)); // Load the artist details with source context await selectArtistForDetail(artist, { source: sourceOverride, plugin: artist.external_urls?.hydrabase_plugin, }); } }) ); // 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 _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 ``; }).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 = '
Loading...
'; 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) { // 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 = `
๐ŸŽฌ

Music Videos

0
`; container.appendChild(section); } section.classList.remove('hidden'); const countEl = document.getElementById('enh-videos-count'); const listEl = document.getElementById('enh-videos-list'); if (countEl) countEl.textContent = videos.length; if (!videos.length) { listEl.innerHTML = '
No music videos found
'; return; } listEl.innerHTML = videos.map(v => { const duration = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : ''; const views = v.view_count ? _formatViewCount(v.view_count) : ''; return `
โ–ถ
${duration ? `${duration}` : ''}
${v.title}
${v.channel}${views ? ` ยท ${views} views` : ''}
`; }).join(''); } function _formatViewCount(count) { if (count >= 1000000000) return `${(count / 1000000000).toFixed(1)}B`; if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; if (count >= 1000) return `${(count / 1000).toFixed(1)}K`; return String(count); } // Lazy load artist images for enhanced search results async function lazyLoadEnhancedSearchArtistImages() { const artistLists = [ document.getElementById('enh-db-artists-list'), document.getElementById('enh-spotify-artists-list') ]; for (const list of artistLists) { if (!list) continue; const cardsNeedingImages = list.querySelectorAll('[data-needs-image="true"]'); if (cardsNeedingImages.length === 0) continue; console.log(`๐Ÿ–ผ๏ธ Lazy loading ${cardsNeedingImages.length} artist images in enhanced search`); for (const card of cardsNeedingImages) { const artistId = card.dataset.artistId; if (!artistId) continue; try { const imgUrl = _activeSearchSource && _activeSearchSource !== 'spotify' ? `/api/artist/${artistId}/image?source=${_activeSearchSource}` : `/api/artist/${artistId}/image`; const response = await fetch(imgUrl); const data = await response.json(); if (data.success && data.image_url) { // Find the placeholder and replace with image const placeholder = card.querySelector('.enh-item-image-placeholder'); if (placeholder) { const img = document.createElement('img'); img.src = data.image_url; img.className = 'enh-item-image artist-image'; img.alt = card.querySelector('.enh-item-name')?.textContent || 'Artist'; placeholder.replaceWith(img); // Apply dynamic glow extractImageColors(data.image_url, (colors) => { applyDynamicGlow(card, colors); }); } card.dataset.needsImage = 'false'; console.log(`โœ… Loaded image for artist ${artistId}`); } } catch (error) { console.warn(`โš ๏ธ Failed to load image for artist ${artistId}:`, error); } } } } function formatDuration(durationMs) { if (!durationMs) return ''; const totalSeconds = Math.floor(durationMs / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; 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 ? `${escapeHtml(config.name)}` : `
${config.placeholder}
`; const badgeHtml = config.badge ? `
${config.badge.text}
` : ''; const durationHtml = config.duration && isTrack ? `
${escapeHtml(config.duration)}
` : ''; elem.innerHTML = ` ${imageHtml}
${escapeHtml(config.name)}
${escapeHtml(config.meta)}
${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); }); } }); } async function handleEnhancedSearchAlbumClick(album) { console.log(`๐Ÿ’ฟ Enhanced search album clicked: ${album.name} by ${album.artist}`); hideDropdown(); showLoadingOverlay('Loading album...'); 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); } // Pass Hydrabase plugin origin so server routes to correct client if (album.external_urls?.hydrabase_plugin) { albumParams.set('plugin', album.external_urls.hydrabase_plugin); } const response = await fetch(`/api/spotify/album/${album.id}?${albumParams}`); if (!response.ok) { if (response.status === 401) { throw new Error('Spotify not authenticated. Please check your API settings.'); } throw new Error(`Failed to load album: ${response.status}`); } const albumData = await response.json(); if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { hideLoadingOverlay(); showToast(`No tracks available for "${album.name}". This release may have been delisted or is not available in your region.`, 'warning'); return; } console.log(`โœ… Loaded ${albumData.tracks.length} tracks for ${albumData.name}`); // Create virtual playlist ID for enhanced search albums const virtualPlaylistId = `enhanced_search_album_${album.id}`; // Check if modal already exists and show it if (activeDownloadProcesses[virtualPlaylistId]) { console.log(`๐Ÿ“ฑ Reopening existing modal for ${album.name}`); const process = activeDownloadProcesses[virtualPlaylistId]; if (process.modalElement) { if (process.status === 'complete') { showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); } process.modalElement.style.display = 'flex'; hideLoadingOverlay(); return; } } // Enrich each track with full album object (needed for wishlist functionality) const enrichedTracks = albumData.tracks.map(track => ({ ...track, album: { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks } })); console.log(`๐Ÿ“ฆ Enriched ${enrichedTracks.length} tracks with album metadata`); // Format playlist name const playlistName = `[${album.artist}] ${albumData.name}`; // Create artist object for the modal โ€” extract ID from album data const firstArtist = (albumData.artists || [])[0] || {}; const artistObject = { id: firstArtist.id || album.id?.split?.('_')?.[0] || '', name: firstArtist.name || album.artist, image_url: firstArtist.image_url || firstArtist.images?.[0]?.url || '', source: _activeSearchSource || '', }; // Prepare full album object for modal const fullAlbumObject = { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks, artists: albumData.artists || [{ name: album.artist }] }; // Open download missing tracks modal await openDownloadMissingModalForArtistAlbum( virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, false // Don't show loading overlay, we already have one ); // Register this download in search bubbles registerSearchDownload( { id: album.id, name: albumData.name, artist: album.artist, image_url: albumData.images?.[0]?.url || null, images: albumData.images || [] }, 'album', virtualPlaylistId, album.artist // artistName for grouping ); hideLoadingOverlay(); } catch (error) { hideLoadingOverlay(); console.error('โŒ Error handling enhanced search album click:', error); showToast(`Error opening album: ${error.message}`, 'error'); } } async function streamEnhancedSearchTrack(track) { console.log(`โ–ถ๏ธ Stream enhanced search track: ${track.name} by ${track.artist}`); hideDropdown(); showLoadingOverlay(`Searching for ${track.name}...`); try { // Send track metadata to backend for quick slskd search const response = await fetch('/api/enhanced-search/stream-track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track_name: track.name, artist_name: track.artist, album_name: track.album, duration_ms: track.duration_ms }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to search for track'); } const data = await response.json(); if (!data.success || !data.result) { throw new Error('No suitable track found'); } const slskdResult = data.result; // Check if audio format is supported (YouTube/Tidal use encoded filenames, skip check) const isStreamingSource = slskdResult.username === 'youtube' || slskdResult.username === 'tidal' || slskdResult.username === 'qobuz' || slskdResult.username === 'hifi'; if (!isStreamingSource && slskdResult.filename && !isAudioFormatSupported(slskdResult.filename)) { const format = getFileExtension(slskdResult.filename); hideLoadingOverlay(); showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error'); return; } console.log(`โœ… Found track to stream:`, slskdResult); console.log(`๐ŸŽต Track details - Username: ${slskdResult.username}, Filename: ${slskdResult.filename}`); hideLoadingOverlay(); // Use existing startStream function to play the track console.log(`๐Ÿ“ก Calling startStream() with result...`); await startStream(slskdResult); console.log(`โœ… startStream() completed`); } catch (error) { hideLoadingOverlay(); console.error('โŒ Error streaming enhanced search track:', error); showToast(`Failed to stream track: ${error.message}`, 'error'); } } async function handleEnhancedSearchTrackClick(track) { console.log(`๐ŸŽต Enhanced search track clicked: ${track.name} by ${track.artist}`); hideDropdown(); showLoadingOverlay('Loading track...'); try { // Create virtual playlist ID for enhanced search tracks const virtualPlaylistId = `enhanced_search_track_${track.id}`; // Check if modal already exists and show it if (activeDownloadProcesses[virtualPlaylistId]) { console.log(`๐Ÿ“ฑ Reopening existing modal for ${track.name}`); const process = activeDownloadProcesses[virtualPlaylistId]; if (process.modalElement) { if (process.status === 'complete') { showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); } process.modalElement.style.display = 'flex'; hideLoadingOverlay(); return; } } // Enrich track with album object (needed for wishlist functionality) const enrichedTrack = { id: track.id, name: track.name, artists: [track.artist], // Convert string to array for modal compatibility album: { name: track.album, id: null, album_type: 'single', images: track.image_url ? [{ url: track.image_url }] : [], release_date: track.release_date || null, total_tracks: 1 }, duration_ms: track.duration_ms, popularity: track.popularity || 0, preview_url: track.preview_url || null, external_urls: track.external_urls || null, image_url: track.image_url }; console.log(`๐Ÿ“ฆ Enriched track with album metadata`); // Format playlist name const playlistName = `${track.artist} - ${track.name}`; // Create minimal artist object for the modal const artistObject = { id: null, name: track.artist }; // Prepare album object for modal (single track) const albumObject = { name: track.album, id: null, album_type: 'single', images: track.image_url ? [{ url: track.image_url }] : [], release_date: track.release_date || null, total_tracks: 1, artists: [{ name: track.artist }] }; // Open download missing tracks modal with single track await openDownloadMissingModalForArtistAlbum( virtualPlaylistId, playlistName, [enrichedTrack], // Array with single track albumObject, artistObject, false ); // Register this download in search bubbles registerSearchDownload( { id: track.id, name: track.name, artist: track.artist, image_url: track.image_url, images: track.image_url ? [{ url: track.image_url }] : [] }, 'track', virtualPlaylistId, track.artist // artistName for grouping ); hideLoadingOverlay(); } catch (error) { hideLoadingOverlay(); console.error('โŒ Error handling enhanced search track click:', error); showToast(`Error opening track: ${error.message}`, 'error'); } } async function searchSlskdFor(type, item) { const mainResultsArea = document.getElementById('enhanced-main-results-area'); if (!mainResultsArea) return; // Show loading in main results area mainResultsArea.innerHTML = `

Searching for ${type === 'album' ? 'album' : 'track'}...

`; const query = `${item.artist} ${item.name}`; try { const response = await fetch('/api/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }) }); const data = await response.json(); if (data.error) { showToast(`Search error: ${data.error}`, 'error'); return; } // Filter results const filtered = data.results.filter(r => r.result_type === type); // Render slskd results in main area renderSlskdInMainArea(filtered, type, item); } catch (error) { console.error('Slskd search error:', error); showToast('Search failed', 'error'); mainResultsArea.innerHTML = '

Search failed. Please try again.

'; } } function renderSlskdInMainArea(results, type, originalItem) { const mainResultsArea = document.getElementById('enhanced-main-results-area'); if (!mainResultsArea) return; if (!results || results.length === 0) { mainResultsArea.innerHTML = '

No matches found for this ' + type + '.

'; return; } // Render results using same style as basic search mainResultsArea.innerHTML = results.map(result => { const title = type === 'album' ? `${result.album_title} (${result.tracks ? result.tracks.length : 0} tracks)` : result.title; return `

${escapeHtml(title)}

${result.bitrate ? `${result.bitrate} kbps` : ''} ${result.format ? `${result.format.toUpperCase()}` : ''} ${result.size ? `${(result.size / 1024 / 1024).toFixed(1)} MB` : ''} ${result.username ? `๐Ÿ‘ค ${escapeHtml(result.username)}` : ''}
`; }).join(''); // Attach download handlers mainResultsArea.querySelectorAll('.download-result-btn').forEach(btn => { btn.addEventListener('click', async function () { const result = JSON.parse(this.dataset.result); const type = this.dataset.type; this.disabled = true; this.textContent = 'Downloading...'; try { const downloadData = type === 'album' ? { result_type: 'album', tracks: result.tracks || [] } : { result_type: 'track', username: result.username, filename: result.filename, size: result.size }; const response = await fetch('/api/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(downloadData) }); const data = await response.json(); if (data.error) { showToast(`Download error: ${data.error}`, 'error'); this.disabled = false; this.innerHTML = '๐Ÿ’พ Download'; } else { showToast('Download started!', 'success'); this.innerHTML = 'โœ… Added'; } } catch (error) { console.error('Download error:', error); showToast('Download failed', 'error'); this.disabled = false; this.innerHTML = '๐Ÿ’พ Download'; } }); }); } function showDropdown() { const dropdown = document.getElementById('enhanced-dropdown'); if (dropdown) { dropdown.classList.remove('hidden'); updateToggleButtonState(); } // Hide the page header + search mode toggle to reclaim space const header = document.querySelector('#downloads-page .downloads-header'); const modeToggle = document.querySelector('.search-mode-toggle-container'); const slskdPlaceholder = document.querySelector('#enhanced-search-section .search-results-container'); if (header) header.classList.add('enh-results-active-hide'); if (modeToggle) modeToggle.classList.add('enh-results-active-hide'); if (slskdPlaceholder) slskdPlaceholder.classList.add('enh-results-active-hide'); } function hideDropdown() { const dropdown = document.getElementById('enhanced-dropdown'); if (dropdown) { dropdown.classList.add('hidden'); updateToggleButtonState(); } // Restore hidden elements const header = document.querySelector('#downloads-page .downloads-header'); const modeToggle = document.querySelector('.search-mode-toggle-container'); const slskdPlaceholder = document.querySelector('#enhanced-search-section .search-results-container'); if (header) header.classList.remove('enh-results-active-hide'); 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() { const query = document.getElementById('search-input').value.trim(); if (!query) { showToast('Please enter a search term', 'error'); return; } try { showLoadingOverlay('Searching...'); displaySearchResults([]); // Clear previous results const response = await fetch(API.search, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }) }); const data = await response.json(); if (data.error) { showToast(`Search error: ${data.error}`, 'error'); return; } searchResults = data.results || []; displaySearchResults(searchResults); if (searchResults.length === 0) { showToast('No results found', 'error'); } else { showToast(`Found ${searchResults.length} results`, 'success'); } } catch (error) { console.error('Error performing search:', error); showToast('Search failed', 'error'); } finally { hideLoadingOverlay(); } } function displaySearchResults(results) { const resultsContainer = document.getElementById('search-results'); if (!results.length) { resultsContainer.innerHTML = '
No search results
'; return; } resultsContainer.innerHTML = results.map((result, index) => { const isAlbum = result.type === 'album'; const sizeText = isAlbum ? `${result.track_count || 0} tracks, ${(result.size_mb || 0).toFixed(1)} MB` : `${(result.file_size / 1024 / 1024).toFixed(1)} MB, ${result.bitrate || 0}kbps`; return `
${escapeHtml(result.title)}
${escapeHtml(result.artist)}
${result.album ? `
${escapeHtml(result.album)}
` : ''}
${sizeText} by ${escapeHtml(result.username)} ${result.quality ? `${escapeHtml(result.quality)}` : ''}
`; }).join(''); } function selectResult(index) { const result = searchResults[index]; if (!result) return; console.log('Selected result:', result); // Could show detailed view or additional actions here } async function startDownload(index) { const result = searchResults[index]; if (!result) return; try { const response = await fetch('/api/downloads/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(result) }); const data = await response.json(); if (data.success) { showToast('Download started', 'success'); } else { showToast(`Download failed: ${data.error}`, 'error'); } } catch (error) { console.error('Error starting download:', error); showToast('Failed to start download', 'error'); } } // =============================== // PAGE DATA LOADING // =============================== async function loadInitialData() { try { // Load artist bubble state first await hydrateArtistBubblesFromSnapshot(); // Load search bubble state await hydrateSearchBubblesFromSnapshot(); // Load discover download state await hydrateDiscoverDownloadsFromSnapshot(); // Navigate to user's home page (or dashboard for admin) const homePage = getProfileHomePage(); const urlPage = _getPageFromPath(); const targetPage = (urlPage && urlPage !== 'dashboard' && isPageAllowed(urlPage)) ? urlPage : homePage; history.replaceState({ page: targetPage }, '', (targetPage === 'dashboard' ? '/' : '/' + targetPage) + window.location.search + window.location.hash); if (targetPage !== 'dashboard') { navigateToPage(targetPage, { skipPushState: true }); } else { await loadDashboardData(); loadDashboardSyncHistory(); } } catch (error) { console.error('Error loading initial data:', error); } } async function loadDashboardData() { try { const response = await fetch(API.activity); const data = await response.json(); const activityFeed = document.getElementById('activity-feed'); if (data.activities && data.activities.length) { activityFeed.innerHTML = data.activities.map(activity => `
${activity.time} ${escapeHtml(activity.text)}
`).join(''); } // Initialize wishlist count when dashboard loads await updateWishlistCount(); // Start periodic refresh of wishlist count (every 30 seconds, matching GUI behavior) stopWishlistCountPolling(); // Ensure no duplicates wishlistCountInterval = setInterval(updateWishlistCount, 30000); } catch (error) { console.error('Error loading dashboard data:', error); } } // ===========================================