// WING IT β€” Download without metadata discovery // ================================================================================== // Blocklist (Phase 2b): when a download is refused because the artist/album/track // is on the blocklist, the backend returns {blocked:true,...}. Ask the user // whether to override; callers re-POST with ignore_blocklist:true on confirm. function confirmBlockedDownload(data) { const what = data.blocked_entity_type || 'item'; const name = data.blocked_name || 'this item'; return confirm(`"${name}" is on your blocklist (${what} blocked).\n\nDownload anyway?`); } 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 isQobuz = state.is_qobuz_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' : isQobuz ? 'Qobuz' : 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 isQobuz = state.is_qobuz_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' : isQobuz ? 'Qobuz' : 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'; } if (typeof refreshOrganizePreferenceForDownloadModal === 'function') { await refreshOrganizePreferenceForDownloadModal(virtualPlaylistId); } 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: { ...artist, name: artist.name, id: artist.id || artist.artist_id || null, source: artist.source || album.source || null, 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} ${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} ${formatDuration(track.duration_ms)} πŸ” Pending - -
`; applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length); modal.style.display = 'flex'; hideLoadingOverlay(); } 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'); } // ── #874: Wishlist ignore-list ("Ignored") modal ──────────────────────── // Tracks the user removed from the wishlist or cancelled mid-download are // auto-skipped (not re-queued) until they expire. This modal lets the user // see what's currently ignored and lift the skip (un-ignore / clear all). async function openWishlistIgnoreModal() { let modal = document.getElementById('wishlist-ignore-modal'); if (modal) modal.remove(); modal = document.createElement('div'); modal.id = 'wishlist-ignore-modal'; modal.className = 'modal-overlay'; modal.style.cssText = 'display:flex;position:fixed;inset:0;z-index:10050;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);'; modal.innerHTML = `

🚫 Ignored Tracks

Removed or cancelled tracks are skipped by auto-download until they expire. Un-ignore to allow auto-download again (you can always download manually).

