// 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 = `
`;
}
/**
* 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' : ''}
`;
}
/**
* 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 = `
`);
}
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 = ``;
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';
});
}