// WING IT β€” Download without metadata discovery // ================================================================================== function _toggleWingItDropdown(btn, urlHash) { // Remove any existing dropdown const existing = document.querySelector('.wing-it-dropdown.visible'); if (existing) { existing.classList.remove('visible'); setTimeout(() => existing.remove(), 150); return; } const wrap = btn.closest('.wing-it-wrap'); if (!wrap) return; const dropdown = document.createElement('div'); dropdown.className = 'wing-it-dropdown'; dropdown.innerHTML = ` `; dropdown.querySelectorAll('.wing-it-dropdown-item').forEach(item => { item.addEventListener('click', () => { dropdown.classList.remove('visible'); setTimeout(() => dropdown.remove(), 150); const action = item.dataset.action; if (action === 'download') { _wingItAction(urlHash, 'download'); } else { _wingItAction(urlHash, 'sync'); } }); }); // Flip dropdown direction if button is in the top portion of viewport const btnRect = btn.getBoundingClientRect(); if (btnRect.top < 200) dropdown.classList.add('flip-down'); wrap.appendChild(dropdown); requestAnimationFrame(() => dropdown.classList.add('visible')); // Close on outside click setTimeout(() => { const closeHandler = e => { if (!dropdown.contains(e.target) && e.target !== btn) { dropdown.classList.remove('visible'); setTimeout(() => dropdown.remove(), 150); document.removeEventListener('click', closeHandler); } }; document.addEventListener('click', closeHandler); }, 50); } function _wingItAction(urlHash, action) { if (urlHash) { // Called from a modal β€” use _wingItFromModal logic const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash] || {}; const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || []; const name = state.playlistName || state.name || state.playlist?.name || 'Playlist'; const isTidal = state.is_tidal_playlist; const isLB = state.is_listenbrainz_playlist; const isBeatport = state.is_beatport_playlist; const isDeezer = state.is_deezer_playlist; const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube'; if (!tracks.length) { showToast('No tracks available for Wing It', 'error'); return; } if (action === 'sync') { // Sync inline β€” keep modal open _wingItSyncFromModal(urlHash, tracks, name, isLB); } else { // Download β€” close modal, open download modal const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); if (modal) modal.remove(); const overlay = document.getElementById(`youtube-discovery-overlay-${urlHash}`); if (overlay) overlay.remove(); wingItDownload(tracks, name, source, null, true); } } } async function _wingItSyncFromModal(urlHash, tracks, name, isLB) { showToast('Starting Wing It sync...', 'info'); updateYouTubeModalButtons(urlHash, 'syncing'); try { const syncTracks = tracks.map((t, i) => { let artists = t.artists || []; if (!Array.isArray(artists)) artists = [{ name: String(artists) }]; return { id: t.id || t.source_track_id || `wing_it_${i}`, name: t.name || t.track_name || 'Unknown', artists: artists.map(a => typeof a === 'string' ? { name: a } : a), album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' }, duration_ms: t.duration_ms || 0, }; }); const res = await fetch('/api/wing-it/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tracks: syncTracks, playlist_name: name }) }); const data = await res.json(); if (data.error) { showToast(`Sync failed: ${data.error}`, 'error'); updateYouTubeModalButtons(urlHash, 'discovered'); return; } if (isLB) { const state = listenbrainzPlaylistStates[urlHash]; if (state) state.syncPlaylistId = data.sync_playlist_id; startListenBrainzSyncPolling(urlHash, data.sync_playlist_id); } else { startYouTubeSyncPolling(urlHash, data.sync_playlist_id); } } catch (e) { showToast('Sync failed: ' + e.message, 'error'); updateYouTubeModalButtons(urlHash, 'discovered'); } } async function wingItDownload(tracks, playlistName, source = 'playlist', cardIdentifier = null, skipConfirm = false) { if (!tracks || tracks.length === 0) { showToast('No tracks to download', 'error'); return; } if (!skipConfirm) { // Show choice: Download or Sync (for LB card button which doesn't have dropdown) const choice = await _showWingItChoiceDialog(tracks.length, source); if (!choice) return; if (choice === 'sync') { await _wingItSync(tracks, playlistName, source, cardIdentifier); return; } } // Normalize tracks to Spotify-compatible format const formattedTracks = tracks.map(t => { // Handle various artist formats let artists = []; if (t.artists) { if (Array.isArray(t.artists)) { artists = t.artists.map(a => typeof a === 'string' ? { name: a } : a); } else if (typeof t.artists === 'string') { artists = [{ name: t.artists }]; } } else if (t.artist_name) { artists = [{ name: t.artist_name }]; } else if (t.artist) { artists = [{ name: t.artist }]; } if (artists.length === 0) artists = [{ name: 'Unknown' }]; // Handle album let album = { name: '' }; if (t.album) { album = typeof t.album === 'string' ? { name: t.album } : t.album; } else if (t.album_name) { album = { name: t.album_name }; } return { id: t.id || t.source_track_id || `wing_it_${Date.now()}_${Math.random()}`, name: t.name || t.track_name || 'Unknown Track', artists: artists, duration_ms: t.duration_ms || 0, album: album, }; }); const virtualPlaylistId = `wing_it_${Date.now()}`; // Store wing_it flag BEFORE opening the modal youtubePlaylistStates[virtualPlaylistId] = { wing_it: true, tracks: formattedTracks, }; await openDownloadMissingModalForYouTube(virtualPlaylistId, `⚑ ${playlistName}`, formattedTracks); // Pre-check the Force Download toggle setTimeout(() => { const forceToggle = document.getElementById(`force-download-all-${virtualPlaylistId}`); if (forceToggle && !forceToggle.checked) forceToggle.checked = true; }, 800); } function _showWingItChoiceDialog(trackCount, source) { return new Promise(resolve => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;'; const close = val => { overlay.remove(); resolve(val); }; overlay.onclick = e => { if (e.target === overlay) close(null); }; overlay.innerHTML = `

⚑ Wing It

${trackCount} track${trackCount !== 1 ? 's' : ''} from ${source}. No metadata discovery β€” uses raw names. Failed tracks won't be added to wishlist.

`; overlay.querySelectorAll('.smart-delete-option').forEach(btn => { btn.addEventListener('click', () => close(btn.dataset.choice)); }); overlay.querySelector('.smart-delete-close').addEventListener('click', () => close(null)); const escH = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escH); close(null); } }; document.addEventListener('keydown', escH); document.body.appendChild(overlay); }); } async function _wingItSync(tracks, playlistName, source, cardIdentifier = null) { try { showToast('Syncing playlist to server...', 'info'); // Format tracks for the sync endpoint const syncTracks = tracks.map((t, i) => { let artists = t.artists || []; if (!Array.isArray(artists)) artists = [{ name: String(artists) }]; return { id: t.id || t.source_track_id || `wing_it_${i}`, name: t.name || t.track_name || 'Unknown', artists: artists.map(a => typeof a === 'string' ? { name: a } : a), album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' }, duration_ms: t.duration_ms || 0, artist_name: t.artist_name, }; }); const res = await fetch('/api/wing-it/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tracks: syncTracks, playlist_name: playlistName }) }); const data = await res.json(); if (data.error) { showToast(`Sync failed: ${data.error}`, 'error'); return; } // Show inline sync status on the card (same display as normal sync) const playlistId = cardIdentifier ? `discover-lb-playlist-${cardIdentifier}` : null; if (playlistId) { const statusDisplay = document.getElementById(`${playlistId}-sync-status`); if (statusDisplay) statusDisplay.style.display = 'block'; // Disable sync/wing-it buttons during sync const syncBtn = document.getElementById(`${playlistId}-sync-btn`); if (syncBtn) { syncBtn.disabled = true; syncBtn.style.opacity = '0.5'; } } // Poll for sync progress β€” update inline display if (data.sync_playlist_id) { _pollWingItSyncProgress(data.sync_playlist_id, playlistName, playlistId); } } catch (e) { showToast('Sync failed: ' + e.message, 'error'); } } function _pollWingItSyncProgress(syncPlaylistId, playlistName, cardPlaylistId) { const poll = setInterval(async () => { try { const res = await fetch(`/api/sync/status/${syncPlaylistId}`); const data = await res.json(); // Update inline status display if we have a card if (cardPlaylistId && data.progress) { const p = data.progress; const total = p.total_tracks || p.total || 0; const matched = p.matched_tracks || p.matched || 0; const failed = p.failed_tracks || p.failed || 0; const totalEl = document.getElementById(`${cardPlaylistId}-sync-total`); const matchedEl = document.getElementById(`${cardPlaylistId}-sync-matched`); const failedEl = document.getElementById(`${cardPlaylistId}-sync-failed`); const pctEl = document.getElementById(`${cardPlaylistId}-sync-percentage`); if (totalEl) totalEl.textContent = total; if (matchedEl) matchedEl.textContent = matched; if (failedEl) failedEl.textContent = failed; if (pctEl) pctEl.textContent = total > 0 ? Math.round((matched / total) * 100) : 0; } if (data.status === 'finished' || data.status === 'complete' || data.status === 'error') { clearInterval(poll); const matched = data.progress?.matched_tracks || data.progress?.matched || 0; const total = data.progress?.total_tracks || data.progress?.total || 0; if (data.status === 'error') { showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); } else { showToast(`⚑ Wing It sync complete β€” "${playlistName}" created on server (${matched}/${total} tracks matched)`, 'success'); } // Update card status display to show completion if (cardPlaylistId) { const statusLabel = document.querySelector(`#${cardPlaylistId}-sync-status .sync-status-label span:last-child`); if (statusLabel) statusLabel.textContent = `Sync complete β€” ${matched}/${total} matched`; const syncIcon = document.querySelector(`#${cardPlaylistId}-sync-status .sync-icon`); if (syncIcon) syncIcon.textContent = 'βœ“'; } } } catch (e) { /* ignore poll errors */ } }, 2000); // Safety timeout setTimeout(() => clearInterval(poll), 180000); } async function _wingItFromModal(urlHash) { // Extract tracks from the discovery modal state β€” tracks can be in various locations const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash] || {}; const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || []; const name = state.playlistName || state.name || state.playlist?.name || 'Playlist'; const isTidal = state.is_tidal_playlist; const isLB = state.is_listenbrainz_playlist; const isBeatport = state.is_beatport_playlist; const isDeezer = state.is_deezer_playlist; const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube'; if (!tracks.length) { showToast('No tracks available for Wing It', 'error'); return; } const choice = await _showWingItChoiceDialog(tracks.length, source); if (!choice) return; if (choice === 'sync') { // Sync inline β€” keep modal open, show progress in modal showToast('Starting Wing It sync...', 'info'); updateYouTubeModalButtons(urlHash, 'syncing'); try { // Format and send sync request const syncTracks = tracks.map((t, i) => { let artists = t.artists || []; if (!Array.isArray(artists)) artists = [{ name: String(artists) }]; return { id: t.id || t.source_track_id || `wing_it_${i}`, name: t.name || t.track_name || 'Unknown', artists: artists.map(a => typeof a === 'string' ? { name: a } : a), album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' }, duration_ms: t.duration_ms || 0, }; }); const res = await fetch('/api/wing-it/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tracks: syncTracks, playlist_name: name }) }); const data = await res.json(); if (data.error) { showToast(`Sync failed: ${data.error}`, 'error'); updateYouTubeModalButtons(urlHash, 'discovered'); return; } // Use the same sync polling as normal sync β€” works for any source if (isLB) { if (state) state.syncPlaylistId = data.sync_playlist_id; startListenBrainzSyncPolling(urlHash, data.sync_playlist_id); } else { startYouTubeSyncPolling(urlHash, data.sync_playlist_id); } } catch (e) { showToast('Sync failed: ' + e.message, 'error'); updateYouTubeModalButtons(urlHash, 'discovered'); } return; } // choice === 'download' β€” close modal and open download modal const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); if (modal) modal.remove(); const overlay = document.getElementById(`youtube-discovery-overlay-${urlHash}`); if (overlay) overlay.remove(); wingItDownload(tracks, name, source); } async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks, artist = null, album = null) { showLoadingOverlay('Loading YouTube playlist...'); // Check if a process is already active for this virtual playlist if (activeDownloadProcesses[virtualPlaylistId]) { console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`); 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(); // Hide overlay when reopening existing modal return; } console.log(`πŸ“₯ Opening Download Missing Tracks modal for YouTube playlist: ${virtualPlaylistId}`); // Create virtual playlist object for compatibility with existing modal logic const virtualPlaylist = { id: virtualPlaylistId, name: playlistName, track_count: spotifyTracks.length }; // Store the tracks in the cache for the modal to use playlistTrackCache[virtualPlaylistId] = spotifyTracks; currentPlaylistTracks = spotifyTracks; currentModalPlaylistId = virtualPlaylistId; let modal = document.createElement('div'); modal.id = `download-missing-modal-${virtualPlaylistId}`; modal.className = 'download-missing-modal'; modal.style.display = 'none'; document.body.appendChild(modal); // Register the new process in our global state tracker using the same structure as Spotify activeDownloadProcesses[virtualPlaylistId] = { status: 'idle', modalElement: modal, poller: null, batchId: null, playlist: virtualPlaylist, tracks: spotifyTracks, artist: artist, // βœ… Store artist context album: album // βœ… Store album context }; // Generate hero section with dynamic source detection const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' : virtualPlaylistId.startsWith('tidal_') ? 'Tidal' : virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' : virtualPlaylistId.startsWith('spotify_public_') ? 'Spotify' : virtualPlaylistId.startsWith('spotify:') ? 'Spotify' : virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' : virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : 'YouTube'; // Store metadata for discover download sidebar (will be added when Begin Analysis is clicked) if (source === 'SoulSync' || virtualPlaylistId.startsWith('discover_lb_') || virtualPlaylistId.startsWith('listenbrainz_') || virtualPlaylistId.startsWith('wing_it_')) { // Extract image URL from album context or first track's album cover let imageUrl = null; if (album && album.images && album.images.length > 0) { imageUrl = album.images[0].url; } else if (spotifyTracks && spotifyTracks.length > 0) { const firstTrack = spotifyTracks[0]; if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) { imageUrl = firstTrack.album.images[0].url; } } // Store in process for later use when Begin Analysis is clicked activeDownloadProcesses[virtualPlaylistId].discoverMetadata = { imageUrl: imageUrl, type: album ? 'album' : 'playlist' // βœ… Use 'album' if album context provided }; } // CRITICAL FIX: Use album context for discover_album playlists const isDiscoverAlbum = virtualPlaylistId.startsWith('discover_album_') || virtualPlaylistId.startsWith('discover_cache_') || virtualPlaylistId.startsWith('seasonal_album_') || virtualPlaylistId.startsWith('spotify_library_'); const heroContext = isDiscoverAlbum && album && artist ? { type: 'album', artist: { name: artist.name, image_url: artist.image_url || null }, album: { name: album.name, album_type: album.album_type || 'album', images: album.images || [] }, trackCount: spotifyTracks.length, playlistId: virtualPlaylistId } : { type: 'playlist', playlist: { name: playlistName, owner: source }, trackCount: spotifyTracks.length, playlistId: virtualPlaylistId }; // Use the exact same modal HTML structure as the existing Spotify modal modal.innerHTML = `
${generateDownloadModalHeroSection(heroContext)}
πŸ” Library Analysis Ready to start
⏬ Downloads Waiting for analysis

πŸ“‹ Track Analysis & Download Status

