// SHARED HELPERS // ============================================================================ // General-purpose helpers extracted from artists.js. These functions are used // across discover.js, api-monitor.js, library.js, enrichment.js, wishlist- // tools.js and others โ€” they have no conceptual home in the old Artists page // file. Moved here so artists.js can be deleted once the inline Artists page // is fully retired. // // Load order: this file must load AFTER core.js (uses artistsPageState, // artistDownloadBubbles, searchDownloadBubbles, beatportDownloadBubbles // globals declared there) and BEFORE any file that calls these functions. // ============================================================================ // ---------------------------------------------------------------------------- // Enhanced search shared utilities (used by Search page + global widget) // ---------------------------------------------------------------------------- // Pass source to restrict results to a single metadata provider; omit or pass // null/'auto' to let the backend fan out across all configured sources. async function enhancedSearchFetch(query, { source = null, signal = null } = {}) { const body = { query }; if (source && source !== 'auto') body.source = source; const res = await fetch('/api/enhanced-search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: signal || undefined, }); if (!res.ok) throw new Error(`Enhanced search failed: ${res.status}`); return res.json(); } // Per-source labels + tab/badge CSS classes + icon glyph for the source // picker row. The `logo` URL (when present) renders as an in the // source-picker chip; `icon` stays as the emoji fallback for sources // without a canonical logo. Logo URLs mirror the constants in core.js so // both places stay in sync. const SOURCE_LABELS = { spotify: { text: 'Spotify', icon: '๐ŸŽต', logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify', }, itunes: { text: 'Apple Music', icon: '๐ŸŽ', logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes', }, deezer: { text: 'Deezer', icon: '๐ŸŽถ', logo: 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer', }, discogs: { text: 'Discogs', icon: '๐Ÿ“€', logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6b/Discogs_icon.svg/960px-Discogs_icon.svg.png', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs', }, hydrabase: { text: 'Hydrabase', icon: '๐Ÿ’Ž', logo: '/static/hydrabase.png', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase', }, musicbrainz: { text: 'MusicBrainz', icon: '๐Ÿง ', logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png', tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz', }, youtube_videos: { text: 'Music Videos', icon: '๐ŸŽฌ', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube', }, soulseek: { // Routes through /api/search (raw slskd file results) โ€” historically // called "Basic Search" in the UI before the source picker landed. text: 'Basic Search', icon: '๐ŸŽผ', tabClass: 'enh-tab-soulseek', badgeClass: 'enh-badge-soulseek', }, }; // Canonical display order for the source picker. Standard metadata sources // first, then YouTube Music Videos, then Soulseek (basic-file source). const SOURCE_ORDER = [ 'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz', 'youtube_videos', 'soulseek', ]; // Sources the config-status endpoint doesn't cover because they don't need // user-supplied credentials โ€” they always render as "configured" in the picker. // Soulseek IS configurable (needs slskd URL), so it's intentionally not here: // /api/settings/config-status reports its real state and the picker dims it // when no slskd is set up, redirecting clicks to Settings โ†’ Downloads. const _ALWAYS_CONFIGURED_SOURCES = new Set(['musicbrainz', 'youtube_videos']); // Fetch /api/settings/config-status and return a map { src -> bool } // covering every source in SOURCE_ORDER. Sources not present in the backend // registry (musicbrainz / youtube_videos / soulseek) are reported as // configured so the picker doesn't dim always-available sources. async function fetchSourceConfiguredMap() { const map = {}; try { const resp = await fetch('/api/settings/config-status'); if (resp.ok) { const data = await resp.json(); for (const src of SOURCE_ORDER) { if (_ALWAYS_CONFIGURED_SOURCES.has(src)) { map[src] = true; } else { map[src] = !!(data[src] && data[src].configured); } } return map; } } catch (_) { /* fall through to conservative default */ } // Network / endpoint failure โ€” be permissive rather than dim everything. for (const src of SOURCE_ORDER) map[src] = true; return map; } // Shared source-picker controller used by both the unified Search page // and the global search widget. Owns all the query/active-source/per-query // cache state, fetch dispatch (enhanced-search for standard sources, NDJSON // for YouTube Music Videos), configured-source discovery, fallback tracking, // and icon-row rendering. Each surface passes per-surface wiring โ€” DOM // elements, a CSS class prefix, and callbacks โ€” and the controller takes // care of the rest. // // Config: // sourceRowElement โ€” HTMLElement where the icon row is rendered // iconClassPrefix โ€” 'enh' or 'gsearch' (drives CSS class names) // onStateChange(state) โ€” called whenever the surface should re-render // results (cache hit, fetch settle, query reset) // onSoulseekSelected(q) โ€” surface decides what happens when the user // clicks the Soulseek icon (basic-section swap // on the Search page, /search handoff on the // global widget) // onUnconfiguredClick(src)โ€” override the default "open Settings" behaviour // // Returned methods: // init() โ€” async; reads /api/settings + /api/settings/ // config-status, seeds default source, falls // forward if primary is unconfigured, draws row // submitQuery(query) โ€” user typed a new query (clears cache on change) // setActiveSource(src) โ€” user clicked a different source icon // renderSourceRow() โ€” re-draws the icon row (call after state edits) function createSearchController({ sourceRowElement, iconClassPrefix = 'enh', onStateChange, onSoulseekSelected, onUnconfiguredClick, } = {}) { const iconClass = `${iconClassPrefix}-source-icon`; const glyphClass = `${iconClassPrefix}-source-icon-glyph`; const labelClass = `${iconClassPrefix}-source-icon-label`; // Per-query cache. `sources[src]` holds the result payload the last // time `src` was fetched for the current query. `fallbacks[src]` // records the source the backend actually served when it auto-fell- // back (e.g. user clicked Spotify but got Deezer because Spotify is // rate-limited). `loadingSources` drives per-icon spinners. The whole // cache is cleared whenever the query string changes โ€” we never // serve stale results across queries. const state = { query: '', activeSource: 'spotify', sources: {}, fallbacks: {}, loadingSources: new Set(), configuredSources: {}, _initialized: false, }; // Optimistic default โ€” replaced by the real config-status lookup on // init. Prevents a flash of "all unconfigured" icons. for (const src of SOURCE_ORDER) state.configuredSources[src] = true; let abortCtrl = null; // Per-source request tokens. Each _fetchSource call increments the // monotonic _requestSeq and stamps it into _sourceRequestIds[src]. // Settle/error blocks bail before mutating shared state if their // requestId no longer matches the latest id for THAT source โ€” // protecting against the fast-retype race (same-source supersession) // without dropping cleanup for cross-source supersession. // // A single global token would mishandle cross-source: switching // Spotify โ†’ Deezer aborts Spotify's fetch, but Spotify's catch needs // to clear 'spotify' from loadingSources (Deezer's request hasn't // touched it). Per-source tracking lets each source's catch own its // own loadingSources entry. let _requestSeq = 0; const _sourceRequestIds = Object.create(null); function _notify() { if (onStateChange) onStateChange(state); } function renderSourceRow() { if (!sourceRowElement) return; sourceRowElement.innerHTML = SOURCE_ORDER.map(src => { const info = SOURCE_LABELS[src]; if (!info) return ''; const active = src === state.activeSource; const cached = !!state.sources[src]; const loading = state.loadingSources.has(src); const fallback = state.fallbacks[src]; const configured = state.configuredSources[src] !== false; const classes = [ iconClass, active ? 'active' : '', cached ? 'cached' : '', loading ? 'loading' : '', fallback ? 'fallback-warning' : '', configured ? '' : 'unconfigured', ].filter(Boolean).join(' '); let title; if (!configured) { title = `${info.text} โ€” set up in Settings`; } else if (fallback) { title = `${info.text} unavailable โ€” served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`; } else { title = info.text; } const glyph = loading ? 'โณ' : (info.logo ? `` : info.icon); return ` `; }).join(''); sourceRowElement.querySelectorAll(`.${iconClass}`).forEach(btn => { btn.addEventListener('click', (e) => { // stopPropagation prevents surface-level outside-click handlers // from dismissing the results while we re-render the icon row // (which detaches the clicked button from the DOM). e.stopPropagation(); setActiveSource(btn.dataset.source); }); }); } async function init() { if (state._initialized) return; state._initialized = true; // Resolve the user's configured primary source. try { const resp = await fetch('/api/settings'); if (resp.ok) { const settings = await resp.json(); const cfg = settings.metadata && settings.metadata.fallback_source; if (cfg && SOURCE_LABELS[cfg]) state.activeSource = cfg; } } catch (_) { /* best-effort */ } if (!SOURCE_LABELS[state.activeSource]) state.activeSource = 'spotify'; // Figure out which sources actually have credentials saved. try { state.configuredSources = await fetchSourceConfiguredMap(); } catch (_) { /* keep optimistic default */ } // If the configured primary is itself unconfigured (Spotify saved // as primary but no client_id yet), fall forward to the first // configured source so the default active icon is usable. if (state.configuredSources[state.activeSource] === false) { const firstConfigured = SOURCE_ORDER.find(s => state.configuredSources[s] !== false); if (firstConfigured) state.activeSource = firstConfigured; } renderSourceRow(); _notify(); } function setActiveSource(src) { if (!SOURCE_LABELS[src]) return; // Unconfigured โ€” jump to the relevant card in Settings rather than // firing a search that can't succeed. Don't swap activeSource so the // user's previous pick stays current when they come back. if (state.configuredSources[src] === false) { if (onUnconfiguredClick) onUnconfiguredClick(src); else openSettingsForSource(src); return; } // Clicking the already-active source is a no-op for normal sources, // but for Soulseek we still re-fire the callback so the surface can // re-issue the handoff (e.g. user typed and wants a fresh search). if (src === state.activeSource) { if (src === 'soulseek' && onSoulseekSelected) onSoulseekSelected(state.query); return; } state.activeSource = src; renderSourceRow(); // Soulseek โ€” let the surface decide what to do (basic-section swap // on Search page, /search handoff on global widget). We don't cache // or auto-fetch soulseek results in the controller. if (src === 'soulseek') { if (onSoulseekSelected) onSoulseekSelected(state.query); return; } if (state.sources[src]) { _notify(); } else if (state.query) { _fetchSource(src); } else { _notify(); } } async function _fetchSource(src) { const query = state.query; if (!query) return; const requestId = ++_requestSeq; _sourceRequestIds[src] = requestId; state.loadingSources.add(src); renderSourceRow(); _notify(); if (abortCtrl) abortCtrl.abort(); abortCtrl = new AbortController(); try { if (src === 'youtube_videos') { await _fetchYouTubeVideos(query, abortCtrl.signal, requestId); } else { const data = await enhancedSearchFetch(query, { source: src, signal: abortCtrl.signal, }); // Bail without writing if a newer request for THIS source // has superseded us. Cross-source supersession (different // src entirely) is handled by the loadingSources cleanup // below โ€” each source's catch owns its own entry. if (_sourceRequestIds[src] !== requestId) return; state.sources[src] = { artists: data.spotify_artists || [], albums: data.spotify_albums || [], tracks: data.spotify_tracks || [], videos: [], db_artists: data.db_artists || [], }; const served = data.primary_source || data.metadata_source; if (served && served !== src) state.fallbacks[src] = served; } if (_sourceRequestIds[src] !== requestId) return; state.loadingSources.delete(src); renderSourceRow(); _notify(); } catch (err) { // Only clear loadingSources if no newer request for THIS source // is in flight. Cross-source supersession (e.g. user switched // Spotify โ†’ Deezer) still falls through here so Spotify's // spinner gets cleared on its own AbortError. if (_sourceRequestIds[src] === requestId) { state.loadingSources.delete(src); renderSourceRow(); _notify(); } if (err.name !== 'AbortError') { console.debug(`Source fetch failed for ${src}:`, err); } } } async function _fetchYouTubeVideos(query, signal, requestId) { const res = await fetch('/api/enhanced-search/source/youtube_videos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }), signal, }); if (!res.ok) throw new Error(`YouTube search failed: ${res.status}`); // Bail before allocating cache entry if a newer YouTube request // has superseded us. if (_sourceRequestIds['youtube_videos'] !== requestId) return; state.sources['youtube_videos'] = { artists: [], albums: [], tracks: [], videos: [], db_artists: [], }; const cache = state.sources['youtube_videos']; const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; if (_sourceRequestIds['youtube_videos'] !== requestId) return; buffer += decoder.decode(value, { stream: true }); let idx; while ((idx = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, idx).trim(); buffer = buffer.slice(idx + 1); if (!line) continue; try { const chunk = JSON.parse(line); if (chunk.type === 'videos') { cache.videos = chunk.data; if (state.activeSource === 'youtube_videos') _notify(); } } catch (_) { /* best-effort NDJSON parse */ } } } } function submitQuery(query) { if (query !== state.query) { state.query = query; state.sources = {}; state.fallbacks = {}; state.loadingSources = new Set(); // Invalidate every in-flight per-source token. Without this, a // settle that arrives AFTER a query reset (e.g. user typed 'a', // fetch started, then user cleared the input) would still // pass the per-source token check and write stale data back // into the just-cleared state.sources. Setting fresh tokens // when each new _fetchSource fires re-stamps as needed. for (const k in _sourceRequestIds) delete _sourceRequestIds[k]; // Abort the active fetch โ€” its results are useless now. if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; } renderSourceRow(); } // Soulseek โ€” surface handles the full query handoff. if (state.activeSource === 'soulseek') { if (onSoulseekSelected) onSoulseekSelected(query); return; } // Cache hit โ€” instant re-render, no fetch. if (state.sources[state.activeSource]) { _notify(); return; } _fetchSource(state.activeSource); } return { state, init, submitQuery, setActiveSource, renderSourceRow, }; } // Navigate to Settings โ†’ relevant tab and scroll to the service card that // matches the picker's source id. Called when a user clicks an unconfigured // source icon. Soulseek is special-cased to land on the Downloads tab where // its slskd URL field lives (gated behind the download-source-mode select); // every other source has a card on Connections. function openSettingsForSource(src) { if (typeof navigateToPage !== 'function') return; navigateToPage('settings'); const targetTab = src === 'soulseek' ? 'downloads' : 'connections'; setTimeout(() => { try { if (typeof switchSettingsTab === 'function') switchSettingsTab(targetTab); } catch (_) { /* best-effort */ } setTimeout(() => { // Soulseek doesn't have a .stg-service card โ€” scroll to the // slskd URL input instead so the user lands on the right field. const target = src === 'soulseek' ? document.querySelector('#settings-page #soulseek-url') : document.querySelector(`#settings-page .stg-service[data-service="${src}"]`); if (!target) return; target.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (src === 'soulseek') { try { target.focus(); } catch (_) { /* best-effort */ } } else { target.classList.add('stg-service-flash'); setTimeout(() => target.classList.remove('stg-service-flash'), 2200); } }, 120); }, 60); } // Render a single enhanced-search result section (artists / albums / tracks). // Shared between the Search page and the global widget. The mapItem callback // projects each backend item to the card config consumed here. function renderCompactSection(sectionId, listId, countId, items, mapItem) { const section = document.getElementById(sectionId); const list = document.getElementById(listId); const count = document.getElementById(countId); if (!list) return; list.innerHTML = ''; if (!items || items.length === 0) { section.classList.add('hidden'); return; } section.classList.remove('hidden'); count.textContent = items.length; // Determine type based on section ID const isArtist = sectionId.includes('artists'); const isAlbum = sectionId.includes('albums') || sectionId.includes('singles'); const isTrack = sectionId.includes('tracks'); // Add appropriate grid class to list if (isArtist) { list.classList.add('enh-artists-grid'); } else if (isAlbum) { list.classList.add('enh-albums-grid'); } else if (isTrack) { list.classList.add('enh-tracks-list'); } items.forEach(item => { const config = mapItem(item); const elem = document.createElement('div'); // Add appropriate card class if (isArtist) { elem.className = 'enh-compact-item artist-card'; // Add data attributes for lazy loading if (item.id) { elem.dataset.artistId = item.id; elem.dataset.needsImage = config.image ? 'false' : 'true'; // Stash the artist name so the lazy-loader can pass it to // the backend. Needed for sources that don't store artist // images directly (MusicBrainz) โ€” backend resolves the // image by looking up the name on a fallback source. if (config.name) elem.dataset.artistName = config.name; } } else if (isAlbum) { elem.className = 'enh-compact-item album-card'; } else if (isTrack) { elem.className = 'enh-compact-item track-item'; } // Build image HTML with type-specific classes let imageClass = 'enh-item-image'; let placeholderClass = 'enh-item-image-placeholder'; if (isArtist) { imageClass += ' artist-image'; placeholderClass += ' artist-placeholder'; } else if (isAlbum) { imageClass += ' album-cover'; placeholderClass += ' album-placeholder'; } else if (isTrack) { imageClass += ' track-cover'; placeholderClass += ' track-placeholder'; } // Fallback placeholder used when the image 404s (common for MB // Cover Art Archive URLs โ€” we construct them deterministically // without probing first, so some will miss). Without onerror the // browser shows its broken-image icon. const placeholderHtml = `
${config.placeholder}
`; const escapedFallback = placeholderHtml.replace(/"/g, '"'); const imageHtml = config.image ? `${escapeHtml(config.name)}` : placeholderHtml; const badgeHtml = config.badge ? `
${config.badge.text}
` : ''; const durationHtml = config.duration && isTrack ? `
${escapeHtml(config.duration)}
` : ''; elem.innerHTML = ` ${imageHtml}
${escapeHtml(config.name)}
${escapeHtml(config.meta)}
${durationHtml} ${badgeHtml} `; elem.addEventListener('click', config.onClick); // Add play button handler for tracks if (isTrack && config.onPlay) { const playBtn = elem.querySelector('.enh-item-play-btn'); if (playBtn) { playBtn.addEventListener('click', (e) => { e.stopPropagation(); // Don't trigger main onClick config.onPlay(); }); } } list.appendChild(elem); // Extract colors from image for dynamic glow effect if (config.image) { extractImageColors(config.image, (colors) => { applyDynamicGlow(elem, colors); }); } }); } // ---------------------------------------------------------------------------- // Discography completion checking (for artist-detail pages, library page) // ---------------------------------------------------------------------------- async function checkDiscographyCompletion(artistId, discography) { console.log(`๐Ÿ” Starting streaming completion check for artist: ${artistId}`); try { // Create new abort controller for this completion check artistCompletionController = new AbortController(); // Use fetch with streaming response const response = await fetch(`/api/artist/${artistId}/completion-stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ discography: discography, artist_name: artistsPageState.selectedArtist?.name || 'Unknown Artist', source: discography?.source || artistsPageState.sourceOverride || null, }), signal: artistCompletionController.signal }); if (!response.ok) { throw new Error(`Failed to start completion check: ${response.status}`); } // Handle streaming response const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)); handleStreamingCompletionUpdate(data); } catch (e) { console.warn('Failed to parse streaming data:', line); } } } } // Clear the controller when done artistCompletionController = null; } catch (error) { // Don't show error if it was aborted (user navigated away) if (error.name === 'AbortError') { console.log('โน๏ธ Completion check aborted (user navigated to new artist)'); return; } console.error('โŒ Failed to check completion status:', error); showCompletionError(); } finally { // Always clear the controller artistCompletionController = null; } } /** * Handle individual streaming completion updates */ function handleStreamingCompletionUpdate(data) { console.log('๐Ÿ”„ Streaming update received:', data.type, data.name || data.artist_name); switch (data.type) { case 'start': console.log(`๐ŸŽค Starting completion check for ${data.artist_name} (${data.total_items} items)`); // Initialize cache for this artist if not exists const artistId = artistsPageState.selectedArtist?.id; if (artistId && !artistsPageState.cache.completionData[artistId]) { artistsPageState.cache.completionData[artistId] = { albums: [], singles: [] }; } break; case 'album_completion': updateAlbumCompletionOverlay(data, 'albums'); // Cache the completion data cacheCompletionData(data, 'albums'); console.log(`๐Ÿ“€ Updated album: ${data.name} (${data.status})`); break; case 'single_completion': updateAlbumCompletionOverlay(data, 'singles'); // Cache the completion data cacheCompletionData(data, 'singles'); console.log(`๐ŸŽต Updated single: ${data.name} (${data.status})`); break; case 'error': console.error('โŒ Error processing item:', data.name, data.error); // Could show error for specific item break; case 'complete': console.log(`โœ… Completion check finished (${data.processed_count} items processed)`); break; default: console.log('Unknown streaming update type:', data.type); } } /** * Cache completion data for future restoration */ function cacheCompletionData(completionData, type) { const artistId = artistsPageState.selectedArtist?.id; if (!artistId) return; // Ensure cache structure exists if (!artistsPageState.cache.completionData[artistId]) { artistsPageState.cache.completionData[artistId] = { albums: [], singles: [] }; } // Add to appropriate cache array if (type === 'albums') { artistsPageState.cache.completionData[artistId].albums.push(completionData); } else if (type === 'singles') { artistsPageState.cache.completionData[artistId].singles.push(completionData); } } /** * Update completion overlay for a specific album/single */ function updateAlbumCompletionOverlay(completionData, containerType) { const containerId = containerType === 'albums' ? 'album-cards-container' : 'singles-cards-container'; const container = document.getElementById(containerId); if (!container) { console.warn(`Container ${containerId} not found`); return; } // Find the album card by data-album-id const albumCard = container.querySelector(`[data-album-id="${completionData.id}"]`); if (!albumCard) { console.warn(`Album card not found for ID: ${completionData.id}`); return; } // Reclassify and move cards when track count reveals single/EP (Discogs lazy fetch) const currentType = albumCard.dataset.albumType; const expectedTracks = completionData.expected_tracks || 0; if (expectedTracks > 0) { albumCard.dataset.totalTracks = expectedTracks; let newType = currentType; if (currentType === 'album' && expectedTracks <= 3) newType = 'single'; else if (currentType === 'album' && expectedTracks <= 6) newType = 'ep'; if (newType !== currentType) { albumCard.dataset.albumType = newType; const typeEl = albumCard.querySelector('.album-card-type'); if (typeEl) typeEl.textContent = newType === 'single' ? 'Single' : 'EP'; // Move card from albums grid to singles grid const singlesGrid = document.getElementById('singles-grid'); const singlesSection = singlesGrid?.closest('.discography-section'); if (singlesGrid) { albumCard.remove(); singlesGrid.appendChild(albumCard); if (singlesSection) singlesSection.style.display = ''; } } } const overlay = albumCard.querySelector('.completion-overlay'); if (!overlay) { console.warn(`Completion overlay not found for album: ${completionData.name}`); return; } // Remove existing status classes overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error'); // Add new status class overlay.classList.add(completionData.status); // Update overlay text and content const statusText = getCompletionStatusText(completionData); const progressText = completionData.expected_tracks > 0 ? `${completionData.owned_tracks}/${completionData.expected_tracks}` : ''; overlay.innerHTML = progressText ? `${statusText}${progressText}` : `${statusText}`; // Add tooltip with more details overlay.title = `${completionData.name}\n${statusText} (${completionData.completion_percentage}%)\nTracks: ${completionData.owned_tracks}/${completionData.expected_tracks}\nConfidence: ${completionData.confidence}`; // Add brief flash animation to indicate update overlay.style.animation = 'none'; overlay.offsetHeight; // Trigger reflow overlay.style.animation = 'completionOverlayFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1)'; console.log(`๐Ÿ“Š Updated overlay for "${completionData.name}": ${statusText} (${completionData.completion_percentage}%)`); } /** * Get human-readable status text for completion overlay */ function getCompletionStatusText(completionData) { switch (completionData.status) { case 'completed': return 'Complete'; case 'nearly_complete': return 'Nearly Complete'; case 'partial': return 'Partial'; case 'missing': return 'Missing'; case 'downloading': return 'Downloading...'; case 'downloaded': return 'Downloaded'; case 'error': return 'Error'; default: return 'Unknown'; } } /** * Set album to downloaded status after download finishes */ function setAlbumDownloadedStatus(albumId) { console.log(`โœ… [DOWNLOAD COMPLETE] Setting album ${albumId} to downloaded status`); const completionData = { id: albumId, status: 'downloaded', owned_tracks: 0, expected_tracks: 0, name: 'Downloaded', completion_percentage: 100 }; // Find if it's in albums or singles container let containerType = 'albums'; let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`); if (!albumCard) { containerType = 'singles'; albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`); } if (albumCard) { updateAlbumCompletionOverlay(completionData, containerType); console.log(`โœ… [DOWNLOAD COMPLETE] Album ${albumId} set to Downloaded status`); } else { console.warn(`โŒ [DOWNLOAD COMPLETE] Album card not found for ID: "${albumId}"`); } } /** * Set album to downloading status */ function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) { console.log(`๐Ÿ” [DOWNLOAD STATUS] Searching for album card with ID: "${albumId}"`); const completionData = { id: albumId, status: 'downloading', owned_tracks: downloaded, expected_tracks: total, name: 'Downloading', completion_percentage: Math.round((downloaded / total) * 100) || 0 }; // Find if it's in albums or singles container let containerType = 'albums'; let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`); if (!albumCard) { containerType = 'singles'; albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`); } if (albumCard) { console.log(`โœ… [DOWNLOAD STATUS] Found album card in ${containerType} container, updating overlay`); updateAlbumCompletionOverlay(completionData, containerType); } else { console.warn(`โŒ [DOWNLOAD STATUS] Album card not found for ID: "${albumId}"`); // Debug: List all available album cards const allAlbums = document.querySelectorAll('#album-cards-container [data-album-id], #singles-cards-container [data-album-id]'); console.log(`๐Ÿ” [DEBUG] Available album IDs:`, Array.from(allAlbums).map(card => card.dataset.albumId)); } } // ---------------------------------------------------------------------------- // Download bubble infrastructure, image colour/glow helpers, HTML escape, // service-status polling, enrichment-card rendering. All originally defined // in artists.js but used broadly across the app. // ---------------------------------------------------------------------------- async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist, showLoadingOverlayParam = true, contextType = 'artist_album') { if (showLoadingOverlayParam) { showLoadingOverlay('Loading album...'); } // Check if a process is already active for this virtual playlist if (activeDownloadProcesses[virtualPlaylistId]) { console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`); const process = activeDownloadProcesses[virtualPlaylistId]; if (process.modalElement) { if (process.status === 'complete') { showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); } process.modalElement.style.display = 'flex'; if (showLoadingOverlayParam) { hideLoadingOverlay(); } } return; } console.log(`๐Ÿ“ฅ Opening Download Missing Tracks modal for artist album: ${virtualPlaylistId}`); // Create virtual playlist object for compatibility with existing modal logic const virtualPlaylist = { id: virtualPlaylistId, name: playlistName, track_count: spotifyTracks.length }; // Store the tracks in the cache for the modal to use playlistTrackCache[virtualPlaylistId] = spotifyTracks; currentPlaylistTracks = spotifyTracks; currentModalPlaylistId = virtualPlaylistId; let modal = document.createElement('div'); modal.id = `download-missing-modal-${virtualPlaylistId}`; modal.className = 'download-missing-modal'; modal.style.display = 'none'; document.body.appendChild(modal); // Register the new process in our global state tracker using the same structure as other modals activeDownloadProcesses[virtualPlaylistId] = { status: 'idle', modalElement: modal, poller: null, batchId: null, playlist: virtualPlaylist, tracks: spotifyTracks, // Additional metadata for artist albums artist: artist, album: album, albumType: album.album_type, source: artist?.source || album?.source || artistsPageState.artistDiscography?.source || null }; // Generate hero section โ€” 'artist_album' for releases, 'playlist' for charts/compilations const heroContext = contextType === 'playlist' ? { type: 'playlist', playlist: { name: playlistName, owner: 'Beatport' }, trackCount: spotifyTracks.length, playlistId: virtualPlaylistId } : { type: 'artist_album', artist: artist, album: album, trackCount: spotifyTracks.length, playlistId: virtualPlaylistId }; // Use the exact same modal HTML structure as the existing modals modal.innerHTML = `
${generateDownloadModalHeroSection(heroContext)}
๐Ÿ” Library Analysis Ready to start
โฌ Downloads Waiting for analysis

