// == SYNC PAGE SPOTIFY FUNCTIONALITY == // =========================================== async function loadSyncData() { // This is called when the sync page is navigated to. // Load server playlists first (default active tab) if (!window._serverPlaylistsLoaded) { window._serverPlaylistsLoaded = true; loadServerPlaylists(); // Don't await โ€” load in background } if (!spotifyPlaylistsLoaded) { await loadSpotifyPlaylists(); } // Load YouTube playlists from backend (always refresh to get latest state) await loadYouTubePlaylistsFromBackend(); // Render saved URL histories for YouTube, Deezer, Spotify Link tabs initUrlHistories(); } async function ensureBeatportContentLoaded() { if (beatportContentState.loaded) { showBeatportDownloadsSection(); return true; } if (beatportContentState.loadingPromise) { return beatportContentState.loadingPromise; } beatportContentState.abortController = new AbortController(); beatportContentState.loadingPromise = (async () => { try { console.log('๐ŸŽง Lazy-loading Beatport content...'); await hydrateBeatportBubblesFromSnapshot(); throwIfBeatportLoadAborted(); await loadBeatportChartsFromBackend(); throwIfBeatportLoadAborted(); initializeBeatportRebuildSlider(); initializeBeatportReleasesSlider(); initializeBeatportHypePicksSlider(); initializeBeatportChartsSlider(); initializeBeatportDJSlider(); throwIfBeatportLoadAborted(); await Promise.all([ loadBeatportTop10Lists(), loadBeatportTop10Releases() ]); throwIfBeatportLoadAborted(); showBeatportDownloadsSection(); beatportContentState.loaded = true; console.log('โœ… Beatport content loaded'); return true; } catch (error) { if (error && error.name === 'AbortError') { console.log('โน Beatport content load aborted'); return false; } console.error('โŒ Error loading Beatport content:', error); return false; } finally { beatportContentState.loadingPromise = null; if (beatportContentState.abortController && beatportContentState.abortController.signal.aborted) { beatportContentState.abortController = null; } } })(); return beatportContentState.loadingPromise; } async function checkForActiveProcesses() { try { const response = await fetch('/api/active-processes'); if (!response.ok) return; const data = await response.json(); const processes = data.active_processes || []; if (processes.length > 0) { console.log(`๐Ÿ”„ Found ${processes.length} active process(es) from backend. Rehydrating UI...`); // Separate download batch processes from YouTube playlist processes const downloadProcesses = processes.filter(p => p.type === 'batch'); const youtubeProcesses = processes.filter(p => p.type === 'youtube_playlist'); console.log(`๐Ÿ“Š Process breakdown: ${downloadProcesses.length} download batches, ${youtubeProcesses.length} YouTube playlists`); // Rehydrate download modal processes (existing Spotify system) for (const processInfo of downloadProcesses) { if (!activeDownloadProcesses[processInfo.playlist_id]) { rehydrateModal(processInfo); } } // Note: YouTube playlists are handled by loadYouTubePlaylistsFromBackend() and rehydrateYouTubePlaylist() // in loadSyncData(), which provides more complete data than active processes and handles download modal rehydration. console.log(`โ„น๏ธ Skipping ${youtubeProcesses.length} YouTube playlists - handled by full backend loading`); } } catch (error) { console.error('Failed to check for active processes:', error); } } async function rehydrateArtistAlbumModal(virtualPlaylistId, playlistName, batchId) { /** * Rehydrates an artist album download modal from backend process data. * Extracts artist/album info from virtual playlist ID and recreates the modal. */ try { console.log(`๐Ÿ’ง Rehydrating artist album modal: ${virtualPlaylistId} (${playlistName})`); // Extract artist_id and album_id from virtualPlaylistId format: artist_album_[artist_id]_[album_id] const parts = virtualPlaylistId.split('_'); if (parts.length < 4 || parts[0] !== 'artist' || parts[1] !== 'album') { console.error(`โŒ Invalid virtual playlist ID format: ${virtualPlaylistId}`); return; } const artistId = parts[2]; const albumId = parts.slice(3).join('_'); // Handle album IDs that might contain underscores console.log(`๐Ÿ” Extracted from virtual playlist: artistId=${artistId}, albumId=${albumId}`); // Fetch the album tracks to get proper artist and album data try { const rehydrateProcess = activeDownloadProcesses[virtualPlaylistId]; const albumSource = rehydrateProcess?.source || rehydrateProcess?.artist?.source || rehydrateProcess?.album?.source || null; const artistName = rehydrateProcess?.artist?.name || ''; const params = new URLSearchParams({ name: playlistName || '', artist: artistName }); if (albumSource) { params.set('source', albumSource); } const response = await fetch(`/api/album/${albumId}/tracks?${params}`); const data = await response.json(); if (!data.success || !data.album || !data.tracks) { console.error('โŒ Failed to fetch album data for rehydration:', data.error); return; } const album = data.album; const tracks = data.tracks; const source = data.source || tracks[0]?._source || albumSource || null; // Extract artist info from the first track (all tracks should have same artist) const artist = { id: artistId, name: tracks[0].artists[0], // Use first artist name from first track source }; if (source) { album.source = source; } console.log(`โœ… Retrieved album data: "${album.name}" by ${artist.name} (${tracks.length} tracks)`); // Create the modal using the same function as normal artist album downloads await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, tracks, album, artist); // Update the rehydrated process with batch info and hide modal for background rehydration const process = activeDownloadProcesses[virtualPlaylistId]; if (process) { process.status = 'running'; process.batchId = batchId; subscribeToDownloadBatch(batchId); // Update button states to reflect running status const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${virtualPlaylistId}`); if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; if (wishlistBtn) wishlistBtn.style.display = 'none'; // Hide the modal - this is background rehydration, not user-requested if (process.modalElement) { process.modalElement.style.display = 'none'; console.log(`๐Ÿ” Hiding rehydrated modal for background processing: ${album.name}`); } console.log(`โœ… Rehydrated artist album modal: ${artist.name} - ${album.name}`); } else { console.error(`โŒ Failed to find rehydrated process for ${virtualPlaylistId}`); } } catch (error) { console.error(`โŒ Error fetching album data for rehydration:`, error); } } catch (error) { console.error(`โŒ Error rehydrating artist album modal:`, error); } } async function rehydrateDiscoverPlaylistModal(virtualPlaylistId, playlistName, batchId) { /** * Rehydrates a discover playlist download modal from backend process data. * Fetches tracks from the appropriate discover API endpoint and recreates the modal. */ try { console.log(`๐Ÿ’ง Rehydrating discover playlist modal: ${virtualPlaylistId} (${playlistName})`); // Handle album downloads from Recent Releases if (virtualPlaylistId.startsWith('discover_album_')) { const albumId = virtualPlaylistId.replace('discover_album_', ''); console.log(`๐Ÿ’ง Album download - fetching album ${albumId}...`); try { const albumResponse = await fetch(`/api/spotify/album/${albumId}`); if (!albumResponse.ok) { console.error(`โŒ Failed to fetch album: ${albumResponse.status}`); return; } const albumData = await albumResponse.json(); if (!albumData.tracks || albumData.tracks.length === 0) { console.error(`โŒ No tracks in album`); return; } // Convert tracks to expected format const spotifyTracks = albumData.tracks.map(track => { let artists = track.artists || []; if (Array.isArray(artists)) { artists = artists.map(a => a.name || a); } return { id: track.id, name: track.name, artists: artists, album: { name: albumData.name || playlistName.split(' - ')[0], images: albumData.images || [] }, duration_ms: track.duration_ms || 0 }; }); console.log(`โœ… Retrieved ${spotifyTracks.length} tracks for album`); // Create modal await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); // Update process const process = activeDownloadProcesses[virtualPlaylistId]; if (process) { process.status = 'running'; process.batchId = batchId; subscribeToDownloadBatch(batchId); const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; // Hide modal for background rehydration if (process.modalElement) { process.modalElement.style.display = 'none'; console.log(`๐Ÿ” Hiding rehydrated modal for background processing: ${playlistName}`); } console.log(`โœ… Rehydrated album modal: ${playlistName}`); } return; } catch (error) { console.error(`โŒ Error fetching album:`, error); return; } } // Determine API endpoint based on playlist ID let apiEndpoint; if (virtualPlaylistId === 'discover_release_radar') { apiEndpoint = '/api/discover/release-radar'; } else if (virtualPlaylistId === 'discover_discovery_weekly') { apiEndpoint = '/api/discover/discovery-weekly'; } else if (virtualPlaylistId === 'discover_seasonal_playlist') { apiEndpoint = '/api/discover/seasonal-playlist'; } else if (virtualPlaylistId === 'discover_popular_picks') { apiEndpoint = '/api/discover/popular-picks'; } else if (virtualPlaylistId === 'discover_hidden_gems') { apiEndpoint = '/api/discover/hidden-gems'; } else if (virtualPlaylistId === 'discover_discovery_shuffle') { apiEndpoint = '/api/discover/discovery-shuffle'; } else if (virtualPlaylistId === 'build_playlist_custom') { apiEndpoint = '/api/discover/build-playlist'; } else if (virtualPlaylistId.startsWith('discover_lb_')) { console.log(`๐Ÿ’ง ListenBrainz playlist - skipping (no automatic rehydration for ListenBrainz)`); return; } else { console.error(`โŒ Unknown discover playlist type: ${virtualPlaylistId}`); return; } // Fetch tracks from API console.log(`๐Ÿ“ก Fetching tracks from ${apiEndpoint}...`); const response = await fetch(apiEndpoint); if (!response.ok) { console.error(`โŒ Failed to fetch discover playlist data: ${response.status}`); return; } const data = await response.json(); if (!data.success || !data.tracks) { console.error(`โŒ Invalid discover playlist data:`, data); return; } const tracks = data.tracks; console.log(`โœ… Retrieved ${tracks.length} tracks for ${playlistName}`); // Transform tracks to format expected by download modal (same as openDownloadModalForDiscoverPlaylist) const spotifyTracks = tracks.map(track => { let spotifyTrack; // Use track_data_json if available, otherwise construct from track data if (track.track_data_json) { spotifyTrack = track.track_data_json; } else { // Fallback: construct track object from available data spotifyTrack = { id: track.spotify_track_id, name: track.track_name, artists: [{ name: track.artist_name }], album: { name: track.album_name, images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] }, duration_ms: track.duration_ms || 0 }; } // Normalize artists to array of strings for modal compatibility if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); } return spotifyTrack; }); // Create the modal using the same function as normal discover downloads await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); // Update the rehydrated process with batch info and hide modal for background rehydration const process = activeDownloadProcesses[virtualPlaylistId]; if (process) { process.status = 'running'; process.batchId = batchId; subscribeToDownloadBatch(batchId); // Update button states to reflect running status const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; // Hide the modal - this is background rehydration, not user-requested if (process.modalElement) { process.modalElement.style.display = 'none'; console.log(`๐Ÿ” Hiding rehydrated modal for background processing: ${playlistName}`); } console.log(`โœ… Rehydrated discover playlist modal: ${playlistName}`); } else { console.error(`โŒ Failed to find rehydrated process for ${virtualPlaylistId}`); } } catch (error) { console.error(`โŒ Error rehydrating discover playlist modal:`, error); } } async function rehydrateEnhancedSearchModal(virtualPlaylistId, playlistName, batchId) { /** * Rehydrates an enhanced search download modal from backend process data. * Fetches item data from searchDownloadBubbles and recreates the modal. */ try { console.log(`๐Ÿ’ง Rehydrating enhanced search modal: ${virtualPlaylistId} (${playlistName})`); // Find the download in searchDownloadBubbles let downloadData = null; for (const artistName in searchDownloadBubbles) { const bubble = searchDownloadBubbles[artistName]; const download = bubble.downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); if (download) { downloadData = download; break; } } if (!downloadData) { console.warn(`โš ๏ธ No download data found in searchDownloadBubbles for ${virtualPlaylistId}`); return; } const { item, type } = downloadData; if (type === 'album') { // For albums, fetch tracks (pass name/artist for Hydrabase support) console.log(`๐Ÿ’ง Album download - fetching album ${item.id}...`); try { const _sap1 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' }); const response = await fetch(`/api/spotify/album/${item.id}?${_sap1}`); if (!response.ok) { console.error(`โŒ Failed to fetch album: ${response.status}`); return; } const albumData = await response.json(); if (!albumData.tracks || albumData.tracks.length === 0) { console.error(`โŒ No tracks in album`); return; } const spotifyTracks = albumData.tracks.map(track => ({ id: track.id, name: track.name, artists: track.artists || [{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }], album: { name: item.name, images: item.image_url ? [{ url: item.image_url }] : [] }, duration_ms: track.duration_ms || 0 })); console.log(`โœ… Retrieved ${spotifyTracks.length} tracks for album`); // Create modal await openDownloadMissingModalForArtistAlbum( virtualPlaylistId, item.name, spotifyTracks, item, { name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }, false // Don't show loading overlay ); // Update process const process = activeDownloadProcesses[virtualPlaylistId]; if (process) { process.status = 'running'; process.batchId = batchId; subscribeToDownloadBatch(batchId); const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; // Hide modal for background rehydration if (process.modalElement) { process.modalElement.style.display = 'none'; console.log(`๐Ÿ” Hiding rehydrated modal for background processing: ${playlistName}`); } // Start polling for live updates startModalDownloadPolling(virtualPlaylistId); console.log(`โœ… Rehydrated enhanced search album modal: ${playlistName}`); } else { console.error(`โŒ Failed to find rehydrated process for ${virtualPlaylistId}`); } } catch (error) { console.error(`โŒ Error fetching album:`, error); } } else { // For tracks, create enriched track and open modal console.log(`๐Ÿ’ง Track download - creating modal for ${item.name}...`); const enrichedTrack = { id: item.id, name: item.name, artists: item.artists || [{ name: item.artist || 'Unknown Artist' }], album: item.album || { name: item.album?.name || 'Unknown Album', images: item.image_url ? [{ url: item.image_url }] : [] }, duration_ms: item.duration_ms || 0 }; // Create modal await openDownloadMissingModalForYouTube( virtualPlaylistId, `${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`, [enrichedTrack] ); // Update process const process = activeDownloadProcesses[virtualPlaylistId]; if (process) { process.status = 'running'; process.batchId = batchId; subscribeToDownloadBatch(batchId); const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; // Hide modal for background rehydration if (process.modalElement) { process.modalElement.style.display = 'none'; console.log(`๐Ÿ” Hiding rehydrated modal for background processing: ${playlistName}`); } // Start polling for live updates startModalDownloadPolling(virtualPlaylistId); console.log(`โœ… Rehydrated enhanced search track modal: ${playlistName}`); } else { console.error(`โŒ Failed to find rehydrated process for ${virtualPlaylistId}`); } } } catch (error) { console.error(`โŒ Error rehydrating enhanced search modal:`, error); } } async function rehydrateModal(processInfo, userRequested = false) { const { playlist_id, playlist_name, batch_id, current_cycle } = processInfo; console.log(`๐Ÿ’ง Rehydrating modal for "${playlist_name}" (batch: ${batch_id}) - User requested: ${userRequested}`); // Handle YouTube virtual playlists - skip rehydration here, handled by YouTube system if (playlist_id.startsWith('youtube_')) { console.log(`โญ๏ธ Skipping YouTube virtual playlist rehydration - handled by YouTube system`); return; } // Handle Beatport virtual playlists - skip rehydration here, handled by Beatport system if (playlist_id.startsWith('beatport_')) { console.log(`โญ๏ธ Skipping Beatport virtual playlist rehydration - handled by Beatport system`); return; } // Handle artist album virtual playlists if (playlist_id.startsWith('artist_album_')) { console.log(`๐Ÿ’ง Rehydrating artist album virtual playlist: ${playlist_id}`); await rehydrateArtistAlbumModal(playlist_id, playlist_name, batch_id); return; } // Handle discover virtual playlists (Fresh Tape, The Archives) if (playlist_id.startsWith('discover_')) { console.log(`๐Ÿ’ง Rehydrating discover playlist: ${playlist_id}`); await rehydrateDiscoverPlaylistModal(playlist_id, playlist_name, batch_id); return; } // Handle enhanced search virtual playlists (albums and tracks) if (playlist_id.startsWith('enhanced_search_album_') || playlist_id.startsWith('enhanced_search_track_')) { console.log(`๐Ÿ’ง Rehydrating enhanced search virtual playlist: ${playlist_id}`); await rehydrateEnhancedSearchModal(playlist_id, playlist_name, batch_id); return; } // Handle wishlist processes specially if (playlist_id === "wishlist") { console.log(`๐Ÿ’ง [Rehydrate] Handling wishlist modal for active process: ${batch_id}`); // Check if modal already exists and is visible const existingProcess = activeDownloadProcesses[playlist_id]; const modalAlreadyOpen = existingProcess && existingProcess.modalElement && existingProcess.modalElement.style.display === 'flex'; if (modalAlreadyOpen) { console.log(`๐Ÿ’ง [Rehydrate] Wishlist modal already open - updating existing modal with auto-process state`); // Update existing process with new batch info existingProcess.status = 'running'; existingProcess.batchId = batch_id; // Update UI to reflect running state const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`); const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`); if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; // Ensure polling is active for live updates if (!existingProcess.intervalId) { console.log(`๐Ÿ’ง [Rehydrate] Starting polling for existing modal`); startModalDownloadPolling(playlist_id); } console.log(`โœ… [Rehydrate] Successfully updated existing wishlist modal for auto-process`); } else { // Only create modal if user requested it - don't create for background auto-processing if (userRequested) { console.log(`๐Ÿ’ง [Rehydrate] User requested - creating wishlist modal for active process: ${batch_id}`); // Create the modal with current server state (pass category filter for auto-processing) await openDownloadMissingWishlistModal(current_cycle); const process = activeDownloadProcesses[playlist_id]; if (!process) { console.error('โŒ [Rehydrate] Failed to create wishlist process in activeDownloadProcesses'); return; } // Sync process state with server console.log(`โœ… [Rehydrate] Syncing wishlist process state - batchId: ${batch_id}, status: running`); process.status = 'running'; process.batchId = batch_id; // Update UI to reflect running state const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`); const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`); if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; // Start polling for live updates startModalDownloadPolling(playlist_id); // Show modal console.log('๐Ÿ‘ค [Rehydrate] User requested - showing wishlist modal'); process.modalElement.style.display = 'flex'; WishlistModalState.setVisible(); WishlistModalState.clearUserClosed(); } else { console.log('๐Ÿ”„ [Rehydrate] Background auto-processing detected - NOT creating modal (user must click wishlist button to see progress)'); // Don't create modal for background auto-processing // User must click the wishlist button to see the modal } } return; } // Handle Deezer ARL playlist processes โ€” ensure playlist data is in spotifyPlaylists for modal reuse if (playlist_id.startsWith('deezer_arl_') && !spotifyPlaylists.find(p => p.id === playlist_id)) { const rawId = playlist_id.replace('deezer_arl_', ''); const deezerPlaylist = deezerArlPlaylists.find(p => String(p.id) === rawId); if (deezerPlaylist) { spotifyPlaylists.push({ id: playlist_id, name: deezerPlaylist.name, track_count: deezerPlaylist.track_count || 0, image_url: deezerPlaylist.image_url || '', owner: deezerPlaylist.owner || '', }); } else { // Playlists not loaded yet โ€” use process info as fallback spotifyPlaylists.push({ id: playlist_id, name: playlist_name || 'Deezer Playlist', track_count: 0, }); } } // Handle regular Spotify / Deezer ARL playlist processes let playlistData = spotifyPlaylists.find(p => p.id === playlist_id); if (!playlistData) { console.warn(`Cannot rehydrate modal: Playlist data for ${playlist_id} not loaded.`); return; } await openDownloadMissingModal(playlist_id); const process = activeDownloadProcesses[playlist_id]; if (!process) return; process.status = 'running'; process.batchId = batch_id; updatePlaylistCardUI(playlist_id); updateRefreshButtonState(); document.getElementById(`begin-analysis-btn-${playlist_id}`).style.display = 'none'; document.getElementById(`cancel-all-btn-${playlist_id}`).style.display = 'inline-block'; // Hide wishlist button if it exists const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlist_id}`); if (wishlistBtn) wishlistBtn.style.display = 'none'; startModalDownloadPolling(playlist_id); process.modalElement.style.display = 'none'; } // =================================================================== // YOUTUBE PLAYLIST BACKEND HYDRATION FUNCTIONS // =================================================================== async function loadYouTubePlaylistsFromBackend() { // Load all stored YouTube playlists from backend and recreate cards (similar to Spotify hydration) try { console.log('๐Ÿ“‹ Loading YouTube playlists from backend...'); const response = await fetch('/api/youtube/playlists'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to fetch YouTube playlists'); } const data = await response.json(); const playlists = data.playlists || []; console.log(`๐ŸŽฌ Found ${playlists.length} stored YouTube playlists in backend`); if (playlists.length === 0) { console.log('๐Ÿ“‹ No YouTube playlists to hydrate'); return; } const container = document.getElementById('youtube-playlist-container'); // Create cards for playlists that don't already exist (avoid duplicates) for (const playlistInfo of playlists) { const urlHash = playlistInfo.url_hash; // Check if card already exists (from rehydration or previous loading) if (youtubePlaylistStates[urlHash] && youtubePlaylistStates[urlHash].cardElement && document.body.contains(youtubePlaylistStates[urlHash].cardElement)) { console.log(`โญ๏ธ Skipping existing YouTube playlist card: ${playlistInfo.playlist.name}`); // Update existing state with backend data const state = youtubePlaylistStates[urlHash]; state.phase = playlistInfo.phase; state.discoveryProgress = playlistInfo.discovery_progress; state.spotifyMatches = playlistInfo.spotify_matches; state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id; // Fetch discovery results for existing cards too if they don't have them if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering' && (!state.discoveryResults || state.discoveryResults.length === 0)) { try { console.log(`๐Ÿ” Fetching missing discovery results for existing card: ${playlistInfo.playlist.name}`); const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); if (stateResponse.ok) { const fullState = await stateResponse.json(); if (fullState.discovery_results) { state.discoveryResults = fullState.discovery_results; state.syncPlaylistId = fullState.sync_playlist_id; state.syncProgress = fullState.sync_progress || {}; console.log(`โœ… Restored ${state.discoveryResults.length} discovery results for existing card`); } } } catch (error) { console.warn(`โš ๏ธ Error fetching discovery results for existing card:`, error.message); } } continue; } console.log(`๐ŸŽฌ Creating YouTube playlist card: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase})`); createYouTubeCardFromBackendState(playlistInfo); // Fetch discovery results for non-fresh playlists (same logic as rehydrateYouTubePlaylist) if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering') { try { console.log(`๐Ÿ” Fetching discovery results for: ${playlistInfo.playlist.name}`); const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); if (stateResponse.ok) { const fullState = await stateResponse.json(); console.log(`๐Ÿ“‹ Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); // Store discovery results in local state const state = youtubePlaylistStates[urlHash]; if (fullState.discovery_results && state) { state.discoveryResults = fullState.discovery_results; state.syncPlaylistId = fullState.sync_playlist_id; state.syncProgress = fullState.sync_progress || {}; console.log(`โœ… Restored ${state.discoveryResults.length} discovery results for: ${playlistInfo.playlist.name}`); } } else { console.warn(`โš ๏ธ Could not fetch discovery results for: ${playlistInfo.playlist.name}`); } } catch (error) { console.warn(`โš ๏ธ Error fetching discovery results for ${playlistInfo.playlist.name}:`, error.message); } } } // Rehydrate download modals for YouTube playlists in downloading/download_complete phases for (const playlistInfo of playlists) { if ((playlistInfo.phase === 'downloading' || playlistInfo.phase === 'download_complete') && playlistInfo.converted_spotify_playlist_id && playlistInfo.download_process_id) { const convertedPlaylistId = playlistInfo.converted_spotify_playlist_id; if (!activeDownloadProcesses[convertedPlaylistId]) { console.log(`๐Ÿ’ง Rehydrating download modal for YouTube playlist: ${playlistInfo.playlist.name}`); try { // Create the download modal using the YouTube-specific function const spotifyTracks = youtubePlaylistStates[playlistInfo.url_hash]?.discoveryResults ?.filter(result => result.spotify_data) ?.map(result => result.spotify_data) || []; if (spotifyTracks.length > 0) { await openDownloadMissingModalForYouTube( convertedPlaylistId, playlistInfo.playlist.name, spotifyTracks ); // Set the modal to running state with the correct batch ID const process = activeDownloadProcesses[convertedPlaylistId]; if (process) { process.status = 'running'; process.batchId = playlistInfo.download_process_id; // Update UI to running state const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; // Start polling for this process startModalDownloadPolling(convertedPlaylistId); // Hide modal since this is background rehydration process.modalElement.style.display = 'none'; console.log(`โœ… Rehydrated download modal for YouTube playlist: ${playlistInfo.playlist.name}`); } } else { console.warn(`โš ๏ธ No Spotify tracks found for YouTube download modal: ${playlistInfo.playlist.name}`); } } catch (error) { console.error(`โŒ Error rehydrating download modal for ${playlistInfo.playlist.name}:`, error); } } } } console.log(`โœ… Successfully hydrated ${playlists.length} YouTube playlists from backend`); } catch (error) { console.error('โŒ Error loading YouTube playlists from backend:', error); showToast(`Error loading YouTube playlists: ${error.message}`, 'error'); } } async function loadBeatportChartsFromBackend() { // Load all stored Beatport charts from backend and recreate cards (similar to YouTube hydration) try { console.log('๐Ÿ“‹ Loading Beatport charts from backend...'); const signal = getBeatportContentSignal(); const response = await fetch('/api/beatport/charts', signal ? { signal } : undefined); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to fetch Beatport charts'); } const charts = await response.json(); console.log(`๐ŸŽง Found ${charts.length} stored Beatport charts in backend`); if (charts.length === 0) { console.log('๐Ÿ“‹ No Beatport charts to hydrate'); return; } const container = document.getElementById('beatport-playlist-container'); // Create cards for charts that don't already exist (avoid duplicates) for (const chartInfo of charts) { const chartHash = chartInfo.hash; // Check if card already exists (from previous loading) if (beatportChartStates[chartHash] && beatportChartStates[chartHash].cardElement && document.body.contains(beatportChartStates[chartHash].cardElement)) { console.log(`โญ๏ธ Skipping existing Beatport chart card: ${chartInfo.name}`); // Update existing state with backend data const state = beatportChartStates[chartHash]; state.phase = chartInfo.phase; continue; } console.log(`๐ŸŽง Creating Beatport chart card: ${chartInfo.name} (Phase: ${chartInfo.phase})`); createBeatportCardFromBackendState(chartInfo); // Fetch full state for non-fresh charts to restore discovery results if (chartInfo.phase !== 'fresh') { try { console.log(`๐Ÿ” Fetching full state for: ${chartInfo.name}`); const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`, signal ? { signal } : undefined); if (stateResponse.ok) { const fullState = await stateResponse.json(); console.log(`๐Ÿ“‹ Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); // Store in YouTube state system (since Beatport reuses it) if (fullState.discovery_results && fullState.discovery_results.length > 0) { // Transform backend results to frontend format (like Tidal does) const transformedResults = fullState.discovery_results.map((result, index) => ({ index: result.index !== undefined ? result.index : index, yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown', yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown', status: result.status === 'found' ? 'โœ… Found' : (result.status === 'error' ? 'โŒ Error' : 'โŒ Not Found'), status_class: result.status_class || (result.status === 'found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')), spotify_track: result.spotify_data ? result.spotify_data.name : '-', spotify_artist: result.spotify_data && result.spotify_data.artists ? result.spotify_data.artists.map(a => a.name || a).join(', ') : '-', spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : '-' })); // Create Beatport state in YouTube system for modal functionality youtubePlaylistStates[chartHash] = { phase: fullState.phase, playlist: { name: chartInfo.name, tracks: chartInfo.chart_data.tracks, description: `${chartInfo.track_count} tracks from ${chartInfo.name}`, source: 'beatport' }, is_beatport_playlist: true, beatport_chart_type: chartInfo.chart_data.chart_type, beatport_chart_hash: chartHash, discovery_progress: fullState.discovery_progress, discoveryProgress: fullState.discovery_progress, spotify_matches: fullState.spotify_matches, spotifyMatches: fullState.spotify_matches, discovery_results: fullState.discovery_results, discoveryResults: transformedResults, convertedSpotifyPlaylistId: fullState.converted_spotify_playlist_id, download_process_id: fullState.download_process_id, syncPlaylistId: fullState.sync_playlist_id, syncProgress: fullState.sync_progress || {} }; console.log(`โœ… Restored ${transformedResults.length} discovery results for: ${chartInfo.name}`); } } else { console.warn(`โš ๏ธ Could not fetch full state for: ${chartInfo.name}`); } } catch (error) { if (error && error.name === 'AbortError') throw error; console.warn(`โš ๏ธ Error fetching full state for ${chartInfo.name}:`, error.message); } } } // Rehydrate download modals for Beatport charts in downloading/download_complete phases for (const chartInfo of charts) { if ((chartInfo.phase === 'downloading' || chartInfo.phase === 'download_complete') && chartInfo.converted_spotify_playlist_id && chartInfo.download_process_id) { const convertedPlaylistId = chartInfo.converted_spotify_playlist_id; console.log(`๐Ÿ“ฅ Rehydrating download modal for Beatport chart: ${chartInfo.name} (Playlist: ${convertedPlaylistId})`); // Set up active download process for Beatport chart (like YouTube/Tidal) try { // Rehydrate the chart state first to get discovery results await rehydrateBeatportChart(chartInfo, false); // Create the download modal using the Beatport-specific function (like YouTube) if (!activeDownloadProcesses[convertedPlaylistId]) { // Get tracks from the rehydrated state const ytState = youtubePlaylistStates[chartInfo.hash]; let spotifyTracks = []; if (ytState && ytState.discovery_results) { spotifyTracks = ytState.discovery_results .filter(result => result.spotify_data) .map(result => { const track = result.spotify_data; // Ensure artists is an array of strings if (track.artists && Array.isArray(track.artists)) { track.artists = track.artists.map(artist => typeof artist === 'string' ? artist : (artist.name || artist) ); } else if (track.artists && typeof track.artists === 'string') { track.artists = [track.artists]; } else { track.artists = ['Unknown Artist']; } return { id: track.id, name: track.name, artists: track.artists, album: track.album || 'Unknown Album', duration_ms: track.duration_ms || 0, external_urls: track.external_urls || {} }; }); } if (spotifyTracks.length > 0) { await openDownloadMissingModalForYouTube( convertedPlaylistId, chartInfo.name, spotifyTracks ); // Set the modal to running state with the correct batch ID const process = activeDownloadProcesses[convertedPlaylistId]; if (process) { process.status = chartInfo.phase === 'download_complete' ? 'complete' : 'running'; process.batchId = chartInfo.download_process_id; // Update UI to running state const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); if (beginBtn) beginBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; // Start polling for this process startModalDownloadPolling(convertedPlaylistId); // Hide modal since this is background rehydration process.modalElement.style.display = 'none'; console.log(`โœ… Rehydrated download modal for Beatport chart: ${chartInfo.name}`); } } else { console.warn(`โš ๏ธ No Spotify tracks found for Beatport download modal: ${chartInfo.name}`); } } } catch (error) { if (error && error.name === 'AbortError') throw error; console.warn(`โš ๏ธ Error setting up download process for Beatport chart "${chartInfo.name}":`, error.message); } } } throwIfBeatportLoadAborted(); console.log(`โœ… Successfully loaded and rehydrated ${charts.length} Beatport charts`); // Start polling for any charts that are still in discovering phase for (const chartInfo of charts) { if (chartInfo.phase === 'discovering') { console.log(`๐Ÿ”„ [Backend Loading] Auto-starting polling for discovering chart: ${chartInfo.name}`); throwIfBeatportLoadAborted(); startBeatportDiscoveryPolling(chartInfo.hash); } } // Update clear button state after loading charts updateBeatportClearButtonState(); } catch (error) { if (error && error.name === 'AbortError') { console.log('โน Beatport chart hydration aborted'); return; } console.error('โŒ Error loading Beatport charts from backend:', error); showToast(`Error loading Beatport charts: ${error.message}`, 'error'); } } async function loadListenBrainzPlaylistsFromBackend() { // Load all stored ListenBrainz playlist states from backend for persistence (similar to Beatport hydration) try { console.log('๐Ÿ“‹ Loading ListenBrainz playlists from backend...'); const response = await fetch('/api/listenbrainz/playlists'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to fetch ListenBrainz playlists'); } const data = await response.json(); const playlists = data.playlists || []; console.log(`๐ŸŽต Found ${playlists.length} stored ListenBrainz playlists in backend`); if (playlists.length === 0) { console.log('๐Ÿ“‹ No ListenBrainz playlists to hydrate'); listenbrainzPlaylistsLoaded = true; return; } // Restore state for each playlist for (const playlistInfo of playlists) { const playlistMbid = playlistInfo.playlist_mbid; console.log(`๐ŸŽต Hydrating ListenBrainz playlist: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase}, MBID: ${playlistMbid})`); // Fetch full state for non-fresh playlists to restore discovery results if (playlistInfo.phase !== 'fresh') { try { console.log(`๐Ÿ” Fetching full state for: ${playlistInfo.playlist.name}`); const stateResponse = await fetch(`/api/listenbrainz/state/${playlistMbid}`); if (stateResponse.ok) { const fullState = await stateResponse.json(); console.log(`๐Ÿ“‹ Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); // Transform backend results to frontend format (like Beatport does) const transformedResults = (fullState.discovery_results || []).map((result, index) => ({ index: result.index !== undefined ? result.index : index, yt_track: result.lb_track || result.track_name || 'Unknown', yt_artist: result.lb_artist || result.artist_name || 'Unknown', status: result.status === 'found' || result.status === 'โœ… Found' || result.status_class === 'found' ? 'โœ… Found' : (result.status === 'error' ? 'โŒ Error' : 'โŒ Not Found'), status_class: result.status_class || (result.status === 'found' || result.status === 'โœ… Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')), spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), spotify_artist: result.spotify_data && result.spotify_data.artists ? (Array.isArray(result.spotify_data.artists) ? (typeof result.spotify_data.artists[0] === 'object' ? result.spotify_data.artists[0].name : result.spotify_data.artists[0]) : result.spotify_data.artists) : (result.spotify_artist || '-'), spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), spotify_data: result.spotify_data, duration: result.duration || '0:00' })); // Create ListenBrainz state with both naming conventions listenbrainzPlaylistStates[playlistMbid] = { phase: fullState.phase, playlist: fullState.playlist, is_listenbrainz_playlist: true, playlist_mbid: playlistMbid, // Store with both naming conventions discovery_results: fullState.discovery_results || [], discoveryResults: transformedResults, discovery_progress: fullState.discovery_progress || 0, discoveryProgress: fullState.discovery_progress || 0, spotify_matches: fullState.spotify_matches || 0, spotifyMatches: fullState.spotify_matches || 0, spotify_total: fullState.spotify_total || 0, spotifyTotal: fullState.spotify_total || 0, convertedSpotifyPlaylistId: fullState.converted_spotify_playlist_id, download_process_id: fullState.download_process_id }; console.log(`โœ… Restored ${transformedResults.length} discovery results for: ${playlistInfo.playlist.name}`); } else { console.warn(`โš ๏ธ Could not fetch full state for: ${playlistInfo.playlist.name}`); } } catch (error) { console.warn(`โš ๏ธ Error fetching full state for ${playlistInfo.playlist.name}:`, error.message); } } } // Start polling for any playlists that are still in discovering phase for (const playlistInfo of playlists) { if (playlistInfo.phase === 'discovering') { console.log(`๐Ÿ”„ [Backend Loading] Auto-starting polling for discovering playlist: ${playlistInfo.playlist.name}`); startListenBrainzDiscoveryPolling(playlistInfo.playlist_mbid); } // Show sync button for discovered playlists (hidden by default) else if (playlistInfo.phase === 'discovered' || playlistInfo.phase === 'syncing' || playlistInfo.phase === 'sync_complete') { const playlistId = `discover-lb-playlist-${playlistInfo.playlist_mbid}`; const syncBtn = document.getElementById(`${playlistId}-sync-btn`); if (syncBtn) { syncBtn.style.display = 'inline-block'; console.log(`โœ… Showing sync button for discovered playlist: ${playlistInfo.playlist.name}`); } } } listenbrainzPlaylistsLoaded = true; console.log(`โœ… Successfully loaded and rehydrated ${playlists.length} ListenBrainz playlists`); } catch (error) { console.error('โŒ Error loading ListenBrainz playlists from backend:', error); listenbrainzPlaylistsLoaded = true; // Mark as loaded even on error to prevent retries } } function createBeatportCardFromBackendState(chartInfo) { // Create Beatport chart card from backend state data const chartHash = chartInfo.hash; const chartData = chartInfo.chart_data; const phase = chartInfo.phase; const container = document.getElementById('beatport-playlist-container'); // Remove placeholder if it exists const placeholder = container.querySelector('.playlist-placeholder'); if (placeholder) { placeholder.remove(); } // Create card HTML using same structure as createBeatportCard const cardHtml = `
๐ŸŽง
${escapeHtml(chartInfo.name)}
${chartInfo.track_count} tracks ${getPhaseText(phase)}
โ™ช ${chartInfo.spotify_total} / โœ“ ${chartInfo.spotify_matches} / โœ— ${chartInfo.spotify_total - chartInfo.spotify_matches} (${Math.round((chartInfo.spotify_matches / chartInfo.spotify_total) * 100) || 0}%)
`; container.insertAdjacentHTML('beforeend', cardHtml); // Initialize state beatportChartStates[chartHash] = { phase: phase, chart: chartData, cardElement: document.getElementById(`beatport-card-${chartHash}`) }; // Add click handler const card = document.getElementById(`beatport-card-${chartHash}`); if (card) { card.addEventListener('click', async () => await handleBeatportCardClick(chartHash)); } console.log(`๐Ÿƒ Created Beatport card from backend state: ${chartInfo.name} (${phase})`); } async function rehydrateBeatportChart(chartInfo, userRequested = false) { // Rehydrate Beatport chart state and optionally open modal (similar to rehydrateYouTubePlaylist) const chartHash = chartInfo.hash; const chartName = chartInfo.name; try { console.log(`๐Ÿ”„ [Rehydration] Starting rehydration for Beatport chart: ${chartName}`); // Get full state from backend including discovery results let fullState; try { const signal = getBeatportContentSignal(); const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`, signal ? { signal } : undefined); if (stateResponse.ok) { fullState = await stateResponse.json(); console.log(`๐Ÿ“‹ [Rehydration] Retrieved full backend state with ${fullState.discovery_results?.length || 0} discovery results`); } else { console.warn(`โš ๏ธ [Rehydration] Could not fetch full state, using basic info`); } } catch (error) { if (error && error.name === 'AbortError') return; console.warn(`โš ๏ธ [Rehydration] Error fetching full state:`, error.message); } const phase = chartInfo.phase; // Create or update Beatport chart state if (!beatportChartStates[chartHash]) { beatportChartStates[chartHash] = { phase: 'fresh', chart: chartInfo.chart_data, cardElement: null }; } const state = beatportChartStates[chartHash]; state.phase = phase; // Transform discovery results if available (like Tidal does) let transformedResults = []; if (fullState && fullState.discovery_results) { transformedResults = fullState.discovery_results.map((result, index) => ({ index: result.index !== undefined ? result.index : index, yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown', yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown', status: result.status === 'found' ? 'โœ… Found' : (result.status === 'error' ? 'โŒ Error' : 'โŒ Not Found'), status_class: result.status_class || (result.status === 'found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')), spotify_track: result.spotify_data ? result.spotify_data.name : '-', spotify_artist: result.spotify_data && result.spotify_data.artists ? result.spotify_data.artists.map(a => a.name || a).join(', ') : '-', spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : '-' })); } // Store in YouTube state system (since Beatport reuses it) youtubePlaylistStates[chartHash] = { phase: phase, playlist: { name: chartName, tracks: chartInfo.chart_data.tracks, description: `${chartInfo.track_count} tracks from ${chartName}`, source: 'beatport' }, is_beatport_playlist: true, beatport_chart_type: chartInfo.chart_data.chart_type, beatport_chart_hash: chartHash, discovery_progress: fullState?.discovery_progress || chartInfo.discovery_progress, discoveryProgress: fullState?.discovery_progress || chartInfo.discovery_progress, spotify_matches: fullState?.spotify_matches || chartInfo.spotify_matches, spotifyMatches: fullState?.spotify_matches || chartInfo.spotify_matches, discovery_results: fullState?.discovery_results || [], discoveryResults: transformedResults, convertedSpotifyPlaylistId: fullState?.converted_spotify_playlist_id || chartInfo.converted_spotify_playlist_id, download_process_id: fullState?.download_process_id || chartInfo.download_process_id, syncPlaylistId: fullState?.sync_playlist_id, syncProgress: fullState?.sync_progress || {} }; // Restore discovery results if we have them if (fullState && fullState.discovery_results) { console.log(`โœ… Restored ${fullState.discovery_results.length} discovery results from backend`); // Update modal if it already exists const existingModal = document.getElementById(`youtube-discovery-modal-${chartHash}`); if (existingModal && !existingModal.classList.contains('hidden')) { console.log(`๐Ÿ”„ Refreshing existing modal with restored discovery results`); refreshYouTubeDiscoveryModalTable(chartHash); } } // Update card display updateBeatportCardPhase(chartHash, phase); updateBeatportCardProgress(chartHash, { spotify_total: chartInfo.spotify_total, spotify_matches: chartInfo.spotify_matches, failed: chartInfo.spotify_total - chartInfo.spotify_matches }); // Handle active polling resumption if (phase === 'discovering') { console.log(`๐Ÿ” Resuming discovery polling for: ${chartName}`); startBeatportDiscoveryPolling(chartHash); } else if (phase === 'syncing') { console.log(`๐Ÿ”„ Resuming sync polling for: ${chartName}`); startBeatportSyncPolling(chartHash); } // Open modal if user requested if (userRequested) { switch (phase) { case 'discovering': case 'discovered': case 'syncing': case 'sync_complete': openYouTubeDiscoveryModal(chartHash); break; case 'downloading': case 'download_complete': // Open download modal if we have the converted playlist ID if (chartInfo.converted_spotify_playlist_id) { await openDownloadMissingModal(chartInfo.converted_spotify_playlist_id); } break; } } console.log(`โœ… Successfully rehydrated Beatport chart: ${chartName}`); } catch (error) { console.error(`โŒ Error rehydrating Beatport chart "${chartName}":`, error); } } function createYouTubeCardFromBackendState(playlistInfo) { // Create YouTube playlist card from backend state data const urlHash = playlistInfo.url_hash; const playlist = playlistInfo.playlist; const phase = playlistInfo.phase; const container = document.getElementById('youtube-playlist-container'); // Remove placeholder if it exists const placeholder = container.querySelector('.youtube-playlist-placeholder'); if (placeholder) { placeholder.remove(); } // Create card HTML (using EXACT same structure as createYouTubeCard) const cardHtml = `
โ–ถ
${escapeHtml(playlist.name)}
${playlist.tracks.length} tracks ${getPhaseText(phase)}
โ™ช ${playlistInfo.spotify_total} / โœ“ ${playlistInfo.spotify_matches} / โœ— ${playlistInfo.spotify_total - playlistInfo.spotify_matches} / ${Math.round(getProgressWidth(playlistInfo))}%
`; container.insertAdjacentHTML('beforeend', cardHtml); // Store state for UI management (but backend remains source of truth) youtubePlaylistStates[urlHash] = { phase: phase, url: playlistInfo.url, playlist: playlist, cardElement: document.getElementById(`youtube-card-${urlHash}`), discoveryResults: [], discoveryProgress: playlistInfo.discovery_progress, spotifyMatches: playlistInfo.spotify_matches, convertedSpotifyPlaylistId: playlistInfo.converted_spotify_playlist_id, backendSynced: true // Flag to indicate this came from backend }; console.log(`๐Ÿƒ Created YouTube card from backend state: ${playlist.name} (${phase})`); } function getActionButtonText(phase) { switch (phase) { case 'fresh': return 'Discover'; case 'discovering': return 'View Progress'; case 'discovered': return 'View Results'; case 'syncing': return 'View Sync'; case 'sync_complete': return 'Download'; case 'downloading': return 'View Downloads'; case 'download_complete': return 'Complete'; default: return 'Open'; } } function getPhaseText(phase) { switch (phase) { case 'fresh': return 'Ready to discover'; case 'discovering': return 'Discovering...'; case 'discovered': return 'Discovery Complete'; case 'syncing': return 'Syncing...'; case 'sync_complete': return 'Sync Complete'; case 'downloading': return 'Downloading...'; case 'download_complete': return 'Download Complete'; default: return phase; } } function getPhaseColor(phase) { switch (phase) { case 'fresh': return '#999'; case 'discovering': case 'syncing': case 'downloading': return '#ffa500'; case 'discovered': case 'sync_complete': case 'download_complete': return 'rgb(var(--accent-rgb))'; default: return '#999'; } } function getProgressWidth(playlistInfo) { if (playlistInfo.phase === 'fresh') return 0; if (playlistInfo.spotify_total === 0) return 0; return Math.round((playlistInfo.spotify_matches / playlistInfo.spotify_total) * 100); } async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) { // Rehydrate a YouTube playlist's discovery modal state (similar to rehydrateModal) const urlHash = playlistInfo.url_hash; const playlistName = playlistInfo.playlist_name; const phase = playlistInfo.phase; console.log(`๐Ÿ’ง Rehydrating YouTube playlist "${playlistName}" (Phase: ${phase}) - User requested: ${userRequested}`); try { // First, ensure the card exists (create from backend if needed) if (!youtubePlaylistStates[urlHash] || !youtubePlaylistStates[urlHash].cardElement) { console.log(`๐Ÿƒ Creating missing YouTube card for rehydration: ${playlistName}`); // Since playlistInfo from active processes doesn't have full playlist data, // we need to fetch it from the backend first try { const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); if (stateResponse.ok) { const fullPlaylistState = await stateResponse.json(); createYouTubeCardFromBackendState(fullPlaylistState); } else { console.error(`โŒ Could not fetch full playlist state for card creation: ${playlistName}`); return; // Can't create card without playlist data } } catch (error) { console.error(`โŒ Error fetching playlist state for card creation: ${error.message}`); return; } } // Fetch full state from backend to get discovery results let fullState = null; if (phase !== 'fresh' && phase !== 'discovering') { try { console.log(`๐Ÿ” Fetching full backend state for: ${playlistName}`); const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); if (stateResponse.ok) { fullState = await stateResponse.json(); console.log(`๐Ÿ“‹ Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); } } catch (error) { console.warn(`โš ๏ธ Could not fetch full state for ${playlistName}:`, error.message); } } // Update local state to match backend const state = youtubePlaylistStates[urlHash]; state.phase = phase; state.discoveryProgress = playlistInfo.discovery_progress; state.spotifyMatches = playlistInfo.spotify_matches; state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id; // Restore discovery results if we have them if (fullState && fullState.discovery_results) { state.discoveryResults = fullState.discovery_results; state.syncPlaylistId = fullState.sync_playlist_id; state.syncProgress = fullState.sync_progress || {}; console.log(`โœ… Restored ${state.discoveryResults.length} discovery results from backend`); // Update modal if it already exists const existingModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); if (existingModal && !existingModal.classList.contains('hidden')) { console.log(`๐Ÿ”„ Refreshing existing modal with restored discovery results`); refreshYouTubeDiscoveryModalTable(urlHash); } } // Update card display updateYouTubeCardPhase(urlHash, phase); updateYouTubeCardProgress(urlHash, playlistInfo); // Handle active polling resumption if (phase === 'discovering') { console.log(`๐Ÿ” Resuming discovery polling for: ${playlistName}`); startYouTubeDiscoveryPolling(urlHash); } else if (phase === 'syncing') { console.log(`๐Ÿ”„ Resuming sync polling for: ${playlistName}`); startYouTubeSyncPolling(urlHash); } // Open modal if user requested if (userRequested) { switch (phase) { case 'discovering': case 'discovered': case 'syncing': case 'sync_complete': openYouTubeDiscoveryModal(urlHash); break; case 'downloading': case 'download_complete': // Open download modal if we have the converted playlist ID if (playlistInfo.converted_spotify_playlist_id) { await openDownloadMissingModal(playlistInfo.converted_spotify_playlist_id); } break; } } console.log(`โœ… Successfully rehydrated YouTube playlist: ${playlistName}`); } catch (error) { console.error(`โŒ Error rehydrating YouTube playlist "${playlistName}":`, error); } } async function removeYouTubePlaylistFromBackend(event, urlHash) { // Remove YouTube playlist from backend storage and update UI event.stopPropagation(); // Prevent card click const state = youtubePlaylistStates[urlHash]; if (!state) return; const playlistName = state.playlist.name; try { console.log(`๐Ÿ—‘๏ธ Removing YouTube playlist from backend: ${playlistName}`); const response = await fetch(`/api/youtube/delete/${urlHash}`, { method: 'DELETE' }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to delete playlist'); } // Remove card from UI if (state.cardElement) { state.cardElement.remove(); } // Remove from client state delete youtubePlaylistStates[urlHash]; // Stop any active polling if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } // Close discovery modal if open const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); if (modal) { modal.remove(); } // Show placeholder if no cards left const container = document.getElementById('youtube-playlist-container'); const cards = container.querySelectorAll('.youtube-playlist-card'); if (cards.length === 0) { container.innerHTML = '
No YouTube playlists added yet. Parse a YouTube playlist URL above to get started!
'; } showToast(`Removed "${playlistName}" from backend storage`, 'success'); console.log(`โœ… Successfully removed YouTube playlist: ${playlistName}`); } catch (error) { console.error(`โŒ Error removing YouTube playlist "${playlistName}":`, error); showToast(`Error removing playlist: ${error.message}`, 'error'); } } async function loadSpotifyPlaylists() { const container = document.getElementById('spotify-playlist-container'); const refreshBtn = document.getElementById('spotify-refresh-btn'); container.innerHTML = `
๐Ÿ”„ Loading playlists...
`; refreshBtn.disabled = true; refreshBtn.textContent = '๐Ÿ”„ Loading...'; try { const response = await fetch('/api/spotify/playlists'); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to fetch playlists'); } spotifyPlaylists = await response.json(); renderSpotifyPlaylists(); spotifyPlaylistsLoaded = true; await checkForActiveProcesses(); } catch (error) { container.innerHTML = `
โŒ Error: ${error.message}
`; showToast(`Error loading playlists: ${error.message}`, 'error'); } finally { refreshBtn.disabled = false; refreshBtn.textContent = '๐Ÿ”„ Refresh'; } } function renderSpotifyPlaylists() { const container = document.getElementById('spotify-playlist-container'); if (spotifyPlaylists.length === 0) { container.innerHTML = `
No Spotify playlists found.
`; return; } container.innerHTML = spotifyPlaylists.map(p => { let statusClass = 'status-never-synced'; if (p.sync_status.startsWith('Synced')) statusClass = 'status-synced'; if (p.sync_status === 'Needs Sync') statusClass = 'status-needs-sync'; // This HTML structure creates the interactive playlist cards return `
${escapeHtml(p.name)}
${p.track_count} tracks โ€ข ${p.sync_status}
`; }).join(''); } function handleViewProgressClick(event, playlistId) { event.stopPropagation(); // Prevent the card selection from toggling const process = activeDownloadProcesses[playlistId]; if (process && process.modalElement) { // If a process is active, just show its modal console.log(`Re-opening active download modal for playlist ${playlistId}`); process.modalElement.style.display = 'flex'; } } function updatePlaylistCardUI(playlistId) { const process = activeDownloadProcesses[playlistId]; const progressBtn = document.getElementById(`progress-btn-${playlistId}`); const actionBtn = document.getElementById(`action-btn-${playlistId}`); const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); if (!progressBtn || !actionBtn) return; if (process && process.status === 'running') { // A process is running: show the progress button progressBtn.classList.remove('hidden'); progressBtn.textContent = 'View Progress'; progressBtn.style.backgroundColor = ''; // Reset any custom styling actionBtn.textContent = '๐Ÿ“ฅ Downloading...'; actionBtn.disabled = true; // Remove completion styling from card if (card) card.classList.remove('download-complete'); } else if (process && process.status === 'complete') { // Process completed: show "ready for review" indicator progressBtn.classList.remove('hidden'); progressBtn.textContent = '๐Ÿ“‹ View Results'; progressBtn.style.backgroundColor = '#28a745'; // Green success color progressBtn.style.color = 'white'; actionBtn.textContent = 'โœ… Ready for Review'; actionBtn.disabled = false; // Allow clicking to see results // Add completion styling to card if (card) card.classList.add('download-complete'); } else { // No process or it's been cleaned up: normal state progressBtn.classList.add('hidden'); progressBtn.style.backgroundColor = ''; // Reset styling progressBtn.style.color = ''; // Reset styling actionBtn.textContent = 'Sync / Download'; actionBtn.disabled = false; // Remove completion styling from card if (card) card.classList.remove('download-complete'); } } async function cleanupDownloadProcess(playlistId) { const process = activeDownloadProcesses[playlistId]; if (!process) return; console.log(`๐Ÿงน Cleaning up download process for playlist ${playlistId}`); // Stop any active polling first if (process.poller) { console.log(`๐Ÿ›‘ Stopping individual polling for ${playlistId}`); clearInterval(process.poller); process.poller = null; } // Mark process as no longer running if (process.status === 'running') { process.status = 'complete'; } // If the process has a batchId, tell the server to clean it up. if (process.batchId) { try { console.log(`๐Ÿš€ Sending cleanup request to server for batch: ${process.batchId}`); const response = await fetch('/api/playlists/cleanup_batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ batch_id: process.batchId }) }); // Handle deferred cleanup (202 = wishlist processing in progress) if (response.status === 202) { console.log(`โณ Wishlist processing in progress for batch ${process.batchId}, will retry cleanup in 2s...`); // Retry cleanup after delay to allow wishlist processing to complete setTimeout(async () => { try { await fetch('/api/playlists/cleanup_batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ batch_id: process.batchId }) }); console.log(`โœ… Delayed cleanup completed for batch: ${process.batchId}`); } catch (error) { console.warn(`โš ๏ธ Delayed cleanup failed:`, error); } }, 2000); // 2 second delay } else { console.log(`โœ… Server cleanup completed for batch: ${process.batchId}`); } } catch (error) { console.warn(`โš ๏ธ Failed to send cleanup request to server:`, error); // Don't show toast for cleanup failures - they're not user-facing } } // Remove modal from DOM if (process.modalElement && process.modalElement.parentElement) { process.modalElement.parentElement.removeChild(process.modalElement); } // Remove from client-side global state delete activeDownloadProcesses[playlistId]; // Check if global polling should be stopped checkAndCleanupGlobalPolling(); // Restore card UI (only for non-wishlist playlists) if (playlistId !== 'wishlist') { updatePlaylistCardUI(playlistId); } updateRefreshButtonState(); // Now safe since hasActiveOperations() excludes wishlist } function togglePlaylistSelection(event) { const card = event.currentTarget; const playlistId = card.dataset.playlistId; // Don't toggle if clicking the button if (event.target.tagName === 'BUTTON') return; const isSelected = !card.classList.contains('selected'); card.classList.toggle('selected', isSelected); if (isSelected) { selectedPlaylists.add(playlistId); } else { selectedPlaylists.delete(playlistId); } updateSyncActionsUI(); } function updateSyncActionsUI() { // If sequential sync is running, let the manager handle UI updates if (sequentialSyncManager && sequentialSyncManager.isRunning) { sequentialSyncManager.updateUI(); return; } const selectionInfo = document.getElementById('selection-info'); const startSyncBtn = document.getElementById('start-sync-btn'); const count = selectedPlaylists.size; if (count === 0) { if (selectionInfo) selectionInfo.textContent = 'Select playlists to sync'; if (startSyncBtn) startSyncBtn.disabled = true; } else { if (selectionInfo) selectionInfo.textContent = `${count} playlist${count > 1 ? 's' : ''} selected`; if (startSyncBtn) startSyncBtn.disabled = false; } } async function openPlaylistDetailsModal(event, playlistId) { event.stopPropagation(); const playlist = spotifyPlaylists.find(p => p.id === playlistId); if (!playlist) return; showLoadingOverlay(`Loading playlist: ${playlist.name}...`); try { // --- CACHING LOGIC START --- if (playlistTrackCache[playlistId]) { console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`); // Use the cached tracks instead of fetching const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] }; showPlaylistDetailsModal(fullPlaylist); } else { console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`); // Fetch from the server if not in cache const response = await fetch(`/api/spotify/playlist/${playlistId}`); const fullPlaylist = await response.json(); if (fullPlaylist.error) throw new Error(fullPlaylist.error); // Store the fetched tracks in the cache playlistTrackCache[playlistId] = fullPlaylist.tracks; console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`); // Auto-mirror this Spotify playlist mirrorPlaylist('spotify', playlistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({ track_name: t.name, artist_name: (t.artists && t.artists[0]) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : '', album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '', duration_ms: t.duration_ms || 0, image_url: t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null, source_track_id: t.id || t.spotify_track_id || '' })), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url }); showPlaylistDetailsModal(fullPlaylist); } // --- CACHING LOGIC END --- } catch (error) { showToast(`Error: ${error.message}`, 'error'); } finally { hideLoadingOverlay(); } } function showPlaylistDetailsModal(playlist) { // Create modal if it doesn't exist let modal = document.getElementById('playlist-details-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'playlist-details-modal'; modal.className = 'modal-overlay'; document.body.appendChild(modal); } // Check if there's a completed download missing tracks process for this playlist const activeProcess = activeDownloadProcesses[playlist.id]; const hasCompletedProcess = activeProcess && activeProcess.status === 'complete'; // Check if sync is currently running for this playlist const isSyncing = !!activeSyncPollers[playlist.id]; modal.innerHTML = ` `; modal.style.display = 'flex'; } function closePlaylistDetailsModal() { const modal = document.getElementById('playlist-details-modal'); if (modal) { modal.style.display = 'none'; } } function formatDuration(ms) { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); return `${minutes}:${seconds.toString().padStart(2, '0')}`; } // =============================== // DOWNLOAD MISSING TRACKS MODAL // =============================== let activeAnalysisTaskId = null; let currentPlaylistTracks = []; let analysisResults = []; let missingTracks = []; // New variables for enhanced modal functionality let currentDownloadBatchId = null; // =============================== // HERO SECTION HELPER FUNCTIONS // =============================== /** * Generate hero section HTML for download missing tracks modal * Context-aware display based on available data */ function generateDownloadModalHeroSection(context) { const { type, playlist, artist, album, trackCount } = context; let heroContent = ''; let heroBackgroundImage = ''; switch (type) { case 'album': case 'artist_album': // Artist/album context - show artist + album images const artistImage = artist?.image_url || artist?.images?.[0]?.url; const albumImage = album?.image_url || album?.images?.[0]?.url; const artistSource = artist?.source || album?.source || context.source || ''; const sourceKey = (artistSource || '').toString().toLowerCase(); const sourceIdFields = { spotify: ['spotify_artist_id', 'id', 'artist_id'], itunes: ['itunes_artist_id', 'artist_id', 'id'], deezer: ['deezer_artist_id', 'deezer_id', 'artist_id', 'id'], discogs: ['discogs_artist_id', 'discogs_id', 'artist_id', 'id'], amazon: ['amazon_artist_id', 'amazon_id', 'artist_id', 'id'], hydrabase: ['soul_id', 'hydrabase_artist_id', 'artist_id', 'id'], musicbrainz: ['musicbrainz_id', 'artist_id', 'id'], }; let detailArtistId = artist?.id || artist?.artist_id || ''; for (const field of (sourceIdFields[sourceKey] || ['artist_id', 'id'])) { const candidate = artist?.[field]; if (candidate) { detailArtistId = candidate; break; } } if (detailArtistId && String(detailArtistId).toLowerCase() === String(artist?.name || '').toLowerCase()) { detailArtistId = ''; } const artistHref = detailArtistId ? buildArtistDetailPath(detailArtistId, artistSource || null) : '#'; // Use album image as background if available if (albumImage) { heroBackgroundImage = `
`; } heroContent = `
${artistImage ? `${escapeHtml(artist.name)}` : ''} ${albumImage ? `${escapeHtml(album.name)}` : ''}