${spotifyTracks.length} / ${spotifyTracks.length} tracks selected
${spotifyTracks.map((track, index) => ` `).join('')}
# Track Artist Duration Library Match Download Status Actions
${index + 1} ${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} ${formatDuration(track.duration_ms)} πŸ” Pending - -
`; applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length); modal.style.display = 'flex'; hideLoadingOverlay(); } function _navigateToArtistFromModal(artistId, artistName, imageUrl, source, playlistId) { if (!artistName) return; // Close the download modal if (playlistId) closeDownloadMissingModal(playlistId); navigateToArtistDetail(artistId || artistName, artistName, source || null); } async function closeDownloadMissingModal(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process) { // If somehow called without a process, try to find and remove the element const modal = document.getElementById(`download-missing-modal-${playlistId}`); if (modal && modal.parentElement) { modal.parentElement.removeChild(modal); } return; } // If the process is running, just hide the modal. // If it's idle, complete, or cancelled, perform a full cleanup. if (process.status === 'running') { console.log(`Hiding active download modal for playlist ${playlistId}.`); process.modalElement.style.display = 'none'; // Track wishlist modal state changes if (playlistId === 'wishlist') { WishlistModalState.setUserClosed(); // User manually closed during processing console.log('πŸ“± [Modal State] User manually closed wishlist modal during processing'); } } else { console.log(`Closing and cleaning up download modal for playlist ${playlistId}.`); // Reset YouTube playlist phase to 'discovered' when modal is closed after completion if (playlistId.startsWith('youtube_')) { const urlHash = playlistId.replace('youtube_', ''); updateYouTubeCardPhase(urlHash, 'discovered'); // Also update mirrored playlist card if applicable if (urlHash.startsWith('mirrored_')) { updateMirroredCardPhase(urlHash, 'discovered'); } // Update backend state to prevent rehydration issues on page refresh (similar to Tidal fix) try { const response = await fetch(`/api/youtube/update_phase/${urlHash}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: 'discovered' }) }); if (response.ok) { console.log(`βœ… [Modal Close] Updated backend phase for YouTube playlist ${urlHash} to 'discovered'`); } else { console.warn(`⚠️ [Modal Close] Failed to update backend phase for YouTube playlist ${urlHash}`); } } catch (error) { console.error(`❌ [Modal Close] Error updating backend phase for YouTube playlist ${urlHash}:`, error); } } // Reset Beatport chart phase to 'discovered' when modal is closed if (playlistId.startsWith('beatport_')) { const urlHash = playlistId.replace('beatport_', ''); const state = youtubePlaylistStates[urlHash]; if (state && state.is_beatport_playlist) { console.log(`🧹 [Modal Close] Processing Beatport chart close: playlistId="${playlistId}", urlHash="${urlHash}"`); const chartHash = state.beatport_chart_hash || urlHash; // Reset to discovered phase (unless download actually started and completed) if (state.phase !== 'download_complete') { updateBeatportCardPhase(chartHash, 'discovered'); state.phase = 'discovered'; // Update Beatport chart state if (beatportChartStates[chartHash]) { beatportChartStates[chartHash].phase = 'discovered'; } // Update backend state try { await fetch(`/api/beatport/charts/update-phase/${chartHash}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: 'discovered' }) }); console.log(`βœ… [Modal Close] Updated backend phase for Beatport chart ${chartHash} to 'discovered'`); } catch (error) { console.error(`❌ [Modal Close] Error updating backend phase for Beatport chart ${chartHash}:`, error); } } } } // Enhanced Tidal playlist state management (based on GUI sync.py patterns) if (playlistId.startsWith('tidal_')) { const tidalPlaylistId = playlistId.replace('tidal_', ''); console.log(`🧹 [Modal Close] Processing Tidal playlist close: playlistId="${playlistId}", tidalPlaylistId="${tidalPlaylistId}"`); console.log(`🧹 [Modal Close] Current Tidal state:`, tidalPlaylistStates[tidalPlaylistId]); // Clear download-specific state but preserve discovery results (like GUI closeEvent) if (tidalPlaylistStates[tidalPlaylistId]) { const currentPhase = tidalPlaylistStates[tidalPlaylistId].phase; console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); // Preserve discovery data for future use (like GUI modal behavior) const preservedData = { playlist: tidalPlaylistStates[tidalPlaylistId].playlist, discovery_results: tidalPlaylistStates[tidalPlaylistId].discovery_results, spotify_matches: tidalPlaylistStates[tidalPlaylistId].spotify_matches, discovery_progress: tidalPlaylistStates[tidalPlaylistId].discovery_progress, convertedSpotifyPlaylistId: tidalPlaylistStates[tidalPlaylistId].convertedSpotifyPlaylistId }; // Clear download-specific state delete tidalPlaylistStates[tidalPlaylistId].download_process_id; delete tidalPlaylistStates[tidalPlaylistId].phase; // Restore preserved data and set to discovered phase Object.assign(tidalPlaylistStates[tidalPlaylistId], preservedData); tidalPlaylistStates[tidalPlaylistId].phase = 'discovered'; console.log(`🧹 [Modal Close] Reset Tidal playlist ${tidalPlaylistId} - cleared download state, preserved discovery data`); console.log(`🧹 [Modal Close] New phase after reset: ${tidalPlaylistStates[tidalPlaylistId].phase}`); } else { console.error(`❌ [Modal Close] No Tidal state found for playlistId: ${tidalPlaylistId}`); } updateTidalCardPhase(tidalPlaylistId, 'discovered'); console.log(`πŸ”„ [Modal Close] Reset Tidal playlist ${tidalPlaylistId} to discovered phase`); console.log(`πŸ“ [Modal Close] Expected button text for discovered phase: "${getActionButtonText('discovered')}"`); // Update backend state to prevent rehydration issues on page refresh try { const response = await fetch(`/api/tidal/update_phase/${tidalPlaylistId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: 'discovered' }) }); if (response.ok) { console.log(`βœ… [Modal Close] Updated backend phase for Tidal playlist ${tidalPlaylistId} to 'discovered'`); } else { console.warn(`⚠️ [Modal Close] Failed to update backend phase for Tidal playlist ${tidalPlaylistId}`); } } catch (error) { console.error(`❌ [Modal Close] Error updating backend phase for Tidal playlist ${tidalPlaylistId}:`, error); } } // Reset ListenBrainz playlist phase to 'discovered' when modal is closed if (playlistId.startsWith('listenbrainz_')) { const playlistMbid = playlistId.replace('listenbrainz_', ''); console.log(`🧹 [Modal Close] Processing ListenBrainz playlist close: playlistId="${playlistId}", mbid="${playlistMbid}"`); // Clear download-specific state but preserve discovery results if (listenbrainzPlaylistStates[playlistMbid]) { const currentPhase = listenbrainzPlaylistStates[playlistMbid].phase; console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); // Reset to discovered phase (unless download actually completed successfully) if (currentPhase !== 'download_complete') { // Clear download-specific fields delete listenbrainzPlaylistStates[playlistMbid].download_process_id; delete listenbrainzPlaylistStates[playlistMbid].convertedSpotifyPlaylistId; // Set back to discovered listenbrainzPlaylistStates[playlistMbid].phase = 'discovered'; // Update backend state try { await fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: 'discovered' }) }); console.log(`βœ… [Modal Close] Updated backend phase for ListenBrainz playlist ${playlistMbid} to 'discovered'`); } catch (error) { console.error(`❌ [Modal Close] Error updating backend phase for ListenBrainz playlist ${playlistMbid}:`, error); } console.log(`πŸ”„ [Modal Close] Reset ListenBrainz playlist ${playlistMbid} to discovered phase`); } } else { console.error(`❌ [Modal Close] No ListenBrainz state found for mbid: ${playlistMbid}`); } } // Reset Spotify Public playlist phase to 'discovered' when modal is closed if (playlistId.startsWith('spotify_public_')) { const spUrlHash = playlistId.replace('spotify_public_', ''); console.log(`🧹 [Modal Close] Processing Spotify Public playlist close: playlistId="${playlistId}", urlHash="${spUrlHash}"`); if (spotifyPublicPlaylistStates[spUrlHash]) { const currentPhase = spotifyPublicPlaylistStates[spUrlHash].phase; console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); const preservedData = { playlist: spotifyPublicPlaylistStates[spUrlHash].playlist, discovery_results: spotifyPublicPlaylistStates[spUrlHash].discovery_results, spotify_matches: spotifyPublicPlaylistStates[spUrlHash].spotify_matches, discovery_progress: spotifyPublicPlaylistStates[spUrlHash].discovery_progress, convertedSpotifyPlaylistId: spotifyPublicPlaylistStates[spUrlHash].convertedSpotifyPlaylistId }; delete spotifyPublicPlaylistStates[spUrlHash].download_process_id; delete spotifyPublicPlaylistStates[spUrlHash].phase; Object.assign(spotifyPublicPlaylistStates[spUrlHash], preservedData); spotifyPublicPlaylistStates[spUrlHash].phase = 'discovered'; console.log(`🧹 [Modal Close] Reset Spotify Public playlist ${spUrlHash} - cleared download state, preserved discovery data`); } updateSpotifyPublicCardPhase(spUrlHash, 'discovered'); try { await fetch(`/api/spotify-public/update_phase/${spUrlHash}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: 'discovered' }) }); console.log(`βœ… [Modal Close] Updated backend phase for Spotify Public playlist ${spUrlHash} to 'discovered'`); } catch (error) { console.error(`❌ [Modal Close] Error updating backend phase for Spotify Public playlist ${spUrlHash}:`, error); } } // Reset Deezer playlist phase to 'discovered' when modal is closed if (playlistId.startsWith('deezer_')) { const deezerPlaylistId = playlistId.replace('deezer_', ''); console.log(`🧹 [Modal Close] Processing Deezer playlist close: playlistId="${playlistId}", deezerPlaylistId="${deezerPlaylistId}"`); if (deezerPlaylistStates[deezerPlaylistId]) { const currentPhase = deezerPlaylistStates[deezerPlaylistId].phase; console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); const preservedData = { playlist: deezerPlaylistStates[deezerPlaylistId].playlist, discovery_results: deezerPlaylistStates[deezerPlaylistId].discovery_results, spotify_matches: deezerPlaylistStates[deezerPlaylistId].spotify_matches, discovery_progress: deezerPlaylistStates[deezerPlaylistId].discovery_progress, convertedSpotifyPlaylistId: deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId }; delete deezerPlaylistStates[deezerPlaylistId].download_process_id; delete deezerPlaylistStates[deezerPlaylistId].phase; Object.assign(deezerPlaylistStates[deezerPlaylistId], preservedData); deezerPlaylistStates[deezerPlaylistId].phase = 'discovered'; console.log(`🧹 [Modal Close] Reset Deezer playlist ${deezerPlaylistId} - cleared download state, preserved discovery data`); } updateDeezerCardPhase(deezerPlaylistId, 'discovered'); try { await fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: 'discovered' }) }); console.log(`βœ… [Modal Close] Updated backend phase for Deezer playlist ${deezerPlaylistId} to 'discovered'`); } catch (error) { console.error(`❌ [Modal Close] Error updating backend phase for Deezer playlist ${deezerPlaylistId}:`, error); } } // Clear wishlist modal state when modal is fully closed if (playlistId === 'wishlist') { WishlistModalState.clear(); // Clear all tracking since modal is fully closed console.log('πŸ“± [Modal State] Cleared wishlist modal state on full close'); } // Clean up artist download if this is an artist album playlist if (playlistId.startsWith('artist_album_')) { console.log(`🧹 [MODAL CLOSE] Cleaning up artist download for completed modal: ${playlistId}`); cleanupArtistDownload(playlistId); console.log(`βœ… [MODAL CLOSE] Artist download cleanup completed for: ${playlistId}`); } // Clean up search download if this is an enhanced search playlist if (playlistId.startsWith('enhanced_search_')) { console.log(`🧹 [MODAL CLOSE] Cleaning up search download for completed modal: ${playlistId}`); cleanupSearchDownload(playlistId); console.log(`βœ… [MODAL CLOSE] Search download cleanup completed for: ${playlistId}`); } // Clean up Beatport download if this is a beatport chart or release playlist if (playlistId.startsWith('beatport_chart_') || playlistId.startsWith('beatport_release_')) { console.log(`🧹 [MODAL CLOSE] Cleaning up Beatport download for completed modal: ${playlistId}`); cleanupBeatportDownload(playlistId); console.log(`βœ… [MODAL CLOSE] Beatport download cleanup completed for: ${playlistId}`); } // Remove from discover download sidebar if this is a discover page download if (discoverDownloads && discoverDownloads[playlistId]) { console.log(`🧹 [MODAL CLOSE] Removing discover download bubble: ${playlistId}`); removeDiscoverDownload(playlistId); console.log(`βœ… [MODAL CLOSE] Discover download bubble removed for: ${playlistId}`); } // Automatic cleanup and server operations after successful downloads await handlePostDownloadAutomation(playlistId, process); cleanupDownloadProcess(playlistId); } } /** * Extract unique album cover images from tracks */ function extractUniqueCoverImages(tracks, maxCovers = 20) { const uniqueCovers = new Set(); const covers = []; for (const track of tracks) { if (covers.length >= maxCovers) break; let coverUrl = null; let spotifyData = track.spotify_data; // Parse spotify_data if it's a string if (typeof spotifyData === 'string') { try { spotifyData = JSON.parse(spotifyData); } catch (e) { continue; } } // Extract cover URL coverUrl = spotifyData?.album?.images?.[0]?.url; // Add to list if unique and valid if (coverUrl && !uniqueCovers.has(coverUrl)) { uniqueCovers.add(coverUrl); covers.push(coverUrl); } } return covers; } /** * Shuffle array using Fisher-Yates algorithm */ function shuffleArray(array) { const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } /** * Generate mosaic grid background HTML with continuous scrolling rows */ function generateMosaicBackground(coverUrls) { // If less than 3 covers, use gradient fallback if (!coverUrls || coverUrls.length < 3) { return `
`; } // Cap covers per row to 15 for GPU performance (avoids hundreds of tiles) if (coverUrls.length > 15) { coverUrls = coverUrls.slice(0, 15); } const rows = 4; let mosaicHTML = '
'; // Calculate scroll speed based on number of images // More images = longer duration to maintain consistent visual speed // Minimum 40s to prevent scrolling too fast const scrollSpeed = Math.max(40, coverUrls.length * 2); for (let row = 0; row < rows; row++) { const isEvenRow = row % 2 === 0; const direction = isEvenRow ? 'left' : 'right'; // Randomize order for each row const shuffledCovers = shuffleArray(coverUrls); // Create row wrapper mosaicHTML += `
`; mosaicHTML += `
`; // Generate tiles - duplicate 2 times for smooth infinite scroll for (let duplicate = 0; duplicate < 2; duplicate++) { for (let i = 0; i < shuffledCovers.length; i++) { const coverUrl = shuffledCovers[i]; mosaicHTML += `
`; } } mosaicHTML += '
'; // Close row mosaicHTML += '
'; // Close wrapper } mosaicHTML += '
'; mosaicHTML += '
'; // Dark overlay for readability return mosaicHTML; } /** * Open wishlist overview modal showing category breakdown * This is the NEW entry point for wishlist from dashboard */ async function openWishlistOverviewModal() { try { showLoadingOverlay('Loading wishlist...'); // Fetch wishlist stats const statsResponse = await fetch('/api/wishlist/stats'); const statsData = await statsResponse.json(); if (!statsResponse.ok) { throw new Error(statsData.error || 'Failed to fetch wishlist stats'); } const { singles, albums, total } = statsData; if (total === 0) { hideLoadingOverlay(); showToast('Wishlist is empty. No tracks to process.', 'info'); return; } // Fetch album covers for mosaic backgrounds // Limit to 50 tracks per category (enough to get 20 unique covers while being efficient) const albumCoversPromise = fetch('/api/wishlist/tracks?category=albums&limit=50').then(r => r.json()); const singleCoversPromise = fetch('/api/wishlist/tracks?category=singles&limit=50').then(r => r.json()); const [albumTracksData, singleTracksData] = await Promise.all([albumCoversPromise, singleCoversPromise]); // Extract unique album covers (max 20 per category) const albumCovers = extractUniqueCoverImages(albumTracksData.tracks || [], 20); const singleCovers = extractUniqueCoverImages(singleTracksData.tracks || [], 20); // Create modal if it doesn't exist let modal = document.getElementById('wishlist-overview-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'wishlist-overview-modal'; modal.className = 'modal-overlay'; document.body.appendChild(modal); } // Fetch current cycle const cycleResponse = await fetch('/api/wishlist/cycle'); const cycleData = await cycleResponse.json(); const currentCycle = cycleData.cycle || 'albums'; // Format countdown timer const nextRunSeconds = statsData.next_run_in_seconds || 0; const countdownText = formatCountdownTime(nextRunSeconds); const nextCycleText = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles'; modal.innerHTML = ` `; modal.style.display = 'flex'; hideLoadingOverlay(); // Start countdown timer update interval startWishlistCountdownTimer(currentCycle, nextRunSeconds); } catch (error) { console.error('Error opening wishlist overview:', error); showToast(`Failed to load wishlist: ${error.message}`, 'error'); hideLoadingOverlay(); } } function startWishlistCountdownTimer(currentCycle, initialSeconds) { // Clear any existing interval if (wishlistCountdownInterval) { clearInterval(wishlistCountdownInterval); } let remainingSeconds = initialSeconds; const nextCycleText = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles'; wishlistCountdownInterval = setInterval(async () => { remainingSeconds--; // Check if auto-processing has started (every 2 seconds to avoid overwhelming backend) if (remainingSeconds % 2 === 0 || remainingSeconds <= 0) { // Use WebSocket data if available, otherwise fall back to HTTP if (socketConnected && _lastWishlistStats) { const data = _lastWishlistStats; if (data.is_auto_processing) { if (!_wishlistAutoProcessingNotified) { navigateToPage('active-downloads'); showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info'); _wishlistAutoProcessingNotified = true; } return; } if (remainingSeconds <= 0) { remainingSeconds = data.next_run_in_seconds || 0; const timerElement = document.getElementById('wishlist-next-auto-timer'); if (timerElement) { const countdownText = formatCountdownTime(remainingSeconds); timerElement.textContent = `Next Auto: ${nextCycleText}${countdownText ? ' in ' + countdownText : ''}`; } } } else { try { const response = await fetch('/api/wishlist/stats'); const data = await response.json(); // AUTO-CLOSE DETECTION: If auto-processing started, close modal and notify user (once) if (data.is_auto_processing) { if (!_wishlistAutoProcessingNotified) { console.log('πŸ€– [Wishlist] Auto-processing detected, closing overview modal'); closeWishlistOverviewModal(); showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info'); _wishlistAutoProcessingNotified = true; } return; // Exit interval } // Update remaining seconds if timer expired if (remainingSeconds <= 0) { remainingSeconds = data.next_run_in_seconds || 0; // Also update cycle in case it changed const newCycle = data.current_cycle || 'albums'; const newCycleText = newCycle === 'albums' ? 'Albums/EPs' : 'Singles'; const timerElement = document.getElementById('wishlist-next-auto-timer'); if (timerElement) { const countdownText = formatCountdownTime(remainingSeconds); timerElement.textContent = `Next Auto: ${newCycleText}${countdownText ? ' in ' + countdownText : ''}`; } } } catch (error) { console.debug('Error updating wishlist countdown:', error); } } // end else (HTTP fallback) } // Always update the display countdown const timerElement = document.getElementById('wishlist-next-auto-timer'); if (timerElement) { const countdownText = formatCountdownTime(remainingSeconds); timerElement.textContent = `Next Auto: ${nextCycleText}${countdownText ? ' in ' + countdownText : ''}`; } }, 1000); // Update every second } function closeWishlistOverviewModal() { console.log('πŸšͺ closeWishlistOverviewModal() called'); // Stop countdown timer if (wishlistCountdownInterval) { clearInterval(wishlistCountdownInterval); wishlistCountdownInterval = null; } const modal = document.getElementById('wishlist-overview-modal'); console.log('Modal element:', modal); if (modal) { modal.style.display = 'none'; console.log('Modal display set to none'); // Also remove from DOM to ensure clean state modal.remove(); console.log('Modal removed from DOM'); } else { console.warn('Modal element not found'); } window.selectedWishlistCategory = null; console.log('βœ… Modal closed'); } async function cleanupWishlistOverview() { console.log('🧹 cleanupWishlistOverview() called'); if (!await showConfirmDialog({ title: 'Cleanup Wishlist', message: 'This will remove all tracks from the wishlist that already exist in your library. Continue?' })) { return; } try { showLoadingOverlay('Cleaning up wishlist...'); const response = await fetch('/api/wishlist/cleanup', { method: 'POST' }); const result = await response.json(); if (result.success) { const removedCount = result.removed_count || 0; if (removedCount > 0) { showToast(`Cleanup complete! Removed ${removedCount} tracks that already exist in your library`, 'success'); } else { showToast('No tracks needed to be removed', 'info'); } // Check if wishlist is now empty const statsResponse = await fetch('/api/wishlist/stats'); const statsData = await statsResponse.json(); if (statsData.total === 0) { // Wishlist is empty, refresh the page to show empty state wishlistPageState.isInitialized = false; await initializeWishlistPage(); await updateWishlistCount(); } else { // Wishlist still has items, refresh the page to show updated counts wishlistPageState.isInitialized = false; await initializeWishlistPage(); } } else { showToast(`Failed to cleanup wishlist: ${result.error || 'Unknown error'}`, 'error'); } hideLoadingOverlay(); } catch (error) { console.error('Error cleaning up wishlist:', error); showToast(`Failed to cleanup wishlist: ${error.message}`, 'error'); hideLoadingOverlay(); } } async function clearEntireWishlist() { console.log('πŸ—‘οΈ clearEntireWishlist() called'); if (!await showConfirmDialog({ title: 'Clear Wishlist', message: 'WARNING: This will permanently delete ALL tracks from your wishlist.\n\nThis action cannot be undone.\n\nAre you sure you want to continue?', confirmText: 'Clear All', destructive: true })) { console.log('User cancelled confirmation'); return; } console.log('User confirmed, proceeding with clear...'); try { showLoadingOverlay('Clearing wishlist...'); console.log('Loading overlay shown'); const response = await fetch('/api/wishlist/clear', { method: 'POST' }); console.log('API response received:', response.status); const result = await response.json(); console.log('Clear wishlist response:', result); hideLoadingOverlay(); console.log('Loading overlay hidden'); if (result.success) { console.log('Clear was successful, showing toast...'); showToast('Wishlist cleared successfully', 'success'); console.log('Updating wishlist button count...'); await updateWishlistCount(); console.log('Refreshing wishlist page...'); wishlistPageState.isInitialized = false; await initializeWishlistPage(); } else { console.error('Clear failed:', result.error); showToast(`Failed to clear wishlist: ${result.error || 'Unknown error'}`, 'error'); } } catch (error) { console.error('Error clearing wishlist:', error); hideLoadingOverlay(); showToast(`Failed to clear wishlist: ${error.message}`, 'error'); } } async function selectWishlistCategory(category) { try { window.selectedWishlistCategory = category; const tracksList = document.getElementById('wishlist-tracks-list'); const categoryTracksSection = document.getElementById('wishlist-category-tracks'); const nebulaEl = document.getElementById('wishlist-nebula'); const downloadBtn = document.getElementById('wishlist-download-btn'); const categoryName = document.getElementById('wishlist-category-name'); if (nebulaEl) nebulaEl.style.display = 'none'; categoryTracksSection.style.display = 'block'; downloadBtn.style.display = 'inline-block'; categoryName.textContent = category === 'albums' ? 'Albums / EPs' : 'Singles'; tracksList.innerHTML = '
Loading tracks...
'; const _wlPageSize = window._wlNextLimit || 200; window._wlNextLimit = null; const response = await fetch(`/api/wishlist/tracks?category=${category}&limit=${_wlPageSize}`); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to fetch tracks'); const tracks = data.tracks || []; const totalAvailable = data.total || tracks.length; window._wlCategory = category; window._wlOffset = tracks.length; window._wlTotal = totalAvailable; if (tracks.length === 0) { tracksList.innerHTML = '
No tracks in this category
'; return; } // For Albums/EPs, group by album if (category === 'albums') { const albumGroups = {}; tracks.forEach(track => { let spotifyData = track.spotify_data; if (typeof spotifyData === 'string') { try { spotifyData = JSON.parse(spotifyData); } catch (e) { spotifyData = null; } } const rawAlbum = spotifyData?.album; const albumName = (typeof rawAlbum === 'string' ? rawAlbum : rawAlbum?.name) || 'Unknown Album'; // Handle both object format {name: '...'} and sanitized string format let artistName = 'Unknown Artist'; let artistId = null; if (spotifyData?.artists?.[0]?.name) { // Object format from Spotify API artistName = spotifyData.artists[0].name; artistId = spotifyData.artists[0].id; } else if (spotifyData?.artists?.[0] && typeof spotifyData.artists[0] === 'string') { // Sanitized string format artistName = spotifyData.artists[0]; } else if (Array.isArray(track.artists) && track.artists.length > 0) { // Fallback to track.artists if (typeof track.artists[0] === 'string') { artistName = track.artists[0]; } else if (track.artists[0]?.name) { artistName = track.artists[0].name; artistId = track.artists[0].id; } } const albumImage = spotifyData?.album?.images?.[0]?.url || ''; // Use album ID if available, otherwise create unique key from album + artist // Sanitize the ID to remove all special characters that could break DOM IDs or CSS selectors const albumId = spotifyData?.album?.id || `${albumName}_${artistName}` .replace(/[^a-zA-Z0-9\s_-]/g, '') // Remove all special chars except spaces, underscores, hyphens .replace(/\s+/g, '_') // Replace spaces with underscores .toLowerCase(); if (!albumGroups[albumId]) { albumGroups[albumId] = { albumName, artistName, artistId, albumImage, tracks: [] }; } const spotifyTrackId = track.spotify_track_id || track.id || ''; albumGroups[albumId].tracks.push({ name: track.name || 'Unknown Track', artistName, trackNumber: spotifyData?.track_number || 0, spotifyTrackId }); }); // Render album cards let albumsHTML = '
'; Object.entries(albumGroups).forEach(([albumId, albumData]) => { // Sort tracks by track number albumData.tracks.sort((a, b) => a.trackNumber - b.trackNumber); const tracksListHTML = albumData.tracks.map(track => `
${track.name}
`).join(''); // Handle missing album images with a placeholder const albumImageStyle = albumData.albumImage ? `background-image: url('${albumData.albumImage}')` : `background: linear-gradient(135deg, rgba(30, 30, 30, 0.9) 0%, rgba(50, 50, 50, 0.9) 100%); display: flex; align-items: center; justify-content: center; font-size: 40px;`; const albumImageContent = albumData.albumImage ? '' : 'πŸ’Ώ'; albumsHTML += `
${albumImageContent}
${albumData.albumName}
${albumData.artistName}
${albumData.tracks.length} track${albumData.tracks.length !== 1 ? 's' : ''}
β–Ό
`; }); albumsHTML += '
'; tracksList.innerHTML = albumsHTML; if (totalAvailable > tracks.length) { tracksList.insertAdjacentHTML('beforeend', ``); } _attachWishlistDelegation(tracksList); } else { // For Singles, show list with album images let tracksHTML = ''; tracks.forEach((track, index) => { const trackName = track.name || 'Unknown Track'; let spotifyData = track.spotify_data; if (typeof spotifyData === 'string') { try { spotifyData = JSON.parse(spotifyData); } catch (e) { spotifyData = null; } } let artistName = 'Unknown Artist'; if (spotifyData?.artists?.[0]?.name) { artistName = spotifyData.artists[0].name; } else if (Array.isArray(track.artists) && track.artists.length > 0) { if (typeof track.artists[0] === 'string') { artistName = track.artists[0]; } else if (track.artists[0]?.name) { artistName = track.artists[0].name; } } let albumName = 'Unknown Album'; if (spotifyData?.album?.name) { albumName = spotifyData.album.name; } else if (typeof track.album === 'string') { albumName = track.album; } else if (track.album?.name) { albumName = track.album.name; } const albumImage = spotifyData?.album?.images?.[0]?.url || ''; const spotifyTrackId = track.spotify_track_id || track.id || ''; tracksHTML += `
${trackName}
${artistName} β€’ ${albumName}
`; }); tracksList.innerHTML = tracksHTML; if (totalAvailable > tracks.length) { tracksList.insertAdjacentHTML('beforeend', ``); } _attachWishlistDelegation(tracksList); } } catch (error) { console.error('Error loading category tracks:', error); showToast(`Failed to load tracks: ${error.message}`, 'error'); } } async function loadMoreWishlistTracks() { const btn = document.querySelector('.wishlist-load-more-btn'); if (btn) { btn.textContent = 'Loading...'; btn.disabled = true; } // Increase page size and reload window._wlOffset = (window._wlOffset || 200) + 200; // Override the page size for this reload window._wlNextLimit = window._wlOffset; selectWishlistCategory(window._wlCategory); } function _attachWishlistDelegation(container) { // Single click handler for all wishlist album/track interactions container.addEventListener('click', (e) => { const target = e.target; // Skip checkbox wrapper clicks β€” handled by change listener if (target.closest('.wishlist-checkbox-wrapper')) return; // Album header click (expand/collapse) const header = target.closest('.wishlist-album-header'); if (header && !target.closest('.wishlist-delete-album-btn')) { toggleAlbumTracks(header.dataset.albumId); return; } // Album delete button const albumDelBtn = target.closest('.wishlist-delete-album-btn'); if (albumDelBtn) { e.stopPropagation(); removeAlbumFromWishlist(albumDelBtn.dataset.albumId, e); return; } // Track delete button const trackDelBtn = target.closest('.wishlist-delete-btn'); if (trackDelBtn && trackDelBtn.dataset.trackId) { e.stopPropagation(); removeTrackFromWishlist(trackDelBtn.dataset.trackId, e); return; } }); // Separate change handler for checkboxes (more reliable than click for inputs) container.addEventListener('change', (e) => { const target = e.target; if (target.classList.contains('wishlist-album-select-all-cb')) { toggleWishlistAlbumSelection(target.dataset.albumId, target.checked); } else if (target.classList.contains('wishlist-select-cb')) { updateWishlistBatchBar(); } }); } function backToCategories() { _nebulaBack(); } function toggleAlbumTracks(albumId) { const tracksElement = document.getElementById(`tracks-${albumId}`); const expandIcon = document.getElementById(`expand-icon-${albumId}`); if (tracksElement.style.display === 'none') { tracksElement.style.display = 'block'; expandIcon.textContent = 'β–²'; } else { tracksElement.style.display = 'none'; expandIcon.textContent = 'β–Ό'; } } /** * Get all checked wishlist track checkboxes */ function getCheckedWishlistTracks() { return Array.from(document.querySelectorAll('.wishlist-select-cb:checked')); } /** * Toggle select all / deselect all tracks in the current wishlist category */ function toggleWishlistSelectAll() { const allCheckboxes = document.querySelectorAll('.wishlist-select-cb'); const albumCheckboxes = document.querySelectorAll('.wishlist-album-select-all-cb'); const btn = document.getElementById('wishlist-select-all-btn'); const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked); const newState = !allChecked; allCheckboxes.forEach(cb => { cb.checked = newState; }); albumCheckboxes.forEach(cb => { cb.checked = newState; }); // Expand all albums when selecting all if (newState) { document.querySelectorAll('.wishlist-album-tracks').forEach(el => { el.style.display = 'block'; }); document.querySelectorAll('[id^="expand-icon-"]').forEach(icon => { icon.textContent = 'β–²'; }); } if (btn) btn.textContent = newState ? 'Deselect All' : 'Select All'; updateWishlistBatchBar(); } /** * Update the wishlist batch action bar based on checkbox selection */ function updateWishlistBatchBar() { const checked = getCheckedWishlistTracks(); const bar = document.getElementById('wishlist-batch-bar'); const countEl = document.getElementById('wishlist-batch-count'); if (!bar || !countEl) return; if (checked.length > 0) { bar.style.display = 'flex'; countEl.textContent = `${checked.length} selected`; } else { bar.style.display = 'none'; } // Sync the Select All button text const btn = document.getElementById('wishlist-select-all-btn'); if (btn) { const allCheckboxes = document.querySelectorAll('.wishlist-select-cb'); const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked); btn.textContent = allChecked ? 'Deselect All' : 'Select All'; } } /** * Toggle all track checkboxes within an album when album header checkbox is clicked */ function toggleWishlistAlbumSelection(albumId, checked) { const tracksContainer = document.getElementById(`tracks-${albumId}`); if (tracksContainer) { // Expand the album tracks if selecting if (checked) { tracksContainer.style.display = 'block'; const expandIcon = document.getElementById(`expand-icon-${albumId}`); if (expandIcon) expandIcon.textContent = 'β–²'; } tracksContainer.querySelectorAll('.wishlist-select-cb').forEach(cb => { cb.checked = checked; }); } updateWishlistBatchBar(); } /** * Batch remove selected tracks from wishlist */ async function batchRemoveFromWishlist() { const checked = getCheckedWishlistTracks(); if (checked.length === 0) return; const count = checked.length; const confirmed = await showConfirmationModal( 'Remove Tracks', `Remove ${count} track${count !== 1 ? 's' : ''} from your wishlist?`, 'πŸ—‘οΈ' ); if (!confirmed) return; const trackIds = checked.map(cb => cb.getAttribute('data-track-id')); try { const response = await fetch('/api/wishlist/remove-batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ spotify_track_ids: trackIds }) }); const data = await response.json(); if (data.success) { showToast(`Removed ${data.removed} track(s) from wishlist`, 'success'); // Reload the current category to refresh the list if (window.selectedWishlistCategory) { await selectWishlistCategory(window.selectedWishlistCategory); } // Update wishlist count in sidebar await updateWishlistCount(); } else { showToast(`Failed to remove tracks: ${data.error}`, 'error'); } } catch (error) { console.error('Error batch removing from wishlist:', error); showToast('Failed to remove tracks from wishlist', 'error'); } } function showConfirmationModal(title, message, icon = '⚠️') { return new Promise((resolve) => { // Create modal if it doesn't exist let modal = document.getElementById('confirmation-modal-overlay'); if (!modal) { modal = document.createElement('div'); modal.id = 'confirmation-modal-overlay'; modal.className = 'confirmation-modal-overlay'; document.body.appendChild(modal); } // Set modal content modal.innerHTML = `
${icon}
${title}
${message}
`; // Show modal with animation setTimeout(() => { modal.classList.add('show'); }, 10); // Escape key handler - defined outside so we can remove it const handleEscape = (e) => { if (e.key === 'Escape') { handleCancel(); } }; // Handle button clicks const handleCancel = () => { document.removeEventListener('keydown', handleEscape); modal.classList.remove('show'); setTimeout(() => { modal.remove(); }, 200); resolve(false); }; const handleConfirm = () => { document.removeEventListener('keydown', handleEscape); modal.classList.remove('show'); setTimeout(() => { modal.remove(); }, 200); resolve(true); }; document.getElementById('confirm-cancel').addEventListener('click', handleCancel); document.getElementById('confirm-yes').addEventListener('click', handleConfirm); // Close on overlay click modal.addEventListener('click', (e) => { if (e.target === modal) { handleCancel(); } }); // Add Escape key listener document.addEventListener('keydown', handleEscape); }); } async function removeTrackFromWishlist(spotifyTrackId, event) { // Stop event propagation to prevent triggering parent click handlers if (event) { event.stopPropagation(); } const confirmed = await showConfirmationModal( 'Remove Track', 'Are you sure you want to remove this track from your wishlist?', 'πŸ—‘οΈ' ); if (!confirmed) { return; } try { const response = await fetch('/api/wishlist/remove-track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ spotify_track_id: spotifyTrackId }) }); const data = await response.json(); if (data.success) { showToast('Track removed from wishlist', 'success'); // Reload the current category to refresh the list if (window.selectedWishlistCategory) { await selectWishlistCategory(window.selectedWishlistCategory); } // Update wishlist count in sidebar await updateWishlistCount(); } else { showToast(`Failed to remove track: ${data.error}`, 'error'); } } catch (error) { console.error('Error removing track from wishlist:', error); showToast('Failed to remove track from wishlist', 'error'); } } async function removeAlbumFromWishlist(albumId, event) { // Stop event propagation to prevent triggering parent click handlers if (event) { event.stopPropagation(); } const confirmed = await showConfirmationModal( 'Remove Album', 'Are you sure you want to remove all tracks from this album from your wishlist?', 'πŸ’Ώ' ); if (!confirmed) { return; } try { const response = await fetch('/api/wishlist/remove-album', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ album_id: albumId }) }); const data = await response.json(); if (data.success) { showToast(`Removed ${data.removed_count} track(s) from wishlist`, 'success'); // Reload the current category to refresh the list if (window.selectedWishlistCategory) { await selectWishlistCategory(window.selectedWishlistCategory); } // Update wishlist count in sidebar await updateWishlistCount(); } else { showToast(`Failed to remove album: ${data.error}`, 'error'); } } catch (error) { console.error('Error removing album from wishlist:', error); showToast('Failed to remove album from wishlist', 'error'); } } async function downloadSelectedCategory() { const category = window.selectedWishlistCategory; if (!category) { showToast('No category selected', 'error'); return; } // Collect checked track IDs const checkedBoxes = document.querySelectorAll('.wishlist-select-cb:checked'); const selectedTrackIds = new Set(Array.from(checkedBoxes).map(cb => cb.dataset.trackId).filter(Boolean)); await openDownloadMissingWishlistModal(category, selectedTrackIds.size > 0 ? selectedTrackIds : null); } async function openDownloadMissingWishlistModal(category = null, selectedTrackIds = null) { showLoadingOverlay('Loading wishlist...'); const playlistId = "wishlist"; // Use a consistent ID for wishlist // Check if a process is already active for the wishlist if (activeDownloadProcesses[playlistId]) { console.log(`Modal for wishlist already exists. Showing it.`); const process = activeDownloadProcesses[playlistId]; if (process.modalElement) { // Show helpful message if it's a completed process if (process.status === 'complete') { showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); } process.modalElement.style.display = 'flex'; WishlistModalState.setVisible(); // Track that modal is now visible } hideLoadingOverlay(); // Always hide overlay before returning return; // Don't create a new one } console.log(`πŸ“₯ Opening Download Missing Tracks modal for wishlist${category ? ' (' + category + ')' : ''}`); // Store category in global state for when process starts window.currentWishlistCategory = category; // Fetch actual wishlist tracks from the server let tracks; try { // Build API URL with optional category filter const apiUrl = category ? `/api/wishlist/tracks?category=${category}` : '/api/wishlist/tracks'; const response = await fetch('/api/wishlist/count'); const countData = await response.json(); if (countData.count === 0) { showToast('Wishlist is empty. No tracks to download.', 'info'); hideLoadingOverlay(); return; } // Fetch the actual wishlist tracks for display (filtered by category if specified) const tracksResponse = await fetch(apiUrl); if (!tracksResponse.ok) { throw new Error('Failed to fetch wishlist tracks'); } const tracksData = await tracksResponse.json(); tracks = tracksData.tracks || []; // Filter to only selected tracks if user made a selection if (selectedTrackIds && selectedTrackIds.size > 0) { tracks = tracks.filter(t => selectedTrackIds.has(t.id) || selectedTrackIds.has(t.spotify_track_id)); console.log(`πŸ“₯ Filtered to ${tracks.length} selected tracks (from ${tracksData.tracks?.length || 0} total)`); } } catch (error) { showToast(`Failed to fetch wishlist data: ${error.message}`, 'error'); hideLoadingOverlay(); return; } currentPlaylistTracks = tracks; currentModalPlaylistId = playlistId; let modal = document.createElement('div'); modal.id = `download-missing-modal-${playlistId}`; // Unique ID modal.className = 'download-missing-modal'; // Use class for styling modal.style.display = 'none'; // Start hidden document.body.appendChild(modal); // Register the new process in our global state tracker activeDownloadProcesses[playlistId] = { status: 'idle', // idle, running, complete, cancelled modalElement: modal, poller: null, batchId: null, playlist: { id: playlistId, name: "Wishlist" }, // Create a pseudo-playlist object tracks: tracks }; // Generate hero section for wishlist context const heroContext = { type: 'wishlist', trackCount: tracks.length, playlistId: playlistId }; modal.innerHTML = `
${generateDownloadModalHeroSection(heroContext)}
πŸ” Library Analysis Ready to start
⏬ Downloads Waiting for analysis

