Add MusicBrainz watchlist artist ID storage, badges, linked-provider editing, and per-artist preferred source support. Backfill watchlist MusicBrainz matches from already-enriched library artists so existing MusicBrainz worker matches appear in watchlist cards and settings. Extend bulk watchlist add, liked artist matching, artist map source picking, and service status labels to recognize MusicBrainz, with regression tests for watchlist ID persistence and backfill.
3935 lines
157 KiB
JavaScript
3935 lines
157 KiB
JavaScript
// 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 <img> 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',
|
||
},
|
||
amazon: {
|
||
text: 'Amazon Music', icon: '🛒',
|
||
tabClass: 'enh-tab-amazon', badgeClass: 'enh-badge-amazon',
|
||
},
|
||
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', 'amazon', '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(['amazon', '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
|
||
? `<img src="${escapeHtml(info.logo)}" alt="" loading="lazy">`
|
||
: info.icon);
|
||
|
||
return `
|
||
<button class="${classes}" data-source="${src}" role="tab"
|
||
aria-selected="${active}" title="${escapeHtml(title)}">
|
||
<span class="${glyphClass}">${glyph}</span>
|
||
<span class="${labelClass}">${escapeHtml(info.text)}</span>
|
||
</button>`;
|
||
}).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.
|
||
// /status is public; /api/settings is admin-only and returns 403 for
|
||
// non-admin profiles, which previously caused them to silently fall
|
||
// back to 'spotify' regardless of what admin had configured.
|
||
//
|
||
// /status returns `metadata_source` as a dict (`{source, connected,
|
||
// response_time, ...}`) — pre-fix this code treated it as a string,
|
||
// so `SOURCE_LABELS[<dict>]` was always undefined and activeSource
|
||
// silently stayed at the hardcoded 'spotify' default regardless of
|
||
// what the user had configured. Read `.source` off the dict; fall
|
||
// back to the legacy string shape for forward-compat with any older
|
||
// /status response that might predate the dict change.
|
||
try {
|
||
const resp = await fetch('/status');
|
||
if (resp.ok) {
|
||
const status = await resp.json();
|
||
const ms = status && status.metadata_source;
|
||
const src = (ms && typeof ms === 'object') ? ms.source : ms;
|
||
if (src && SOURCE_LABELS[src]) state.activeSource = src;
|
||
}
|
||
} 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 = `<div class="${placeholderClass}" data-lazy-image="true">${config.placeholder}</div>`;
|
||
const escapedFallback = placeholderHtml.replace(/"/g, '"');
|
||
const imageHtml = config.image
|
||
? `<img src="${escapeHtml(config.image)}" class="${imageClass}" alt="${escapeHtml(config.name)}" onerror="this.outerHTML='${escapedFallback}'">`
|
||
: placeholderHtml;
|
||
|
||
const badgeHtml = config.badge
|
||
? `<div class="enh-item-badge ${config.badge.class}">${config.badge.text}</div>`
|
||
: '';
|
||
|
||
const durationHtml = config.duration && isTrack
|
||
? `<div class="enh-item-duration">
|
||
${escapeHtml(config.duration)}
|
||
<button class="enh-item-play-btn" title="Stream this track">▶</button>
|
||
</div>`
|
||
: '';
|
||
|
||
elem.innerHTML = `
|
||
${imageHtml}
|
||
<div class="enh-item-info">
|
||
<div class="enh-item-name">${escapeHtml(config.name)}</div>
|
||
<div class="enh-item-meta">${escapeHtml(config.meta)}</div>
|
||
</div>
|
||
${durationHtml}
|
||
${badgeHtml}
|
||
`;
|
||
|
||
elem.addEventListener('click', config.onClick);
|
||
|
||
// Add play button handler for tracks
|
||
if (isTrack && config.onPlay) {
|
||
const playBtn = elem.querySelector('.enh-item-play-btn');
|
||
if (playBtn) {
|
||
playBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation(); // Don't trigger main onClick
|
||
config.onPlay();
|
||
});
|
||
}
|
||
}
|
||
|
||
list.appendChild(elem);
|
||
|
||
// Extract colors from image for dynamic glow effect
|
||
if (config.image) {
|
||
extractImageColors(config.image, (colors) => {
|
||
applyDynamicGlow(elem, colors);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// Discography completion checking (for artist-detail pages, library page)
|
||
// ----------------------------------------------------------------------------
|
||
|
||
async function checkDiscographyCompletion(artistId, discography) {
|
||
console.log(`🔍 Starting streaming completion check for artist: ${artistId}`);
|
||
|
||
try {
|
||
// Create new abort controller for this completion check
|
||
artistCompletionController = new AbortController();
|
||
|
||
// Use fetch with streaming response
|
||
const response = await fetch(`/api/artist/${artistId}/completion-stream`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
discography: discography,
|
||
artist_name: artistsPageState.selectedArtist?.name || 'Unknown Artist',
|
||
source: discography?.source || artistsPageState.sourceOverride || null,
|
||
}),
|
||
signal: artistCompletionController.signal
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to start completion check: ${response.status}`);
|
||
}
|
||
|
||
// Handle streaming response
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
const chunk = decoder.decode(value);
|
||
const lines = chunk.split('\n');
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('data: ')) {
|
||
try {
|
||
const data = JSON.parse(line.slice(6));
|
||
handleStreamingCompletionUpdate(data);
|
||
} catch (e) {
|
||
console.warn('Failed to parse streaming data:', line);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clear the controller when done
|
||
artistCompletionController = null;
|
||
|
||
} catch (error) {
|
||
// Don't show error if it was aborted (user navigated away)
|
||
if (error.name === 'AbortError') {
|
||
console.log('⏹️ Completion check aborted (user navigated to new artist)');
|
||
return;
|
||
}
|
||
|
||
console.error('❌ Failed to check completion status:', error);
|
||
showCompletionError();
|
||
} finally {
|
||
// Always clear the controller
|
||
artistCompletionController = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle individual streaming completion updates
|
||
*/
|
||
function handleStreamingCompletionUpdate(data) {
|
||
console.log('🔄 Streaming update received:', data.type, data.name || data.artist_name);
|
||
|
||
switch (data.type) {
|
||
case 'start':
|
||
console.log(`🎤 Starting completion check for ${data.artist_name} (${data.total_items} items)`);
|
||
// Initialize cache for this artist if not exists
|
||
const artistId = artistsPageState.selectedArtist?.id;
|
||
if (artistId && !artistsPageState.cache.completionData[artistId]) {
|
||
artistsPageState.cache.completionData[artistId] = {
|
||
albums: [],
|
||
singles: []
|
||
};
|
||
}
|
||
break;
|
||
|
||
case 'album_completion':
|
||
updateAlbumCompletionOverlay(data, 'albums');
|
||
// Cache the completion data
|
||
cacheCompletionData(data, 'albums');
|
||
console.log(`📀 Updated album: ${data.name} (${data.status})`);
|
||
break;
|
||
|
||
case 'single_completion':
|
||
updateAlbumCompletionOverlay(data, 'singles');
|
||
// Cache the completion data
|
||
cacheCompletionData(data, 'singles');
|
||
console.log(`🎵 Updated single: ${data.name} (${data.status})`);
|
||
break;
|
||
|
||
case 'error':
|
||
console.error('❌ Error processing item:', data.name, data.error);
|
||
// Could show error for specific item
|
||
break;
|
||
|
||
case 'complete':
|
||
console.log(`✅ Completion check finished (${data.processed_count} items processed)`);
|
||
break;
|
||
|
||
default:
|
||
console.log('Unknown streaming update type:', data.type);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cache completion data for future restoration
|
||
*/
|
||
function cacheCompletionData(completionData, type) {
|
||
const artistId = artistsPageState.selectedArtist?.id;
|
||
if (!artistId) return;
|
||
|
||
// Ensure cache structure exists
|
||
if (!artistsPageState.cache.completionData[artistId]) {
|
||
artistsPageState.cache.completionData[artistId] = {
|
||
albums: [],
|
||
singles: []
|
||
};
|
||
}
|
||
|
||
// Add to appropriate cache array
|
||
if (type === 'albums') {
|
||
artistsPageState.cache.completionData[artistId].albums.push(completionData);
|
||
} else if (type === 'singles') {
|
||
artistsPageState.cache.completionData[artistId].singles.push(completionData);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update completion overlay for a specific album/single
|
||
*/
|
||
function updateAlbumCompletionOverlay(completionData, containerType) {
|
||
const containerId = containerType === 'albums' ? 'album-cards-container' : 'singles-cards-container';
|
||
const container = document.getElementById(containerId);
|
||
|
||
if (!container) {
|
||
console.warn(`Container ${containerId} not found`);
|
||
return;
|
||
}
|
||
|
||
// Find the album card by data-album-id
|
||
const albumCard = container.querySelector(`[data-album-id="${completionData.id}"]`);
|
||
|
||
if (!albumCard) {
|
||
console.warn(`Album card not found for ID: ${completionData.id}`);
|
||
return;
|
||
}
|
||
|
||
// Reclassify and move cards when track count reveals single/EP (Discogs lazy fetch)
|
||
const currentType = albumCard.dataset.albumType;
|
||
const expectedTracks = completionData.expected_tracks || 0;
|
||
if (expectedTracks > 0) {
|
||
albumCard.dataset.totalTracks = expectedTracks;
|
||
let newType = currentType;
|
||
if (currentType === 'album' && expectedTracks <= 3) newType = 'single';
|
||
else if (currentType === 'album' && expectedTracks <= 6) newType = 'ep';
|
||
|
||
if (newType !== currentType) {
|
||
albumCard.dataset.albumType = newType;
|
||
const typeEl = albumCard.querySelector('.album-card-type');
|
||
if (typeEl) typeEl.textContent = newType === 'single' ? 'Single' : 'EP';
|
||
|
||
// Move card from albums grid to singles grid
|
||
const singlesGrid = document.getElementById('singles-grid');
|
||
const singlesSection = singlesGrid?.closest('.discography-section');
|
||
if (singlesGrid) {
|
||
albumCard.remove();
|
||
singlesGrid.appendChild(albumCard);
|
||
if (singlesSection) singlesSection.style.display = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
const overlay = albumCard.querySelector('.completion-overlay');
|
||
if (!overlay) {
|
||
console.warn(`Completion overlay not found for album: ${completionData.name}`);
|
||
return;
|
||
}
|
||
|
||
// Remove existing status classes
|
||
overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error');
|
||
|
||
// Add new status class
|
||
overlay.classList.add(completionData.status);
|
||
|
||
// Update overlay text and content
|
||
const statusText = getCompletionStatusText(completionData);
|
||
const progressText = completionData.expected_tracks > 0
|
||
? `${completionData.owned_tracks}/${completionData.expected_tracks}`
|
||
: '';
|
||
|
||
overlay.innerHTML = progressText
|
||
? `<span class="completion-status">${statusText}</span><span class="completion-progress">${progressText}</span>`
|
||
: `<span class="completion-status">${statusText}</span>`;
|
||
|
||
// 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,
|
||
source: artist?.source || album?.source || artistsPageState.artistDiscography?.source || null,
|
||
trackCount: spotifyTracks.length,
|
||
playlistId: virtualPlaylistId
|
||
};
|
||
|
||
// Use the exact same modal HTML structure as the existing modals
|
||
modal.innerHTML = `
|
||
<div class="download-missing-modal-content" data-context="${contextType}">
|
||
<div class="download-missing-modal-header">
|
||
${generateDownloadModalHeroSection(heroContext)}
|
||
</div>
|
||
|
||
<div class="download-missing-modal-body">
|
||
<div class="download-progress-section">
|
||
<div class="progress-item">
|
||
<div class="progress-label">
|
||
🔍 Library Analysis
|
||
<span id="analysis-progress-text-${virtualPlaylistId}">Ready to start</span>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill analysis" id="analysis-progress-fill-${virtualPlaylistId}"></div>
|
||
</div>
|
||
</div>
|
||
<div class="progress-item">
|
||
<div class="progress-label">
|
||
⏬ Downloads
|
||
<span id="download-progress-text-${virtualPlaylistId}">Waiting for analysis</span>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill download" id="download-progress-fill-${virtualPlaylistId}"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-tracks-section">
|
||
<div class="download-tracks-header">
|
||
<h3 class="download-tracks-title">📋 Track Analysis & Download Status</h3>
|
||
<span class="track-selection-count" id="track-selection-count-${virtualPlaylistId}">${spotifyTracks.length} / ${spotifyTracks.length} tracks selected</span>
|
||
</div>
|
||
<div class="download-tracks-table-container">
|
||
<table class="download-tracks-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="track-select-header">
|
||
<input type="checkbox" class="track-select-all"
|
||
id="select-all-${virtualPlaylistId}" checked
|
||
onchange="toggleAllTrackSelections('${virtualPlaylistId}', this.checked)">
|
||
</th>
|
||
<th>#</th>
|
||
<th>Track Name</th>
|
||
<th>Artist(s)</th>
|
||
<th>Duration</th>
|
||
<th>Library Status</th>
|
||
<th>Download Status</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="download-tracks-tbody-${virtualPlaylistId}">
|
||
${spotifyTracks.map((track, index) => `
|
||
<tr data-track-index="${index}">
|
||
<td class="track-select-cell">
|
||
<input type="checkbox" class="track-select-cb"
|
||
data-track-index="${index}" checked
|
||
onchange="updateTrackSelectionCount('${virtualPlaylistId}')">
|
||
</td>
|
||
<td class="track-number">${index + 1}</td>
|
||
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
|
||
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
|
||
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
|
||
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
|
||
<td class="track-download-status" id="download-${virtualPlaylistId}-${index}">-</td>
|
||
<td class="track-actions" id="actions-${virtualPlaylistId}-${index}">-</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-missing-modal-footer">
|
||
<div class="download-phase-controls">
|
||
<div class="force-download-toggle-container" style="margin-bottom: 0px; display: flex; flex-direction: column; gap: 8px; align-items: flex-start;">
|
||
<label class="force-download-toggle">
|
||
<input type="checkbox" id="force-download-all-${virtualPlaylistId}">
|
||
<span>Force Download All</span>
|
||
</label>
|
||
${contextType === 'playlist' ? `
|
||
<label class="force-download-toggle">
|
||
<input type="checkbox" id="playlist-folder-mode-${virtualPlaylistId}">
|
||
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span>
|
||
</label>
|
||
` : ''}
|
||
</div>
|
||
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
|
||
Begin Analysis
|
||
</button>
|
||
${_isBeatportPlaylistId(virtualPlaylistId) ? `
|
||
<button class="download-control-btn sync-to-server-btn" id="sync-server-btn-${virtualPlaylistId}" onclick="syncPlaylistToServer('${virtualPlaylistId}')" ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>
|
||
Sync to Server
|
||
</button>` : ''}
|
||
<button class="download-control-btn" id="add-to-wishlist-btn-${virtualPlaylistId}" onclick="addModalTracksToWishlist('${virtualPlaylistId}')" style="background-color: #9333ea; color: white;">
|
||
Add to Wishlist
|
||
</button>
|
||
<button class="download-control-btn danger" id="cancel-all-btn-${virtualPlaylistId}" onclick="cancelAllOperations('${virtualPlaylistId}')" style="display: none;">
|
||
Cancel All
|
||
</button>
|
||
</div>
|
||
<div class="modal-close-section">
|
||
<button class="download-control-btn export" onclick="exportPlaylistAsM3U('${virtualPlaylistId}')">
|
||
📋 Export as M3U
|
||
</button>
|
||
<button class="download-control-btn secondary" onclick="closeDownloadMissingModal('${virtualPlaylistId}')">Close</button>
|
||
</div>
|
||
</div>
|
||
<!-- Sync to server live progress (below footer, hidden by default) -->
|
||
<div class="modal-sync-progress-area" id="modal-sync-progress-${virtualPlaylistId}" style="display:none;">
|
||
<div class="modal-sync-progress-bar-bg">
|
||
<div class="modal-sync-progress-bar-fill" id="modal-sync-bar-${virtualPlaylistId}"></div>
|
||
</div>
|
||
<div class="modal-sync-progress-info">
|
||
<span class="modal-sync-step" id="modal-sync-step-${virtualPlaylistId}">Starting sync...</span>
|
||
<div class="modal-sync-stats">
|
||
<span class="matched" id="modal-sync-matched-${virtualPlaylistId}">0 matched</span>
|
||
<span class="failed" id="modal-sync-failed-${virtualPlaylistId}">0 failed</span>
|
||
</div>
|
||
<button class="modal-sync-cancel-btn" id="modal-sync-cancel-${virtualPlaylistId}" onclick="cancelModalSync('${virtualPlaylistId}')">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="search-results-placeholder">
|
||
<p>Search results will appear here when you select an album or track.</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Create bubbles display
|
||
const bubblesHTML = activeArtists.map(artistName =>
|
||
createSearchBubbleCard(searchDownloadBubbles[artistName])
|
||
).join('');
|
||
|
||
resultsArea.innerHTML = `
|
||
<div class="search-bubble-section">
|
||
<div class="search-bubble-header">
|
||
<h3 class="search-bubble-title">Active Downloads</h3>
|
||
<span class="search-bubble-count">${activeArtists.length}</span>
|
||
</div>
|
||
<div class="search-bubble-container" id="search-bubble-container">
|
||
${bubblesHTML}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 `
|
||
<div class="search-bubble-card ${allCompleted ? 'all-completed' : ''}"
|
||
data-artist-name="${escapeHtml(artist.name)}"
|
||
onclick="openSearchDownloadModal('${escapeForInlineJs(artist.name)}')"
|
||
title="Click to manage downloads for ${escapeHtml(artist.name)}">
|
||
<div class="search-bubble-image" style="${backgroundStyle}"></div>
|
||
<div class="search-bubble-overlay"></div>
|
||
<div class="search-bubble-content">
|
||
<div class="search-bubble-name">${escapeHtml(artist.name)}</div>
|
||
<div class="search-bubble-status">
|
||
${activeCount > 0 ? `${activeCount} active` : ''}
|
||
${completedCount > 0 ? `${completedCount} completed` : ''}
|
||
</div>
|
||
</div>
|
||
${allCompleted ? `
|
||
<div class="bulk-complete-indicator"
|
||
onclick="event.stopPropagation(); bulkCompleteSearchDownloads('${escapeForInlineJs(artist.name)}')"
|
||
title="Complete all downloads">
|
||
<span class="bulk-complete-icon">✅</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 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 = `
|
||
<div class="artist-download-modal-content">
|
||
<div class="artist-download-modal-hero">
|
||
<div class="artist-download-modal-hero-bg" ${artistBubbleData.artist.image_url ? `style="background-image: url('${escapeHtml(artistBubbleData.artist.image_url)}')"` : ''}></div>
|
||
<div class="artist-download-modal-hero-overlay">
|
||
<div class="artist-download-modal-hero-content">
|
||
<div class="artist-download-modal-hero-avatar">
|
||
${artistBubbleData.artist.image_url
|
||
? `<img src="${escapeHtml(artistBubbleData.artist.image_url)}" alt="${escapeHtml(artistBubbleData.artist.name)}" class="artist-download-modal-hero-image" loading="lazy">`
|
||
: '<div class="artist-download-modal-hero-fallback">🎵</div>'
|
||
}
|
||
</div>
|
||
<div class="artist-download-modal-hero-info">
|
||
<h2 class="artist-download-modal-hero-title">${escapeHtml(artistBubbleData.artist.name)}</h2>
|
||
<p class="artist-download-modal-hero-subtitle">${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}</p>
|
||
</div>
|
||
</div>
|
||
<span class="artist-download-modal-close" onclick="closeSearchDownloadModal()">×</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="artist-download-modal-body">
|
||
<div class="artist-download-items" id="search-download-items">
|
||
${artistBubbleData.downloads.map((download, index) => createSearchDownloadItem(download, index)).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="artist-download-modal-overlay" onclick="closeSearchDownloadModal()"></div>
|
||
`;
|
||
|
||
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 = `<img src="${data.image_url}" alt="${artistBubbleData.artist.name}" class="artist-download-modal-hero-image" loading="lazy">`;
|
||
}
|
||
|
||
// 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 `
|
||
<div class="artist-download-item" data-playlist-id="${virtualPlaylistId}">
|
||
<div class="download-item-artwork">
|
||
${item.image_url
|
||
? `<img src="${escapeHtml(item.image_url)}" alt="${escapeHtml(item.name)}" class="download-item-image" loading="lazy">`
|
||
: `<div class="download-item-fallback">
|
||
${type === 'album' ? '💿' : '🎵'}
|
||
</div>`
|
||
}
|
||
</div>
|
||
<div class="download-item-info">
|
||
<div class="download-item-name">${escapeHtml(item.name)}</div>
|
||
<div class="download-item-type">${typeLabel}</div>
|
||
</div>
|
||
<div class="download-item-actions">
|
||
<button class="download-item-btn ${buttonClass}"
|
||
onclick="reopenDownloadModal('${virtualPlaylistId}')">
|
||
${buttonText}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 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 = `
|
||
<div class="artist-downloads-header">
|
||
<h3 class="artist-downloads-title">Current Downloads</h3>
|
||
<p class="artist-downloads-subtitle">Active download processes</p>
|
||
</div>
|
||
<div class="artist-bubble-container" id="artist-bubble-container">
|
||
${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')}
|
||
</div>
|
||
`;
|
||
|
||
// 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 = `
|
||
<div class="artist-downloads-header">
|
||
<h3 class="artist-downloads-title">Current Downloads</h3>
|
||
<p class="artist-downloads-subtitle">Active download processes</p>
|
||
</div>
|
||
<div class="artist-bubble-container">
|
||
${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')}
|
||
</div>
|
||
`;
|
||
|
||
// 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 `
|
||
<div class="artist-bubble-card ${allCompleted ? 'all-completed' : ''}"
|
||
data-artist-id="${artist.id}"
|
||
title="Click to manage downloads for ${escapeHtml(artist.name)}">
|
||
<div class="artist-bubble-image" style="${backgroundStyle}"></div>
|
||
<div class="artist-bubble-overlay"></div>
|
||
<div class="artist-bubble-content">
|
||
<div class="artist-bubble-name">${escapeHtml(artist.name)}</div>
|
||
<div class="artist-bubble-status">
|
||
${activeCount > 0 ? `${activeCount} active` : ''}
|
||
${completedCount > 0 ? `${completedCount} completed` : ''}
|
||
</div>
|
||
</div>
|
||
${allCompleted ? `
|
||
<div class="bulk-complete-indicator"
|
||
onclick="event.stopPropagation(); bulkCompleteArtistDownloads('${artist.id}')"
|
||
title="Complete all downloads">
|
||
<span class="bulk-complete-icon">✅</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 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 = `
|
||
<div class="artist-download-modal-content">
|
||
<div class="artist-download-modal-hero">
|
||
<div class="artist-download-modal-hero-bg" ${artistBubbleData.artist.image_url ? `style="background-image: url('${escapeHtml(artistBubbleData.artist.image_url)}')"` : ''}></div>
|
||
<div class="artist-download-modal-hero-overlay">
|
||
<div class="artist-download-modal-hero-content">
|
||
<div class="artist-download-modal-hero-avatar">
|
||
${artistBubbleData.artist.image_url
|
||
? `<img src="${escapeHtml(artistBubbleData.artist.image_url)}" alt="${escapeHtml(artistBubbleData.artist.name)}" class="artist-download-modal-hero-image" loading="lazy">`
|
||
: '<div class="artist-download-modal-hero-fallback"><i class="fas fa-user-music"></i></div>'
|
||
}
|
||
</div>
|
||
<div class="artist-download-modal-hero-info">
|
||
<h2 class="artist-download-modal-hero-title">${escapeHtml(artistBubbleData.artist.name)}</h2>
|
||
<p class="artist-download-modal-hero-subtitle">${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}</p>
|
||
</div>
|
||
</div>
|
||
<span class="artist-download-modal-close" onclick="closeArtistDownloadModal()">×</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="artist-download-modal-body">
|
||
<div class="artist-download-items" id="artist-download-items-${artistId}">
|
||
${artistBubbleData.downloads.map((download, index) => createArtistDownloadItem(download, index)).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="artist-download-modal-overlay" onclick="closeArtistDownloadModal()"></div>
|
||
`;
|
||
|
||
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 `
|
||
<div class="artist-download-item" data-playlist-id="${virtualPlaylistId}">
|
||
<div class="download-item-artwork">
|
||
${album.image_url
|
||
? `<img src="${escapeHtml(album.image_url)}" alt="${escapeHtml(album.name)}" class="download-item-image" loading="lazy">`
|
||
: `<div class="download-item-fallback">
|
||
<i class="fas fa-${albumType === 'album' ? 'compact-disc' : albumType === 'single' ? 'music' : 'record-vinyl'}"></i>
|
||
</div>`
|
||
}
|
||
</div>
|
||
<div class="download-item-info">
|
||
<div class="download-item-name">${escapeHtml(album.name)}</div>
|
||
<div class="download-item-type">${albumType === 'album' ? 'Album' : albumType === 'single' ? 'Single' : 'EP'}</div>
|
||
</div>
|
||
<div class="download-item-actions">
|
||
<button class="download-item-btn ${buttonClass}"
|
||
onclick="openArtistDownloadProcess('${virtualPlaylistId}')">
|
||
${buttonText}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 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 = `
|
||
<div class="artist-downloads-header">
|
||
<h3 class="artist-downloads-title">Beatport Downloads</h3>
|
||
<p class="artist-downloads-subtitle">Active chart download processes</p>
|
||
</div>
|
||
<div class="artist-bubble-container" id="beatport-bubble-container">
|
||
${activeCharts.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')}
|
||
</div>
|
||
`;
|
||
|
||
// 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 `
|
||
<div class="artist-bubble-card ${allCompleted ? 'all-completed' : ''}"
|
||
data-chart-key="${chartKey}"
|
||
title="Click to manage downloads for ${escapeHtml(chart.name)}">
|
||
<div class="artist-bubble-image" style="${backgroundStyle}"></div>
|
||
<div class="artist-bubble-overlay"></div>
|
||
<div class="artist-bubble-content">
|
||
<div class="artist-bubble-name">${escapeHtml(chart.name)}</div>
|
||
<div class="artist-bubble-status">
|
||
${activeCount > 0 ? `${activeCount} active` : ''}
|
||
${completedCount > 0 ? `${completedCount} completed` : ''}
|
||
</div>
|
||
</div>
|
||
${allCompleted ? `
|
||
<div class="bulk-complete-indicator"
|
||
onclick="event.stopPropagation(); bulkCompleteBeatportDownloads('${chartKey}')"
|
||
title="Complete all downloads">
|
||
<span class="bulk-complete-icon">✅</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|
||
|
||
let _spotifyAuthCompletionListenerInstalled = false;
|
||
window._spotifyAuthWindow = window._spotifyAuthWindow || null;
|
||
|
||
function initializeSpotifyAuthCompletionListener() {
|
||
if (_spotifyAuthCompletionListenerInstalled) return;
|
||
_spotifyAuthCompletionListenerInstalled = true;
|
||
|
||
window.addEventListener('message', async event => {
|
||
if (!event.data || event.data.type !== 'spotify-auth-complete') return;
|
||
if (window._spotifyAuthWindow && event.source && event.source !== window._spotifyAuthWindow) return;
|
||
|
||
try {
|
||
window._spotifyAuthWindow = null;
|
||
await _forceServiceStatusRefresh();
|
||
if (event.data.authenticated === false) {
|
||
showToast(
|
||
event.data.detail || 'Spotify authorization completed, but no authenticated session was detected.',
|
||
'warning'
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.warn('Could not refresh Spotify status after auth completion:', 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
|
||
_lastStatusPayload = data;
|
||
|
||
if (typeof syncSpotifySettingsAuthState === 'function') {
|
||
syncSpotifySettingsAuthState(data?.spotify || null);
|
||
}
|
||
if (typeof syncPrimaryMetadataSourceAvailability === 'function') {
|
||
syncPrimaryMetadataSourceAvailability(data?.spotify || null);
|
||
}
|
||
if (typeof sanitizeMetadataSourceSelection === 'function') {
|
||
sanitizeMetadataSourceSelection({ quiet: true });
|
||
}
|
||
|
||
// Update service status indicators and text (dashboard)
|
||
updateServiceStatus('metadata-source', data.metadata_source, data.spotify);
|
||
updateServiceStatus('media-server', data.media_server);
|
||
updateServiceStatus('soulseek', data.soulseek);
|
||
|
||
// Update sidebar service status indicators
|
||
updateSidebarServiceStatus('metadata-source', data.metadata_source, 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 syncPrimaryMetadataSourceAvailability(statusData) {
|
||
const select = document.getElementById('metadata-fallback-source');
|
||
if (!select) return;
|
||
if (!statusData) return;
|
||
|
||
const spotifyOption = select.querySelector('option[value="spotify"]');
|
||
const discogsOption = select.querySelector('option[value="discogs"]');
|
||
|
||
const spotifyAvailable = statusData?.authenticated === true;
|
||
if (spotifyOption) {
|
||
spotifyOption.dataset.unavailable = spotifyAvailable ? 'false' : 'true';
|
||
spotifyOption.textContent = spotifyAvailable ? 'Spotify' : '🔒 Spotify';
|
||
spotifyOption.title = spotifyAvailable
|
||
? 'Spotify'
|
||
: 'Spotify authentication is required before this source can be selected.';
|
||
}
|
||
|
||
if (discogsOption) {
|
||
const discogsToken = document.getElementById('discogs-token');
|
||
const discogsAvailable = !!discogsToken?.value?.trim();
|
||
discogsOption.dataset.unavailable = discogsAvailable ? 'false' : 'true';
|
||
discogsOption.textContent = discogsAvailable ? 'Discogs' : '🔒 Discogs';
|
||
discogsOption.title = discogsAvailable
|
||
? 'Discogs'
|
||
: 'Discogs personal access token is required before this source can be selected.';
|
||
}
|
||
}
|
||
|
||
function getMetadataSourceLabel(source) {
|
||
if (source === 'deezer') return 'Deezer';
|
||
if (source === 'discogs') return 'Discogs';
|
||
if (source === 'hydrabase') return 'Hydrabase';
|
||
if (source === 'itunes') return 'iTunes';
|
||
if (source === 'musicbrainz') return 'MusicBrainz';
|
||
if (source === 'spotify') return 'Spotify';
|
||
return 'Unmapped';
|
||
}
|
||
|
||
function getMetadataSourcePresentation(metadataStatus, spotifyStatus) {
|
||
const source = metadataStatus?.source;
|
||
const sourceLabel = getMetadataSourceLabel(source);
|
||
const connected = metadataStatus?.connected === true;
|
||
const sessionActive = spotifyStatus?.authenticated === true || (source === 'spotify' && connected);
|
||
const rateLimited = !!(source === 'spotify' && spotifyStatus?.rate_limited && spotifyStatus?.rate_limit);
|
||
const cooldown = !!(source === 'spotify' && spotifyStatus?.post_ban_cooldown > 0);
|
||
|
||
if (rateLimited) {
|
||
const remaining = spotifyStatus.rate_limit?.remaining_seconds || 0;
|
||
return {
|
||
statusClass: 'rate-limited',
|
||
statusText: `Spotify paused \u2014 ${formatRateLimitDuration(remaining)}`,
|
||
dotClass: 'rate-limited',
|
||
dotTitle: `Spotify paused \u2014 ${formatRateLimitDuration(remaining)} remaining`,
|
||
sessionActive
|
||
};
|
||
}
|
||
|
||
if (cooldown) {
|
||
const remaining = spotifyStatus.post_ban_cooldown;
|
||
return {
|
||
statusClass: 'rate-limited',
|
||
statusText: `Spotify recovering \u2014 ${formatRateLimitDuration(remaining)}`,
|
||
dotClass: 'rate-limited',
|
||
dotTitle: `Spotify recovering \u2014 ${formatRateLimitDuration(remaining)} cooldown`,
|
||
sessionActive
|
||
};
|
||
}
|
||
|
||
if (source) {
|
||
return {
|
||
statusClass: connected ? 'connected' : 'disconnected',
|
||
statusText: connected ? (source === 'spotify' ? `Connected (${metadataStatus?.response_time}ms)` : sourceLabel) : 'Disconnected',
|
||
dotClass: connected ? 'connected' : 'disconnected',
|
||
dotTitle: connected ? sourceLabel : 'Disconnected',
|
||
sessionActive
|
||
};
|
||
}
|
||
|
||
return {
|
||
statusClass: 'disconnected',
|
||
statusText: 'Disconnected',
|
||
dotClass: 'disconnected',
|
||
dotTitle: 'Disconnected',
|
||
sessionActive
|
||
};
|
||
}
|
||
|
||
function updateServiceStatus(service, statusData, spotifyStatus = null) {
|
||
const serviceCard = document.getElementById(`${service}-service-card`);
|
||
const indicator = document.getElementById(`${service}-status-indicator`);
|
||
const statusText = document.getElementById(`${service}-status-text`);
|
||
|
||
if (serviceCard) {
|
||
serviceCard.dataset.statusReady = 'true';
|
||
}
|
||
|
||
if (indicator && statusText) {
|
||
if (service === 'metadata-source') {
|
||
const presentation = getMetadataSourcePresentation(statusData || {}, spotifyStatus || {});
|
||
indicator.className = `service-card-indicator ${presentation.statusClass}`;
|
||
statusText.textContent = presentation.statusText;
|
||
statusText.className = `service-card-status-text ${presentation.statusClass}`;
|
||
} 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 === 'metadata-source' && statusData.source) {
|
||
const musicSourceTitleElement = document.getElementById('metadata-source-title');
|
||
if (musicSourceTitleElement) {
|
||
const sourceName = getMetadataSourceLabel(statusData.source);
|
||
musicSourceTitleElement.textContent = sourceName;
|
||
currentMusicSourceName = sourceName;
|
||
}
|
||
|
||
// Keep the Spotify action buttons aligned with the actual auth session.
|
||
const spotifySessionActive = spotifyStatus?.authenticated === true;
|
||
const authBtn = document.querySelector('button[onclick="authenticateSpotify()"]');
|
||
const disconnectBtn = document.getElementById('spotify-disconnect-btn');
|
||
if (authBtn) {
|
||
authBtn.style.display = spotifySessionActive ? 'none' : '';
|
||
}
|
||
if (disconnectBtn) {
|
||
disconnectBtn.style.display = spotifySessionActive ? '' : 'none';
|
||
}
|
||
|
||
syncPrimaryMetadataSourceAvailability(spotifyStatus);
|
||
|
||
const responseTimeElement = document.getElementById('metadata-source-response-time');
|
||
if (responseTimeElement) {
|
||
const responseTime = statusData.response_time;
|
||
responseTimeElement.textContent = responseTime !== undefined && responseTime !== null
|
||
? `Response: ${responseTime}ms`
|
||
: 'Response: --';
|
||
}
|
||
|
||
const testButton = document.querySelector('#metadata-source-service-card .service-card-button');
|
||
if (testButton) {
|
||
const source = statusData.source || 'spotify';
|
||
testButton.setAttribute('onclick', `testDashboardConnection('${source}')`);
|
||
}
|
||
}
|
||
|
||
// 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', lidarr: 'Lidarr', soundcloud: 'SoundCloud', hybrid: 'Hybrid' };
|
||
const displayName = sourceNames[statusData.source] || 'Soulseek';
|
||
const titleEl = document.getElementById('download-source-title');
|
||
if (titleEl) titleEl.textContent = displayName;
|
||
}
|
||
}
|
||
|
||
function updateSidebarServiceStatus(service, statusData, spotifyStatus = null) {
|
||
const indicator = document.getElementById(`${service}-indicator`);
|
||
if (indicator) {
|
||
indicator.dataset.statusReady = 'true';
|
||
const dot = indicator.querySelector('.status-dot');
|
||
const nameElement = indicator.querySelector('.status-name');
|
||
|
||
if (dot) {
|
||
if (service === 'metadata-source') {
|
||
const presentation = getMetadataSourcePresentation(statusData || {}, spotifyStatus || {});
|
||
dot.className = `status-dot ${presentation.dotClass}`;
|
||
dot.title = presentation.dotTitle;
|
||
} else {
|
||
dot.className = statusData?.connected ? 'status-dot connected' : '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 === 'metadata-source' && statusData.source) {
|
||
const musicSourceNameElement = document.getElementById('metadata-source-name');
|
||
if (musicSourceNameElement) {
|
||
const sourceName = getMetadataSourceLabel(statusData.source);
|
||
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', lidarr: 'Lidarr', soundcloud: 'SoundCloud', 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 = `<span class="enrichment-chip-activity">${b.used.toLocaleString()} / ${b.limit.toLocaleString()}</span>`;
|
||
metaHtml = `<div class="enrichment-chip-budget">
|
||
<div class="enrichment-chip-budget-bar ${barClass}" style="width: ${pct}%"></div>
|
||
</div>`;
|
||
} else if (c24h > 0) {
|
||
// Other services: show 24h count
|
||
activityHtml = `<span class="enrichment-chip-activity">${c24h.toLocaleString()} / 24h</span>`;
|
||
}
|
||
}
|
||
|
||
// 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(`
|
||
<div class="enrichment-chip status-${statusClass}" ${clickAttr} title="${tooltipLines.join('\n')}">
|
||
<span class="enrichment-chip-dot"></span>
|
||
<span class="enrichment-chip-name">${svc.name}</span>
|
||
${activityHtml}
|
||
<span class="enrichment-chip-status">${statusDisplay}</span>
|
||
${metaHtml}
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
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 = `
|
||
<div style="width: 100%; text-align: center; padding: 40px 20px; color: rgba(255, 255, 255, 0.5);">
|
||
<div style="font-size: 18px; margin-bottom: 8px;">🎵</div>
|
||
<div style="font-size: 14px;">No similar artists found</div>
|
||
</div>
|
||
`;
|
||
} 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 = `
|
||
<div style="width: 100%; text-align: center; padding: 40px 20px; color: rgba(239, 68, 68, 0.7);">
|
||
<div style="font-size: 18px; margin-bottom: 8px;">⚠️</div>
|
||
<div style="font-size: 14px;">${error.message}</div>
|
||
</div>
|
||
`;
|
||
} 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 = `<img src="${data.image_url}" alt="${artistName}">`;
|
||
bubble.setAttribute('data-needs-image', 'false');
|
||
console.log(`✅ Loaded image for similar artist ${artistId}`);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error);
|
||
}
|
||
}));
|
||
}
|
||
|
||
console.log('✅ Finished lazy loading similar artist images');
|
||
}
|
||
|
||
/**
|
||
* Display similar artist bubble cards progressively (one at a time with delay)
|
||
*/
|
||
function displaySimilarArtistsProgressively(artists) {
|
||
const targets = _resolveSimilarArtistsTargets();
|
||
const container = targets && targets.container;
|
||
|
||
if (!container) {
|
||
console.warn('⚠️ Similar artists container not found');
|
||
return;
|
||
}
|
||
|
||
// Clear container
|
||
container.innerHTML = '';
|
||
|
||
// Add each bubble with a delay to simulate progressive loading
|
||
artists.forEach((artist, index) => {
|
||
setTimeout(() => {
|
||
const bubble = createSimilarArtistBubble(artist);
|
||
container.appendChild(bubble);
|
||
}, index * 100); // 100ms delay between each bubble
|
||
});
|
||
|
||
console.log(`✅ Displaying ${artists.length} similar artist bubbles progressively`);
|
||
}
|
||
|
||
/**
|
||
* Display similar artist bubble cards (all at once - legacy)
|
||
*/
|
||
function displaySimilarArtists(artists) {
|
||
const targets = _resolveSimilarArtistsTargets();
|
||
const container = targets && targets.container;
|
||
|
||
if (!container) {
|
||
console.warn('⚠️ Similar artists container not found');
|
||
return;
|
||
}
|
||
|
||
// Clear container
|
||
container.innerHTML = '';
|
||
|
||
// Create bubble cards with staggered animation
|
||
artists.forEach((artist, index) => {
|
||
const bubble = createSimilarArtistBubble(artist);
|
||
|
||
// Add staggered animation delay (50ms per bubble)
|
||
bubble.style.animationDelay = `${index * 0.05}s`;
|
||
|
||
container.appendChild(bubble);
|
||
});
|
||
|
||
console.log(`✅ Displayed ${artists.length} similar artist bubbles`);
|
||
}
|
||
|
||
/**
|
||
* Create a similar artist bubble card element
|
||
*/
|
||
function createSimilarArtistBubble(artist) {
|
||
// Create bubble container
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'similar-artist-bubble';
|
||
bubble.setAttribute('data-artist-id', artist.id);
|
||
bubble.setAttribute('data-artist-source', artist.source || '');
|
||
if (artist.plugin) {
|
||
bubble.setAttribute('data-artist-plugin', artist.plugin);
|
||
}
|
||
|
||
// Track if image needs lazy loading
|
||
const hasImage = artist.image_url && artist.image_url.trim() !== '';
|
||
bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true');
|
||
|
||
// Create image container
|
||
const imageContainer = document.createElement('div');
|
||
imageContainer.className = 'similar-artist-bubble-image';
|
||
|
||
if (hasImage) {
|
||
const img = document.createElement('img');
|
||
img.src = artist.image_url;
|
||
img.alt = artist.name;
|
||
|
||
// Handle image load error
|
||
img.onerror = () => {
|
||
console.log(`Failed to load image for ${artist.name}`);
|
||
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
||
bubble.setAttribute('data-needs-image', 'true');
|
||
};
|
||
|
||
imageContainer.appendChild(img);
|
||
} else {
|
||
// No image - show fallback (will be lazy loaded)
|
||
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
||
}
|
||
|
||
// 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/<id>/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 = '<span class="completion-status">Error</span>';
|
||
overlay.title = 'Failed to check completion status';
|
||
});
|
||
}
|