From a993a972fd1100e22fb84ee135ce2272ae2a788f Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sat, 27 Sep 2025 12:57:24 -0700 Subject: [PATCH] beatport progress --- web_server.py | 141 ++++++++ webui/static/script.js | 753 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 874 insertions(+), 20 deletions(-) diff --git a/web_server.py b/web_server.py index 7d6292e7..b660de69 100644 --- a/web_server.py +++ b/web_server.py @@ -12532,6 +12532,147 @@ def cancel_beatport_sync(url_hash): print(f"โŒ Error cancelling Beatport sync: {e}") return jsonify({"error": str(e)}), 500 +# =================================================================== +# BEATPORT CHART PERSISTENCE API ENDPOINTS +# =================================================================== + +@app.route('/api/beatport/charts', methods=['GET']) +def get_beatport_charts(): + """Get all persistent Beatport chart states for frontend hydration""" + try: + charts = [] + current_time = time.time() + + # Clean up old charts (older than 24 hours) + to_remove = [] + for chart_hash, state in beatport_chart_states.items(): + last_accessed = state.get('last_accessed', 0) + if current_time - last_accessed > 86400: # 24 hours + to_remove.append(chart_hash) + else: + # Include in response + chart_info = { + 'hash': chart_hash, + 'name': state['chart']['name'], + 'track_count': len(state['chart']['tracks']), + 'phase': state.get('phase', 'fresh'), + 'discovery_progress': state.get('discovery_progress', 0), + 'spotify_matches': state.get('spotify_matches', 0), + 'spotify_total': state.get('spotify_total', 0), + 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), + 'download_process_id': state.get('download_process_id'), + 'last_accessed': last_accessed, + 'chart_data': state['chart'] # Full chart data for restoration + } + charts.append(chart_info) + + # Remove old charts + for chart_hash in to_remove: + del beatport_chart_states[chart_hash] + logger.info(f"๐Ÿงน Cleaned up old Beatport chart: {chart_hash}") + + logger.info(f"๐Ÿ“Š Returning {len(charts)} Beatport charts for hydration") + return jsonify(charts) + + except Exception as e: + logger.error(f"โŒ Error getting Beatport charts: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/beatport/charts/status/', methods=['GET']) +def get_beatport_chart_status(chart_hash): + """Get individual Beatport chart status with full state data""" + try: + if chart_hash not in beatport_chart_states: + return jsonify({"error": "Beatport chart not found"}), 404 + + state = beatport_chart_states[chart_hash] + state['last_accessed'] = time.time() # Update access time + + # Return full state including discovery results for modal restoration + response = { + 'hash': chart_hash, + 'phase': state.get('phase', 'fresh'), + 'status': state.get('status', 'fresh'), + 'discovery_progress': state.get('discovery_progress', 0), + 'spotify_matches': state.get('spotify_matches', 0), + 'spotify_total': state.get('spotify_total', 0), + 'discovery_results': state.get('discovery_results', []), + 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), + 'download_process_id': state.get('download_process_id'), + 'sync_playlist_id': state.get('sync_playlist_id'), + 'sync_progress': state.get('sync_progress', {}), + 'chart_data': state['chart'] # Full chart data + } + + return jsonify(response) + + except Exception as e: + logger.error(f"โŒ Error getting Beatport chart status: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/beatport/charts/update-phase/', methods=['POST']) +def update_beatport_chart_phase(chart_hash): + """Update Beatport chart phase (for modal close operations and reset)""" + try: + if chart_hash not in beatport_chart_states: + return jsonify({"error": "Beatport chart not found"}), 404 + + data = request.get_json() or {} + new_phase = data.get('phase') + is_reset = data.get('reset', False) + + if not new_phase: + return jsonify({"error": "Phase is required"}), 400 + + state = beatport_chart_states[chart_hash] + state['phase'] = new_phase + state['last_accessed'] = time.time() + + # Handle reset operation - clear discovery data + if is_reset and new_phase == 'fresh': + state['discovery_results'] = [] + state['discovery_progress'] = 0 + state['spotify_matches'] = 0 + state['status'] = 'fresh' + state['converted_spotify_playlist_id'] = None + state['download_process_id'] = None + state['sync_playlist_id'] = None + state['sync_progress'] = {} + logger.info(f"๐ŸŽง Reset Beatport chart {chart_hash} to fresh state") + else: + # Handle other phase updates (like download phase transitions) + converted_playlist_id = data.get('converted_spotify_playlist_id') + if converted_playlist_id: + state['converted_spotify_playlist_id'] = converted_playlist_id + + download_process_id = data.get('download_process_id') + if download_process_id: + state['download_process_id'] = download_process_id + + logger.info(f"๐ŸŽง Updated Beatport chart {chart_hash} phase to: {new_phase}") + return jsonify({"success": True, "phase": new_phase}) + + except Exception as e: + logger.error(f"โŒ Error updating Beatport chart phase: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/beatport/charts/delete/', methods=['DELETE']) +def delete_beatport_chart(chart_hash): + """Delete a Beatport chart from backend storage""" + try: + if chart_hash not in beatport_chart_states: + return jsonify({"error": "Beatport chart not found"}), 404 + + chart_name = beatport_chart_states[chart_hash]['chart']['name'] + del beatport_chart_states[chart_hash] + + logger.info(f"๐Ÿ—‘๏ธ Deleted Beatport chart: {chart_name}") + return jsonify({"success": True, "message": f"Deleted chart: {chart_name}"}) + + except Exception as e: + logger.error(f"โŒ Error deleting Beatport chart: {e}") + return jsonify({"error": str(e)}), 500 + def convert_beatport_results_to_spotify_tracks(discovery_results): """Convert Beatport discovery results to Spotify tracks format for sync""" spotify_tracks = [] diff --git a/webui/static/script.js b/webui/static/script.js index ebfda4eb..f7cbb71a 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -1982,9 +1982,12 @@ async function loadSyncData() { if (!spotifyPlaylistsLoaded) { await loadSpotifyPlaylists(); } - + // Load YouTube playlists from backend (always refresh to get latest state) await loadYouTubePlaylistsFromBackend(); + + // Load Beatport charts from backend (always refresh to get latest state) + await loadBeatportChartsFromBackend(); } async function checkForActiveProcesses() { @@ -2106,6 +2109,12 @@ async function rehydrateModal(processInfo, userRequested = false) { 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}`); @@ -2352,13 +2361,359 @@ async function loadYouTubePlaylistsFromBackend() { } 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 response = await fetch('/api/beatport/charts'); + 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' && chartInfo.phase !== 'discovering') { + try { + console.log(`๐Ÿ” Fetching full state for: ${chartInfo.name}`); + const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`); + 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 ? 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) { + 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/update active download process with tracks + 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 || {} + }; + }); + } + + activeDownloadProcesses[convertedPlaylistId] = { + status: chartInfo.phase === 'download_complete' ? 'complete' : 'running', + batchId: chartInfo.download_process_id, + tracks: spotifyTracks, + modalElement: null + }; + } + + console.log(`โœ… Set up active download process for Beatport chart: ${chartInfo.name} with ${activeDownloadProcesses[convertedPlaylistId].tracks.length} tracks`); + } catch (error) { + console.warn(`โš ๏ธ Error setting up download process for Beatport chart "${chartInfo.name}":`, error.message); + } + } + } + + console.log(`โœ… Successfully loaded and rehydrated ${charts.length} Beatport charts`); + + } catch (error) { + console.error('โŒ Error loading Beatport charts from backend:', error); + showToast(`Error loading Beatport charts: ${error.message}`, 'error'); + } +} + +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 stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`); + 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) { + 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 ? 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 discovery polling + if (phase === 'discovering') { + console.log(`๐Ÿ” Resuming discovery polling for: ${chartName}`); + startBeatportDiscoveryPolling(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; @@ -3451,6 +3806,41 @@ async function closeDownloadMissingModal(playlistId) { } } + // Reset Beatport chart phase to 'discovered' when modal is closed + if (playlistId.startsWith('beatport_')) { + const urlHash = playlistId.replace('beatport_', ''); + const state = youtubePlaylistStates[urlHash]; + + if (state && state.is_beatport_playlist) { + console.log(`๐Ÿงน [Modal Close] Processing Beatport chart close: playlistId="${playlistId}", urlHash="${urlHash}"`); + + const chartHash = state.beatport_chart_hash || urlHash; + + // Reset to discovered phase (unless download actually started and completed) + if (state.phase !== 'download_complete') { + updateBeatportCardPhase(chartHash, 'discovered'); + state.phase = 'discovered'; + + // Update Beatport chart state + if (beatportChartStates[chartHash]) { + beatportChartStates[chartHash].phase = 'discovered'; + } + + // Update backend state + try { + await fetch(`/api/beatport/charts/update-phase/${chartHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + console.log(`โœ… [Modal Close] Updated backend phase for Beatport chart ${chartHash} to 'discovered'`); + } catch (error) { + console.error(`โŒ [Modal Close] Error updating backend phase for Beatport chart ${chartHash}:`, error); + } + } + } + } + // Enhanced Tidal playlist state management (based on GUI sync.py patterns) if (playlistId.startsWith('tidal_')) { const tidalPlaylistId = playlistId.replace('tidal_', ''); @@ -3815,6 +4205,38 @@ async function startMissingTracksProcess(playlistId) { console.log(`๐Ÿ”„ Updated Tidal playlist ${tidalPlaylistId} to downloading phase`); } } + + // Update Beatport chart phase to 'downloading' if this is a Beatport chart + if (playlistId.startsWith('beatport_')) { + const urlHash = playlistId.replace('beatport_', ''); + const state = youtubePlaylistStates[urlHash]; + + if (state && state.is_beatport_playlist) { + const chartHash = state.beatport_chart_hash || urlHash; + + // Update frontend states + state.phase = 'downloading'; + if (beatportChartStates[chartHash]) { + beatportChartStates[chartHash].phase = 'downloading'; + } + + // Update card UI + updateBeatportCardPhase(chartHash, 'downloading'); + + // Update backend state + try { + fetch(`/api/beatport/charts/update-phase/${chartHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'downloading' }) + }); + } catch (error) { + console.warn('โš ๏ธ Error updating backend Beatport phase to downloading:', error); + } + + console.log(`๐Ÿ”„ Updated Beatport chart ${chartHash} to downloading phase`); + } + } document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none'; document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block'; @@ -4358,6 +4780,42 @@ function processModalStatusUpdate(playlistId, data) { console.log(`โœ… [Status Complete] Updated Tidal playlist ${tidalPlaylistId} to download_complete phase`); } } + + // Update Beatport chart phase to 'download_complete' if this is a Beatport chart + if (playlistId.startsWith('beatport_')) { + const urlHash = playlistId.replace('beatport_', ''); + const state = youtubePlaylistStates[urlHash]; + + if (state && state.is_beatport_playlist) { + const chartHash = state.beatport_chart_hash || urlHash; + + // Update frontend states + state.phase = 'download_complete'; + state.download_process_id = process.batchId; + if (beatportChartStates[chartHash]) { + beatportChartStates[chartHash].phase = 'download_complete'; + } + + // Update card UI + updateBeatportCardPhase(chartHash, 'download_complete'); + + // Update backend state + try { + fetch(`/api/beatport/charts/update-phase/${chartHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phase: 'download_complete', + download_process_id: process.batchId + }) + }); + } catch (error) { + console.warn('โš ๏ธ Error updating backend Beatport phase to download_complete:', error); + } + + console.log(`โœ… [Status Complete] Updated Beatport chart ${chartHash} to download_complete phase`); + } + } // Handle background wishlist processing completion specially if (isBackgroundWishlist) { @@ -10047,6 +10505,11 @@ function startBeatportDiscoveryPolling(urlHash) { failed: (status.spotify_total || 0) - (status.spotify_matches || 0) }); + // Sync with backend Beatport chart state + if (beatportChartStates[chartHash]) { + beatportChartStates[chartHash].phase = status.phase || 'discovering'; + } + // Update modal display with transformed data updateYouTubeDiscoveryModal(urlHash, transformedStatus); } @@ -10165,7 +10628,7 @@ function addBeatportCardToContainer(chartData) { // Add click handler const card = document.getElementById(`beatport-card-${chartData.hash}`); if (card) { - card.addEventListener('click', () => handleBeatportCardClick(chartData.hash)); + card.addEventListener('click', async () => await handleBeatportCardClick(chartData.hash)); } console.log(`๐Ÿƒ Created Beatport card: ${chartData.name}`); @@ -10195,9 +10658,53 @@ async function handleBeatportCardClick(chartHash) { console.log(`๐ŸŽง [Card Click] Opening Beatport discovery modal for ${state.phase} phase`); openYouTubeDiscoveryModal(chartHash); } else if (state.phase === 'downloading' || state.phase === 'download_complete') { - // Show download modal - console.log(`๐Ÿ“ฅ [Card Click] Opening Beatport download modal for ${state.phase} phase`); - showToast('Download modal for Beatport coming soon!', 'info'); + // Open download modal if we have the converted playlist ID + const ytState = youtubePlaylistStates[chartHash]; + if (ytState && ytState.is_beatport_playlist && ytState.convertedSpotifyPlaylistId) { + console.log(`๐Ÿ“ฅ [Card Click] Opening Beatport download modal for ${state.phase} phase`); + + // Get Spotify tracks from discovery results (like startBeatportDownloadMissing does) + if (ytState.discovery_results && ytState.discovery_results.length > 0) { + const 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 || {} + }; + }); + + const playlistName = `[Beatport] ${ytState.playlist.name}`; + try { + await openDownloadMissingModalForYouTube(ytState.convertedSpotifyPlaylistId, playlistName, spotifyTracks); + } catch (error) { + console.error(`โŒ Error opening Beatport download modal:`, error); + hideLoadingOverlay(); + showToast('Error opening download modal', 'error'); + } + } else { + console.warn(`โš ๏ธ [Card Click] No discovery results found for Beatport chart: ${chartHash}`); + showToast('No tracks found - try refreshing the page', 'error'); + } + } else { + console.warn(`โš ๏ธ [Card Click] No converted playlist ID found for Beatport chart: ${chartHash}`); + showToast('Download data not found - try refreshing the page', 'error'); + } } } @@ -10332,15 +10839,27 @@ function startBeatportSyncPolling(urlHash) { // Update final state const state = youtubePlaylistStates[urlHash]; if (state) { + const chartHash = state.beatport_chart_hash || urlHash; if (status.complete) { state.phase = 'sync_complete'; state.convertedSpotifyPlaylistId = status.converted_spotify_playlist_id; - updateBeatportCardPhase(state.beatport_chart_hash || urlHash, 'sync_complete'); + updateBeatportCardPhase(chartHash, 'sync_complete'); updateBeatportModalButtons(urlHash, 'sync_complete'); + + // Sync with backend Beatport chart state + if (beatportChartStates[chartHash]) { + beatportChartStates[chartHash].phase = 'sync_complete'; + } + console.log('โœ… Beatport sync complete:', urlHash); } else { state.phase = 'discovered'; // Revert on error - updateBeatportCardPhase(state.beatport_chart_hash || urlHash, 'discovered'); + updateBeatportCardPhase(chartHash, 'discovered'); + + // Sync with backend Beatport chart state + if (beatportChartStates[chartHash]) { + beatportChartStates[chartHash].phase = 'discovered'; + } } } @@ -10393,7 +10912,7 @@ function updateBeatportModalButtons(urlHash, phase) { async function startBeatportDownloadMissing(urlHash) { try { - console.log('๐Ÿ” Starting download missing tracks for Beatport playlist:', urlHash); + console.log('๐Ÿ” Starting download missing tracks for Beatport chart:', urlHash); const state = youtubePlaylistStates[urlHash]; if (!state || !state.discovery_results) { @@ -10401,18 +10920,35 @@ async function startBeatportDownloadMissing(urlHash) { return; } - // Convert Beatport results to a format compatible with the download modal (same as YouTube) + if (!state.is_beatport_playlist) { + console.error('โŒ State is not a Beatport playlist'); + showToast('Invalid Beatport chart state', 'error'); + return; + } + + // Convert Beatport discovery results to Spotify tracks format (like Tidal does) const spotifyTracks = state.discovery_results .filter(result => result.spotify_data) .map(result => { const track = result.spotify_data; - // Ensure artists is an array of strings (like YouTube format) + // 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 track; + 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) { @@ -10420,27 +10956,49 @@ async function startBeatportDownloadMissing(urlHash) { return; } - // Create a virtual playlist for the download system (same as YouTube) + console.log(`๐ŸŽง Found ${spotifyTracks.length} Spotify tracks for Beatport download`); + + // Create a virtual playlist for the download system const virtualPlaylistId = `beatport_${urlHash}`; const playlistName = `[Beatport] ${state.playlist.name}`; - // Store reference for card navigation (same as YouTube) + // Store reference for card navigation (but don't change phase yet) state.convertedSpotifyPlaylistId = virtualPlaylistId; - // Close the discovery modal if it's open (same as YouTube) + // Store converted playlist ID in backend but keep current phase + const chartHash = state.beatport_chart_hash || urlHash; + if (beatportChartStates[chartHash]) { + try { + await fetch(`/api/beatport/charts/update-phase/${chartHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phase: state.phase, // Keep current phase (should be 'discovered') + converted_spotify_playlist_id: virtualPlaylistId + }) + }); + console.log('โœ… Updated backend with Beatport converted playlist ID (phase unchanged)'); + } catch (error) { + console.warn('โš ๏ธ Error updating backend Beatport state:', error); + } + } + + // Close the discovery modal if it's open const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); if (discoveryModal) { discoveryModal.classList.add('hidden'); console.log('๐Ÿ”„ Closed Beatport discovery modal to show download modal'); } - // Open download missing tracks modal for Beatport playlist (same as YouTube) + // DON'T update card phase here - let the download modal handle phase changes when "Begin Analysis" is clicked + + // Open download missing tracks modal using the same system as YouTube/Tidal await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); - // Phase will change to 'downloading' when user clicks "Begin Analysis" button + console.log(`โœ… Opened download modal for Beatport chart: ${state.playlist.name}`); } catch (error) { - console.error('โŒ Error starting download missing tracks:', error); + console.error('โŒ Error starting Beatport download missing tracks:', error); showToast(`Error starting downloads: ${error.message}`, 'error'); } } @@ -11153,6 +11711,8 @@ function getModalActionButtons(urlHash, phase, state = null) { if (isTidal) { // Tidal doesn't have a reset function yet, but could be added // syncCompleteButtons += ``; + } else if (isBeatport) { + syncCompleteButtons += ``; } else { syncCompleteButtons += ``; } @@ -11322,7 +11882,91 @@ function closeYouTubeDiscoveryModal(urlHash) { modal.classList.add('hidden'); console.log('๐Ÿšช Hidden YouTube discovery modal (preserving state):', urlHash); } - + + // Handle phase reset for completed discovery (Tidal/Beatport pattern) + const state = youtubePlaylistStates[urlHash]; + if (state) { + const isTidal = state.is_tidal_playlist; + const isBeatport = state.is_beatport_playlist; + + // Reset to 'discovered' phase if modal is closed after completion (like Tidal does) + if (state.phase === 'sync_complete' || state.phase === 'download_complete') { + console.log(`๐Ÿงน [Modal Close] Resetting ${isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')} state after completion`); + + if (isTidal) { + // Tidal: Extract playlist ID and reset Tidal state + const tidalPlaylistId = state.beatport_chart_hash ? state.beatport_chart_hash.replace('tidal_', '') : null; + if (tidalPlaylistId && tidalPlaylistStates[tidalPlaylistId]) { + // Preserve discovery data but reset phase + const preservedData = { + playlist: tidalPlaylistStates[tidalPlaylistId].playlist, + discovery_results: tidalPlaylistStates[tidalPlaylistId].discovery_results, + spotify_matches: tidalPlaylistStates[tidalPlaylistId].spotify_matches, + discovery_progress: tidalPlaylistStates[tidalPlaylistId].discovery_progress, + convertedSpotifyPlaylistId: tidalPlaylistStates[tidalPlaylistId].convertedSpotifyPlaylistId + }; + + // Clear download state + delete tidalPlaylistStates[tidalPlaylistId].download_process_id; + delete tidalPlaylistStates[tidalPlaylistId].phase; + + // Restore preserved data and set to discovered phase + Object.assign(tidalPlaylistStates[tidalPlaylistId], preservedData); + tidalPlaylistStates[tidalPlaylistId].phase = 'discovered'; + + updateTidalCardPhase(tidalPlaylistId, 'discovered'); + + // Update backend state + try { + fetch(`/api/tidal/update-phase/${tidalPlaylistId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + } catch (error) { + console.warn('โš ๏ธ Error updating backend Tidal phase:', error); + } + } + } else if (isBeatport) { + // Beatport: Reset chart state + const chartHash = state.beatport_chart_hash || urlHash; + if (beatportChartStates[chartHash]) { + beatportChartStates[chartHash].phase = 'discovered'; + updateBeatportCardPhase(chartHash, 'discovered'); + + // Update backend state + try { + fetch(`/api/beatport/charts/update-phase/${chartHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + } catch (error) { + console.warn('โš ๏ธ Error updating backend Beatport phase:', error); + } + } + } else { + // YouTube: Reset to discovered phase + updateYouTubeCardPhase(urlHash, 'discovered'); + + // Update backend state + try { + fetch(`/api/youtube/update-phase/${urlHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + } catch (error) { + console.warn('โš ๏ธ Error updating backend YouTube phase:', error); + } + } + + // Reset frontend state to discovered + state.phase = 'discovered'; + console.log(`โœ… [Modal Close] Reset to discovered phase: ${urlHash}`); + } + } + // Keep modal reference and all state intact // Discovery polling continues in background if active } @@ -11624,13 +12268,82 @@ async function resetYouTubePlaylist(urlHash) { showToast(`Reset "${state.playlist.name}" to fresh state`, 'success'); console.log(`โœ… Successfully reset YouTube playlist: ${state.playlist.name}`); - + } catch (error) { console.error(`โŒ Error resetting YouTube playlist:`, error); showToast(`Error resetting playlist: ${error.message}`, 'error'); } } +async function resetBeatportChart(urlHash) { + const state = youtubePlaylistStates[urlHash]; + const chartState = beatportChartStates[urlHash]; + + if (!state || !state.is_beatport_playlist || !chartState) { + console.error('โŒ Invalid Beatport chart state for reset'); + return; + } + + try { + console.log(`๐Ÿ”„ Resetting Beatport chart to fresh state: ${state.playlist.name}`); + + // Call backend reset endpoint for Beatport + const chartHash = state.beatport_chart_hash || urlHash; + const response = await fetch(`/api/beatport/charts/update-phase/${chartHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phase: 'fresh', + reset: true + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to reset Beatport chart'); + } + + // Stop any active polling + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + + // Update client state to match backend reset + state.phase = 'fresh'; + state.discoveryResults = []; + state.discoveryProgress = 0; + state.spotifyMatches = 0; + state.discovery_results = []; + state.discovery_progress = 0; + state.spotify_matches = 0; + state.syncPlaylistId = null; + state.syncProgress = {}; + state.convertedSpotifyPlaylistId = null; + + // Update Beatport chart state + chartState.phase = 'fresh'; + + // Update card to reflect fresh state + updateBeatportCardPhase(chartHash, 'fresh'); + updateBeatportCardProgress(chartHash, { + spotify_total: state.playlist.tracks.length, + spotify_matches: 0, + failed: 0 + }); + + // Close modal + closeYouTubeDiscoveryModal(urlHash); + + showToast(`Reset "${state.playlist.name}" to fresh state`, 'success'); + console.log(`โœ… Successfully reset Beatport chart: ${state.playlist.name}`); + + } catch (error) { + console.error(`โŒ Error resetting Beatport chart:`, error); + showToast(`Error resetting chart: ${error.message}`, 'error'); + } +} + // ============================================================================ // ARTISTS PAGE FUNCTIONALITY - ELEGANT SEARCH & DISCOVERY // ============================================================================