πŸ“‹ Track Analysis & Download Status

${tracks.map((track, index) => ` `).join('')}
# Track Artist Library Match Download Status Actions
${index + 1} ${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} πŸ” Pending - -
`; applyProgressiveTrackRendering(playlistId, tracks.length); modal.style.display = 'flex'; hideLoadingOverlay(); WishlistModalState.setVisible(); // Track that new wishlist modal is now visible } async function startWishlistMissingTracksProcess(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process) return; console.log(`πŸš€ Kicking off wishlist missing tracks process`); try { process.status = 'running'; // Note: Wishlist processes don't affect sync page refresh button state document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none'; document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block'; // Check if force download toggle is enabled const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`); const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false; // Hide the force download toggle during processing const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null; if (forceToggleContainer) { forceToggleContainer.style.display = 'none'; } // Extract track IDs from what the user is currently seeing in the modal // This prevents race conditions where wishlist changes between modal open and analysis start const trackIds = process.tracks ? process.tracks.map(t => t.spotify_track_id || t.id).filter(id => id) : null; console.log(`🎯 [Wishlist] Sending ${trackIds ? trackIds.length : 'all'} specific track IDs to prevent race condition`); const response = await fetch('/api/wishlist/download_missing', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force_download_all: forceDownloadAll, category: window.currentWishlistCategory, // Keep for backward compat track_ids: trackIds // NEW: Send exact tracks to process }) }); const data = await response.json(); if (!data.success) { // Special handling for auto-processing conflict if (response.status === 409) { console.log('πŸ€– [Wishlist] Auto-processing is running, redirecting to download manager'); showToast('Wishlist auto-processing is already running. Opening Download Manager...', 'info'); // Close wishlist modal and show download manager const wishlistModal = document.getElementById('download-modal-wishlist'); if (wishlistModal) { wishlistModal.remove(); } delete activeDownloadProcesses[playlistId]; // Open download manager to show active batch setTimeout(() => { const downloadManager = document.getElementById('download-manager-modal'); if (downloadManager) { downloadManager.style.display = 'flex'; } else { openDownloadManagerModal(); } }, 300); return; } // Special handling for rate limit if (response.status === 429) { throw new Error(`${data.error} Try closing some other download processes first.`); } throw new Error(data.error); } process.batchId = data.batch_id; console.log(`βœ… Wishlist process started successfully. Batch ID: ${data.batch_id}`); // Start polling for updates startModalDownloadPolling(playlistId); } catch (error) { console.error('Error starting wishlist missing tracks process:', error); showToast(`Error: ${error.message}`, 'error'); // Reset UI state on error process.status = 'idle'; // Note: Wishlist processes don't affect sync page refresh button state document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'inline-block'; document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none'; // Show the force download toggle again const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container'); if (forceToggleContainer) { forceToggleContainer.style.display = 'flex'; } } } async function startMissingTracksProcess(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process) return; console.log(`πŸš€ Kicking off unified missing tracks process for playlist: ${playlistId}`); try { process.status = 'running'; updatePlaylistCardUI(playlistId); updateRefreshButtonState(); // Set album to downloading status if this is an artist album if (playlistId.startsWith('artist_album_')) { // Format: artist_album_{artist.id}_{album.id} const parts = playlistId.split('_'); if (parts.length >= 4) { const albumId = parts.slice(3).join('_'); // In case album ID has underscores const totalTracks = process.tracks ? process.tracks.length : 0; setAlbumDownloadingStatus(albumId, 0, totalTracks); console.log(`πŸ”„ Set album ${albumId} to downloading status (0/${totalTracks} tracks)`); console.log(`πŸ” Virtual playlist ID: ${playlistId} β†’ Album ID: ${albumId}`); } } // Update YouTube playlist phase to 'downloading' if this is a YouTube playlist if (playlistId.startsWith('youtube_')) { const urlHash = playlistId.replace('youtube_', ''); updateYouTubeCardPhase(urlHash, 'downloading'); // Also update mirrored playlist card if applicable if (urlHash.startsWith('mirrored_')) { updateMirroredCardPhase(urlHash, 'downloading'); } } // Update Tidal playlist phase to 'downloading' if this is a Tidal playlist if (playlistId.startsWith('tidal_')) { const tidalPlaylistId = playlistId.replace('tidal_', ''); if (tidalPlaylistStates[tidalPlaylistId]) { tidalPlaylistStates[tidalPlaylistId].phase = 'downloading'; updateTidalCardPhase(tidalPlaylistId, 'downloading'); console.log(`πŸ”„ Updated Tidal playlist ${tidalPlaylistId} to downloading phase`); } } // Update Beatport chart phase to 'downloading' if this is a Beatport chart if (playlistId.startsWith('beatport_')) { const urlHash = playlistId.replace('beatport_', ''); const state = youtubePlaylistStates[urlHash]; if (state && state.is_beatport_playlist) { const chartHash = state.beatport_chart_hash || urlHash; // Update frontend states state.phase = 'downloading'; if (beatportChartStates[chartHash]) { beatportChartStates[chartHash].phase = 'downloading'; } // Update card UI updateBeatportCardPhase(chartHash, 'downloading'); // Update backend state try { fetch(`/api/beatport/charts/update-phase/${chartHash}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: 'downloading' }) }); } catch (error) { console.warn('⚠️ Error updating backend Beatport phase to downloading:', error); } console.log(`πŸ”„ Updated Beatport chart ${chartHash} to downloading phase`); } } // Update Spotify Public playlist phase to 'downloading' if this is a Spotify Public playlist if (playlistId.startsWith('spotify_public_')) { const urlHash = playlistId.replace('spotify_public_', ''); if (spotifyPublicPlaylistStates[urlHash]) { spotifyPublicPlaylistStates[urlHash].phase = 'downloading'; spotifyPublicPlaylistStates[urlHash].convertedSpotifyPlaylistId = playlistId; updateSpotifyPublicCardPhase(urlHash, 'downloading'); try { fetch(`/api/spotify-public/update_phase/${urlHash}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId }) }); } catch (error) { console.warn('Error updating backend Spotify Public phase to downloading:', error); } console.log(`πŸ”„ Updated Spotify Public playlist ${urlHash} to downloading phase`); } } // Update Deezer playlist phase to 'downloading' if this is a Deezer playlist if (playlistId.startsWith('deezer_')) { const deezerPlaylistId = playlistId.replace('deezer_', ''); if (deezerPlaylistStates[deezerPlaylistId]) { deezerPlaylistStates[deezerPlaylistId].phase = 'downloading'; deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId = playlistId; updateDeezerCardPhase(deezerPlaylistId, 'downloading'); try { fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId }) }); } catch (error) { console.warn('Error updating backend Deezer phase to downloading:', error); } console.log(`πŸ”„ Updated Deezer playlist ${deezerPlaylistId} to downloading phase`); } } // Update ListenBrainz playlist phase to 'downloading' if this is a ListenBrainz playlist if (playlistId.startsWith('listenbrainz_')) { const playlistMbid = playlistId.replace('listenbrainz_', ''); const state = listenbrainzPlaylistStates[playlistMbid]; if (state) { // Update frontend state state.phase = 'downloading'; // Update backend state try { fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: 'downloading' }) }); } catch (error) { console.warn('⚠️ Error updating backend ListenBrainz phase to downloading:', error); } console.log(`πŸ”„ Updated ListenBrainz playlist ${playlistMbid} to downloading phase`); } } document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none'; document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block'; // Hide wishlist button if it exists (only for non-wishlist modals) const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); if (wishlistBtn) { wishlistBtn.style.display = 'none'; } // Add to discover download sidebar if this is a discover page download if (process.discoverMetadata) { const playlistName = process.playlist.name; const imageUrl = process.discoverMetadata.imageUrl; const type = process.discoverMetadata.type; addDiscoverDownload(playlistId, playlistName, type, imageUrl); console.log(`πŸ“₯ [BEGIN ANALYSIS] Added discover download: ${playlistName}`); } // Check if force download toggle is enabled const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`); const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false; // Check if playlist folder mode toggle is enabled (only for sync page playlists) const playlistFolderModeCheckbox = document.getElementById(`playlist-folder-mode-${playlistId}`); const playlistFolderMode = playlistFolderModeCheckbox ? playlistFolderModeCheckbox.checked : false; // Hide the force download toggle during processing const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null; if (forceToggleContainer) { forceToggleContainer.style.display = 'none'; } // Filter tracks based on checkbox selection (if checkboxes exist in this modal) const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`); let selectedTracks = process.tracks; if (tbody) { const allCbs = tbody.querySelectorAll('.track-select-cb'); if (allCbs.length > 0) { // Checkboxes exist β€” filter to only checked tracks const checkedCbs = tbody.querySelectorAll('.track-select-cb:checked'); const selectedIndices = new Set([...checkedCbs].map(cb => parseInt(cb.dataset.trackIndex))); console.log(`πŸ”² [Track Selection] Total checkboxes: ${allCbs.length}, Checked: ${checkedCbs.length}`); console.log(`πŸ”² [Track Selection] Checked indices:`, [...selectedIndices]); console.log(`πŸ”² [Track Selection] process.tracks has ${process.tracks.length} items, first: "${process.tracks[0]?.name}", last: "${process.tracks[process.tracks.length - 1]?.name}"`); // Stamp each selected track with its original table index so the backend // maps status updates back to the correct modal row selectedTracks = process.tracks .map((track, i) => ({ ...track, _original_index: i })) .filter(track => selectedIndices.has(track._original_index)); console.log(`πŸ”² [Track Selection] Filtered to ${selectedTracks.length} tracks:`, selectedTracks.map(t => `[${t._original_index}] ${t.name}`)); // Disable checkboxes once analysis starts allCbs.forEach(cb => { cb.disabled = true; }); } } const selectAllCb = document.getElementById(`select-all-${playlistId}`); if (selectAllCb) selectAllCb.disabled = true; // Prepare request body - add album/artist context for artist album downloads const wingItState = youtubePlaylistStates[playlistId] || {}; const isWingIt = wingItState.wing_it || false; const requestBody = { tracks: selectedTracks, force_download_all: forceDownloadAll || isWingIt, wing_it: isWingIt, }; // If this is an artist album download, use album name and include full context // Match 'artist_album_', 'enhanced_search_album_', 'discover_album_', and 'seasonal_album_' prefixes // Note: 'enhanced_search_track_' is excluded β€” single track search results use singles context const _isAlbumContext = playlistId.startsWith('artist_album_') || playlistId.startsWith('enhanced_search_album_') || playlistId.startsWith('discover_album_') || playlistId.startsWith('seasonal_album_') || playlistId.startsWith('spotify_library_') || playlistId.startsWith('issue_download_') || playlistId.startsWith('library_redownload_') || playlistId.startsWith('beatport_release_'); const _isSearchTrack = playlistId.startsWith('enhanced_search_track_') || playlistId.startsWith('gsearch_track_'); if (_isAlbumContext || _isSearchTrack) { requestBody.playlist_name = process.album?.name || process.playlist.name; requestBody.is_album_download = _isAlbumContext; // false for single track search results requestBody.album_context = process.album; // Full Spotify album object requestBody.artist_context = process.artist; // Full Spotify artist object console.log(`🎡 [${_isAlbumContext ? 'Album' : 'Single Track'}] Sending context: ${process.album?.name} by ${process.artist?.name}`); } else { // For playlists/wishlists, use the virtual playlist name requestBody.playlist_name = process.playlist.name; // Add playlist folder mode flag for sync page playlists requestBody.playlist_folder_mode = playlistFolderMode; if (playlistFolderMode) { console.log(`πŸ“ [Playlist Folder] Enabled for playlist: ${process.playlist.name}`); } } const response = await fetch(`/api/playlists/${playlistId}/start-missing-process`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); const data = await response.json(); if (!data.success) { // Special handling for rate limit if (response.status === 429) { throw new Error(`${data.error} Try closing some other download processes first.`); } throw new Error(data.error); } process.batchId = data.batch_id; // Update Beatport backend state with download_process_id now that we have the batchId if (playlistId.startsWith('beatport_')) { const urlHash = playlistId.replace('beatport_', ''); const state = youtubePlaylistStates[urlHash]; if (state && state.is_beatport_playlist) { const chartHash = state.beatport_chart_hash || urlHash; try { fetch(`/api/beatport/charts/update-phase/${chartHash}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: 'downloading', download_process_id: data.batch_id }) }); console.log(`πŸ”„ Updated Beatport backend with download_process_id: ${data.batch_id}`); } catch (error) { console.warn('⚠️ Error updating Beatport backend with download_process_id:', error); } } } // Update ListenBrainz backend state with download_process_id and convertedSpotifyPlaylistId if (playlistId.startsWith('listenbrainz_')) { const playlistMbid = playlistId.replace('listenbrainz_', ''); const state = listenbrainzPlaylistStates[playlistMbid]; if (state) { // Store in frontend state state.download_process_id = data.batch_id; state.convertedSpotifyPlaylistId = playlistId; // Update backend state try { fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: 'downloading', download_process_id: data.batch_id, converted_spotify_playlist_id: playlistId }) }); console.log(`πŸ”„ Updated ListenBrainz backend with download_process_id: ${data.batch_id}`); } catch (error) { console.warn('⚠️ Error updating ListenBrainz backend with download_process_id:', error); } } } startModalDownloadPolling(playlistId); } catch (error) { showToast(`Failed to start process: ${error.message}`, 'error'); process.status = 'cancelled'; // Reset button states on error const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); if (beginBtn) beginBtn.style.display = 'inline-block'; if (cancelBtn) cancelBtn.style.display = 'none'; if (wishlistBtn) wishlistBtn.style.display = 'inline-block'; // Show the force download toggle again const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container'); if (forceToggleContainer) { forceToggleContainer.style.display = 'flex'; } cleanupDownloadProcess(playlistId); } } function updateTrackAnalysisResults(playlistId, results) { // Update match results for all rows (tracks are now pre-populated) for (const result of results) { const matchElement = document.getElementById(`match-${playlistId}-${result.track_index}`); if (matchElement) { matchElement.textContent = result.found ? 'βœ… Found' : '❌ Missing'; matchElement.className = `track-match-status ${result.found ? 'match-found' : 'match-missing'}`; } } } // ============================================================================ // GLOBAL BATCHED POLLING SYSTEM - Optimized for multiple concurrent modals // ============================================================================ let globalDownloadStatusPoller = null; let globalPollingFailureCount = 0; // Track consecutive failures for exponential backoff let globalPollingBaseInterval = 2000; // Base polling interval in ms - MATCHES sync.py exactly function startGlobalDownloadPolling() { // Always run HTTP polling as a fallback β€” WebSocket connections can silently // stop delivering messages (room subscription lost, server emit error, proxy // timeout) without triggering a disconnect event. The 2-second poll is cheap // (single batched request) and ensures modals never go stale. if (globalDownloadStatusPoller) { console.debug('πŸ”„ [Global Polling] Already running, skipping start'); return; // Prevent duplicate pollers } console.log('πŸ”„ [Global Polling] Starting batched download status polling'); globalDownloadStatusPoller = setInterval(async () => { if (document.hidden) return; // Skip polling when tab is not visible // Get all active processes that need polling const activeBatchIds = []; const batchToPlaylistMap = {}; let hasOpenWishlistModal = false; Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => { // Include running AND recently-completed batches β€” ensures late task // status updates still reach the modal so rows don't freeze mid-download if (process.batchId && (process.status === 'running' || process.status === 'complete')) { activeBatchIds.push(process.batchId); batchToPlaylistMap[process.batchId] = playlistId; } // Check if there's an open wishlist modal (visible and idle/waiting) if (playlistId === 'wishlist' && process.modalElement && process.modalElement.style.display === 'flex' && (!process.batchId || process.status !== 'running')) { hasOpenWishlistModal = true; } }); // Special handling for open wishlist modal - check for new auto-processing if (hasOpenWishlistModal) { try { const response = await fetch('/api/active-processes'); if (response.ok) { const data = await response.json(); const processes = data.active_processes || []; const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist'); if (serverWishlistProcess) { console.log('πŸ”„ [Global Polling] Detected auto-processing for open wishlist modal - rehydrating'); await rehydrateModal(serverWishlistProcess, false); // false = not user-requested } } } catch (error) { console.debug('⚠️ [Global Polling] Failed to check for wishlist auto-processing:', error); } } if (activeBatchIds.length === 0) { console.debug('πŸ“Š [Global Polling] No active processes, continuing polling'); return; } try { // Single batched API call for all active processes const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&'); const response = await fetch(`/api/download_status/batch?${queryParams}`); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); console.debug(`πŸ“Š [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`); // Process each batch's status data using existing logic Object.entries(data.batches).forEach(([batchId, statusData]) => { const playlistId = batchToPlaylistMap[batchId]; if (!playlistId || statusData.error) { if (statusData.error) { console.error(`❌ [Global Polling] Error for batch ${batchId}:`, statusData.error); } return; } // Use existing modal update logic - zero changes needed! processModalStatusUpdate(playlistId, statusData); }); // ENHANCED: Reset failure count on successful polling globalPollingFailureCount = 0; } catch (error) { console.error('❌ [Global Polling] Batched request failed:', error); // ENHANCED: Implement exponential backoff on failure globalPollingFailureCount++; if (globalPollingFailureCount >= 5) { console.error(`🚨 [Global Polling] ${globalPollingFailureCount} consecutive failures, continuing with backoff`); // Don't stop polling - just continue with exponential backoff } // Exponential backoff: increase interval temporarily const backoffInterval = Math.min(globalPollingBaseInterval * Math.pow(2, globalPollingFailureCount - 1), 8000); console.warn(`⚠️ [Global Polling] Failure ${globalPollingFailureCount}/5, backing off to ${backoffInterval}ms`); // Temporarily adjust the polling interval if (globalDownloadStatusPoller) { clearInterval(globalDownloadStatusPoller); globalDownloadStatusPoller = null; // Restart with backoff interval setTimeout(() => { if (Object.keys(activeDownloadProcesses).length > 0) { startGlobalDownloadPollingWithInterval(backoffInterval); } }, backoffInterval); } } }, globalPollingBaseInterval); // Use base interval initially } function startGlobalDownloadPollingWithInterval(interval) { if (globalDownloadStatusPoller) { console.debug('πŸ”„ [Global Polling] Already running, skipping start with interval'); return; } console.log(`πŸ”„ [Global Polling] Starting with interval ${interval}ms`); // Use the exact same logic as startGlobalDownloadPolling but with custom interval globalDownloadStatusPoller = setInterval(async () => { const activeBatchIds = []; const batchToPlaylistMap = {}; let hasOpenWishlistModal = false; Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => { if (process.batchId && (process.status === 'running' || process.status === 'complete')) { activeBatchIds.push(process.batchId); batchToPlaylistMap[process.batchId] = playlistId; } // Check if there's an open wishlist modal (visible and idle/waiting) if (playlistId === 'wishlist' && process.modalElement && process.modalElement.style.display === 'flex' && (!process.batchId || process.status !== 'running')) { hasOpenWishlistModal = true; } }); // Special handling for open wishlist modal - check for new auto-processing if (hasOpenWishlistModal) { try { const response = await fetch('/api/active-processes'); if (response.ok) { const data = await response.json(); const processes = data.active_processes || []; const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist'); if (serverWishlistProcess) { console.log('πŸ”„ [Global Polling] Detected auto-processing for open wishlist modal - rehydrating'); await rehydrateModal(serverWishlistProcess, false); // false = not user-requested } } } catch (error) { console.debug('⚠️ [Global Polling] Failed to check for wishlist auto-processing:', error); } } if (activeBatchIds.length === 0) { console.debug('πŸ“Š [Global Polling] No active processes, continuing polling'); return; } try { const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&'); const response = await fetch(`/api/download_status/batch?${queryParams}`); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); console.debug(`πŸ“Š [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`); Object.entries(data.batches).forEach(([batchId, statusData]) => { const playlistId = batchToPlaylistMap[batchId]; if (!playlistId || statusData.error) { if (statusData.error) { console.error(`❌ [Global Polling] Error for batch ${batchId}:`, statusData.error); } return; } processModalStatusUpdate(playlistId, statusData); }); // Success - reset to normal interval if we were backing off globalPollingFailureCount = 0; if (interval !== globalPollingBaseInterval) { console.log('βœ… [Global Polling] Recovered from backoff, returning to normal interval'); clearInterval(globalDownloadStatusPoller); globalDownloadStatusPoller = null; startGlobalDownloadPolling(); // Restart with normal interval } } catch (error) { console.error('❌ [Global Polling] Request failed:', error); globalPollingFailureCount++; if (globalPollingFailureCount >= 5) { console.error(`🚨 [Global Polling] Too many failures, continuing with backoff`); // Don't stop polling - just continue with exponential backoff } } }, interval); } function stopGlobalDownloadPolling() { if (globalDownloadStatusPoller) { console.log('πŸ›‘ [Global Polling] Stopping batched download status polling'); clearInterval(globalDownloadStatusPoller); globalDownloadStatusPoller = null; } } // --- Error tooltip for failed/cancelled downloads (fixed-position, escapes overflow) --- function _getErrorTooltipPopup() { let el = document.getElementById('error-tooltip-popup'); if (!el) { el = document.createElement('div'); el.id = 'error-tooltip-popup'; document.body.appendChild(el); } return el; } function _hideErrorTooltip() { const popup = document.getElementById('error-tooltip-popup'); if (popup) popup.classList.remove('visible'); } function _ensureErrorTooltipListeners(statusEl) { if (statusEl._errorTooltipBound) return; statusEl._errorTooltipBound = true; statusEl.addEventListener('mouseenter', function () { const msg = this.dataset.errorMsg; if (!msg || !this.offsetParent) return; // skip if element is hidden const popup = _getErrorTooltipPopup(); popup.textContent = msg; popup.classList.add('visible'); const rect = this.getBoundingClientRect(); const popupRect = popup.getBoundingClientRect(); let left = rect.left + rect.width / 2 - popupRect.width / 2; let top = rect.top - popupRect.height - 10; // Keep within viewport if (left < 8) left = 8; if (left + popupRect.width > window.innerWidth - 8) left = window.innerWidth - 8 - popupRect.width; if (top < 8) { top = rect.bottom + 10; } // flip below if no room above popup.style.left = left + 'px'; popup.style.top = top + 'px'; }); statusEl.addEventListener('mouseleave', _hideErrorTooltip); // Dismiss tooltip when the scrollable modal body scrolls const scrollParent = statusEl.closest('.download-missing-modal-body'); if (scrollParent && !scrollParent._errorTooltipScrollBound) { scrollParent._errorTooltipScrollBound = true; scrollParent.addEventListener('scroll', _hideErrorTooltip, { passive: true }); } } function _ensureCandidatesClickListener(statusEl) { if (statusEl._candidatesClickBound) return; statusEl._candidatesClickBound = true; statusEl.addEventListener('click', function (e) { e.stopPropagation(); _hideErrorTooltip(); const taskId = this.dataset.taskId; if (taskId) showCandidatesModal(taskId); }); } async function showCandidatesModal(taskId) { try { const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/candidates`); if (!resp.ok) { console.error('Failed to fetch candidates:', resp.status); return; } const data = await resp.json(); _renderCandidatesModal(data); } catch (err) { console.error('Error fetching candidates:', err); } } function _renderCandidatesModal(data) { let overlay = document.getElementById('candidates-modal-overlay'); if (overlay) overlay.remove(); const trackName = data.track_info?.name || 'Unknown Track'; const trackArtist = data.track_info?.artist || 'Unknown Artist'; const candidates = data.candidates || []; const errorMsg = data.error_message || ''; const fmtSize = (bytes) => { if (!bytes) return '-'; const units = ['B', 'KB', 'MB', 'GB']; let s = bytes, u = 0; while (s >= 1024 && u < units.length - 1) { s /= 1024; u++; } return `${s.toFixed(1)} ${units[u]}`; }; const fmtDur = (ms) => { if (!ms) return '-'; const sec = Math.floor(ms / 1000); return `${Math.floor(sec / 60)}:${(sec % 60).toString().padStart(2, '0')}`; }; let tableRows = ''; if (candidates.length === 0) { tableRows = ` No candidates were found during search.`; } else { candidates.forEach((c, i) => { const shortFile = c.filename ? c.filename.split(/[/\\]/).pop() : '-'; const qBadge = c.quality ? `${c.quality.toUpperCase()}` : ''; tableRows += ` ${i + 1} ${escapeHtml(shortFile)} ${qBadge}${c.bitrate ? ` ${c.bitrate}kbps` : ''} ${fmtSize(c.size)} ${fmtDur(c.duration)} ${escapeHtml(c.username || '-')} `; }); } overlay = document.createElement('div'); overlay.id = 'candidates-modal-overlay'; overlay.className = 'candidates-modal-overlay'; overlay.onclick = (e) => { if (e.target === overlay) closeCandidatesModal(); }; overlay.innerHTML = `

