From a5d97261e48afe469daabf6c8f17414246cb6f3a Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Wed, 22 Apr 2026 15:25:59 -0700
Subject: [PATCH] Extract shared helpers from artists.js to shared-helpers.js
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Part C of the deferred unification cleanup. The Artists page is no
longer in the sidebar, but its JS file can't be deleted yet because
it houses ~20 general-purpose helpers that other modules depend on
(escapeHtml used in 229 places, service-status polling, image-colour
extraction, download-bubble infrastructure, discography completion
checking, enrichment card rendering).
Moved all non-page-specific code from artists.js into the new
webui/static/shared-helpers.js — pure copy/paste, zero logic change.
Two contiguous blocks extracted:
Block A (lines 1097..1398 of original artists.js): discography
completion suite — checkDiscographyCompletion, handleStreaming-
CompletionUpdate, cacheCompletionData, updateAlbumCompletion-
Overlay, getCompletionStatusText, setAlbumDownloadedStatus,
setAlbumDownloadingStatus.
Block B (lines 2206..EOF of original artists.js): download-bubble
infrastructure (artist + search + Beatport clusters with their
snapshot/hydrate/modal/monitor helpers), openDownloadMissingModal-
ForArtistAlbum, image-colour extractor and dynamic-glow helper,
escapeHtml, service-status polling, renderEnrichmentCards.
Function declarations in a plain
+
diff --git a/webui/static/artists.js b/webui/static/artists.js
index 1f741c01..7d70c875 100644
--- a/webui/static/artists.js
+++ b/webui/static/artists.js
@@ -1094,308 +1094,6 @@ function restoreCachedCompletionData(artistId) {
/**
* Check completion status for entire discography with streaming updates
*/
-async function checkDiscographyCompletion(artistId, discography) {
- console.log(`🔍 Starting streaming completion check for artist: ${artistId}`);
-
- try {
- // Create new abort controller for this completion check
- artistCompletionController = new AbortController();
-
- // Use fetch with streaming response
- const response = await fetch(`/api/artist/${artistId}/completion-stream`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- discography: discography,
- artist_name: artistsPageState.selectedArtist?.name || 'Unknown Artist',
- source: discography?.source || artistsPageState.sourceOverride || null,
- }),
- signal: artistCompletionController.signal
- });
-
- if (!response.ok) {
- throw new Error(`Failed to start completion check: ${response.status}`);
- }
-
- // Handle streaming response
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
-
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- const chunk = decoder.decode(value);
- const lines = chunk.split('\n');
-
- for (const line of lines) {
- if (line.startsWith('data: ')) {
- try {
- const data = JSON.parse(line.slice(6));
- handleStreamingCompletionUpdate(data);
- } catch (e) {
- console.warn('Failed to parse streaming data:', line);
- }
- }
- }
- }
-
- // Clear the controller when done
- artistCompletionController = null;
-
- } catch (error) {
- // Don't show error if it was aborted (user navigated away)
- if (error.name === 'AbortError') {
- console.log('⏹️ Completion check aborted (user navigated to new artist)');
- return;
- }
-
- console.error('❌ Failed to check completion status:', error);
- showCompletionError();
- } finally {
- // Always clear the controller
- artistCompletionController = null;
- }
-}
-
-/**
- * Handle individual streaming completion updates
- */
-function handleStreamingCompletionUpdate(data) {
- console.log('🔄 Streaming update received:', data.type, data.name || data.artist_name);
-
- switch (data.type) {
- case 'start':
- console.log(`🎤 Starting completion check for ${data.artist_name} (${data.total_items} items)`);
- // Initialize cache for this artist if not exists
- const artistId = artistsPageState.selectedArtist?.id;
- if (artistId && !artistsPageState.cache.completionData[artistId]) {
- artistsPageState.cache.completionData[artistId] = {
- albums: [],
- singles: []
- };
- }
- break;
-
- case 'album_completion':
- updateAlbumCompletionOverlay(data, 'albums');
- // Cache the completion data
- cacheCompletionData(data, 'albums');
- console.log(`📀 Updated album: ${data.name} (${data.status})`);
- break;
-
- case 'single_completion':
- updateAlbumCompletionOverlay(data, 'singles');
- // Cache the completion data
- cacheCompletionData(data, 'singles');
- console.log(`🎵 Updated single: ${data.name} (${data.status})`);
- break;
-
- case 'error':
- console.error('❌ Error processing item:', data.name, data.error);
- // Could show error for specific item
- break;
-
- case 'complete':
- console.log(`✅ Completion check finished (${data.processed_count} items processed)`);
- break;
-
- default:
- console.log('Unknown streaming update type:', data.type);
- }
-}
-
-/**
- * Cache completion data for future restoration
- */
-function cacheCompletionData(completionData, type) {
- const artistId = artistsPageState.selectedArtist?.id;
- if (!artistId) return;
-
- // Ensure cache structure exists
- if (!artistsPageState.cache.completionData[artistId]) {
- artistsPageState.cache.completionData[artistId] = {
- albums: [],
- singles: []
- };
- }
-
- // Add to appropriate cache array
- if (type === 'albums') {
- artistsPageState.cache.completionData[artistId].albums.push(completionData);
- } else if (type === 'singles') {
- artistsPageState.cache.completionData[artistId].singles.push(completionData);
- }
-}
-
-/**
- * Update completion overlay for a specific album/single
- */
-function updateAlbumCompletionOverlay(completionData, containerType) {
- const containerId = containerType === 'albums' ? 'album-cards-container' : 'singles-cards-container';
- const container = document.getElementById(containerId);
-
- if (!container) {
- console.warn(`Container ${containerId} not found`);
- return;
- }
-
- // Find the album card by data-album-id
- const albumCard = container.querySelector(`[data-album-id="${completionData.id}"]`);
-
- if (!albumCard) {
- console.warn(`Album card not found for ID: ${completionData.id}`);
- return;
- }
-
- // Reclassify and move cards when track count reveals single/EP (Discogs lazy fetch)
- const currentType = albumCard.dataset.albumType;
- const expectedTracks = completionData.expected_tracks || 0;
- if (expectedTracks > 0) {
- albumCard.dataset.totalTracks = expectedTracks;
- let newType = currentType;
- if (currentType === 'album' && expectedTracks <= 3) newType = 'single';
- else if (currentType === 'album' && expectedTracks <= 6) newType = 'ep';
-
- if (newType !== currentType) {
- albumCard.dataset.albumType = newType;
- const typeEl = albumCard.querySelector('.album-card-type');
- if (typeEl) typeEl.textContent = newType === 'single' ? 'Single' : 'EP';
-
- // Move card from albums grid to singles grid
- const singlesGrid = document.getElementById('singles-grid');
- const singlesSection = singlesGrid?.closest('.discography-section');
- if (singlesGrid) {
- albumCard.remove();
- singlesGrid.appendChild(albumCard);
- if (singlesSection) singlesSection.style.display = '';
- }
- }
- }
-
- const overlay = albumCard.querySelector('.completion-overlay');
- if (!overlay) {
- console.warn(`Completion overlay not found for album: ${completionData.name}`);
- return;
- }
-
- // Remove existing status classes
- overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error');
-
- // Add new status class
- overlay.classList.add(completionData.status);
-
- // Update overlay text and content
- const statusText = getCompletionStatusText(completionData);
- const progressText = completionData.expected_tracks > 0
- ? `${completionData.owned_tracks}/${completionData.expected_tracks}`
- : '';
-
- overlay.innerHTML = progressText
- ? `${statusText}${progressText}`
- : `${statusText}`;
-
- // Add tooltip with more details
- overlay.title = `${completionData.name}\n${statusText} (${completionData.completion_percentage}%)\nTracks: ${completionData.owned_tracks}/${completionData.expected_tracks}\nConfidence: ${completionData.confidence}`;
-
- // Add brief flash animation to indicate update
- overlay.style.animation = 'none';
- overlay.offsetHeight; // Trigger reflow
- overlay.style.animation = 'completionOverlayFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1)';
-
- console.log(`📊 Updated overlay for "${completionData.name}": ${statusText} (${completionData.completion_percentage}%)`);
-}
-
-/**
- * Get human-readable status text for completion overlay
- */
-function getCompletionStatusText(completionData) {
- switch (completionData.status) {
- case 'completed':
- return 'Complete';
- case 'nearly_complete':
- return 'Nearly Complete';
- case 'partial':
- return 'Partial';
- case 'missing':
- return 'Missing';
- case 'downloading':
- return 'Downloading...';
- case 'downloaded':
- return 'Downloaded';
- case 'error':
- return 'Error';
- default:
- return 'Unknown';
- }
-}
-
-/**
- * Set album to downloaded status after download finishes
- */
-function setAlbumDownloadedStatus(albumId) {
- console.log(`✅ [DOWNLOAD COMPLETE] Setting album ${albumId} to downloaded status`);
-
- const completionData = {
- id: albumId,
- status: 'downloaded',
- owned_tracks: 0,
- expected_tracks: 0,
- name: 'Downloaded',
- completion_percentage: 100
- };
-
- // Find if it's in albums or singles container
- let containerType = 'albums';
- let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`);
- if (!albumCard) {
- containerType = 'singles';
- albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`);
- }
-
- if (albumCard) {
- updateAlbumCompletionOverlay(completionData, containerType);
- console.log(`✅ [DOWNLOAD COMPLETE] Album ${albumId} set to Downloaded status`);
- } else {
- console.warn(`❌ [DOWNLOAD COMPLETE] Album card not found for ID: "${albumId}"`);
- }
-}
-
-/**
- * Set album to downloading status
- */
-function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) {
- console.log(`🔍 [DOWNLOAD STATUS] Searching for album card with ID: "${albumId}"`);
-
- const completionData = {
- id: albumId,
- status: 'downloading',
- owned_tracks: downloaded,
- expected_tracks: total,
- name: 'Downloading',
- completion_percentage: Math.round((downloaded / total) * 100) || 0
- };
-
- // Find if it's in albums or singles container
- let containerType = 'albums';
- let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`);
- if (!albumCard) {
- containerType = 'singles';
- albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`);
- }
-
- if (albumCard) {
- console.log(`✅ [DOWNLOAD STATUS] Found album card in ${containerType} container, updating overlay`);
- updateAlbumCompletionOverlay(completionData, containerType);
- } else {
- console.warn(`❌ [DOWNLOAD STATUS] Album card not found for ID: "${albumId}"`);
- // Debug: List all available album cards
- const allAlbums = document.querySelectorAll('#album-cards-container [data-album-id], #singles-cards-container [data-album-id]');
- console.log(`🔍 [DEBUG] Available album IDs:`, Array.from(allAlbums).map(card => card.dataset.albumId));
- }
-}
/**
* Show error state on all completion overlays
@@ -2203,2436 +1901,3 @@ async function createArtistAlbumVirtualPlaylist(album, albumType) {
* Open download missing tracks modal specifically for artist albums
* Similar to openDownloadMissingModalForYouTube but for artist albums
*/
-async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist, showLoadingOverlayParam = true, contextType = 'artist_album') {
- if (showLoadingOverlayParam) {
- showLoadingOverlay('Loading album...');
- }
- // Check if a process is already active for this virtual playlist
- if (activeDownloadProcesses[virtualPlaylistId]) {
- console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`);
- const process = activeDownloadProcesses[virtualPlaylistId];
- if (process.modalElement) {
- if (process.status === 'complete') {
- showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
- }
- process.modalElement.style.display = 'flex';
- if (showLoadingOverlayParam) {
- hideLoadingOverlay();
- }
- }
- return;
- }
-
- console.log(`📥 Opening Download Missing Tracks modal for artist album: ${virtualPlaylistId}`);
-
- // Create virtual playlist object for compatibility with existing modal logic
- const virtualPlaylist = {
- id: virtualPlaylistId,
- name: playlistName,
- track_count: spotifyTracks.length
- };
-
- // Store the tracks in the cache for the modal to use
- playlistTrackCache[virtualPlaylistId] = spotifyTracks;
- currentPlaylistTracks = spotifyTracks;
- currentModalPlaylistId = virtualPlaylistId;
-
- let modal = document.createElement('div');
- modal.id = `download-missing-modal-${virtualPlaylistId}`;
- modal.className = 'download-missing-modal';
- modal.style.display = 'none';
- document.body.appendChild(modal);
-
- // Register the new process in our global state tracker using the same structure as other modals
- activeDownloadProcesses[virtualPlaylistId] = {
- status: 'idle',
- modalElement: modal,
- poller: null,
- batchId: null,
- playlist: virtualPlaylist,
- tracks: spotifyTracks,
- // Additional metadata for artist albums
- artist: artist,
- album: album,
- albumType: album.album_type,
- source: artist?.source || album?.source || artistsPageState.artistDiscography?.source || null
- };
-
- // Generate hero section — 'artist_album' for releases, 'playlist' for charts/compilations
- const heroContext = contextType === 'playlist' ? {
- type: 'playlist',
- playlist: { name: playlistName, owner: 'Beatport' },
- trackCount: spotifyTracks.length,
- playlistId: virtualPlaylistId
- } : {
- type: 'artist_album',
- artist: artist,
- album: album,
- trackCount: spotifyTracks.length,
- playlistId: virtualPlaylistId
- };
-
- // Use the exact same modal HTML structure as the existing modals
- modal.innerHTML = `
-
-
-
-
-
-
-
- 🔍 Library Analysis
- Ready to start
-
-
-
-
-
- ⏬ Downloads
- Waiting for analysis
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Starting sync...
-
- 0 matched
- 0 failed
-
-
-
-
-
- `;
-
- applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length);
- modal.style.display = 'flex';
- hideLoadingOverlay();
-
- console.log(`✅ Successfully opened download missing tracks modal for: ${playlistName}`);
-}
-
-// ===============================
-// ARTIST DOWNLOADS MANAGEMENT SYSTEM
-// ===============================
-
-/**
- * Register a new artist download for bubble management
- */
-function registerArtistDownload(artist, album, virtualPlaylistId, albumType) {
- console.log(`📝 Registering artist download: ${artist.name} - ${album.name}`);
-
- const artistId = artist.id;
-
- // Initialize artist bubble if it doesn't exist
- if (!artistDownloadBubbles[artistId]) {
- artistDownloadBubbles[artistId] = {
- artist: artist,
- downloads: [],
- element: null,
- hasCompletedDownloads: false
- };
- }
-
- // Add this download to the artist's downloads
- const downloadInfo = {
- virtualPlaylistId: virtualPlaylistId,
- album: album,
- albumType: albumType,
- status: 'in_progress', // 'in_progress', 'completed', 'view_results'
- startTime: new Date()
- };
-
- artistDownloadBubbles[artistId].downloads.push(downloadInfo);
-
- // Show/update the artist downloads section
- updateArtistDownloadsSection();
-
- // Save snapshot of current state
- saveArtistBubbleSnapshot();
-
- // Monitor this download for completion
- monitorArtistDownload(artistId, virtualPlaylistId);
-}
-
-/**
- * Debounced update for artist downloads section to prevent rapid updates
- */
-function updateArtistDownloadsSection() {
- if (downloadsUpdateTimeout) {
- clearTimeout(downloadsUpdateTimeout);
- }
- downloadsUpdateTimeout = setTimeout(() => {
- showArtistDownloadsSection();
- showLibraryDownloadsSection();
- showBeatportDownloadsSection();
- updateDashboardDownloads();
- }, 300); // 300ms debounce
-}
-
-// --- Artist Bubble Snapshot System ---
-
-let snapshotSaveTimeout = null; // Debounce snapshot saves
-
-async function saveArtistBubbleSnapshot() {
- /**
- * Saves current artistDownloadBubbles state to backend for persistence.
- * Debounced to prevent excessive backend calls.
- */
-
- // Clear any existing timeout
- if (snapshotSaveTimeout) {
- clearTimeout(snapshotSaveTimeout);
- }
-
- // Debounce the actual save
- snapshotSaveTimeout = setTimeout(async () => {
- try {
- const bubbleCount = Object.keys(artistDownloadBubbles).length;
-
- // Don't save empty state
- if (bubbleCount === 0) {
- console.log('📸 Skipping snapshot save - no artist bubbles to save');
- return;
- }
-
- console.log(`📸 Saving artist bubble snapshot: ${bubbleCount} artists`);
-
- // Prepare snapshot data (clean up DOM references)
- const cleanBubbles = {};
- for (const [artistId, bubbleData] of Object.entries(artistDownloadBubbles)) {
- cleanBubbles[artistId] = {
- artist: bubbleData.artist,
- downloads: bubbleData.downloads.map(download => ({
- virtualPlaylistId: download.virtualPlaylistId,
- album: download.album,
- albumType: download.albumType,
- status: download.status,
- startTime: download.startTime instanceof Date ? download.startTime.toISOString() : download.startTime
- })),
- hasCompletedDownloads: bubbleData.hasCompletedDownloads
- };
- }
-
- const response = await fetch('/api/artist_bubbles/snapshot', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- bubbles: cleanBubbles
- })
- });
-
- const data = await response.json();
-
- if (data.success) {
- console.log(`✅ Artist bubble snapshot saved: ${bubbleCount} artists`);
- } else {
- console.error('❌ Failed to save artist bubble snapshot:', data.error);
- }
-
- } catch (error) {
- console.error('❌ Error saving artist bubble snapshot:', error);
- }
- }, 1000); // 1 second debounce
-}
-
-async function hydrateArtistBubblesFromSnapshot() {
- /**
- * Hydrates artist download bubbles from backend snapshot with live status.
- * Called on page load to restore bubble state.
- */
- try {
- console.log('🔄 Loading artist bubble snapshot from backend...');
-
- const response = await fetch('/api/artist_bubbles/hydrate');
- const data = await response.json();
-
- if (!data.success) {
- console.error('❌ Failed to load artist bubble snapshot:', data.error);
- return;
- }
-
- const bubbles = data.bubbles || {};
- const stats = data.stats || {};
-
- console.log(`🔄 Loaded bubble snapshot: ${stats.total_artists || 0} artists, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`);
-
- if (Object.keys(bubbles).length === 0) {
- console.log('ℹ️ No artist bubbles to hydrate');
- return;
- }
-
- // Clear existing state
- artistDownloadBubbles = {};
-
- // Restore artistDownloadBubbles with hydrated data
- for (const [artistId, bubbleData] of Object.entries(bubbles)) {
- artistDownloadBubbles[artistId] = {
- artist: bubbleData.artist,
- downloads: bubbleData.downloads.map(download => ({
- virtualPlaylistId: download.virtualPlaylistId,
- album: download.album,
- albumType: download.albumType,
- status: download.status, // Live status from backend
- startTime: new Date(download.startTime)
- })),
- element: null, // Will be created when UI updates
- hasCompletedDownloads: bubbleData.hasCompletedDownloads
- };
-
- console.log(`🔄 Hydrated artist: ${bubbleData.artist.name} (${bubbleData.downloads.length} downloads)`);
-
- // Start monitoring for any in-progress downloads
- for (const download of bubbleData.downloads) {
- if (download.status === 'in_progress') {
- console.log(`📡 Starting monitoring for: ${download.album.name}`);
- monitorArtistDownload(artistId, download.virtualPlaylistId);
- }
- }
- }
-
- // Update UI to show hydrated bubbles
- updateArtistDownloadsSection();
-
- const totalArtists = Object.keys(artistDownloadBubbles).length;
- console.log(`✅ Successfully hydrated ${totalArtists} artist download bubbles`);
-
- } catch (error) {
- console.error('❌ Error hydrating artist bubbles from snapshot:', error);
- }
-}
-
-// --- Search Bubble Snapshot System ---
-
-async function saveSearchBubbleSnapshot() {
- /**
- * Saves current searchDownloadBubbles state to backend for persistence.
- */
- try {
- // Rate limit saves to avoid spamming backend
- if (saveSearchBubbleSnapshot.lastSaveTime) {
- const timeSinceLastSave = Date.now() - saveSearchBubbleSnapshot.lastSaveTime;
- if (timeSinceLastSave < 2000) {
- console.log('⏱️ Skipping search bubble snapshot save (rate limited)');
- return;
- }
- }
-
- const bubbleCount = Object.keys(searchDownloadBubbles).length;
-
- if (bubbleCount === 0) {
- console.log('📸 Skipping snapshot save - no search bubbles to save');
- return;
- }
-
- console.log(`📸 Saving search bubble snapshot: ${bubbleCount} artists`);
-
- // Convert search bubbles to plain objects for serialization
- const bubblesToSave = {};
- for (const [artistName, bubbleData] of Object.entries(searchDownloadBubbles)) {
- bubblesToSave[artistName] = {
- artist: bubbleData.artist,
- downloads: bubbleData.downloads.map(d => ({
- virtualPlaylistId: d.virtualPlaylistId,
- item: d.item,
- type: d.type,
- status: d.status,
- startTime: d.startTime
- }))
- };
- }
-
- const response = await fetch('/api/search_bubbles/snapshot', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ bubbles: bubblesToSave })
- });
-
- const data = await response.json();
-
- if (data.success) {
- console.log(`✅ Search bubble snapshot saved: ${bubbleCount} artists`);
- saveSearchBubbleSnapshot.lastSaveTime = Date.now();
- } else {
- console.error('❌ Failed to save search bubble snapshot:', data.error);
- }
-
- } catch (error) {
- console.error('❌ Error saving search bubble snapshot:', error);
- }
-}
-
-async function hydrateSearchBubblesFromSnapshot() {
- /**
- * Hydrates search download bubbles from backend snapshot with live status.
- */
- try {
- console.log('🔄 Loading search bubble snapshot from backend...');
-
- const response = await fetch('/api/search_bubbles/hydrate');
- const data = await response.json();
-
- if (!data.success) {
- console.error('❌ Failed to load search bubble snapshot:', data.error);
- return;
- }
-
- const bubbles = data.bubbles || {};
- const stats = data.stats || {};
-
- if (Object.keys(bubbles).length === 0) {
- console.log('ℹ️ No search bubbles to hydrate');
- return;
- }
-
- // Clear and restore search bubbles
- searchDownloadBubbles = {};
-
- for (const [artistName, bubbleData] of Object.entries(bubbles)) {
- searchDownloadBubbles[artistName] = {
- artist: bubbleData.artist,
- downloads: bubbleData.downloads || []
- };
-
- console.log(`🔄 Hydrated artist: ${artistName} (${bubbleData.downloads.length} downloads)`);
-
- // Setup monitoring for each download
- for (const download of bubbleData.downloads) {
- if (download.status === 'in_progress') {
- monitorSearchDownload(artistName, download.virtualPlaylistId);
- }
- }
- }
-
- const totalArtists = Object.keys(searchDownloadBubbles).length;
- console.log(`✅ Successfully hydrated ${totalArtists} search download bubbles`);
-
- // Refresh display
- showSearchDownloadBubbles();
-
- } catch (error) {
- console.error('❌ Error hydrating search bubbles from snapshot:', error);
- }
-}
-
-/**
- * Register a new search download for bubble management (grouped by artist)
- */
-function registerSearchDownload(item, type, virtualPlaylistId, artistName) {
- console.log(`📝 [REGISTER] Registering search download: ${item.name} (${type}) by ${artistName}`);
-
- // Initialize artist bubble if it doesn't exist
- if (!searchDownloadBubbles[artistName]) {
- searchDownloadBubbles[artistName] = {
- artist: {
- name: artistName,
- image_url: item.image_url || (item.images && item.images[0]?.url) || null
- },
- downloads: []
- };
- }
-
- // Add this download to the artist's downloads
- const downloadInfo = {
- virtualPlaylistId: virtualPlaylistId,
- item: item,
- type: type, // 'album' or 'track'
- status: 'in_progress',
- startTime: new Date().toISOString()
- };
-
- searchDownloadBubbles[artistName].downloads.push(downloadInfo);
-
- console.log(`✅ [REGISTER] Registered search download for ${artistName} - ${item.name}`);
-
- // Save snapshot
- saveSearchBubbleSnapshot();
-
- // Setup monitoring
- monitorSearchDownload(artistName, virtualPlaylistId);
-
- // Refresh display
- updateSearchDownloadsSection();
-}
-
-/**
- * Debounced update for search downloads section
- */
-function updateSearchDownloadsSection() {
- if (window.searchUpdateTimeout) {
- clearTimeout(window.searchUpdateTimeout);
- }
- window.searchUpdateTimeout = setTimeout(() => {
- showSearchDownloadBubbles();
- updateDashboardDownloads();
- }, 300);
-}
-
-/**
- * Monitor a search download for completion status changes
- */
-function monitorSearchDownload(artistName, virtualPlaylistId) {
- const checkCompletion = setInterval(() => {
- const process = activeDownloadProcesses[virtualPlaylistId];
-
- if (!process || !searchDownloadBubbles[artistName]) {
- clearInterval(checkCompletion);
- return;
- }
-
- // Find the download in the artist's downloads
- const download = searchDownloadBubbles[artistName].downloads.find(
- d => d.virtualPlaylistId === virtualPlaylistId
- );
-
- if (!download) {
- clearInterval(checkCompletion);
- return;
- }
-
- // Update status
- const newStatus = process.status === 'complete' || process.status === 'view_results'
- ? 'view_results'
- : 'in_progress';
-
- if (download.status !== newStatus) {
- console.log(`🔄 [MONITOR] Status changed for ${download.item.name}: ${download.status} -> ${newStatus}`);
- download.status = newStatus;
-
- // Save snapshot and refresh
- saveSearchBubbleSnapshot();
- updateSearchDownloadsSection();
- }
- }, 2000);
-}
-
-/**
- * Show or update the search downloads bubble section
- */
-function showSearchDownloadBubbles() {
- console.log(`🔄 [SHOW] showSearchDownloadBubbles() called`);
-
- const resultsArea = document.getElementById('enhanced-main-results-area');
- if (!resultsArea) {
- console.log(`⏭️ [SHOW] Skipping - no enhanced-main-results-area found`);
- return;
- }
-
- // Count active artists (those with downloads)
- const activeArtists = Object.keys(searchDownloadBubbles).filter(artistName =>
- searchDownloadBubbles[artistName].downloads.length > 0
- );
-
- if (activeArtists.length === 0) {
- // Show placeholder
- resultsArea.innerHTML = `
-
-
Search results will appear here when you select an album or track.
-
- `;
- return;
- }
-
- // Create bubbles display
- const bubblesHTML = activeArtists.map(artistName =>
- createSearchBubbleCard(searchDownloadBubbles[artistName])
- ).join('');
-
- resultsArea.innerHTML = `
-
- `;
-
- console.log(`✅ [SHOW] Displayed ${activeArtists.length} search bubbles`);
-}
-
-/**
- * Create HTML for a search bubble card (grouped by artist)
- */
-function createSearchBubbleCard(artistBubbleData) {
- const { artist, downloads } = artistBubbleData;
- 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;
-
- console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, {
- totalDownloads: downloads.length,
- activeCount,
- completedCount,
- allCompleted
- });
-
- const imageUrl = artist.image_url || '';
- const backgroundStyle = imageUrl ?
- `background-image: url('${escapeHtml(imageUrl)}');` :
- `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
-
- return `
-
-
-
-
-
${escapeHtml(artist.name)}
-
- ${activeCount > 0 ? `${activeCount} active` : ''}
- ${completedCount > 0 ? `${completedCount} completed` : ''}
-
-
- ${allCompleted ? `
-
- ✅
-
- ` : ''}
-
- `;
-}
-
-/**
- * Open modal showing all downloads for an artist
- */
-async function openSearchDownloadModal(artistName) {
- const artistBubbleData = searchDownloadBubbles[artistName];
- if (!artistBubbleData || searchDownloadModalOpen) return;
-
- console.log(`🎵 [MODAL OPEN] Opening search download modal for: ${artistBubbleData.artist.name}`);
-
- searchDownloadModalOpen = true;
-
- const modal = document.createElement('div');
- modal.id = 'search-download-management-modal';
- modal.className = 'artist-download-management-modal';
- modal.innerHTML = `
-
-
-
-
-
-
- ${artistBubbleData.artist.image_url
- ? `
})
`
- : '
🎵
'
- }
-
-
-
${escapeHtml(artistBubbleData.artist.name)}
-
${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}
-
-
-
×
-
-
-
-
-
- ${artistBubbleData.downloads.map((download, index) => createSearchDownloadItem(download, index)).join('')}
-
-
-
-
- `;
-
- document.body.appendChild(modal);
- modal.style.display = 'flex';
-
- // Start monitoring for status changes
- // Start monitoring for status changes
- monitorSearchDownloadModal(artistName);
-
- // Lazy load artist image if missing (common for iTunes)
- if (!artistBubbleData.artist.image_url) {
- console.log(`🖼️ Lazy loading modal image for ${artistBubbleData.artist.name} (${artistBubbleData.artist.id})`);
- fetch(`/api/artist/${artistBubbleData.artist.id}/image`)
- .then(response => response.json())
- .then(data => {
- if (data.success && data.image_url) {
- // Update header background
- const headerBg = modal.querySelector('.artist-download-modal-hero-bg');
- if (headerBg) {
- headerBg.style.backgroundImage = `url('${data.image_url}')`;
- }
-
- // Update avatar
- const avatarContainer = modal.querySelector('.artist-download-modal-hero-avatar');
- if (avatarContainer) {
- avatarContainer.innerHTML = `
`;
- }
-
- // Update artist object in memory
- artistBubbleData.artist.image_url = data.image_url;
- }
- })
- .catch(err => console.error('❌ Failed to load modal image:', err));
- }
-}
-
-/**
- * Create HTML for a download item in the search modal
- */
-function createSearchDownloadItem(download, index) {
- const { item, type, status, virtualPlaylistId } = download;
- const buttonText = status === 'view_results' ? 'View Results' : 'View Progress';
- const buttonClass = status === 'view_results' ? 'completed' : 'active';
- const typeLabel = type === 'album' ? 'Album' : type === 'single' ? 'Single' : 'Track';
-
- return `
-
-
- ${item.image_url
- ? `
})
`
- : `
- ${type === 'album' ? '💿' : '🎵'}
-
`
- }
-
-
-
${escapeHtml(item.name)}
-
${typeLabel}
-
-
-
-
-
- `;
-}
-
-/**
- * Reopen an individual download modal from the artist modal
- */
-async function reopenDownloadModal(virtualPlaylistId) {
- const process = activeDownloadProcesses[virtualPlaylistId];
-
- // If process exists, show the existing modal
- if (process && process.modalElement) {
- console.log(`✅ [REOPEN] Showing existing modal for ${virtualPlaylistId}`);
- closeSearchDownloadModal();
- setTimeout(() => {
- process.modalElement.style.display = 'flex';
- }, 100);
- return;
- }
-
- // Process doesn't exist (after page refresh) - recreate it
- console.log(`🔄 [REOPEN] Modal not found, recreating for ${virtualPlaylistId}`);
-
- // Find the download in searchDownloadBubbles
- let downloadData = null;
- for (const artistName in searchDownloadBubbles) {
- const bubble = searchDownloadBubbles[artistName];
- const download = bubble.downloads.find(d => d.virtualPlaylistId === virtualPlaylistId);
- if (download) {
- downloadData = download;
- break;
- }
- }
-
- if (!downloadData) {
- console.warn(`⚠️ No download data found for ${virtualPlaylistId}`);
- return;
- }
-
- // Close search modal first
- closeSearchDownloadModal();
-
- // Recreate the modal based on type
- const { item, type } = downloadData;
-
- if (type === 'album') {
- // For albums, we need to fetch the tracks
- console.log(`📥 [REOPEN] Recreating album modal for: ${item.name}`);
-
- // Fetch album tracks (pass name/artist for Hydrabase support)
- showLoadingOverlay(`Loading ${item.name}...`);
-
- try {
- const _sap2 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' });
- const response = await fetch(`/api/spotify/album/${item.id}?${_sap2}`);
- if (!response.ok) {
- throw new Error('Failed to fetch album tracks');
- }
-
- const albumData = await response.json();
- if (!albumData.tracks || albumData.tracks.length === 0) {
- throw new Error('No tracks found in album');
- }
-
- const spotifyTracks = albumData.tracks.map(track => ({
- id: track.id,
- name: track.name,
- artists: track.artists || [{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }],
- album: {
- name: item.name,
- images: item.image_url ? [{ url: item.image_url }] : []
- },
- duration_ms: track.duration_ms || 0
- }));
-
- hideLoadingOverlay();
-
- // Open the modal
- await openDownloadMissingModalForArtistAlbum(
- virtualPlaylistId,
- item.name,
- spotifyTracks,
- item,
- { name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' },
- false // Don't show loading overlay again
- );
-
- // Sync with backend to check for active batch process
- const process = activeDownloadProcesses[virtualPlaylistId];
- if (process) {
- try {
- const processResponse = await fetch('/api/active-processes');
- if (processResponse.ok) {
- const processData = await processResponse.json();
- const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId);
-
- if (activeProcess) {
- console.log(`📡 [REOPEN] Found active batch for album: ${activeProcess.batch_id}`);
- process.status = 'running';
- process.batchId = activeProcess.batch_id;
-
- // Update UI to show running state
- const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
- const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
- if (beginBtn) beginBtn.style.display = 'none';
- if (cancelBtn) cancelBtn.style.display = 'inline-block';
-
- // Start polling for live updates
- startModalDownloadPolling(virtualPlaylistId);
- }
- }
- } catch (err) {
- console.warn('Could not check for active processes:', err);
- }
- }
-
- } catch (error) {
- hideLoadingOverlay();
- showToast(`Failed to load album: ${error.message}`, 'error');
- console.error('Error loading album:', error);
- }
-
- } else {
- // For tracks, create enriched track and open modal
- console.log(`🎵 [REOPEN] Recreating track modal for: ${item.name}`);
-
- const enrichedTrack = {
- id: item.id,
- name: item.name,
- artists: item.artists || [{ name: item.artist || 'Unknown Artist' }],
- album: item.album || {
- name: item.album?.name || 'Unknown Album',
- images: item.image_url ? [{ url: item.image_url }] : []
- },
- duration_ms: item.duration_ms || 0
- };
-
- await openDownloadMissingModalForYouTube(
- virtualPlaylistId,
- `${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`,
- [enrichedTrack]
- );
-
- // Sync with backend to check for active batch process
- const process = activeDownloadProcesses[virtualPlaylistId];
- if (process) {
- try {
- const processResponse = await fetch('/api/active-processes');
- if (processResponse.ok) {
- const processData = await processResponse.json();
- const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId);
-
- if (activeProcess) {
- console.log(`📡 [REOPEN] Found active batch for track: ${activeProcess.batch_id}`);
- process.status = 'running';
- process.batchId = activeProcess.batch_id;
-
- // Update UI to show running state
- const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
- const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
- if (beginBtn) beginBtn.style.display = 'none';
- if (cancelBtn) cancelBtn.style.display = 'inline-block';
-
- // Start polling for live updates
- startModalDownloadPolling(virtualPlaylistId);
- }
- }
- } catch (err) {
- console.warn('Could not check for active processes:', err);
- }
- }
- }
-}
-
-/**
- * Monitor search download modal for status changes
- */
-function monitorSearchDownloadModal(artistName) {
- const updateModal = () => {
- if (!searchDownloadModalOpen) return;
-
- const modal = document.getElementById('search-download-management-modal');
- const itemsContainer = document.getElementById('search-download-items');
-
- if (!modal || !itemsContainer || !searchDownloadBubbles[artistName]) return;
-
- const downloads = searchDownloadBubbles[artistName].downloads;
-
- // If no downloads at all, close modal
- if (downloads.length === 0) {
- closeSearchDownloadModal();
- return;
- }
-
- // Update modal content and sync status with active processes
- let statusChanged = false;
- itemsContainer.innerHTML = downloads.map((download, index) => {
- const process = activeDownloadProcesses[download.virtualPlaylistId];
-
- // Only update status if process exists (otherwise keep current status)
- if (process) {
- const newStatus = process.status === 'complete' || process.status === 'view_results'
- ? 'view_results'
- : 'in_progress';
-
- if (download.status !== newStatus) {
- console.log(`🔄 [MODAL MONITOR] Status changed: ${download.item.name} ${download.status} -> ${newStatus}`);
- download.status = newStatus;
- statusChanged = true;
- }
- }
-
- return createSearchDownloadItem(download, index);
- }).join('');
-
- // If status changed, refresh bubble display and save
- if (statusChanged) {
- updateSearchDownloadsSection();
- saveSearchBubbleSnapshot();
- }
-
- // Continue monitoring
- setTimeout(updateModal, 2000);
- };
-
- setTimeout(updateModal, 1000);
-}
-
-/**
- * Close the search download modal
- */
-function closeSearchDownloadModal() {
- const modal = document.getElementById('search-download-management-modal');
- if (modal) {
- modal.style.display = 'none';
- if (modal.parentElement) {
- modal.parentElement.removeChild(modal);
- }
- }
- searchDownloadModalOpen = false;
-}
-
-/**
- * Bulk complete all downloads for an artist (called when user clicks green checkmark)
- */
-function bulkCompleteSearchDownloads(artistName) {
- console.log(`🎯 Bulk completing downloads for artist: ${artistName}`);
-
- const artistBubbleData = searchDownloadBubbles[artistName];
- if (!artistBubbleData) {
- console.warn(`❌ No artist bubble data found for ${artistName}`);
- return;
- }
-
- // Find all completed downloads
- const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results');
- console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`,
- completedDownloads.map(d => d.item.name));
-
- if (completedDownloads.length === 0) {
- console.warn(`⚠️ No completed downloads found for bulk close`);
- showToast('No completed downloads to close', 'info');
- return;
- }
-
- // Close all completed modals
- completedDownloads.forEach(download => {
- const process = activeDownloadProcesses[download.virtualPlaylistId];
- if (process && process.modalElement) {
- console.log(`🗑️ Closing modal for: ${download.item.name}`);
- closeDownloadMissingModal(download.virtualPlaylistId);
- } else {
- // No modal open — clean up the bubble entry directly
- console.log(`🧹 Direct cleanup (no modal) for: ${download.item.name}`);
- cleanupSearchDownload(download.virtualPlaylistId);
- }
- });
-
- showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success');
-}
-
-/**
- * Cleanup search download when modal is closed
- */
-function cleanupSearchDownload(virtualPlaylistId) {
- console.log(`🔍 [CLEANUP] Looking for search download to cleanup: ${virtualPlaylistId}`);
-
- // Find which artist this download belongs to
- for (const artistName in searchDownloadBubbles) {
- const downloads = searchDownloadBubbles[artistName].downloads;
- const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId);
-
- if (downloadIndex !== -1) {
- console.log(`🧹 [CLEANUP] Found download in artist ${artistName}: ${downloads[downloadIndex].item.name}`);
-
- // Remove this download
- downloads.splice(downloadIndex, 1);
- console.log(`🗑️ [CLEANUP] Removed download from ${artistName}'s bubble`);
-
- // If no more downloads for this artist, remove the bubble
- if (downloads.length === 0) {
- delete searchDownloadBubbles[artistName];
- console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistName}`);
- }
-
- // Save snapshot and refresh
- saveSearchBubbleSnapshot();
- updateSearchDownloadsSection();
-
- return;
- }
- }
-
- console.log(`⚠️ [CLEANUP] No matching search download found for: ${virtualPlaylistId}`);
-}
-
-/**
- * Show or update the artist downloads section in search state
- */
-function showArtistDownloadsSection() {
- console.log(`🔄 [SHOW] showArtistDownloadsSection() called - refreshing artist bubbles`);
- console.log(`🔄 [SHOW] Current view: ${artistsPageState.currentView}, artistDownloadBubbles count: ${Object.keys(artistDownloadBubbles).length}`);
-
- // Only show in search state
- if (artistsPageState.currentView !== 'search') {
- console.log(`⏭️ [SHOW] Skipping - not in search state (current: ${artistsPageState.currentView})`);
- return;
- }
-
- const artistsSearchState = document.getElementById('artists-search-state');
- if (!artistsSearchState) {
- console.log(`⏭️ [SHOW] Skipping - no artists-search-state element found`);
- return;
- }
-
- let downloadsSection = document.getElementById('artist-downloads-section');
-
- // Create section if it doesn't exist
- if (!downloadsSection) {
- downloadsSection = document.createElement('div');
- downloadsSection.id = 'artist-downloads-section';
- downloadsSection.className = 'artist-downloads-section';
-
- // Insert after the search container
- const searchContainer = artistsSearchState.querySelector('.artists-search-container');
- if (searchContainer) {
- searchContainer.insertAdjacentElement('afterend', downloadsSection);
- }
- }
-
- // Count active artists (those with downloads)
- const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId =>
- artistDownloadBubbles[artistId].downloads.length > 0
- );
-
- if (activeArtists.length === 0) {
- downloadsSection.style.display = 'none';
- return;
- }
-
- // Show and populate the section
- downloadsSection.style.display = 'block';
- downloadsSection.innerHTML = `
-
-
- ${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')}
-
- `;
-
- // Add event listeners to bubble cards
- activeArtists.forEach(artistId => {
- const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`);
- if (bubbleCard) {
- bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId));
-
- // Add dynamic glow effect
- const artist = artistDownloadBubbles[artistId].artist;
- if (artist.image_url) {
- extractImageColors(artist.image_url, (colors) => {
- applyDynamicGlow(bubbleCard, colors);
- });
- }
- }
- });
-}
-
-/**
- * Show download bubbles on the Library page (mirrors showArtistDownloadsSection)
- */
-function showLibraryDownloadsSection() {
- const libraryContent = document.querySelector('.library-content');
- if (!libraryContent) return;
-
- let downloadsSection = document.getElementById('library-downloads-section');
-
- // Create section if it doesn't exist
- if (!downloadsSection) {
- downloadsSection = document.createElement('div');
- downloadsSection.id = 'library-downloads-section';
- downloadsSection.className = 'artist-downloads-section';
-
- // Insert before the artist grid
- const artistGrid = document.getElementById('library-artists-grid');
- if (artistGrid) {
- libraryContent.insertBefore(downloadsSection, artistGrid);
- }
- }
-
- // Count active artists (reuses artistDownloadBubbles state)
- const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId =>
- artistDownloadBubbles[artistId].downloads.length > 0
- );
-
- if (activeArtists.length === 0) {
- downloadsSection.style.display = 'none';
- return;
- }
-
- downloadsSection.style.display = 'block';
- downloadsSection.innerHTML = `
-
-
- ${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')}
-
- `;
-
- // Add click handlers + glow effects
- activeArtists.forEach(artistId => {
- const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`);
- if (bubbleCard) {
- bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId));
- const artist = artistDownloadBubbles[artistId].artist;
- if (artist.image_url) {
- extractImageColors(artist.image_url, (colors) => {
- applyDynamicGlow(bubbleCard, colors);
- });
- }
- }
- });
-}
-
-/**
- * Create HTML for an artist bubble card
- */
-function createArtistBubbleCard(artistBubbleData) {
- const { artist, downloads } = artistBubbleData;
- 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;
-
- // Enhanced debug logging for bubble card creation and green checkmark detection
- console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, {
- totalDownloads: downloads.length,
- activeCount,
- completedCount,
- allCompleted,
- downloadStatuses: downloads.map(d => `${d.album.name}: ${d.status}`)
- });
-
- // CRITICAL: Green checkmark detection logging
- if (allCompleted) {
- console.log(`🟢 [BUBBLE] GREEN CHECKMARK DETECTED for ${artist.name} - all ${downloads.length} downloads completed`);
- console.log(`✅ [BUBBLE] This bubble will have 'all-completed' class and green checkmark`);
- } else if (activeCount === 0 && completedCount === 0) {
- console.log(`⭕ [BUBBLE] No active or completed downloads for ${artist.name} - this shouldn't happen`);
- } else {
- console.log(`⏳ [BUBBLE] Still waiting for completion: ${activeCount} active, ${completedCount} completed`);
- }
-
- const imageUrl = artist.image_url || '';
- const backgroundStyle = imageUrl ?
- `background-image: url('${imageUrl}');` :
- `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
-
- return `
-
-
-
-
-
${escapeHtml(artist.name)}
-
- ${activeCount > 0 ? `${activeCount} active` : ''}
- ${completedCount > 0 ? `${completedCount} completed` : ''}
-
-
- ${allCompleted ? `
-
- ✅
-
- ` : ''}
-
- `;
-}
-
-/**
- * Monitor an artist download for completion status changes
- */
-function monitorArtistDownload(artistId, virtualPlaylistId) {
- // Check if the download process exists and monitor its status
- const checkStatus = () => {
- const process = activeDownloadProcesses[virtualPlaylistId];
- if (!process || !artistDownloadBubbles[artistId]) {
- return; // Process or artist bubble no longer exists
- }
-
- // Find this download in the artist's downloads
- const download = artistDownloadBubbles[artistId].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId);
- if (!download) return;
-
- // Update download status based on process status
- if (process.status === 'complete' && download.status === 'in_progress') {
- download.status = 'view_results';
- console.log(`✅ Download completed for ${artistDownloadBubbles[artistId].artist.name} - ${download.album.name}`);
- console.log(`📊 Artist ${artistId} downloads status:`, artistDownloadBubbles[artistId].downloads.map(d => `${d.album.name}: ${d.status}`));
-
- // Update the downloads section
- updateArtistDownloadsSection();
-
- // Save snapshot of updated state
- saveArtistBubbleSnapshot();
-
- // Check if all downloads for this artist are now completed
- const artistDownloads = artistDownloadBubbles[artistId].downloads;
- const allCompleted = artistDownloads.every(d => d.status === 'view_results');
- if (allCompleted) {
- console.log(`🟢 All downloads completed for ${artistDownloadBubbles[artistId].artist.name} - green checkmark should appear`);
- console.log(`🎯 [STATUS DEBUG] Green checkmark trigger - forcing bubble refresh`);
- // Force immediate bubble refresh to show green checkmark
- setTimeout(updateArtistDownloadsSection, 100);
- }
- }
-
- // Continue monitoring if still active
- if (process.status !== 'complete') {
- setTimeout(checkStatus, 2000); // Check every 2 seconds
- }
- };
-
- // Start monitoring after a brief delay
- setTimeout(checkStatus, 1000);
-}
-
-/**
- * Open the artist download management modal
- */
-function openArtistDownloadModal(artistId) {
- const artistBubbleData = artistDownloadBubbles[artistId];
- if (!artistBubbleData || artistDownloadModalOpen) return;
-
- console.log(`🎵 [MODAL OPEN] Opening artist download modal for: ${artistBubbleData.artist.name}`);
- console.log(`📊 [MODAL OPEN] Current download statuses:`, artistBubbleData.downloads.map(d => `${d.album.name}: ${d.status}`));
- artistDownloadModalOpen = true;
-
- const modal = document.createElement('div');
- modal.id = 'artist-download-management-modal';
- modal.className = 'artist-download-management-modal';
- modal.innerHTML = `
-
-
-
-
-
-
- ${artistBubbleData.artist.image_url
- ? `
})
`
- : '
'
- }
-
-
-
${escapeHtml(artistBubbleData.artist.name)}
-
${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}
-
-
-
×
-
-
-
-
-
- ${artistBubbleData.downloads.map((download, index) => createArtistDownloadItem(download, index)).join('')}
-
-
-
-
- `;
-
- document.body.appendChild(modal);
- modal.style.display = 'flex';
-
- // Monitor for real-time updates
- startArtistDownloadModalMonitoring(artistId);
-}
-
-/**
- * Create HTML for an individual download item in the artist modal
- */
-function createArtistDownloadItem(download, index) {
- const { album, albumType, status, virtualPlaylistId } = download;
- const buttonText = status === 'view_results' ? 'View Results' : 'View Progress';
- const buttonClass = status === 'view_results' ? 'completed' : 'active';
-
- // Enhanced debugging for button text generation
- console.log(`🎯 [BUTTON] Creating item for ${album.name}: status='${status}' → buttonText='${buttonText}'`);
-
- return `
-
-
- ${album.image_url
- ? `
})
`
- : `
-
-
`
- }
-
-
-
${escapeHtml(album.name)}
-
${albumType === 'album' ? 'Album' : albumType === 'single' ? 'Single' : 'EP'}
-
-
-
-
-
- `;
-}
-
-/**
- * Monitor artist download modal for real-time updates
- */
-function startArtistDownloadModalMonitoring(artistId) {
- if (!artistDownloadModalOpen) return;
-
- const updateModal = () => {
- const modal = document.getElementById('artist-download-management-modal');
- const itemsContainer = document.getElementById(`artist-download-items-${artistId}`);
-
- if (!modal || !itemsContainer || !artistDownloadBubbles[artistId]) return;
-
- // Check for completed downloads that need to be removed
- const activeDownloads = artistDownloadBubbles[artistId].downloads.filter(download => {
- const process = activeDownloadProcesses[download.virtualPlaylistId];
- // Keep if process exists or if it's completed but not yet cleaned up
- return process !== undefined;
- });
-
- // Update the downloads array
- artistDownloadBubbles[artistId].downloads = activeDownloads;
-
- // If no downloads left, close modal
- if (activeDownloads.length === 0) {
- closeArtistDownloadModal();
- return;
- }
-
- // Update modal content and synchronize with bubble state
- let statusChanged = false;
- itemsContainer.innerHTML = activeDownloads.map((download, index) => {
- const process = activeDownloadProcesses[download.virtualPlaylistId];
- if (process) {
- const newStatus = process.status === 'complete' ? 'view_results' : 'in_progress';
- if (download.status !== newStatus) {
- console.log(`🔄 [ARTIST MODAL] Updating ${download.album.name} status from ${download.status} to ${newStatus}`);
- download.status = newStatus;
- statusChanged = true;
- }
- }
- return createArtistDownloadItem(download, index);
- }).join('');
-
- // CRITICAL: If any status changed, immediately refresh artist bubble to show green checkmarks
- if (statusChanged) {
- console.log(`🎯 [SYNC] Status change detected in artist modal - refreshing bubble display`);
- updateArtistDownloadsSection();
-
- // Check if all downloads for this artist are now completed
- const artistDownloads = artistDownloadBubbles[artistId].downloads;
- const allCompleted = artistDownloads.every(d => d.status === 'view_results');
- if (allCompleted) {
- console.log(`🟢 [ARTIST MODAL] All downloads completed for artist ${artistId} - triggering green checkmark`);
- // Force additional refresh after a brief delay to ensure UI updates
- setTimeout(() => {
- console.log(`✨ [ARTIST MODAL] Forcing final refresh for green checkmark`);
- updateArtistDownloadsSection();
- }, 200);
- }
- }
-
- // Continue monitoring
- setTimeout(updateModal, 2000);
- };
-
- setTimeout(updateModal, 1000);
-}
-
-/**
- * Open a specific artist download process modal
- */
-function openArtistDownloadProcess(virtualPlaylistId) {
- const process = activeDownloadProcesses[virtualPlaylistId];
- if (process && process.modalElement) {
- // Close artist management modal first
- closeArtistDownloadModal();
-
- // Show the download process modal
- process.modalElement.style.display = 'flex';
-
- if (process.status === 'complete') {
- showToast('Review download results and click "Close" to finish.', 'info');
- }
- }
-}
-
-/**
- * Close the artist download management modal
- */
-function closeArtistDownloadModal() {
- const modal = document.getElementById('artist-download-management-modal');
- if (modal) {
- modal.remove();
- }
- artistDownloadModalOpen = false;
-}
-
-/**
- * Bulk complete all downloads for an artist (when all are in 'view_results' state)
- */
-function bulkCompleteArtistDownloads(artistId) {
- console.log(`🎯 Bulk completing downloads for artist: ${artistId}`);
-
- const artistBubbleData = artistDownloadBubbles[artistId];
- if (!artistBubbleData) {
- console.warn(`❌ No artist bubble data found for ${artistId}`);
- return;
- }
-
- // Find all downloads in 'view_results' state
- const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results');
- console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`,
- completedDownloads.map(d => d.album.name));
-
- if (completedDownloads.length === 0) {
- console.warn(`⚠️ No completed downloads found for bulk close`);
- showToast('No completed downloads to close', 'info');
- return;
- }
-
- // Programmatically close all completed modals
- completedDownloads.forEach(download => {
- const process = activeDownloadProcesses[download.virtualPlaylistId];
- if (process && process.modalElement) {
- console.log(`🗑️ Closing modal for: ${download.album.name}`);
- // Trigger the close function which handles cleanup
- closeDownloadMissingModal(download.virtualPlaylistId);
- } else {
- // No modal open — clean up the bubble entry directly
- console.log(`🧹 Direct cleanup (no modal) for: ${download.album.name}`);
- cleanupArtistDownload(download.virtualPlaylistId);
- }
- });
-
- 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 = `
-
-
- ${activeCharts.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')}
-
- `;
-
- // Attach click handlers + glow
- activeCharts.forEach(chartKey => {
- const card = downloadsSection.querySelector(`[data-chart-key="${chartKey}"]`);
- if (card) {
- card.addEventListener('click', () => openBeatportBubbleModal(chartKey));
- const chartImage = beatportDownloadBubbles[chartKey].chart.image;
- if (chartImage) {
- extractImageColors(chartImage, (colors) => {
- applyDynamicGlow(card, colors);
- });
- }
- }
- });
-}
-
-/**
- * Create HTML for a Beatport bubble card (reuses artist bubble CSS)
- */
-function createBeatportBubbleCard(bubbleData) {
- const { chart, downloads } = bubbleData;
- const chartKey = chart.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
- const activeCount = downloads.filter(d => d.status === 'in_progress').length;
- const completedCount = downloads.filter(d => d.status === 'view_results').length;
- const allCompleted = activeCount === 0 && completedCount > 0;
-
- const backgroundStyle = chart.image
- ? `background-image: url('${chart.image}');`
- : `background: linear-gradient(135deg, rgba(0, 210, 120, 0.3) 0%, rgba(0, 170, 100, 0.2) 100%);`;
-
- return `
-
-
-
-
-
${escapeHtml(chart.name)}
-
- ${activeCount > 0 ? `${activeCount} active` : ''}
- ${completedCount > 0 ? `${completedCount} completed` : ''}
-
-
- ${allCompleted ? `
-
- ✅
-
- ` : ''}
-
- `;
-}
-
-/**
- * Monitor a Beatport download for completion
- */
-function monitorBeatportDownload(chartKey, virtualPlaylistId) {
- const checkStatus = () => {
- const process = activeDownloadProcesses[virtualPlaylistId];
- if (!process || !beatportDownloadBubbles[chartKey]) return;
-
- const download = beatportDownloadBubbles[chartKey].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId);
- if (!download) return;
-
- if (process.status === 'complete' && download.status === 'in_progress') {
- download.status = 'view_results';
- console.log(`✅ Beatport download completed for ${beatportDownloadBubbles[chartKey].chart.name}`);
-
- updateBeatportDownloadsSection();
- saveBeatportBubbleSnapshot();
-
- const allCompleted = beatportDownloadBubbles[chartKey].downloads.every(d => d.status === 'view_results');
- if (allCompleted) {
- console.log(`🟢 All Beatport downloads completed for ${beatportDownloadBubbles[chartKey].chart.name}`);
- setTimeout(updateBeatportDownloadsSection, 100);
- }
- }
-
- if (process.status !== 'complete') {
- setTimeout(checkStatus, 2000);
- }
- };
-
- setTimeout(checkStatus, 1000);
-}
-
-/**
- * Open the download modal for a Beatport chart bubble
- */
-function openBeatportBubbleModal(chartKey) {
- const bubbleData = beatportDownloadBubbles[chartKey];
- if (!bubbleData) return;
-
- // Find the first download with an active modal
- for (const download of bubbleData.downloads) {
- const process = activeDownloadProcesses[download.virtualPlaylistId];
- if (process && process.modalElement) {
- process.modalElement.style.display = 'flex';
- if (process.status === 'complete') {
- showToast('Review download results and click "Close" to finish.', 'info');
- }
- return;
- }
- }
-
- showToast('No active download modal found for this chart', 'info');
-}
-
-/**
- * Bulk complete all downloads for a Beatport chart
- */
-function bulkCompleteBeatportDownloads(chartKey) {
- console.log(`🎯 Bulk completing Beatport downloads for chart: ${chartKey}`);
-
- const bubbleData = beatportDownloadBubbles[chartKey];
- if (!bubbleData) return;
-
- const completedDownloads = bubbleData.downloads.filter(d => d.status === 'view_results');
- if (completedDownloads.length === 0) {
- showToast('No completed downloads to close', 'info');
- return;
- }
-
- completedDownloads.forEach(download => {
- const process = activeDownloadProcesses[download.virtualPlaylistId];
- if (process && process.modalElement) {
- closeDownloadMissingModal(download.virtualPlaylistId);
- } else {
- cleanupBeatportDownload(download.virtualPlaylistId);
- }
- });
-
- showToast(`Completed ${completedDownloads.length} downloads for ${bubbleData.chart.name}`, 'success');
-}
-
-/**
- * Clean up a Beatport download when its modal is closed
- */
-function cleanupBeatportDownload(virtualPlaylistId) {
- console.log(`🔍 [CLEANUP] Looking for Beatport download to cleanup: ${virtualPlaylistId}`);
-
- for (const chartKey in beatportDownloadBubbles) {
- const downloads = beatportDownloadBubbles[chartKey].downloads;
- const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId);
-
- if (downloadIndex !== -1) {
- downloads.splice(downloadIndex, 1);
- console.log(`🧹 [CLEANUP] Removed Beatport download from ${chartKey}. Remaining: ${downloads.length}`);
-
- if (downloads.length === 0) {
- delete beatportDownloadBubbles[chartKey];
- console.log(`🧹 [CLEANUP] No more downloads - removed Beatport bubble: ${chartKey}`);
- }
-
- updateBeatportDownloadsSection();
- saveBeatportBubbleSnapshot();
- return;
- }
- }
-}
-
-// --- Beatport Bubble Snapshot System ---
-
-let beatportSnapshotSaveTimeout = null;
-
-async function saveBeatportBubbleSnapshot() {
- if (beatportSnapshotSaveTimeout) {
- clearTimeout(beatportSnapshotSaveTimeout);
- }
-
- beatportSnapshotSaveTimeout = setTimeout(async () => {
- try {
- const bubbleCount = Object.keys(beatportDownloadBubbles).length;
- if (bubbleCount === 0) return;
-
- const cleanBubbles = {};
- for (const [chartKey, bubbleData] of Object.entries(beatportDownloadBubbles)) {
- cleanBubbles[chartKey] = {
- chart: bubbleData.chart,
- downloads: bubbleData.downloads.map(d => ({
- virtualPlaylistId: d.virtualPlaylistId,
- status: d.status,
- startTime: d.startTime instanceof Date ? d.startTime.toISOString() : d.startTime
- }))
- };
- }
-
- const response = await fetch('/api/beatport_bubbles/snapshot', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ bubbles: cleanBubbles })
- });
-
- const data = await response.json();
- if (data.success) {
- console.log(`✅ Beatport bubble snapshot saved: ${bubbleCount} charts`);
- }
- } catch (error) {
- console.error('❌ Error saving Beatport bubble snapshot:', error);
- }
- }, 1000);
-}
-
-async function hydrateBeatportBubblesFromSnapshot() {
- try {
- console.log('🔄 Loading Beatport bubble snapshot from backend...');
-
- const signal = getBeatportContentSignal();
- const response = await fetch('/api/beatport_bubbles/hydrate', signal ? { signal } : undefined);
- 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) {
- if (error && error.name === 'AbortError') {
- console.log('⏹ Beatport bubble hydration aborted');
- return;
- }
- console.error('❌ Error hydrating Beatport bubbles:', error);
- }
-}
-
-/**
- * Clean up artist download when a modal is closed
- */
-function cleanupArtistDownload(virtualPlaylistId) {
- console.log(`🔍 [CLEANUP] Looking for download to cleanup: ${virtualPlaylistId}`);
- console.log(`🔍 [CLEANUP] Current artist bubbles:`, Object.keys(artistDownloadBubbles));
-
- // Find which artist this download belongs to
- for (const artistId in artistDownloadBubbles) {
- const downloads = artistDownloadBubbles[artistId].downloads;
- const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId);
-
- console.log(`🔍 [CLEANUP] Checking artist ${artistId}: ${downloads.length} downloads`);
- downloads.forEach(d => console.log(` - ${d.album.name} (${d.virtualPlaylistId}): ${d.status}`));
-
- if (downloadIndex !== -1) {
- const downloadToRemove = downloads[downloadIndex];
- console.log(`🧹 [CLEANUP] Found download to cleanup: ${downloadToRemove.album.name} (status: ${downloadToRemove.status})`);
-
- // Remove this download from the artist's downloads
- downloads.splice(downloadIndex, 1);
- console.log(`✅ [CLEANUP] Removed download from artist ${artistId}. Remaining: ${downloads.length}`);
-
- // If no more downloads for this artist, remove the bubble
- if (downloads.length === 0) {
- delete artistDownloadBubbles[artistId];
- console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistId}`);
- } else {
- console.log(`📊 [CLEANUP] Artist ${artistId} still has ${downloads.length} downloads remaining`);
- }
-
- // Update the downloads section
- console.log(`🔄 [CLEANUP] Updating artist downloads section...`);
- updateArtistDownloadsSection();
-
- // Save snapshot of updated state
- saveArtistBubbleSnapshot();
- break;
- }
- }
- console.log(`✅ [CLEANUP] Cleanup process completed for ${virtualPlaylistId}`);
-}
-
-/**
- * Force refresh all artist download statuses (useful for debugging)
- */
-function refreshAllArtistDownloadStatuses() {
- console.log('🔄 Force refreshing all artist download statuses...');
-
- for (const artistId in artistDownloadBubbles) {
- const artistData = artistDownloadBubbles[artistId];
- let hasChanges = false;
-
- artistData.downloads.forEach(download => {
- const process = activeDownloadProcesses[download.virtualPlaylistId];
- if (process) {
- const expectedStatus = process.status === 'complete' ? 'view_results' : 'in_progress';
- if (download.status !== expectedStatus) {
- console.log(`🔧 Fixing status for ${download.album.name}: ${download.status} → ${expectedStatus}`);
- download.status = expectedStatus;
- hasChanges = true;
- }
- }
- });
-
- if (hasChanges) {
- console.log(`✅ Updated statuses for ${artistData.artist.name}`);
- }
- }
-
- // Force update the downloads section
- showArtistDownloadsSection();
-}
-
-/**
- * Extract dominant colors from an image for dynamic glow effects
- */
-async function extractImageColors(imageUrl, callback) {
- if (!imageUrl) {
- callback(getAccentFallbackColors()); // Fallback to Spotify green
- return;
- }
-
- // Check cache first for performance
- if (artistsPageState.cache.colors[imageUrl]) {
- callback(artistsPageState.cache.colors[imageUrl]);
- return;
- }
-
- try {
- // Create a canvas to analyze the image
- const canvas = document.createElement('canvas');
- const ctx = canvas.getContext('2d');
- const img = new Image();
-
- img.crossOrigin = 'anonymous';
-
- img.onload = function () {
- // Resize to small dimensions for faster processing
- const size = 50;
- canvas.width = size;
- canvas.height = size;
-
- // Draw image to canvas
- ctx.drawImage(img, 0, 0, size, size);
-
- try {
- // Get image data
- const imageData = ctx.getImageData(0, 0, size, size);
- const data = imageData.data;
-
- // Extract colors (sample every few pixels for performance)
- const colors = [];
- for (let i = 0; i < data.length; i += 16) { // Sample every 4th pixel
- const r = data[i];
- const g = data[i + 1];
- const b = data[i + 2];
- const alpha = data[i + 3];
-
- // Skip transparent or very dark pixels
- if (alpha > 128 && (r + g + b) > 150) {
- colors.push({ r, g, b });
- }
- }
-
- if (colors.length === 0) {
- callback(getAccentFallbackColors()); // Fallback
- return;
- }
-
- // Find dominant colors using a simple clustering approach
- const dominantColors = findDominantColors(colors, 2);
-
- // Convert to CSS hex colors
- const hexColors = dominantColors.map(color =>
- `#${((1 << 24) + (color.r << 16) + (color.g << 8) + color.b).toString(16).slice(1)}`
- );
-
- // Cache the colors for future use
- artistsPageState.cache.colors[imageUrl] = hexColors;
-
- callback(hexColors);
-
- } catch (e) {
- console.warn('Color extraction failed, using fallback colors:', e);
- callback(getAccentFallbackColors());
- }
- };
-
- img.onerror = function () {
- callback(getAccentFallbackColors()); // Fallback on error
- };
-
- img.src = imageUrl;
-
- } catch (error) {
- console.warn('Image color extraction error:', error);
- callback(getAccentFallbackColors());
- }
-}
-
-/**
- * Simple color clustering to find dominant colors
- */
-function findDominantColors(colors, numColors = 2) {
- if (colors.length === 0) return [{ r: 29, g: 185, b: 84 }];
-
- // Simple k-means clustering
- let centroids = [];
-
- // Initialize centroids randomly
- for (let i = 0; i < numColors; i++) {
- centroids.push(colors[Math.floor(Math.random() * colors.length)]);
- }
-
- // Run a few iterations of k-means
- for (let iteration = 0; iteration < 5; iteration++) {
- const clusters = Array(numColors).fill().map(() => []);
-
- // Assign each color to nearest centroid
- colors.forEach(color => {
- let minDistance = Infinity;
- let nearestCluster = 0;
-
- centroids.forEach((centroid, i) => {
- const distance = Math.sqrt(
- Math.pow(color.r - centroid.r, 2) +
- Math.pow(color.g - centroid.g, 2) +
- Math.pow(color.b - centroid.b, 2)
- );
-
- if (distance < minDistance) {
- minDistance = distance;
- nearestCluster = i;
- }
- });
-
- clusters[nearestCluster].push(color);
- });
-
- // Update centroids
- centroids = clusters.map(cluster => {
- if (cluster.length === 0) return centroids[0]; // Fallback
-
- const avgR = cluster.reduce((sum, c) => sum + c.r, 0) / cluster.length;
- const avgG = cluster.reduce((sum, c) => sum + c.g, 0) / cluster.length;
- const avgB = cluster.reduce((sum, c) => sum + c.b, 0) / cluster.length;
-
- return { r: Math.round(avgR), g: Math.round(avgG), b: Math.round(avgB) };
- });
- }
-
- // Ensure we have vibrant colors by boosting saturation
- return centroids.map(color => {
- const max = Math.max(color.r, color.g, color.b);
- const min = Math.min(color.r, color.g, color.b);
- const saturation = max === 0 ? 0 : (max - min) / max;
-
- // Boost low saturation colors
- if (saturation < 0.4) {
- const factor = 1.3;
- return {
- r: Math.min(255, Math.round(color.r * factor)),
- g: Math.min(255, Math.round(color.g * factor)),
- b: Math.min(255, Math.round(color.b * factor))
- };
- }
-
- return color;
- });
-}
-
-/**
- * Apply dynamic glow effect to a card element
- */
-function applyDynamicGlow(cardElement, colors) {
- if (!cardElement || colors.length < 2) return;
-
- const color1 = colors[0];
- const color2 = colors[1];
-
- // Add a small delay to make the effect feel more natural
- setTimeout(() => {
- // Create CSS custom properties for the dynamic colors
- cardElement.style.setProperty('--glow-color-1', color1);
- cardElement.style.setProperty('--glow-color-2', color2);
- cardElement.classList.add('has-dynamic-glow');
-
- console.log(`🎨 Applied dynamic glow: ${color1}, ${color2}`);
- }, Math.random() * 200 + 100); // Random delay between 100-300ms
-}
-
-/**
- * Utility function to escape HTML
- */
-function escapeHtml(text) {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
-}
-
-// --- Service Status and System Stats Functions ---
-
-async function _forceServiceStatusRefresh() {
- // Force an immediate status refresh (bypasses WebSocket check) — used after settings save
- try {
- const response = await fetch('/status');
- if (!response.ok) return;
- const data = await response.json();
- handleServiceStatusUpdate(data);
- } catch (error) {
- console.warn('Could not force service status refresh:', error);
- }
-}
-
-async function fetchAndUpdateServiceStatus() {
- if (document.hidden) return; // Skip polling when tab is not visible
- if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll
- try {
- const response = await fetch('/status');
- if (!response.ok) return;
-
- const data = await response.json();
-
- // Cache for library status card
- _lastServiceStatus = data;
-
- // Update service status indicators and text (dashboard)
- updateServiceStatus('spotify', data.spotify);
- updateServiceStatus('media-server', data.media_server);
- updateServiceStatus('soulseek', data.soulseek);
-
- // Update sidebar service status indicators
- updateSidebarServiceStatus('spotify', data.spotify);
- updateSidebarServiceStatus('media-server', data.media_server);
- updateSidebarServiceStatus('soulseek', data.soulseek);
-
- // Update downloads nav badge
- if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads);
-
- // Hide sync buttons (not the page) for standalone mode
- const isSoulsyncStandalone2 = data.media_server?.type === 'soulsync';
- _isSoulsyncStandalone = isSoulsyncStandalone2;
- document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => {
- if (isSoulsyncStandalone2) {
- btn.dataset.hiddenByStandalone = '1';
- btn.style.display = 'none';
- } else if (btn.dataset.hiddenByStandalone) {
- delete btn.dataset.hiddenByStandalone;
- btn.style.display = '';
- }
- });
-
- // Update enrichment service cards
- if (data.enrichment) renderEnrichmentCards(data.enrichment);
-
- // Check for Spotify rate limit
- if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) {
- handleSpotifyRateLimit(data.spotify.rate_limit);
- } else if (_spotifyRateLimitShown) {
- handleSpotifyRateLimit(null);
- }
-
- } catch (error) {
- console.warn('Could not fetch service status:', error);
- }
-}
-
-function updateServiceStatus(service, statusData) {
- const indicator = document.getElementById(`${service}-status-indicator`);
- const statusText = document.getElementById(`${service}-status-text`);
-
- if (indicator && statusText) {
- if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
- indicator.className = 'service-card-indicator rate-limited';
- const remaining = statusData.rate_limited
- ? formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)
- : formatRateLimitDuration(statusData.post_ban_cooldown);
- const phase = statusData.rate_limited ? 'paused' : 'recovering';
- const fallbackLabel = statusData.source === 'deezer' ? 'Deezer' : 'iTunes';
- statusText.textContent = `${fallbackLabel} (Spotify ${phase} \u2014 ${remaining})`;
- statusText.className = 'service-card-status-text rate-limited';
- } else if (statusData.connected) {
- indicator.className = 'service-card-indicator connected';
- statusText.textContent = `Connected (${statusData.response_time}ms)`;
- statusText.className = 'service-card-status-text connected';
- } else {
- indicator.className = 'service-card-indicator disconnected';
- statusText.textContent = 'Disconnected';
- statusText.className = 'service-card-status-text disconnected';
- }
- }
-
- // Update music source title based on active source
- if (service === 'spotify' && statusData.source) {
- const musicSourceTitleElement = document.getElementById('music-source-title');
- if (musicSourceTitleElement) {
- const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes';
- musicSourceTitleElement.textContent = sourceName;
- currentMusicSourceName = sourceName;
- }
-
- // Show/hide Spotify disconnect button based on connection state
- const disconnectBtn = document.getElementById('spotify-disconnect-btn');
- if (disconnectBtn) {
- disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none';
- }
- }
-
- // Update download source title on dashboard card
- if (service === 'soulseek' && statusData.source) {
- const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' };
- const displayName = sourceNames[statusData.source] || 'Soulseek';
- const titleEl = document.getElementById('download-source-title');
- if (titleEl) titleEl.textContent = displayName;
- }
-}
-
-function updateSidebarServiceStatus(service, statusData) {
- const indicator = document.getElementById(`${service}-indicator`);
- if (indicator) {
- const dot = indicator.querySelector('.status-dot');
- const nameElement = indicator.querySelector('.status-name');
-
- if (dot) {
- if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
- dot.className = 'status-dot rate-limited';
- dot.title = statusData.rate_limited
- ? `Spotify paused \u2014 ${formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)} remaining`
- : `Spotify recovering \u2014 ${formatRateLimitDuration(statusData.post_ban_cooldown)} cooldown`;
- } else if (statusData.connected) {
- dot.className = 'status-dot connected';
- dot.title = '';
- } else {
- dot.className = 'status-dot disconnected';
- dot.title = '';
- }
- }
-
- // Update media server name if it's the media server indicator
- if (service === 'media-server' && statusData.type) {
- const mediaServerNameElement = document.getElementById('media-server-name');
- if (mediaServerNameElement) {
- const serverName = statusData.type.charAt(0).toUpperCase() + statusData.type.slice(1);
- mediaServerNameElement.textContent = serverName;
- }
- }
-
- // Update music source name in sidebar based on active source
- if (service === 'spotify' && statusData.source) {
- const musicSourceNameElement = document.getElementById('music-source-name');
- if (musicSourceNameElement) {
- const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes';
- musicSourceNameElement.textContent = sourceName;
- }
- }
-
- // Update download source name based on configured mode
- if (service === 'soulseek' && statusData.source) {
- const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' };
- const displayName = sourceNames[statusData.source] || 'Soulseek';
- const sidebarName = document.getElementById('download-source-name');
- if (sidebarName) sidebarName.textContent = displayName;
- }
- }
-}
-
-function renderEnrichmentCards(enrichment) {
- const grid = document.getElementById('enrichment-status-grid');
- if (!grid || !enrichment) return;
-
- // Service display order
- const serviceOrder = [
- 'musicbrainz', 'spotify_enrichment', 'itunes_enrichment', 'deezer_enrichment',
- 'tidal_enrichment', 'qobuz_enrichment', 'lastfm', 'genius', 'audiodb',
- 'acoustid', 'listenbrainz'
- ];
-
- // Map service keys to their settings page selector for click-to-configure
- const settingsSelectors = {
- 'spotify_enrichment': '.spotify-title',
- 'tidal_enrichment': '.tidal-title',
- 'qobuz_enrichment': '.qobuz-title',
- 'lastfm': '.lastfm-title',
- 'genius': '.genius-title',
- 'acoustid': '.acoustid-title',
- 'listenbrainz': '.listenbrainz-title',
- };
-
- const chips = [];
- for (const key of serviceOrder) {
- const svc = enrichment[key];
- if (!svc) continue;
-
- // Determine status class and text
- let statusClass, statusLabel;
- if ('running' in svc) {
- if (!svc.configured) {
- statusClass = 'not-configured';
- statusLabel = 'Set up';
- } else if (svc.paused) {
- statusClass = 'paused';
- statusLabel = svc.yield_reason === 'downloads' ? 'Yielding' : 'Paused';
- } else if (svc.running) {
- statusClass = svc.idle ? 'idle' : 'running';
- statusLabel = svc.idle ? 'Idle' : 'Running';
- } else {
- statusClass = 'stopped';
- statusLabel = 'Stopped';
- }
- } else {
- statusClass = svc.configured ? 'running' : 'not-configured';
- statusLabel = svc.configured ? 'Ready' : 'Set up';
- }
-
- const selector = settingsSelectors[key];
- const clickAttr = selector
- ? `onclick="navigateToPage('settings'); setTimeout(() => { switchSettingsTab('connections'); setTimeout(() => { const el = document.querySelector('${selector}'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); }, 50);"`
- : '';
-
- // Build activity display — human-readable, not cryptic numbers
- let activityHtml = '';
- let metaHtml = '';
- const isSpotify = key === 'spotify_enrichment';
-
- if ('running' in svc && svc.configured) {
- const c1h = svc.calls_1h || 0;
- const c24h = svc.calls_24h || 0;
-
- if (isSpotify && svc.daily_budget) {
- // Spotify: show budget usage prominently
- const b = svc.daily_budget;
- const pct = Math.min(100, Math.round((b.used / b.limit) * 100));
- const barClass = b.exhausted ? 'exhausted' : pct > 80 ? 'high' : '';
- activityHtml = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()}`;
- metaHtml = ``;
- } else if (c24h > 0) {
- // Other services: show 24h count
- activityHtml = `${c24h.toLocaleString()} / 24h`;
- }
- }
-
- // Tooltip: full details including 1h breakdown
- let tooltipLines = [svc.name + ' — ' + statusLabel];
- if ('running' in svc && svc.configured) {
- const c1h = svc.calls_1h || 0;
- const c24h = svc.calls_24h || 0;
- if (c24h > 0 || c1h > 0) tooltipLines.push('Last hour: ' + c1h + ' · Last 24h: ' + c24h);
- }
- if (isSpotify && svc.daily_budget) {
- const b = svc.daily_budget;
- tooltipLines.push('Daily budget: ' + b.used + ' / ' + b.limit + (b.exhausted ? ' (exhausted)' : ''));
- }
- if (selector && statusClass === 'not-configured') {
- tooltipLines = ['Click to configure in Settings'];
- }
-
- const statusDisplay = statusClass === 'not-configured' && selector ? 'Configure →' : statusLabel;
-
- chips.push(`
-
-
- ${svc.name}
- ${activityHtml}
- ${statusDisplay}
- ${metaHtml}
-
- `);
- }
-
- grid.innerHTML = chips.join('');
-}
-
-// ===============================
diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js
new file mode 100644
index 00000000..d45737f5
--- /dev/null
+++ b/webui/static/shared-helpers.js
@@ -0,0 +1,2761 @@
+// SHARED HELPERS
+// ============================================================================
+// General-purpose helpers extracted from artists.js. These functions are used
+// across discover.js, api-monitor.js, library.js, enrichment.js, wishlist-
+// tools.js and others — they have no conceptual home in the old Artists page
+// file. Moved here so artists.js can be deleted once the inline Artists page
+// is fully retired.
+//
+// Load order: this file must load AFTER core.js (uses artistsPageState,
+// artistDownloadBubbles, searchDownloadBubbles, beatportDownloadBubbles
+// globals declared there) and BEFORE any file that calls these functions.
+// ============================================================================
+
+
+// ----------------------------------------------------------------------------
+// Discography completion checking (for artist-detail pages, library page)
+// ----------------------------------------------------------------------------
+
+async function checkDiscographyCompletion(artistId, discography) {
+ console.log(`🔍 Starting streaming completion check for artist: ${artistId}`);
+
+ try {
+ // Create new abort controller for this completion check
+ artistCompletionController = new AbortController();
+
+ // Use fetch with streaming response
+ const response = await fetch(`/api/artist/${artistId}/completion-stream`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ discography: discography,
+ artist_name: artistsPageState.selectedArtist?.name || 'Unknown Artist',
+ source: discography?.source || artistsPageState.sourceOverride || null,
+ }),
+ signal: artistCompletionController.signal
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to start completion check: ${response.status}`);
+ }
+
+ // Handle streaming response
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ const chunk = decoder.decode(value);
+ const lines = chunk.split('\n');
+
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ try {
+ const data = JSON.parse(line.slice(6));
+ handleStreamingCompletionUpdate(data);
+ } catch (e) {
+ console.warn('Failed to parse streaming data:', line);
+ }
+ }
+ }
+ }
+
+ // Clear the controller when done
+ artistCompletionController = null;
+
+ } catch (error) {
+ // Don't show error if it was aborted (user navigated away)
+ if (error.name === 'AbortError') {
+ console.log('⏹️ Completion check aborted (user navigated to new artist)');
+ return;
+ }
+
+ console.error('❌ Failed to check completion status:', error);
+ showCompletionError();
+ } finally {
+ // Always clear the controller
+ artistCompletionController = null;
+ }
+}
+
+/**
+ * Handle individual streaming completion updates
+ */
+function handleStreamingCompletionUpdate(data) {
+ console.log('🔄 Streaming update received:', data.type, data.name || data.artist_name);
+
+ switch (data.type) {
+ case 'start':
+ console.log(`🎤 Starting completion check for ${data.artist_name} (${data.total_items} items)`);
+ // Initialize cache for this artist if not exists
+ const artistId = artistsPageState.selectedArtist?.id;
+ if (artistId && !artistsPageState.cache.completionData[artistId]) {
+ artistsPageState.cache.completionData[artistId] = {
+ albums: [],
+ singles: []
+ };
+ }
+ break;
+
+ case 'album_completion':
+ updateAlbumCompletionOverlay(data, 'albums');
+ // Cache the completion data
+ cacheCompletionData(data, 'albums');
+ console.log(`📀 Updated album: ${data.name} (${data.status})`);
+ break;
+
+ case 'single_completion':
+ updateAlbumCompletionOverlay(data, 'singles');
+ // Cache the completion data
+ cacheCompletionData(data, 'singles');
+ console.log(`🎵 Updated single: ${data.name} (${data.status})`);
+ break;
+
+ case 'error':
+ console.error('❌ Error processing item:', data.name, data.error);
+ // Could show error for specific item
+ break;
+
+ case 'complete':
+ console.log(`✅ Completion check finished (${data.processed_count} items processed)`);
+ break;
+
+ default:
+ console.log('Unknown streaming update type:', data.type);
+ }
+}
+
+/**
+ * Cache completion data for future restoration
+ */
+function cacheCompletionData(completionData, type) {
+ const artistId = artistsPageState.selectedArtist?.id;
+ if (!artistId) return;
+
+ // Ensure cache structure exists
+ if (!artistsPageState.cache.completionData[artistId]) {
+ artistsPageState.cache.completionData[artistId] = {
+ albums: [],
+ singles: []
+ };
+ }
+
+ // Add to appropriate cache array
+ if (type === 'albums') {
+ artistsPageState.cache.completionData[artistId].albums.push(completionData);
+ } else if (type === 'singles') {
+ artistsPageState.cache.completionData[artistId].singles.push(completionData);
+ }
+}
+
+/**
+ * Update completion overlay for a specific album/single
+ */
+function updateAlbumCompletionOverlay(completionData, containerType) {
+ const containerId = containerType === 'albums' ? 'album-cards-container' : 'singles-cards-container';
+ const container = document.getElementById(containerId);
+
+ if (!container) {
+ console.warn(`Container ${containerId} not found`);
+ return;
+ }
+
+ // Find the album card by data-album-id
+ const albumCard = container.querySelector(`[data-album-id="${completionData.id}"]`);
+
+ if (!albumCard) {
+ console.warn(`Album card not found for ID: ${completionData.id}`);
+ return;
+ }
+
+ // Reclassify and move cards when track count reveals single/EP (Discogs lazy fetch)
+ const currentType = albumCard.dataset.albumType;
+ const expectedTracks = completionData.expected_tracks || 0;
+ if (expectedTracks > 0) {
+ albumCard.dataset.totalTracks = expectedTracks;
+ let newType = currentType;
+ if (currentType === 'album' && expectedTracks <= 3) newType = 'single';
+ else if (currentType === 'album' && expectedTracks <= 6) newType = 'ep';
+
+ if (newType !== currentType) {
+ albumCard.dataset.albumType = newType;
+ const typeEl = albumCard.querySelector('.album-card-type');
+ if (typeEl) typeEl.textContent = newType === 'single' ? 'Single' : 'EP';
+
+ // Move card from albums grid to singles grid
+ const singlesGrid = document.getElementById('singles-grid');
+ const singlesSection = singlesGrid?.closest('.discography-section');
+ if (singlesGrid) {
+ albumCard.remove();
+ singlesGrid.appendChild(albumCard);
+ if (singlesSection) singlesSection.style.display = '';
+ }
+ }
+ }
+
+ const overlay = albumCard.querySelector('.completion-overlay');
+ if (!overlay) {
+ console.warn(`Completion overlay not found for album: ${completionData.name}`);
+ return;
+ }
+
+ // Remove existing status classes
+ overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error');
+
+ // Add new status class
+ overlay.classList.add(completionData.status);
+
+ // Update overlay text and content
+ const statusText = getCompletionStatusText(completionData);
+ const progressText = completionData.expected_tracks > 0
+ ? `${completionData.owned_tracks}/${completionData.expected_tracks}`
+ : '';
+
+ overlay.innerHTML = progressText
+ ? `${statusText}${progressText}`
+ : `${statusText}`;
+
+ // Add tooltip with more details
+ overlay.title = `${completionData.name}\n${statusText} (${completionData.completion_percentage}%)\nTracks: ${completionData.owned_tracks}/${completionData.expected_tracks}\nConfidence: ${completionData.confidence}`;
+
+ // Add brief flash animation to indicate update
+ overlay.style.animation = 'none';
+ overlay.offsetHeight; // Trigger reflow
+ overlay.style.animation = 'completionOverlayFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1)';
+
+ console.log(`📊 Updated overlay for "${completionData.name}": ${statusText} (${completionData.completion_percentage}%)`);
+}
+
+/**
+ * Get human-readable status text for completion overlay
+ */
+function getCompletionStatusText(completionData) {
+ switch (completionData.status) {
+ case 'completed':
+ return 'Complete';
+ case 'nearly_complete':
+ return 'Nearly Complete';
+ case 'partial':
+ return 'Partial';
+ case 'missing':
+ return 'Missing';
+ case 'downloading':
+ return 'Downloading...';
+ case 'downloaded':
+ return 'Downloaded';
+ case 'error':
+ return 'Error';
+ default:
+ return 'Unknown';
+ }
+}
+
+/**
+ * Set album to downloaded status after download finishes
+ */
+function setAlbumDownloadedStatus(albumId) {
+ console.log(`✅ [DOWNLOAD COMPLETE] Setting album ${albumId} to downloaded status`);
+
+ const completionData = {
+ id: albumId,
+ status: 'downloaded',
+ owned_tracks: 0,
+ expected_tracks: 0,
+ name: 'Downloaded',
+ completion_percentage: 100
+ };
+
+ // Find if it's in albums or singles container
+ let containerType = 'albums';
+ let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`);
+ if (!albumCard) {
+ containerType = 'singles';
+ albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`);
+ }
+
+ if (albumCard) {
+ updateAlbumCompletionOverlay(completionData, containerType);
+ console.log(`✅ [DOWNLOAD COMPLETE] Album ${albumId} set to Downloaded status`);
+ } else {
+ console.warn(`❌ [DOWNLOAD COMPLETE] Album card not found for ID: "${albumId}"`);
+ }
+}
+
+/**
+ * Set album to downloading status
+ */
+function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) {
+ console.log(`🔍 [DOWNLOAD STATUS] Searching for album card with ID: "${albumId}"`);
+
+ const completionData = {
+ id: albumId,
+ status: 'downloading',
+ owned_tracks: downloaded,
+ expected_tracks: total,
+ name: 'Downloading',
+ completion_percentage: Math.round((downloaded / total) * 100) || 0
+ };
+
+ // Find if it's in albums or singles container
+ let containerType = 'albums';
+ let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`);
+ if (!albumCard) {
+ containerType = 'singles';
+ albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`);
+ }
+
+ if (albumCard) {
+ console.log(`✅ [DOWNLOAD STATUS] Found album card in ${containerType} container, updating overlay`);
+ updateAlbumCompletionOverlay(completionData, containerType);
+ } else {
+ console.warn(`❌ [DOWNLOAD STATUS] Album card not found for ID: "${albumId}"`);
+ // Debug: List all available album cards
+ const allAlbums = document.querySelectorAll('#album-cards-container [data-album-id], #singles-cards-container [data-album-id]');
+ console.log(`🔍 [DEBUG] Available album IDs:`, Array.from(allAlbums).map(card => card.dataset.albumId));
+ }
+}
+
+
+// ----------------------------------------------------------------------------
+// Download bubble infrastructure, image colour/glow helpers, HTML escape,
+// service-status polling, enrichment-card rendering. All originally defined
+// in artists.js but used broadly across the app.
+// ----------------------------------------------------------------------------
+
+async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist, showLoadingOverlayParam = true, contextType = 'artist_album') {
+ if (showLoadingOverlayParam) {
+ showLoadingOverlay('Loading album...');
+ }
+ // Check if a process is already active for this virtual playlist
+ if (activeDownloadProcesses[virtualPlaylistId]) {
+ console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`);
+ const process = activeDownloadProcesses[virtualPlaylistId];
+ if (process.modalElement) {
+ if (process.status === 'complete') {
+ showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
+ }
+ process.modalElement.style.display = 'flex';
+ if (showLoadingOverlayParam) {
+ hideLoadingOverlay();
+ }
+ }
+ return;
+ }
+
+ console.log(`📥 Opening Download Missing Tracks modal for artist album: ${virtualPlaylistId}`);
+
+ // Create virtual playlist object for compatibility with existing modal logic
+ const virtualPlaylist = {
+ id: virtualPlaylistId,
+ name: playlistName,
+ track_count: spotifyTracks.length
+ };
+
+ // Store the tracks in the cache for the modal to use
+ playlistTrackCache[virtualPlaylistId] = spotifyTracks;
+ currentPlaylistTracks = spotifyTracks;
+ currentModalPlaylistId = virtualPlaylistId;
+
+ let modal = document.createElement('div');
+ modal.id = `download-missing-modal-${virtualPlaylistId}`;
+ modal.className = 'download-missing-modal';
+ modal.style.display = 'none';
+ document.body.appendChild(modal);
+
+ // Register the new process in our global state tracker using the same structure as other modals
+ activeDownloadProcesses[virtualPlaylistId] = {
+ status: 'idle',
+ modalElement: modal,
+ poller: null,
+ batchId: null,
+ playlist: virtualPlaylist,
+ tracks: spotifyTracks,
+ // Additional metadata for artist albums
+ artist: artist,
+ album: album,
+ albumType: album.album_type,
+ source: artist?.source || album?.source || artistsPageState.artistDiscography?.source || null
+ };
+
+ // Generate hero section — 'artist_album' for releases, 'playlist' for charts/compilations
+ const heroContext = contextType === 'playlist' ? {
+ type: 'playlist',
+ playlist: { name: playlistName, owner: 'Beatport' },
+ trackCount: spotifyTracks.length,
+ playlistId: virtualPlaylistId
+ } : {
+ type: 'artist_album',
+ artist: artist,
+ album: album,
+ trackCount: spotifyTracks.length,
+ playlistId: virtualPlaylistId
+ };
+
+ // Use the exact same modal HTML structure as the existing modals
+ modal.innerHTML = `
+
+
+
+
+
+
+
+ 🔍 Library Analysis
+ Ready to start
+
+
+
+
+
+ ⏬ Downloads
+ Waiting for analysis
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Starting sync...
+
+ 0 matched
+ 0 failed
+
+
+
+
+
+ `;
+
+ applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length);
+ modal.style.display = 'flex';
+ hideLoadingOverlay();
+
+ console.log(`✅ Successfully opened download missing tracks modal for: ${playlistName}`);
+}
+
+// ===============================
+// ARTIST DOWNLOADS MANAGEMENT SYSTEM
+// ===============================
+
+/**
+ * Register a new artist download for bubble management
+ */
+function registerArtistDownload(artist, album, virtualPlaylistId, albumType) {
+ console.log(`📝 Registering artist download: ${artist.name} - ${album.name}`);
+
+ const artistId = artist.id;
+
+ // Initialize artist bubble if it doesn't exist
+ if (!artistDownloadBubbles[artistId]) {
+ artistDownloadBubbles[artistId] = {
+ artist: artist,
+ downloads: [],
+ element: null,
+ hasCompletedDownloads: false
+ };
+ }
+
+ // Add this download to the artist's downloads
+ const downloadInfo = {
+ virtualPlaylistId: virtualPlaylistId,
+ album: album,
+ albumType: albumType,
+ status: 'in_progress', // 'in_progress', 'completed', 'view_results'
+ startTime: new Date()
+ };
+
+ artistDownloadBubbles[artistId].downloads.push(downloadInfo);
+
+ // Show/update the artist downloads section
+ updateArtistDownloadsSection();
+
+ // Save snapshot of current state
+ saveArtistBubbleSnapshot();
+
+ // Monitor this download for completion
+ monitorArtistDownload(artistId, virtualPlaylistId);
+}
+
+/**
+ * Debounced update for artist downloads section to prevent rapid updates
+ */
+function updateArtistDownloadsSection() {
+ if (downloadsUpdateTimeout) {
+ clearTimeout(downloadsUpdateTimeout);
+ }
+ downloadsUpdateTimeout = setTimeout(() => {
+ showArtistDownloadsSection();
+ showLibraryDownloadsSection();
+ showBeatportDownloadsSection();
+ updateDashboardDownloads();
+ }, 300); // 300ms debounce
+}
+
+// --- Artist Bubble Snapshot System ---
+
+let snapshotSaveTimeout = null; // Debounce snapshot saves
+
+async function saveArtistBubbleSnapshot() {
+ /**
+ * Saves current artistDownloadBubbles state to backend for persistence.
+ * Debounced to prevent excessive backend calls.
+ */
+
+ // Clear any existing timeout
+ if (snapshotSaveTimeout) {
+ clearTimeout(snapshotSaveTimeout);
+ }
+
+ // Debounce the actual save
+ snapshotSaveTimeout = setTimeout(async () => {
+ try {
+ const bubbleCount = Object.keys(artistDownloadBubbles).length;
+
+ // Don't save empty state
+ if (bubbleCount === 0) {
+ console.log('📸 Skipping snapshot save - no artist bubbles to save');
+ return;
+ }
+
+ console.log(`📸 Saving artist bubble snapshot: ${bubbleCount} artists`);
+
+ // Prepare snapshot data (clean up DOM references)
+ const cleanBubbles = {};
+ for (const [artistId, bubbleData] of Object.entries(artistDownloadBubbles)) {
+ cleanBubbles[artistId] = {
+ artist: bubbleData.artist,
+ downloads: bubbleData.downloads.map(download => ({
+ virtualPlaylistId: download.virtualPlaylistId,
+ album: download.album,
+ albumType: download.albumType,
+ status: download.status,
+ startTime: download.startTime instanceof Date ? download.startTime.toISOString() : download.startTime
+ })),
+ hasCompletedDownloads: bubbleData.hasCompletedDownloads
+ };
+ }
+
+ const response = await fetch('/api/artist_bubbles/snapshot', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ bubbles: cleanBubbles
+ })
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ console.log(`✅ Artist bubble snapshot saved: ${bubbleCount} artists`);
+ } else {
+ console.error('❌ Failed to save artist bubble snapshot:', data.error);
+ }
+
+ } catch (error) {
+ console.error('❌ Error saving artist bubble snapshot:', error);
+ }
+ }, 1000); // 1 second debounce
+}
+
+async function hydrateArtistBubblesFromSnapshot() {
+ /**
+ * Hydrates artist download bubbles from backend snapshot with live status.
+ * Called on page load to restore bubble state.
+ */
+ try {
+ console.log('🔄 Loading artist bubble snapshot from backend...');
+
+ const response = await fetch('/api/artist_bubbles/hydrate');
+ const data = await response.json();
+
+ if (!data.success) {
+ console.error('❌ Failed to load artist bubble snapshot:', data.error);
+ return;
+ }
+
+ const bubbles = data.bubbles || {};
+ const stats = data.stats || {};
+
+ console.log(`🔄 Loaded bubble snapshot: ${stats.total_artists || 0} artists, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`);
+
+ if (Object.keys(bubbles).length === 0) {
+ console.log('ℹ️ No artist bubbles to hydrate');
+ return;
+ }
+
+ // Clear existing state
+ artistDownloadBubbles = {};
+
+ // Restore artistDownloadBubbles with hydrated data
+ for (const [artistId, bubbleData] of Object.entries(bubbles)) {
+ artistDownloadBubbles[artistId] = {
+ artist: bubbleData.artist,
+ downloads: bubbleData.downloads.map(download => ({
+ virtualPlaylistId: download.virtualPlaylistId,
+ album: download.album,
+ albumType: download.albumType,
+ status: download.status, // Live status from backend
+ startTime: new Date(download.startTime)
+ })),
+ element: null, // Will be created when UI updates
+ hasCompletedDownloads: bubbleData.hasCompletedDownloads
+ };
+
+ console.log(`🔄 Hydrated artist: ${bubbleData.artist.name} (${bubbleData.downloads.length} downloads)`);
+
+ // Start monitoring for any in-progress downloads
+ for (const download of bubbleData.downloads) {
+ if (download.status === 'in_progress') {
+ console.log(`📡 Starting monitoring for: ${download.album.name}`);
+ monitorArtistDownload(artistId, download.virtualPlaylistId);
+ }
+ }
+ }
+
+ // Update UI to show hydrated bubbles
+ updateArtistDownloadsSection();
+
+ const totalArtists = Object.keys(artistDownloadBubbles).length;
+ console.log(`✅ Successfully hydrated ${totalArtists} artist download bubbles`);
+
+ } catch (error) {
+ console.error('❌ Error hydrating artist bubbles from snapshot:', error);
+ }
+}
+
+// --- Search Bubble Snapshot System ---
+
+async function saveSearchBubbleSnapshot() {
+ /**
+ * Saves current searchDownloadBubbles state to backend for persistence.
+ */
+ try {
+ // Rate limit saves to avoid spamming backend
+ if (saveSearchBubbleSnapshot.lastSaveTime) {
+ const timeSinceLastSave = Date.now() - saveSearchBubbleSnapshot.lastSaveTime;
+ if (timeSinceLastSave < 2000) {
+ console.log('⏱️ Skipping search bubble snapshot save (rate limited)');
+ return;
+ }
+ }
+
+ const bubbleCount = Object.keys(searchDownloadBubbles).length;
+
+ if (bubbleCount === 0) {
+ console.log('📸 Skipping snapshot save - no search bubbles to save');
+ return;
+ }
+
+ console.log(`📸 Saving search bubble snapshot: ${bubbleCount} artists`);
+
+ // Convert search bubbles to plain objects for serialization
+ const bubblesToSave = {};
+ for (const [artistName, bubbleData] of Object.entries(searchDownloadBubbles)) {
+ bubblesToSave[artistName] = {
+ artist: bubbleData.artist,
+ downloads: bubbleData.downloads.map(d => ({
+ virtualPlaylistId: d.virtualPlaylistId,
+ item: d.item,
+ type: d.type,
+ status: d.status,
+ startTime: d.startTime
+ }))
+ };
+ }
+
+ const response = await fetch('/api/search_bubbles/snapshot', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ bubbles: bubblesToSave })
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ console.log(`✅ Search bubble snapshot saved: ${bubbleCount} artists`);
+ saveSearchBubbleSnapshot.lastSaveTime = Date.now();
+ } else {
+ console.error('❌ Failed to save search bubble snapshot:', data.error);
+ }
+
+ } catch (error) {
+ console.error('❌ Error saving search bubble snapshot:', error);
+ }
+}
+
+async function hydrateSearchBubblesFromSnapshot() {
+ /**
+ * Hydrates search download bubbles from backend snapshot with live status.
+ */
+ try {
+ console.log('🔄 Loading search bubble snapshot from backend...');
+
+ const response = await fetch('/api/search_bubbles/hydrate');
+ const data = await response.json();
+
+ if (!data.success) {
+ console.error('❌ Failed to load search bubble snapshot:', data.error);
+ return;
+ }
+
+ const bubbles = data.bubbles || {};
+ const stats = data.stats || {};
+
+ if (Object.keys(bubbles).length === 0) {
+ console.log('ℹ️ No search bubbles to hydrate');
+ return;
+ }
+
+ // Clear and restore search bubbles
+ searchDownloadBubbles = {};
+
+ for (const [artistName, bubbleData] of Object.entries(bubbles)) {
+ searchDownloadBubbles[artistName] = {
+ artist: bubbleData.artist,
+ downloads: bubbleData.downloads || []
+ };
+
+ console.log(`🔄 Hydrated artist: ${artistName} (${bubbleData.downloads.length} downloads)`);
+
+ // Setup monitoring for each download
+ for (const download of bubbleData.downloads) {
+ if (download.status === 'in_progress') {
+ monitorSearchDownload(artistName, download.virtualPlaylistId);
+ }
+ }
+ }
+
+ const totalArtists = Object.keys(searchDownloadBubbles).length;
+ console.log(`✅ Successfully hydrated ${totalArtists} search download bubbles`);
+
+ // Refresh display
+ showSearchDownloadBubbles();
+
+ } catch (error) {
+ console.error('❌ Error hydrating search bubbles from snapshot:', error);
+ }
+}
+
+/**
+ * Register a new search download for bubble management (grouped by artist)
+ */
+function registerSearchDownload(item, type, virtualPlaylistId, artistName) {
+ console.log(`📝 [REGISTER] Registering search download: ${item.name} (${type}) by ${artistName}`);
+
+ // Initialize artist bubble if it doesn't exist
+ if (!searchDownloadBubbles[artistName]) {
+ searchDownloadBubbles[artistName] = {
+ artist: {
+ name: artistName,
+ image_url: item.image_url || (item.images && item.images[0]?.url) || null
+ },
+ downloads: []
+ };
+ }
+
+ // Add this download to the artist's downloads
+ const downloadInfo = {
+ virtualPlaylistId: virtualPlaylistId,
+ item: item,
+ type: type, // 'album' or 'track'
+ status: 'in_progress',
+ startTime: new Date().toISOString()
+ };
+
+ searchDownloadBubbles[artistName].downloads.push(downloadInfo);
+
+ console.log(`✅ [REGISTER] Registered search download for ${artistName} - ${item.name}`);
+
+ // Save snapshot
+ saveSearchBubbleSnapshot();
+
+ // Setup monitoring
+ monitorSearchDownload(artistName, virtualPlaylistId);
+
+ // Refresh display
+ updateSearchDownloadsSection();
+}
+
+/**
+ * Debounced update for search downloads section
+ */
+function updateSearchDownloadsSection() {
+ if (window.searchUpdateTimeout) {
+ clearTimeout(window.searchUpdateTimeout);
+ }
+ window.searchUpdateTimeout = setTimeout(() => {
+ showSearchDownloadBubbles();
+ updateDashboardDownloads();
+ }, 300);
+}
+
+/**
+ * Monitor a search download for completion status changes
+ */
+function monitorSearchDownload(artistName, virtualPlaylistId) {
+ const checkCompletion = setInterval(() => {
+ const process = activeDownloadProcesses[virtualPlaylistId];
+
+ if (!process || !searchDownloadBubbles[artistName]) {
+ clearInterval(checkCompletion);
+ return;
+ }
+
+ // Find the download in the artist's downloads
+ const download = searchDownloadBubbles[artistName].downloads.find(
+ d => d.virtualPlaylistId === virtualPlaylistId
+ );
+
+ if (!download) {
+ clearInterval(checkCompletion);
+ return;
+ }
+
+ // Update status
+ const newStatus = process.status === 'complete' || process.status === 'view_results'
+ ? 'view_results'
+ : 'in_progress';
+
+ if (download.status !== newStatus) {
+ console.log(`🔄 [MONITOR] Status changed for ${download.item.name}: ${download.status} -> ${newStatus}`);
+ download.status = newStatus;
+
+ // Save snapshot and refresh
+ saveSearchBubbleSnapshot();
+ updateSearchDownloadsSection();
+ }
+ }, 2000);
+}
+
+/**
+ * Show or update the search downloads bubble section
+ */
+function showSearchDownloadBubbles() {
+ console.log(`🔄 [SHOW] showSearchDownloadBubbles() called`);
+
+ const resultsArea = document.getElementById('enhanced-main-results-area');
+ if (!resultsArea) {
+ console.log(`⏭️ [SHOW] Skipping - no enhanced-main-results-area found`);
+ return;
+ }
+
+ // Count active artists (those with downloads)
+ const activeArtists = Object.keys(searchDownloadBubbles).filter(artistName =>
+ searchDownloadBubbles[artistName].downloads.length > 0
+ );
+
+ if (activeArtists.length === 0) {
+ // Show placeholder
+ resultsArea.innerHTML = `
+
+
Search results will appear here when you select an album or track.
+
+ `;
+ return;
+ }
+
+ // Create bubbles display
+ const bubblesHTML = activeArtists.map(artistName =>
+ createSearchBubbleCard(searchDownloadBubbles[artistName])
+ ).join('');
+
+ resultsArea.innerHTML = `
+
+ `;
+
+ console.log(`✅ [SHOW] Displayed ${activeArtists.length} search bubbles`);
+}
+
+/**
+ * Create HTML for a search bubble card (grouped by artist)
+ */
+function createSearchBubbleCard(artistBubbleData) {
+ const { artist, downloads } = artistBubbleData;
+ 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;
+
+ console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, {
+ totalDownloads: downloads.length,
+ activeCount,
+ completedCount,
+ allCompleted
+ });
+
+ const imageUrl = artist.image_url || '';
+ const backgroundStyle = imageUrl ?
+ `background-image: url('${escapeHtml(imageUrl)}');` :
+ `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
+
+ return `
+
+
+
+
+
${escapeHtml(artist.name)}
+
+ ${activeCount > 0 ? `${activeCount} active` : ''}
+ ${completedCount > 0 ? `${completedCount} completed` : ''}
+
+
+ ${allCompleted ? `
+
+ ✅
+
+ ` : ''}
+
+ `;
+}
+
+/**
+ * Open modal showing all downloads for an artist
+ */
+async function openSearchDownloadModal(artistName) {
+ const artistBubbleData = searchDownloadBubbles[artistName];
+ if (!artistBubbleData || searchDownloadModalOpen) return;
+
+ console.log(`🎵 [MODAL OPEN] Opening search download modal for: ${artistBubbleData.artist.name}`);
+
+ searchDownloadModalOpen = true;
+
+ const modal = document.createElement('div');
+ modal.id = 'search-download-management-modal';
+ modal.className = 'artist-download-management-modal';
+ modal.innerHTML = `
+
+
+
+
+
+
+ ${artistBubbleData.artist.image_url
+ ? `
})
`
+ : '
🎵
'
+ }
+
+
+
${escapeHtml(artistBubbleData.artist.name)}
+
${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}
+
+
+
×
+
+
+
+
+
+ ${artistBubbleData.downloads.map((download, index) => createSearchDownloadItem(download, index)).join('')}
+
+
+
+
+ `;
+
+ document.body.appendChild(modal);
+ modal.style.display = 'flex';
+
+ // Start monitoring for status changes
+ // Start monitoring for status changes
+ monitorSearchDownloadModal(artistName);
+
+ // Lazy load artist image if missing (common for iTunes)
+ if (!artistBubbleData.artist.image_url) {
+ console.log(`🖼️ Lazy loading modal image for ${artistBubbleData.artist.name} (${artistBubbleData.artist.id})`);
+ fetch(`/api/artist/${artistBubbleData.artist.id}/image`)
+ .then(response => response.json())
+ .then(data => {
+ if (data.success && data.image_url) {
+ // Update header background
+ const headerBg = modal.querySelector('.artist-download-modal-hero-bg');
+ if (headerBg) {
+ headerBg.style.backgroundImage = `url('${data.image_url}')`;
+ }
+
+ // Update avatar
+ const avatarContainer = modal.querySelector('.artist-download-modal-hero-avatar');
+ if (avatarContainer) {
+ avatarContainer.innerHTML = `
`;
+ }
+
+ // Update artist object in memory
+ artistBubbleData.artist.image_url = data.image_url;
+ }
+ })
+ .catch(err => console.error('❌ Failed to load modal image:', err));
+ }
+}
+
+/**
+ * Create HTML for a download item in the search modal
+ */
+function createSearchDownloadItem(download, index) {
+ const { item, type, status, virtualPlaylistId } = download;
+ const buttonText = status === 'view_results' ? 'View Results' : 'View Progress';
+ const buttonClass = status === 'view_results' ? 'completed' : 'active';
+ const typeLabel = type === 'album' ? 'Album' : type === 'single' ? 'Single' : 'Track';
+
+ return `
+
+
+ ${item.image_url
+ ? `
})
`
+ : `
+ ${type === 'album' ? '💿' : '🎵'}
+
`
+ }
+
+
+
${escapeHtml(item.name)}
+
${typeLabel}
+
+
+
+
+
+ `;
+}
+
+/**
+ * Reopen an individual download modal from the artist modal
+ */
+async function reopenDownloadModal(virtualPlaylistId) {
+ const process = activeDownloadProcesses[virtualPlaylistId];
+
+ // If process exists, show the existing modal
+ if (process && process.modalElement) {
+ console.log(`✅ [REOPEN] Showing existing modal for ${virtualPlaylistId}`);
+ closeSearchDownloadModal();
+ setTimeout(() => {
+ process.modalElement.style.display = 'flex';
+ }, 100);
+ return;
+ }
+
+ // Process doesn't exist (after page refresh) - recreate it
+ console.log(`🔄 [REOPEN] Modal not found, recreating for ${virtualPlaylistId}`);
+
+ // Find the download in searchDownloadBubbles
+ let downloadData = null;
+ for (const artistName in searchDownloadBubbles) {
+ const bubble = searchDownloadBubbles[artistName];
+ const download = bubble.downloads.find(d => d.virtualPlaylistId === virtualPlaylistId);
+ if (download) {
+ downloadData = download;
+ break;
+ }
+ }
+
+ if (!downloadData) {
+ console.warn(`⚠️ No download data found for ${virtualPlaylistId}`);
+ return;
+ }
+
+ // Close search modal first
+ closeSearchDownloadModal();
+
+ // Recreate the modal based on type
+ const { item, type } = downloadData;
+
+ if (type === 'album') {
+ // For albums, we need to fetch the tracks
+ console.log(`📥 [REOPEN] Recreating album modal for: ${item.name}`);
+
+ // Fetch album tracks (pass name/artist for Hydrabase support)
+ showLoadingOverlay(`Loading ${item.name}...`);
+
+ try {
+ const _sap2 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' });
+ const response = await fetch(`/api/spotify/album/${item.id}?${_sap2}`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch album tracks');
+ }
+
+ const albumData = await response.json();
+ if (!albumData.tracks || albumData.tracks.length === 0) {
+ throw new Error('No tracks found in album');
+ }
+
+ const spotifyTracks = albumData.tracks.map(track => ({
+ id: track.id,
+ name: track.name,
+ artists: track.artists || [{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }],
+ album: {
+ name: item.name,
+ images: item.image_url ? [{ url: item.image_url }] : []
+ },
+ duration_ms: track.duration_ms || 0
+ }));
+
+ hideLoadingOverlay();
+
+ // Open the modal
+ await openDownloadMissingModalForArtistAlbum(
+ virtualPlaylistId,
+ item.name,
+ spotifyTracks,
+ item,
+ { name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' },
+ false // Don't show loading overlay again
+ );
+
+ // Sync with backend to check for active batch process
+ const process = activeDownloadProcesses[virtualPlaylistId];
+ if (process) {
+ try {
+ const processResponse = await fetch('/api/active-processes');
+ if (processResponse.ok) {
+ const processData = await processResponse.json();
+ const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId);
+
+ if (activeProcess) {
+ console.log(`📡 [REOPEN] Found active batch for album: ${activeProcess.batch_id}`);
+ process.status = 'running';
+ process.batchId = activeProcess.batch_id;
+
+ // Update UI to show running state
+ const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
+ const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
+ if (beginBtn) beginBtn.style.display = 'none';
+ if (cancelBtn) cancelBtn.style.display = 'inline-block';
+
+ // Start polling for live updates
+ startModalDownloadPolling(virtualPlaylistId);
+ }
+ }
+ } catch (err) {
+ console.warn('Could not check for active processes:', err);
+ }
+ }
+
+ } catch (error) {
+ hideLoadingOverlay();
+ showToast(`Failed to load album: ${error.message}`, 'error');
+ console.error('Error loading album:', error);
+ }
+
+ } else {
+ // For tracks, create enriched track and open modal
+ console.log(`🎵 [REOPEN] Recreating track modal for: ${item.name}`);
+
+ const enrichedTrack = {
+ id: item.id,
+ name: item.name,
+ artists: item.artists || [{ name: item.artist || 'Unknown Artist' }],
+ album: item.album || {
+ name: item.album?.name || 'Unknown Album',
+ images: item.image_url ? [{ url: item.image_url }] : []
+ },
+ duration_ms: item.duration_ms || 0
+ };
+
+ await openDownloadMissingModalForYouTube(
+ virtualPlaylistId,
+ `${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`,
+ [enrichedTrack]
+ );
+
+ // Sync with backend to check for active batch process
+ const process = activeDownloadProcesses[virtualPlaylistId];
+ if (process) {
+ try {
+ const processResponse = await fetch('/api/active-processes');
+ if (processResponse.ok) {
+ const processData = await processResponse.json();
+ const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId);
+
+ if (activeProcess) {
+ console.log(`📡 [REOPEN] Found active batch for track: ${activeProcess.batch_id}`);
+ process.status = 'running';
+ process.batchId = activeProcess.batch_id;
+
+ // Update UI to show running state
+ const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
+ const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
+ if (beginBtn) beginBtn.style.display = 'none';
+ if (cancelBtn) cancelBtn.style.display = 'inline-block';
+
+ // Start polling for live updates
+ startModalDownloadPolling(virtualPlaylistId);
+ }
+ }
+ } catch (err) {
+ console.warn('Could not check for active processes:', err);
+ }
+ }
+ }
+}
+
+/**
+ * Monitor search download modal for status changes
+ */
+function monitorSearchDownloadModal(artistName) {
+ const updateModal = () => {
+ if (!searchDownloadModalOpen) return;
+
+ const modal = document.getElementById('search-download-management-modal');
+ const itemsContainer = document.getElementById('search-download-items');
+
+ if (!modal || !itemsContainer || !searchDownloadBubbles[artistName]) return;
+
+ const downloads = searchDownloadBubbles[artistName].downloads;
+
+ // If no downloads at all, close modal
+ if (downloads.length === 0) {
+ closeSearchDownloadModal();
+ return;
+ }
+
+ // Update modal content and sync status with active processes
+ let statusChanged = false;
+ itemsContainer.innerHTML = downloads.map((download, index) => {
+ const process = activeDownloadProcesses[download.virtualPlaylistId];
+
+ // Only update status if process exists (otherwise keep current status)
+ if (process) {
+ const newStatus = process.status === 'complete' || process.status === 'view_results'
+ ? 'view_results'
+ : 'in_progress';
+
+ if (download.status !== newStatus) {
+ console.log(`🔄 [MODAL MONITOR] Status changed: ${download.item.name} ${download.status} -> ${newStatus}`);
+ download.status = newStatus;
+ statusChanged = true;
+ }
+ }
+
+ return createSearchDownloadItem(download, index);
+ }).join('');
+
+ // If status changed, refresh bubble display and save
+ if (statusChanged) {
+ updateSearchDownloadsSection();
+ saveSearchBubbleSnapshot();
+ }
+
+ // Continue monitoring
+ setTimeout(updateModal, 2000);
+ };
+
+ setTimeout(updateModal, 1000);
+}
+
+/**
+ * Close the search download modal
+ */
+function closeSearchDownloadModal() {
+ const modal = document.getElementById('search-download-management-modal');
+ if (modal) {
+ modal.style.display = 'none';
+ if (modal.parentElement) {
+ modal.parentElement.removeChild(modal);
+ }
+ }
+ searchDownloadModalOpen = false;
+}
+
+/**
+ * Bulk complete all downloads for an artist (called when user clicks green checkmark)
+ */
+function bulkCompleteSearchDownloads(artistName) {
+ console.log(`🎯 Bulk completing downloads for artist: ${artistName}`);
+
+ const artistBubbleData = searchDownloadBubbles[artistName];
+ if (!artistBubbleData) {
+ console.warn(`❌ No artist bubble data found for ${artistName}`);
+ return;
+ }
+
+ // Find all completed downloads
+ const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results');
+ console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`,
+ completedDownloads.map(d => d.item.name));
+
+ if (completedDownloads.length === 0) {
+ console.warn(`⚠️ No completed downloads found for bulk close`);
+ showToast('No completed downloads to close', 'info');
+ return;
+ }
+
+ // Close all completed modals
+ completedDownloads.forEach(download => {
+ const process = activeDownloadProcesses[download.virtualPlaylistId];
+ if (process && process.modalElement) {
+ console.log(`🗑️ Closing modal for: ${download.item.name}`);
+ closeDownloadMissingModal(download.virtualPlaylistId);
+ } else {
+ // No modal open — clean up the bubble entry directly
+ console.log(`🧹 Direct cleanup (no modal) for: ${download.item.name}`);
+ cleanupSearchDownload(download.virtualPlaylistId);
+ }
+ });
+
+ showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success');
+}
+
+/**
+ * Cleanup search download when modal is closed
+ */
+function cleanupSearchDownload(virtualPlaylistId) {
+ console.log(`🔍 [CLEANUP] Looking for search download to cleanup: ${virtualPlaylistId}`);
+
+ // Find which artist this download belongs to
+ for (const artistName in searchDownloadBubbles) {
+ const downloads = searchDownloadBubbles[artistName].downloads;
+ const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId);
+
+ if (downloadIndex !== -1) {
+ console.log(`🧹 [CLEANUP] Found download in artist ${artistName}: ${downloads[downloadIndex].item.name}`);
+
+ // Remove this download
+ downloads.splice(downloadIndex, 1);
+ console.log(`🗑️ [CLEANUP] Removed download from ${artistName}'s bubble`);
+
+ // If no more downloads for this artist, remove the bubble
+ if (downloads.length === 0) {
+ delete searchDownloadBubbles[artistName];
+ console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistName}`);
+ }
+
+ // Save snapshot and refresh
+ saveSearchBubbleSnapshot();
+ updateSearchDownloadsSection();
+
+ return;
+ }
+ }
+
+ console.log(`⚠️ [CLEANUP] No matching search download found for: ${virtualPlaylistId}`);
+}
+
+/**
+ * Show or update the artist downloads section in search state
+ */
+function showArtistDownloadsSection() {
+ console.log(`🔄 [SHOW] showArtistDownloadsSection() called - refreshing artist bubbles`);
+ console.log(`🔄 [SHOW] Current view: ${artistsPageState.currentView}, artistDownloadBubbles count: ${Object.keys(artistDownloadBubbles).length}`);
+
+ // Only show in search state
+ if (artistsPageState.currentView !== 'search') {
+ console.log(`⏭️ [SHOW] Skipping - not in search state (current: ${artistsPageState.currentView})`);
+ return;
+ }
+
+ const artistsSearchState = document.getElementById('artists-search-state');
+ if (!artistsSearchState) {
+ console.log(`⏭️ [SHOW] Skipping - no artists-search-state element found`);
+ return;
+ }
+
+ let downloadsSection = document.getElementById('artist-downloads-section');
+
+ // Create section if it doesn't exist
+ if (!downloadsSection) {
+ downloadsSection = document.createElement('div');
+ downloadsSection.id = 'artist-downloads-section';
+ downloadsSection.className = 'artist-downloads-section';
+
+ // Insert after the search container
+ const searchContainer = artistsSearchState.querySelector('.artists-search-container');
+ if (searchContainer) {
+ searchContainer.insertAdjacentElement('afterend', downloadsSection);
+ }
+ }
+
+ // Count active artists (those with downloads)
+ const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId =>
+ artistDownloadBubbles[artistId].downloads.length > 0
+ );
+
+ if (activeArtists.length === 0) {
+ downloadsSection.style.display = 'none';
+ return;
+ }
+
+ // Show and populate the section
+ downloadsSection.style.display = 'block';
+ downloadsSection.innerHTML = `
+
+
+ ${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')}
+
+ `;
+
+ // Add event listeners to bubble cards
+ activeArtists.forEach(artistId => {
+ const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`);
+ if (bubbleCard) {
+ bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId));
+
+ // Add dynamic glow effect
+ const artist = artistDownloadBubbles[artistId].artist;
+ if (artist.image_url) {
+ extractImageColors(artist.image_url, (colors) => {
+ applyDynamicGlow(bubbleCard, colors);
+ });
+ }
+ }
+ });
+}
+
+/**
+ * Show download bubbles on the Library page (mirrors showArtistDownloadsSection)
+ */
+function showLibraryDownloadsSection() {
+ const libraryContent = document.querySelector('.library-content');
+ if (!libraryContent) return;
+
+ let downloadsSection = document.getElementById('library-downloads-section');
+
+ // Create section if it doesn't exist
+ if (!downloadsSection) {
+ downloadsSection = document.createElement('div');
+ downloadsSection.id = 'library-downloads-section';
+ downloadsSection.className = 'artist-downloads-section';
+
+ // Insert before the artist grid
+ const artistGrid = document.getElementById('library-artists-grid');
+ if (artistGrid) {
+ libraryContent.insertBefore(downloadsSection, artistGrid);
+ }
+ }
+
+ // Count active artists (reuses artistDownloadBubbles state)
+ const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId =>
+ artistDownloadBubbles[artistId].downloads.length > 0
+ );
+
+ if (activeArtists.length === 0) {
+ downloadsSection.style.display = 'none';
+ return;
+ }
+
+ downloadsSection.style.display = 'block';
+ downloadsSection.innerHTML = `
+
+
+ ${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')}
+
+ `;
+
+ // Add click handlers + glow effects
+ activeArtists.forEach(artistId => {
+ const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`);
+ if (bubbleCard) {
+ bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId));
+ const artist = artistDownloadBubbles[artistId].artist;
+ if (artist.image_url) {
+ extractImageColors(artist.image_url, (colors) => {
+ applyDynamicGlow(bubbleCard, colors);
+ });
+ }
+ }
+ });
+}
+
+/**
+ * Create HTML for an artist bubble card
+ */
+function createArtistBubbleCard(artistBubbleData) {
+ const { artist, downloads } = artistBubbleData;
+ 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;
+
+ // Enhanced debug logging for bubble card creation and green checkmark detection
+ console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, {
+ totalDownloads: downloads.length,
+ activeCount,
+ completedCount,
+ allCompleted,
+ downloadStatuses: downloads.map(d => `${d.album.name}: ${d.status}`)
+ });
+
+ // CRITICAL: Green checkmark detection logging
+ if (allCompleted) {
+ console.log(`🟢 [BUBBLE] GREEN CHECKMARK DETECTED for ${artist.name} - all ${downloads.length} downloads completed`);
+ console.log(`✅ [BUBBLE] This bubble will have 'all-completed' class and green checkmark`);
+ } else if (activeCount === 0 && completedCount === 0) {
+ console.log(`⭕ [BUBBLE] No active or completed downloads for ${artist.name} - this shouldn't happen`);
+ } else {
+ console.log(`⏳ [BUBBLE] Still waiting for completion: ${activeCount} active, ${completedCount} completed`);
+ }
+
+ const imageUrl = artist.image_url || '';
+ const backgroundStyle = imageUrl ?
+ `background-image: url('${imageUrl}');` :
+ `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
+
+ return `
+
+
+
+
+
${escapeHtml(artist.name)}
+
+ ${activeCount > 0 ? `${activeCount} active` : ''}
+ ${completedCount > 0 ? `${completedCount} completed` : ''}
+
+
+ ${allCompleted ? `
+
+ ✅
+
+ ` : ''}
+
+ `;
+}
+
+/**
+ * Monitor an artist download for completion status changes
+ */
+function monitorArtistDownload(artistId, virtualPlaylistId) {
+ // Check if the download process exists and monitor its status
+ const checkStatus = () => {
+ const process = activeDownloadProcesses[virtualPlaylistId];
+ if (!process || !artistDownloadBubbles[artistId]) {
+ return; // Process or artist bubble no longer exists
+ }
+
+ // Find this download in the artist's downloads
+ const download = artistDownloadBubbles[artistId].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId);
+ if (!download) return;
+
+ // Update download status based on process status
+ if (process.status === 'complete' && download.status === 'in_progress') {
+ download.status = 'view_results';
+ console.log(`✅ Download completed for ${artistDownloadBubbles[artistId].artist.name} - ${download.album.name}`);
+ console.log(`📊 Artist ${artistId} downloads status:`, artistDownloadBubbles[artistId].downloads.map(d => `${d.album.name}: ${d.status}`));
+
+ // Update the downloads section
+ updateArtistDownloadsSection();
+
+ // Save snapshot of updated state
+ saveArtistBubbleSnapshot();
+
+ // Check if all downloads for this artist are now completed
+ const artistDownloads = artistDownloadBubbles[artistId].downloads;
+ const allCompleted = artistDownloads.every(d => d.status === 'view_results');
+ if (allCompleted) {
+ console.log(`🟢 All downloads completed for ${artistDownloadBubbles[artistId].artist.name} - green checkmark should appear`);
+ console.log(`🎯 [STATUS DEBUG] Green checkmark trigger - forcing bubble refresh`);
+ // Force immediate bubble refresh to show green checkmark
+ setTimeout(updateArtistDownloadsSection, 100);
+ }
+ }
+
+ // Continue monitoring if still active
+ if (process.status !== 'complete') {
+ setTimeout(checkStatus, 2000); // Check every 2 seconds
+ }
+ };
+
+ // Start monitoring after a brief delay
+ setTimeout(checkStatus, 1000);
+}
+
+/**
+ * Open the artist download management modal
+ */
+function openArtistDownloadModal(artistId) {
+ const artistBubbleData = artistDownloadBubbles[artistId];
+ if (!artistBubbleData || artistDownloadModalOpen) return;
+
+ console.log(`🎵 [MODAL OPEN] Opening artist download modal for: ${artistBubbleData.artist.name}`);
+ console.log(`📊 [MODAL OPEN] Current download statuses:`, artistBubbleData.downloads.map(d => `${d.album.name}: ${d.status}`));
+ artistDownloadModalOpen = true;
+
+ const modal = document.createElement('div');
+ modal.id = 'artist-download-management-modal';
+ modal.className = 'artist-download-management-modal';
+ modal.innerHTML = `
+
+
+
+
+
+
+ ${artistBubbleData.artist.image_url
+ ? `
})
`
+ : '
'
+ }
+
+
+
${escapeHtml(artistBubbleData.artist.name)}
+
${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}
+
+
+
×
+
+
+
+
+
+ ${artistBubbleData.downloads.map((download, index) => createArtistDownloadItem(download, index)).join('')}
+
+
+
+
+ `;
+
+ document.body.appendChild(modal);
+ modal.style.display = 'flex';
+
+ // Monitor for real-time updates
+ startArtistDownloadModalMonitoring(artistId);
+}
+
+/**
+ * Create HTML for an individual download item in the artist modal
+ */
+function createArtistDownloadItem(download, index) {
+ const { album, albumType, status, virtualPlaylistId } = download;
+ const buttonText = status === 'view_results' ? 'View Results' : 'View Progress';
+ const buttonClass = status === 'view_results' ? 'completed' : 'active';
+
+ // Enhanced debugging for button text generation
+ console.log(`🎯 [BUTTON] Creating item for ${album.name}: status='${status}' → buttonText='${buttonText}'`);
+
+ return `
+
+
+ ${album.image_url
+ ? `
})
`
+ : `
+
+
`
+ }
+
+
+
${escapeHtml(album.name)}
+
${albumType === 'album' ? 'Album' : albumType === 'single' ? 'Single' : 'EP'}
+
+
+
+
+
+ `;
+}
+
+/**
+ * Monitor artist download modal for real-time updates
+ */
+function startArtistDownloadModalMonitoring(artistId) {
+ if (!artistDownloadModalOpen) return;
+
+ const updateModal = () => {
+ const modal = document.getElementById('artist-download-management-modal');
+ const itemsContainer = document.getElementById(`artist-download-items-${artistId}`);
+
+ if (!modal || !itemsContainer || !artistDownloadBubbles[artistId]) return;
+
+ // Check for completed downloads that need to be removed
+ const activeDownloads = artistDownloadBubbles[artistId].downloads.filter(download => {
+ const process = activeDownloadProcesses[download.virtualPlaylistId];
+ // Keep if process exists or if it's completed but not yet cleaned up
+ return process !== undefined;
+ });
+
+ // Update the downloads array
+ artistDownloadBubbles[artistId].downloads = activeDownloads;
+
+ // If no downloads left, close modal
+ if (activeDownloads.length === 0) {
+ closeArtistDownloadModal();
+ return;
+ }
+
+ // Update modal content and synchronize with bubble state
+ let statusChanged = false;
+ itemsContainer.innerHTML = activeDownloads.map((download, index) => {
+ const process = activeDownloadProcesses[download.virtualPlaylistId];
+ if (process) {
+ const newStatus = process.status === 'complete' ? 'view_results' : 'in_progress';
+ if (download.status !== newStatus) {
+ console.log(`🔄 [ARTIST MODAL] Updating ${download.album.name} status from ${download.status} to ${newStatus}`);
+ download.status = newStatus;
+ statusChanged = true;
+ }
+ }
+ return createArtistDownloadItem(download, index);
+ }).join('');
+
+ // CRITICAL: If any status changed, immediately refresh artist bubble to show green checkmarks
+ if (statusChanged) {
+ console.log(`🎯 [SYNC] Status change detected in artist modal - refreshing bubble display`);
+ updateArtistDownloadsSection();
+
+ // Check if all downloads for this artist are now completed
+ const artistDownloads = artistDownloadBubbles[artistId].downloads;
+ const allCompleted = artistDownloads.every(d => d.status === 'view_results');
+ if (allCompleted) {
+ console.log(`🟢 [ARTIST MODAL] All downloads completed for artist ${artistId} - triggering green checkmark`);
+ // Force additional refresh after a brief delay to ensure UI updates
+ setTimeout(() => {
+ console.log(`✨ [ARTIST MODAL] Forcing final refresh for green checkmark`);
+ updateArtistDownloadsSection();
+ }, 200);
+ }
+ }
+
+ // Continue monitoring
+ setTimeout(updateModal, 2000);
+ };
+
+ setTimeout(updateModal, 1000);
+}
+
+/**
+ * Open a specific artist download process modal
+ */
+function openArtistDownloadProcess(virtualPlaylistId) {
+ const process = activeDownloadProcesses[virtualPlaylistId];
+ if (process && process.modalElement) {
+ // Close artist management modal first
+ closeArtistDownloadModal();
+
+ // Show the download process modal
+ process.modalElement.style.display = 'flex';
+
+ if (process.status === 'complete') {
+ showToast('Review download results and click "Close" to finish.', 'info');
+ }
+ }
+}
+
+/**
+ * Close the artist download management modal
+ */
+function closeArtistDownloadModal() {
+ const modal = document.getElementById('artist-download-management-modal');
+ if (modal) {
+ modal.remove();
+ }
+ artistDownloadModalOpen = false;
+}
+
+/**
+ * Bulk complete all downloads for an artist (when all are in 'view_results' state)
+ */
+function bulkCompleteArtistDownloads(artistId) {
+ console.log(`🎯 Bulk completing downloads for artist: ${artistId}`);
+
+ const artistBubbleData = artistDownloadBubbles[artistId];
+ if (!artistBubbleData) {
+ console.warn(`❌ No artist bubble data found for ${artistId}`);
+ return;
+ }
+
+ // Find all downloads in 'view_results' state
+ const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results');
+ console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`,
+ completedDownloads.map(d => d.album.name));
+
+ if (completedDownloads.length === 0) {
+ console.warn(`⚠️ No completed downloads found for bulk close`);
+ showToast('No completed downloads to close', 'info');
+ return;
+ }
+
+ // Programmatically close all completed modals
+ completedDownloads.forEach(download => {
+ const process = activeDownloadProcesses[download.virtualPlaylistId];
+ if (process && process.modalElement) {
+ console.log(`🗑️ Closing modal for: ${download.album.name}`);
+ // Trigger the close function which handles cleanup
+ closeDownloadMissingModal(download.virtualPlaylistId);
+ } else {
+ // No modal open — clean up the bubble entry directly
+ console.log(`🧹 Direct cleanup (no modal) for: ${download.album.name}`);
+ cleanupArtistDownload(download.virtualPlaylistId);
+ }
+ });
+
+ 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 = `
+
+
+ ${activeCharts.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')}
+
+ `;
+
+ // Attach click handlers + glow
+ activeCharts.forEach(chartKey => {
+ const card = downloadsSection.querySelector(`[data-chart-key="${chartKey}"]`);
+ if (card) {
+ card.addEventListener('click', () => openBeatportBubbleModal(chartKey));
+ const chartImage = beatportDownloadBubbles[chartKey].chart.image;
+ if (chartImage) {
+ extractImageColors(chartImage, (colors) => {
+ applyDynamicGlow(card, colors);
+ });
+ }
+ }
+ });
+}
+
+/**
+ * Create HTML for a Beatport bubble card (reuses artist bubble CSS)
+ */
+function createBeatportBubbleCard(bubbleData) {
+ const { chart, downloads } = bubbleData;
+ const chartKey = chart.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
+ const activeCount = downloads.filter(d => d.status === 'in_progress').length;
+ const completedCount = downloads.filter(d => d.status === 'view_results').length;
+ const allCompleted = activeCount === 0 && completedCount > 0;
+
+ const backgroundStyle = chart.image
+ ? `background-image: url('${chart.image}');`
+ : `background: linear-gradient(135deg, rgba(0, 210, 120, 0.3) 0%, rgba(0, 170, 100, 0.2) 100%);`;
+
+ return `
+
+
+
+
+
${escapeHtml(chart.name)}
+
+ ${activeCount > 0 ? `${activeCount} active` : ''}
+ ${completedCount > 0 ? `${completedCount} completed` : ''}
+
+
+ ${allCompleted ? `
+
+ ✅
+
+ ` : ''}
+
+ `;
+}
+
+/**
+ * Monitor a Beatport download for completion
+ */
+function monitorBeatportDownload(chartKey, virtualPlaylistId) {
+ const checkStatus = () => {
+ const process = activeDownloadProcesses[virtualPlaylistId];
+ if (!process || !beatportDownloadBubbles[chartKey]) return;
+
+ const download = beatportDownloadBubbles[chartKey].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId);
+ if (!download) return;
+
+ if (process.status === 'complete' && download.status === 'in_progress') {
+ download.status = 'view_results';
+ console.log(`✅ Beatport download completed for ${beatportDownloadBubbles[chartKey].chart.name}`);
+
+ updateBeatportDownloadsSection();
+ saveBeatportBubbleSnapshot();
+
+ const allCompleted = beatportDownloadBubbles[chartKey].downloads.every(d => d.status === 'view_results');
+ if (allCompleted) {
+ console.log(`🟢 All Beatport downloads completed for ${beatportDownloadBubbles[chartKey].chart.name}`);
+ setTimeout(updateBeatportDownloadsSection, 100);
+ }
+ }
+
+ if (process.status !== 'complete') {
+ setTimeout(checkStatus, 2000);
+ }
+ };
+
+ setTimeout(checkStatus, 1000);
+}
+
+/**
+ * Open the download modal for a Beatport chart bubble
+ */
+function openBeatportBubbleModal(chartKey) {
+ const bubbleData = beatportDownloadBubbles[chartKey];
+ if (!bubbleData) return;
+
+ // Find the first download with an active modal
+ for (const download of bubbleData.downloads) {
+ const process = activeDownloadProcesses[download.virtualPlaylistId];
+ if (process && process.modalElement) {
+ process.modalElement.style.display = 'flex';
+ if (process.status === 'complete') {
+ showToast('Review download results and click "Close" to finish.', 'info');
+ }
+ return;
+ }
+ }
+
+ showToast('No active download modal found for this chart', 'info');
+}
+
+/**
+ * Bulk complete all downloads for a Beatport chart
+ */
+function bulkCompleteBeatportDownloads(chartKey) {
+ console.log(`🎯 Bulk completing Beatport downloads for chart: ${chartKey}`);
+
+ const bubbleData = beatportDownloadBubbles[chartKey];
+ if (!bubbleData) return;
+
+ const completedDownloads = bubbleData.downloads.filter(d => d.status === 'view_results');
+ if (completedDownloads.length === 0) {
+ showToast('No completed downloads to close', 'info');
+ return;
+ }
+
+ completedDownloads.forEach(download => {
+ const process = activeDownloadProcesses[download.virtualPlaylistId];
+ if (process && process.modalElement) {
+ closeDownloadMissingModal(download.virtualPlaylistId);
+ } else {
+ cleanupBeatportDownload(download.virtualPlaylistId);
+ }
+ });
+
+ showToast(`Completed ${completedDownloads.length} downloads for ${bubbleData.chart.name}`, 'success');
+}
+
+/**
+ * Clean up a Beatport download when its modal is closed
+ */
+function cleanupBeatportDownload(virtualPlaylistId) {
+ console.log(`🔍 [CLEANUP] Looking for Beatport download to cleanup: ${virtualPlaylistId}`);
+
+ for (const chartKey in beatportDownloadBubbles) {
+ const downloads = beatportDownloadBubbles[chartKey].downloads;
+ const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId);
+
+ if (downloadIndex !== -1) {
+ downloads.splice(downloadIndex, 1);
+ console.log(`🧹 [CLEANUP] Removed Beatport download from ${chartKey}. Remaining: ${downloads.length}`);
+
+ if (downloads.length === 0) {
+ delete beatportDownloadBubbles[chartKey];
+ console.log(`🧹 [CLEANUP] No more downloads - removed Beatport bubble: ${chartKey}`);
+ }
+
+ updateBeatportDownloadsSection();
+ saveBeatportBubbleSnapshot();
+ return;
+ }
+ }
+}
+
+// --- Beatport Bubble Snapshot System ---
+
+let beatportSnapshotSaveTimeout = null;
+
+async function saveBeatportBubbleSnapshot() {
+ if (beatportSnapshotSaveTimeout) {
+ clearTimeout(beatportSnapshotSaveTimeout);
+ }
+
+ beatportSnapshotSaveTimeout = setTimeout(async () => {
+ try {
+ const bubbleCount = Object.keys(beatportDownloadBubbles).length;
+ if (bubbleCount === 0) return;
+
+ const cleanBubbles = {};
+ for (const [chartKey, bubbleData] of Object.entries(beatportDownloadBubbles)) {
+ cleanBubbles[chartKey] = {
+ chart: bubbleData.chart,
+ downloads: bubbleData.downloads.map(d => ({
+ virtualPlaylistId: d.virtualPlaylistId,
+ status: d.status,
+ startTime: d.startTime instanceof Date ? d.startTime.toISOString() : d.startTime
+ }))
+ };
+ }
+
+ const response = await fetch('/api/beatport_bubbles/snapshot', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ bubbles: cleanBubbles })
+ });
+
+ const data = await response.json();
+ if (data.success) {
+ console.log(`✅ Beatport bubble snapshot saved: ${bubbleCount} charts`);
+ }
+ } catch (error) {
+ console.error('❌ Error saving Beatport bubble snapshot:', error);
+ }
+ }, 1000);
+}
+
+async function hydrateBeatportBubblesFromSnapshot() {
+ try {
+ console.log('🔄 Loading Beatport bubble snapshot from backend...');
+
+ const signal = getBeatportContentSignal();
+ const response = await fetch('/api/beatport_bubbles/hydrate', signal ? { signal } : undefined);
+ 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) {
+ if (error && error.name === 'AbortError') {
+ console.log('⏹ Beatport bubble hydration aborted');
+ return;
+ }
+ console.error('❌ Error hydrating Beatport bubbles:', error);
+ }
+}
+
+/**
+ * Clean up artist download when a modal is closed
+ */
+function cleanupArtistDownload(virtualPlaylistId) {
+ console.log(`🔍 [CLEANUP] Looking for download to cleanup: ${virtualPlaylistId}`);
+ console.log(`🔍 [CLEANUP] Current artist bubbles:`, Object.keys(artistDownloadBubbles));
+
+ // Find which artist this download belongs to
+ for (const artistId in artistDownloadBubbles) {
+ const downloads = artistDownloadBubbles[artistId].downloads;
+ const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId);
+
+ console.log(`🔍 [CLEANUP] Checking artist ${artistId}: ${downloads.length} downloads`);
+ downloads.forEach(d => console.log(` - ${d.album.name} (${d.virtualPlaylistId}): ${d.status}`));
+
+ if (downloadIndex !== -1) {
+ const downloadToRemove = downloads[downloadIndex];
+ console.log(`🧹 [CLEANUP] Found download to cleanup: ${downloadToRemove.album.name} (status: ${downloadToRemove.status})`);
+
+ // Remove this download from the artist's downloads
+ downloads.splice(downloadIndex, 1);
+ console.log(`✅ [CLEANUP] Removed download from artist ${artistId}. Remaining: ${downloads.length}`);
+
+ // If no more downloads for this artist, remove the bubble
+ if (downloads.length === 0) {
+ delete artistDownloadBubbles[artistId];
+ console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistId}`);
+ } else {
+ console.log(`📊 [CLEANUP] Artist ${artistId} still has ${downloads.length} downloads remaining`);
+ }
+
+ // Update the downloads section
+ console.log(`🔄 [CLEANUP] Updating artist downloads section...`);
+ updateArtistDownloadsSection();
+
+ // Save snapshot of updated state
+ saveArtistBubbleSnapshot();
+ break;
+ }
+ }
+ console.log(`✅ [CLEANUP] Cleanup process completed for ${virtualPlaylistId}`);
+}
+
+/**
+ * Force refresh all artist download statuses (useful for debugging)
+ */
+function refreshAllArtistDownloadStatuses() {
+ console.log('🔄 Force refreshing all artist download statuses...');
+
+ for (const artistId in artistDownloadBubbles) {
+ const artistData = artistDownloadBubbles[artistId];
+ let hasChanges = false;
+
+ artistData.downloads.forEach(download => {
+ const process = activeDownloadProcesses[download.virtualPlaylistId];
+ if (process) {
+ const expectedStatus = process.status === 'complete' ? 'view_results' : 'in_progress';
+ if (download.status !== expectedStatus) {
+ console.log(`🔧 Fixing status for ${download.album.name}: ${download.status} → ${expectedStatus}`);
+ download.status = expectedStatus;
+ hasChanges = true;
+ }
+ }
+ });
+
+ if (hasChanges) {
+ console.log(`✅ Updated statuses for ${artistData.artist.name}`);
+ }
+ }
+
+ // Force update the downloads section
+ showArtistDownloadsSection();
+}
+
+/**
+ * Extract dominant colors from an image for dynamic glow effects
+ */
+async function extractImageColors(imageUrl, callback) {
+ if (!imageUrl) {
+ callback(getAccentFallbackColors()); // Fallback to Spotify green
+ return;
+ }
+
+ // Check cache first for performance
+ if (artistsPageState.cache.colors[imageUrl]) {
+ callback(artistsPageState.cache.colors[imageUrl]);
+ return;
+ }
+
+ try {
+ // Create a canvas to analyze the image
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ const img = new Image();
+
+ img.crossOrigin = 'anonymous';
+
+ img.onload = function () {
+ // Resize to small dimensions for faster processing
+ const size = 50;
+ canvas.width = size;
+ canvas.height = size;
+
+ // Draw image to canvas
+ ctx.drawImage(img, 0, 0, size, size);
+
+ try {
+ // Get image data
+ const imageData = ctx.getImageData(0, 0, size, size);
+ const data = imageData.data;
+
+ // Extract colors (sample every few pixels for performance)
+ const colors = [];
+ for (let i = 0; i < data.length; i += 16) { // Sample every 4th pixel
+ const r = data[i];
+ const g = data[i + 1];
+ const b = data[i + 2];
+ const alpha = data[i + 3];
+
+ // Skip transparent or very dark pixels
+ if (alpha > 128 && (r + g + b) > 150) {
+ colors.push({ r, g, b });
+ }
+ }
+
+ if (colors.length === 0) {
+ callback(getAccentFallbackColors()); // Fallback
+ return;
+ }
+
+ // Find dominant colors using a simple clustering approach
+ const dominantColors = findDominantColors(colors, 2);
+
+ // Convert to CSS hex colors
+ const hexColors = dominantColors.map(color =>
+ `#${((1 << 24) + (color.r << 16) + (color.g << 8) + color.b).toString(16).slice(1)}`
+ );
+
+ // Cache the colors for future use
+ artistsPageState.cache.colors[imageUrl] = hexColors;
+
+ callback(hexColors);
+
+ } catch (e) {
+ console.warn('Color extraction failed, using fallback colors:', e);
+ callback(getAccentFallbackColors());
+ }
+ };
+
+ img.onerror = function () {
+ callback(getAccentFallbackColors()); // Fallback on error
+ };
+
+ img.src = imageUrl;
+
+ } catch (error) {
+ console.warn('Image color extraction error:', error);
+ callback(getAccentFallbackColors());
+ }
+}
+
+/**
+ * Simple color clustering to find dominant colors
+ */
+function findDominantColors(colors, numColors = 2) {
+ if (colors.length === 0) return [{ r: 29, g: 185, b: 84 }];
+
+ // Simple k-means clustering
+ let centroids = [];
+
+ // Initialize centroids randomly
+ for (let i = 0; i < numColors; i++) {
+ centroids.push(colors[Math.floor(Math.random() * colors.length)]);
+ }
+
+ // Run a few iterations of k-means
+ for (let iteration = 0; iteration < 5; iteration++) {
+ const clusters = Array(numColors).fill().map(() => []);
+
+ // Assign each color to nearest centroid
+ colors.forEach(color => {
+ let minDistance = Infinity;
+ let nearestCluster = 0;
+
+ centroids.forEach((centroid, i) => {
+ const distance = Math.sqrt(
+ Math.pow(color.r - centroid.r, 2) +
+ Math.pow(color.g - centroid.g, 2) +
+ Math.pow(color.b - centroid.b, 2)
+ );
+
+ if (distance < minDistance) {
+ minDistance = distance;
+ nearestCluster = i;
+ }
+ });
+
+ clusters[nearestCluster].push(color);
+ });
+
+ // Update centroids
+ centroids = clusters.map(cluster => {
+ if (cluster.length === 0) return centroids[0]; // Fallback
+
+ const avgR = cluster.reduce((sum, c) => sum + c.r, 0) / cluster.length;
+ const avgG = cluster.reduce((sum, c) => sum + c.g, 0) / cluster.length;
+ const avgB = cluster.reduce((sum, c) => sum + c.b, 0) / cluster.length;
+
+ return { r: Math.round(avgR), g: Math.round(avgG), b: Math.round(avgB) };
+ });
+ }
+
+ // Ensure we have vibrant colors by boosting saturation
+ return centroids.map(color => {
+ const max = Math.max(color.r, color.g, color.b);
+ const min = Math.min(color.r, color.g, color.b);
+ const saturation = max === 0 ? 0 : (max - min) / max;
+
+ // Boost low saturation colors
+ if (saturation < 0.4) {
+ const factor = 1.3;
+ return {
+ r: Math.min(255, Math.round(color.r * factor)),
+ g: Math.min(255, Math.round(color.g * factor)),
+ b: Math.min(255, Math.round(color.b * factor))
+ };
+ }
+
+ return color;
+ });
+}
+
+/**
+ * Apply dynamic glow effect to a card element
+ */
+function applyDynamicGlow(cardElement, colors) {
+ if (!cardElement || colors.length < 2) return;
+
+ const color1 = colors[0];
+ const color2 = colors[1];
+
+ // Add a small delay to make the effect feel more natural
+ setTimeout(() => {
+ // Create CSS custom properties for the dynamic colors
+ cardElement.style.setProperty('--glow-color-1', color1);
+ cardElement.style.setProperty('--glow-color-2', color2);
+ cardElement.classList.add('has-dynamic-glow');
+
+ console.log(`🎨 Applied dynamic glow: ${color1}, ${color2}`);
+ }, Math.random() * 200 + 100); // Random delay between 100-300ms
+}
+
+/**
+ * Utility function to escape HTML
+ */
+function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+// --- Service Status and System Stats Functions ---
+
+async function _forceServiceStatusRefresh() {
+ // Force an immediate status refresh (bypasses WebSocket check) — used after settings save
+ try {
+ const response = await fetch('/status');
+ if (!response.ok) return;
+ const data = await response.json();
+ handleServiceStatusUpdate(data);
+ } catch (error) {
+ console.warn('Could not force service status refresh:', error);
+ }
+}
+
+async function fetchAndUpdateServiceStatus() {
+ if (document.hidden) return; // Skip polling when tab is not visible
+ if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll
+ try {
+ const response = await fetch('/status');
+ if (!response.ok) return;
+
+ const data = await response.json();
+
+ // Cache for library status card
+ _lastServiceStatus = data;
+
+ // Update service status indicators and text (dashboard)
+ updateServiceStatus('spotify', data.spotify);
+ updateServiceStatus('media-server', data.media_server);
+ updateServiceStatus('soulseek', data.soulseek);
+
+ // Update sidebar service status indicators
+ updateSidebarServiceStatus('spotify', data.spotify);
+ updateSidebarServiceStatus('media-server', data.media_server);
+ updateSidebarServiceStatus('soulseek', data.soulseek);
+
+ // Update downloads nav badge
+ if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads);
+
+ // Hide sync buttons (not the page) for standalone mode
+ const isSoulsyncStandalone2 = data.media_server?.type === 'soulsync';
+ _isSoulsyncStandalone = isSoulsyncStandalone2;
+ document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => {
+ if (isSoulsyncStandalone2) {
+ btn.dataset.hiddenByStandalone = '1';
+ btn.style.display = 'none';
+ } else if (btn.dataset.hiddenByStandalone) {
+ delete btn.dataset.hiddenByStandalone;
+ btn.style.display = '';
+ }
+ });
+
+ // Update enrichment service cards
+ if (data.enrichment) renderEnrichmentCards(data.enrichment);
+
+ // Check for Spotify rate limit
+ if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) {
+ handleSpotifyRateLimit(data.spotify.rate_limit);
+ } else if (_spotifyRateLimitShown) {
+ handleSpotifyRateLimit(null);
+ }
+
+ } catch (error) {
+ console.warn('Could not fetch service status:', error);
+ }
+}
+
+function updateServiceStatus(service, statusData) {
+ const indicator = document.getElementById(`${service}-status-indicator`);
+ const statusText = document.getElementById(`${service}-status-text`);
+
+ if (indicator && statusText) {
+ if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
+ indicator.className = 'service-card-indicator rate-limited';
+ const remaining = statusData.rate_limited
+ ? formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)
+ : formatRateLimitDuration(statusData.post_ban_cooldown);
+ const phase = statusData.rate_limited ? 'paused' : 'recovering';
+ const fallbackLabel = statusData.source === 'deezer' ? 'Deezer' : 'iTunes';
+ statusText.textContent = `${fallbackLabel} (Spotify ${phase} \u2014 ${remaining})`;
+ statusText.className = 'service-card-status-text rate-limited';
+ } else if (statusData.connected) {
+ indicator.className = 'service-card-indicator connected';
+ statusText.textContent = `Connected (${statusData.response_time}ms)`;
+ statusText.className = 'service-card-status-text connected';
+ } else {
+ indicator.className = 'service-card-indicator disconnected';
+ statusText.textContent = 'Disconnected';
+ statusText.className = 'service-card-status-text disconnected';
+ }
+ }
+
+ // Update music source title based on active source
+ if (service === 'spotify' && statusData.source) {
+ const musicSourceTitleElement = document.getElementById('music-source-title');
+ if (musicSourceTitleElement) {
+ const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes';
+ musicSourceTitleElement.textContent = sourceName;
+ currentMusicSourceName = sourceName;
+ }
+
+ // Show/hide Spotify disconnect button based on connection state
+ const disconnectBtn = document.getElementById('spotify-disconnect-btn');
+ if (disconnectBtn) {
+ disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none';
+ }
+ }
+
+ // Update download source title on dashboard card
+ if (service === 'soulseek' && statusData.source) {
+ const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' };
+ const displayName = sourceNames[statusData.source] || 'Soulseek';
+ const titleEl = document.getElementById('download-source-title');
+ if (titleEl) titleEl.textContent = displayName;
+ }
+}
+
+function updateSidebarServiceStatus(service, statusData) {
+ const indicator = document.getElementById(`${service}-indicator`);
+ if (indicator) {
+ const dot = indicator.querySelector('.status-dot');
+ const nameElement = indicator.querySelector('.status-name');
+
+ if (dot) {
+ if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
+ dot.className = 'status-dot rate-limited';
+ dot.title = statusData.rate_limited
+ ? `Spotify paused \u2014 ${formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)} remaining`
+ : `Spotify recovering \u2014 ${formatRateLimitDuration(statusData.post_ban_cooldown)} cooldown`;
+ } else if (statusData.connected) {
+ dot.className = 'status-dot connected';
+ dot.title = '';
+ } else {
+ dot.className = 'status-dot disconnected';
+ dot.title = '';
+ }
+ }
+
+ // Update media server name if it's the media server indicator
+ if (service === 'media-server' && statusData.type) {
+ const mediaServerNameElement = document.getElementById('media-server-name');
+ if (mediaServerNameElement) {
+ const serverName = statusData.type.charAt(0).toUpperCase() + statusData.type.slice(1);
+ mediaServerNameElement.textContent = serverName;
+ }
+ }
+
+ // Update music source name in sidebar based on active source
+ if (service === 'spotify' && statusData.source) {
+ const musicSourceNameElement = document.getElementById('music-source-name');
+ if (musicSourceNameElement) {
+ const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes';
+ musicSourceNameElement.textContent = sourceName;
+ }
+ }
+
+ // Update download source name based on configured mode
+ if (service === 'soulseek' && statusData.source) {
+ const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' };
+ const displayName = sourceNames[statusData.source] || 'Soulseek';
+ const sidebarName = document.getElementById('download-source-name');
+ if (sidebarName) sidebarName.textContent = displayName;
+ }
+ }
+}
+
+function renderEnrichmentCards(enrichment) {
+ const grid = document.getElementById('enrichment-status-grid');
+ if (!grid || !enrichment) return;
+
+ // Service display order
+ const serviceOrder = [
+ 'musicbrainz', 'spotify_enrichment', 'itunes_enrichment', 'deezer_enrichment',
+ 'tidal_enrichment', 'qobuz_enrichment', 'lastfm', 'genius', 'audiodb',
+ 'acoustid', 'listenbrainz'
+ ];
+
+ // Map service keys to their settings page selector for click-to-configure
+ const settingsSelectors = {
+ 'spotify_enrichment': '.spotify-title',
+ 'tidal_enrichment': '.tidal-title',
+ 'qobuz_enrichment': '.qobuz-title',
+ 'lastfm': '.lastfm-title',
+ 'genius': '.genius-title',
+ 'acoustid': '.acoustid-title',
+ 'listenbrainz': '.listenbrainz-title',
+ };
+
+ const chips = [];
+ for (const key of serviceOrder) {
+ const svc = enrichment[key];
+ if (!svc) continue;
+
+ // Determine status class and text
+ let statusClass, statusLabel;
+ if ('running' in svc) {
+ if (!svc.configured) {
+ statusClass = 'not-configured';
+ statusLabel = 'Set up';
+ } else if (svc.paused) {
+ statusClass = 'paused';
+ statusLabel = svc.yield_reason === 'downloads' ? 'Yielding' : 'Paused';
+ } else if (svc.running) {
+ statusClass = svc.idle ? 'idle' : 'running';
+ statusLabel = svc.idle ? 'Idle' : 'Running';
+ } else {
+ statusClass = 'stopped';
+ statusLabel = 'Stopped';
+ }
+ } else {
+ statusClass = svc.configured ? 'running' : 'not-configured';
+ statusLabel = svc.configured ? 'Ready' : 'Set up';
+ }
+
+ const selector = settingsSelectors[key];
+ const clickAttr = selector
+ ? `onclick="navigateToPage('settings'); setTimeout(() => { switchSettingsTab('connections'); setTimeout(() => { const el = document.querySelector('${selector}'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); }, 50);"`
+ : '';
+
+ // Build activity display — human-readable, not cryptic numbers
+ let activityHtml = '';
+ let metaHtml = '';
+ const isSpotify = key === 'spotify_enrichment';
+
+ if ('running' in svc && svc.configured) {
+ const c1h = svc.calls_1h || 0;
+ const c24h = svc.calls_24h || 0;
+
+ if (isSpotify && svc.daily_budget) {
+ // Spotify: show budget usage prominently
+ const b = svc.daily_budget;
+ const pct = Math.min(100, Math.round((b.used / b.limit) * 100));
+ const barClass = b.exhausted ? 'exhausted' : pct > 80 ? 'high' : '';
+ activityHtml = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()}`;
+ metaHtml = ``;
+ } else if (c24h > 0) {
+ // Other services: show 24h count
+ activityHtml = `${c24h.toLocaleString()} / 24h`;
+ }
+ }
+
+ // Tooltip: full details including 1h breakdown
+ let tooltipLines = [svc.name + ' — ' + statusLabel];
+ if ('running' in svc && svc.configured) {
+ const c1h = svc.calls_1h || 0;
+ const c24h = svc.calls_24h || 0;
+ if (c24h > 0 || c1h > 0) tooltipLines.push('Last hour: ' + c1h + ' · Last 24h: ' + c24h);
+ }
+ if (isSpotify && svc.daily_budget) {
+ const b = svc.daily_budget;
+ tooltipLines.push('Daily budget: ' + b.used + ' / ' + b.limit + (b.exhausted ? ' (exhausted)' : ''));
+ }
+ if (selector && statusClass === 'not-configured') {
+ tooltipLines = ['Click to configure in Settings'];
+ }
+
+ const statusDisplay = statusClass === 'not-configured' && selector ? 'Configure →' : statusLabel;
+
+ chips.push(`
+
+
+ ${svc.name}
+ ${activityHtml}
+ ${statusDisplay}
+ ${metaHtml}
+
+ `);
+ }
+
+ grid.innerHTML = chips.join('');
+}
+
+// ===============================