diff --git a/web_server.py b/web_server.py index e4f635af..d5cfb994 100644 --- a/web_server.py +++ b/web_server.py @@ -1859,6 +1859,28 @@ beatport_data_cache = { 'cache_lock': threading.Lock() } +# --- Beatport Enrichment Cache --- +# Cache enriched track data (per-track page visits) to avoid re-scraping on repeat chart clicks. +# Keyed by track URL, expires after 2 hours. +_beatport_enrichment_cache = {} +_beatport_enrichment_lock = threading.Lock() +_BEATPORT_ENRICHMENT_TTL = 7200 # 2 hours in seconds + +def get_cached_enrichment(track_url): + """Return cached enrichment data for a track URL, or None if missing/expired.""" + with _beatport_enrichment_lock: + entry = _beatport_enrichment_cache.get(track_url) + if entry and (time.time() - entry['ts']) < _BEATPORT_ENRICHMENT_TTL: + return dict(entry['data']) # shallow copy โ€” prevent callers from mutating cache + elif entry: + del _beatport_enrichment_cache[track_url] + return None + +def set_cached_enrichment(track_url, data): + """Store enrichment data for a track URL.""" + with _beatport_enrichment_lock: + _beatport_enrichment_cache[track_url] = {'data': dict(data), 'ts': time.time()} + def get_cached_beatport_data(section_type, data_key, genre_slug=None): """ Get Beatport data from cache if valid, otherwise return None. @@ -30446,6 +30468,152 @@ def hydrate_search_bubbles(): 'error': str(e) }), 500 +@app.route('/api/beatport_bubbles/snapshot', methods=['POST']) +def save_beatport_bubble_snapshot(): + """Saves a snapshot of current Beatport download bubble state for persistence.""" + try: + from datetime import datetime + + data = request.json + if not data or 'bubbles' not in data: + return jsonify({'success': False, 'error': 'No bubble data provided'}), 400 + + bubbles = data['bubbles'] + + db = get_database() + db.save_bubble_snapshot('beatport_bubbles', bubbles, profile_id=get_current_profile_id()) + + bubble_count = len(bubbles) + print(f"๐Ÿ“ธ Saved Beatport bubble snapshot: {bubble_count} charts") + + return jsonify({ + 'success': True, + 'message': f'Snapshot saved with {bubble_count} Beatport bubbles', + 'timestamp': datetime.now().isoformat() + }) + + except Exception as e: + print(f"โŒ Error saving Beatport bubble snapshot: {e}") + import traceback + traceback.print_exc() + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + +@app.route('/api/beatport_bubbles/hydrate', methods=['GET']) +def hydrate_beatport_bubbles(): + """Loads Beatport download bubbles with live status from snapshot.""" + try: + from datetime import datetime, timedelta + + db = get_database() + snapshot = db.get_bubble_snapshot('beatport_bubbles', profile_id=get_current_profile_id()) + + if not snapshot: + return jsonify({ + 'success': True, + 'bubbles': {}, + 'message': 'No snapshots found' + }) + + saved_bubbles = snapshot['data'] + snapshot_time = snapshot['timestamp'] + + # Clean up old snapshots (older than 48 hours) + try: + if snapshot_time: + snapshot_dt = datetime.fromisoformat(snapshot_time.replace('Z', '+00:00')) + cutoff = datetime.now() - timedelta(hours=48) + if snapshot_dt < cutoff: + print(f"๐Ÿงน Cleaning up old Beatport snapshot from {snapshot_time}") + db.delete_bubble_snapshot('beatport_bubbles', profile_id=get_current_profile_id()) + return jsonify({ + 'success': True, + 'bubbles': {}, + 'message': 'Old snapshot cleaned up' + }) + except ValueError as e: + print(f"โš ๏ธ Error checking Beatport snapshot age: {e}") + + # Get current active download processes for live status + current_processes = {} + try: + with tasks_lock: + for batch_id, batch_data in download_batches.items(): + if batch_data.get('phase') not in ['complete', 'error', 'cancelled']: + playlist_id = batch_data.get('playlist_id') + if playlist_id: + current_processes[playlist_id] = { + 'status': 'in_progress', + 'batch_id': batch_id, + 'phase': batch_data.get('phase') + } + except Exception as e: + print(f"โš ๏ธ Error fetching active processes for Beatport hydration: {e}") + + # If no active processes exist, app likely restarted โ€” clean up + if not current_processes: + print(f"๐Ÿงน No active processes found - cleaning up Beatport snapshot") + db.delete_bubble_snapshot('beatport_bubbles', profile_id=get_current_profile_id()) + return jsonify({ + 'success': True, + 'bubbles': {}, + 'message': 'No active processes - returning empty bubbles' + }) + + # Update bubble statuses with live data + hydrated_bubbles = {} + for chart_key, bubble_data in saved_bubbles.items(): + hydrated_bubble = { + 'chart': bubble_data['chart'], + 'downloads': [] + } + + for download in bubble_data.get('downloads', []): + virtual_playlist_id = download['virtualPlaylistId'] + + if virtual_playlist_id in current_processes: + live_status = 'in_progress' + else: + live_status = 'view_results' + + hydrated_bubble['downloads'].append({ + 'virtualPlaylistId': virtual_playlist_id, + 'status': live_status, + 'startTime': download.get('startTime', datetime.now().isoformat()) + }) + + if hydrated_bubble['downloads']: + hydrated_bubbles[chart_key] = hydrated_bubble + + bubble_count = len(hydrated_bubbles) + active_count = sum(1 for b in hydrated_bubbles.values() + for d in b['downloads'] if d['status'] == 'in_progress') + completed_count = sum(1 for b in hydrated_bubbles.values() + for d in b['downloads'] if d['status'] == 'view_results') + + print(f"๐Ÿ”„ Hydrated {bubble_count} Beatport bubbles: {active_count} active, {completed_count} completed") + + return jsonify({ + 'success': True, + 'bubbles': hydrated_bubbles, + 'stats': { + 'total_charts': bubble_count, + 'active_downloads': active_count, + 'completed_downloads': completed_count + } + }) + + except Exception as e: + print(f"โŒ Error hydrating Beatport bubbles: {e}") + import traceback + traceback.print_exc() + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + # --- Profile API Endpoints --- @app.route('/api/profiles', methods=['GET']) @@ -36264,19 +36432,55 @@ def enrich_beatport_tracks(): logger.info(f"๐ŸŽฏ Enriching {len(tracks)} Beatport tracks with per-track metadata (id: {enrichment_id})") - def on_progress(completed, total, track_name): + # --- Check enrichment cache --- + cached_results = {} # index -> enriched track data + uncached_tracks = [] # tracks that need scraping + uncached_indices = [] # original indices of uncached tracks + + for i, track in enumerate(tracks): + url = track.get('url') or track.get('track_url') or '' + if url: + cached = get_cached_enrichment(url) + if cached: + cached_results[i] = cached + continue + uncached_tracks.append(track) + uncached_indices.append(i) + + cache_hits = len(cached_results) + cache_misses = len(uncached_tracks) + cache_size = len(_beatport_enrichment_cache) + logger.info(f"๐Ÿ“ฆ Enrichment cache: {cache_hits} hits, {cache_misses} misses (cache has {cache_size} entries)") + if cache_misses > 0 and cache_size > 0: + sample_input_url = (uncached_tracks[0].get('url') or uncached_tracks[0].get('track_url') or 'NO_URL') + sample_cached_url = next(iter(_beatport_enrichment_cache), 'EMPTY') + logger.info(f"๐Ÿ“ฆ Cache debug โ€” input URL: {sample_input_url[:80]} | cached URL: {sample_cached_url[:80]}") + + # Emit instant progress for cached tracks + if cache_hits > 0: socketio.emit('beatport:enrich_progress', { 'enrichment_id': enrichment_id, - 'completed': completed, - 'total': total, - 'current_track': track_name + 'completed': cache_hits, + 'total': len(tracks), + 'current_track': f'{cache_hits} tracks (cached)' }) - scraper = BeatportUnifiedScraper() - enriched = scraper.enrich_chart_tracks(tracks, progress_callback=on_progress) + # --- Enrich uncached tracks --- + newly_enriched = [] + if uncached_tracks: + def on_progress(completed, total, track_name): + socketio.emit('beatport:enrich_progress', { + 'enrichment_id': enrichment_id, + 'completed': cache_hits + completed, + 'total': len(tracks), + 'current_track': track_name + }) - # Apply text cleaning - for track in enriched: + scraper = BeatportUnifiedScraper() + newly_enriched = scraper.enrich_chart_tracks(uncached_tracks, progress_callback=on_progress) + + # --- Apply text cleaning and cache newly enriched tracks --- + def clean_track(track): if track.get('title'): track['title'] = clean_beatport_text(track['title']) if track.get('artist'): @@ -36285,10 +36489,30 @@ def enrich_beatport_tracks(): track['release_name'] = clean_beatport_text(track['release_name']) if track.get('label'): track['label'] = clean_beatport_text(track['label']) + return track - logger.info(f"โœ… Enriched {len(enriched)} tracks") + for track in newly_enriched: + clean_track(track) + url = track.get('url') or track.get('track_url') or '' + if url: + set_cached_enrichment(url, track) - return jsonify({"success": True, "tracks": enriched}) + # --- Merge results in original order --- + merged = [None] * len(tracks) + for idx, data in cached_results.items(): + merged[idx] = data + for j, idx in enumerate(uncached_indices): + if j < len(newly_enriched): + merged[idx] = newly_enriched[j] + + # Fill any gaps with original track data (safety) + for i in range(len(merged)): + if merged[i] is None: + merged[i] = tracks[i] + + logger.info(f"โœ… Enriched {len(merged)} tracks ({cache_hits} cached, {cache_misses} scraped)") + + return jsonify({"success": True, "tracks": merged}) except Exception as e: logger.error(f"โŒ Error enriching tracks: {e}") diff --git a/webui/index.html b/webui/index.html index 837ee73a..404b9880 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1092,8 +1092,8 @@
- -
+ +
+ + +
diff --git a/webui/static/script.js b/webui/static/script.js index e9cdf831..09d18092 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -93,6 +93,11 @@ let downloadsUpdateTimeout = null; // Debounce downloads section updates // --- Search Downloads Management State --- let searchDownloadBubbles = {}; // Track search download bubbles: artistName -> { artist, downloads: [] } let searchDownloadModalOpen = false; // Track if search download modal is open + +// --- Beatport Downloads Management State --- +let beatportDownloadBubbles = {}; // Track Beatport download bubbles: chartKey -> { chart: { name, image }, downloads: [] } +let beatportDownloadsUpdateTimeout = null; // Debounce Beatport downloads section updates + let artistsSearchTimeout = null; let artistsSearchController = null; let artistCompletionController = null; // Track ongoing completion check to cancel when navigating away @@ -7610,6 +7615,9 @@ async function loadInitialData() { // Load discover download state await hydrateDiscoverDownloadsFromSnapshot(); + // Load Beatport bubble state + await hydrateBeatportBubblesFromSnapshot(); + // Navigate to user's home page (or dashboard for admin) const homePage = getProfileHomePage(); if (homePage !== 'dashboard') { @@ -10578,6 +10586,13 @@ async function closeDownloadMissingModal(playlistId) { console.log(`โœ… [MODAL CLOSE] Search download cleanup completed for: ${playlistId}`); } + // Clean up Beatport download if this is a beatport chart playlist + if (playlistId.startsWith('beatport_chart_')) { + console.log(`๐Ÿงน [MODAL CLOSE] Cleaning up Beatport download for completed modal: ${playlistId}`); + cleanupBeatportDownload(playlistId); + console.log(`โœ… [MODAL CLOSE] Beatport download cleanup completed for: ${playlistId}`); + } + // Remove from discover download sidebar if this is a discover page download if (discoverDownloads && discoverDownloads[playlistId]) { console.log(`๐Ÿงน [MODAL CLOSE] Removing discover download bubble: ${playlistId}`); @@ -21263,8 +21278,11 @@ function updateDashboardDownloads() { searchDownloadBubbles[name].downloads.length > 0 ); const activeDiscover = Object.keys(discoverDownloads); + const activeBeatport = Object.keys(beatportDownloadBubbles).filter(key => + beatportDownloadBubbles[key].downloads.length > 0 + ); - const totalCount = activeArtists.length + activeSearch.length + activeDiscover.length; + const totalCount = activeArtists.length + activeSearch.length + activeDiscover.length + activeBeatport.length; if (totalCount === 0) { section.style.display = 'none'; @@ -21317,6 +21335,20 @@ function updateDashboardDownloads() {
`; } + // --- Beatport group --- + if (activeBeatport.length > 0) { + html += ` +
+
+ Beatport + ${activeBeatport.length} +
+
+ ${activeBeatport.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')} +
+
`; + } + container.innerHTML = html; // Post-render: attach artist bubble click handlers + dynamic glow @@ -21332,6 +21364,19 @@ function updateDashboardDownloads() { } } }); + // Beatport bubble click handlers + glow + activeBeatport.forEach(chartKey => { + const card = container.querySelector(`.artist-bubble-card[data-chart-key="${chartKey}"]`); + if (card) { + card.addEventListener('click', () => openBeatportBubbleModal(chartKey)); + const chartImage = beatportDownloadBubbles[chartKey].chart.image; + if (chartImage) { + extractImageColors(chartImage, (colors) => { + applyDynamicGlow(card, colors); + }); + } + } + }); // Search and discover cards use inline onclick โ€” no post-render needed } @@ -32385,6 +32430,7 @@ function updateArtistDownloadsSection() { downloadsUpdateTimeout = setTimeout(() => { showArtistDownloadsSection(); showLibraryDownloadsSection(); + showBeatportDownloadsSection(); updateDashboardDownloads(); }, 300); // 300ms debounce } @@ -33702,6 +33748,324 @@ function bulkCompleteArtistDownloads(artistId) { showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success'); } +// ======================================== +// Beatport Download Bubbles +// ======================================== + +/** + * Register a new Beatport chart download for bubble management + */ +function registerBeatportDownload(chartName, chartImage, virtualPlaylistId) { + console.log(`๐Ÿ“ Registering Beatport download: ${chartName}`); + + // Use chart name as key (sanitised) + const chartKey = chartName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); + + if (!beatportDownloadBubbles[chartKey]) { + beatportDownloadBubbles[chartKey] = { + chart: { name: chartName, image: chartImage || '' }, + downloads: [] + }; + } + + beatportDownloadBubbles[chartKey].downloads.push({ + virtualPlaylistId: virtualPlaylistId, + status: 'in_progress', + startTime: new Date() + }); + + updateBeatportDownloadsSection(); + saveBeatportBubbleSnapshot(); + monitorBeatportDownload(chartKey, virtualPlaylistId); +} + +/** + * Debounced update for Beatport downloads section + */ +function updateBeatportDownloadsSection() { + if (beatportDownloadsUpdateTimeout) { + clearTimeout(beatportDownloadsUpdateTimeout); + } + beatportDownloadsUpdateTimeout = setTimeout(() => { + showBeatportDownloadsSection(); + updateDashboardDownloads(); + }, 300); +} + +/** + * Render Beatport download bubbles on the Beatport page + */ +function showBeatportDownloadsSection() { + const downloadsSection = document.getElementById('beatport-downloads-section'); + if (!downloadsSection) return; + + const activeCharts = Object.keys(beatportDownloadBubbles).filter(key => + beatportDownloadBubbles[key].downloads.length > 0 + ); + + if (activeCharts.length === 0) { + downloadsSection.style.display = 'none'; + return; + } + + downloadsSection.style.display = 'block'; + downloadsSection.innerHTML = ` +
+

Beatport Downloads

+

Active chart download processes

+
+
+ ${activeCharts.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')} +
+ `; + + // Attach click handlers + glow + activeCharts.forEach(chartKey => { + const card = downloadsSection.querySelector(`[data-chart-key="${chartKey}"]`); + if (card) { + card.addEventListener('click', () => openBeatportBubbleModal(chartKey)); + const chartImage = beatportDownloadBubbles[chartKey].chart.image; + if (chartImage) { + extractImageColors(chartImage, (colors) => { + applyDynamicGlow(card, colors); + }); + } + } + }); +} + +/** + * Create HTML for a Beatport bubble card (reuses artist bubble CSS) + */ +function createBeatportBubbleCard(bubbleData) { + const { chart, downloads } = bubbleData; + const chartKey = chart.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); + const activeCount = downloads.filter(d => d.status === 'in_progress').length; + const completedCount = downloads.filter(d => d.status === 'view_results').length; + const allCompleted = activeCount === 0 && completedCount > 0; + + const backgroundStyle = chart.image + ? `background-image: url('${chart.image}');` + : `background: linear-gradient(135deg, rgba(0, 210, 120, 0.3) 0%, rgba(0, 170, 100, 0.2) 100%);`; + + return ` +
+
+
+
+
${escapeHtml(chart.name)}
+
+ ${activeCount > 0 ? `${activeCount} active` : ''} + ${completedCount > 0 ? `${completedCount} completed` : ''} +
+
+ ${allCompleted ? ` +
+ โœ… +
+ ` : ''} +
+ `; +} + +/** + * Monitor a Beatport download for completion + */ +function monitorBeatportDownload(chartKey, virtualPlaylistId) { + const checkStatus = () => { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (!process || !beatportDownloadBubbles[chartKey]) return; + + const download = beatportDownloadBubbles[chartKey].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); + if (!download) return; + + if (process.status === 'complete' && download.status === 'in_progress') { + download.status = 'view_results'; + console.log(`โœ… Beatport download completed for ${beatportDownloadBubbles[chartKey].chart.name}`); + + updateBeatportDownloadsSection(); + saveBeatportBubbleSnapshot(); + + const allCompleted = beatportDownloadBubbles[chartKey].downloads.every(d => d.status === 'view_results'); + if (allCompleted) { + console.log(`๐ŸŸข All Beatport downloads completed for ${beatportDownloadBubbles[chartKey].chart.name}`); + setTimeout(updateBeatportDownloadsSection, 100); + } + } + + if (process.status !== 'complete') { + setTimeout(checkStatus, 2000); + } + }; + + setTimeout(checkStatus, 1000); +} + +/** + * Open the download modal for a Beatport chart bubble + */ +function openBeatportBubbleModal(chartKey) { + const bubbleData = beatportDownloadBubbles[chartKey]; + if (!bubbleData) return; + + // Find the first download with an active modal + for (const download of bubbleData.downloads) { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process && process.modalElement) { + process.modalElement.style.display = 'flex'; + if (process.status === 'complete') { + showToast('Review download results and click "Close" to finish.', 'info'); + } + return; + } + } + + showToast('No active download modal found for this chart', 'info'); +} + +/** + * Bulk complete all downloads for a Beatport chart + */ +function bulkCompleteBeatportDownloads(chartKey) { + console.log(`๐ŸŽฏ Bulk completing Beatport downloads for chart: ${chartKey}`); + + const bubbleData = beatportDownloadBubbles[chartKey]; + if (!bubbleData) return; + + const completedDownloads = bubbleData.downloads.filter(d => d.status === 'view_results'); + if (completedDownloads.length === 0) { + showToast('No completed downloads to close', 'info'); + return; + } + + completedDownloads.forEach(download => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process && process.modalElement) { + closeDownloadMissingModal(download.virtualPlaylistId); + } else { + cleanupBeatportDownload(download.virtualPlaylistId); + } + }); + + showToast(`Completed ${completedDownloads.length} downloads for ${bubbleData.chart.name}`, 'success'); +} + +/** + * Clean up a Beatport download when its modal is closed + */ +function cleanupBeatportDownload(virtualPlaylistId) { + console.log(`๐Ÿ” [CLEANUP] Looking for Beatport download to cleanup: ${virtualPlaylistId}`); + + for (const chartKey in beatportDownloadBubbles) { + const downloads = beatportDownloadBubbles[chartKey].downloads; + const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); + + if (downloadIndex !== -1) { + downloads.splice(downloadIndex, 1); + console.log(`๐Ÿงน [CLEANUP] Removed Beatport download from ${chartKey}. Remaining: ${downloads.length}`); + + if (downloads.length === 0) { + delete beatportDownloadBubbles[chartKey]; + console.log(`๐Ÿงน [CLEANUP] No more downloads - removed Beatport bubble: ${chartKey}`); + } + + updateBeatportDownloadsSection(); + saveBeatportBubbleSnapshot(); + return; + } + } +} + +// --- Beatport Bubble Snapshot System --- + +let beatportSnapshotSaveTimeout = null; + +async function saveBeatportBubbleSnapshot() { + if (beatportSnapshotSaveTimeout) { + clearTimeout(beatportSnapshotSaveTimeout); + } + + beatportSnapshotSaveTimeout = setTimeout(async () => { + try { + const bubbleCount = Object.keys(beatportDownloadBubbles).length; + if (bubbleCount === 0) return; + + const cleanBubbles = {}; + for (const [chartKey, bubbleData] of Object.entries(beatportDownloadBubbles)) { + cleanBubbles[chartKey] = { + chart: bubbleData.chart, + downloads: bubbleData.downloads.map(d => ({ + virtualPlaylistId: d.virtualPlaylistId, + status: d.status, + startTime: d.startTime instanceof Date ? d.startTime.toISOString() : d.startTime + })) + }; + } + + const response = await fetch('/api/beatport_bubbles/snapshot', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bubbles: cleanBubbles }) + }); + + const data = await response.json(); + if (data.success) { + console.log(`โœ… Beatport bubble snapshot saved: ${bubbleCount} charts`); + } + } catch (error) { + console.error('โŒ Error saving Beatport bubble snapshot:', error); + } + }, 1000); +} + +async function hydrateBeatportBubblesFromSnapshot() { + try { + console.log('๐Ÿ”„ Loading Beatport bubble snapshot from backend...'); + + const response = await fetch('/api/beatport_bubbles/hydrate'); + const data = await response.json(); + + if (!data.success) { + console.error('โŒ Failed to load Beatport bubble snapshot:', data.error); + return; + } + + const bubbles = data.bubbles || {}; + if (Object.keys(bubbles).length === 0) { + console.log('โ„น๏ธ No Beatport bubbles to hydrate'); + return; + } + + beatportDownloadBubbles = {}; + + for (const [chartKey, bubbleData] of Object.entries(bubbles)) { + beatportDownloadBubbles[chartKey] = { + chart: bubbleData.chart, + downloads: bubbleData.downloads.map(d => ({ + virtualPlaylistId: d.virtualPlaylistId, + status: d.status, + startTime: new Date(d.startTime) + })) + }; + + for (const download of bubbleData.downloads) { + if (download.status === 'in_progress') { + monitorBeatportDownload(chartKey, download.virtualPlaylistId); + } + } + } + + updateBeatportDownloadsSection(); + console.log(`โœ… Hydrated ${Object.keys(beatportDownloadBubbles).length} Beatport download bubbles`); + } catch (error) { + console.error('โŒ Error hydrating Beatport bubbles:', error); + } +} + /** * Clean up artist download when a modal is closed */ @@ -43208,36 +43572,46 @@ async function handleBeatportReleaseCardClick(cardElement, release) { let _beatportModalOpening = false; /** - * Enrich tracks one-by-one via the backend, updating the loading overlay with progress. + * Enrich tracks via a single batch request to the backend. + * Progress is reported via WebSocket (beatport:enrich_progress) and updates the loading overlay. * Returns the enriched tracks array. */ async function _enrichTracksWithProgress(tracks, chartName) { - const enrichedTracks = []; - for (let i = 0; i < tracks.length; i++) { - const track = tracks[i]; + const enrichmentId = `enrich_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`; + + // Listen for WebSocket progress updates to drive the loading overlay + const progressHandler = (data) => { + if (data.enrichment_id !== enrichmentId) return; const overlayText = document.querySelector('#loading-overlay .loading-message'); if (overlayText) { - overlayText.textContent = `Fetching track metadata... (${i + 1}/${tracks.length}) ${track.title || ''}`; + overlayText.textContent = `Fetching track metadata... (${data.completed}/${data.total}) ${data.current_track || ''}`; } + }; + if (typeof socket !== 'undefined' && socket) { + socket.on('beatport:enrich_progress', progressHandler); + } - try { - const resp = await fetch('/api/beatport/enrich-tracks', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tracks: [track] }) - }); - const data = await resp.json(); - if (data.success && data.tracks && data.tracks.length > 0) { - enrichedTracks.push(data.tracks[0]); - } else { - enrichedTracks.push(track); - } - } catch (e) { - console.warn(`โš ๏ธ Failed to enrich track ${i + 1}:`, e); - enrichedTracks.push(track); + try { + const resp = await fetch('/api/beatport/enrich-tracks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tracks, enrichment_id: enrichmentId }) + }); + const data = await resp.json(); + + if (data.success && data.tracks) { + return data.tracks; + } + console.warn('โš ๏ธ Enrichment failed, returning original tracks'); + return tracks; + } catch (e) { + console.warn('โš ๏ธ Failed to enrich tracks:', e); + return tracks; + } finally { + if (typeof socket !== 'undefined' && socket) { + socket.off('beatport:enrich_progress', progressHandler); } } - return enrichedTracks; } function parseBeatportDuration(raw) { @@ -43250,9 +43624,9 @@ function parseBeatportDuration(raw) { } function openBeatportChartAsDownloadModal(tracks, chartName, chartImage) { - if (_beatportModalOpening) return; - _beatportModalOpening = true; - setTimeout(() => { _beatportModalOpening = false; }, 500); + // Note: callers already guard against double-clicks via _beatportModalOpening. + // Reset the flag here so the modal can open even after fast (cached) enrichment. + _beatportModalOpening = false; const albumObj = { id: `beatport_chart_${Date.now()}`, @@ -43311,6 +43685,9 @@ function openBeatportChartAsDownloadModal(tracks, chartName, chartImage) { false, 'playlist' ); + + // Register Beatport download bubble + registerBeatportDownload(chartName, chartImage, virtualPlaylistId); } /**