Search Results

${escapeHtml(trackName)} β€” ${escapeHtml(trackArtist)}
${errorMsg ? `
${escapeHtml(errorMsg)}
` : ''}
${candidates.length} candidate${candidates.length !== 1 ? 's' : ''} found${candidates.length > 0 ? ' but none passed filters' : ''}
${tableRows}
#FileQualitySizeDurationUser
`; document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add('visible')); // Bind download buttons overlay.querySelectorAll('.candidates-download-btn').forEach(btn => { btn.addEventListener('click', () => { const idx = parseInt(btn.dataset.index); const c = candidates[idx]; if (c) downloadCandidate(data.task_id, c, trackName); }); }); } async function downloadCandidate(taskId, candidate, trackName) { if (!await showConfirmDialog({ title: 'Download File', message: `Download this file as "${trackName}"?\n\n${candidate.filename?.split(/[/\\]/).pop() || 'Unknown file'}\nfrom ${candidate.username || 'Unknown user'}`, confirmText: 'Download' })) return; try { const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/download-candidate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(candidate) }); const result = await resp.json(); if (result.success) { closeCandidatesModal(); showToast(result.message || 'Download initiated', 'success'); } else { showToast(`Failed: ${result.error}`, 'error'); } } catch (err) { console.error('Error initiating manual download:', err); showToast('Failed to initiate download', 'error'); } } function closeCandidatesModal() { const overlay = document.getElementById('candidates-modal-overlay'); if (overlay) { overlay.classList.remove('visible'); setTimeout(() => overlay.remove(), 300); } } function processModalStatusUpdate(playlistId, data) { // This function contains ALL the existing polling logic from startModalDownloadPolling // Extracted so it can be called from both individual and batched polling const process = activeDownloadProcesses[playlistId]; if (!process) { console.debug(`⚠️ [Status Update] No process found for ${playlistId}, skipping update`); return; } if (data.error) { console.error(`❌ [Status Update] Error for ${playlistId}: ${data.error}`); return; } // ENHANCED: Validate response data to prevent UI corruption if (!data || typeof data !== 'object') { console.error(`❌ [Status Update] Invalid data for ${playlistId}:`, data); return; } // ENHANCED: Validate task data structure if (data.tasks && !Array.isArray(data.tasks)) { console.error(`❌ [Status Update] Invalid tasks data for ${playlistId} - not an array:`, data.tasks); return; } console.debug(`πŸ“Š [Status Update] Processing update for ${playlistId}: phase=${data.phase}, tasks=${(data.tasks || []).length}`); // Note: Wishlist modal visibility is now managed by handleWishlistButtonClick() only // Auto-show logic has been simplified to prevent conflicts if (data.phase === 'analysis') { const progress = data.analysis_progress; const percent = progress.total > 0 ? (progress.processed / progress.total) * 100 : 0; document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = `${percent}%`; document.getElementById(`analysis-progress-text-${playlistId}`).textContent = `${progress.processed}/${progress.total} tracks analyzed`; if (data.analysis_results) { updateTrackAnalysisResults(playlistId, data.analysis_results); // Update stats when we first get analysis results const foundCount = data.analysis_results.filter(r => r.found).length; const missingCount = data.analysis_results.filter(r => !r.found).length; document.getElementById(`stat-found-${playlistId}`).textContent = foundCount; document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount; // Auto-save M3U file for playlists after analysis autoSavePlaylistM3U(playlistId); } } else if (data.phase === 'downloading' || data.phase === 'complete' || data.phase === 'error') { console.debug(`πŸ“Š [Status Update] Processing ${data.phase} phase for playlistId: ${playlistId}, tasks: ${(data.tasks || []).length}`); if (document.getElementById(`analysis-progress-fill-${playlistId}`).style.width !== '100%') { document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = '100%'; document.getElementById(`analysis-progress-text-${playlistId}`).textContent = 'Analysis complete!'; if (data.analysis_results) { updateTrackAnalysisResults(playlistId, data.analysis_results); const foundCount = data.analysis_results.filter(r => r.found).length; const missingCount = data.analysis_results.filter(r => !r.found).length; document.getElementById(`stat-found-${playlistId}`).textContent = foundCount; document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount; } } const missingTracks = (data.analysis_results || []).filter(r => !r.found); const missingCount = missingTracks.length; let completedCount = 0; let failedOrCancelledCount = 0; let notFoundCount = 0; // Verify modal exists before processing tasks const modal = document.getElementById(`download-missing-modal-${playlistId}`); if (!modal) { console.error(`❌ [Status Update] Modal not found: download-missing-modal-${playlistId}`); return; } // Update download progress text immediately when entering downloading phase // This handles the case where tasks array is empty or still being populated const downloadProgressText = document.getElementById(`download-progress-text-${playlistId}`); if (data.phase === 'downloading' && missingCount > 0 && (!data.tasks || data.tasks.length === 0)) { // No tasks yet, but we're in downloading phase with missing tracks if (downloadProgressText) { downloadProgressText.textContent = 'Preparing downloads...'; console.log(`πŸ“₯ [Download Phase] Preparing ${missingCount} downloads...`); } } (data.tasks || []).forEach(task => { const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${task.track_index}"]`); if (!row) { console.debug(`❌ [Status Update] Row not found for playlistId: ${playlistId}, track_index: ${task.track_index}`); return; } // V2 SYSTEM: Check for persistent cancel state from backend const isV2Task = task.playlist_id !== undefined; // V2 tasks have playlist_id const cancelRequested = task.cancel_requested || false; const uiState = task.ui_state || 'normal'; // Legacy protection for old system compatibility if (row.dataset.locallyCancelled === 'true' && !isV2Task) { failedOrCancelledCount++; return; // Only skip for legacy system tasks } // Mark row with V2 system info if (isV2Task) { row.dataset.useV2System = 'true'; row.dataset.cancelRequested = cancelRequested.toString(); row.dataset.uiState = uiState; } row.dataset.taskId = task.task_id; const statusEl = document.getElementById(`download-${playlistId}-${task.track_index}`); const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`); let statusText = ''; // V2 SYSTEM: Handle UI state override for cancelling tasks if (isV2Task && uiState === 'cancelling' && task.status !== 'cancelled') { statusText = 'πŸ”„ Cancelling...'; } else { switch (task.status) { case 'pending': statusText = '⏸️ Pending'; break; case 'searching': statusText = 'πŸ” Searching...'; break; case 'downloading': statusText = `⏬ Downloading... ${Math.round(task.progress || 0)}%`; break; case 'post_processing': statusText = 'βŒ› Processing...'; break; case 'completed': statusText = 'βœ… Completed'; completedCount++; break; case 'not_found': statusText = 'πŸ”‡ Not Found'; notFoundCount++; break; case 'failed': statusText = '❌ Failed'; failedOrCancelledCount++; break; case 'cancelled': statusText = '🚫 Cancelled'; failedOrCancelledCount++; break; default: statusText = `βšͺ ${task.status}`; break; } } if (statusEl) { statusEl.classList.remove('has-error-tooltip'); statusEl.removeAttribute('title'); statusEl.removeAttribute('data-error-msg'); statusEl.textContent = statusText; if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) { statusEl.classList.add('has-error-tooltip'); statusEl.dataset.errorMsg = task.error_message; _ensureErrorTooltipListeners(statusEl); } // Make not_found and failed cells clickable to review search candidates if ((task.status === 'not_found' || task.status === 'failed') && task.has_candidates) { statusEl.classList.add('has-candidates'); statusEl.dataset.taskId = task.task_id; _ensureCandidatesClickListener(statusEl); } console.debug(`βœ… [Status Update] Updated track ${task.track_index} to: ${statusText}${isV2Task ? ' (V2)' : ''}`); } else { console.warn(`❌ [Status Update] Status element not found: download-${playlistId}-${task.track_index}`); } // V2 SYSTEM: Smart button management with persistent state awareness if (actionsEl && !['completed', 'failed', 'cancelled', 'not_found', 'post_processing'].includes(task.status)) { // Check if we're in a cancelling state if (isV2Task && uiState === 'cancelling') { actionsEl.innerHTML = 'Cancelling...'; } else { // Create V2 cancel button for all active tasks const onclickHandler = isV2Task ? 'cancelTrackDownloadV2' : 'cancelTrackDownload'; actionsEl.innerHTML = ``; } } else if (actionsEl && ['completed', 'failed', 'cancelled', 'not_found', 'post_processing'].includes(task.status)) { actionsEl.innerHTML = '-'; // No actions available for terminal or processing states } }); // ENHANCED: Validate worker counts from server data const serverActiveWorkers = data.active_count || 0; const maxWorkers = data.max_concurrent || 3; // V2 SYSTEM: Simplified worker counting - backend is authoritative // Count active tasks, excluding locally cancelled legacy tasks only const clientActiveWorkers = (data.tasks || []).filter(task => { const row = document.querySelector(`tr[data-track-index="${task.track_index}"]`); const isLegacyCancelled = row && row.dataset.locallyCancelled === 'true' && !row.dataset.useV2System; return ['searching', 'downloading', 'queued'].includes(task.status) && !isLegacyCancelled; }).length; // Log discrepancies for debugging if (serverActiveWorkers !== clientActiveWorkers) { console.warn(`πŸ” [Worker Validation] ${playlistId}: server reports ${serverActiveWorkers} active, client sees ${clientActiveWorkers} active tasks`); // If server reports 0 but client sees active tasks, this might indicate ghost workers were fixed if (serverActiveWorkers === 0 && clientActiveWorkers > 0) { console.warn(`🚨 [Worker Validation] Server reports 0 workers but client sees ${clientActiveWorkers} active tasks - potential UI desync`); } } console.debug(`πŸ“Š [Worker Status] ${playlistId}: ${serverActiveWorkers}/${maxWorkers} active workers, ${clientActiveWorkers} client-side active tasks`); const totalFinished = completedCount + failedOrCancelledCount + notFoundCount; const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 0; document.getElementById(`download-progress-fill-${playlistId}`).style.width = `${progressPercent}%`; document.getElementById(`download-progress-text-${playlistId}`).textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`; document.getElementById(`stat-downloaded-${playlistId}`).textContent = completedCount; // Auto-save M3U file once when all downloads finish (not on every poll cycle). // Previously this fired on EVERY 2-second poll when completedCount > 0, flooding // the server with heavyweight M3U generation requests that exhausted Flask threads // and caused the batch status endpoint to hang β€” killing the poller. // CLIENT-SIDE COMPLETION: Only complete when ALL task rows in the UI reflect a terminal state. // Using totalFinished (derived from DOM updates in THIS render pass) prevents premature // completion when the server sends phase='complete' before all rows have been updated. const allTracksFinished = totalFinished >= missingCount && missingCount > 0 && totalFinished > 0; // Extra guard: require the server to also report no active tasks const serverHasActiveWork = (data.tasks || []).some(t => ['downloading', 'searching', 'queued', 'pending', 'post_processing'].includes(t.status)); if (allTracksFinished && !serverHasActiveWork && process.status !== 'complete') { console.log(`🎯 [Client Completion] All ${totalFinished}/${missingCount} tracks finished - completing modal locally`); // Hide cancel button and mark as complete document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none'; process.status = 'complete'; updatePlaylistCardUI(playlistId); // Save M3U once on completion (not during progress polling) if (completedCount > 0) { autoSavePlaylistM3U(playlistId); } // Show the force download toggle again const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container'); if (forceToggleContainer) { forceToggleContainer.style.display = 'flex'; } // Set album to downloaded status if this is an artist album if (playlistId.startsWith('artist_album_')) { const parts = playlistId.split('_'); if (parts.length >= 4) { const albumId = parts.slice(3).join('_'); setTimeout(() => setAlbumDownloadedStatus(albumId), 500); // Small delay to ensure UI updates } } // Update mirrored playlist card phase on client-side completion if (playlistId.startsWith('youtube_')) { const urlHash = playlistId.replace('youtube_', ''); if (urlHash.startsWith('mirrored_')) { updateMirroredCardPhase(urlHash, 'download_complete'); } } // Auto-save final M3U file for playlists autoSavePlaylistM3U(playlistId); // Show completion message let completionParts = [`${completedCount} downloaded`]; if (notFoundCount > 0) completionParts.push(`${notFoundCount} not found`); if (failedOrCancelledCount > 0) completionParts.push(`${failedOrCancelledCount} failed`); const completionMessage = `Download complete! ${completionParts.join(', ')}.`; showToast(completionMessage, 'success'); // Refresh server playlists tab so it reflects newly synced tracks if (typeof loadServerPlaylists === 'function') { setTimeout(() => loadServerPlaylists(), 2000); } // Auto-close wishlist modal when completed (for auto-processing) if (playlistId === 'wishlist') { console.log('πŸ”„ [Auto-Wishlist] Auto-closing completed wishlist modal to enable next cycle'); setTimeout(() => { closeDownloadMissingModal(playlistId); }, 3000); // 3-second delay to show completion message } // Check if any other processes still need polling checkAndCleanupGlobalPolling(); return; // Skip waiting for backend signal } // FIXED: Only trigger completion logic when backend actually reports batch as complete // Don't assume completion based on task counts - let backend determine when truly complete if (data.phase === 'complete' || data.phase === 'error') { // Enhanced check for background auto-processing for wishlist const isWishlist = (playlistId === 'wishlist'); const isModalHidden = (process.modalElement && process.modalElement.style.display === 'none'); const isAutoInitiated = data.auto_initiated || false; // Server indicates if batch was auto-started const isBackgroundWishlist = isWishlist && (isModalHidden || isAutoInitiated); // Note: Auto-show logic removed - wishlist modal visibility managed by user interaction only if (data.phase === 'cancelled') { if (process.status !== 'cancelled') { process.status = 'cancelled'; // Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on cancel if (playlistId.startsWith('youtube_')) { const urlHash = playlistId.replace('youtube_', ''); updateYouTubeCardPhase(urlHash, 'discovered'); if (urlHash.startsWith('mirrored_')) { updateMirroredCardPhase(urlHash, 'discovered'); } } showToast(`Process cancelled for ${process.playlist.name}.`, 'info'); } } else if (data.phase === 'error') { if (process.status !== 'complete') { process.status = 'complete'; updatePlaylistCardUI(playlistId); // Update card to show ready for review // Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on error if (playlistId.startsWith('youtube_')) { const urlHash = playlistId.replace('youtube_', ''); updateYouTubeCardPhase(urlHash, 'discovered'); if (urlHash.startsWith('mirrored_')) { updateMirroredCardPhase(urlHash, 'discovered'); } } showToast(`Process for ${process.playlist.name} failed!`, 'error'); } } else { if (process.status !== 'complete') { process.status = 'complete'; updatePlaylistCardUI(playlistId); // Update card to show ready for review // Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist if (playlistId.startsWith('youtube_')) { const urlHash = playlistId.replace('youtube_', ''); updateYouTubeCardPhase(urlHash, 'download_complete'); if (urlHash.startsWith('mirrored_')) { updateMirroredCardPhase(urlHash, 'download_complete'); } } // Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist if (playlistId.startsWith('tidal_')) { const tidalPlaylistId = playlistId.replace('tidal_', ''); if (tidalPlaylistStates[tidalPlaylistId]) { tidalPlaylistStates[tidalPlaylistId].phase = 'download_complete'; // Store the download process ID for potential modal rehydration tidalPlaylistStates[tidalPlaylistId].download_process_id = process.batchId; updateTidalCardPhase(tidalPlaylistId, 'download_complete'); console.log(`βœ… [Status Complete] Updated Tidal playlist ${tidalPlaylistId} to download_complete phase`); } } // Update Beatport chart phase to 'download_complete' if this is a Beatport chart if (playlistId.startsWith('beatport_')) { const urlHash = playlistId.replace('beatport_', ''); const state = youtubePlaylistStates[urlHash]; if (state && state.is_beatport_playlist) { const chartHash = state.beatport_chart_hash || urlHash; // Update frontend states state.phase = 'download_complete'; state.download_process_id = process.batchId; if (beatportChartStates[chartHash]) { beatportChartStates[chartHash].phase = 'download_complete'; } // Update card UI updateBeatportCardPhase(chartHash, 'download_complete'); // Update backend state try { fetch(`/api/beatport/charts/update-phase/${chartHash}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: 'download_complete', download_process_id: process.batchId }) }); } catch (error) { console.warn('⚠️ Error updating backend Beatport phase to download_complete:', error); } console.log(`βœ… [Status Complete] Updated Beatport chart ${chartHash} to download_complete phase`); } } // Handle background wishlist processing completion specially if (isBackgroundWishlist) { console.log(`πŸŽ‰ Background wishlist processing complete: ${completedCount} downloaded, ${notFoundCount} not found, ${failedOrCancelledCount} failed`); // Reset modal to idle state to prevent "complete" phase disruption setTimeout(() => { resetWishlistModalToIdleState(); // Server-side auto-processing will handle next cycle automatically }, 500); return; // Skip normal completion handling } // Show completion summary with wishlist stats (matching sync.py behavior) let completionMessage = `Process complete for ${process.playlist.name}!`; let messageType = 'success'; // Check for wishlist summary from backend (added when failed/cancelled tracks are processed) if (data.wishlist_summary) { const summary = data.wishlist_summary; let summaryParts = [`Downloaded: ${completedCount}`]; if (notFoundCount > 0) summaryParts.push(`Not Found: ${notFoundCount}`); if (failedOrCancelledCount > 0) summaryParts.push(`Failed: ${failedOrCancelledCount}`); completionMessage = `Download process complete! ${summaryParts.join(', ')}.`; if (summary.tracks_added > 0) { completionMessage += ` Added ${summary.tracks_added} failed track${summary.tracks_added !== 1 ? 's' : ''} to wishlist for automatic retry.`; } else if (summary.total_failed > 0) { completionMessage += ` ${summary.total_failed} track${summary.total_failed !== 1 ? 's' : ''} could not be added to wishlist.`; messageType = 'warning'; } } showToast(completionMessage, messageType); } } document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none'; // Mark process as complete and trigger cleanup check process.status = 'complete'; updatePlaylistCardUI(playlistId); // Check if any other processes still need polling checkAndCleanupGlobalPolling(); } } } function checkAndCleanupGlobalPolling() { // Check if any processes still need polling const hasActivePolling = Object.values(activeDownloadProcesses) .some(p => p.batchId && p.status === 'running'); if (!hasActivePolling) { console.debug('🧹 [Cleanup] No more active processes, continuing polling'); // Keep polling active - no need to stop } } // LEGACY FUNCTION: Keep for backward compatibility, but now uses global polling function startModalDownloadPolling(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process || !process.batchId) return; console.log(`πŸ”„ [Legacy Polling] Starting polling for ${playlistId}, delegating to global poller`); // Clear any existing individual poller (cleanup) if (process.poller) { clearInterval(process.poller); process.poller = null; } // Mark process as running to be picked up by global poller process.status = 'running'; // Start global polling if not already running startGlobalDownloadPolling(); // Create dummy poller for backward compatibility with cleanup functions ensureLegacyCompatibility(playlistId); } // For backward compatibility with cleanup functions that expect process.poller // Creates a dummy poller that will be cleaned up by the existing cleanup logic function createLegacyPoller(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process) return; // Create a dummy interval that just checks if the process is still active // This ensures existing cleanup logic that calls clearInterval(process.poller) works process.poller = setInterval(() => { // This dummy poller doesn't do anything - global poller handles updates if (!activeDownloadProcesses[playlistId] || process.status === 'complete') { clearInterval(process.poller); process.poller = null; return; } }, 5000); // Very infrequent check, just for cleanup compatibility } // Call this to create the legacy poller after starting global polling function ensureLegacyCompatibility(playlistId) { const process = activeDownloadProcesses[playlistId]; if (process && !process.poller) { createLegacyPoller(playlistId); } } async function updateModalWithLiveDownloadProgress() { try { if (!currentDownloadBatchId) return; // Fetch live download data from the downloads API const response = await fetch('/api/downloads/status'); const downloadData = await response.json(); if (downloadData.error) return; // Get all active and finished downloads const allDownloads = { ...(downloadData.active || {}), ...(downloadData.finished || {}) }; // Update modal tracks that have active downloads const modalRows = document.querySelectorAll('.download-missing-modal tr[data-track-index]'); for (const row of modalRows) { const taskId = row.dataset.taskId; if (!taskId) continue; // Find corresponding download by checking if filename/title matches const trackName = row.querySelector('.track-name')?.textContent?.trim(); if (!trackName) continue; // Search for matching download for (const [downloadId, downloadInfo] of Object.entries(allDownloads)) { // Extract display title from filename (handle YouTube encoding) let downloadTitle = ''; if (downloadInfo.filename) { if ((downloadInfo.username === 'youtube' || downloadInfo.username === 'tidal' || downloadInfo.username === 'qobuz' || downloadInfo.username === 'hifi') && downloadInfo.filename.includes('||')) { const parts = downloadInfo.filename.split('||'); downloadTitle = parts[1] || parts[0]; } else { downloadTitle = downloadInfo.filename.split(/[\\/]/).pop(); } } // Simple matching - could be improved with better logic if (downloadTitle && trackName && ( downloadTitle.toLowerCase().includes(trackName.toLowerCase()) || trackName.toLowerCase().includes(downloadTitle.toLowerCase()) )) { // Update the track with live download progress const statusElement = row.querySelector('.track-download-status'); const progress = downloadInfo.percentComplete || 0; const state = downloadInfo.state || ''; if (statusElement && state.includes('InProgress') && progress > 0) { statusElement.textContent = `⏬ Downloading... ${Math.round(progress)}%`; statusElement.className = 'track-download-status download-downloading'; } else if (statusElement && (state.includes('Completed') || state.includes('Succeeded'))) { statusElement.textContent = 'βœ… Completed'; statusElement.className = 'track-download-status download-complete'; } break; // Found a match, stop searching } } } } catch (error) { // Silent fail - don't spam console during normal operation } } function toggleAllTrackSelections(playlistId, checked) { const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`); if (!tbody) return; const checkboxes = tbody.querySelectorAll('.track-select-cb'); checkboxes.forEach(cb => { cb.checked = checked; }); updateTrackSelectionCount(playlistId); } function updateTrackSelectionCount(playlistId) { const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`); if (!tbody) return; const allCbs = tbody.querySelectorAll('.track-select-cb'); const checkedCbs = tbody.querySelectorAll('.track-select-cb:checked'); const total = allCbs.length; const selected = checkedCbs.length; // Update selection count label const countLabel = document.getElementById(`track-selection-count-${playlistId}`); if (countLabel) { countLabel.textContent = `${selected} / ${total} tracks selected`; } // Update select-all checkbox state const selectAll = document.getElementById(`select-all-${playlistId}`); if (selectAll) { selectAll.checked = selected === total; selectAll.indeterminate = selected > 0 && selected < total; } // Update row dimming allCbs.forEach(cb => { const row = cb.closest('tr'); if (row) row.classList.toggle('track-deselected', !cb.checked); }); // Disable Begin Analysis and Add to Wishlist buttons when 0 selected const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); if (beginBtn) { beginBtn.disabled = selected === 0; } const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); if (wishlistBtn) { wishlistBtn.disabled = selected === 0; } } async function cancelAllOperations(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process) return; // Prevent multiple cancel all operations if (process.cancellingAll) { console.log(`⚠️ Cancel All already in progress for ${playlistId}`); return; } process.cancellingAll = true; console.log(`🚫 Cancel All clicked for playlist ${playlistId} - closing modal and cleaning up server`); showToast('Cancelling all operations and closing modal...', 'info'); // Mark process as complete immediately so polling stops process.status = 'complete'; // Stop any active polling if (process.poller) { clearInterval(process.poller); process.poller = null; } // Tell server to stop starting new downloads and clean up the batch if (process.batchId) { try { // Cancel the batch (stops new downloads from starting) const cancelResponse = await fetch(`/api/playlists/${process.batchId}/cancel_batch`, { method: 'POST' }); if (cancelResponse.ok) { const cancelData = await cancelResponse.json(); console.log(`βœ… Server stopped new downloads for batch ${process.batchId}`); } } catch (error) { console.warn('Error during server batch cancel:', error); } } // Close the modal immediately - this will handle cleanup closeDownloadMissingModal(playlistId); showToast('Modal closed. Active downloads will finish in background.', 'success'); } function resetToInitialState() { // Reset UI document.getElementById('begin-analysis-btn').style.display = 'inline-block'; document.getElementById('start-downloads-btn').style.display = 'none'; document.getElementById('cancel-all-btn').style.display = 'none'; // Reset progress bars document.getElementById('analysis-progress-fill').style.width = '0%'; document.getElementById('download-progress-fill').style.width = '0%'; document.getElementById('analysis-progress-text').textContent = 'Ready to start'; document.getElementById('download-progress-text').textContent = 'Waiting for analysis'; // Reset stats document.getElementById('stat-found').textContent = '-'; document.getElementById('stat-missing').textContent = '-'; document.getElementById('stat-downloaded').textContent = '0'; // Reset track table const tbody = document.getElementById('download-tracks-tbody'); if (tbody) { const rows = tbody.querySelectorAll('tr'); rows.forEach((row, index) => { const matchElement = row.querySelector('.track-match-status'); const downloadElement = row.querySelector('.track-download-status'); const actionsElement = row.querySelector('.track-actions'); if (matchElement) { matchElement.textContent = 'πŸ” Pending'; matchElement.className = 'track-match-status match-checking'; } if (downloadElement) { downloadElement.textContent = '-'; downloadElement.className = 'track-download-status'; } if (actionsElement) { actionsElement.textContent = '-'; } }); } // Reset state activeAnalysisTaskId = null; analysisResults = []; missingTracks = []; } // =============================== // NEW ATOMIC CANCEL SYSTEM V2 // =============================== async function cancelTrackDownloadV2(playlistId, trackIndex) { /** * NEW ATOMIC CANCEL SYSTEM V2 * * - No optimistic UI updates * - Single API call handles everything atomically * - Backend is single source of truth for all state * - No race conditions or dual state management */ const process = activeDownloadProcesses[playlistId]; if (!process) { console.warn(`❌ [Cancel V2] No process found for playlist: ${playlistId}`); return; } const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${trackIndex}"]`); if (!row) { console.warn(`❌ [Cancel V2] No row found for track index: ${trackIndex}`); return; } // Check if already in cancelling state const statusEl = document.getElementById(`download-${playlistId}-${trackIndex}`); const currentStatus = statusEl ? statusEl.textContent : ''; if (currentStatus.includes('Cancelling') || currentStatus.includes('Cancelled')) { console.log(`⚠️ [Cancel V2] Task already being cancelled or cancelled: ${currentStatus}`); return; } console.log(`🎯 [Cancel V2] Starting atomic cancel: playlist=${playlistId}, track=${trackIndex}`); // V2 SYSTEM: Set temporary UI state - will be confirmed by server row.dataset.uiState = 'cancelling'; // Show loading state only - no optimistic "cancelled" state if (statusEl) { statusEl.textContent = 'πŸ”„ Cancelling...'; } // Disable the cancel button to prevent double-clicks const actionsEl = document.getElementById(`actions-${playlistId}-${trackIndex}`); if (actionsEl) { actionsEl.innerHTML = 'Cancelling...'; } try { const response = await fetch('/api/downloads/cancel_task_v2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playlist_id: playlistId, track_index: trackIndex }) }); const data = await response.json(); if (data.success) { console.log(`βœ… [Cancel V2] Successfully cancelled: ${data.task_info.track_name}`); showToast(`Cancelled "${data.task_info.track_name}" and added to wishlist.`, 'success'); // Let the status polling system update the UI with server truth // No manual UI updates - backend is authoritative } else { console.error(`❌ [Cancel V2] Cancel failed: ${data.error}`); showToast(`Cancel failed: ${data.error}`, 'error'); // Reset UI to previous state on failure row.dataset.uiState = 'normal'; // Reset UI state if (statusEl) { statusEl.textContent = '❌ Cancel Failed'; } if (actionsEl) { actionsEl.innerHTML = ``; } } } catch (error) { console.error(`❌ [Cancel V2] Network/API error:`, error); showToast(`Cancel request failed: ${error.message}`, 'error'); // Reset UI on network error row.dataset.uiState = 'normal'; // Reset UI state if (statusEl) { statusEl.textContent = '❌ Cancel Failed'; } if (actionsEl) { actionsEl.innerHTML = ``; } } } // =============================== // LEGACY CANCEL SYSTEM (OLD) // =============================== async function cancelTrackDownload(playlistId, trackIndex) { const process = activeDownloadProcesses[playlistId]; if (!process) return; const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${trackIndex}"]`); if (!row) return; // Prevent double cancellation if (row.dataset.locallyCancelled === 'true') { return; // Already cancelled locally } const taskId = row.dataset.taskId; if (!taskId) { showToast('Task not started yet, cannot cancel.', 'warning'); return; } // UI update for immediate feedback - mark as cancelled FIRST to prevent race conditions row.dataset.locallyCancelled = 'true'; document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '🚫 Cancelling...'; document.getElementById(`actions-${playlistId}-${trackIndex}`).innerHTML = '-'; try { const response = await fetch('/api/downloads/cancel_task', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId }) }); const data = await response.json(); if (data.success) { // Update final UI state after successful cancellation document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '🚫 Cancelled'; showToast('Download cancelled and added to wishlist.', 'info'); } else { throw new Error(data.error); } } catch (error) { // Reset UI state if cancellation failed row.dataset.locallyCancelled = 'false'; document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '❌ Cancel Failed'; showToast(`Could not cancel task: ${error.message}`, 'error'); } } // Find and REPLACE the old startPlaylistSyncFromModal function async function startPlaylistSync(playlistId) { const startTime = Date.now(); console.log(`πŸš€ [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId}`); const playlist = spotifyPlaylists.find(p => p.id === playlistId); if (!playlist) { console.error(`❌ Could not find playlist data for ID: ${playlistId}`); showToast('Could not find playlist data.', 'error'); return; } console.log(`βœ… Found playlist: ${playlist.name} with ${playlist.track_count || 'unknown'} tracks`); // Check if already syncing to prevent duplicate syncs if (activeSyncPollers[playlistId]) { showToast('Sync already in progress for this playlist', 'warning'); return; } // Update button state immediately for user feedback const syncBtn = document.getElementById(`sync-btn-${playlistId}`); if (syncBtn) { syncBtn.disabled = true; syncBtn.textContent = '⏳ Syncing...'; } // Ensure we have the full track list before starting let tracks = playlistTrackCache[playlistId]; if (!tracks) { const trackFetchStart = Date.now(); console.log(`πŸ”„ [${new Date().toTimeString().split(' ')[0]}] Cache miss - fetching tracks for playlist ${playlistId}`); try { // Use the right endpoint based on playlist source const fetchUrl = playlistId.startsWith('deezer_arl_') ? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}` : `/api/spotify/playlist/${playlistId}`; const response = await fetch(fetchUrl); const fullPlaylist = await response.json(); if (fullPlaylist.error) throw new Error(fullPlaylist.error); tracks = fullPlaylist.tracks; playlistTrackCache[playlistId] = tracks; // Cache it const trackFetchTime = Date.now() - trackFetchStart; console.log(`βœ… [${new Date().toTimeString().split(' ')[0]}] Fetched and cached ${tracks.length} tracks (took ${trackFetchTime}ms)`); } catch (error) { console.error(`❌ Failed to fetch tracks:`, error); showToast(`Failed to fetch tracks for sync: ${error.message}`, 'error'); return; } } else { console.log(`βœ… [${new Date().toTimeString().split(' ')[0]}] Using cached tracks: ${tracks.length} tracks`); } // DON'T close the modal - let it show live progress like the GUI try { const syncStartTime = Date.now(); console.log(`πŸ”„ [${new Date().toTimeString().split(' ')[0]}] Making API call to /api/sync/start with ${tracks.length} tracks`); const response = await fetch('/api/sync/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playlist_id: playlist.id, playlist_name: playlist.name, tracks: tracks, // Send the full track list image_url: playlist.image_url || '' }) }); const syncRequestTime = Date.now() - syncStartTime; console.log(`πŸ“‘ [${new Date().toTimeString().split(' ')[0]}] API response status: ${response.status} (took ${syncRequestTime}ms)`); const data = await response.json(); console.log(`πŸ“‘ [${new Date().toTimeString().split(' ')[0]}] API response data:`, data); if (!data.success) throw new Error(data.error); const totalTime = Date.now() - startTime; console.log(`βœ… [${new Date().toTimeString().split(' ')[0]}] Sync started successfully for "${playlist.name}" (total time: ${totalTime}ms)`); showToast(`Sync started for "${playlist.name}"`, 'success'); // Show initial sync state in modal if open const modal = document.getElementById('playlist-details-modal') || document.getElementById('deezer-arl-playlist-details-modal'); if (modal && modal.style.display !== 'none') { const statusDisplay = document.getElementById(`modal-sync-status-${playlist.id}`); if (statusDisplay) { statusDisplay.style.display = 'flex'; console.log(`πŸ“Š [${new Date().toTimeString().split(' ')[0]}] Showing modal sync status for ${playlist.id}`); } } updateCardToSyncing(playlist.id, 0); // Initial state startSyncPolling(playlist.id); } catch (error) { console.error(`❌ Failed to start sync:`, error); showToast(`Failed to start sync: ${error.message}`, 'error'); updateCardToDefault(playlist.id); } } // Add these new helper functions to script.js function startSyncPolling(playlistId) { // Clear any existing poller for this playlist if (activeSyncPollers[playlistId]) { clearInterval(activeSyncPollers[playlistId]); } // Phase 5: Subscribe via WebSocket if (socketConnected) { socket.emit('sync:subscribe', { playlist_ids: [playlistId] }); _syncProgressCallbacks[playlistId] = (data) => { if (data.status === 'syncing') { const progress = data.progress; updateCardToSyncing(playlistId, progress.progress, progress); updateModalSyncProgress(playlistId, progress); } else if (data.status === 'finished' || data.status === 'error' || data.status === 'cancelled') { stopSyncPolling(playlistId); updateCardToDefault(playlistId, data); closePlaylistDetailsModal(); } }; } // Start a new poller that checks every 2 seconds console.log(`πŸ”„ Starting sync polling for playlist: ${playlistId}`); activeSyncPollers[playlistId] = setInterval(async () => { // Always poll β€” no dedicated WebSocket events for discovery progress try { console.log(`πŸ“Š Polling sync status for: ${playlistId}`); const response = await fetch(`/api/sync/status/${playlistId}`); const state = await response.json(); console.log(`πŸ“Š Poll response:`, state); if (state.status === 'syncing') { const progress = state.progress; console.log(`πŸ“Š Sync progress:`, progress); console.log(` πŸ“Š Progress values: ${progress.progress}% | Total: ${progress.total_tracks} | Matched: ${progress.matched_tracks} | Failed: ${progress.failed_tracks}`); console.log(` πŸ“Š Current step: "${progress.current_step}" | Current track: "${progress.current_track}"`); // Use the actual progress percentage from the sync service updateCardToSyncing(playlistId, progress.progress, progress); // Also update the modal if it's open updateModalSyncProgress(playlistId, progress); } else if (state.status === 'finished' || state.status === 'error' || state.status === 'cancelled') { console.log(`🏁 Sync completed with status: ${state.status}`); stopSyncPolling(playlistId); updateCardToDefault(playlistId, state); // Also update the modal if it's open closePlaylistDetailsModal(); closeDeezerArlPlaylistDetailsModal(); // Close modal on completion/error } } catch (error) { console.error(`❌ Error polling sync status for ${playlistId}:`, error); stopSyncPolling(playlistId); updateCardToDefault(playlistId, { status: 'error', error: 'Polling failed' }); } }, 2000); // Poll every 2 seconds updateRefreshButtonState(); } function stopSyncPolling(playlistId) { if (activeSyncPollers[playlistId]) { clearInterval(activeSyncPollers[playlistId]); delete activeSyncPollers[playlistId]; } // Phase 5: Unsubscribe and clean up callback if (_syncProgressCallbacks[playlistId]) { if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] }); delete _syncProgressCallbacks[playlistId]; } updateRefreshButtonState(); } // Sync sidebar visibility helpers function showSyncSidebar() { const sidebar = document.querySelector('.sync-sidebar'); const contentArea = document.querySelector('.sync-content-area'); if (sidebar && contentArea && window.innerWidth > 1300) { sidebar.style.display = ''; contentArea.style.gridTemplateColumns = '2.5fr 0.75fr'; } } function hideSyncSidebar() { const sidebar = document.querySelector('.sync-sidebar'); const contentArea = document.querySelector('.sync-content-area'); if (sidebar && contentArea) { sidebar.style.display = 'none'; contentArea.style.gridTemplateColumns = '1fr'; } } // Sequential Sync Functions function startSequentialSync() { // Initialize manager if needed if (!sequentialSyncManager) { sequentialSyncManager = new SequentialSyncManager(); } // Check if already running - if so, cancel if (sequentialSyncManager.isRunning) { sequentialSyncManager.cancel(); return; } // Validate selection if (selectedPlaylists.size === 0) { showToast('No playlists selected for sync', 'error'); return; } // Get playlist order from DOM to maintain display order const playlistCards = document.querySelectorAll('.playlist-card'); const orderedPlaylistIds = []; playlistCards.forEach(card => { const playlistId = card.dataset.playlistId; if (selectedPlaylists.has(playlistId)) { orderedPlaylistIds.push(playlistId); } }); console.log(`πŸš€ Starting sequential sync for ${orderedPlaylistIds.length} playlists`); // Show sidebar for sync progress showSyncSidebar(); // Start sequential sync sequentialSyncManager.start(orderedPlaylistIds); // Disable playlist selection during sync disablePlaylistSelection(true); } function disablePlaylistSelection(disabled) { const checkboxes = document.querySelectorAll('.playlist-checkbox'); checkboxes.forEach(checkbox => { checkbox.disabled = disabled; }); } function hasActiveOperations() { const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0; // Only check non-wishlist download processes for sync page refresh button const hasActiveDownloads = Object.entries(activeDownloadProcesses) .filter(([playlistId, process]) => playlistId !== 'wishlist') // Exclude wishlist .some(([_, process]) => process.status === 'running'); const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning; return hasActiveSyncs || hasActiveDownloads || hasSequentialSync; } function updateRefreshButtonState() { const refreshBtn = document.getElementById('spotify-refresh-btn'); if (!refreshBtn) return; if (hasActiveOperations()) { refreshBtn.disabled = true; // Provide context-specific text const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0; const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning; if (hasActiveSyncs || hasSequentialSync) { refreshBtn.textContent = 'πŸ”„ Syncing...'; } else { refreshBtn.textContent = 'πŸ“₯ Downloading...'; } } else { refreshBtn.disabled = false; refreshBtn.textContent = 'πŸ”„ Refresh'; } } function updateCardToSyncing(playlistId, percent, progress = null) { const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); if (!card) return; const progressBar = card.querySelector('.sync-progress-indicator'); progressBar.style.display = 'block'; let progressText = 'Starting...'; let actualPercent = percent || 0; if (progress) { // Create detailed progress text like the GUI const matched = progress.matched_tracks || 0; const failed = progress.failed_tracks || 0; const total = progress.total_tracks || 0; const currentStep = progress.current_step || 'Processing'; // Calculate actual progress as processed/total, not just successful/total if (total > 0) { const processed = matched + failed; actualPercent = Math.round((processed / total) * 100); progressText = `${currentStep}: ${processed}/${total} (${matched} matched, ${failed} failed)`; } else { progressText = currentStep; } // If there's a current track being processed, show it if (progress.current_track) { progressText += ` - ${progress.current_track}`; } } // Build live status counter HTML (same as modal) let statusCounterHTML = ''; if (progress && progress.total_tracks > 0) { const matched = progress.matched_tracks || 0; const failed = progress.failed_tracks || 0; const total = progress.total_tracks || 0; const processed = matched + failed; const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; statusCounterHTML = `
β™ͺ ${total} / βœ“ ${matched} / βœ— ${failed} (${percentage}%)
`; } progressBar.innerHTML = ` ${statusCounterHTML}
${progressText}
`; } function updateCardToDefault(playlistId, finalState = null) { const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); if (!card) return; const progressBar = card.querySelector('.sync-progress-indicator'); progressBar.style.display = 'none'; progressBar.innerHTML = ''; const statusEl = card.querySelector('.playlist-card-status'); if (finalState) { if (finalState.status === 'finished') { statusEl.textContent = `Synced: Just now`; statusEl.className = 'playlist-card-status status-synced'; // Check if any tracks were added to wishlist const wishlistCount = finalState.progress?.wishlist_added_count || finalState.result?.wishlist_added_count || 0; const unmatchedTracks = finalState.progress?.unmatched_tracks || finalState.result?.unmatched_tracks || []; const playlistName = card.querySelector('.playlist-card-name').textContent; if (wishlistCount > 0 && unmatchedTracks.length > 0) { const trackList = unmatchedTracks.map(t => `${t.artist} - ${t.name}`).join(', '); showToast(`Sync complete for "${playlistName}". ${wishlistCount} not found in library: ${trackList}`, 'warning'); } else if (wishlistCount > 0) { showToast(`Sync complete for "${playlistName}". Added ${wishlistCount} missing track${wishlistCount > 1 ? 's' : ''} to wishlist.`, 'success'); } else { showToast(`Sync complete for "${playlistName}"`, 'success'); } } else { statusEl.textContent = `Sync Failed`; statusEl.className = 'playlist-card-status status-needs-sync'; // Or a new error class showToast(`Sync failed: ${finalState.error || 'Unknown error'}`, 'error'); } } } // Update the modal's sync progress display (matches GUI functionality) function updateModalSyncProgress(playlistId, progress) { const modal = document.getElementById('playlist-details-modal') || document.getElementById('deezer-arl-playlist-details-modal'); if (modal && modal.style.display !== 'none') { console.log(`πŸ“Š Updating modal sync progress for ${playlistId}:`, progress); // Show sync status display const statusDisplay = document.getElementById(`modal-sync-status-${playlistId}`); if (statusDisplay) { statusDisplay.style.display = 'flex'; // Update counters (matching GUI exactly) const totalEl = document.getElementById(`modal-total-${playlistId}`); const matchedEl = document.getElementById(`modal-matched-${playlistId}`); const failedEl = document.getElementById(`modal-failed-${playlistId}`); const percentageEl = document.getElementById(`modal-percentage-${playlistId}`); const total = progress.total_tracks || 0; const matched = progress.matched_tracks || 0; const failed = progress.failed_tracks || 0; if (totalEl) totalEl.textContent = total; if (matchedEl) matchedEl.textContent = matched; if (failedEl) failedEl.textContent = failed; // Calculate percentage like GUI if (total > 0) { const processed = matched + failed; const percentage = Math.round((processed / total) * 100); if (percentageEl) percentageEl.textContent = percentage; } console.log(`πŸ“Š Modal updated: β™ͺ ${total} / βœ“ ${matched} / βœ— ${failed} (${Math.round((matched + failed) / total * 100)}%)`); } else { console.warn(`❌ Modal sync status display not found for ${playlistId}`); } } else { console.log(`πŸ“Š Modal not open for ${playlistId}, skipping update`); } } // Raw Soulseek file search (used by the 'Soulseek (raw files)' source picker option). async function performDownloadsSearch() { const query = document.getElementById('downloads-search-input').value.trim(); if (!query) { showToast('Please enter a search term', 'error'); return; } // --- UI Element References --- const searchInput = document.getElementById('downloads-search-input'); const searchButton = document.getElementById('downloads-search-btn'); const cancelButton = document.getElementById('downloads-cancel-btn'); const statusText = document.getElementById('search-status-text'); const spinner = document.querySelector('.spinner-animation'); const dots = document.querySelector('.dots-animation'); // --- Start a new AbortController for this search --- searchAbortController = new AbortController(); try { // --- 1. Update UI to "Searching" State --- searchInput.disabled = true; searchButton.disabled = true; cancelButton.classList.remove('hidden'); spinner.classList.remove('hidden'); dots.classList.remove('hidden'); statusText.textContent = `Searching for '${query}'...`; displayDownloadsResults([]); // Clear previous results // --- 2. Perform the Fetch Request --- const response = await fetch('/api/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }), signal: searchAbortController.signal // Link fetch to the AbortController }); const data = await response.json(); if (data.error) { throw new Error(data.error); } const results = data.results || []; allSearchResults = results; resetFilters(); applyFiltersAndSort(); // --- 3. Update UI with Success State --- if (results.length === 0) { statusText.textContent = `No results found for '${query}'`; showToast('No results found', 'error'); } else { document.getElementById('filters-container').classList.remove('hidden'); // Count albums and singles like the GUI app let totalAlbums = 0; let totalTracks = 0; results.forEach(result => { if (result.result_type === 'album') { totalAlbums++; } else { totalTracks++; } }); statusText.textContent = `✨ Found ${results.length} results β€’ ${totalAlbums} albums, ${totalTracks} singles`; showToast(`Found ${results.length} results`, 'success'); } } catch (error) { // --- 4. Handle Errors, Including Cancellation --- if (error.name === 'AbortError') { // This specific error is thrown when the user clicks "Cancel" statusText.textContent = 'Search was cancelled.'; showToast('Search cancelled', 'info'); displayDownloadsResults([]); // Clear any partial results } else { console.error('Search failed:', error); statusText.textContent = `Search failed: ${error.message}`; showToast('Search failed', 'error'); } } finally { // --- 5. Clean Up UI Regardless of Outcome --- searchInput.disabled = false; searchButton.disabled = false; cancelButton.classList.add('hidden'); spinner.classList.add('hidden'); dots.classList.add('hidden'); searchAbortController = null; // Clear the controller } } function displayDownloadsResults(results) { const resultsArea = document.getElementById('search-results-area'); if (!resultsArea) return; if (!results.length) { resultsArea.innerHTML = '

No search results found.

'; return; } let html = ''; results.forEach((result, index) => { const isAlbum = result.result_type === 'album'; if (isAlbum) { const trackCount = result.tracks ? result.tracks.length : 0; const totalSize = result.total_size ? `${(result.total_size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size'; // Generate individual track items let trackListHtml = ''; if (result.tracks && result.tracks.length > 0) { // Detect disc boundaries from track number resets for multi-disc albums let currentDisc = 1; let lastTrackNum = 0; let discBreaks = new Set(); result.tracks.forEach((track, trackIndex) => { const tn = track.track_number || 0; if (trackIndex > 0 && tn > 0 && tn <= lastTrackNum) { currentDisc++; discBreaks.add(trackIndex); } if (tn > 0) lastTrackNum = tn; }); const isMultiDisc = discBreaks.size > 0; if (isMultiDisc) { trackListHtml += `
Disc 1
`; } let discNum = 1; result.tracks.forEach((track, trackIndex) => { if (discBreaks.has(trackIndex)) { discNum++; trackListHtml += `
Disc ${discNum}
`; } const trackSize = track.size ? `${(track.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size'; const trackBitrate = track.bitrate ? `${track.bitrate}kbps` : ''; trackListHtml += `
${escapeHtml(track.title || `Track ${trackIndex + 1}`)}
${track.track_number ? `${track.track_number}. ` : ''}${escapeHtml(track.artist || result.artist || 'Unknown Artist')} β€’ ${trackSize} β€’ ${escapeHtml(track.quality || 'Unknown')} ${trackBitrate}
`; }); } html += `
β–Ά
πŸ’Ώ
${escapeHtml(result.album_title || result.title || 'Unknown Album')}
by ${escapeHtml(result.artist || 'Unknown Artist')}
${trackCount} tracks β€’ ${totalSize} β€’ ${escapeHtml(result.quality || 'Mixed')}
Shared by ${escapeHtml(result.username || 'Unknown')}
`; } else { const sizeText = result.size ? `${(result.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size'; const bitrateText = result.bitrate ? `${result.bitrate}kbps` : ''; html += `
🎡
${escapeHtml(result.title || 'Unknown Title')}
by ${escapeHtml(result.artist || 'Unknown Artist')}
${sizeText} β€’ ${escapeHtml(result.quality || 'Unknown')} ${bitrateText}
Shared by ${escapeHtml(result.username || 'Unknown')}
`; } }); resultsArea.innerHTML = html; // Store results globally for download functions window.currentSearchResults = results; } async function downloadTrack(index) { const results = window.currentSearchResults; if (!results || !results[index]) return; const track = results[index]; try { const response = await fetch('/api/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(track) }); const data = await response.json(); if (data.success) { showToast(`Download started: ${track.title}`, 'success'); } else { showToast(`Download failed: ${data.error}`, 'error'); } } catch (error) { console.error('Download error:', error); showToast('Failed to start download', 'error'); } } async function downloadAlbum(index) { const results = window.currentSearchResults; if (!results || !results[index]) return; const album = results[index]; try { const response = await fetch('/api/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(album) }); const data = await response.json(); if (data.success) { showToast(data.message, 'success'); } else { showToast(`Album download failed: ${data.error}`, 'error'); } } catch (error) { console.error('Album download error:', error); showToast('Failed to start album download', 'error'); } } // Matched download functions function matchedDownloadTrack(index) { const results = window.currentSearchResults; if (!results || !results[index]) return; const track = results[index]; console.log('🎯 Starting matched download for single track:', track); // Open matching modal for single track openMatchingModal(track, false, null); } function matchedDownloadAlbum(index) { const results = window.currentSearchResults; if (!results || !results[index]) return; const album = results[index]; console.log('🎯 Starting matched download for album:', album); // Open matching modal for album download openMatchingModal(album, true, album); } function matchedDownloadAlbumTrack(albumIndex, trackIndex) { const results = window.currentSearchResults; if (!results || !results[albumIndex]) return; const album = results[albumIndex]; if (!album.tracks || !album.tracks[trackIndex]) return; const track = album.tracks[trackIndex]; // Ensure track has necessary properties from parent album track.username = album.username; track.artist = track.artist || album.artist; track.album = album.album_title || album.title; console.log('🎯 Starting matched download for album track:', track); // Open matching modal for single track (from album context) openMatchingModal(track, false, null); } function toggleAlbumExpansion(albumIndex) { const albumCard = document.querySelector(`[data-album-index="${albumIndex}"]`); if (!albumCard) return; const trackList = albumCard.querySelector('.album-track-list'); const indicator = albumCard.querySelector('.album-expand-indicator'); if (trackList.style.display === 'none' || !trackList.style.display) { // Expand trackList.style.display = 'block'; indicator.textContent = 'β–Ό'; albumCard.classList.add('expanded'); } else { // Collapse trackList.style.display = 'none'; indicator.textContent = 'β–Ά'; albumCard.classList.remove('expanded'); } } async function downloadAlbumTrack(albumIndex, trackIndex) { const results = window.currentSearchResults; if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) return; const track = results[albumIndex].tracks[trackIndex]; try { const response = await fetch('/api/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...track, result_type: 'track' }) }); const data = await response.json(); if (data.success) { showToast(`Download started: ${track.title}`, 'success'); } else { showToast(`Track download failed: ${data.error}`, 'error'); } } catch (error) { console.error('Track download error:', error); showToast('Failed to start track download', 'error'); } } // =============================== // STREAMING WRAPPER FUNCTIONS // =============================== async function streamTrack(index) { // Stream a single track from search results try { console.log(`🎡 streamTrack called with index: ${index}`); console.log(`🎡 window.currentSearchResults:`, window.currentSearchResults); if (!window.currentSearchResults || !window.currentSearchResults[index]) { console.error(`❌ No search results or invalid index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`); showToast('Track not found', 'error'); return; } const result = window.currentSearchResults[index]; console.log(`🎡 Streaming track:`, result); // Check for unsupported formats before streaming (streaming sources use encoded filenames, skip check) const isStreamingSource = result.username === 'youtube' || result.username === 'tidal' || result.username === 'qobuz' || result.username === 'hifi'; if (!isStreamingSource && result.filename) { const format = getFileExtension(result.filename); console.log(`🎡 [STREAM CHECK] File: ${result.filename}, Extension: ${format}`); const isSupported = isAudioFormatSupported(result.filename); console.log(`🎡 [STREAM CHECK] Format ${format} supported: ${isSupported}`); if (!isSupported) { showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error'); return; } } await startStream(result); } catch (error) { console.error('Track streaming error:', error); showToast('Failed to start track stream', 'error'); } } async function streamAlbumTrack(albumIndex, trackIndex) { // Stream a specific track from an album try { console.log(`🎡 streamAlbumTrack called with albumIndex: ${albumIndex}, trackIndex: ${trackIndex}`); console.log(`🎡 window.currentSearchResults:`, window.currentSearchResults); if (!window.currentSearchResults || !window.currentSearchResults[albumIndex]) { console.error(`❌ No search results or invalid album index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`); showToast('Album not found', 'error'); return; } const album = window.currentSearchResults[albumIndex]; console.log(`🎡 Album data:`, album); // Surgical Fix: Handle YouTube/Tidal results which are "flat" (no tracks array) if (album.username === 'youtube' || album.username === 'tidal' || album.username === 'qobuz' || album.username === 'hifi') { // For YouTube/Tidal results, the "album" is actually the track itself const track = album; const trackData = { ...track, username: track.username, filename: track.filename, artist: track.artist, album: track.title, // Use title as album name for player title: track.title }; console.log(`🎡 Streaming YouTube track directly:`, trackData); await startStream(trackData); return; } if (!album.tracks || !album.tracks[trackIndex]) { console.error(`❌ No tracks in album or invalid track index. Tracks length: ${album.tracks ? album.tracks.length : 'undefined'}`); showToast('Track not found in album', 'error'); return; } const track = album.tracks[trackIndex]; console.log(`🎡 Streaming album track:`, track); // Ensure album tracks have required fields const trackData = { ...track, username: track.username || album.username, filename: track.filename || track.path, artist: track.artist || album.artist, album: track.album || album.title || album.album }; console.log(`🎡 Enhanced track data:`, trackData); // Check for unsupported formats before streaming (streaming sources use encoded filenames, skip check) const isStreamingSource2 = trackData.username === 'youtube' || trackData.username === 'tidal' || trackData.username === 'qobuz' || trackData.username === 'hifi'; if (!isStreamingSource2 && trackData.filename && !isAudioFormatSupported(trackData.filename)) { const format = getFileExtension(trackData.filename); showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error'); return; } await startStream(trackData); } catch (error) { console.error('Album track streaming error:', error); showToast('Failed to start track stream', 'error'); } } async function loadArtistsData() { try { const response = await fetch(API.artists); const data = await response.json(); const artistsGrid = document.getElementById('artists-grid'); if (data.artists && data.artists.length) { artistsGrid.innerHTML = data.artists.map(artist => `
${artist.image ? `${escapeHtml(artist.name)}` : '
🎡
' }
${escapeHtml(artist.name)}
${artist.album_count || 0} albums
`).join(''); } else { artistsGrid.innerHTML = '
No artists found
'; } } catch (error) { console.error('Error loading artists data:', error); document.getElementById('artists-grid').innerHTML = '
Error loading artists
'; } } // =============================== // UTILITY FUNCTIONS // =============================== function showLoadingOverlay(message = 'Loading...') { const overlay = document.getElementById('loading-overlay'); const messageElement = overlay.querySelector('.loading-message'); messageElement.textContent = message; overlay.classList.remove('hidden'); } function hideLoadingOverlay() { document.getElementById('loading-overlay').classList.add('hidden'); } // ================================================================================== // NOTIFICATION SYSTEM β€” Compact toasts + bell button + notification history panel // ================================================================================== const _notifState = { history: [], unreadCount: 0, panelOpen: false, currentToast: null, toastTimer: null, maxHistory: 50, }; const _recentToastKeys = new Map(); const _notifIcons = { success: 'βœ“', error: 'βœ•', warning: '⚠', info: 'β„Ή' }; function showToast(message, type = 'success', helpSection = null) { const toastKey = `${type}:${message}`; const now = Date.now(); // Deduplication β€” suppress identical toasts within 5 seconds if (_recentToastKeys.has(toastKey) && now - _recentToastKeys.get(toastKey) < 5000) return; _recentToastKeys.set(toastKey, now); for (const [k, t] of _recentToastKeys) { if (now - t > 10000) _recentToastKeys.delete(k); } // Add to notification history const entry = { id: now + Math.random(), message, type, helpSection, timestamp: now, read: false }; _notifState.history.unshift(entry); if (_notifState.history.length > _notifState.maxHistory) _notifState.history.pop(); _notifState.unreadCount++; _updateNotifBadge(); // Show compact toast β€” dismiss current if showing const container = document.getElementById('toast-container'); if (!container) return; if (_notifState.currentToast && container.contains(_notifState.currentToast)) { _notifState.currentToast.classList.add('toast-exit'); const old = _notifState.currentToast; setTimeout(() => { if (container.contains(old)) container.removeChild(old); }, 200); } if (_notifState.toastTimer) clearTimeout(_notifState.toastTimer); const icon = _notifIcons[type] || 'β„Ή'; const toast = document.createElement('div'); toast.className = `toast-compact toast-${type}`; toast.innerHTML = `${icon}${_escToast(message)}`; if (helpSection) { const link = document.createElement('span'); link.className = 'toast-compact-link'; link.textContent = 'Learn more β†’'; link.onclick = e => { e.stopPropagation(); if (typeof navigateToDocsSection === 'function') navigateToDocsSection(helpSection); }; toast.appendChild(link); } toast.onclick = () => { toast.classList.add('toast-exit'); setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 200); }; container.appendChild(toast); requestAnimationFrame(() => toast.classList.add('toast-enter')); _notifState.currentToast = toast; _notifState.toastTimer = setTimeout(() => { if (container.contains(toast)) { toast.classList.add('toast-exit'); setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 300); } _notifState.currentToast = null; }, helpSection ? 5000 : 3500); } function _escToast(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } function _escAttr(s) { return _escToast(s).replace(/'/g, "\\'").replace(/\n/g, ' ').replace(/\r/g, ''); } function _updateNotifBadge() { const badge = document.getElementById('notif-bell-badge'); if (badge) { badge.textContent = _notifState.unreadCount > 99 ? '99+' : _notifState.unreadCount; badge.style.display = _notifState.unreadCount > 0 ? '' : 'none'; } } function toggleNotifPanel() { if (_notifState.panelOpen) { _closeNotifPanel(); } else { _openNotifPanel(); } } function _openNotifPanel() { _closeNotifPanel(); // Remove existing _notifState.panelOpen = true; _notifState.unreadCount = 0; _notifState.history.forEach(e => e.read = true); _updateNotifBadge(); const btn = document.getElementById('notif-bell-btn'); const panel = document.createElement('div'); panel.id = 'notif-panel'; panel.className = 'notif-panel'; const entries = _notifState.history; panel.innerHTML = `
Notifications ${entries.length > 0 ? '' : ''}
${entries.length === 0 ? '
No notifications yet
' : entries.map(e => { const icon = _notifIcons[e.type] || 'β„Ή'; const ago = _notifTimeAgo(e.timestamp); const unreadDot = e.read ? '' : ''; const learnMore = e.helpSection ? `Learn more β†’` : ''; return `
${unreadDot} ${icon}
${_escToast(e.message)}
`; }).join('')}
`; document.body.appendChild(panel); // Position above the bell button if (btn) { const rect = btn.getBoundingClientRect(); panel.style.right = (window.innerWidth - rect.right) + 'px'; panel.style.bottom = (window.innerHeight - rect.top + 8) + 'px'; } requestAnimationFrame(() => panel.classList.add('visible')); // Close on outside click setTimeout(() => { const closeHandler = e => { if (!panel.contains(e.target) && e.target.id !== 'notif-bell-btn') { _closeNotifPanel(); document.removeEventListener('click', closeHandler); } }; document.addEventListener('click', closeHandler); }, 100); } function _closeNotifPanel() { _notifState.panelOpen = false; const panel = document.getElementById('notif-panel'); if (panel) { panel.classList.remove('visible'); setTimeout(() => panel.remove(), 200); } } function _clearNotifHistory() { _notifState.history = []; _notifState.unreadCount = 0; _updateNotifBadge(); _closeNotifPanel(); } function _notifTimeAgo(ts) { const s = Math.floor((Date.now() - ts) / 1000); if (s < 5) return 'just now'; if (s < 60) return `${s}s ago`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; return `${Math.floor(h / 24)}d ago`; } // ================================================================================== // Music video download handler β€” defined at top level so both enhanced and global search can use it function _downloadMusicVideo(cardEl, video) { if (cardEl.classList.contains('downloading') || cardEl.classList.contains('completed')) return; cardEl.classList.add('downloading'); cardEl.onclick = null; const playBtn = cardEl.querySelector('.enh-video-play'); const progressRing = cardEl.querySelector('.enh-video-progress-ring'); const progressBar = cardEl.querySelector('.enh-video-progress-bar'); const doneIcon = cardEl.querySelector('.enh-video-done'); const errorIcon = cardEl.querySelector('.enh-video-error'); if (playBtn) playBtn.classList.add('hidden'); if (progressRing) progressRing.classList.remove('hidden'); fetch('/api/music-video/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ video_id: video.video_id, url: video.url, title: video.title, channel: video.channel }), }).then(res => { if (!res.ok) throw new Error('Download request failed'); const circumference = 97.4; const pollInterval = setInterval(async () => { try { const statusRes = await fetch(`/api/music-video/status/${video.video_id}`); const status = await statusRes.json(); if (progressBar && status.progress > 0) { progressBar.style.strokeDashoffset = circumference - (status.progress / 100) * circumference; } if (status.status === 'completed') { clearInterval(pollInterval); cardEl.classList.remove('downloading'); cardEl.classList.add('completed'); if (progressRing) progressRing.classList.add('hidden'); if (doneIcon) doneIcon.classList.remove('hidden'); } else if (status.status === 'error') { clearInterval(pollInterval); cardEl.classList.remove('downloading'); cardEl.classList.add('errored'); if (progressRing) progressRing.classList.add('hidden'); if (errorIcon) errorIcon.classList.remove('hidden'); cardEl.onclick = () => _downloadMusicVideo(cardEl, video); } } catch (e) { } }, 500); }).catch(e => { cardEl.classList.remove('downloading'); if (progressRing) progressRing.classList.add('hidden'); if (playBtn) playBtn.classList.remove('hidden'); if (errorIcon) errorIcon.classList.remove('hidden'); cardEl.onclick = () => _downloadMusicVideo(cardEl, video); }); } // Global search video click β€” decodes base64 video data and delegates to _downloadMusicVideo function _gsClickVideo(cardEl) { try { const encoded = cardEl.dataset.video; const video = JSON.parse(decodeURIComponent(escape(atob(encoded)))); _downloadMusicVideo(cardEl, video); } catch (e) { console.error('Failed to parse video data:', e); } } // GLOBAL SEARCH BAR β€” Spotlight-style search from anywhere // ================================================================================== // Popover-only state. Query/source/cache/config all live in `_gsController` // (shared with the Search page via createSearchController in shared-helpers.js). const _gsState = { active: false, _lastInteraction: 0, debounceTimer: null, }; // Shared source-picker controller β€” built on DOM-ready in `_doInit`. let _gsController = null; (function initGlobalSearch() { // Defer init until DOM is ready const _doInit = () => { const bar = document.getElementById('gsearch-bar'); const input = document.getElementById('gsearch-input'); const results = document.getElementById('gsearch-results'); if (!input || !bar || !results) return; // Build the stable results-panel structure up front so the controller // has a sourceRow element to render into on its first _notify(). results.innerHTML = `
`; _gsController = createSearchController({ sourceRowElement: document.getElementById('gsearch-source-row'), iconClassPrefix: 'gsearch', onStateChange: _gsRenderFromState, onSoulseekSelected: (query) => _gsNavigateToSearchPage(query, 'soulseek'), onUnconfiguredClick: (src) => { _gsDeactivate(); openSettingsForSource(src); }, }); bar.addEventListener('click', () => input.focus()); input.addEventListener('focus', () => { bar.classList.add('active'); const aura = document.getElementById('gsearch-aura'); if (aura) aura.classList.add('active'); _gsState.active = true; const shortcut = document.getElementById('gsearch-shortcut'); if (shortcut) shortcut.style.display = 'none'; // Always redraw on focus so the source icon row is current // (cache dots, active state, etc.). init() is a no-op after the // first call β€” safe to invoke on every focus. _gsController.init().then(() => _gsRenderFromState(_gsController.state)); }); // No blur handler β€” closing is handled by click-outside and Escape only // This prevents tab switching and result clicks from closing the panel const clearBtn = document.getElementById('gsearch-clear'); input.addEventListener('input', () => { const q = input.value.trim(); if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none'; if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer); if (q.length < 2) { _gsHideResults(); return; } _gsState.debounceTimer = setTimeout(() => _gsController.submitQuery(q), 300); }); if (clearBtn) { clearBtn.addEventListener('click', e => { e.stopPropagation(); input.value = ''; clearBtn.style.display = 'none'; // Drop cache so the next search starts clean, but don't // auto-fire a fetch for an empty query. if (_gsController) { _gsController.state.query = ''; _gsController.state.sources = {}; _gsController.state.fallbacks = {}; _gsController.state.loadingSources = new Set(); _gsController.renderSourceRow(); } _gsHideResults(); input.focus(); }); } input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer); const q = input.value.trim(); if (q.length >= 2) _gsController.submitQuery(q); } else if (e.key === 'Escape') { _gsDeactivate(); input.blur(); } }); // Keyboard shortcuts document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); input.focus(); return; } if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); input.focus(); } }); // Click outside to close β€” uses delayed check because tab clicks replace DOM document.addEventListener('click', e => { if (!_gsState.active) return; // Skip if click was recent interaction with search system (within 100ms of a switch) if (_gsState._lastInteraction && Date.now() - _gsState._lastInteraction < 200) return; setTimeout(() => { if (!_gsState.active) return; const freshBar = document.getElementById('gsearch-bar'); const freshResults = document.getElementById('gsearch-results'); const target = e.target; if (freshBar?.contains(target) || freshResults?.contains(target)) return; _gsDeactivate(); }, 100); }); // Collapse on sidebar navigation + hide on downloads page document.addEventListener('click', e => { if (e.target.closest('.sidebar-link, .nav-item, .back-btn')) { if (_gsState.active) _gsDeactivate(); // Check after navigation which page we're on setTimeout(_gsUpdateVisibility, 200); } }); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => { _doInit(); _gsUpdateVisibility(); }); else { _doInit(); setTimeout(_gsUpdateVisibility, 500); } })(); function _gsUpdateVisibility() { const bar = document.getElementById('gsearch-bar'); const aura = document.getElementById('gsearch-aura'); if (!bar) return; // Hide on the Search page where the unified search already exists. Accept the // legacy 'downloads' id for callers that predate the page rename. const onSearchPage = typeof currentPage !== 'undefined' && (currentPage === 'search' || currentPage === 'downloads'); bar.style.display = onSearchPage ? 'none' : ''; if (aura) aura.classList.toggle('hidden', onSearchPage); if (onSearchPage && _gsState.active) _gsDeactivate(); } function _gsDeactivate() { const bar = document.getElementById('gsearch-bar'); const aura = document.getElementById('gsearch-aura'); const shortcut = document.getElementById('gsearch-shortcut'); if (bar) bar.classList.remove('active'); if (aura) aura.classList.remove('active'); if (shortcut) shortcut.style.display = ''; _gsState.active = false; _gsHideResults(); } function _gsHideResults() { const r = document.getElementById('gsearch-results'); if (r) r.classList.remove('visible'); } function _gsShowResults() { const r = document.getElementById('gsearch-results'); if (r && r.innerHTML.trim()) r.classList.add('visible'); } function _gsNavigateToSearchPage(query, src) { _gsDeactivate(); if (typeof navigateToPage !== 'function') return; navigateToPage('search'); // After the page mounts, mirror the query into whichever input drives the // requested source. Soulseek goes through the basic-search file flow, not // the enhanced metadata flow β€” without this branch the Search page would // run /api/enhanced-search instead of /api/search and the user would get // metadata results when they clicked the Soulseek icon. setTimeout(() => { if (src === 'soulseek') { const basicInput = document.getElementById('downloads-search-input'); if (basicInput && query) basicInput.value = query; // Sync the Search page controller's state.query to the widget's // query BEFORE clicking the Soulseek icon. Otherwise the icon // click fires onSoulseekSelected(state.query) where state.query // is whatever the user last typed on /search (often stale), and // the callback would overwrite basicInput.value with that stale // value before running performDownloadsSearch. if (typeof _searchPageController !== 'undefined' && _searchPageController) { _searchPageController.state.query = query || ''; } const soulseekIcon = document.querySelector('#enh-source-row [data-source="soulseek"]'); if (soulseekIcon) { soulseekIcon.click(); return; } // Fallback: controller hasn't initialized yet (slow /api/settings // fetches at first /search visit). Run the search directly + swap // sections so the user still gets results. Icon row will catch up // visually on the next render. const basicSection = document.getElementById('basic-search-section'); const enhancedSection = document.getElementById('enhanced-search-section'); if (basicSection) basicSection.classList.add('active'); if (enhancedSection) enhancedSection.classList.remove('active'); if (basicInput && basicInput.value && typeof performDownloadsSearch === 'function') { performDownloadsSearch(); } return; } const input = document.getElementById('enhanced-search-input'); if (input && query) { input.value = query; input.dispatchEvent(new Event('input', { bubbles: true })); } }, 300); } // Re-render the results body + fallback banner whenever the controller's // state changes (cache hit, fetch settle, query reset). The icon row itself // is rendered by the controller into `#gsearch-source-row`. function _gsRenderFromState(state) { const results = document.getElementById('gsearch-results'); const body = document.getElementById('gsearch-body'); if (!results || !body) return; // Fallback banner β€” independent of body content. const banner = document.getElementById('gsearch-fallback-banner'); const activeSrc = state.activeSource; const actual = state.fallbacks[activeSrc]; if (banner) { if (actual && actual !== activeSrc) { const clicked = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc; const served = (SOURCE_LABELS[actual] || {}).text || actual; banner.textContent = `${clicked} unavailable β€” showing ${served}.`; banner.classList.remove('hidden'); } else { banner.classList.add('hidden'); } } // Soulseek has its own dedicated handler (navigate to /search); there's // nothing to render in the popover. if (activeSrc === 'soulseek') return; const cached = state.sources[activeSrc]; const isLoading = state.loadingSources.has(activeSrc); const query = state.query; // No query yet β€” prompt. if (!query) { body.innerHTML = '
Type to search…
'; results.classList.add('visible'); return; } // In-flight, nothing cached yet β€” loading state. if (isLoading && !cached) { const info = SOURCE_LABELS[activeSrc]; body.innerHTML = `
Searching ${_escToast((info && info.text) || activeSrc)}...
`; results.classList.add('visible'); return; } // No cache, not loading β€” source switch before fetch fired (e.g. empty query). if (!cached) { body.innerHTML = '
Click the source above to search.
'; results.classList.add('visible'); return; } // Music Videos β€” video grid instead of regular sections. if (activeSrc === 'youtube_videos') { const videos = cached.videos || []; let h = `
Results${videos.length} videos
`; h += '
'; if (videos.length === 0) { h += `
No music videos found for "${_escToast(query)}"
`; } else { h += '
🎬 Music Videos
'; h += '
'; h += videos.map(v => { const dur = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : ''; const views = v.view_count >= 1000000 ? `${(v.view_count / 1000000).toFixed(1)}M` : v.view_count >= 1000 ? `${(v.view_count / 1000).toFixed(1)}K` : (v.view_count || ''); const vJson = btoa(unescape(encodeURIComponent(JSON.stringify(v)))); return `
β–Ά
${dur ? `${dur}` : ''}
${_escToast(v.title)}
${_escToast(v.channel)}${views ? ` Β· ${views} views` : ''}
`; }).join(''); h += '
'; } h += '
'; body.innerHTML = h; results.classList.add('visible'); return; } // Standard metadata source β€” library + artists + albums + singles + tracks. const dbArtists = cached.db_artists || []; const artists = cached.artists || []; const allAlbums = cached.albums || []; const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation'); const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep'); const tracks = cached.tracks || []; const total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length; if (total === 0) { body.innerHTML = `
No results for "${_escToast(query)}"
Try different keywords or check spelling
`; results.classList.add('visible'); return; } const srcLabel = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc || ''; let h = ''; h += `
Results${total} items
`; h += '
'; if (dbArtists.length) { h += '
πŸ“š In Your Library
'; h += dbArtists.map(a => `
${a.image_url ? `` : '🎀'}
${_escToast(a.name)}
Library
`).join(''); h += '
'; } if (artists.length) { h += `
🎀 Artists ${srcLabel}
`; h += artists.map(a => `
${a.image_url ? `` : '🎀'}
${_escToast(a.name)}
`).join(''); h += '
'; } if (albums.length) { h += `
πŸ’Ώ Albums ${srcLabel}
`; h += albums.map(a => { const ar = a.artist || (a.artists ? a.artists.join(', ') : ''); const yr = a.release_date ? a.release_date.substring(0, 4) : ''; const img = (a.image_url || '').replace(/'/g, "\\'"); return `
${a.image_url ? `` : 'πŸ’Ώ'}
${_escToast(a.name)}
${_escToast(ar)}${yr ? ` Β· ${yr}` : ''}
`; }).join(''); h += '
'; } if (singles.length) { h += `
🎢 Singles & EPs ${srcLabel}
`; h += singles.map(a => { const ar = a.artist || (a.artists ? a.artists.join(', ') : ''); const img = (a.image_url || '').replace(/'/g, "\\'"); return `
${a.image_url ? `` : '🎢'}
${_escToast(a.name)}
${_escToast(ar)}
`; }).join(''); h += '
'; } if (tracks.length) { h += `
🎡 Tracks ${srcLabel}
`; h += tracks.map(t => { const ar = t.artist || (t.artists ? t.artists.join(', ') : ''); const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; return `
${t.image_url ? `` : '🎡'}
${_escToast(t.name)}
${_escToast(ar)}${t.album ? ` Β· ${_escToast(t.album)}` : ''}
${dur}
`; }).join(''); h += '
'; } h += '
'; body.innerHTML = h; results.classList.add('visible'); // Lazy load artist images for sources that don't provide them (iTunes/Deezer). _gsLazyLoadArtistImages(); // Library ownership check β€” adds "In Library" badges + swaps play buttons. // Idempotent enough to run on every render with a cache hit; the old flow // also fired it on both cache-hit and fetch-settle. setTimeout(() => _gsLibraryCheck(), 200); } async function _gsLazyLoadArtistImages() { const grid = document.getElementById('gsearch-artists-grid'); if (!grid) return; const cards = grid.querySelectorAll('[data-needs-image="true"]'); if (cards.length === 0) return; const activeSrc = (_gsController && _gsController.state.activeSource) || 'spotify'; for (const card of cards) { const artistId = card.dataset.artistId; if (!artistId) continue; try { // Pass the artist name so MusicBrainz lookups (which have no // artist art) can resolve the image by name on a fallback source. const params = new URLSearchParams({ source: activeSrc }); if (card.dataset.artistName) params.set('name', card.dataset.artistName); const res = await fetch(`/api/artist/${artistId}/image?${params}`); const data = await res.json(); if (data.success && data.image_url) { const artDiv = card.querySelector('.gsearch-item-art'); if (artDiv) artDiv.innerHTML = ``; card.removeAttribute('data-needs-image'); } } catch (e) { /* ignore */ } } } function _gsClickArtist(id, name, isLibrary) { _gsDeactivate(); const activeSource = _gsController && _gsController.state.activeSource; const source = isLibrary ? null : (activeSource || null); navigateToArtistDetail(id, name, source); } async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) { _gsDeactivate(); // Same flow as handleEnhancedSearchAlbumClick β€” fetch album, open download modal showLoadingOverlay('Loading album...'); try { const params = new URLSearchParams({ name: albumName, artist: artistName }); if (source && source !== 'spotify') params.set('source', source); const response = await fetch(`/api/spotify/album/${albumId}?${params}`); if (!response.ok) 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 "${albumName}"`, 'warning'); return; } const enrichedTracks = albumData.tracks.map(t => ({ ...t, 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 } })); const virtualPlaylistId = `enhanced_search_album_${albumId}`; const firstArtist = (albumData.artists || [])[0] || {}; const artistObj = { id: firstArtist.id || '', name: firstArtist.name || artistName, source: source || '' }; const albumObj = { 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: artistName }] }; await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, `[${artistName}] ${albumData.name}`, enrichedTracks, albumObj, artistObj, false); // Register download bubble (same pattern as enhanced search) registerSearchDownload( { id: albumData.id, name: albumData.name, artist: artistName, image_url: albumData.images?.[0]?.url || imageUrl || null, images: albumData.images || [] }, 'album', virtualPlaylistId, artistName ); } catch (e) { hideLoadingOverlay(); showToast('Failed to load album: ' + e.message, 'error'); } } async function _gsClickTrack(artistName, trackName, albumName, trackId, imageUrl, durationMs) { _gsDeactivate(); // Build enriched track + open download modal directly (same as enhanced search) const virtualPlaylistId = `gsearch_track_${trackId || (artistName + '_' + trackName).replace(/\s/g, '_')}`; const enrichedTrack = { id: trackId || '', name: trackName, artists: [artistName], album: { name: albumName || '', id: null, album_type: 'single', images: imageUrl ? [{ url: imageUrl }] : [], total_tracks: 1 }, duration_ms: durationMs || 0, image_url: imageUrl || '', }; const albumObject = { name: albumName || '', id: null, album_type: 'single', images: imageUrl ? [{ url: imageUrl }] : [], artists: [{ name: artistName }], total_tracks: 1, }; const artistObject = { id: null, name: artistName }; const playlistName = `${artistName} - ${trackName}`; try { showLoadingOverlay('Loading track...'); await openDownloadMissingModalForArtistAlbum( virtualPlaylistId, playlistName, [enrichedTrack], albumObject, artistObject, false ); // Register download bubble (same pattern as enhanced search) registerSearchDownload( { id: trackId || '', name: trackName, artist: artistName, image_url: imageUrl || null, images: imageUrl ? [{ url: imageUrl }] : [] }, 'track', virtualPlaylistId, artistName ); } catch (e) { console.error('Error opening track download:', e); // Fallback: navigate to the unified Search page navigateToPage('search'); setTimeout(() => { const input = document.getElementById('enhanced-search-input'); if (input) { input.value = `${artistName} ${trackName}`.trim(); input.dispatchEvent(new Event('input')); } }, 300); } finally { hideLoadingOverlay(); } } async function _gsPlayTrack(trackName, artistName, albumName) { try { showToast('Searching for stream...', 'info'); const res = await fetch('/api/enhanced-search/stream-track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track_name: trackName, artist_name: artistName, album_name: albumName }) }); const data = await res.json(); if (data.success && data.result) { if (typeof startStream === 'function') { startStream(data.result); } else { showToast('Streaming not available', 'error'); } } else { showToast(data.error || 'No stream found', 'error'); } } catch (e) { showToast('Stream failed: ' + e.message, 'error'); } } // Async library check for global search results β€” adds badges + swaps play buttons async function _gsLibraryCheck() { try { if (!_gsController) return; const src = _gsController.state.sources[_gsController.state.activeSource] || {}; const allAlbums = src.albums || []; const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation'); const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep'); const tracks = src.tracks || []; if (!allAlbums.length && !tracks.length) return; const res = 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 || (a.artists ? a.artists.join(', ') : '') })), tracks: tracks.map(t => ({ name: t.name, artist: t.artist || (t.artists ? t.artists.join(', ') : '') })), }) }); const checkData = await res.json(); // Add "In Library" badges to albums β€” match by index against allAlbums order const albumResults = checkData.albums || []; let albumIdx = 0; // Albums section document.querySelectorAll('#gsearch-results .gsearch-results-body').forEach(body => { // Find all gsearch-item elements and tag ones that are albums const sections = body.querySelectorAll('.gsearch-section-header'); sections.forEach(header => { const text = header.textContent; const isAlbumSection = text.includes('Albums') || text.includes('Singles'); if (!isAlbumSection) return; const grid = header.nextElementSibling; if (!grid) return; const items = grid.querySelectorAll('.gsearch-item'); items.forEach(item => { if (albumIdx < albumResults.length && albumResults[albumIdx]) { if (!item.querySelector('.gsearch-item-badge')) { const badge = document.createElement('span'); badge.className = 'gsearch-item-badge'; badge.textContent = 'In Library'; item.appendChild(badge); } } albumIdx++; }); }); }); // Tag tracks + swap play buttons for library playback const trackResults = checkData.tracks || []; const trackEls = document.querySelectorAll('#gsearch-results .gsearch-track'); trackEls.forEach((el, i) => { const tr = trackResults[i]; if (tr && tr.in_library) { // Add badge if (!el.querySelector('.gsearch-item-badge')) { const badge = document.createElement('span'); badge.className = 'gsearch-item-badge'; badge.textContent = 'In Library'; badge.style.marginRight = '4px'; el.querySelector('.gsearch-track-dur')?.before(badge); } // Swap play button to library playback if (tr.file_path) { const playBtn = el.querySelector('.gsearch-play-btn'); if (playBtn) { const newBtn = playBtn.cloneNode(true); newBtn.removeAttribute('onclick'); newBtn.title = 'Play from library'; newBtn.style.background = 'rgba(76,175,80,0.15)'; newBtn.style.color = '#4caf50'; newBtn.addEventListener('click', e => { e.stopPropagation(); playLibraryTrack( { id: tr.track_id, title: tr.title, file_path: tr.file_path, _stats_image: tr.album_thumb_url || null }, tr.album_title || '', tr.artist_name || '' ); }); playBtn.replaceWith(newBtn); } } } else if (tr && tr.in_wishlist) { if (!el.querySelector('.gsearch-item-badge')) { const badge = document.createElement('span'); badge.className = 'gsearch-item-badge gsearch-wishlist-badge'; badge.textContent = 'In Wishlist'; badge.style.marginRight = '4px'; el.querySelector('.gsearch-track-dur')?.before(badge); } } }); } catch (e) { // Non-critical } } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Escape a value for safe use inside a single-quoted JS string literal * within a double-quoted HTML attribute (e.g. onclick="fn('${val}')"). * * Layer 1 (JS): escape \ and ' so the JS string parses correctly. * Layer 2 (HTML): escape &, ", <, > so the HTML attribute parses correctly. * The browser applies these in reverse: HTML-decode first, then JS-execute. */ function escapeForInlineJs(str) { if (str == null) return ''; return String(str) .replace(/\\/g, '\\\\') // JS: literal backslash .replace(/'/g, "\\'") // JS: single quote .replace(/&/g, '&') // HTML: ampersand .replace(/"/g, '"') // HTML: double quote .replace(//g, '>'); // HTML: greater-than } function formatArtists(artists) { if (!artists || !Array.isArray(artists)) { return 'Unknown Artist'; } // Handle both string arrays and object arrays with 'name' property const artistNames = artists.map(artist => { let artistName; if (typeof artist === 'string') { artistName = artist; } else if (artist && typeof artist === 'object' && artist.name) { artistName = artist.name; } else { artistName = 'Unknown Artist'; } // Clean featured artists from the name return cleanArtistName(artistName); }); return artistNames.join(', ') || 'Unknown Artist'; } async function checkForUpdates() { try { const res = await fetch('/api/update-check'); if (!res.ok) return; const data = await res.json(); const btn = document.querySelector('.version-button'); if (!btn) return; if (data.update_available) { const dismissed = localStorage.getItem('soulsync-update-dismissed'); if (dismissed !== data.latest_sha) { // Add glow class btn.classList.add('update-available'); // Add UPDATE badge if not already present if (!btn.querySelector('.update-badge')) { const badge = document.createElement('span'); badge.className = 'update-badge'; badge.textContent = 'UPDATE'; btn.appendChild(badge); } // Show toast on first detection (not if already notified this session) const notified = sessionStorage.getItem('soulsync-update-notified'); if (notified !== data.latest_sha) { sessionStorage.setItem('soulsync-update-notified', data.latest_sha); showToast(data.is_docker ? 'A new SoulSync update has been pushed to the repo β€” Docker image will be updated soon!' : 'A new SoulSync update is available!', 'info'); } } } else { btn.classList.remove('update-available'); const badge = btn.querySelector('.update-badge'); if (badge) badge.remove(); } } catch (e) { console.debug('Update check failed:', e); } } async function showVersionInfo() { // Check update status before dismissing so we can pass it to the modal let updateInfo = null; const btn = document.querySelector('.version-button'); const hadUpdate = btn && btn.classList.contains('update-available'); // Dismiss update glow when user opens the modal if (hadUpdate) { btn.classList.remove('update-available'); const badge = btn.querySelector('.update-badge'); if (badge) badge.remove(); try { const updateRes = await fetch('/api/update-check'); if (updateRes.ok) { updateInfo = await updateRes.json(); if (updateInfo.latest_sha) { localStorage.setItem('soulsync-update-dismissed', updateInfo.latest_sha); } } } catch (e) { /* ignore */ } } // Build version data straight from helper.js β€” single source of truth. // No backend round-trip; the changelog content is shipped in the same // bundle the browser already loaded. const version = (typeof _getCurrentVersion === 'function') ? _getCurrentVersion() : (btn ? btn.textContent.trim().replace('v', '') : ''); const sections = (typeof VERSION_MODAL_SECTIONS !== 'undefined') ? VERSION_MODAL_SECTIONS : []; const versionData = { version, title: "What's New in SoulSync", subtitle: version ? `Version ${version} β€” Latest Changes` : 'Latest Changes', sections, }; try { populateVersionModal(versionData, hadUpdate ? updateInfo : null); const modalOverlay = document.getElementById('version-modal-overlay'); if (modalOverlay) modalOverlay.classList.remove('hidden'); } catch (error) { console.error('Error showing version info:', error); showToast('Failed to load version information', 'error'); } } function closeVersionModal() { const modalOverlay = document.getElementById('version-modal-overlay'); modalOverlay.classList.add('hidden'); console.log('Version modal closed'); } function populateVersionModal(versionData, updateInfo) { const container = document.getElementById('version-content-container'); if (!container) { console.error('Version content container not found'); return; } // Update header with dynamic data const titleElement = document.querySelector('.version-modal-title'); const subtitleElement = document.querySelector('.version-modal-subtitle'); if (titleElement) titleElement.textContent = versionData.title; if (subtitleElement) subtitleElement.textContent = versionData.subtitle; // Clear existing content container.innerHTML = ''; // Show update banner if an update was available when modal was opened if (updateInfo && updateInfo.update_available) { const banner = document.createElement('div'); banner.className = 'version-update-banner'; const isDocker = updateInfo.is_docker; banner.innerHTML = `
${isDocker ? 'Repo update detected' : 'New update available'} ${isDocker ? 'A new update has been pushed to the repo. The Docker image will be updated soon β€” no action needed yet.' : `Your version: ${updateInfo.current_sha || 'unknown'} → Latest: ${updateInfo.latest_sha || 'unknown'}`}
`; container.appendChild(banner); } // Create sections versionData.sections.forEach(section => { const sectionDiv = document.createElement('div'); sectionDiv.className = 'version-feature-section'; // Section title const titleDiv = document.createElement('div'); titleDiv.className = 'version-section-title'; titleDiv.textContent = section.title; sectionDiv.appendChild(titleDiv); // Section description const descDiv = document.createElement('div'); descDiv.className = 'version-section-description'; descDiv.textContent = section.description; sectionDiv.appendChild(descDiv); // Features list const featuresList = document.createElement('ul'); featuresList.className = 'version-feature-list'; section.features.forEach(feature => { const featureItem = document.createElement('li'); featureItem.className = 'version-feature-item'; featureItem.textContent = feature; featuresList.appendChild(featureItem); }); sectionDiv.appendChild(featuresList); // Usage note (if present) if (section.usage_note) { const usageDiv = document.createElement('div'); usageDiv.className = 'version-usage-note'; usageDiv.textContent = `πŸ’‘ ${section.usage_note}`; sectionDiv.appendChild(usageDiv); } container.appendChild(sectionDiv); }); console.log('Version modal content populated'); } // =============================== // ADDITIONAL STYLES FOR SEARCH RESULTS // =============================== // Add dynamic styles for search results (since they're created dynamically) const additionalStyles = ` `; // Inject additional styles document.head.insertAdjacentHTML('beforeend', additionalStyles); // ============================================================================