// == 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 = `