// ARTISTS PAGE FUNCTIONALITY - ELEGANT SEARCH & DISCOVERY // ============================================================================ /** * Initialize the artists page when navigated to (only runs once) */ function initializeArtistsPage() { console.log('๐ŸŽต Initializing Artists Page (first time)'); // Get DOM elements const searchInput = document.getElementById('artists-search-input'); const headerSearchInput = document.getElementById('artists-header-search-input'); const searchStatus = document.getElementById('artists-search-status'); const backButton = document.getElementById('artists-back-button'); const detailBackButton = document.getElementById('artist-detail-back-button'); // Set up event listeners (only need to do this once) if (searchInput) { searchInput.addEventListener('input', handleArtistsSearchInput); searchInput.addEventListener('keypress', handleArtistsSearchKeypress); } if (headerSearchInput) { headerSearchInput.addEventListener('input', handleArtistsHeaderSearchInput); headerSearchInput.addEventListener('keypress', handleArtistsSearchKeypress); } if (backButton) { backButton.addEventListener('click', () => showArtistsSearchState()); } if (detailBackButton) { detailBackButton.addEventListener('click', () => { // If there are no search results (user navigated directly to artist), // go straight to the main search view instead of showing an empty results page if (!artistsPageState.searchResults || artistsPageState.searchResults.length === 0) { showArtistsSearchState(); } else { showArtistsResultsState(); } }); } // Initialize tabs (only need to do this once) initializeArtistTabs(); // Mark as initialized artistsPageState.isInitialized = true; // Restore previous state instead of always resetting to search restoreArtistsPageState(); console.log('โœ… Artists Page initialized successfully (ready for navigation)'); } /** * Restore the artists page to its previous state */ function restoreArtistsPageState() { console.log(`๐Ÿ”„ Restoring artists page state: ${artistsPageState.currentView}`); switch (artistsPageState.currentView) { case 'results': // Restore search results state if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { console.log(`๐Ÿ“ฆ Restoring search results for: "${artistsPageState.searchQuery}"`); // Restore search input values const searchInput = document.getElementById('artists-search-input'); const headerSearchInput = document.getElementById('artists-header-search-input'); if (searchInput) searchInput.value = artistsPageState.searchQuery; if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery; // Display the cached results displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults); } else { // No valid results state, fall back to search showArtistsSearchState(); } break; case 'detail': // Restore artist detail state if (artistsPageState.selectedArtist && artistsPageState.artistDiscography) { console.log(`๐ŸŽค Restoring artist detail for: ${artistsPageState.selectedArtist.name}`); // First restore search results if they exist if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { const searchInput = document.getElementById('artists-search-input'); const headerSearchInput = document.getElementById('artists-header-search-input'); if (searchInput) searchInput.value = artistsPageState.searchQuery; if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery; } // Show artist detail state showArtistDetailState(); // Update artist info in header updateArtistDetailHeader(artistsPageState.selectedArtist); // Display cached discography if (artistsPageState.artistDiscography.albums || artistsPageState.artistDiscography.singles) { displayArtistDiscography(artistsPageState.artistDiscography); // Restore cached completion data instead of re-scanning restoreCachedCompletionData(artistsPageState.selectedArtist.id); } } else { // No valid detail state, fall back to search or results if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults); } else { showArtistsSearchState(); } } break; default: case 'search': // Show search state (but preserve any existing search query) if (artistsPageState.searchQuery) { const searchInput = document.getElementById('artists-search-input'); if (searchInput) searchInput.value = artistsPageState.searchQuery; } showArtistsSearchState(); break; } } /** * Handle search input with debouncing */ function handleArtistsSearchInput(event) { const query = event.target.value.trim(); updateArtistsSearchStatus('searching'); // Clear existing timeout if (artistsSearchTimeout) { clearTimeout(artistsSearchTimeout); } // Cancel any active search if (artistsSearchController) { artistsSearchController.abort(); } if (query === '') { updateArtistsSearchStatus('default'); return; } // Set up new debounced search artistsSearchTimeout = setTimeout(() => { performArtistsSearch(query); }, 1000); // 1 second debounce } /** * Handle header search input (already in results state) */ function handleArtistsHeaderSearchInput(event) { const query = event.target.value.trim(); // Update main search input to match const mainInput = document.getElementById('artists-search-input'); if (mainInput) { mainInput.value = query; } // Trigger search with same debouncing logic handleArtistsSearchInput(event); } /** * Handle Enter key press in search inputs */ function handleArtistsSearchKeypress(event) { if (event.key === 'Enter') { event.preventDefault(); const query = event.target.value.trim(); if (query && query !== artistsPageState.searchQuery) { // Clear timeout and search immediately if (artistsSearchTimeout) { clearTimeout(artistsSearchTimeout); } performArtistsSearch(query); } } } /** * Perform artist search with API call */ async function performArtistsSearch(query) { console.log(`๐Ÿ” Searching for artists: "${query}"`); // Check cache first if (artistsPageState.cache.searches[query]) { console.log('๐Ÿ“ฆ Using cached search results'); displayArtistsResults(query, artistsPageState.cache.searches[query]); return; } // Update status updateArtistsSearchStatus('searching'); // Show loading cards immediately if we're in results view if (artistsPageState.currentView === 'results') { showSearchLoadingCards(); } try { // Set up abort controller artistsSearchController = new AbortController(); const response = await fetch('/api/match/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: query, context: 'artist' }), signal: artistsSearchController.signal }); if (!response.ok) { throw new Error(`Search failed: ${response.status}`); } const data = await response.json(); console.log(`โœ… Found ${data.results?.length || 0} artists`); // Transform the results to flatten the nested artist data const transformedResults = (data.results || []).map(result => { // Extract artist data from the nested structure const artist = result.artist || result; return { id: artist.id, name: artist.name, image_url: artist.image_url, genres: artist.genres, popularity: artist.popularity, confidence: result.confidence || 0 }; }); console.log('๐Ÿ”ง Transformed results:', transformedResults); // Cache the transformed results artistsPageState.cache.searches[query] = transformedResults; // Display results displayArtistsResults(query, transformedResults); } catch (error) { if (error.name !== 'AbortError') { console.error('โŒ Artist search failed:', error); // Provide specific error messages based on the error type let errorMessage = 'Search failed. Please try again.'; if (error.message.includes('401') || error.message.includes('authentication')) { errorMessage = 'Spotify not authenticated. Please check your API settings.'; } else if (error.message.includes('network') || error.message.includes('fetch')) { errorMessage = 'Network error. Please check your connection.'; } else if (error.message.includes('timeout')) { errorMessage = 'Search timed out. Please try again.'; } updateArtistsSearchStatus('error', errorMessage); } } finally { artistsSearchController = null; } } /** * Display artist search results */ function displayArtistsResults(query, results) { console.log(`๐Ÿ“Š Displaying ${results.length} artist results`); // Update state artistsPageState.searchQuery = query; artistsPageState.searchResults = results; artistsPageState.currentView = 'results'; // Update header search input if different const headerInput = document.getElementById('artists-header-search-input'); if (headerInput && headerInput.value !== query) { headerInput.value = query; } // Show results state showArtistsResultsState(); // Populate results const container = document.getElementById('artists-cards-container'); if (!container) return; if (results.length === 0) { container.innerHTML = `
๐Ÿ”
No artists found
Try a different search term
`; return; } // Create artist cards container.innerHTML = results.map(result => createArtistCardHTML(result)).join(''); observeLazyBackgrounds(container); // Add event listeners to cards container.querySelectorAll('.artist-card').forEach((card, index) => { card.addEventListener('click', () => selectArtistForDetail(results[index])); // Extract colors from artist image for dynamic glow const artist = results[index]; if (artist.image_url) { extractImageColors(artist.image_url, (colors) => { applyDynamicGlow(card, colors); }); } }); // Update watchlist status for all cards updateArtistCardWatchlistStatus(); // Lazy load missing artist images console.log('๐Ÿ–ผ๏ธ Starting lazy load for artist images on Artists page...'); if (typeof lazyLoadArtistImages === 'function') { lazyLoadArtistImages(container); } else if (typeof window.lazyLoadArtistImages === 'function') { window.lazyLoadArtistImages(container); } else { console.error('โŒ lazyLoadArtistImages function not found!'); } // Add mouse wheel horizontal scrolling container.addEventListener('wheel', (event) => { if (event.deltaY !== 0) { event.preventDefault(); container.scrollLeft += event.deltaY; } }); } /** * Lazy load artist images for cards that don't have images yet. * Fetches images asynchronously so search results appear immediately. */ async function lazyLoadArtistImages(container) { if (!container) { console.error('โŒ lazyLoadArtistImages: container is null'); return; } // Find all cards that need images const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]'); if (cardsNeedingImages.length === 0) { console.log('โœ… All artist cards have images'); return; } console.log(`๐Ÿ–ผ๏ธ Lazy loading images for ${cardsNeedingImages.length} artist cards`); // Load images in parallel (but with a small batch to avoid overwhelming the server) const batchSize = 5; const cards = Array.from(cardsNeedingImages); for (let i = 0; i < cards.length; i += batchSize) { const batch = cards.slice(i, i + batchSize); await Promise.all(batch.map(async (card) => { const artistId = card.dataset.artistId; if (!artistId) { console.warn('โš ๏ธ Card missing artistId:', card); return; } try { console.log(`๐Ÿ”„ Fetching image for artist ${artistId}...`); const response = await fetch(`/api/artist/${artistId}/image`); const data = await response.json(); console.log(`๐Ÿ“ฅ Got response for ${artistId}:`, data); if (data.success && data.image_url) { // Update the card's background image // Handle both card types (suggestion-card and artist-card) if (card.classList.contains('suggestion-card')) { card.style.backgroundImage = `url(${data.image_url})`; card.style.backgroundSize = 'cover'; card.style.backgroundPosition = 'center'; } else if (card.classList.contains('artist-card')) { const bgElement = card.querySelector('.artist-card-background'); if (bgElement) { // Clear the gradient first, then set the image bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`; } } card.dataset.needsImage = 'false'; console.log(`โœ… Loaded image for artist ${artistId}`); } } catch (error) { console.error(`โŒ Failed to load image for artist ${artistId}:`, error); } })); } console.log('โœ… Finished lazy loading artist images'); } // Make function globally accessible window.lazyLoadArtistImages = lazyLoadArtistImages; /** * Create HTML for an artist card */ function createArtistCardHTML(artist) { const imageUrl = artist.image_url || ''; const genres = artist.genres && artist.genres.length > 0 ? artist.genres.slice(0, 3).join(', ') : 'Various genres'; const popularity = artist.popularity || 0; // Use data-bg-src for lazy background loading via IntersectionObserver const backgroundAttr = imageUrl ? `data-bg-src="${imageUrl}"` : `style="background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);"`; // Format popularity as a percentage for better UX const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown'; // Track if image needs to be lazy loaded const needsImage = imageUrl ? 'false' : 'true'; // Check for MusicBrainz ID let mbIconHTML = ''; if (artist.musicbrainz_id) { mbIconHTML = `
`; } return `
${mbIconHTML}
${escapeHtml(artist.name)}
${escapeHtml(genres)}
๐Ÿ”ฅ ${popularityText}
`; } /** * Select an artist and show their discography */ async function selectArtistForDetail(artist, options = {}) { console.log(`๐ŸŽค Selected artist: ${artist.name}`); // Cancel any ongoing completion check from previous artist if (artistCompletionController) { console.log('โน๏ธ Canceling previous artist completion check'); artistCompletionController.abort(); artistCompletionController = null; } // Cancel any ongoing similar artists stream from previous artist if (similarArtistsController) { console.log('โน๏ธ Canceling previous similar artists stream'); similarArtistsController.abort(); similarArtistsController = null; } // Update state artistsPageState.selectedArtist = artist; artistsPageState.currentView = 'detail'; artistsPageState.sourceOverride = options.source || artist.source || null; artistsPageState.pluginOverride = options.plugin || null; // Show detail state showArtistDetailState(); // Update artist info in header updateArtistDetailHeader(artist); // Load discography (pass artist name for cross-source fallback) await loadArtistDiscography(artist.id, artist.name, artistsPageState.sourceOverride, options.plugin); } /** * Load artist's discography from Spotify or iTunes * @param {string} artistId - Artist ID (Spotify or iTunes format) * @param {string} [artistName] - Optional artist name for fallback searches */ async function loadArtistDiscography(artistId, artistName = null, sourceOverride = null, pluginOverride = null) { console.log(`๐Ÿ’ฟ Loading discography for artist: ${artistId} (name: ${artistName}, source: ${sourceOverride || 'auto'})`); // Use source-prefixed cache key to avoid ID collisions between sources const cacheKey = sourceOverride ? `${sourceOverride}:${artistId}` : artistId; // Check cache first if (artistsPageState.cache.discography[cacheKey]) { console.log('๐Ÿ“ฆ Using cached discography'); const cachedDiscography = artistsPageState.cache.discography[cacheKey]; if (artistsPageState.selectedArtist) { artistsPageState.selectedArtist = { ...artistsPageState.selectedArtist, source: cachedDiscography.source || sourceOverride || artistsPageState.selectedArtist.source || null, }; } artistsPageState.sourceOverride = cachedDiscography.source || sourceOverride || artistsPageState.sourceOverride || null; displayArtistDiscography(cachedDiscography); // Load similar artists in parallel (don't wait) โ€” always uses primary source loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => { console.error('โŒ Error loading similar artists:', err); }); // Still check completion status for cached data await checkDiscographyCompletion(artistId, cachedDiscography); return; } try { // Show loading states showDiscographyLoading(); // Build URL with optional artist name and source override for fallback let url = `/api/artist/${artistId}/discography`; const params = new URLSearchParams(); if (artistName) params.set('artist_name', artistName); if (sourceOverride) params.set('source', sourceOverride); if (pluginOverride) params.set('plugin', pluginOverride); if (params.toString()) url += `?${params.toString()}`; // Call the real API endpoint const response = await fetch(url); if (!response.ok) { if (response.status === 401) { throw new Error('Spotify not authenticated. Please check your API settings.'); } throw new Error(`Failed to load discography: ${response.status}`); } const data = await response.json(); if (data.error) { throw new Error(data.error); } const discography = { albums: data.albums || [], singles: data.singles || [], source: data.source || sourceOverride || null, }; // Keep the resolved metadata source on the selected artist so album clicks // can pass it through to /api/album//tracks. if (artistsPageState.selectedArtist) { artistsPageState.selectedArtist = { ...artistsPageState.selectedArtist, source: discography.source, }; } artistsPageState.sourceOverride = discography.source || artistsPageState.sourceOverride || null; // Update selected artist with full details from backend (includes MusicBrainz ID) if (data.artist) { console.log('โœจ Updating artist details with fresh data from backend'); artistsPageState.selectedArtist = { ...artistsPageState.selectedArtist, ...data.artist }; } // Merge artist_info enrichment from discography response if (data.artist_info) { artistsPageState.selectedArtist = { ...artistsPageState.selectedArtist, artist_info: data.artist_info, }; } // Refresh header with all available data updateArtistDetailHeader(artistsPageState.selectedArtist); console.log(`โœ… Loaded ${discography.albums.length} albums and ${discography.singles.length} singles`); // Cache the results (use source-prefixed key if source override active) artistsPageState.cache.discography[cacheKey] = discography; artistsPageState.artistDiscography = discography; // Display results displayArtistDiscography(discography); // Load similar artists and check completion in parallel (don't wait) loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => { console.error('โŒ Error loading similar artists:', err); }); // Check completion status for all albums and singles await checkDiscographyCompletion(artistId, discography); } catch (error) { console.error('โŒ Failed to load discography:', error); showDiscographyError(error.message); } } /** * Display artist's discography in tabs */ function displayArtistDiscography(discography) { console.log(`๐Ÿ“€ Displaying discography: ${discography.albums?.length || 0} albums, ${discography.singles?.length || 0} singles`); // Show Download Discography button(s) if there are any releases const _totalReleases = (discography.albums?.length || 0) + (discography.eps?.length || 0) + (discography.singles?.length || 0); const _discogWrap = document.getElementById('discog-download-wrap'); if (_discogWrap) _discogWrap.style.display = _totalReleases > 0 ? '' : 'none'; const _discogBtnArtists = document.getElementById('discog-download-btn-artists'); if (_discogBtnArtists) _discogBtnArtists.style.display = _totalReleases > 0 ? '' : 'none'; // Populate albums const albumsContainer = document.getElementById('album-cards-container'); if (albumsContainer) { if (discography.albums?.length > 0) { albumsContainer.innerHTML = discography.albums.map(album => createAlbumCardHTML(album)).join(''); observeLazyBackgrounds(albumsContainer); // Add dynamic glow effects and click handlers to album cards albumsContainer.querySelectorAll('.album-card').forEach((card, index) => { const album = discography.albums[index]; if (album.image_url) { extractImageColors(album.image_url, (colors) => { applyDynamicGlow(card, colors); }); } // Add click handler for download missing tracks modal card.addEventListener('click', () => handleArtistAlbumClick(album, 'albums')); card.style.cursor = 'pointer'; }); } else { albumsContainer.innerHTML = `
๐Ÿ’ฟ
No albums found
`; } } // Populate singles const singlesContainer = document.getElementById('singles-cards-container'); if (singlesContainer) { if (discography.singles?.length > 0) { singlesContainer.innerHTML = discography.singles.map(single => createAlbumCardHTML(single)).join(''); observeLazyBackgrounds(singlesContainer); // Add dynamic glow effects and click handlers to singles cards singlesContainer.querySelectorAll('.album-card').forEach((card, index) => { const single = discography.singles[index]; if (single.image_url) { extractImageColors(single.image_url, (colors) => { applyDynamicGlow(card, colors); }); } // Add click handler for download missing tracks modal card.addEventListener('click', () => handleArtistAlbumClick(single, 'singles')); card.style.cursor = 'pointer'; }); } else { singlesContainer.innerHTML = `
๐ŸŽต
No singles or EPs found
`; } } // Auto-switch to Singles tab if no albums but has singles if ((!discography.albums || discography.albums.length === 0) && discography.singles && discography.singles.length > 0) { console.log('๐Ÿ“€ No albums found, auto-switching to Singles & EPs tab'); // Switch to singles tab const albumsTab = document.getElementById('albums-tab'); const singlesTab = document.getElementById('singles-tab'); const albumsContent = document.getElementById('albums-content'); const singlesContent = document.getElementById('singles-content'); if (albumsTab && singlesTab && albumsContent && singlesContent) { // Remove active from albums albumsTab.classList.remove('active'); albumsContent.classList.remove('active'); // Add active to singles singlesTab.classList.add('active'); singlesContent.classList.add('active'); } } } /** * Load similar artists from MusicMap */ async function loadSimilarArtists(artistName) { if (!artistName) { console.warn('โš ๏ธ No artist name provided for similar artists'); return; } console.log(`๐Ÿ” Loading similar artists for: ${artistName}`); // Get DOM elements const section = document.getElementById('similar-artists-section'); const loadingEl = document.getElementById('similar-artists-loading'); const errorEl = document.getElementById('similar-artists-error'); const container = document.getElementById('similar-artists-bubbles-container'); if (!section || !loadingEl || !errorEl || !container) { console.warn('โš ๏ธ Similar artists section elements not found'); return; } // Show loading state loadingEl.classList.remove('hidden'); errorEl.classList.add('hidden'); container.innerHTML = ''; section.style.display = 'block'; try { // Create new abort controller for this similar artists stream similarArtistsController = new AbortController(); // Use streaming endpoint for real-time bubble creation const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`; console.log(`๐Ÿ“ก Streaming from: ${url}`); const response = await fetch(url, { signal: similarArtistsController.signal }); if (!response.ok) { throw new Error(`Failed to fetch similar artists: ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let artistCount = 0; // Read the stream while (true) { const { done, value } = await reader.read(); if (done) { console.log('โœ… Stream complete'); break; } // Decode the chunk and add to buffer buffer += decoder.decode(value, { stream: true }); // Process complete messages (separated by \n\n) const messages = buffer.split('\n\n'); buffer = messages.pop() || ''; // Keep incomplete message in buffer for (const message of messages) { if (!message.trim() || !message.startsWith('data: ')) continue; try { const jsonData = JSON.parse(message.substring(6)); // Remove 'data: ' prefix if (jsonData.error) { throw new Error(jsonData.error); } if (jsonData.artist) { // Hide loading on first artist if (artistCount === 0) { loadingEl.classList.add('hidden'); } // Create and append bubble immediately const bubble = createSimilarArtistBubble(jsonData.artist); container.appendChild(bubble); artistCount++; console.log(`โœ… Added bubble for: ${jsonData.artist.name} (${artistCount})`); } if (jsonData.complete) { console.log(`๐ŸŽ‰ Streaming complete: ${jsonData.total} artists`); if (artistCount === 0) { loadingEl.classList.add('hidden'); container.innerHTML = `
๐ŸŽต
No similar artists found
`; } else { // Lazy load images for similar artists that don't have them lazyLoadSimilarArtistImages(container); } } } catch (parseError) { console.error('โŒ Error parsing stream message:', parseError); } } } // Clear the controller when done similarArtistsController = null; } catch (error) { // Don't show error if it was aborted (user navigated away) if (error.name === 'AbortError') { console.log('โน๏ธ Similar artists stream aborted (user navigated to new artist)'); loadingEl.classList.add('hidden'); return; } console.error('โŒ Error loading similar artists:', error); // Hide loading, show error loadingEl.classList.add('hidden'); errorEl.classList.remove('hidden'); // Also show error message in container container.innerHTML = `
โš ๏ธ
${error.message}
`; } finally { // Always clear the controller similarArtistsController = null; } } /** * Lazy load images for similar artist bubbles that don't have images */ async function lazyLoadSimilarArtistImages(container) { if (!container) return; const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]'); if (bubblesNeedingImages.length === 0) { console.log('โœ… All similar artist bubbles have images'); return; } console.log(`๐Ÿ–ผ๏ธ Lazy loading images for ${bubblesNeedingImages.length} similar artists`); // Load images in parallel batches const batchSize = 5; const bubbles = Array.from(bubblesNeedingImages); for (let i = 0; i < bubbles.length; i += batchSize) { const batch = bubbles.slice(i, i + batchSize); await Promise.all(batch.map(async (bubble) => { const artistId = bubble.getAttribute('data-artist-id'); const artistSource = bubble.getAttribute('data-artist-source') || ''; const artistPlugin = bubble.getAttribute('data-artist-plugin') || ''; if (!artistId) return; try { const params = new URLSearchParams(); if (artistSource) params.set('source', artistSource); if (artistPlugin) params.set('plugin', artistPlugin); const imageUrl = params.toString() ? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}` : `/api/artist/${encodeURIComponent(artistId)}/image`; const response = await fetch(imageUrl); const data = await response.json(); if (data.success && data.image_url) { const imageContainer = bubble.querySelector('.similar-artist-bubble-image'); if (imageContainer) { const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist'; imageContainer.innerHTML = `${artistName}`; bubble.setAttribute('data-needs-image', 'false'); console.log(`โœ… Loaded image for similar artist ${artistId}`); } } } catch (error) { console.warn(`โš ๏ธ Failed to load image for similar artist ${artistId}:`, error); } })); } console.log('โœ… Finished lazy loading similar artist images'); } /** * Display similar artist bubble cards progressively (one at a time with delay) */ function displaySimilarArtistsProgressively(artists) { const container = document.getElementById('similar-artists-bubbles-container'); if (!container) { console.warn('โš ๏ธ Similar artists container not found'); return; } // Clear container container.innerHTML = ''; // Add each bubble with a delay to simulate progressive loading artists.forEach((artist, index) => { setTimeout(() => { const bubble = createSimilarArtistBubble(artist); container.appendChild(bubble); }, index * 100); // 100ms delay between each bubble }); console.log(`โœ… Displaying ${artists.length} similar artist bubbles progressively`); } /** * Display similar artist bubble cards (all at once - legacy) */ function displaySimilarArtists(artists) { const container = document.getElementById('similar-artists-bubbles-container'); if (!container) { console.warn('โš ๏ธ Similar artists container not found'); return; } // Clear container container.innerHTML = ''; // Create bubble cards with staggered animation artists.forEach((artist, index) => { const bubble = createSimilarArtistBubble(artist); // Add staggered animation delay (50ms per bubble) bubble.style.animationDelay = `${index * 0.05}s`; container.appendChild(bubble); }); console.log(`โœ… Displayed ${artists.length} similar artist bubbles`); } /** * Create a similar artist bubble card element */ function createSimilarArtistBubble(artist) { // Create bubble container const bubble = document.createElement('div'); bubble.className = 'similar-artist-bubble'; bubble.setAttribute('data-artist-id', artist.id); bubble.setAttribute('data-artist-source', artist.source || ''); if (artist.plugin) { bubble.setAttribute('data-artist-plugin', artist.plugin); } // Track if image needs lazy loading const hasImage = artist.image_url && artist.image_url.trim() !== ''; bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true'); // Create image container const imageContainer = document.createElement('div'); imageContainer.className = 'similar-artist-bubble-image'; if (hasImage) { const img = document.createElement('img'); img.src = artist.image_url; img.alt = artist.name; // Handle image load error img.onerror = () => { console.log(`Failed to load image for ${artist.name}`); imageContainer.innerHTML = `
๐ŸŽต
`; bubble.setAttribute('data-needs-image', 'true'); }; imageContainer.appendChild(img); } else { // No image - show fallback (will be lazy loaded) imageContainer.innerHTML = `
๐ŸŽต
`; } // Create name element const name = document.createElement('div'); name.className = 'similar-artist-bubble-name'; name.textContent = artist.name; name.title = artist.name; // Tooltip for full name // Optional: Create genres element (hidden by default in CSS) const genres = document.createElement('div'); genres.className = 'similar-artist-bubble-genres'; if (artist.genres && artist.genres.length > 0) { genres.textContent = artist.genres.slice(0, 2).join(', '); } // Assemble bubble bubble.appendChild(imageContainer); bubble.appendChild(name); if (artist.genres && artist.genres.length > 0) { bubble.appendChild(genres); } // Add click handler to navigate to artist detail page bubble.addEventListener('click', () => { console.log(`๐ŸŽต Clicked similar artist: ${artist.name} (ID: ${artist.id})`); // Navigate to this artist's detail page (same as clicking from search results) selectArtistForDetail( artist, artist.source ? { source: artist.source, plugin: artist.plugin } : {} ); }); return bubble; } /** * Restore cached completion data without re-scanning the database */ function restoreCachedCompletionData(artistId) { console.log(`๐Ÿ“ฆ Restoring cached completion data for artist: ${artistId}`); const cachedData = artistsPageState.cache.completionData[artistId]; if (!cachedData) { console.log('โš ๏ธ No cached completion data found, skipping restoration'); return; } // Restore album completion overlays if (cachedData.albums) { cachedData.albums.forEach(albumCompletion => { updateAlbumCompletionOverlay(albumCompletion, 'albums'); }); console.log(`โœ… Restored ${cachedData.albums.length} album completion overlays`); } // Restore singles completion overlays if (cachedData.singles) { cachedData.singles.forEach(singleCompletion => { updateAlbumCompletionOverlay(singleCompletion, 'singles'); }); console.log(`โœ… Restored ${cachedData.singles.length} single completion overlays`); } } /** * 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 */ function showCompletionError() { const allOverlays = document.querySelectorAll('.completion-overlay.checking'); allOverlays.forEach(overlay => { overlay.classList.remove('checking'); overlay.classList.add('error'); overlay.innerHTML = 'Error'; overlay.title = 'Failed to check completion status'; }); } /** * Create HTML for an album/single card */ function createAlbumCardHTML(album) { const imageUrl = album.image_url || ''; const year = album.release_date ? new Date(album.release_date).getFullYear() : ''; const type = album.album_type === 'album' ? 'Album' : album.album_type === 'single' ? 'Single' : 'EP'; // Use data-bg-src for lazy background loading via IntersectionObserver const backgroundAttr = imageUrl ? `data-bg-src="${imageUrl}"` : `style="background: linear-gradient(135deg, rgba(29, 185, 84, 0.2) 0%, rgba(24, 156, 71, 0.1) 100%);"`; return `
Checking...
${escapeHtml(album.name)}
${year || 'Unknown'}
${type}
`; } /** * Initialize artist detail tabs */ function initializeArtistTabs() { const tabButtons = document.querySelectorAll('.artist-tab'); const tabContents = document.querySelectorAll('.tab-content'); tabButtons.forEach(button => { button.addEventListener('click', () => { const tabName = button.getAttribute('data-tab'); // Update button states tabButtons.forEach(btn => btn.classList.remove('active')); button.classList.add('active'); // Update content states tabContents.forEach(content => { content.classList.remove('active'); if (content.id === `${tabName}-content`) { content.classList.add('active'); } }); console.log(`๐Ÿ”„ Switched to ${tabName} tab`); }); }); } /** * State management functions */ function showArtistsSearchState() { console.log('๐Ÿ”„ Showing search state'); // Cancel any ongoing completion check when navigating back to search if (artistCompletionController) { console.log('โน๏ธ Canceling completion check (navigating back to search)'); artistCompletionController.abort(); artistCompletionController = null; } // Cancel any ongoing similar artists stream when navigating back to search if (similarArtistsController) { console.log('โน๏ธ Canceling similar artists stream (navigating back to search)'); similarArtistsController.abort(); similarArtistsController = null; } const searchState = document.getElementById('artists-search-state'); const resultsState = document.getElementById('artists-results-state'); const detailState = document.getElementById('artist-detail-state'); if (searchState) { searchState.classList.remove('hidden', 'fade-out'); } if (resultsState) { resultsState.classList.add('hidden'); resultsState.classList.remove('show'); } if (detailState) { detailState.classList.add('hidden'); detailState.classList.remove('show'); } artistsPageState.currentView = 'search'; updateArtistsSearchStatus('default'); // Show artist downloads section if there are active downloads showArtistDownloadsSection(); } function showArtistsResultsState() { console.log('๐Ÿ”„ Showing results state'); // Cancel any ongoing completion check when navigating back if (artistCompletionController) { console.log('โน๏ธ Canceling completion check (navigating back to results)'); artistCompletionController.abort(); artistCompletionController = null; } // Cancel any ongoing similar artists stream when navigating back if (similarArtistsController) { console.log('โน๏ธ Canceling similar artists stream (navigating back to results)'); similarArtistsController.abort(); similarArtistsController = null; } // Clear artist-specific data when navigating back to results // This ensures that selecting the same artist again will trigger a fresh scan if (artistsPageState.selectedArtist) { const artistId = artistsPageState.selectedArtist.id; console.log(`๐Ÿ—‘๏ธ Clearing cached data for artist: ${artistsPageState.selectedArtist.name}`); // Clear artist-specific cache data delete artistsPageState.cache.completionData[artistId]; delete artistsPageState.cache.discography[artistId]; // Clear artist state artistsPageState.selectedArtist = null; artistsPageState.artistDiscography = { albums: [], singles: [] }; } const searchState = document.getElementById('artists-search-state'); const resultsState = document.getElementById('artists-results-state'); const detailState = document.getElementById('artist-detail-state'); if (searchState) { searchState.classList.add('fade-out'); setTimeout(() => searchState.classList.add('hidden'), 200); } if (resultsState) { resultsState.classList.remove('hidden'); setTimeout(() => resultsState.classList.add('show'), 50); } if (detailState) { detailState.classList.add('hidden'); detailState.classList.remove('show'); } artistsPageState.currentView = 'results'; } function showArtistDetailState() { console.log('๐Ÿ”„ Showing detail state'); const searchState = document.getElementById('artists-search-state'); const resultsState = document.getElementById('artists-results-state'); const detailState = document.getElementById('artist-detail-state'); if (searchState) { searchState.classList.add('hidden', 'fade-out'); } if (resultsState) { resultsState.classList.add('hidden'); resultsState.classList.remove('show'); } if (detailState) { detailState.classList.remove('hidden'); setTimeout(() => detailState.classList.add('show'), 50); } artistsPageState.currentView = 'detail'; } /** * Update search status text and styling */ function updateArtistsSearchStatus(status, message = null) { const statusElement = document.getElementById('artists-search-status'); if (!statusElement) return; // Clear all status classes statusElement.classList.remove('searching', 'error'); switch (status) { case 'default': statusElement.textContent = 'Start typing to search for artists'; break; case 'searching': statusElement.classList.add('searching'); statusElement.textContent = 'Searching for artists...'; break; case 'error': statusElement.classList.add('error'); statusElement.innerHTML = `
${message || 'Search failed. Please try again.'}
`; break; } } /** * Retry the last search query */ function retryLastSearch() { const searchInput = document.getElementById('artists-search-input'); const headerSearchInput = document.getElementById('artists-header-search-input'); // Get the last search query from either input const query = searchInput?.value?.trim() || headerSearchInput?.value?.trim() || artistsPageState.searchQuery; if (query) { console.log(`๐Ÿ”„ Retrying search for: "${query}"`); performArtistsSearch(query); } } /** * Update artist detail header with artist info */ function updateArtistDetailHeader(artist) { const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const info = artist.artist_info || {}; const imageUrl = artist.image_url || info.image_url || ''; // Background blur const heroBg = document.getElementById('artists-hero-bg'); if (heroBg) { heroBg.style.backgroundImage = imageUrl ? `url('${imageUrl}')` : 'none'; } // Artist image const heroImage = document.getElementById('artists-hero-image'); if (heroImage) { if (imageUrl) { heroImage.style.backgroundImage = `url('${imageUrl}')`; heroImage.innerHTML = ''; } else { heroImage.style.backgroundImage = 'none'; heroImage.innerHTML = '๐ŸŽค'; // Lazy load fetch(`/api/artist/${artist.id}/image`) .then(r => r.json()) .then(d => { if (d.success && d.image_url) { heroImage.style.backgroundImage = `url('${d.image_url}')`; heroImage.innerHTML = ''; if (heroBg) heroBg.style.backgroundImage = `url('${d.image_url}')`; artist.image_url = d.image_url; } }).catch(() => { }); } } // Name const heroName = document.getElementById('artists-hero-name'); if (heroName) heroName.textContent = artist.name || 'Unknown Artist'; // Badges (service links โ€” real logos matching library page) const badgesEl = document.getElementById('artists-hero-badges'); if (badgesEl) { const _hb = (logo, fallback, title, url) => { const inner = logo ? `${fallback}` : `${fallback}`; if (url) return `${inner}`; return `
${inner}
`; }; const badges = []; if (info.spotify_artist_id) badges.push(_hb(SPOTIFY_LOGO_URL, 'SP', 'Spotify', `https://open.spotify.com/artist/${info.spotify_artist_id}`)); if (info.musicbrainz_id || artist.musicbrainz_id) badges.push(_hb(MUSICBRAINZ_LOGO_URL, 'MB', 'MusicBrainz', `https://musicbrainz.org/artist/${info.musicbrainz_id || artist.musicbrainz_id}`)); if (info.deezer_id) badges.push(_hb(DEEZER_LOGO_URL, 'Dz', 'Deezer', `https://www.deezer.com/artist/${info.deezer_id}`)); if (info.itunes_artist_id) badges.push(_hb(ITUNES_LOGO_URL, 'IT', 'Apple Music', `https://music.apple.com/artist/${info.itunes_artist_id}`)); if (info.lastfm_url) badges.push(_hb(LASTFM_LOGO_URL, 'LFM', 'Last.fm', info.lastfm_url)); if (info.genius_url) badges.push(_hb(GENIUS_LOGO_URL, 'GEN', 'Genius', info.genius_url)); if (info.tidal_id) badges.push(_hb(TIDAL_LOGO_URL, 'TD', 'Tidal', `https://tidal.com/browse/artist/${info.tidal_id}`)); if (info.qobuz_id) badges.push(_hb(QOBUZ_LOGO_URL, 'Qz', 'Qobuz', `https://www.qobuz.com/artist/${info.qobuz_id}`)); if (info.discogs_id) badges.push(_hb(DISCOGS_LOGO_URL, 'DC', 'Discogs', `https://www.discogs.com/artist/${info.discogs_id}`)); badgesEl.innerHTML = badges.join(''); } // Genres (pill tags โ€” merge with Last.fm tags, deduplicated) const genresEl = document.getElementById('artists-hero-genres'); if (genresEl) { let genres = info.genres || artist.genres || []; // Merge Last.fm tags const lfmTags = info.lastfm_tags || []; if (Array.isArray(lfmTags) && lfmTags.length > 0) { const existing = new Set(genres.map(g => g.toLowerCase())); const newTags = lfmTags.filter(t => !existing.has(t.toLowerCase())); genres = [...genres, ...newTags]; } if (genres.length > 0) { genresEl.innerHTML = genres.slice(0, 8).map(g => `${_esc(g)}` ).join(''); } else { genresEl.innerHTML = ''; } } // Bio (Last.fm bio or summary fallback โ€” matching library page pattern) const bioEl = document.getElementById('artists-hero-bio'); if (bioEl) { const bio = info.lastfm_bio || info.bio || ''; if (bio) { // Strip HTML tags and "Read more on Last.fm" links let cleanBio = bio.replace(/]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim(); if (cleanBio) { bioEl.innerHTML = `${_esc(cleanBio)} Read more`; bioEl.style.display = ''; } else { bioEl.style.display = 'none'; } } else { bioEl.style.display = 'none'; } } // Stats (Last.fm listeners + playcount, with followers fallback) const statsEl = document.getElementById('artists-hero-stats'); if (statsEl) { const _fmtNum = (n) => { if (!n || n <= 0) return '0'; if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; return n.toLocaleString(); }; let stats = ''; if (info.lastfm_listeners) { stats += `${_fmtNum(info.lastfm_listeners)} listeners`; } if (info.lastfm_playcount) { stats += `${_fmtNum(info.lastfm_playcount)} plays`; } if (!stats && info.followers) { stats += `${_fmtNum(info.followers)} followers`; } statsEl.innerHTML = stats; } // Also update old hidden elements for any JS that references them const oldImage = document.getElementById('search-artist-detail-image'); if (oldImage && imageUrl) oldImage.style.backgroundImage = `url('${imageUrl}')`; const oldName = document.getElementById('search-artist-detail-name'); if (oldName) oldName.textContent = artist.name; // Initialize watchlist button initializeArtistDetailWatchlistButton(artist); } /** * Initialize watchlist button for artist detail page */ async function initializeArtistDetailWatchlistButton(artist) { const button = document.getElementById('artist-detail-watchlist-btn'); if (!button) return; console.log(`๐Ÿ”ง Initializing watchlist button for artist: ${artist.name} (${artist.id})`); // Store artist info on the button for settings gear access button.dataset.artistId = artist.id; button.dataset.artistName = artist.name; // Reset button state completely button.disabled = false; button.classList.remove('watching'); button.style.background = ''; button.style.cursor = ''; // Remove any existing click handlers to prevent duplicates button.onclick = null; // Set up new click handler button.onclick = (event) => toggleArtistDetailWatchlist(event, artist.id, artist.name); // Check and update current status await updateArtistDetailWatchlistButton(artist.id, artist.name); } /** * Toggle watchlist status for artist detail page */ async function toggleArtistDetailWatchlist(event, artistId, artistName) { event.preventDefault(); const button = document.getElementById('artist-detail-watchlist-btn'); const icon = button.querySelector('.watchlist-icon'); const text = button.querySelector('.watchlist-text'); // Show loading state const originalText = text.textContent; text.textContent = 'Loading...'; button.disabled = true; try { // Check current status const checkResponse = await fetch('/api/watchlist/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ artist_id: artistId }) }); const checkData = await checkResponse.json(); if (!checkData.success) { throw new Error(checkData.error || 'Failed to check watchlist status'); } const isWatching = checkData.is_watching; // Toggle watchlist status const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add'; const payload = isWatching ? { artist_id: artistId } : { artist_id: artistId, artist_name: artistName }; const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to update watchlist'); } // Update button appearance if (isWatching) { // Was watching, now removed icon.textContent = '๐Ÿ‘๏ธ'; text.textContent = 'Add to Watchlist'; button.classList.remove('watching'); console.log(`โŒ Removed ${artistName} from watchlist`); } else { // Was not watching, now added icon.textContent = '๐Ÿ‘๏ธ'; text.textContent = 'Remove from Watchlist'; button.classList.add('watching'); console.log(`โœ… Added ${artistName} to watchlist`); } // Show/hide watchlist settings gear const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn'); if (settingsBtn) { if (!isWatching) { // Just added to watchlist โ€” show gear settingsBtn.classList.remove('hidden'); settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, artistName); } else { // Just removed from watchlist โ€” hide gear settingsBtn.classList.add('hidden'); settingsBtn.onclick = null; } } // Update dashboard watchlist count updateWatchlistButtonCount(); // Update any visible artist cards updateArtistCardWatchlistStatus(); } catch (error) { console.error('Error toggling watchlist:', error); text.textContent = originalText; // Show error feedback const originalBackground = button.style.background; button.style.background = 'rgba(255, 59, 48, 0.3)'; setTimeout(() => { button.style.background = originalBackground; }, 2000); } finally { button.disabled = false; } } /** * Update artist detail watchlist button status */ async function updateArtistDetailWatchlistButton(artistId, artistName) { const button = document.getElementById('artist-detail-watchlist-btn'); if (!button) { console.warn('โš ๏ธ Artist detail watchlist button not found'); return; } // Use passed name or fall back to stored data attribute const name = artistName || button.dataset.artistName || ''; try { console.log(`๐Ÿ” Checking watchlist status for artist: ${artistId}`); const response = await fetch('/api/watchlist/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ artist_id: artistId }) }); const data = await response.json(); if (data.success) { const icon = button.querySelector('.watchlist-icon'); const text = button.querySelector('.watchlist-text'); console.log(`๐Ÿ“Š Watchlist status for ${artistId}: ${data.is_watching ? 'WATCHING' : 'NOT WATCHING'}`); // Ensure button is enabled button.disabled = false; // Show/hide watchlist settings gear const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn'); if (settingsBtn) { if (data.is_watching) { settingsBtn.classList.remove('hidden'); settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, name); } else { settingsBtn.classList.add('hidden'); settingsBtn.onclick = null; } } if (data.is_watching) { icon.textContent = '๐Ÿ‘๏ธ'; text.textContent = 'Remove from Watchlist'; button.classList.add('watching'); } else { icon.textContent = '๐Ÿ‘๏ธ'; text.textContent = 'Add to Watchlist'; button.classList.remove('watching'); } } else { console.error('โŒ Failed to check watchlist status:', data.error); } } catch (error) { console.error('โŒ Error checking watchlist status:', error); // Ensure button doesn't get stuck in bad state button.disabled = false; } } /** * Show loading state for discography */ function showDiscographyLoading() { const albumsContainer = document.getElementById('album-cards-container'); const singlesContainer = document.getElementById('singles-cards-container'); const loadingHtml = `
Loading...
-
-
`.repeat(4); if (albumsContainer) albumsContainer.innerHTML = loadingHtml; if (singlesContainer) singlesContainer.innerHTML = loadingHtml; } /** * Show error state for discography */ function showDiscographyError(message = 'Failed to load discography') { const albumsContainer = document.getElementById('album-cards-container'); const singlesContainer = document.getElementById('singles-cards-container'); const errorHtml = `
โš ๏ธ
Failed to load discography
${escapeHtml(message)}
`; if (albumsContainer) albumsContainer.innerHTML = errorHtml; if (singlesContainer) singlesContainer.innerHTML = errorHtml; } /** * Show loading cards while searching */ function showSearchLoadingCards() { const container = document.getElementById('artists-cards-container'); if (!container) return; const loadingCardHtml = `
Loading...
Fetching data...
โณ Loading...
`; // Show 6 loading cards container.innerHTML = loadingCardHtml.repeat(6); } // =============================== // ARTIST ALBUM DOWNLOAD MISSING TRACKS INTEGRATION // =============================== /** * Get the completion status of an album from cached data or DOM * @param {string} albumId - The album ID * @param {string} albumType - The album type ('albums' or 'singles') * @returns {Object|null} - Completion status object or null */ function getAlbumCompletionStatus(albumId, albumType) { try { // First, check cached completion data const artistId = artistsPageState.selectedArtist?.id; if (artistId && artistsPageState.cache.completionData[artistId]) { const cachedData = artistsPageState.cache.completionData[artistId]; const dataArray = albumType === 'albums' ? cachedData.albums : cachedData.singles; if (dataArray) { const completionData = dataArray.find(item => item.album_id === albumId || item.id === albumId); if (completionData) { console.log(`๐Ÿ“Š Found cached completion data for album ${albumId}:`, completionData); return completionData; } } } // Fallback: Check DOM completion overlay const containerId = albumType === 'albums' ? 'album-cards-container' : 'singles-cards-container'; const container = document.getElementById(containerId); if (container) { const albumCard = container.querySelector(`[data-album-id="${albumId}"]`); if (albumCard) { const overlay = albumCard.querySelector('.completion-overlay'); if (overlay) { // Extract status from overlay classes const classList = Array.from(overlay.classList); const statusClasses = ['completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error']; const status = statusClasses.find(cls => classList.includes(cls)); if (status) { console.log(`๐Ÿ“Š Found DOM completion status for album ${albumId}: ${status}`); return { status, completion_percentage: status === 'completed' ? 100 : 0 }; } } } } console.warn(`โš ๏ธ No completion status found for album ${albumId}`); return null; } catch (error) { console.error(`โŒ Error getting album completion status for ${albumId}:`, error); return null; } } /** * Handle album/single/EP click to open download missing tracks modal */ async function handleArtistAlbumClick(album, albumType) { console.log(`๐ŸŽต Album clicked: ${album.name} (${album.album_type}) from artist: ${artistsPageState.selectedArtist?.name}`); if (!artistsPageState.selectedArtist) { console.error('โŒ No selected artist found'); showToast('Error: No artist selected', 'error'); return; } showLoadingOverlay('Loading album...'); try { // Check completion status of the album const completionStatus = getAlbumCompletionStatus(album.id, albumType); console.log(`๐Ÿ“Š Album completion status: ${completionStatus?.status || 'unknown'} (${completionStatus?.completion_percentage || 0}%)`); // For Artists page, always use Download Missing Tracks modal to analyze and download console.log(`๐Ÿ”„ Opening download missing tracks modal for album analysis`); // Create virtual playlist ID const virtualPlaylistId = `artist_album_${artistsPageState.selectedArtist.id}_${album.id}`; // Check if modal already exists and show it if (activeDownloadProcesses[virtualPlaylistId]) { console.log(`๐Ÿ“ฑ Reopening existing modal for ${album.name}`); 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'; hideLoadingOverlay(); return; } } // Create virtual playlist and open modal // Note: Don't hide loading overlay here - let the flow continue through to the modal await createArtistAlbumVirtualPlaylist(album, albumType); } catch (error) { hideLoadingOverlay(); console.error('โŒ Error handling album click:', error); showToast(`Error opening download modal: ${error.message}`, 'error'); } } /** * Create virtual playlist for artist album and open download missing tracks modal */ async function createArtistAlbumVirtualPlaylist(album, albumType) { const artist = artistsPageState.selectedArtist; const virtualPlaylistId = `artist_album_${artist.id}_${album.id}`; console.log(`๐ŸŽต Creating virtual playlist for: ${artist.name} - ${album.name}`); try { // Loading overlay already shown by handleArtistAlbumClick // Fetch album tracks from backend (pass name/artist for Hydrabase support) const _aat1 = new URLSearchParams({ name: album.name || '', artist: artist.name || '' }); const albumSource = artistsPageState.sourceOverride || album.source || artist.source || artistsPageState.artistDiscography?.source || null; if (albumSource) { _aat1.set('source', albumSource); } if (artistsPageState.pluginOverride) { _aat1.set('plugin', artistsPageState.pluginOverride); } const response = await fetch(`/api/album/${album.id}/tracks?${_aat1}`); if (!response.ok) { if (response.status === 401) { throw new Error('Spotify not authenticated. Please check your API settings.'); } const errData = await response.json().catch(() => ({})); throw new Error(errData.error || `Failed to load album tracks: ${response.status}`); } const data = await response.json(); if (!data.success || !data.tracks || data.tracks.length === 0) { throw new Error('No tracks found for this album'); } console.log(`โœ… Loaded ${data.tracks.length} tracks for ${data.album.name}`); // Use album data from API response (has complete data including images array) const fullAlbumData = data.album; // Format playlist name with artist and album info const playlistName = `[${artist.name}] ${fullAlbumData.name}`; // Open download missing tracks modal with formatted tracks // Pass false for showLoadingOverlay since we already have one from handleArtistAlbumClick // Use fullAlbumData from API response instead of album parameter await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, data.tracks, fullAlbumData, artist, false); // Track this download for artist bubble management registerArtistDownload(artist, album, virtualPlaylistId, albumType); } catch (error) { console.error('โŒ Error creating virtual playlist:', error); showToast(`Failed to load album: ${error.message}`, 'error'); throw error; } } /** * 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 Name Artist(s) Duration Library Status Download Status Actions
${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(''); } // ===============================