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()
|
'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):
|
def get_cached_beatport_data(section_type, data_key, genre_slug=None):
|
||||||
"""
|
"""
|
||||||
Get Beatport data from cache if valid, otherwise return None.
|
Get Beatport data from cache if valid, otherwise return None.
|
||||||
|
|
@ -30446,6 +30468,152 @@ def hydrate_search_bubbles():
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
}), 500
|
}), 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 ---
|
# --- Profile API Endpoints ---
|
||||||
|
|
||||||
@app.route('/api/profiles', methods=['GET'])
|
@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})")
|
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', {
|
socketio.emit('beatport:enrich_progress', {
|
||||||
'enrichment_id': enrichment_id,
|
'enrichment_id': enrichment_id,
|
||||||
'completed': completed,
|
'completed': cache_hits,
|
||||||
'total': total,
|
'total': len(tracks),
|
||||||
'current_track': track_name
|
'current_track': f'{cache_hits} tracks (cached)'
|
||||||
})
|
})
|
||||||
|
|
||||||
scraper = BeatportUnifiedScraper()
|
# --- Enrich uncached tracks ---
|
||||||
enriched = scraper.enrich_chart_tracks(tracks, progress_callback=on_progress)
|
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
|
scraper = BeatportUnifiedScraper()
|
||||||
for track in enriched:
|
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'):
|
if track.get('title'):
|
||||||
track['title'] = clean_beatport_text(track['title'])
|
track['title'] = clean_beatport_text(track['title'])
|
||||||
if track.get('artist'):
|
if track.get('artist'):
|
||||||
|
|
@ -36285,10 +36489,30 @@ def enrich_beatport_tracks():
|
||||||
track['release_name'] = clean_beatport_text(track['release_name'])
|
track['release_name'] = clean_beatport_text(track['release_name'])
|
||||||
if track.get('label'):
|
if track.get('label'):
|
||||||
track['label'] = clean_beatport_text(track['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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error enriching tracks: {e}")
|
logger.error(f"❌ Error enriching tracks: {e}")
|
||||||
|
|
|
||||||
|
|
@ -1092,8 +1092,8 @@
|
||||||
|
|
||||||
<!-- Beatport Tab Content -->
|
<!-- Beatport Tab Content -->
|
||||||
<div class="sync-tab-content" id="beatport-tab-content">
|
<div class="sync-tab-content" id="beatport-tab-content">
|
||||||
<!-- Beatport Nested Tabs -->
|
<!-- Beatport Nested Tabs (hidden — Browse is the only view) -->
|
||||||
<div class="beatport-tabs">
|
<div class="beatport-tabs" style="display: none;">
|
||||||
<button class="beatport-tab-button active" data-beatport-tab="rebuild">
|
<button class="beatport-tab-button active" data-beatport-tab="rebuild">
|
||||||
<span class="tab-icon rebuild-icon"></span> Browse
|
<span class="tab-icon rebuild-icon"></span> Browse
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1561,6 +1561,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Beatport Download Bubbles -->
|
||||||
|
<div id="beatport-downloads-section" class="artist-downloads-section" style="display: none;"></div>
|
||||||
|
|
||||||
<!-- Top 10 Lists Section -->
|
<!-- Top 10 Lists Section -->
|
||||||
<div class="beatport-top10-section">
|
<div class="beatport-top10-section">
|
||||||
<div class="beatport-top10-header">
|
<div class="beatport-top10-header">
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,11 @@ let downloadsUpdateTimeout = null; // Debounce downloads section updates
|
||||||
// --- Search Downloads Management State ---
|
// --- Search Downloads Management State ---
|
||||||
let searchDownloadBubbles = {}; // Track search download bubbles: artistName -> { artist, downloads: [] }
|
let searchDownloadBubbles = {}; // Track search download bubbles: artistName -> { artist, downloads: [] }
|
||||||
let searchDownloadModalOpen = false; // Track if search download modal is open
|
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 artistsSearchTimeout = null;
|
||||||
let artistsSearchController = null;
|
let artistsSearchController = null;
|
||||||
let artistCompletionController = null; // Track ongoing completion check to cancel when navigating away
|
let artistCompletionController = null; // Track ongoing completion check to cancel when navigating away
|
||||||
|
|
@ -7610,6 +7615,9 @@ async function loadInitialData() {
|
||||||
// Load discover download state
|
// Load discover download state
|
||||||
await hydrateDiscoverDownloadsFromSnapshot();
|
await hydrateDiscoverDownloadsFromSnapshot();
|
||||||
|
|
||||||
|
// Load Beatport bubble state
|
||||||
|
await hydrateBeatportBubblesFromSnapshot();
|
||||||
|
|
||||||
// Navigate to user's home page (or dashboard for admin)
|
// Navigate to user's home page (or dashboard for admin)
|
||||||
const homePage = getProfileHomePage();
|
const homePage = getProfileHomePage();
|
||||||
if (homePage !== 'dashboard') {
|
if (homePage !== 'dashboard') {
|
||||||
|
|
@ -10578,6 +10586,13 @@ async function closeDownloadMissingModal(playlistId) {
|
||||||
console.log(`✅ [MODAL CLOSE] Search download cleanup completed for: ${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
|
// Remove from discover download sidebar if this is a discover page download
|
||||||
if (discoverDownloads && discoverDownloads[playlistId]) {
|
if (discoverDownloads && discoverDownloads[playlistId]) {
|
||||||
console.log(`🧹 [MODAL CLOSE] Removing discover download bubble: ${playlistId}`);
|
console.log(`🧹 [MODAL CLOSE] Removing discover download bubble: ${playlistId}`);
|
||||||
|
|
@ -21263,8 +21278,11 @@ function updateDashboardDownloads() {
|
||||||
searchDownloadBubbles[name].downloads.length > 0
|
searchDownloadBubbles[name].downloads.length > 0
|
||||||
);
|
);
|
||||||
const activeDiscover = Object.keys(discoverDownloads);
|
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) {
|
if (totalCount === 0) {
|
||||||
section.style.display = 'none';
|
section.style.display = 'none';
|
||||||
|
|
@ -21317,6 +21335,20 @@ function updateDashboardDownloads() {
|
||||||
</div>`;
|
</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;
|
container.innerHTML = html;
|
||||||
|
|
||||||
// Post-render: attach artist bubble click handlers + dynamic glow
|
// 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
|
// Search and discover cards use inline onclick — no post-render needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32385,6 +32430,7 @@ function updateArtistDownloadsSection() {
|
||||||
downloadsUpdateTimeout = setTimeout(() => {
|
downloadsUpdateTimeout = setTimeout(() => {
|
||||||
showArtistDownloadsSection();
|
showArtistDownloadsSection();
|
||||||
showLibraryDownloadsSection();
|
showLibraryDownloadsSection();
|
||||||
|
showBeatportDownloadsSection();
|
||||||
updateDashboardDownloads();
|
updateDashboardDownloads();
|
||||||
}, 300); // 300ms debounce
|
}, 300); // 300ms debounce
|
||||||
}
|
}
|
||||||
|
|
@ -33702,6 +33748,324 @@ function bulkCompleteArtistDownloads(artistId) {
|
||||||
showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success');
|
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
|
* Clean up artist download when a modal is closed
|
||||||
*/
|
*/
|
||||||
|
|
@ -43208,36 +43572,46 @@ async function handleBeatportReleaseCardClick(cardElement, release) {
|
||||||
let _beatportModalOpening = false;
|
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.
|
* Returns the enriched tracks array.
|
||||||
*/
|
*/
|
||||||
async function _enrichTracksWithProgress(tracks, chartName) {
|
async function _enrichTracksWithProgress(tracks, chartName) {
|
||||||
const enrichedTracks = [];
|
const enrichmentId = `enrich_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
|
||||||
const track = tracks[i];
|
// 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');
|
const overlayText = document.querySelector('#loading-overlay .loading-message');
|
||||||
if (overlayText) {
|
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 {
|
try {
|
||||||
const resp = await fetch('/api/beatport/enrich-tracks', {
|
const resp = await fetch('/api/beatport/enrich-tracks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ tracks: [track] })
|
body: JSON.stringify({ tracks, enrichment_id: enrichmentId })
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success && data.tracks && data.tracks.length > 0) {
|
|
||||||
enrichedTracks.push(data.tracks[0]);
|
if (data.success && data.tracks) {
|
||||||
} else {
|
return data.tracks;
|
||||||
enrichedTracks.push(track);
|
}
|
||||||
}
|
console.warn('⚠️ Enrichment failed, returning original tracks');
|
||||||
} catch (e) {
|
return tracks;
|
||||||
console.warn(`⚠️ Failed to enrich track ${i + 1}:`, e);
|
} catch (e) {
|
||||||
enrichedTracks.push(track);
|
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) {
|
function parseBeatportDuration(raw) {
|
||||||
|
|
@ -43250,9 +43624,9 @@ function parseBeatportDuration(raw) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBeatportChartAsDownloadModal(tracks, chartName, chartImage) {
|
function openBeatportChartAsDownloadModal(tracks, chartName, chartImage) {
|
||||||
if (_beatportModalOpening) return;
|
// Note: callers already guard against double-clicks via _beatportModalOpening.
|
||||||
_beatportModalOpening = true;
|
// Reset the flag here so the modal can open even after fast (cached) enrichment.
|
||||||
setTimeout(() => { _beatportModalOpening = false; }, 500);
|
_beatportModalOpening = false;
|
||||||
|
|
||||||
const albumObj = {
|
const albumObj = {
|
||||||
id: `beatport_chart_${Date.now()}`,
|
id: `beatport_chart_${Date.now()}`,
|
||||||
|
|
@ -43311,6 +43685,9 @@ function openBeatportChartAsDownloadModal(tracks, chartName, chartImage) {
|
||||||
false,
|
false,
|
||||||
'playlist'
|
'playlist'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Register Beatport download bubble
|
||||||
|
registerBeatportDownload(chartName, chartImage, virtualPlaylistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue