From f5ae3d611c7d5e9cee34522fa6742b624398c99d Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Fri, 26 Sep 2025 22:04:46 -0700 Subject: [PATCH] beatport progress --- web_server.py | 388 ++++++++++++++++++++++++++++++ webui/static/script.js | 524 +++++++++++++++++++++++++++++++++++++++-- webui/static/style.css | 2 +- 3 files changed, 898 insertions(+), 16 deletions(-) diff --git a/web_server.py b/web_server.py index 92807dab..6387cdaf 100644 --- a/web_server.py +++ b/web_server.py @@ -10155,6 +10155,10 @@ def cancel_tidal_sync(playlist_id): youtube_playlist_states = {} # Key: url_hash, Value: persistent playlist state youtube_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="youtube_discovery") +# Global state for Beatport chart management (persistent across page reloads) +beatport_chart_states = {} # Key: url_hash, Value: persistent chart state +beatport_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="beatport_discovery") + @app.route('/api/youtube/parse', methods=['POST']) def parse_youtube_playlist_endpoint(): """Parse a YouTube playlist URL and return structured track data""" @@ -12011,6 +12015,390 @@ def get_beatport_genre_image(genre_slug, genre_id): "image_url": None }), 500 +@app.route('/api/beatport/hype-top-100', methods=['GET']) +def get_beatport_hype_top_100(): + """Get Beatport Hype Top 100 - Improved with fixed URL""" + try: + logger.info("🔥 API request for Beatport Hype Top 100") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Get query parameters + limit = int(request.args.get('limit', '100')) + + # Scrape Hype Top 100 using improved method + tracks = scraper.scrape_hype_top_100(limit=limit) + + logger.info(f"✅ Successfully scraped {len(tracks)} tracks from Beatport Hype Top 100") + + return jsonify({ + "success": True, + "tracks": tracks, + "chart_name": "Beatport Hype Top 100", + "count": len(tracks) + }) + + except Exception as e: + logger.error(f"❌ Error fetching Beatport Hype Top 100: {e}") + return jsonify({ + "success": False, + "error": str(e), + "tracks": [], + "count": 0 + }), 500 + +@app.route('/api/beatport/top-100-releases', methods=['GET']) +def get_beatport_top_100_releases(): + """Get Beatport Top 100 Releases - New endpoint""" + try: + logger.info("📊 API request for Beatport Top 100 Releases") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Get query parameters + limit = int(request.args.get('limit', '100')) + + # Scrape Top 100 Releases using new method + tracks = scraper.scrape_top_100_releases(limit=limit) + + logger.info(f"✅ Successfully scraped {len(tracks)} tracks from Beatport Top 100 Releases") + + return jsonify({ + "success": True, + "tracks": tracks, + "chart_name": "Beatport Top 100 Releases", + "count": len(tracks) + }) + + except Exception as e: + logger.error(f"❌ Error fetching Beatport Top 100 Releases: {e}") + return jsonify({ + "success": False, + "error": str(e), + "tracks": [], + "count": 0 + }), 500 + +@app.route('/api/beatport/chart-sections', methods=['GET']) +def get_beatport_chart_sections(): + """Get dynamically discovered Beatport chart sections""" + try: + logger.info("🔍 API request for Beatport chart sections discovery") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Discover chart sections dynamically + chart_sections = scraper.discover_chart_sections() + + logger.info(f"✅ Successfully discovered chart sections") + + return jsonify({ + "success": True, + "chart_sections": chart_sections, + "summary": chart_sections.get('summary', {}) + }) + + except Exception as e: + logger.error(f"❌ Error discovering Beatport chart sections: {e}") + return jsonify({ + "success": False, + "error": str(e), + "chart_sections": {}, + "summary": {} + }), 500 + +@app.route('/api/beatport/dj-charts-improved', methods=['GET']) +def get_beatport_dj_charts_improved(): + """Get Beatport DJ Charts using improved method""" + try: + logger.info("🎧 API request for Beatport DJ Charts (improved)") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Get query parameters + limit = int(request.args.get('limit', '20')) + + # Scrape DJ Charts using improved method + charts = scraper.scrape_dj_charts(limit=limit) + + logger.info(f"✅ Successfully scraped {len(charts)} DJ charts") + + return jsonify({ + "success": True, + "charts": charts, + "chart_name": "Beatport DJ Charts", + "count": len(charts) + }) + + except Exception as e: + logger.error(f"❌ Error fetching Beatport DJ Charts: {e}") + return jsonify({ + "success": False, + "error": str(e), + "charts": [], + "count": 0 + }), 500 + +@app.route('/api/beatport/discovery/start/', methods=['POST']) +def start_beatport_discovery(url_hash): + """Start Spotify discovery for Beatport chart tracks""" + import json + try: + logger.info(f"🔍 Starting Beatport discovery for: {url_hash}") + + # Get chart data from request body + data = request.get_json() or {} + print(f"🔍 Raw request data: {data}") + + chart_data = data.get('chart_data') + print(f"🔍 Chart data extracted: {chart_data is not None}") + + # Debug logging + if chart_data: + print(f"🔍 Chart data keys: {list(chart_data.keys()) if isinstance(chart_data, dict) else 'Not a dict'}") + print(f"🔍 Chart name: {chart_data.get('name') if isinstance(chart_data, dict) else 'N/A'}") + if isinstance(chart_data, dict) and 'tracks' in chart_data: + print(f"🔍 Number of tracks: {len(chart_data['tracks'])}") + if chart_data['tracks']: + print(f"🔍 First track: {chart_data['tracks'][0]}") + else: + print("🔍 No chart data received") + + if not chart_data or not chart_data.get('tracks'): + return jsonify({"error": "Chart data with tracks is required"}), 400 + + # Initialize Beatport chart state (similar to YouTube) + if url_hash not in beatport_chart_states: + beatport_chart_states[url_hash] = { + 'chart': chart_data, + 'phase': 'fresh', + 'discovery_results': [], + 'discovery_progress': 0, + 'spotify_matches': 0, + 'spotify_total': len(chart_data['tracks']), + 'status': 'fresh', + 'last_accessed': time.time() + } + + state = beatport_chart_states[url_hash] + state['last_accessed'] = time.time() + + if state['phase'] == 'discovering': + return jsonify({"error": "Discovery already in progress"}), 400 + + # Update phase to discovering + state['phase'] = 'discovering' + state['status'] = 'discovering' + state['discovery_progress'] = 0 + state['spotify_matches'] = 0 + + # Add activity for discovery start + chart_name = chart_data.get('name', 'Unknown Chart') + track_count = len(chart_data['tracks']) + add_activity_item("🔍", "Beatport Discovery Started", f"'{chart_name}' - {track_count} tracks", "Now") + + # Start discovery worker + future = beatport_discovery_executor.submit(_run_beatport_discovery_worker, url_hash) + state['discovery_future'] = future + + print(f"🔍 Started Spotify discovery for Beatport chart: {chart_name}") + return jsonify({"success": True, "message": "Discovery started", "status": "discovering"}) + + except Exception as e: + logger.error(f"❌ Error starting Beatport discovery: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/beatport/discovery/status/', methods=['GET']) +def get_beatport_discovery_status(url_hash): + """Get real-time discovery status for a Beatport chart""" + try: + if url_hash not in beatport_chart_states: + return jsonify({"error": "Beatport chart not found"}), 404 + + state = beatport_chart_states[url_hash] + state['last_accessed'] = time.time() + + response = { + 'phase': state['phase'], + 'status': state['status'], + 'progress': state['discovery_progress'], + 'spotify_matches': state['spotify_matches'], + 'spotify_total': state['spotify_total'], + 'results': state['discovery_results'], + 'complete': state['phase'] == 'discovered' + } + + return jsonify(response) + + except Exception as e: + logger.error(f"❌ Error getting Beatport discovery status: {e}") + return jsonify({"error": str(e)}), 500 + +def _run_beatport_discovery_worker(url_hash): + """Background worker for Beatport Spotify discovery process""" + try: + state = beatport_chart_states[url_hash] + chart = state['chart'] + tracks = chart['tracks'] + + print(f"🔍 Starting Spotify discovery for {len(tracks)} Beatport tracks...") + + if not spotify_client or not spotify_client.is_authenticated(): + print("❌ Spotify client not authenticated") + state['status'] = 'error' + state['phase'] = 'fresh' + return + + # Process each track for Spotify discovery + for i, track in enumerate(tracks): + try: + # Update progress + state['discovery_progress'] = int((i / len(tracks)) * 100) + + # Get track info from Beatport data (frontend sends 'name' and 'artists' fields) + track_title = track.get('name', 'Unknown Title') + track_artists = track.get('artists', ['Unknown Artist']) + # Handle artists - could be a list or string + if isinstance(track_artists, list): + if len(track_artists) > 0 and isinstance(track_artists[0], str): + # Handle case like ["CID,Taylr Renee"] - split on comma + track_artist = track_artists[0].split(',')[0].strip() + else: + track_artist = track_artists[0] if track_artists else 'Unknown Artist' + else: + track_artist = str(track_artists) + + print(f"🔍 Searching Spotify for: '{track_artist}' - '{track_title}'") + + # Try multiple search strategies + spotify_track = None + + # Clean track title for search (remove remix info) + import re + clean_title = re.sub(r'\s*\([^)]*\)', '', track_title).strip() # Remove (Extended Mix), (Original Mix), etc. + clean_title = re.sub(r'\s*\[[^\]]*\]', '', clean_title).strip() # Remove [brackets] + + # Strategy 1: Simple search with cleaned terms + search_query = f"{track_artist} {clean_title}" + print(f"🔍 Search query: {search_query}") + + try: + search_results = spotify_client.search_tracks(search_query, limit=10) + print(f"🔍 Search results type: {type(search_results)}, length: {len(search_results) if search_results else 0}") + + # Find best match from search_tracks result + if search_results: + for result in search_results: + try: + # Check if artist matches (case insensitive, flexible) + result_artists = [artist.lower() for artist in result.artists] + artist_match = any(track_artist.lower() in artist for artist in result_artists) or any(artist in track_artist.lower() for artist in result_artists) + + # Check if title matches (case insensitive, flexible) + title_match = clean_title.lower() in result.name.lower() or result.name.lower() in clean_title.lower() + + if artist_match and title_match: + spotify_track = result + print(f"✅ Found match: {result.artists[0]} - {result.name}") + break + except Exception as e: + print(f"❌ Error processing search result: {e}") + continue + except Exception as e: + print(f"❌ Error in Spotify search: {e}") + + # Strategy 2: Try artist-only search if no match + if not spotify_track: + print(f"🔍 Trying artist-only search: {track_artist}") + search_results = spotify_client.search_tracks(track_artist, limit=5) + + if search_results: + for result in search_results: + result_artists = [artist.lower() for artist in result.artists] + if any(track_artist.lower() in artist for artist in result_artists): + print(f"✅ Found by artist: {result.artists[0]} - {result.name}") + spotify_track = result + break + + # Create result entry + result_entry = { + 'index': i, # Add index for frontend table row identification + 'beatport_track': { + 'title': track_title, + 'artist': track_artist + }, + 'status': 'found' if spotify_track else 'not_found', + 'status_class': 'found' if spotify_track else 'not-found' # Add status class for CSS styling + } + + if spotify_track: + # Debug: show available attributes + print(f"🔍 Spotify track attributes: {dir(spotify_track)}") + + # Format artists correctly for frontend compatibility + formatted_artists = [] + if isinstance(spotify_track.artists, list): + # If it's already a list of strings, convert to objects with 'name' property + for artist in spotify_track.artists: + if isinstance(artist, str): + formatted_artists.append({'name': artist}) + else: + # If it's already an object, use as-is + formatted_artists.append(artist) + else: + # Single artist case + formatted_artists = [{'name': str(spotify_track.artists)}] + + result_entry['spotify_data'] = { + 'name': spotify_track.name, + 'artists': formatted_artists, # Now formatted as list of objects with 'name' property + 'album': spotify_track.album, # Already a string + 'id': spotify_track.id + # Remove uri for now since it's causing errors + } + state['spotify_matches'] += 1 + + state['discovery_results'].append(result_entry) + + # Small delay to avoid rate limiting + time.sleep(0.1) + + except Exception as e: + print(f"❌ Error processing Beatport track {i}: {e}") + # Add error result + state['discovery_results'].append({ + 'index': i, # Add index for frontend table row identification + 'beatport_track': { + 'title': track.get('name', 'Unknown'), # Changed from 'title' to 'name' to match track structure + 'artist': track.get('artists', ['Unknown'])[0] if isinstance(track.get('artists'), list) else 'Unknown' + }, + 'status': 'error', + 'status_class': 'error', # Add status class for CSS styling + 'error': str(e) + }) + + # Mark discovery as complete + state['discovery_progress'] = 100 + state['phase'] = 'discovered' + state['status'] = 'discovered' + + # Add activity for completion + chart_name = chart.get('name', 'Unknown Chart') + add_activity_item("✅", "Beatport Discovery Complete", + f"'{chart_name}' - {state['spotify_matches']}/{len(tracks)} tracks found", "Now") + + print(f"✅ Beatport discovery complete: {state['spotify_matches']}/{len(tracks)} tracks found") + + except Exception as e: + print(f"❌ Error in Beatport discovery worker: {e}") + if url_hash in beatport_chart_states: + beatport_chart_states[url_hash]['status'] = 'error' + beatport_chart_states[url_hash]['phase'] = 'fresh' + class WebMetadataUpdateWorker: """Web-based metadata update worker - EXACT port of dashboard.py MetadataUpdateWorker""" diff --git a/webui/static/script.js b/webui/static/script.js index 7dfec0e8..eeeddc3d 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -47,6 +47,9 @@ let tidalPlaylists = []; let tidalPlaylistStates = {}; // Key: playlist_id, Value: playlist state with phases let tidalPlaylistsLoaded = false; +// --- Beatport Chart State Management (Similar to YouTube/Tidal) --- +let beatportChartStates = {}; // Key: chart_hash, Value: chart state with phases + // --- Artists Page State Management --- let artistsPageState = { currentView: 'search', // 'search', 'results', 'detail' @@ -9237,7 +9240,9 @@ function initializeSyncPage() { item.addEventListener('click', () => { const chartType = item.dataset.chartType; const chartId = item.dataset.chartId; - handleBeatportChartClick(chartType, chartId); + const chartName = item.dataset.chartName; + const chartEndpoint = item.dataset.chartEndpoint; + handleBeatportChartClick(chartType, chartId, chartName, chartEndpoint); }); }); @@ -9573,6 +9578,7 @@ function handleBeatportCategoryClick(category) { switch(category) { case 'top-charts': showBeatportSubView('top-charts'); + loadBeatportTopCharts(); // Load top charts dynamically break; case 'genres': showBeatportSubView('genres'); @@ -9749,6 +9755,287 @@ async function loadGenreImagesProgressively(genres) { console.log(`✅ Progressive image loading complete: ${imagesLoaded}/${genres.length} images loaded`); } +async function loadBeatportTopCharts() { + console.log('🔥 Loading Beatport top charts dynamically...'); + + const chartList = document.querySelector('#beatport-top-charts-view .beatport-chart-list'); + if (!chartList) { + console.error('❌ Could not find chart list element'); + return; + } + + // Show loading state + chartList.innerHTML = ` +
+
+

🔥 Discovering current Beatport top charts...

+
+ `; + + try { + // Define the top charts we want to show (based on our working endpoints) + const topCharts = [ + { + id: 'beatport-top-100', + name: 'Beatport Top 100', + description: 'The hottest electronic tracks right now', + icon: '🏆', + endpoint: '/api/beatport/top-100', + trackCount: '100 tracks' + }, + { + id: 'hype-top-100', + name: 'Hype Top 100', + description: 'Trending tracks gaining momentum', + icon: '🚀', + endpoint: '/api/beatport/hype-top-100', + trackCount: '100 tracks' + } + ]; + + // Generate chart cards dynamically + const chartCardsHTML = topCharts.map(chart => ` +
+
${chart.icon}
+
+

${chart.name}

+

${chart.description}

+ ${chart.trackCount} +
+
+ `).join(''); + + chartList.innerHTML = chartCardsHTML; + + // Add click handlers to dynamically created chart items + const chartItems = chartList.querySelectorAll('.beatport-chart-item'); + chartItems.forEach(item => { + item.addEventListener('click', () => { + const chartType = item.dataset.chartType; + const chartId = item.dataset.chartId; + const chartName = item.dataset.chartName; + const chartEndpoint = item.dataset.chartEndpoint; + handleBeatportTopChartClick(chartType, chartId, chartName, chartEndpoint); + }); + }); + + console.log(`✅ Loaded ${topCharts.length} Beatport top charts dynamically`); + showToast(`Loaded ${topCharts.length} top charts`, 'success'); + + } catch (error) { + console.error('❌ Error loading Beatport top charts:', error); + chartList.innerHTML = ` +
+

