|
|
// 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',
|
|
|
},
|
|
|
musicbrainz: {
|
|
|
text: 'MusicBrainz', icon: '🧠',
|
|
|
logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png',
|
|
|
tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz',
|
|
|
},
|
|
|
youtube_videos: {
|
|
|
text: 'Music Videos', icon: '🎬',
|
|
|
tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube',
|
|
|
},
|
|
|
soulseek: {
|
|
|
// Routes through /api/search (raw slskd file results) — historically
|
|
|
// called "Basic Search" in the UI before the source picker landed.
|
|
|
text: 'Basic Search', icon: '🎼',
|
|
|
tabClass: 'enh-tab-soulseek', badgeClass: 'enh-badge-soulseek',
|
|
|
},
|
|
|
};
|
|
|
|
|
|
// Canonical display order for the source picker. Standard metadata sources
|
|
|
// first, then YouTube Music Videos, then Soulseek (basic-file source).
|
|
|
const SOURCE_ORDER = [
|
|
|
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz',
|
|
|
'youtube_videos', 'soulseek',
|
|
|
];
|
|
|
|
|
|
// Sources the config-status endpoint doesn't cover because they don't need
|
|
|
// user-supplied credentials — they always render as "configured" in the picker.
|
|
|
// Soulseek IS configurable (needs slskd URL), so it's intentionally not here:
|
|
|
// /api/settings/config-status reports its real state and the picker dims it
|
|
|
// when no slskd is set up, redirecting clicks to Settings → Downloads.
|
|
|
const _ALWAYS_CONFIGURED_SOURCES = new Set(['musicbrainz', 'youtube_videos']);
|
|
|
|
|
|
// Fetch /api/settings/config-status and return a map { src -> bool }
|
|
|
// covering every source in SOURCE_ORDER. Sources not present in the backend
|
|
|
// registry (musicbrainz / youtube_videos / soulseek) are reported as
|
|
|
// configured so the picker doesn't dim always-available sources.
|
|
|
async function fetchSourceConfiguredMap() {
|
|
|
const map = {};
|
|
|
try {
|
|
|
const resp = await fetch('/api/settings/config-status');
|
|
|
if (resp.ok) {
|
|
|
const data = await resp.json();
|
|
|
for (const src of SOURCE_ORDER) {
|
|
|
if (_ALWAYS_CONFIGURED_SOURCES.has(src)) {
|
|
|
map[src] = true;
|
|
|
} else {
|
|
|
map[src] = !!(data[src] && data[src].configured);
|
|
|
}
|
|
|
}
|
|
|
return map;
|
|
|
}
|
|
|
} catch (_) { /* fall through to conservative default */ }
|
|
|
// Network / endpoint failure — be permissive rather than dim everything.
|
|
|
for (const src of SOURCE_ORDER) map[src] = true;
|
|
|
return map;
|
|
|
}
|
|
|
|
|
|
// Shared source-picker controller used by both the unified Search page
|
|
|
// and the global search widget. Owns all the query/active-source/per-query
|
|
|
// cache state, fetch dispatch (enhanced-search for standard sources, NDJSON
|
|
|
// for YouTube Music Videos), configured-source discovery, fallback tracking,
|
|
|
// and icon-row rendering. Each surface passes per-surface wiring — DOM
|
|
|
// elements, a CSS class prefix, and callbacks — and the controller takes
|
|
|
// care of the rest.
|
|
|
//
|
|
|
// Config:
|
|
|
// sourceRowElement — HTMLElement where the icon row is rendered
|
|
|
// iconClassPrefix — 'enh' or 'gsearch' (drives CSS class names)
|
|
|
// onStateChange(state) — called whenever the surface should re-render
|
|
|
// results (cache hit, fetch settle, query reset)
|
|
|
// onSoulseekSelected(q) — surface decides what happens when the user
|
|
|
// clicks the Soulseek icon (basic-section swap
|
|
|
// on the Search page, /search handoff on the
|
|
|
// global widget)
|
|
|
// onUnconfiguredClick(src)— override the default "open Settings" behaviour
|
|
|
//
|
|
|
// Returned methods:
|
|
|
// init() — async; reads /api/settings + /api/settings/
|
|
|
// config-status, seeds default source, falls
|
|
|
// forward if primary is unconfigured, draws row
|
|
|
// submitQuery(query) — user typed a new query (clears cache on change)
|
|
|
// setActiveSource(src) — user clicked a different source icon
|
|
|
// renderSourceRow() — re-draws the icon row (call after state edits)
|
|
|
function createSearchController({
|
|
|
sourceRowElement,
|
|
|
iconClassPrefix = 'enh',
|
|
|
onStateChange,
|
|
|
onSoulseekSelected,
|
|
|
onUnconfiguredClick,
|
|
|
} = {}) {
|
|
|
const iconClass = `${iconClassPrefix}-source-icon`;
|
|
|
const glyphClass = `${iconClassPrefix}-source-icon-glyph`;
|
|
|
const labelClass = `${iconClassPrefix}-source-icon-label`;
|
|
|
|
|
|
// Per-query cache. `sources[src]` holds the result payload the last
|
|
|
// time `src` was fetched for the current query. `fallbacks[src]`
|
|
|
// records the source the backend actually served when it auto-fell-
|
|
|
// back (e.g. user clicked Spotify but got Deezer because Spotify is
|
|
|
// rate-limited). `loadingSources` drives per-icon spinners. The whole
|
|
|
// cache is cleared whenever the query string changes — we never
|
|
|
// serve stale results across queries.
|
|
|
const state = {
|
|
|
query: '',
|
|
|
activeSource: 'spotify',
|
|
|
sources: {},
|
|
|
fallbacks: {},
|
|
|
loadingSources: new Set(),
|
|
|
configuredSources: {},
|
|
|
_initialized: false,
|
|
|
};
|
|
|
// Optimistic default — replaced by the real config-status lookup on
|
|
|
// init. Prevents a flash of "all unconfigured" icons.
|
|
|
for (const src of SOURCE_ORDER) state.configuredSources[src] = true;
|
|
|
|
|
|
let abortCtrl = null;
|
|
|
// Per-source request tokens. Each _fetchSource call increments the
|
|
|
// monotonic _requestSeq and stamps it into _sourceRequestIds[src].
|
|
|
// Settle/error blocks bail before mutating shared state if their
|
|
|
// requestId no longer matches the latest id for THAT source —
|
|
|
// protecting against the fast-retype race (same-source supersession)
|
|
|
// without dropping cleanup for cross-source supersession.
|
|
|
//
|
|
|
// A single global token would mishandle cross-source: switching
|
|
|
// Spotify → Deezer aborts Spotify's fetch, but Spotify's catch needs
|
|
|
// to clear 'spotify' from loadingSources (Deezer's request hasn't
|
|
|
// touched it). Per-source tracking lets each source's catch own its
|
|
|
// own loadingSources entry.
|
|
|
let _requestSeq = 0;
|
|
|
const _sourceRequestIds = Object.create(null);
|
|
|
|
|
|
function _notify() { if (onStateChange) onStateChange(state); }
|
|
|
|
|
|
function renderSourceRow() {
|
|
|
if (!sourceRowElement) return;
|
|
|
sourceRowElement.innerHTML = SOURCE_ORDER.map(src => {
|
|
|
const info = SOURCE_LABELS[src];
|
|
|
if (!info) return '';
|
|
|
const active = src === state.activeSource;
|
|
|
const cached = !!state.sources[src];
|
|
|
const loading = state.loadingSources.has(src);
|
|
|
const fallback = state.fallbacks[src];
|
|
|
const configured = state.configuredSources[src] !== false;
|
|
|
|
|
|
const classes = [
|
|
|
iconClass,
|
|
|
active ? 'active' : '',
|
|
|
cached ? 'cached' : '',
|
|
|
loading ? 'loading' : '',
|
|
|
fallback ? 'fallback-warning' : '',
|
|
|
configured ? '' : 'unconfigured',
|
|
|
].filter(Boolean).join(' ');
|
|
|
|
|
|
let title;
|
|
|
if (!configured) {
|
|
|
title = `${info.text} — set up in Settings`;
|
|
|
} else if (fallback) {
|
|
|
title = `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`;
|
|
|
} else {
|
|
|
title = info.text;
|
|
|
}
|
|
|
|
|
|
const glyph = loading
|
|
|
? '⏳'
|
|
|
: (info.logo
|
|
|
? `<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.
|
|
|
try {
|
|
|
const resp = await fetch('/api/settings');
|
|
|
if (resp.ok) {
|
|
|
const settings = await resp.json();
|
|
|
const cfg = settings.metadata && settings.metadata.fallback_source;
|
|
|
if (cfg && SOURCE_LABELS[cfg]) state.activeSource = cfg;
|
|
|
}
|
|
|
} catch (_) { /* best-effort */ }
|
|
|
if (!SOURCE_LABELS[state.activeSource]) state.activeSource = 'spotify';
|
|
|
|
|
|
// Figure out which sources actually have credentials saved.
|
|
|
try {
|
|
|
state.configuredSources = await fetchSourceConfiguredMap();
|
|
|
} catch (_) { /* keep optimistic default */ }
|
|
|
|
|
|
// If the configured primary is itself unconfigured (Spotify saved
|
|
|
// as primary but no client_id yet), fall forward to the first
|
|
|
// configured source so the default active icon is usable.
|
|
|
if (state.configuredSources[state.activeSource] === false) {
|
|
|
const firstConfigured = SOURCE_ORDER.find(s => state.configuredSources[s] !== false);
|
|
|
if (firstConfigured) state.activeSource = firstConfigured;
|
|
|
}
|
|
|
|
|
|
renderSourceRow();
|
|
|
_notify();
|
|
|
}
|
|
|
|
|
|
function setActiveSource(src) {
|
|
|
if (!SOURCE_LABELS[src]) return;
|
|
|
|
|
|
// Unconfigured — jump to the relevant card in Settings rather than
|
|
|
// firing a search that can't succeed. Don't swap activeSource so the
|
|
|
// user's previous pick stays current when they come back.
|
|
|
if (state.configuredSources[src] === false) {
|
|
|
if (onUnconfiguredClick) onUnconfiguredClick(src);
|
|
|
else openSettingsForSource(src);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Clicking the already-active source is a no-op for normal sources,
|
|
|
// but for Soulseek we still re-fire the callback so the surface can
|
|
|
// re-issue the handoff (e.g. user typed and wants a fresh search).
|
|
|
if (src === state.activeSource) {
|
|
|
if (src === 'soulseek' && onSoulseekSelected) onSoulseekSelected(state.query);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
state.activeSource = src;
|
|
|
renderSourceRow();
|
|
|
|
|
|
// Soulseek — let the surface decide what to do (basic-section swap
|
|
|
// on Search page, /search handoff on global widget). We don't cache
|
|
|
// or auto-fetch soulseek results in the controller.
|
|
|
if (src === 'soulseek') {
|
|
|
if (onSoulseekSelected) onSoulseekSelected(state.query);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (state.sources[src]) {
|
|
|
_notify();
|
|
|
} else if (state.query) {
|
|
|
_fetchSource(src);
|
|
|
} else {
|
|
|
_notify();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function _fetchSource(src) {
|
|
|
const query = state.query;
|
|
|
if (!query) return;
|
|
|
|
|
|
const requestId = ++_requestSeq;
|
|
|
_sourceRequestIds[src] = requestId;
|
|
|
|
|
|
state.loadingSources.add(src);
|
|
|
renderSourceRow();
|
|
|
_notify();
|
|
|
|
|
|
if (abortCtrl) abortCtrl.abort();
|
|
|
abortCtrl = new AbortController();
|
|
|
|
|
|
try {
|
|
|
if (src === 'youtube_videos') {
|
|
|
await _fetchYouTubeVideos(query, abortCtrl.signal, requestId);
|
|
|
} else {
|
|
|
const data = await enhancedSearchFetch(query, {
|
|
|
source: src,
|
|
|
signal: abortCtrl.signal,
|
|
|
});
|
|
|
// Bail without writing if a newer request for THIS source
|
|
|
// has superseded us. Cross-source supersession (different
|
|
|
// src entirely) is handled by the loadingSources cleanup
|
|
|
// below — each source's catch owns its own entry.
|
|
|
if (_sourceRequestIds[src] !== requestId) return;
|
|
|
state.sources[src] = {
|
|
|
artists: data.spotify_artists || [],
|
|
|
albums: data.spotify_albums || [],
|
|
|
tracks: data.spotify_tracks || [],
|
|
|
videos: [],
|
|
|
db_artists: data.db_artists || [],
|
|
|
};
|
|
|
const served = data.primary_source || data.metadata_source;
|
|
|
if (served && served !== src) state.fallbacks[src] = served;
|
|
|
}
|
|
|
|
|
|
if (_sourceRequestIds[src] !== requestId) return;
|
|
|
state.loadingSources.delete(src);
|
|
|
renderSourceRow();
|
|
|
_notify();
|
|
|
} catch (err) {
|
|
|
// Only clear loadingSources if no newer request for THIS source
|
|
|
// is in flight. Cross-source supersession (e.g. user switched
|
|
|
// Spotify → Deezer) still falls through here so Spotify's
|
|
|
// spinner gets cleared on its own AbortError.
|
|
|
if (_sourceRequestIds[src] === requestId) {
|
|
|
state.loadingSources.delete(src);
|
|
|
renderSourceRow();
|
|
|
_notify();
|
|
|
}
|
|
|
if (err.name !== 'AbortError') {
|
|
|
console.debug(`Source fetch failed for ${src}:`, err);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function _fetchYouTubeVideos(query, signal, requestId) {
|
|
|
const res = await fetch('/api/enhanced-search/source/youtube_videos', {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({ query }),
|
|
|
signal,
|
|
|
});
|
|
|
if (!res.ok) throw new Error(`YouTube search failed: ${res.status}`);
|
|
|
|
|
|
// Bail before allocating cache entry if a newer YouTube request
|
|
|
// has superseded us.
|
|
|
if (_sourceRequestIds['youtube_videos'] !== requestId) return;
|
|
|
|
|
|
state.sources['youtube_videos'] = {
|
|
|
artists: [], albums: [], tracks: [], videos: [], db_artists: [],
|
|
|
};
|
|
|
const cache = state.sources['youtube_videos'];
|
|
|
|
|
|
const reader = res.body.getReader();
|
|
|
const decoder = new TextDecoder();
|
|
|
let buffer = '';
|
|
|
while (true) {
|
|
|
const { done, value } = await reader.read();
|
|
|
if (done) break;
|
|
|
if (_sourceRequestIds['youtube_videos'] !== requestId) return;
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
let idx;
|
|
|
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
|
const line = buffer.slice(0, idx).trim();
|
|
|
buffer = buffer.slice(idx + 1);
|
|
|
if (!line) continue;
|
|
|
try {
|
|
|
const chunk = JSON.parse(line);
|
|
|
if (chunk.type === 'videos') {
|
|
|
cache.videos = chunk.data;
|
|
|
if (state.activeSource === 'youtube_videos') _notify();
|
|
|
}
|
|
|
} catch (_) { /* best-effort NDJSON parse */ }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function submitQuery(query) {
|
|
|
if (query !== state.query) {
|
|
|
state.query = query;
|
|
|
state.sources = {};
|
|
|
state.fallbacks = {};
|
|
|
state.loadingSources = new Set();
|
|
|
// Invalidate every in-flight per-source token. Without this, a
|
|
|
// settle that arrives AFTER a query reset (e.g. user typed 'a',
|
|
|
// fetch started, then user cleared the input) would still
|
|
|
// pass the per-source token check and write stale data back
|
|
|
// into the just-cleared state.sources. Setting fresh tokens
|
|
|
// when each new _fetchSource fires re-stamps as needed.
|
|
|
for (const k in _sourceRequestIds) delete _sourceRequestIds[k];
|
|
|
// Abort the active fetch — its results are useless now.
|
|
|
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
|
|
|
renderSourceRow();
|
|
|
}
|
|
|
|
|
|
// Soulseek — surface handles the full query handoff.
|
|
|
if (state.activeSource === 'soulseek') {
|
|
|
if (onSoulseekSelected) onSoulseekSelected(query);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Cache hit — instant re-render, no fetch.
|
|
|
if (state.sources[state.activeSource]) {
|
|
|
_notify();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
_fetchSource(state.activeSource);
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
state,
|
|
|
init,
|
|
|
submitQuery,
|
|
|
setActiveSource,
|
|
|
renderSourceRow,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
|
|
|
// Navigate to Settings → relevant tab and scroll to the service card that
|
|
|
// matches the picker's source id. Called when a user clicks an unconfigured
|
|
|
// source icon. Soulseek is special-cased to land on the Downloads tab where
|
|
|
// its slskd URL field lives (gated behind the download-source-mode select);
|
|
|
// every other source has a card on Connections.
|
|
|
function openSettingsForSource(src) {
|
|
|
if (typeof navigateToPage !== 'function') return;
|
|
|
navigateToPage('settings');
|
|
|
const targetTab = src === 'soulseek' ? 'downloads' : 'connections';
|
|
|
setTimeout(() => {
|
|
|
try {
|
|
|
if (typeof switchSettingsTab === 'function') switchSettingsTab(targetTab);
|
|
|
} catch (_) { /* best-effort */ }
|
|
|
setTimeout(() => {
|
|
|
// Soulseek doesn't have a .stg-service card — scroll to the
|
|
|
// slskd URL input instead so the user lands on the right field.
|
|
|
const target = src === 'soulseek'
|
|
|
? document.querySelector('#settings-page #soulseek-url')
|
|
|
: document.querySelector(`#settings-page .stg-service[data-service="${src}"]`);
|
|
|
if (!target) return;
|
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
if (src === 'soulseek') {
|
|
|
try { target.focus(); } catch (_) { /* best-effort */ }
|
|
|
} else {
|
|
|
target.classList.add('stg-service-flash');
|
|
|
setTimeout(() => target.classList.remove('stg-service-flash'), 2200);
|
|
|
}
|
|
|
}, 120);
|
|
|
}, 60);
|
|
|
}
|
|
|
|
|
|
// Render a single enhanced-search result section (artists / albums / tracks).
|
|
|
// Shared between the Search page and the global widget. The mapItem callback
|
|
|
// projects each backend item to the card config consumed here.
|
|
|
function renderCompactSection(sectionId, listId, countId, items, mapItem) {
|
|
|
const section = document.getElementById(sectionId);
|
|
|
const list = document.getElementById(listId);
|
|
|
const count = document.getElementById(countId);
|
|
|
|
|
|
if (!list) return;
|
|
|
|
|
|
list.innerHTML = '';
|
|
|
|
|
|
if (!items || items.length === 0) {
|
|
|
section.classList.add('hidden');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
section.classList.remove('hidden');
|
|
|
count.textContent = items.length;
|
|
|
|
|
|
// Determine type based on section ID
|
|
|
const isArtist = sectionId.includes('artists');
|
|
|
const isAlbum = sectionId.includes('albums') || sectionId.includes('singles');
|
|
|
const isTrack = sectionId.includes('tracks');
|
|
|
|
|
|
// Add appropriate grid class to list
|
|
|
if (isArtist) {
|
|
|
list.classList.add('enh-artists-grid');
|
|
|
} else if (isAlbum) {
|
|
|
list.classList.add('enh-albums-grid');
|
|
|
} else if (isTrack) {
|
|
|
list.classList.add('enh-tracks-list');
|
|
|
}
|
|
|
|
|
|
items.forEach(item => {
|
|
|
const config = mapItem(item);
|
|
|
const elem = document.createElement('div');
|
|
|
|
|
|
// Add appropriate card class
|
|
|
if (isArtist) {
|
|
|
elem.className = 'enh-compact-item artist-card';
|
|
|
// Add data attributes for lazy loading
|
|
|
if (item.id) {
|
|
|
elem.dataset.artistId = item.id;
|
|
|
elem.dataset.needsImage = config.image ? 'false' : 'true';
|
|
|
// Stash the artist name so the lazy-loader can pass it to
|
|
|
// the backend. Needed for sources that don't store artist
|
|
|
// images directly (MusicBrainz) — backend resolves the
|
|
|
// image by looking up the name on a fallback source.
|
|
|
if (config.name) elem.dataset.artistName = config.name;
|
|
|
}
|
|
|
} else if (isAlbum) {
|
|
|
elem.className = 'enh-compact-item album-card';
|
|
|
} else if (isTrack) {
|
|
|
elem.className = 'enh-compact-item track-item';
|
|
|
}
|
|
|
|
|
|
// Build image HTML with type-specific classes
|
|
|
let imageClass = 'enh-item-image';
|
|
|
let placeholderClass = 'enh-item-image-placeholder';
|
|
|
|
|
|
if (isArtist) {
|
|
|
imageClass += ' artist-image';
|
|
|
placeholderClass += ' artist-placeholder';
|
|
|
} else if (isAlbum) {
|
|
|
imageClass += ' album-cover';
|
|
|
placeholderClass += ' album-placeholder';
|
|
|
} else if (isTrack) {
|
|
|
imageClass += ' track-cover';
|
|
|
placeholderClass += ' track-placeholder';
|
|
|
}
|
|
|
|
|
|
// Fallback placeholder used when the image 404s (common for MB
|
|
|
// Cover Art Archive URLs — we construct them deterministically
|
|
|
// without probing first, so some will miss). Without onerror the
|
|
|
// browser shows its broken-image icon.
|
|
|
const placeholderHtml = `<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,
|
|
|
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);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function fetchAndUpdateServiceStatus() {
|
|
|
if (document.hidden) return; // Skip polling when tab is not visible
|
|
|
if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll
|
|
|
try {
|
|
|
const response = await fetch('/status');
|
|
|
if (!response.ok) return;
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
// Cache for library status card
|
|
|
_lastServiceStatus = data;
|
|
|
|
|
|
// Update service status indicators and text (dashboard)
|
|
|
updateServiceStatus('spotify', data.spotify);
|
|
|
updateServiceStatus('media-server', data.media_server);
|
|
|
updateServiceStatus('soulseek', data.soulseek);
|
|
|
|
|
|
// Update sidebar service status indicators
|
|
|
updateSidebarServiceStatus('spotify', data.spotify);
|
|
|
updateSidebarServiceStatus('media-server', data.media_server);
|
|
|
updateSidebarServiceStatus('soulseek', data.soulseek);
|
|
|
|
|
|
// Update downloads nav badge
|
|
|
if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads);
|
|
|
|
|
|
// Hide sync buttons (not the page) for standalone mode
|
|
|
const isSoulsyncStandalone2 = data.media_server?.type === 'soulsync';
|
|
|
_isSoulsyncStandalone = isSoulsyncStandalone2;
|
|
|
document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => {
|
|
|
if (isSoulsyncStandalone2) {
|
|
|
btn.dataset.hiddenByStandalone = '1';
|
|
|
btn.style.display = 'none';
|
|
|
} else if (btn.dataset.hiddenByStandalone) {
|
|
|
delete btn.dataset.hiddenByStandalone;
|
|
|
btn.style.display = '';
|
|
|
}
|
|
|
});
|
|
|
|
|
|
// Update enrichment service cards
|
|
|
if (data.enrichment) renderEnrichmentCards(data.enrichment);
|
|
|
|
|
|
// Check for Spotify rate limit
|
|
|
if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) {
|
|
|
handleSpotifyRateLimit(data.spotify.rate_limit);
|
|
|
} else if (_spotifyRateLimitShown) {
|
|
|
handleSpotifyRateLimit(null);
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
console.warn('Could not fetch service status:', error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function updateServiceStatus(service, statusData) {
|
|
|
const indicator = document.getElementById(`${service}-status-indicator`);
|
|
|
const statusText = document.getElementById(`${service}-status-text`);
|
|
|
|
|
|
if (indicator && statusText) {
|
|
|
if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
|
|
|
indicator.className = 'service-card-indicator rate-limited';
|
|
|
const remaining = statusData.rate_limited
|
|
|
? formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)
|
|
|
: formatRateLimitDuration(statusData.post_ban_cooldown);
|
|
|
const phase = statusData.rate_limited ? 'paused' : 'recovering';
|
|
|
const fallbackLabel = statusData.source === 'deezer' ? 'Deezer' : 'iTunes';
|
|
|
statusText.textContent = `${fallbackLabel} (Spotify ${phase} \u2014 ${remaining})`;
|
|
|
statusText.className = 'service-card-status-text rate-limited';
|
|
|
} else if (statusData.connected) {
|
|
|
indicator.className = 'service-card-indicator connected';
|
|
|
statusText.textContent = `Connected (${statusData.response_time}ms)`;
|
|
|
statusText.className = 'service-card-status-text connected';
|
|
|
} else {
|
|
|
indicator.className = 'service-card-indicator disconnected';
|
|
|
statusText.textContent = 'Disconnected';
|
|
|
statusText.className = 'service-card-status-text disconnected';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Update music source title based on active source
|
|
|
if (service === 'spotify' && statusData.source) {
|
|
|
const musicSourceTitleElement = document.getElementById('music-source-title');
|
|
|
if (musicSourceTitleElement) {
|
|
|
const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes';
|
|
|
musicSourceTitleElement.textContent = sourceName;
|
|
|
currentMusicSourceName = sourceName;
|
|
|
}
|
|
|
|
|
|
// Show/hide Spotify disconnect button based on connection state
|
|
|
const disconnectBtn = document.getElementById('spotify-disconnect-btn');
|
|
|
if (disconnectBtn) {
|
|
|
disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Update download source title on dashboard card
|
|
|
if (service === 'soulseek' && statusData.source) {
|
|
|
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' };
|
|
|
const displayName = sourceNames[statusData.source] || 'Soulseek';
|
|
|
const titleEl = document.getElementById('download-source-title');
|
|
|
if (titleEl) titleEl.textContent = displayName;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function updateSidebarServiceStatus(service, statusData) {
|
|
|
const indicator = document.getElementById(`${service}-indicator`);
|
|
|
if (indicator) {
|
|
|
const dot = indicator.querySelector('.status-dot');
|
|
|
const nameElement = indicator.querySelector('.status-name');
|
|
|
|
|
|
if (dot) {
|
|
|
if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
|
|
|
dot.className = 'status-dot rate-limited';
|
|
|
dot.title = statusData.rate_limited
|
|
|
? `Spotify paused \u2014 ${formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)} remaining`
|
|
|
: `Spotify recovering \u2014 ${formatRateLimitDuration(statusData.post_ban_cooldown)} cooldown`;
|
|
|
} else if (statusData.connected) {
|
|
|
dot.className = 'status-dot connected';
|
|
|
dot.title = '';
|
|
|
} else {
|
|
|
dot.className = 'status-dot disconnected';
|
|
|
dot.title = '';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Update media server name if it's the media server indicator
|
|
|
if (service === 'media-server' && statusData.type) {
|
|
|
const mediaServerNameElement = document.getElementById('media-server-name');
|
|
|
if (mediaServerNameElement) {
|
|
|
const serverName = statusData.type.charAt(0).toUpperCase() + statusData.type.slice(1);
|
|
|
mediaServerNameElement.textContent = serverName;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Update music source name in sidebar based on active source
|
|
|
if (service === 'spotify' && statusData.source) {
|
|
|
const musicSourceNameElement = document.getElementById('music-source-name');
|
|
|
if (musicSourceNameElement) {
|
|
|
const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes';
|
|
|
musicSourceNameElement.textContent = sourceName;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Update download source name based on configured mode
|
|
|
if (service === 'soulseek' && statusData.source) {
|
|
|
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' };
|
|
|
const displayName = sourceNames[statusData.source] || 'Soulseek';
|
|
|
const sidebarName = document.getElementById('download-source-name');
|
|
|
if (sidebarName) sidebarName.textContent = displayName;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function renderEnrichmentCards(enrichment) {
|
|
|
const grid = document.getElementById('enrichment-status-grid');
|
|
|
if (!grid || !enrichment) return;
|
|
|
|
|
|
// Service display order
|
|
|
const serviceOrder = [
|
|
|
'musicbrainz', 'spotify_enrichment', 'itunes_enrichment', 'deezer_enrichment',
|
|
|
'tidal_enrichment', 'qobuz_enrichment', 'lastfm', 'genius', 'audiodb',
|
|
|
'acoustid', 'listenbrainz'
|
|
|
];
|
|
|
|
|
|
// Map service keys to their settings page selector for click-to-configure
|
|
|
const settingsSelectors = {
|
|
|
'spotify_enrichment': '.spotify-title',
|
|
|
'tidal_enrichment': '.tidal-title',
|
|
|
'qobuz_enrichment': '.qobuz-title',
|
|
|
'lastfm': '.lastfm-title',
|
|
|
'genius': '.genius-title',
|
|
|
'acoustid': '.acoustid-title',
|
|
|
'listenbrainz': '.listenbrainz-title',
|
|
|
};
|
|
|
|
|
|
const chips = [];
|
|
|
for (const key of serviceOrder) {
|
|
|
const svc = enrichment[key];
|
|
|
if (!svc) continue;
|
|
|
|
|
|
// Determine status class and text
|
|
|
let statusClass, statusLabel;
|
|
|
if ('running' in svc) {
|
|
|
if (!svc.configured) {
|
|
|
statusClass = 'not-configured';
|
|
|
statusLabel = 'Set up';
|
|
|
} else if (svc.paused) {
|
|
|
statusClass = 'paused';
|
|
|
statusLabel = svc.yield_reason === 'downloads' ? 'Yielding' : 'Paused';
|
|
|
} else if (svc.running) {
|
|
|
statusClass = svc.idle ? 'idle' : 'running';
|
|
|
statusLabel = svc.idle ? 'Idle' : 'Running';
|
|
|
} else {
|
|
|
statusClass = 'stopped';
|
|
|
statusLabel = 'Stopped';
|
|
|
}
|
|
|
} else {
|
|
|
statusClass = svc.configured ? 'running' : 'not-configured';
|
|
|
statusLabel = svc.configured ? 'Ready' : 'Set up';
|
|
|
}
|
|
|
|
|
|
const selector = settingsSelectors[key];
|
|
|
const clickAttr = selector
|
|
|
? `onclick="navigateToPage('settings'); setTimeout(() => { switchSettingsTab('connections'); setTimeout(() => { const el = document.querySelector('${selector}'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); }, 50);"`
|
|
|
: '';
|
|
|
|
|
|
// Build activity display — human-readable, not cryptic numbers
|
|
|
let activityHtml = '';
|
|
|
let metaHtml = '';
|
|
|
const isSpotify = key === 'spotify_enrichment';
|
|
|
|
|
|
if ('running' in svc && svc.configured) {
|
|
|
const c1h = svc.calls_1h || 0;
|
|
|
const c24h = svc.calls_24h || 0;
|
|
|
|
|
|
if (isSpotify && svc.daily_budget) {
|
|
|
// Spotify: show budget usage prominently
|
|
|
const b = svc.daily_budget;
|
|
|
const pct = Math.min(100, Math.round((b.used / b.limit) * 100));
|
|
|
const barClass = b.exhausted ? 'exhausted' : pct > 80 ? 'high' : '';
|
|
|
activityHtml = `<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';
|
|
|
});
|
|
|
}
|