Loading...
`; document.body.appendChild(modal); modal.addEventListener('click', (e) => { if (e.target === modal) closeWishlistIgnoreModal(); }); await loadWishlistIgnoreList(); } function closeWishlistIgnoreModal() { const modal = document.getElementById('wishlist-ignore-modal'); if (modal) modal.remove(); } async function loadWishlistIgnoreList() { const list = document.getElementById('wishlist-ignore-list'); const clearBtn = document.getElementById('wishlist-ignore-clear-btn'); if (!list) return; try { const resp = await fetch('/api/wishlist/ignore-list'); const data = await resp.json(); const entries = (data && data.entries) || []; if (clearBtn) clearBtn.style.display = entries.length ? '' : 'none'; if (!entries.length) { list.innerHTML = '
πŸŽ‰

Nothing ignored.
'; return; } const ttl = (data && data.ttl_days) || 30; list.innerHTML = entries.map(e => { const title = escapeHtml(e.track_name || e.track_id || 'Unknown'); const artist = escapeHtml(e.artist_name || ''); const reason = e.reason === 'cancelled' ? 'Cancelled' : 'Removed'; const tid = escapeHtml(String(e.track_id || '')); return `
${title}
${artist}${artist ? ' Β· ' : ''}${reason} Β· skips ${ttl}d
`; }).join(''); } catch (err) { list.innerHTML = '
Error loading ignored tracks
'; } } async function unignoreWishlistTrack(trackId) { if (!trackId) return; try { const resp = await fetch('/api/wishlist/ignore-list/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track_id: trackId }), }); const data = await resp.json(); if (data && data.success) { showToast('Track un-ignored β€” it can be auto-downloaded again.', 'success'); await loadWishlistIgnoreList(); } else { showToast(`Un-ignore failed: ${(data && data.error) || 'unknown'}`, 'error'); } } catch (err) { showToast(`Un-ignore failed: ${err.message}`, 'error'); } } async function clearWishlistIgnoreList() { if (!await showConfirmDialog({ title: 'Clear Ignored List', message: 'Allow all currently-ignored tracks to be auto-downloaded again?', confirmText: 'Clear All', cancelText: 'Cancel', })) return; try { const resp = await fetch('/api/wishlist/ignore-list/clear', { method: 'POST' }); const data = await resp.json(); if (data && data.success) { showToast(`Cleared ${data.cleared || 0} ignored track(s).`, 'success'); await loadWishlistIgnoreList(); } else { showToast(`Clear failed: ${(data && data.error) || 'unknown'}`, 'error'); } } catch (err) { showToast(`Clear failed: ${err.message}`, 'error'); } } 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 => { const safeTrackId = escapeHtml(track.spotifyTrackId || ''); const safeTrackName = escapeHtml(track.name || 'Unknown Track'); return `
${safeTrackName}
`; }).join(''); // Handle missing album images with a placeholder const safeAlbumId = escapeHtml(albumId); const safeAlbumName = escapeHtml(albumData.albumName || 'Unknown Album'); const safeArtistName = escapeHtml(albumData.artistName || 'Unknown Artist'); const safeAlbumImage = escapeHtml(albumData.albumImage || '').replace(/'/g, '''); const albumImageStyle = albumData.albumImage ? `background-image: url('${safeAlbumImage}')` : `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}
${safeAlbumName}
${safeArtistName}
${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 || ''; const safeTrackId = escapeHtml(spotifyTrackId); const safeTrackName = escapeHtml(trackName); const safeArtistName = escapeHtml(artistName); const safeAlbumName = escapeHtml(albumName); const safeAlbumImage = escapeHtml(albumImage).replace(/'/g, '''); tracksHTML += `
${safeTrackName}
${safeArtistName} β€’ ${safeAlbumName}
`; }); 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} ${renderModalTrackPlayButton(playlistId, index)}${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; // Issue #797 β€” per-request "Skip AcoustID verification" toggle. Absent // checkbox (other call sites) β†’ false, so behavior is unchanged there. const skipAcoustidCheckbox = document.getElementById(`skip-acoustid-${playlistId}`); const skipAcoustid = skipAcoustidCheckbox ? skipAcoustidCheckbox.checked : false; // Check if playlist folder mode toggle is enabled (only for sync page playlists) const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function' ? isPlaylistOrganizeEnabled(playlistId) : (document.getElementById(`playlist-folder-mode-${playlistId}`)?.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, ignore_manual_matches: forceDownloadAll, wing_it: isWingIt, skip_acoustid: skipAcoustid, }; // 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}`); } } let response = await fetch(`/api/playlists/${playlistId}/start-missing-process`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); let data = await response.json(); // Blocklist (Phase 2b): whole album/artist is blocked β†’ confirm override. if (data.blocked) { if (!confirmBlockedDownload(data)) { showToast(`Skipped β€” ${data.blocked_name} is blocklisted`, 'info'); return; } requestBody.ignore_blocklist = true; response = await fetch(`/api/playlists/${playlistId}/start-missing-process`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); 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'}`; } } } function getModalTrackArtistName(track, fallbackArtist = '') { const formatted = formatArtists(track?.artists); if (formatted && formatted !== 'Unknown Artist') return formatted; return track?.artist_name || track?.artist || fallbackArtist || formatted || ''; } function getModalTrackAlbumTitle(track, process = null) { if (track?.album) { if (typeof track.album === 'string') return track.album; if (track.album.name) return track.album.name; if (track.album.title) return track.album.title; } if (process?.album) { return process.album.name || process.album.title || ''; } return ''; } function renderModalTrackPlayButton(playlistId, trackIndex) { return ``; } async function playTrackFromLibraryOrStream(track, albumTitle = '', artistName = '') { const title = track?.title || track?.name || ''; if (!title) { showToast('No track title available to play', 'error'); return; } if (track?.file_path && typeof playLibraryTrack === 'function') { await playLibraryTrack({ id: track.id || track.track_id || null, title, file_path: track.file_path, _stats_image: track._stats_image || track.album_thumb_url || null, bitrate: track.bitrate, artist_id: track.artist_id, album_id: track.album_id }, albumTitle, artistName); return; } try { const res = await fetch('/api/stats/resolve-track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, artist: artistName }) }); const data = await res.json(); if (data.success && data.track && data.track.file_path && typeof playLibraryTrack === 'function') { await playLibraryTrack({ ...data.track, title: data.track.title || title, _stats_image: data.track.album_thumb_url || data.track.artist_thumb_url || null }, data.track.album_title || albumTitle, data.track.artist_name || artistName); return; } } catch (e) { console.debug('Library resolve failed before stream fallback:', e); } if (typeof _gsPlayTrack === 'function') { await _gsPlayTrack(title, artistName, albumTitle); } else { showToast('Playback is not available here', 'error'); } } async function playDownloadModalTrack(playlistId, trackIndex) { const process = activeDownloadProcesses[playlistId]; const track = process?.tracks?.[trackIndex] || playlistTrackCache[playlistId]?.[trackIndex]; if (!track) { showToast('Track is no longer available in this modal', 'error'); return; } await playTrackFromLibraryOrStream( track, getModalTrackAlbumTitle(track, process), getModalTrackArtistName(track, process?.artist?.name || '') ); } // ============================================================================ // 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) return; // Decide at click-time from dataset set each render: completed and // quarantined rows open the rich track-detail modal (it carries the // play/listen + Accept/Search actions); plain failed/not-found go // straight to the search modal. if (this.dataset.detailOpen && typeof openTrackDetail === 'function') { openTrackDetail(taskId); } else { 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); } } // Format helpers used by both auto-candidates and manual-search rendering. function _candidatesFmtSize(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]}`; } function _candidatesFmtDur(ms) { if (!ms) return '-'; const sec = Math.floor(ms / 1000); return `${Math.floor(sec / 60)}:${(sec % 60).toString().padStart(2, '0')}`; } // Build a single for the candidates table. ``rowClass`` lets the // manual-search renderer distinguish its rows from the auto-candidates // rows (different click binding scope). ``showSourceBadge`` adds a small // per-row source pill β€” used in hybrid "All sources" mode where the user // otherwise can't tell which source a row came from. // Display label for a candidate's filename. Encoded ``id||title`` sources // (youtube/tidal/qobuz/hifi) carry the title after ``||`` β€” a '/' in that title // is part of the name, NOT a path separator, so it must not be basename-split // (issue #835: "YouSeeBIGGIRL/T:T" was showing as just "T:T"). Real file paths // (Soulseek) keep the rightmost-segment basename. function _ssShortFileLabel(filename) { if (!filename) return '-'; if (filename.includes('||')) return filename.split('||').slice(1).join('||'); return filename.split(/[/\\]/).pop(); } function _renderCandidateRow(c, index, rowClass, showSourceBadge) { const shortFile = _ssShortFileLabel(c.filename); const qBadge = c.quality ? `${c.quality.toUpperCase()}` : ''; const sourceBadge = (showSourceBadge && c.source) ? `${escapeHtml(c.source)} ` : ''; return ` ${index + 1} ${sourceBadge}${escapeHtml(shortFile)} ${qBadge}${c.bitrate ? ` ${c.bitrate}kbps` : ''} ${_candidatesFmtSize(c.size)} ${_candidatesFmtDur(c.duration)} ${escapeHtml(c.username || '-')} `; } 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 downloadMode = data.download_mode || 'soulseek'; const availableSources = Array.isArray(data.available_sources) ? data.available_sources : []; // Hybrid mode shows the dropdown; everything else implies a single source. const isHybrid = downloadMode === 'hybrid'; let tableRows = ''; if (candidates.length === 0) { tableRows = ` No candidates were found during search.`; } else { // Auto-candidates only show source badges in hybrid mode (where the // user can't infer source from the dropdown). candidates.forEach((c, i) => { tableRows += _renderCandidateRow(c, i, 'candidates-row-auto', isHybrid); }); } // ----- Manual search bar ----- let sourceControl; if (isHybrid && availableSources.length > 0) { const optionsHtml = [''] .concat(availableSources.map(s => `` )) .join(''); sourceControl = ``; } else { // Single-source mode β€” render a small static label, not a dropdown. const onlySrc = availableSources[0]; const label = onlySrc ? onlySrc.label : (downloadMode || 'configured source'); sourceControl = `Searching ${escapeHtml(label)}`; } const manualSearchHtml = ` `; 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)}
` : ''} ${manualSearchHtml}
${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 auto-candidate download buttons (existing behavior, scoped to // .candidates-row-auto so manual-search rows don't double-trigger). overlay.querySelectorAll('.candidates-row-auto .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); }); }); // Wire manual search controls. _wireManualSearch(overlay, data.task_id, trackName, isHybrid); } // Manual-search wiring β€” input/button/dropdown. Kept separate from // _renderCandidatesModal so the existing render path stays readable and // any future refactor can lift this into its own module. function _wireManualSearch(overlay, taskId, trackName, isHybrid) { const input = overlay.querySelector('#candidates-manual-search-input'); const button = overlay.querySelector('#candidates-manual-search-btn'); const hint = overlay.querySelector('#candidates-manual-search-hint'); const resultsContainer = overlay.querySelector('#candidates-manual-search-results'); const sourceSelect = overlay.querySelector('#candidates-manual-source'); if (!input || !button || !resultsContainer) return; // Aggregated results across all source streams for the current query. // Cleared at the start of each new search. let currentResults = []; let inFlight = false; let abortController = null; const updateButtonState = () => { const q = (input.value || '').trim(); const tooShort = q.length < 2; button.disabled = tooShort || inFlight; if (hint) { if (tooShort) { hint.textContent = 'Type at least 2 characters'; hint.style.display = ''; } else { hint.style.display = 'none'; } } }; const _renderTableShell = (query) => { resultsContainer.innerHTML = `
Searching...
`; }; const _appendRows = (newCandidates, query) => { if (!newCandidates || newCandidates.length === 0) return; const startIdx = currentResults.length; currentResults = currentResults.concat(newCandidates); const wrapper = resultsContainer.querySelector('#candidates-manual-table-wrapper'); const tbody = resultsContainer.querySelector('#candidates-manual-tbody'); const statusEl = resultsContainer.querySelector('#candidates-manual-search-status'); if (!tbody || !wrapper) return; let rowsHtml = ''; newCandidates.forEach((c, i) => { rowsHtml += _renderCandidateRow(c, startIdx + i, 'candidates-row-manual', isHybrid); }); tbody.insertAdjacentHTML('beforeend', rowsHtml); wrapper.style.display = ''; if (statusEl) { statusEl.textContent = `${currentResults.length} result${currentResults.length !== 1 ? 's' : ''} so far...`; } // Wire newly-appended buttons tbody.querySelectorAll('.candidates-download-btn').forEach(btn => { if (btn._candidatesWired) return; btn._candidatesWired = true; btn.addEventListener('click', () => { const idx = parseInt(btn.dataset.index); const c = currentResults[idx]; if (c) downloadCandidate(taskId, c, trackName); }); }); }; const _setStatus = (text) => { const statusEl = resultsContainer.querySelector('#candidates-manual-search-status'); if (statusEl) statusEl.textContent = text; }; const _setError = (msg) => { resultsContainer.innerHTML = `
${escapeHtml(msg)}
`; }; const runSearch = async () => { const q = (input.value || '').trim(); if (q.length < 2 || inFlight) return; if (abortController) { try { abortController.abort(); } catch (_) { } } abortController = new AbortController(); const source = sourceSelect ? sourceSelect.value : 'all'; inFlight = true; button.disabled = true; const originalLabel = button.textContent; button.textContent = 'Searching...'; currentResults = []; _renderTableShell(q); try { const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/manual-search`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, source: source }), signal: abortController.signal, }); if (!resp.ok) { let errMsg = 'Search failed'; try { const payload = await resp.json(); if (payload && payload.error) errMsg = payload.error; } catch (_) { } _setError(errMsg); return; } const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; const errors = []; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let lineEnd; while ((lineEnd = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, lineEnd).trim(); buffer = buffer.slice(lineEnd + 1); if (!line) continue; let msg; try { msg = JSON.parse(line); } catch (_) { continue; } if (msg.type === 'source_results') { _appendRows(msg.candidates || [], q); } else if (msg.type === 'source_error') { errors.push(`${msg.source}: ${msg.error}`); } else if (msg.type === 'done') { if (currentResults.length === 0) { const errorNote = errors.length ? `
${errors.length} source${errors.length !== 1 ? 's' : ''} failed
` : ''; resultsContainer.innerHTML = `
No manual search results for "${escapeHtml(q)}"
${errorNote}`; } else { _setStatus(`${currentResults.length} result${currentResults.length !== 1 ? 's' : ''}`); } } } } } catch (err) { if (err.name === 'AbortError') return; console.error('Manual search failed:', err); _setError('Search request failed'); } finally { inFlight = false; button.textContent = originalLabel; updateButtonState(); } }; input.addEventListener('input', updateButtonState); input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !button.disabled) { e.preventDefault(); runSearch(); } }); button.addEventListener('click', runSearch); updateButtonState(); } 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'); } } async function approveQuarantineFromDownloadRow(button) { const entryId = button?.dataset?.entryId || ''; const taskId = button?.dataset?.taskId || ''; if (!entryId) { showToast('Open Quarantine to approve this file.', 'warning'); return; } const confirmed = await showConfirmDialog({ title: 'Approve Quarantined File', message: 'Import this quarantined file and skip quarantine checks for this approved pass?', confirmText: 'Approve & Import', cancelText: 'Cancel', }); if (!confirmed) return; const originalText = button.textContent; button.disabled = true; button.textContent = 'Approving...'; try { const response = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId }), }); const data = await response.json(); if (data.success) { showToast('Approved quarantined file. Re-running post-processing.', 'success'); } else { showToast(`Approve failed: ${data.error || 'Unknown error'}`, 'error'); button.disabled = false; button.textContent = originalText; } } catch (error) { showToast(`Approve failed: ${error.message}`, 'error'); button.disabled = false; button.textContent = originalText; } } // Quarantine actions (Listen / Accept & Import / Search) now live in the // track-detail modal (static/track-detail.js), which a quarantined row opens // via _ensureCandidatesClickListener + dataset.detailOpen. function closeCandidatesModal() { const overlay = document.getElementById('candidates-modal-overlay'); if (overlay) { overlay.classList.remove('visible'); setTimeout(() => overlay.remove(), 300); } } function _downloadModalBundleProgressPercent(bundle) { if (!bundle) return 0; const raw = bundle.progress_percent ?? bundle.progress ?? 0; let progress = Number(raw); if (!Number.isFinite(progress)) progress = 0; if (progress <= 1) progress *= 100; return Math.max(0, Math.min(100, Math.round(progress))); } function _downloadModalFormatBytes(bytes) { const value = Number(bytes); if (!Number.isFinite(value) || value <= 0) return ''; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let size = value; let unit = 0; while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; } const decimals = size >= 10 || unit === 0 ? 0 : 1; return `${size.toFixed(decimals)} ${units[unit]}`; } function _downloadModalFormatSpeed(bytesPerSecond) { const formatted = _downloadModalFormatBytes(bytesPerSecond); return formatted ? `${formatted}/s` : ''; } function _downloadModalSourceLabel(source) { const labels = { torrent: 'Torrent', usenet: 'Usenet', soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', amazon: 'Amazon', lidarr: 'Lidarr', soundcloud: 'SoundCloud' }; const key = String(source || '').toLowerCase(); return labels[key] || (source ? String(source) : 'Release'); } function _downloadModalBundleStateLabel(state) { const labels = { searching: 'searching for release', downloading: 'downloading release', staged: 'matching tracks', failed: 'release failed' }; const key = String(state || '').toLowerCase(); return labels[key] || (state ? String(state).replace(/_/g, ' ') : 'downloading release'); } function _downloadModalBundleProgressText(bundle) { const percent = _downloadModalBundleProgressPercent(bundle); const source = _downloadModalSourceLabel(bundle && bundle.source); const state = _downloadModalBundleStateLabel(bundle && bundle.state); const release = bundle && bundle.release ? ` - ${bundle.release}` : ''; const speed = _downloadModalFormatSpeed(bundle && bundle.speed); const size = _downloadModalFormatBytes(bundle && bundle.size); const detail = speed || size ? ` (${[speed, size].filter(Boolean).join(' of ')})` : ''; return `${source} ${state} ${percent}%${release}${detail}`; } 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 === 'queued') { // Submitted to the executor but no worker has picked it up yet. // ``missing_download_executor`` is bounded (max_workers=3 by // default) so wishlist runs with N > 3 sub-batches park the // rest at this phase. Show distinct text so users don't think // 26 batches are all in-flight at once. const total = data.analysis_progress?.total || 0; const elText = document.getElementById(`analysis-progress-text-${playlistId}`); const elFill = document.getElementById(`analysis-progress-fill-${playlistId}`); if (elText) elText.textContent = `Queued β€” waiting for worker (${total} tracks)`; if (elFill) elFill.style.width = '0%'; } else 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 === 'album_downloading') { const analysisFill = document.getElementById(`analysis-progress-fill-${playlistId}`); const analysisText = document.getElementById(`analysis-progress-text-${playlistId}`); if (analysisFill) analysisFill.style.width = '100%'; if (analysisText) analysisText.textContent = 'Analysis complete!'; const bundle = data.album_bundle || {}; const percent = _downloadModalBundleProgressPercent(bundle); const downloadFill = document.getElementById(`download-progress-fill-${playlistId}`); const downloadText = document.getElementById(`download-progress-text-${playlistId}`); if (downloadFill) downloadFill.style.width = `${percent}%`; if (downloadText) { downloadText.textContent = _downloadModalBundleProgressText(bundle); downloadText.title = 'SoulSync downloads one album release first, then matches the selected tracks from the staged files.'; } const modal = document.getElementById(`download-missing-modal-${playlistId}`); if (modal) { modal.querySelectorAll('[id^="download-"]').forEach(statusEl => { if (!statusEl.id.startsWith(`download-${playlistId}-`)) return; if (!statusEl.textContent || statusEl.textContent === '-' || statusEl.textContent.includes('Pending')) { statusEl.textContent = 'Waiting for release'; statusEl.classList.add('album-bundle-waiting'); statusEl.title = 'The album release is downloading first. Tracks will move to processing once SoulSync can match files from it.'; } }); } } 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 = ''; let isQuarantinedTask = false; // 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...'; // Quarantine-retry engine: show which attempt we're on // ("retry 2/5") while it walks the next-best candidates. if (task.retry_info) statusText += ` πŸ” retry ${task.retry_info}`; break; case 'downloading': statusText = `⏬ Downloading... ${Math.round(task.progress || 0)}%`; if (task.retry_info) statusText += ` πŸ” retry ${task.retry_info}`; break; case 'post_processing': statusText = 'βŒ› Processing...'; break; case 'completed': { statusText = 'βœ… Completed'; // Verification badge β€” how this file passed verification: // verified = clean AcoustID pass; unverified = couldn't be // hard-confirmed (cross-script/ambiguous/no fingerprint match); // force_imported = accepted as best candidate after the retry // budget was exhausted (version-mismatch fallback). if (task.verification_status === 'force_imported') { statusText += ' βš‘'; } else if (task.verification_status === 'unverified') { statusText += ' ⚠'; } else if (task.verification_status === 'verified') { statusText += ' βœ”'; } else if (task.verification_status === 'human_verified') { statusText += ' πŸ›‘βœ”'; } completedCount++; break; } case 'not_found': statusText = 'πŸ”‡ Not Found'; notFoundCount++; break; case 'failed': { // Distinguish quarantine outcomes from generic // failures β€” the file is recoverable, not lost. const _em = (task.error_message || '').toLowerCase(); if (_em.includes('integrity check failed') || _em.includes('bit depth filter') || _em.includes('verification failed') || _em.includes('quality filter') || _em.includes('audio guard') || _em.includes('silence guard') || _em.includes('quarantin')) { isQuarantinedTask = true; statusText = 'πŸ›‘οΈ Quarantined'; } else { 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'); // Clear clickable/quarantine state each render; the failure // branch below re-adds it when still applicable. Without this a // task that flips failed/quarantined -> completed (e.g. after // Accept & Import) keeps a stale chooser on the cell. statusEl.classList.remove('has-candidates'); delete statusEl.dataset.quarantineEntryId; delete statusEl.dataset.quarantineReason; delete statusEl.dataset.quarantineTrack; delete statusEl.dataset.detailOpen; // statusText is static markup only; the verif-badge span is the // one case that needs HTML. Everything else stays textContent // (XSS-safe default). if (statusText.includes('class="verif-badge')) { statusEl.innerHTML = statusText; } else { statusEl.textContent = statusText; } // Visual-only hooks: the cell carries its state for the badge // styling, the row glows while a track is actively working. statusEl.dataset.state = isQuarantinedTask ? 'quarantined' : (isV2Task && uiState === 'cancelling' ? 'cancelling' : task.status); row.classList.toggle('row-working', ['searching', 'downloading', 'post_processing'].includes(task.status)); 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); } // Completed rows are clickable into the rich track-detail modal // (play, location, AcoustID verdict, provenance). if (task.status === 'completed') { statusEl.classList.add('has-candidates'); statusEl.dataset.taskId = task.task_id; statusEl.dataset.detailOpen = '1'; _ensureCandidatesClickListener(statusEl); } else if (task.status === 'not_found' || task.status === 'failed' || task.status === 'cancelled') { // Clickable to recover: quarantined -> track-detail modal // (Listen / Accept / Search); plain failed/not-found -> // straight to the search modal. detailOpen is set/cleared // each render so a row that changes kind stays correct. statusEl.classList.add('has-candidates'); statusEl.dataset.taskId = task.task_id; if (isQuarantinedTask && task.quarantine_entry_id) { statusEl.dataset.detailOpen = '1'; } _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 && task.status === 'failed' && isQuarantinedTask && task.quarantine_entry_id) { const entryId = escapeHtml(task.quarantine_entry_id); actionsEl.innerHTML = ``; const approveBtn = actionsEl.querySelector('.approve-quarantine-inline-btn'); if (approveBtn) { approveBtn.addEventListener('click', () => approveQuarantineFromDownloadRow(approveBtn)); } } 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); } // Keep visible wishlist results open so failed tracks can be reviewed. if (playlistId === 'wishlist') { console.log('[Wishlist] Leaving completed wishlist modal open for failed-track review'); } // 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, syncModeOverride = null) { const startTime = Date.now(); // Sync mode: prefer explicit override (e.g. from automation/discover code paths // that don't render the modal selector), else read the per-playlist