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