// 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; // 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() { // Subsequent invocations: just re-display cached results so they don't // vanish on navigate-back. Skip the duplicate-listener setup. if (searchModeToggleInitialized) { if (_searchPageRestoreOnEnter) _searchPageRestoreOnEnter(); 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)'); // 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 enhancedInput = document.getElementById('enhanced-search-input'); 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; // ── 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'); } } // 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 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. 600ms (was 300) so a name being typed coalesces // into ONE search after the user pauses, instead of firing a new // external-API search per letter (#751). Enter still triggers an // immediate search via the keypress handler below. debounceTimer = setTimeout(() => { searchController.submitQuery(query); }, 600); }); enhancedInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { const query = e.target.value.trim(); if (query.length >= 2) { clearTimeout(debounceTimer); searchController.submitQuery(query); } } }); } if (enhancedCancelBtn) { enhancedCancelBtn.addEventListener('click', () => { enhancedInput.value = ''; enhancedCancelBtn.classList.add('hidden'); hideDropdown(); }); } // ── Link / ID lookup (#775) ────────────────────────────────────── // Paste a provider link or bare ID; the backend resolves it directly // on the owning source (no fuzzy search) and returns the single hit // plus that source. We adopt the resolved source as the active one so // the existing album re-fetch + download/import flow routes correctly, // then render through the same dropdown path as a normal search. const idInput = document.getElementById('enh-id-input'); const idBtn = document.getElementById('enh-id-btn'); async function submitIdLookup() { if (!idInput) return; const raw = idInput.value.trim(); if (!raw) return; // Show the controller's loading UI while resolving. if (loadingState) { loadingState.classList.remove('hidden'); const loadingText = document.getElementById('enhanced-loading-text'); if (loadingText) loadingText.textContent = 'Resolving link…'; } if (emptyState) emptyState.classList.add('hidden'); if (resultsContainer) resultsContainer.classList.add('hidden'); showDropdown(); let data; try { const resp = await fetch('/api/enhanced-search/by-id', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: raw }), }); data = await resp.json(); } catch (err) { console.error('Link/ID lookup failed:', err); if (loadingState) loadingState.classList.add('hidden'); if (typeof showToast === 'function') showToast('Link/ID lookup failed.', 'error'); return; } if (data && data.available && data.source) { // Adopt the resolving source as active, seed its cache with the // single hit, and render via the shared state-driven path. searchController.state.activeSource = data.source; searchController.state.query = raw; searchController.state.sources[data.source] = { db_artists: [], artists: data.artists || [], albums: data.albums || [], tracks: data.tracks || [], }; if (typeof searchController.renderSourceRow === 'function') { searchController.renderSourceRow(); } _renderFromState(searchController.state); } else { // Not a link, or nothing resolved — surface the backend's hint // via a toast (non-destructive) and show the empty state. if (loadingState) loadingState.classList.add('hidden'); if (resultsContainer) resultsContainer.classList.add('hidden'); if (emptyState) emptyState.classList.remove('hidden'); showDropdown(); const msg = (data && data.message) || 'No match for that link.'; if (typeof showToast === 'function') showToast(msg, 'warning'); } } if (idBtn) idBtn.addEventListener('click', submitIdLookup); if (idInput) { idInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') submitIdLookup(); }); } // 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'); // The media player (mini bar + expanded now-playing modal) floats // above the page. Interacting with it — e.g. clicking the mini // player to open the full modal, or anywhere inside that modal — // must NOT dismiss the search results (#732). const isClickInPlayer = e.target.closest('#media-player, #np-modal-overlay'); if (!isClickInside && !isClickOnSourceRow && !isClickInModal && !isClickInPlayer) { hideDropdown(); } } }); function renderDropdownResults(data) { const activeSource = searchController.state.activeSource; // Music Videos tab — don't render regular sections if (activeSource === 'youtube_videos') return; // Determine source badge from active tab (not just primary) const displaySource = activeSource || 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' }, href: buildArtistDetailPath(artist.id), }) ); // 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, href: buildArtistDetailPath(artist.id, searchController.state.activeSource || 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 = `
🎬

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 activeSource = searchController.state.activeSource; // Pass the artist name so the backend can look up images // for sources that don't store them (e.g. MusicBrainz — // it only has MBIDs, not artist art, so the resolver // falls back to iTunes/Deezer keyed by name). const artistName = card.dataset.artistName || ''; const params = new URLSearchParams(); if (activeSource && activeSource !== 'spotify') params.set('source', activeSource); if (artistName) params.set('name', artistName); const qs = params.toString(); const imgUrl = `/api/artist/${artistId}/image${qs ? '?' + qs : ''}`; 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')}`; } // renderCompactSection now lives in shared-helpers.js. async function handleEnhancedSearchAlbumClick(album) { console.log(`💿 Enhanced search album clicked: ${album.name} by ${album.artist}`); 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 || '' }); 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) { 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, // Carry the metadata source for source-specific import logic // (Deezer contributors upgrade for multi-artist tags). source: track.source || album.source || null, 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: activeSource || '', }; // 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}`); 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}`); 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, // Carry the metadata source so the import pipeline can run // source-specific logic (Deezer contributors upgrade for // multi-artist tags) on the downloaded file. source: track.source || null, // Prefer the real artist list (Spotify/Tidal collabs) over the // joined "A, B" display string, so downloads get proper // multi-artist tags instead of one combined artist. artists: (Array.isArray(track.artists) && track.artists.length) ? track.artists : [track.artist], 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, // Carry artist/title so the blocklist guard can match (Phase 2b). artist: result.artist || result.artist_name || '', title: result.title || result.name || '' }; let response = await fetch('/api/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(downloadData) }); let data = await response.json(); // Blocklist (Phase 2b): blocked artist → confirm override + retry. if (data.blocked) { if (typeof confirmBlockedDownload === 'function' && confirmBlockedDownload(data)) { downloadData.ignore_blocklist = true; response = await fetch('/api/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(downloadData) }); data = await response.json(); } else { showToast(`Skipped — ${data.blocked_name} is blocklisted`, 'info'); this.disabled = false; this.innerHTML = '💾 Download'; return; } } 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'); // 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'); 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'); // Restore hidden elements const header = document.querySelector('#search-page .downloads-header'); const modeToggle = document.querySelector('.search-source-picker-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'); } } 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 { const initialPath = window.location.pathname; const initialNavigationEpoch = navigationEpoch; // 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; if (window.location.pathname !== initialPath || navigationEpoch !== initialNavigationEpoch) { return; } if (targetPage === 'artist-detail') { const artistRoute = typeof parseArtistDetailPath === 'function' ? parseArtistDetailPath() : null; if (artistRoute && typeof navigateToArtistDetail === 'function') { navigateToArtistDetail(artistRoute.artistId, '', artistRoute.source); } return; } // Always apply the target page to the legacy shell chrome. const router = getWebRouter(); const route = router?.routeManifest?.find((entry) => entry.pageId === targetPage); if (route?.kind === 'react') { showReactHost(targetPage); setActivePageChrome(targetPage); // Keep nested react-tab URLs like /import/auto or /import/singles intact. return; } navigateToPage(targetPage, { forceReload: true }); } 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); } } // ===========================================