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:
parent
dc612da9d5
commit
fbf44123ec
3 changed files with 641 additions and 37 deletions
244
web_server.py
244
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}")
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue