diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index 89a12e5a..d5982c9e 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -27,9 +27,11 @@ _ROOT = Path(__file__).resolve().parent.parent _STATIC = _ROOT / "webui" / "static" _INDEX = _ROOT / "webui" / "index.html" -# The 17 modules that replaced script.js (order matters for first/last checks) +# The 17 modules that replaced script.js + shared-helpers.js extracted from +# artists.js (order matters for first/last checks) SPLIT_MODULES = [ "core.js", + "shared-helpers.js", "media-player.js", "settings.js", "search.js", diff --git a/webui/index.html b/webui/index.html index 35fb1a2a..fed35624 100644 --- a/webui/index.html +++ b/webui/index.html @@ -7988,6 +7988,7 @@ + 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 = ` -
-
- ${generateDownloadModalHeroSection(heroContext)} -
- -
-
-
-
- ๐Ÿ” Library Analysis - Ready to start -
-
-
-
-
-
-
- โฌ Downloads - Waiting for analysis -
-
-
-
-
-
- -
-
-

๐Ÿ“‹ Track Analysis & Download Status

- ${spotifyTracks.length} / ${spotifyTracks.length} tracks selected -
-
- - - - - - - - - - - - - - - ${spotifyTracks.map((track, index) => ` - - - - - - - - - - - `).join('')} - -
- - #Track NameArtist(s)DurationLibrary StatusDownload StatusActions
- - ${index + 1}${escapeHtml(track.name)}${escapeHtml(formatArtists(track.artists))}${formatDuration(track.duration_ms)}๐Ÿ” Pending--
-
-
-
- - - - -
- `; - - 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 = ` -
-
-

Active Downloads

- ${activeArtists.length} -
-
- ${bubblesHTML} -
-
- `; - - 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)}` - : '
๐ŸŽต
' - } -
-
-