๐Ÿ“‹ Track Analysis & Download Status

${spotifyTracks.length} / ${spotifyTracks.length} tracks selected
${spotifyTracks.map((track, index) => ` `).join('')}
# Track 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(''); } // =============================== // ---------------------------------------------------------------------------- // Similar Artists โ€” fetch + render via MusicMap. Source-agnostic (artist name // based), works for both library and metadata-source artists. Targets DOM IDs // #similar-artists-loading, #similar-artists-error, #similar-artists-bubbles- // container that live on the artist-detail page. // ---------------------------------------------------------------------------- // Similar artists section lives on the standalone artist-detail page with the // 'ad-' prefixed ids. The resolver shape was originally designed for both the // inline Artists page and the standalone page; the inline page has since been // retired, so only the standalone candidate remains. function _resolveSimilarArtistsTargets() { const sectionEl = document.getElementById('ad-similar-artists-section'); if (!sectionEl) return null; return { section: sectionEl, loadingEl: document.getElementById('ad-similar-artists-loading'), errorEl: document.getElementById('ad-similar-artists-error'), container: document.getElementById('ad-similar-artists-bubbles-container'), }; } async function loadSimilarArtists(artistName) { if (!artistName) { console.warn('โš ๏ธ No artist name provided for similar artists'); return; } console.log(`๐Ÿ” Loading similar artists for: ${artistName}`); const targets = _resolveSimilarArtistsTargets(); if (!targets) { console.warn('โš ๏ธ Similar artists section elements not found on any active page'); return; } const { section, loadingEl, errorEl, container } = targets; // 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 targets = _resolveSimilarArtistsTargets(); const container = targets && targets.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 targets = _resolveSimilarArtistsTargets(); const container = targets && targets.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); } // Click โ†’ navigate to the standalone artist-detail page. Works for both // library and source artists thanks to the source-aware backend endpoint. bubble.addEventListener('click', () => { console.log(`๐ŸŽต Clicked similar artist: ${artist.name} (ID: ${artist.id})`); navigateToArtistDetail(artist.id, artist.name, artist.source || null); }); return bubble; } // ---------------------------------------------------------------------------- // Lazy artist-card image loader (used by wishlist-tools.js + the legacy inline // Artists page search results). Fetches /api/artist//image for each card // flagged data-needs-image="true" in batches of 5. // ---------------------------------------------------------------------------- async function lazyLoadArtistImages(container) { if (!container) { console.error('โŒ lazyLoadArtistImages: container is null'); return; } const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]'); if (cardsNeedingImages.length === 0) return; 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) return; try { const response = await fetch(`/api/artist/${artistId}/image`); const data = await response.json(); if (data.success && data.image_url) { 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) { bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`; } } card.dataset.needsImage = 'false'; } } catch (error) { console.error(`โŒ Failed to load image for artist ${artistId}:`, error); } })); } } // Legacy global alias โ€” wishlist-tools.js falls back to window.lazyLoadArtistImages window.lazyLoadArtistImages = lazyLoadArtistImages; // ---------------------------------------------------------------------------- // Album-card completion overlay error state (called from checkDiscographyCompletion // when the API request fails) // ---------------------------------------------------------------------------- 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'; }); }