${escapeHtml(album.name || 'Unknown Album')}

${album.album_type || 'Album'} ${trackCount} tracks
`; break; case 'playlist': // Playlist context - show playlist info heroContent = `
๐ŸŽต

${escapeHtml(playlist.name)}

by ${escapeHtml(playlist.owner || 'Spotify')}
Playlist ${trackCount} tracks
`; break; case 'wishlist': // Wishlist context - show wishlist icon heroContent = `
๐Ÿ‘๏ธ

Wishlist

From watched artists
Wishlist ${trackCount} tracks
`; break; default: // Fallback - basic display heroContent = `
๐Ÿ“ฅ

Download Missing Tracks

${trackCount} tracks
`; break; } return `
${heroBackgroundImage} ${heroContent}
${context.trackCount}
Total
-
Found
-
Missing
0
Downloaded
×
`; } let modalDownloadPoller = null; let currentModalPlaylistId = null; // PHASE 2: Local cancelled track management (GUI PARITY) let cancelledTracks = new Set(); // Track cancelled track indices like GUI's cancelled_tracks const TRACK_RENDER_BATCH_SIZE = 100; function applyProgressiveTrackRendering(playlistId, totalTrackCount) { if (totalTrackCount <= TRACK_RENDER_BATCH_SIZE) return; const modal = document.getElementById(`download-missing-modal-${playlistId}`); if (!modal) return; const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`); if (!tbody) return; const rows = tbody.querySelectorAll('tr[data-track-index]'); if (rows.length <= TRACK_RENDER_BATCH_SIZE) return; // Hide rows beyond first batch for (let i = TRACK_RENDER_BATCH_SIZE; i < rows.length; i++) { rows[i].classList.add('hidden'); } let revealedCount = TRACK_RENDER_BATCH_SIZE; // Append indicator into .download-tracks-title const titleEl = modal.querySelector('.download-tracks-title'); if (titleEl) { const indicator = document.createElement('span'); indicator.className = 'track-render-indicator'; indicator.id = `track-render-indicator-${playlistId}`; indicator.textContent = `Showing ${revealedCount} of ${totalTrackCount} tracks`; titleEl.appendChild(indicator); } // Scroll listener on table container const container = modal.querySelector('.download-tracks-table-container'); if (!container) return; container.addEventListener('scroll', function onScroll() { const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight; if (scrollBottom > 200) return; if (revealedCount >= rows.length) return; const nextEnd = Math.min(revealedCount + TRACK_RENDER_BATCH_SIZE, rows.length); for (let i = revealedCount; i < nextEnd; i++) { rows[i].classList.remove('hidden'); } revealedCount = nextEnd; const indicator = document.getElementById(`track-render-indicator-${playlistId}`); if (indicator) { indicator.textContent = revealedCount >= rows.length ? `Showing all ${totalTrackCount} tracks` : `Showing ${revealedCount} of ${totalTrackCount} tracks`; } if (revealedCount >= rows.length) { container.removeEventListener('scroll', onScroll); } }); } async function openDownloadMissingModal(playlistId) { showLoadingOverlay('Loading playlist...'); // **NEW**: Check if a process is already active for this playlist if (activeDownloadProcesses[playlistId]) { console.log(`Modal for ${playlistId} already exists. Showing it.`); closePlaylistDetailsModal(); // Close playlist details modal even when reusing existing modal 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'; } hideLoadingOverlay(); return; // Don't create a new one } console.log(`๐Ÿ“ฅ Opening Download Missing Tracks modal for playlist: ${playlistId}`); closePlaylistDetailsModal(); const playlist = spotifyPlaylists.find(p => p.id === playlistId); if (!playlist) { showToast('Could not find playlist data.', 'error'); hideLoadingOverlay(); return; } let tracks = playlistTrackCache[playlistId]; if (!tracks) { try { const fetchUrl = playlistId.startsWith('deezer_arl_') ? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}` : `/api/spotify/playlist/${playlistId}`; const response = await fetch(fetchUrl); const fullPlaylist = await response.json(); if (fullPlaylist.error) throw new Error(fullPlaylist.error); tracks = fullPlaylist.tracks; playlistTrackCache[playlistId] = tracks; } catch (error) { showToast(`Failed to fetch tracks: ${error.message}`, 'error'); hideLoadingOverlay(); return; } } currentPlaylistTracks = tracks; currentModalPlaylistId = playlistId; let modal = document.createElement('div'); modal.id = `download-missing-modal-${playlistId}`; // **NEW**: Unique ID modal.className = 'download-missing-modal'; // **NEW**: Use class for styling modal.style.display = 'none'; // Start hidden document.body.appendChild(modal); // **NEW**: Register the new process in our global state tracker activeDownloadProcesses[playlistId] = { status: 'idle', // idle, running, complete, cancelled modalElement: modal, poller: null, batchId: null, playlist: playlist, tracks: tracks }; // Generate hero section for playlist context const heroContext = { type: 'playlist', playlist: playlist, trackCount: tracks.length, playlistId: playlistId }; modal.innerHTML = `
${generateDownloadModalHeroSection(heroContext)}
๐Ÿ” Library Analysis Ready to start
โฌ Downloads Waiting for analysis