${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 = `${artistBubbleData.artist.name}`; - } - - // 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 - ? `${escapeHtml(item.name)}` - : `
- ${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 = ` -
-

Current Downloads

-

Active download processes

-
-
- ${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 = ` -
-

Current Downloads

-

Active download processes

-
-
- ${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)}` - : '
' - } -
-
-

${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)}` - : `
- -
` - } -
-
-
${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 = ` -
-

Beatport Downloads

-

Active chart download processes

-
-
- ${activeCharts.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')} -
- `; - - // Attach click handlers + glow - activeCharts.forEach(chartKey => { - const card = downloadsSection.querySelector(`[data-chart-key="${chartKey}"]`); - if (card) { - card.addEventListener('click', () => openBeatportBubbleModal(chartKey)); - const chartImage = beatportDownloadBubbles[chartKey].chart.image; - if (chartImage) { - extractImageColors(chartImage, (colors) => { - applyDynamicGlow(card, colors); - }); - } - } - }); -} - -/** - * Create HTML for a Beatport bubble card (reuses artist bubble CSS) - */ -function createBeatportBubbleCard(bubbleData) { - const { chart, downloads } = bubbleData; - const chartKey = chart.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); - const activeCount = downloads.filter(d => d.status === 'in_progress').length; - const completedCount = downloads.filter(d => d.status === 'view_results').length; - const allCompleted = activeCount === 0 && completedCount > 0; - - const backgroundStyle = chart.image - ? `background-image: url('${chart.image}');` - : `background: linear-gradient(135deg, rgba(0, 210, 120, 0.3) 0%, rgba(0, 170, 100, 0.2) 100%);`; - - return ` -
-
-
-
-
${escapeHtml(chart.name)}
-
- ${activeCount > 0 ? `${activeCount} active` : ''} - ${completedCount > 0 ? `${completedCount} completed` : ''} -
-
- ${allCompleted ? ` -
- โœ… -
- ` : ''} -
- `; -} - -/** - * Monitor a Beatport download for completion - */ -function monitorBeatportDownload(chartKey, virtualPlaylistId) { - const checkStatus = () => { - const process = activeDownloadProcesses[virtualPlaylistId]; - if (!process || !beatportDownloadBubbles[chartKey]) return; - - const download = beatportDownloadBubbles[chartKey].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); - if (!download) return; - - if (process.status === 'complete' && download.status === 'in_progress') { - download.status = 'view_results'; - console.log(`โœ… Beatport download completed for ${beatportDownloadBubbles[chartKey].chart.name}`); - - updateBeatportDownloadsSection(); - saveBeatportBubbleSnapshot(); - - const allCompleted = beatportDownloadBubbles[chartKey].downloads.every(d => d.status === 'view_results'); - if (allCompleted) { - console.log(`๐ŸŸข All Beatport downloads completed for ${beatportDownloadBubbles[chartKey].chart.name}`); - setTimeout(updateBeatportDownloadsSection, 100); - } - } - - if (process.status !== 'complete') { - setTimeout(checkStatus, 2000); - } - }; - - setTimeout(checkStatus, 1000); -} - -/** - * Open the download modal for a Beatport chart bubble - */ -function openBeatportBubbleModal(chartKey) { - const bubbleData = beatportDownloadBubbles[chartKey]; - if (!bubbleData) return; - - // Find the first download with an active modal - for (const download of bubbleData.downloads) { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process && process.modalElement) { - process.modalElement.style.display = 'flex'; - if (process.status === 'complete') { - showToast('Review download results and click "Close" to finish.', 'info'); - } - return; - } - } - - showToast('No active download modal found for this chart', 'info'); -} - -/** - * Bulk complete all downloads for a Beatport chart - */ -function bulkCompleteBeatportDownloads(chartKey) { - console.log(`๐ŸŽฏ Bulk completing Beatport downloads for chart: ${chartKey}`); - - const bubbleData = beatportDownloadBubbles[chartKey]; - if (!bubbleData) return; - - const completedDownloads = bubbleData.downloads.filter(d => d.status === 'view_results'); - if (completedDownloads.length === 0) { - showToast('No completed downloads to close', 'info'); - return; - } - - completedDownloads.forEach(download => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process && process.modalElement) { - closeDownloadMissingModal(download.virtualPlaylistId); - } else { - cleanupBeatportDownload(download.virtualPlaylistId); - } - }); - - showToast(`Completed ${completedDownloads.length} downloads for ${bubbleData.chart.name}`, 'success'); -} - -/** - * Clean up a Beatport download when its modal is closed - */ -function cleanupBeatportDownload(virtualPlaylistId) { - console.log(`๐Ÿ” [CLEANUP] Looking for Beatport download to cleanup: ${virtualPlaylistId}`); - - for (const chartKey in beatportDownloadBubbles) { - const downloads = beatportDownloadBubbles[chartKey].downloads; - const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); - - if (downloadIndex !== -1) { - downloads.splice(downloadIndex, 1); - console.log(`๐Ÿงน [CLEANUP] Removed Beatport download from ${chartKey}. Remaining: ${downloads.length}`); - - if (downloads.length === 0) { - delete beatportDownloadBubbles[chartKey]; - console.log(`๐Ÿงน [CLEANUP] No more downloads - removed Beatport bubble: ${chartKey}`); - } - - updateBeatportDownloadsSection(); - saveBeatportBubbleSnapshot(); - return; - } - } -} - -// --- Beatport Bubble Snapshot System --- - -let beatportSnapshotSaveTimeout = null; - -async function saveBeatportBubbleSnapshot() { - if (beatportSnapshotSaveTimeout) { - clearTimeout(beatportSnapshotSaveTimeout); - } - - beatportSnapshotSaveTimeout = setTimeout(async () => { - try { - const bubbleCount = Object.keys(beatportDownloadBubbles).length; - if (bubbleCount === 0) return; - - const cleanBubbles = {}; - for (const [chartKey, bubbleData] of Object.entries(beatportDownloadBubbles)) { - cleanBubbles[chartKey] = { - chart: bubbleData.chart, - downloads: bubbleData.downloads.map(d => ({ - virtualPlaylistId: d.virtualPlaylistId, - status: d.status, - startTime: d.startTime instanceof Date ? d.startTime.toISOString() : d.startTime - })) - }; - } - - const response = await fetch('/api/beatport_bubbles/snapshot', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ bubbles: cleanBubbles }) - }); - - const data = await response.json(); - if (data.success) { - console.log(`โœ… Beatport bubble snapshot saved: ${bubbleCount} charts`); - } - } catch (error) { - console.error('โŒ Error saving Beatport bubble snapshot:', error); - } - }, 1000); -} - -async function hydrateBeatportBubblesFromSnapshot() { - try { - console.log('๐Ÿ”„ Loading Beatport bubble snapshot from backend...'); - - const 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 = ` +
+
+ ${generateDownloadModalHeroSection(heroContext)} +
+ +
+
+
+
+ ๐Ÿ” Library Analysis + Ready to start +
+
+
+
+
+
+
+ โฌ Downloads + Waiting for analysis +
+
+
+
+
+
+ +
+
+

๐Ÿ“‹ Track Analysis & Download Status

+ ${spotifyTracks.length} / ${spotifyTracks.length} tracks selected +
+
+ + + + + + + + + + + + + + + ${spotifyTracks.map((track, index) => ` + + + + + + + + + + + `).join('')} + +
+ + #Track NameArtist(s)DurationLibrary StatusDownload StatusActions
+ + ${index + 1}${escapeHtml(track.name)}${escapeHtml(formatArtists(track.artists))}${formatDuration(track.duration_ms)}๐Ÿ” Pending--
+
+
+
+ + + + +
+ `; + + 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 = ` +
+
+

Active Downloads

+ ${activeArtists.length} +
+
+ ${bubblesHTML} +
+
+ `; + + 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)}` + : '
๐ŸŽต
' + } +
+
+

${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 = `${artistBubbleData.artist.name}`; + } + + // 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 + ? `${escapeHtml(item.name)}` + : `
+ ${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 = ` +
+

Current Downloads

+

Active download processes

+
+
+ ${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 = ` +
+

Current Downloads

+

Active download processes

+
+
+ ${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)}` + : '
' + } +
+
+

${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)}` + : `
+ +
` + } +
+
+
${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 = ` +
+

Beatport Downloads

+

Active chart download processes

+
+
+ ${activeCharts.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')} +
+ `; + + // Attach click handlers + glow + activeCharts.forEach(chartKey => { + const card = downloadsSection.querySelector(`[data-chart-key="${chartKey}"]`); + if (card) { + card.addEventListener('click', () => openBeatportBubbleModal(chartKey)); + const chartImage = beatportDownloadBubbles[chartKey].chart.image; + if (chartImage) { + extractImageColors(chartImage, (colors) => { + applyDynamicGlow(card, colors); + }); + } + } + }); +} + +/** + * Create HTML for a Beatport bubble card (reuses artist bubble CSS) + */ +function createBeatportBubbleCard(bubbleData) { + const { chart, downloads } = bubbleData; + const chartKey = chart.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); + const activeCount = downloads.filter(d => d.status === 'in_progress').length; + const completedCount = downloads.filter(d => d.status === 'view_results').length; + const allCompleted = activeCount === 0 && completedCount > 0; + + const backgroundStyle = chart.image + ? `background-image: url('${chart.image}');` + : `background: linear-gradient(135deg, rgba(0, 210, 120, 0.3) 0%, rgba(0, 170, 100, 0.2) 100%);`; + + return ` +
+
+
+
+
${escapeHtml(chart.name)}
+
+ ${activeCount > 0 ? `${activeCount} active` : ''} + ${completedCount > 0 ? `${completedCount} completed` : ''} +
+
+ ${allCompleted ? ` +
+ โœ… +
+ ` : ''} +
+ `; +} + +/** + * Monitor a Beatport download for completion + */ +function monitorBeatportDownload(chartKey, virtualPlaylistId) { + const checkStatus = () => { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (!process || !beatportDownloadBubbles[chartKey]) return; + + const download = beatportDownloadBubbles[chartKey].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); + if (!download) return; + + if (process.status === 'complete' && download.status === 'in_progress') { + download.status = 'view_results'; + console.log(`โœ… Beatport download completed for ${beatportDownloadBubbles[chartKey].chart.name}`); + + updateBeatportDownloadsSection(); + saveBeatportBubbleSnapshot(); + + const allCompleted = beatportDownloadBubbles[chartKey].downloads.every(d => d.status === 'view_results'); + if (allCompleted) { + console.log(`๐ŸŸข All Beatport downloads completed for ${beatportDownloadBubbles[chartKey].chart.name}`); + setTimeout(updateBeatportDownloadsSection, 100); + } + } + + if (process.status !== 'complete') { + setTimeout(checkStatus, 2000); + } + }; + + setTimeout(checkStatus, 1000); +} + +/** + * Open the download modal for a Beatport chart bubble + */ +function openBeatportBubbleModal(chartKey) { + const bubbleData = beatportDownloadBubbles[chartKey]; + if (!bubbleData) return; + + // Find the first download with an active modal + for (const download of bubbleData.downloads) { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process && process.modalElement) { + process.modalElement.style.display = 'flex'; + if (process.status === 'complete') { + showToast('Review download results and click "Close" to finish.', 'info'); + } + return; + } + } + + showToast('No active download modal found for this chart', 'info'); +} + +/** + * Bulk complete all downloads for a Beatport chart + */ +function bulkCompleteBeatportDownloads(chartKey) { + console.log(`๐ŸŽฏ Bulk completing Beatport downloads for chart: ${chartKey}`); + + const bubbleData = beatportDownloadBubbles[chartKey]; + if (!bubbleData) return; + + const completedDownloads = bubbleData.downloads.filter(d => d.status === 'view_results'); + if (completedDownloads.length === 0) { + showToast('No completed downloads to close', 'info'); + return; + } + + completedDownloads.forEach(download => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process && process.modalElement) { + closeDownloadMissingModal(download.virtualPlaylistId); + } else { + cleanupBeatportDownload(download.virtualPlaylistId); + } + }); + + showToast(`Completed ${completedDownloads.length} downloads for ${bubbleData.chart.name}`, 'success'); +} + +/** + * Clean up a Beatport download when its modal is closed + */ +function cleanupBeatportDownload(virtualPlaylistId) { + console.log(`๐Ÿ” [CLEANUP] Looking for Beatport download to cleanup: ${virtualPlaylistId}`); + + for (const chartKey in beatportDownloadBubbles) { + const downloads = beatportDownloadBubbles[chartKey].downloads; + const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); + + if (downloadIndex !== -1) { + downloads.splice(downloadIndex, 1); + console.log(`๐Ÿงน [CLEANUP] Removed Beatport download from ${chartKey}. Remaining: ${downloads.length}`); + + if (downloads.length === 0) { + delete beatportDownloadBubbles[chartKey]; + console.log(`๐Ÿงน [CLEANUP] No more downloads - removed Beatport bubble: ${chartKey}`); + } + + updateBeatportDownloadsSection(); + saveBeatportBubbleSnapshot(); + return; + } + } +} + +// --- Beatport Bubble Snapshot System --- + +let beatportSnapshotSaveTimeout = null; + +async function saveBeatportBubbleSnapshot() { + if (beatportSnapshotSaveTimeout) { + clearTimeout(beatportSnapshotSaveTimeout); + } + + beatportSnapshotSaveTimeout = setTimeout(async () => { + try { + const bubbleCount = Object.keys(beatportDownloadBubbles).length; + if (bubbleCount === 0) return; + + const cleanBubbles = {}; + for (const [chartKey, bubbleData] of Object.entries(beatportDownloadBubbles)) { + cleanBubbles[chartKey] = { + chart: bubbleData.chart, + downloads: bubbleData.downloads.map(d => ({ + virtualPlaylistId: d.virtualPlaylistId, + status: d.status, + startTime: d.startTime instanceof Date ? d.startTime.toISOString() : d.startTime + })) + }; + } + + const response = await fetch('/api/beatport_bubbles/snapshot', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bubbles: cleanBubbles }) + }); + + const data = await response.json(); + if (data.success) { + console.log(`โœ… Beatport bubble snapshot saved: ${bubbleCount} charts`); + } + } catch (error) { + console.error('โŒ Error saving Beatport bubble snapshot:', error); + } + }, 1000); +} + +async function hydrateBeatportBubblesFromSnapshot() { + try { + console.log('๐Ÿ”„ Loading Beatport bubble snapshot from backend...'); + + const 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(''); +} + +// ===============================