❌ Failed to load top charts: ${error.message}

+ +
+ `; + showToast(`Error loading Beatport top charts: ${error.message}`, 'error'); + } +} + +async function handleBeatportTopChartClick(chartType, chartId, chartName, chartEndpoint) { + console.log(`🔥 Beatport top chart clicked: ${chartName} - CREATING PLAYLIST CARD`); + + try { + // First, create a chart hash for state management + const chartHash = `${chartType}_${chartId}_${Date.now()}`; + + showToast(`Loading ${chartName}...`, 'info'); + + // Fetch tracks from the chart endpoint + const response = await fetch(`${chartEndpoint}?limit=100`); + if (!response.ok) { + throw new Error(`Failed to fetch ${chartName}: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error(`No tracks found in ${chartName}`); + } + + // Create chart data object for playlist card + const chartData = { + hash: chartHash, + name: chartName, + chart_type: chartType, + track_count: data.tracks.length, + tracks: data.tracks.map(track => ({ + name: track.title || 'Unknown Title', + artists: [track.artist || 'Unknown Artist'], + album: chartName, + duration_ms: 0, + external_urls: { beatport: track.url || '' }, + source: 'beatport' + })) + }; + + // Add card to container (in background, like YouTube does) + console.log(`🃏 Creating Beatport playlist card for: ${chartName}`); + addBeatportCardToContainer(chartData); + + // Automatically open discovery modal (like when you click a YouTube or Tidal card in fresh state) + handleBeatportCardClick(chartHash); + + console.log(`✅ Created Beatport card and opened discovery modal for ${chartName}`); + + } catch (error) { + console.error(`❌ Error loading ${chartName}:`, error); + showToast(`Error loading ${chartName}: ${error.message}`, 'error'); + } +} + +async function openBeatportDiscoveryModal(chartHash, chartData) { + console.log(`🎵 Opening Beatport discovery modal (reusing YouTube modal): ${chartData.name}`); + + // Create YouTube-style state entry for this Beatport chart + const beatportState = { + phase: 'fresh', + playlist: { + name: chartData.name, + tracks: chartData.tracks, + description: `${chartData.track_count} tracks from ${chartData.name}`, + source: 'beatport' + }, + is_beatport_playlist: true, + beatport_chart_type: chartData.chart_type, + beatport_chart_hash: chartHash // Link to Beatport card state + }; + + // Store in YouTube playlist states (reusing the infrastructure) + youtubePlaylistStates[chartHash] = beatportState; + + // Start discovery automatically (like Tidal does) + try { + console.log(`🔍 Starting Beatport discovery for: ${chartData.name}`); + + // Update card phase to discovering immediately + updateBeatportCardPhase(chartHash, 'discovering'); + + // Call the discovery start endpoint with chart data + const response = await fetch(`/api/beatport/discovery/start/${chartHash}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + chart_data: chartData + }) + }); + + const result = await response.json(); + if (result.success) { + // Update state to discovering + youtubePlaylistStates[chartHash].phase = 'discovering'; + + // Start polling for progress + startBeatportDiscoveryPolling(chartHash); + + console.log(`✅ Started Beatport discovery for: ${chartData.name}`); + } else { + console.error('❌ Error starting Beatport discovery:', result.error); + showToast(`Error starting discovery: ${result.error}`, 'error'); + // Revert card phase on error + updateBeatportCardPhase(chartHash, 'fresh'); + } + } catch (error) { + console.error('❌ Error starting Beatport discovery:', error); + showToast(`Error starting discovery: ${error.message}`, 'error'); + // Revert card phase on error + updateBeatportCardPhase(chartHash, 'fresh'); + } + + // Open the existing YouTube discovery modal infrastructure + openYouTubeDiscoveryModal(chartHash); + + console.log(`✅ Beatport discovery modal opened for ${chartData.name} with ${chartData.tracks.length} tracks`); +} + +function startBeatportDiscoveryPolling(urlHash) { + console.log(`🔄 Starting Beatport discovery polling for: ${urlHash}`); + + // Stop any existing polling (reuse YouTube polling infrastructure) + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + } + + const pollInterval = setInterval(async () => { + try { + const response = await fetch(`/api/beatport/discovery/status/${urlHash}`); + const status = await response.json(); + + if (status.error) { + console.error('❌ Error polling Beatport discovery status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + // Update state and modal (reuse YouTube infrastructure like Tidal) + if (youtubePlaylistStates[urlHash]) { + // Transform Beatport results to YouTube modal format (like Tidal does) + const transformedStatus = { + progress: status.progress || 0, + spotify_matches: status.spotify_matches || 0, + spotify_total: status.spotify_total || 0, + results: (status.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 : '-' + })) + }; + + // Update state with both backend and frontend formats (like Tidal) + const state = youtubePlaylistStates[urlHash]; + state.discovery_progress = status.progress; // Backend format + state.discoveryProgress = status.progress; // Frontend format - for modal progress display + state.spotify_matches = status.spotify_matches; // Backend format + state.spotifyMatches = status.spotify_matches; // Frontend format - for button logic + state.discovery_results = status.results; // Backend format + state.discoveryResults = transformedStatus.results; // Frontend format - for button logic + state.phase = status.phase || 'discovering'; + + // Update Beatport card phase and progress + const chartHash = state.beatport_chart_hash || urlHash; + updateBeatportCardPhase(chartHash, status.phase || 'discovering'); + updateBeatportCardProgress(chartHash, { + spotify_total: status.spotify_total || 0, + spotify_matches: status.spotify_matches || 0, + failed: (status.spotify_total || 0) - (status.spotify_matches || 0) + }); + + // Update modal display with transformed data + updateYouTubeDiscoveryModal(urlHash, transformedStatus); + } + + // Stop polling when discovery is complete + if (status.phase === 'discovered' || status.phase === 'error') { + console.log(`✅ Beatport discovery polling complete for: ${urlHash}`); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + } + + } catch (error) { + console.error('❌ Error polling Beatport discovery:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + } + }, 2000); // Poll every 2 seconds like Tidal + + // Store the interval so we can clean it up later + activeYouTubePollers[urlHash] = pollInterval; +} + function showBeatportSubView(viewType) { // Hide main category view const mainView = document.getElementById('beatport-main-view'); @@ -9785,11 +10072,211 @@ function showBeatportMainView() { } } -function handleBeatportChartClick(chartType, chartId) { - console.log(`🎵 Beatport chart clicked: ${chartType} - ${chartId}`); +// =============================== +// BEATPORT CHART FUNCTIONALITY +// =============================== - // Placeholder for Phase 2 - will open discovery modal - showToast(`🎵 Chart "${chartId}" selected - Discovery modal coming in Phase 2!`, 'info'); +function createBeatportCard(chartData) { + const state = beatportChartStates[chartData.hash]; + const phase = state ? state.phase : 'fresh'; + + let buttonText = getActionButtonText(phase); + let phaseText = getPhaseText(phase); + let phaseColor = getPhaseColor(phase); + + return ` +
+
🎧
+
+
${escapeHtml(chartData.name)}
+
+ ${chartData.track_count} tracks + ${phaseText} +
+
+
+ +
+ +
+ `; +} + +function addBeatportCardToContainer(chartData) { + const container = document.getElementById('beatport-playlist-container'); + + // Remove placeholder if it exists + const placeholder = container.querySelector('.playlist-placeholder'); + if (placeholder) { + placeholder.remove(); + } + + // Check if card already exists + const existingCard = document.getElementById(`beatport-card-${chartData.hash}`); + if (existingCard) { + console.log(`Card already exists for ${chartData.name}, updating instead`); + return; + } + + // Create and add the card + const cardHtml = createBeatportCard(chartData); + container.insertAdjacentHTML('beforeend', cardHtml); + + // Initialize state + beatportChartStates[chartData.hash] = { + phase: 'fresh', + chart: chartData, + cardElement: document.getElementById(`beatport-card-${chartData.hash}`) + }; + + // Add click handler + const card = document.getElementById(`beatport-card-${chartData.hash}`); + if (card) { + card.addEventListener('click', () => handleBeatportCardClick(chartData.hash)); + } + + console.log(`🃏 Created Beatport card: ${chartData.name}`); +} + +async function handleBeatportCardClick(chartHash) { + const state = beatportChartStates[chartHash]; + if (!state) { + console.error(`❌ [Card Click] No state found for Beatport chart: ${chartHash}`); + showToast('Chart state not found - try refreshing the page', 'error'); + return; + } + + if (!state.chart) { + console.error(`❌ [Card Click] No chart data found for Beatport chart: ${chartHash}`); + showToast('Chart data missing - try refreshing the page', 'error'); + return; + } + + console.log(`🎧 [Card Click] Beatport card clicked: ${chartHash}, Phase: ${state.phase}`); + + if (state.phase === 'fresh') { + // Open discovery modal and start discovery + openBeatportDiscoveryModal(chartHash, state.chart); + } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { + // Reopen existing modal with preserved discovery results + 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'); + } +} + +function updateBeatportCardPhase(chartHash, phase) { + const state = beatportChartStates[chartHash]; + if (!state) return; + + state.phase = phase; + + // Re-render the card with new phase + const card = document.getElementById(`beatport-card-${chartHash}`); + if (card) { + const newCardHtml = createBeatportCard(state.chart); + card.outerHTML = newCardHtml; + + // Re-attach click handler + const newCard = document.getElementById(`beatport-card-${chartHash}`); + if (newCard) { + newCard.addEventListener('click', () => handleBeatportCardClick(chartHash)); + state.cardElement = newCard; + } + } +} + +function updateBeatportCardProgress(chartHash, progress) { + const state = beatportChartStates[chartHash]; + if (!state) return; + + const card = document.getElementById(`beatport-card-${chartHash}`); + if (!card) return; + + const progressElement = card.querySelector('.playlist-card-progress'); + if (!progressElement) return; + + const { spotify_total, spotify_matches, failed } = progress; + const percentage = spotify_total > 0 ? Math.round((spotify_matches / spotify_total) * 100) : 0; + + progressElement.textContent = `♪ ${spotify_total} / ✓ ${spotify_matches} / ✗ ${failed} / ${percentage}%`; + progressElement.classList.remove('hidden'); + + console.log('🎧 Updated Beatport card progress:', chartHash, `${spotify_matches}/${spotify_total} (${percentage}%)`); +} + +function switchToBeatportPlaylistsTab() { + // Switch from "Browse Charts" to "My Playlists" tab + const browseTab = document.querySelector('.beatport-tab-button[data-beatport-tab="browse"]'); + const playlistsTab = document.querySelector('.beatport-tab-button[data-beatport-tab="playlists"]'); + const browseContent = document.getElementById('beatport-browse-content'); + const playlistsContent = document.getElementById('beatport-playlists-content'); + + if (browseTab && playlistsTab && browseContent && playlistsContent) { + // Update tab buttons + browseTab.classList.remove('active'); + playlistsTab.classList.add('active'); + + // Update tab content + browseContent.classList.remove('active'); + playlistsContent.classList.add('active'); + + console.log('🔄 Switched to Beatport "My Playlists" tab'); + } +} + +async function handleBeatportChartClick(chartType, chartId, chartName, chartEndpoint) { + console.log(`🎵 Beatport chart clicked: ${chartType} - ${chartId} - ${chartName}`); + + try { + // First, create a chart hash for state management + const chartHash = `${chartType}_${chartId}_${Date.now()}`; + + // Load chart data from backend using the specific endpoint + console.log(`🔍 Loading ${chartName} tracks from ${chartEndpoint}...`); + showToast(`Loading ${chartName}...`, 'info'); + + const response = await fetch(`${chartEndpoint}?limit=100`); + if (!response.ok) { + throw new Error(`Failed to fetch ${chartName}: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error(`No tracks found in ${chartName}`); + } + + // Create chart data object + const chartData = { + hash: chartHash, + name: chartName, + chart_type: chartType, + track_count: data.tracks.length, + tracks: data.tracks.map(track => ({ + name: track.title || 'Unknown Title', + artists: [track.artist || 'Unknown Artist'], + album: chartName, + duration_ms: 0, + external_urls: { beatport: track.url || '' }, + source: 'beatport' + })) + }; + + // Add card to container (in background, like YouTube does) + addBeatportCardToContainer(chartData); + + // Automatically open discovery modal (like when you click a YouTube or Tidal card in fresh state) + handleBeatportCardClick(chartHash); + + console.log(`✅ Created Beatport card and opened discovery modal for ${chartName}`); + + } catch (error) { + console.error(`❌ Error handling Beatport chart click:`, error); + showToast(`Error loading ${chartName || chartId}: ${error.message}`, 'error'); + } } function handleBeatportGenreClick(genreSlug, genreId) { @@ -10249,10 +10736,15 @@ function openYouTubeDiscoveryModal(urlHash) { startYouTubeDiscoveryPolling(urlHash); } } else { - // Create new modal (support both YouTube and Tidal like sync.py) + // Create new modal (support YouTube, Tidal, and Beatport like sync.py) const isTidal = state.is_tidal_playlist; - const modalTitle = isTidal ? '🎵 Tidal Playlist Discovery' : '🎵 YouTube Playlist Discovery'; - const sourceLabel = isTidal ? 'Tidal' : 'YT'; + const isBeatport = state.is_beatport_playlist; + const modalTitle = isTidal ? '🎵 Tidal Playlist Discovery' : + isBeatport ? '🎵 Beatport Chart Discovery' : + '🎵 YouTube Playlist Discovery'; + const sourceLabel = isTidal ? 'Tidal' : + isBeatport ? 'Beatport' : + 'YT'; const modalHtml = `
@@ -10283,7 +10775,7 @@ function openYouTubeDiscoveryModal(urlHash) { Spotify Track Spotify Artist Album - ${isTidal ? '' : 'Duration'} + ${(isTidal || isBeatport) ? '' : 'Duration'} @@ -10334,6 +10826,7 @@ function getModalActionButtons(urlHash, phase, state = null) { } const isTidal = state && state.is_tidal_playlist; + const isBeatport = state && state.is_beatport_playlist; // Validate data availability for buttons const hasDiscoveryResults = state && state.discoveryResults && state.discoveryResults.length > 0; @@ -10449,7 +10942,7 @@ function getModalDescription(phase, isTidal = false) { } } -function getInitialProgressText(phase, isTidal = false) { +function getInitialProgressText(phase, isTidal = false, isBeatport = false) { switch (phase) { case 'fresh': return 'Click Start Discovery to begin...'; @@ -10464,6 +10957,7 @@ function getInitialProgressText(phase, isTidal = false) { function generateTableRowsFromState(state, urlHash) { const isTidal = state.is_tidal_playlist; + const isBeatport = state.is_beatport_playlist; if (state.discoveryResults && state.discoveryResults.length > 0) { // Generate rows from existing discovery results @@ -10475,16 +10969,16 @@ function generateTableRowsFromState(state, urlHash) { ${result.spotify_track || '-'} ${result.spotify_artist || '-'} ${result.spotify_album || '-'} - ${isTidal ? '' : `${result.duration}`} + ${(isTidal || isBeatport) ? '' : `${result.duration}`} `).join(''); } else { // Generate initial rows from playlist tracks - return generateInitialTableRows(state.playlist.tracks, isTidal, urlHash); + return generateInitialTableRows(state.playlist.tracks, isTidal, urlHash, isBeatport); } } -function generateInitialTableRows(tracks, isTidal = false, urlHash = '') { +function generateInitialTableRows(tracks, isTidal = false, urlHash = '', isBeatport = false) { return tracks.map((track, index) => ` ${track.name} @@ -10493,7 +10987,7 @@ function generateInitialTableRows(tracks, isTidal = false, urlHash = '') { - - - - ${isTidal ? '' : `${formatDuration(track.duration_ms)}`} + ${(isTidal || isBeatport) ? '' : `${formatDuration(track.duration_ms)}`} `).join(''); } diff --git a/webui/static/style.css b/webui/static/style.css index 6dce90e3..4ec0e5b3 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4980,7 +4980,7 @@ body { box-shadow: none; } -.progress-section { +.sync-sidebar .progress-section { flex-grow: 1; overflow: hidden; display: flex;