Add Beatport download bubbles, enrichment cache, and batch enrichment

- Persistent download bubble cards on Beatport page and dashboard,
  matching the existing artist/search bubble UX with click-to-reopen,
  green checkmark on completion, and snapshot persistence
- In-memory enrichment cache (2h TTL, thread-safe) skips re-scraping
  when the same chart is clicked twice
- Batch enrichment replaces one-by-one HTTP requests with a single
  POST, using WebSocket progress events for overlay updates
- Fix _beatportModalOpening guard blocking modal open after fast
  cached enrichment by resetting the flag in openBeatportChartAsDownloadModal
- Hide Browse/My Playlists tabs — Browse is now the only Beatport view
This commit is contained in:
Broque Thomas 2026-03-17 21:41:04 -07:00
parent dc612da9d5
commit fbf44123ec
3 changed files with 641 additions and 37 deletions

View file

@ -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}")

View file

@ -1092,8 +1092,8 @@
<!-- Beatport Tab Content -->
<div class="sync-tab-content" id="beatport-tab-content">
<!-- Beatport Nested Tabs -->
<div class="beatport-tabs">
<!-- Beatport Nested Tabs (hidden — Browse is the only view) -->
<div class="beatport-tabs" style="display: none;">
<button class="beatport-tab-button active" data-beatport-tab="rebuild">
<span class="tab-icon rebuild-icon"></span> Browse
</button>
@ -1561,6 +1561,9 @@
</div>
</div>
<!-- Beatport Download Bubbles -->
<div id="beatport-downloads-section" class="artist-downloads-section" style="display: none;"></div>
<!-- Top 10 Lists Section -->
<div class="beatport-top10-section">
<div class="beatport-top10-header">

View file

@ -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() {
</div>`;
}
// --- Beatport group ---
if (activeBeatport.length > 0) {
html += `
<div class="dashboard-downloads-group">
<div class="dashboard-downloads-group-header">
<span class="dashboard-downloads-group-label">Beatport</span>
<span class="dashboard-downloads-group-count">${activeBeatport.length}</span>
</div>
<div class="dashboard-bubble-container">
${activeBeatport.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')}
</div>
</div>`;
}
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 = `
<div class="artist-downloads-header">
<h3 class="artist-downloads-title">Beatport Downloads</h3>
<p class="artist-downloads-subtitle">Active chart download processes</p>
</div>
<div class="artist-bubble-container" id="beatport-bubble-container">
${activeCharts.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')}
</div>
`;
// 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 `
<div class="artist-bubble-card ${allCompleted ? 'all-completed' : ''}"
data-chart-key="${chartKey}"
title="Click to manage downloads for ${escapeHtml(chart.name)}">
<div class="artist-bubble-image" style="${backgroundStyle}"></div>
<div class="artist-bubble-overlay"></div>
<div class="artist-bubble-content">
<div class="artist-bubble-name">${escapeHtml(chart.name)}</div>
<div class="artist-bubble-status">
${activeCount > 0 ? `${activeCount} active` : ''}
${completedCount > 0 ? `${completedCount} completed` : ''}
</div>
</div>
${allCompleted ? `
<div class="bulk-complete-indicator"
onclick="event.stopPropagation(); bulkCompleteBeatportDownloads('${chartKey}')"
title="Complete all downloads">
<span class="bulk-complete-icon"></span>
</div>
` : ''}
</div>
`;
}
/**
* 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);
}
/**