๐Ÿ“‹ Track Analysis & Download Status

${tracks.length} / ${tracks.length} tracks selected
${tracks.map((track, index) => ` `).join('')}
# Track Artist Duration Library Match Download Status Actions
${index + 1} ${renderModalTrackPlayButton(playlistId, index)}${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} ${formatDuration(track.duration_ms)} ๐Ÿ” Pending - -
`; applyProgressiveTrackRendering(playlistId, tracks.length); modal.style.display = 'flex'; hideLoadingOverlay(); } async function autoSavePlaylistM3U(playlistId) { /** * Automatically save M3U file server-side for playlist modals only. * Albums are skipped โ€” they're already grouped by media servers. * The server checks the m3u_export.enabled setting before writing. * Uses real DB file paths via /api/generate-playlist-m3u. */ const process = activeDownloadProcesses[playlistId]; if (!process || !process.tracks || process.tracks.length === 0) { return; } const modal = document.getElementById(`download-missing-modal-${playlistId}`); if (!modal) return; // Skip M3U for non-playlist downloads โ€” albums, singles, redownloads, etc. const nonPlaylistPrefixes = [ 'artist_album_', 'discover_album_', 'enhanced_search_album_', 'enhanced_search_track_', 'seasonal_album_', 'spotify_library_', 'beatport_release_', 'discover_cache_', 'issue_download_', 'library_redownload_', 'redownload_', ]; if (nonPlaylistPrefixes.some(p => playlistId.startsWith(p))) return; const playlistName = process.playlist?.name || process.playlistName || 'Playlist'; const artistName = process.artist?.name || ''; const albumName = process.album?.name || ''; const releaseDate = process.album?.release_date || ''; const year = releaseDate ? releaseDate.substring(0, 4) : ''; try { const response = await fetch('/api/generate-playlist-m3u', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playlist_name: playlistName, tracks: _extractM3UTracks(process.tracks), context_type: 'playlist', artist_name: artistName, album_name: albumName, year: year, save_to_disk: true }) }); if (response.ok) { console.log(`โœ… Auto-saved M3U for playlist: ${playlistName}`); } else { console.warn(`โš ๏ธ Failed to auto-save M3U for ${playlistName}`); } } catch (error) { console.debug('Auto-save M3U error (non-critical):', error); } } function generateM3UContent(playlistId) { /** * Generate M3U file content from modal data * Shared between manual export and auto-save */ const process = activeDownloadProcesses[playlistId]; if (!process || !process.tracks || process.tracks.length === 0) { return null; } const tracks = process.tracks; const playlistName = process.playlist?.name || process.playlistName || 'Playlist'; // Generate M3U8 content with status information let m3uContent = '#EXTM3U\n'; m3uContent += `#PLAYLIST:${playlistName}\n`; m3uContent += `#GENERATED:${new Date().toISOString()}\n\n`; let foundCount = 0; let downloadedCount = 0; let missingCount = 0; tracks.forEach((track, index) => { const durationSeconds = track.duration_ms ? Math.floor(track.duration_ms / 1000) : -1; let artists = 'Unknown Artist'; if (Array.isArray(track.artists)) { artists = track.artists.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : String(a)).filter(Boolean).join(', ') || 'Unknown Artist'; } else if (typeof track.artists === 'string') { artists = track.artists; } else if (track.artist) { artists = typeof track.artist === 'object' ? (track.artist.name || 'Unknown Artist') : String(track.artist); } // Check library match status from the modal UI const matchEl = document.getElementById(`match-${playlistId}-${index}`); const downloadEl = document.getElementById(`download-${playlistId}-${index}`); const isFoundInLibrary = matchEl && matchEl.textContent.includes('Found'); const isDownloaded = downloadEl && downloadEl.textContent.includes('Completed'); const isMissing = matchEl && matchEl.textContent.includes('Missing'); // Track status let status = 'UNKNOWN'; if (isDownloaded) { status = 'DOWNLOADED'; downloadedCount++; } else if (isFoundInLibrary) { status = 'FOUND_IN_LIBRARY'; foundCount++; } else if (isMissing) { status = 'MISSING'; missingCount++; } // Add track info m3uContent += `#EXTINF:${durationSeconds},${artists} - ${track.name}\n`; m3uContent += `#STATUS:${status}\n`; // Generate file path const sanitizedArtist = artists.replace(/[/\\?%*:|"<>]/g, '-'); const sanitizedTrack = track.name.replace(/[/\\?%*:|"<>]/g, '-'); if (isDownloaded || isFoundInLibrary) { m3uContent += `${sanitizedArtist} - ${sanitizedTrack}.mp3\n\n`; } else { m3uContent += `# NOT AVAILABLE: ${sanitizedArtist} - ${sanitizedTrack}.mp3\n\n`; } }); // Add summary m3uContent += `#SUMMARY\n`; m3uContent += `#TOTAL_TRACKS:${tracks.length}\n`; m3uContent += `#FOUND_IN_LIBRARY:${foundCount}\n`; m3uContent += `#DOWNLOADED:${downloadedCount}\n`; m3uContent += `#MISSING:${missingCount}\n`; return m3uContent; } async function exportPlaylistAsM3U(playlistId) { /** * Export the tracks from the download missing tracks modal as an M3U playlist file. * Downloads via browser AND saves server-side to the relevant folder (force=true). * Uses real DB file paths via /api/generate-playlist-m3u. */ console.log(`๐Ÿ“‹ Exporting playlist ${playlistId} as M3U`); const process = activeDownloadProcesses[playlistId]; if (!process || !process.tracks || process.tracks.length === 0) { showToast('No tracks available to export', 'warning'); return; } const playlistName = process.playlist?.name || process.playlistName || 'Playlist'; const albumPrefixes = ['artist_album_', 'discover_album_', 'enhanced_search_album_', 'seasonal_album_', 'spotify_library_', 'beatport_release_', 'discover_cache_']; const isAlbumExport = albumPrefixes.some(p => playlistId.startsWith(p)); const releaseDate = process.album?.release_date || ''; const year = releaseDate ? releaseDate.substring(0, 4) : ''; let m3uContent, foundCount, missingCount; try { const response = await fetch('/api/generate-playlist-m3u', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playlist_name: playlistName, tracks: _extractM3UTracks(process.tracks), context_type: isAlbumExport ? 'album' : 'playlist', artist_name: process.artist?.name || '', album_name: process.album?.name || '', year: year, save_to_disk: true, force: true }) }); const data = await response.json(); if (!data.success) throw new Error(data.error || 'Unknown error'); m3uContent = data.m3u_content; foundCount = (data.stats?.found || 0) + (data.stats?.downloaded || 0); missingCount = data.stats?.missing || 0; } catch (error) { showToast('Failed to generate M3U content', 'error'); console.error('M3U export error:', error); return; } // Browser download const blob = new Blob([m3uContent], { type: 'audio/x-mpegurl;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${playlistName.replace(/[/\\?%*:|"<>]/g, '-')}.m3u`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); showToast(`Exported M3U: ${foundCount} available, ${missingCount} missing`, 'success'); console.log(`โœ… Exported M3U - Total: ${process.tracks.length}, Available: ${foundCount}, Missing: ${missingCount}`); } function _extractM3UTracks(tracks) { /** Extract simplified track data for the /api/generate-playlist-m3u endpoint. */ return tracks.map(t => { let artist = ''; if (Array.isArray(t.artists)) { const first = t.artists[0]; artist = typeof first === 'object' ? (first.name || '') : String(first || ''); } else if (typeof t.artists === 'string') { artist = t.artists; } else if (t.artist) { artist = typeof t.artist === 'object' ? (t.artist.name || '') : String(t.artist); } return { name: t.name || '', artist, duration_ms: t.duration_ms || 0 }; }); } // ==================================================================================