// == API RATE MONITOR GAUGES ==
// ===============================
const _rateMonitorState = {};
const _RATE_GAUGE_SERVICES = [
'spotify', 'itunes', 'deezer', 'lastfm', 'genius',
'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs', 'amazon',
];
const _RATE_GAUGE_LABELS = {
spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer',
lastfm: 'Last.fm', genius: 'Genius', musicbrainz: 'MusicBrainz',
audiodb: 'AudioDB', tidal: 'Tidal', qobuz: 'Qobuz', discogs: 'Discogs',
amazon: 'Amazon Music',
};
const _RATE_GAUGE_COLORS = {
spotify: '#1DB954', itunes: '#FC3C44', deezer: '#A238FF',
lastfm: '#D51007', genius: '#FFFF64', musicbrainz: '#BA478F',
audiodb: '#00BCD4', tidal: '#00FFFF', qobuz: '#FF6B35', discogs: '#D4A574',
amazon: '#FF9900',
};
// Brand logos rendered inside each equalizer bar's avatar disc.
// Same URLs the header-actions worker-orb buttons use — sourced
// from each service's official press / SVG-repo asset so the row
// reads as branded chips, not anonymous initials. AudioDB ships
// no public logo URL, so it lives as a local static file.
const _RATE_GAUGE_LOGOS = {
spotify: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png',
itunes: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png',
deezer: 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610',
lastfm: 'https://www.last.fm/static/images/lastfm_avatar_twitter.52a5d69a85ac.png',
genius: 'https://images.genius.com/8ed669cadd956443e29c70361ec4f372.1000x1000x1.png',
musicbrainz:'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png',
audiodb: '/static/audiodb.png',
tidal: 'https://www.svgrepo.com/show/519734/tidal.svg',
qobuz: 'https://www.svgrepo.com/show/504778/qobuz.svg',
discogs: 'https://www.svgrepo.com/show/305957/discogs.svg',
amazon: '/static/amazon.svg',
};
// Per-service display state for the equalizer bars. Holds the last
// rendered count so we can animate digit changes via easing, and the
// last fill height so the spike-detect peak flash only fires on a
// real upward step (not on repaint / equal-value socket updates).
const _eqDisplay = {};
// SVG constants — 240° arc, gap at bottom
const _G = { size: 160, cx: 80, cy: 84, r: 56, stroke: 8, startAngle: 240, totalArc: 240 };
function _gPt(angle, radius) {
const rad = (angle - 90) * Math.PI / 180;
const r = radius || _G.r;
return { x: _G.cx + r * Math.cos(rad), y: _G.cy + r * Math.sin(rad) };
}
function _gArc(startDeg, endDeg, radius) {
const r = radius || _G.r;
const s = _gPt(startDeg, r), e = _gPt(endDeg, r);
const sweep = ((endDeg - startDeg + 360) % 360);
const large = sweep > 180 ? 1 : 0;
return `M${s.x},${s.y} A${r},${r} 0 ${large} 1 ${e.x},${e.y}`;
}
function _handleRateMonitorUpdate(data) {
const grid = document.getElementById('rate-monitor-grid');
if (!grid) return;
// Skip DOM writes while the equalizer is off-screen (you're on another page).
// All pages stay mounted, so updating a hidden grid still fires every
// MutationObserver on the document — including password-manager extensions
// that re-scan the WHOLE DOM on each mutation — for zero visible benefit.
// offsetParent is null when an ancestor is display:none. The next update that
// arrives while the dashboard is visible renders normally.
if (grid.offsetParent === null) return;
// The dashboard rate monitor uses the equalizer-bar visual — a
// vertical-bar VU-meter row that fits any service count without
// an orphan grid cell. Detail page / mobile breakpoints keep the
// legacy speedometer card markup so existing CSS still applies.
const useEqualizer = grid.classList.contains('rate-monitor-grid--equalizer')
|| grid.closest('[data-card="enrichment"]') !== null;
if (useEqualizer) {
grid.classList.add('rate-monitor-grid--equalizer');
_renderEqualizerBars(grid, data);
return;
}
if (!grid.children.length) {
for (const svc of _RATE_GAUGE_SERVICES) {
const div = document.createElement('div');
div.className = 'rate-gauge-card';
div.id = `rate-gauge-${svc}`;
div.onclick = () => _openRateModal(svc);
grid.appendChild(div);
}
}
for (const svc of _RATE_GAUGE_SERVICES) {
const d = data[svc];
if (!d) continue;
_rateMonitorState[svc] = d;
const container = document.getElementById(`rate-gauge-${svc}`);
if (!container) continue;
const value = d.cpm || 0;
const max = d.limit || 60;
const pct = Math.min(value / max, 1);
const accent = _RATE_GAUGE_COLORS[svc] || '#888';
const label = _RATE_GAUGE_LABELS[svc] || svc;
const worker = d.worker || {};
const wStatus = worker.status || 'stopped';
const isRateLimited = d.rate_limited === true;
// Build or update the card content
let gaugeWrap = container.querySelector('.gauge-arc-wrap');
if (!gaugeWrap) {
// Full rebuild
container.innerHTML = `
${_buildGaugeSVG(svc, value, max)}
${value.toFixed(0)} calls/min
${worker.calls_1h || 0} last hour
${(worker.calls_24h || 0).toLocaleString()} 24h
${svc === 'spotify' && worker.daily_budget ? _buildBudgetBar(worker.daily_budget) : ''}
${isRateLimited ? _buildRateLimitBadge(d) : ''}
`;
} else {
// Fast update — only change values
_updateGauge(gaugeWrap, value, max, svc);
// Update status
const statusEl = container.querySelector('.gauge-card-status');
if (statusEl) {
statusEl.dataset.status = wStatus;
statusEl.textContent = _workerStatusLabel(wStatus, worker);
}
// Update stats
const statVals = container.querySelectorAll('.gauge-card-stat-val');
if (statVals[0]) statVals[0].textContent = value.toFixed(0);
if (statVals[1]) statVals[1].textContent = worker.calls_1h || 0;
if (statVals[2]) statVals[2].textContent = (worker.calls_24h || 0).toLocaleString();
// Budget bar (Spotify)
if (svc === 'spotify' && worker.daily_budget) {
let budgetEl = container.querySelector('.gauge-budget-bar');
if (!budgetEl) {
const div = document.createElement('div');
div.innerHTML = _buildBudgetBar(worker.daily_budget);
const statsEl = container.querySelector('.gauge-card-stats');
if (statsEl) statsEl.after(div.firstElementChild);
} else {
const b = worker.daily_budget;
const pctB = Math.min(100, Math.round((b.used / b.limit) * 100));
const fill = budgetEl.querySelector('.gauge-budget-fill');
if (fill) { fill.style.width = pctB + '%'; }
const label = budgetEl.querySelector('.gauge-budget-label');
if (label) label.textContent = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()} daily`;
}
}
// Rate limit badge
let badge = container.querySelector('.gauge-rl-badge');
if (isRateLimited) {
if (!badge) {
const div = document.createElement('div');
div.innerHTML = _buildRateLimitBadge(d);
container.appendChild(div.firstElementChild);
} else {
const mins = Math.ceil((d.rl_remaining || 0) / 60);
const timeEl = badge.querySelector('.gauge-rl-time');
if (timeEl) timeEl.textContent = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
}
} else if (badge) {
badge.remove();
}
}
container.classList.toggle('danger', pct > 0.8 || isRateLimited);
container.classList.toggle('active', value > 0 || wStatus === 'running');
container.classList.toggle('rate-limited', isRateLimited);
}
}
// ── Equalizer-bar renderer (dashboard) ─────────────────────────────────
//
// VU-meter aesthetic: one vertical bar per service. Bar height = current
// rate / limit. Service brand color fades up the bar with an animated
// glow at the tip when active. Click opens the same detail modal the
// speedometer used. Symmetric by design — any service count fits one
// flex row regardless of viewport.
function _renderEqualizerBars(grid, data) {
if (!grid.children.length) {
for (const svc of _RATE_GAUGE_SERVICES) {
const accent = _RATE_GAUGE_COLORS[svc] || '#888';
const label = _RATE_GAUGE_LABELS[svc] || svc;
const logoSrc = _RATE_GAUGE_LOGOS[svc] || '';
const fallbackGlyph = (label[0] || '?').toUpperCase();
const bar = document.createElement('button');
bar.type = 'button';
bar.className = 'rate-eq';
bar.id = `rate-eq-${svc}`;
bar.dataset.svc = svc;
bar.style.setProperty('--eq-accent', accent);
bar.setAttribute('aria-label', `${label} rate detail`);
bar.onclick = () => _openRateModal(svc);
// Layout, top-to-bottom:
// - avatar disc (brand logo) anchored above the track —
// ``onerror`` swap to the initial-letter glyph keeps the
// UI legible if a CDN URL ever breaks
// - track (with reflection puddle as ::after) holding the
// fill / shimmer / peak tip / live count
// - meta column (state pill + service name)
const avatar = logoSrc
? ``
: `
${fallbackGlyph}
`;
bar.innerHTML = `
${avatar}
Stopped
${label}
`;
grid.appendChild(bar);
_eqDisplay[svc] = { value: 0, pct: 0.04 };
}
}
for (const svc of _RATE_GAUGE_SERVICES) {
const d = data[svc];
if (!d) continue;
_rateMonitorState[svc] = d;
const bar = document.getElementById(`rate-eq-${svc}`);
if (!bar) continue;
const value = d.cpm || 0;
const max = d.limit || 60;
// Clamp the visual fill to [4%, 100%]. The 4% floor lets idle
// bars still show a sliver of accent so the row reads as
// present-but-quiet instead of empty — critical for the
// "everything alive" vibe; an actual zero would make most
// services disappear most of the time.
const pct = Math.max(0.04, Math.min(value / max, 1));
const worker = d.worker || {};
const wStatus = worker.status || 'stopped';
const isRateLimited = d.rate_limited === true;
const prev = _eqDisplay[svc] || { value: 0, pct: 0.04 };
const fill = bar.querySelector('.rate-eq-fill');
if (fill) fill.style.height = `${pct * 100}%`;
// Peak-hold tick — the VU-meter idiom: a thin marker sticks at the
// recent maximum, holds ~1.2s, then falls a few %/update until it
// rests on the live fill. A burst stays readable after it's over.
const nowTs = performance.now();
let peak = prev.peak ?? pct;
let peakAt = prev.peakAt ?? nowTs;
if (pct >= peak) {
peak = pct;
peakAt = nowTs;
} else if (nowTs - peakAt > 1200) {
peak = Math.max(pct, peak - 0.045);
}
const peakEl = bar.querySelector('.rate-eq-peak');
if (peakEl) {
peakEl.style.bottom = `${peak * 100}%`;
// Only show while meaningfully above the live fill — idle bars
// shouldn't carry a stray line.
peakEl.classList.toggle('visible', peak > pct + 0.015 && peak > 0.06);
}
// The reflection puddle (CSS ::after on the track) fades in
// proportional to the real (unclamped) rate so idle services
// don't pollute the row with a floor of glow. Bound to a
// CSS variable so the puddle and any future glow-aware
// styling can share the same source-of-truth value.
const realPct = max > 0 ? value / max : 0;
bar.style.setProperty('--eq-glow', String(Math.min(1, realPct + 0.04)));
// Rolling counter — animate the digit change instead of
// snapping. Premium feel; matches the smooth height
// transition the fill already has.
const val = bar.querySelector('.rate-eq-value');
if (val) {
const rounded = Math.round(value);
const prevRounded = Math.round(prev.value);
if (rounded !== prevRounded) {
_animateRollingNumber(val, prevRounded, rounded);
} else {
val.textContent = rounded;
}
}
// Peak-flash detector: if the rate stepped UPWARD between
// socket updates, briefly trigger the .peak-flash class on
// the bar so the tip emits a quick accent burst. Mimics a
// hardware VU meter's peak-detect LED — sells the "alive"
// feeling and ties bar movement to actual call activity.
// Only fires on real increases above a small noise floor
// (jitter on near-zero rates would otherwise pulse the
// bar constantly).
const PEAK_JITTER_THRESHOLD = 1;
if (value - prev.value > PEAK_JITTER_THRESHOLD) {
bar.classList.remove('peak-flash');
// Force a reflow so re-adding the class restarts the
// animation even if it was already mid-cycle.
void bar.offsetWidth; // eslint-disable-line no-unused-expressions
bar.classList.add('peak-flash');
window.setTimeout(() => bar.classList.remove('peak-flash'), 700);
}
const state = bar.querySelector('.rate-eq-state');
if (state) {
state.dataset.status = wStatus;
const text = state.querySelector('.rate-eq-state-text');
if (text) text.textContent = _workerStatusLabel(wStatus, worker);
}
// Danger threshold uses the REAL (unclamped) ratio so the
// 4% visual floor for idle bars doesn't push everything into
// a permanent green state — the threshold reads true rate.
bar.classList.toggle('danger', realPct > 0.8 || isRateLimited);
bar.classList.toggle('active', value > 0 || wStatus === 'running');
bar.classList.toggle('rate-limited', isRateLimited);
// Cooldown drain — a ban isn't just "red", it's a COUNTDOWN. The
// payload only carries seconds remaining, so latch the largest value
// seen this ban as the denominator; the red column then drains away
// as the ban ticks down and the timer shows m:ss until recovery.
const rlRemaining = isRateLimited ? Math.max(0, Math.round(d.rl_remaining || 0)) : 0;
let rlTotal = prev.rlTotal || 0;
const cooling = rlRemaining > 0;
if (cooling) {
rlTotal = Math.max(rlTotal, rlRemaining);
const cdFill = bar.querySelector('.rate-eq-cooldown-fill');
if (cdFill) cdFill.style.height = `${(rlRemaining / rlTotal) * 100}%`;
const cdTime = bar.querySelector('.rate-eq-cooldown-time');
if (cdTime) {
const m = Math.floor(rlRemaining / 60);
const s = String(rlRemaining % 60).padStart(2, '0');
cdTime.textContent = `${m}:${s}`;
}
} else {
rlTotal = 0;
// Recovery moment: the ban just ended — flash the bar back alive.
if (prev.cooling) {
bar.classList.remove('recovered');
void bar.offsetWidth; // restart the animation
bar.classList.add('recovered');
window.setTimeout(() => bar.classList.remove('recovered'), 1300);
}
}
bar.classList.toggle('cooldown', cooling);
// Call embers: tiny accent sparks rise off the fill tip, spawned per
// socket update in proportion to REAL traffic — motion strictly means
// API calls are happening right now. Suppressed during cooldown and
// under reduced-effects.
if (!window._reduceEffectsActive && !cooling && realPct > 0.03) {
_spawnEmbers(bar, pct, realPct > 0.6 ? 3 : realPct > 0.25 ? 2 : 1);
}
// Daily-budget ring (Spotify is the only service with a real daily
// cap): a conic rim inside the avatar disc fills as the budget is
// spent — green → amber → red, purple once the worker has bridged
// to Spotify Free for the rest of the day.
const budget = worker.daily_budget;
if (budget && budget.limit > 0) {
const bPct = Math.min(1, (budget.used || 0) / budget.limit);
const bridged = !!worker.using_free && !!budget.exhausted;
bar.classList.add('has-budget');
bar.style.setProperty('--eq-budget', String(Math.max(bPct, 0.02)));
bar.style.setProperty('--eq-budget-color',
bridged ? '#a78bfa' : bPct < 0.7 ? '#4ade80' : bPct < 0.95 ? '#fbbf24' : '#ef4444');
const avatar = bar.querySelector('.rate-eq-avatar');
if (avatar) {
avatar.title = bridged
? `Daily budget spent — running on Spotify Free (${budget.used}/${budget.limit})`
: `Daily API budget: ${budget.used}/${budget.limit}`;
}
} else {
bar.classList.remove('has-budget');
}
_eqDisplay[svc] = { value, pct, peak, peakAt, rlTotal, cooling };
}
}
// Spawn ember particles at a bar's fill tip. Self-removing DOM sparks with
// a per-bar live cap so a busy hour can't accumulate nodes.
function _spawnEmbers(bar, pct, count) {
const track = bar.querySelector('.rate-eq-track');
if (!track || track.querySelectorAll('.rate-eq-ember').length > 6) return;
for (let i = 0; i < count; i++) {
const e = document.createElement('span');
e.className = 'rate-eq-ember';
e.style.left = `${20 + Math.random() * 60}%`;
e.style.bottom = `${Math.min(96, pct * 100)}%`;
e.style.setProperty('--ember-drift', `${(Math.random() - 0.5) * 14}px`);
e.style.animationDuration = `${1.1 + Math.random() * 0.7}s`;
e.addEventListener('animationend', () => e.remove());
track.appendChild(e);
}
}
// Animate a single integer counter from `from` to `to` with an
// easeOutCubic curve. Used by the equalizer bars so the live count
// digit-rolls instead of snapping when sockets push a new value.
const _eqRollingHandles = new WeakMap();
function _animateRollingNumber(el, from, to, duration = 520) {
// Cancel any animation already running on this element so we
// don't get two RAF loops fighting over its textContent.
const prev = _eqRollingHandles.get(el);
if (prev) cancelAnimationFrame(prev);
if (from === to) {
el.textContent = String(to);
return;
}
const start = performance.now();
const span = to - from;
function step(now) {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
el.textContent = String(Math.round(from + span * eased));
if (t < 1) {
_eqRollingHandles.set(el, requestAnimationFrame(step));
} else {
_eqRollingHandles.delete(el);
}
}
_eqRollingHandles.set(el, requestAnimationFrame(step));
}
function _workerStatusLabel(status, worker) {
if (status === 'not_configured') return 'Not configured';
if (status === 'paused') return worker.yield_reason === 'downloads' ? 'Yielding' : 'Paused';
if (status === 'idle') return 'Idle';
if (status === 'running') return 'Running';
return 'Stopped';
}
function _buildBudgetBar(budget) {
const pct = Math.min(100, Math.round((budget.used / budget.limit) * 100));
const cls = budget.exhausted ? 'exhausted' : pct > 80 ? 'high' : '';
return `
${budget.used.toLocaleString()} / ${budget.limit.toLocaleString()} daily
`;
}
function _buildRateLimitBadge(d) {
const mins = Math.ceil((d.rl_remaining || 0) / 60);
const text = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
return ` RATE LIMITED${text}
`;
}
function _buildGaugeSVG(svc, value, max) {
const { size, cx, cy, r, stroke, startAngle, totalArc } = _G;
const label = _RATE_GAUGE_LABELS[svc] || svc;
const accent = _RATE_GAUGE_COLORS[svc] || '#888';
const pct = Math.min(value / max, 1);
const endAngle = startAngle + pct * totalArc;
const arcEnd = startAngle + totalArc;
const glowId = `glow-${svc}`;
// Endpoint dot position
const dot = pct > 0 ? _gPt(endAngle, r) : null;
// Gradient ID for the colored arc
const gradId = `grad-${svc}`;
const color = pct > 0.8 ? '#ef4444' : pct > 0.6 ? '#eab308' : accent;
return `
${pct > 0 ? ` ` : ''}
${dot ? ` ` : ''}
0
${max}
${Math.round(value)}
/min
${label}
`;
}
function _updateGauge(container, value, max, svc) {
const { r, stroke, startAngle, totalArc } = _G;
const accent = _RATE_GAUGE_COLORS[svc] || '#888';
const pct = Math.min(value / max, 1);
const endAngle = startAngle + pct * totalArc;
const color = pct > 0.8 ? '#ef4444' : pct > 0.6 ? '#eab308' : accent;
// Update center value
const valText = container.querySelector('.gauge-value');
if (valText) valText.textContent = Math.round(value);
// Update active arc
const activeArc = container.querySelector('.gauge-active-arc');
if (pct > 0) {
const d = _gArc(startAngle, endAngle);
if (activeArc) {
activeArc.setAttribute('d', d);
activeArc.setAttribute('stroke', color);
activeArc.style.filter = `drop-shadow(0 0 6px ${color}60)`;
} else {
// Rebuild the whole gauge when transitioning from 0 to active
container.innerHTML = _buildGaugeSVG(svc, value, max);
return;
}
} else if (activeArc) {
activeArc.remove();
// Also remove dots
container.querySelectorAll('.gauge-dot').forEach(d => d.remove());
const innerDot = container.querySelector('.gauge-dot + circle');
if (innerDot) innerDot.remove();
return;
}
// Update endpoint dot
const gaugeDot = container.querySelector('.gauge-dot');
if (pct > 0 && gaugeDot) {
const dot = _gPt(endAngle, r);
gaugeDot.setAttribute('cx', dot.x);
gaugeDot.setAttribute('cy', dot.y);
gaugeDot.setAttribute('fill', color);
gaugeDot.style.filter = `drop-shadow(0 0 4px ${color}80)`;
const inner = gaugeDot.nextElementSibling;
if (inner && inner.tagName === 'circle') {
inner.setAttribute('cx', dot.x);
inner.setAttribute('cy', dot.y);
}
}
}
// ── Rate Monitor Detail Modal ──
let _rateModalService = null;
let _rateModalInterval = null;
function _openRateModal(serviceKey) {
_rateModalService = serviceKey;
const label = _RATE_GAUGE_LABELS[serviceKey] || serviceKey;
const accent = _RATE_GAUGE_COLORS[serviceKey] || '#888';
let overlay = document.getElementById('rate-modal-overlay');
if (overlay) overlay.remove();
overlay = document.createElement('div');
overlay.id = 'rate-modal-overlay';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) _closeRateModal(); };
const isSpotify = serviceKey === 'spotify';
const currentData = _rateMonitorState[serviceKey] || {};
overlay.innerHTML = `
24-Hour Call History
${isSpotify ? '
Per-Endpoint Breakdown
' : ''}
`;
document.body.appendChild(overlay);
// Fetch main history + per-endpoint histories for Spotify
const historyPromises = [
fetch(`/api/rate-monitor/history/${serviceKey}`).then(r => r.json())
];
if (isSpotify) {
const activeEps = Object.keys(_rateMonitorState.spotify?.endpoints || {});
for (const ep of activeEps) {
historyPromises.push(
fetch(`/api/rate-monitor/history/spotify:${ep}`).then(r => r.json()).catch(() => null)
);
}
}
Promise.all(historyPromises).then(results => {
const main = results[0];
const epHistories = isSpotify ? results.slice(1).filter(Boolean) : [];
_renderRateChart(main.history || [], main.rate_limit || 60, accent, epHistories);
}).catch(() => { });
if (isSpotify) {
_updateSpotifyEndpoints();
_rateModalInterval = setInterval(_updateSpotifyEndpoints, 1000);
}
}
function _closeRateModal() {
const overlay = document.getElementById('rate-modal-overlay');
if (overlay) overlay.remove();
if (_rateModalInterval) { clearInterval(_rateModalInterval); _rateModalInterval = null; }
_rateModalService = null;
}
function _renderRateChart(history, rateLimit, accent, epHistories = []) {
const canvas = document.getElementById('rate-modal-chart');
if (!canvas) return;
// HiDPI support
const dpr = window.devicePixelRatio || 1;
const W = 700, H = 280;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const pad = { top: 24, right: 24, bottom: 36, left: 50 };
const plotW = W - pad.left - pad.right;
const plotH = H - pad.top - pad.bottom;
ctx.clearRect(0, 0, W, H);
// Build data points
const now = Math.floor(Date.now() / 1000);
const start = now - 86400;
const points = [];
if (history.length > 0) {
const histMap = new Map(history.map(h => [h[0], h[1]]));
for (let t = start; t <= now; t += 300) {
const bucket = Math.floor(t / 60) * 60;
let sum = 0;
for (let m = bucket; m < bucket + 300; m += 60) sum += histMap.get(m) || 0;
points.push({ t, v: sum / 5 });
}
}
const maxVal = Math.max(rateLimit * 1.15, ...points.map(p => p.v), 1);
// Grid lines (horizontal)
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
for (let i = 1; i <= 4; i++) {
const y = pad.top + plotH * (1 - i / 4);
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(pad.left + plotW, y);
ctx.stroke();
}
// Danger zone band
const dangerY = pad.top + plotH * (1 - rateLimit / maxVal);
const grad = ctx.createLinearGradient(0, pad.top, 0, dangerY);
grad.addColorStop(0, 'rgba(239, 68, 68, 0.08)');
grad.addColorStop(1, 'rgba(239, 68, 68, 0.02)');
ctx.fillStyle = grad;
ctx.fillRect(pad.left, pad.top, plotW, dangerY - pad.top);
// Rate limit line
ctx.strokeStyle = 'rgba(239, 68, 68, 0.5)';
ctx.setLineDash([8, 5]);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(pad.left, dangerY);
ctx.lineTo(pad.left + plotW, dangerY);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(239, 68, 68, 0.6)';
ctx.font = '10px -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`Rate limit: ${rateLimit}/min`, pad.left + 6, dangerY - 6);
// Draw area fill + line
if (points.length > 1) {
// Area gradient fill
const areaGrad = ctx.createLinearGradient(0, pad.top, 0, pad.top + plotH);
// Parse accent to rgba
areaGrad.addColorStop(0, accent + '30');
areaGrad.addColorStop(1, accent + '05');
ctx.beginPath();
points.forEach((p, i) => {
const x = pad.left + (i / (points.length - 1)) * plotW;
const y = pad.top + plotH * (1 - p.v / maxVal);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.lineTo(pad.left + plotW, pad.top + plotH);
ctx.lineTo(pad.left, pad.top + plotH);
ctx.closePath();
ctx.fillStyle = areaGrad;
ctx.fill();
// Line
ctx.beginPath();
points.forEach((p, i) => {
const x = pad.left + (i / (points.length - 1)) * plotW;
const y = pad.top + plotH * (1 - p.v / maxVal);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.strokeStyle = accent;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.stroke();
// Glow effect
ctx.shadowColor = accent;
ctx.shadowBlur = 8;
ctx.stroke();
ctx.shadowBlur = 0;
}
// Per-endpoint lines (Spotify breakdown)
const legendEl = document.getElementById('rate-modal-chart-legend');
if (epHistories.length > 0) {
const epColors = ['#1DB954', '#FF6B6B', '#4ECDC4', '#FFE66D', '#A78BFA', '#F97316', '#06B6D4', '#EC4899', '#F472B6', '#34D399'];
const legendItems = [];
epHistories.forEach((epData, idx) => {
if (!epData || !epData.history || epData.history.length === 0) return;
const epName = (epData.service || '').replace('spotify:', '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const color = epColors[idx % epColors.length];
legendItems.push({ name: epName, color });
const histMap = new Map(epData.history.map(h => [h[0], h[1]]));
const epPoints = [];
for (let t = start; t <= now; t += 300) {
const bucket = Math.floor(t / 60) * 60;
let sum = 0;
for (let m = bucket; m < bucket + 300; m += 60) sum += histMap.get(m) || 0;
epPoints.push({ t, v: sum / 5 });
}
if (epPoints.length > 1) {
ctx.beginPath();
epPoints.forEach((p, i) => {
const x = pad.left + (i / (epPoints.length - 1)) * plotW;
const y = pad.top + plotH * (1 - p.v / maxVal);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.strokeStyle = color + 'BB';
ctx.lineWidth = 1.5;
ctx.lineJoin = 'round';
ctx.stroke();
}
});
// HTML legend below chart
if (legendEl && legendItems.length > 0) {
legendEl.innerHTML = legendItems.map(item =>
` ${item.name} `
).join('');
}
} else if (legendEl) {
legendEl.innerHTML = '';
}
// X-axis labels
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '10px -apple-system, sans-serif';
ctx.textAlign = 'center';
for (let i = 0; i <= 6; i++) {
const t = start + (86400 * i / 6);
const x = pad.left + (i / 6) * plotW;
const d = new Date(t * 1000);
const hr = d.getHours();
const label = hr === 0 ? '12am' : hr < 12 ? `${hr}am` : hr === 12 ? '12pm' : `${hr - 12}pm`;
ctx.fillText(label, x, H - 10);
// Subtle vertical grid
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, pad.top);
ctx.lineTo(x, pad.top + plotH);
ctx.stroke();
}
// Y-axis labels
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.textAlign = 'right';
ctx.font = '10px -apple-system, sans-serif';
for (let i = 0; i <= 4; i++) {
const v = maxVal * i / 4;
const y = pad.top + plotH * (1 - i / 4);
ctx.fillText(Math.round(v), pad.left - 8, y + 4);
}
// Empty state
if (points.length === 0) {
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.font = '13px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('No call history yet — data populates as API calls are made', W / 2, H / 2);
}
}
function _updateSpotifyEndpoints() {
const container = document.getElementById('rate-modal-endpoints');
if (!container) return;
const endpoints = _rateMonitorState.spotify?.endpoints || {};
const entries = Object.entries(endpoints).sort((a, b) => b[1] - a[1]);
if (entries.length === 0) {
container.innerHTML = 'No active Spotify endpoints — start an enrichment worker or search to see activity
';
return;
}
const limit = _rateMonitorState.spotify?.limit || 171;
container.innerHTML = entries.map(([ep, cpm]) => {
const pct = Math.min(cpm / limit * 100, 100);
const name = ep.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const color = pct > 80 ? '#ef4444' : pct > 60 ? '#eab308' : '#1DB954';
return `
${name}
${Math.round(cpm)}/min
`;
}).join('');
}
async function fetchAndUpdateSystemStats() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/system/stats');
if (!response.ok) return;
const data = await response.json();
// Update all stat cards
updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading');
updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed downloads');
updateStatCard('download-speed-card', data.download_speed, 'Combined speed');
updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing');
updateStatCard('uptime-card', data.uptime, 'Application runtime');
updateStatCard('memory-card', data.memory_usage, 'Current usage');
} catch (error) {
console.warn('Could not fetch system stats:', error);
}
}
function updateStatCard(cardId, value, subtitle) {
const card = document.getElementById(cardId);
if (card) {
const valueElement = card.querySelector('.stat-card-value');
const subtitleElement = card.querySelector('.stat-card-subtitle');
if (valueElement) {
valueElement.textContent = value;
}
if (subtitleElement) {
subtitleElement.textContent = subtitle;
}
}
}
async function fetchAndUpdateActivityFeed() {
if (socketConnected) return; // WebSocket handles this
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/activity/feed');
if (!response.ok) {
console.warn('Activity feed response not ok:', response.status, response.statusText);
return;
}
const data = await response.json();
console.log('Activity feed data received:', data);
updateActivityFeed(data.activities || []);
} catch (error) {
console.warn('Could not fetch activity feed:', error);
}
}
// Cache last feed signature to avoid unnecessary DOM rebuilds (prevents blink)
let _lastActivityFeedSig = '';
// Activity items carry `timestamp` (Unix epoch seconds) — `activity.time`
// is a human label like "Now" that doesn't parse as a date. Use the
// epoch for relative-time formatting; fall back to the label only
// when no timestamp is present (legacy items, future shapes).
function _activityTimeAgo(activity) {
const ts = activity && activity.timestamp;
if (typeof ts !== 'number' || !isFinite(ts)) {
return (activity && activity.time) || '';
}
const diffMs = Date.now() - ts * 1000;
if (diffMs < 60000) return 'Just now';
const mins = Math.floor(diffMs / 60000);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return `${Math.floor(days / 30)}mo ago`;
}
function updateActivityFeed(activities) {
const feedContainer = document.getElementById('dashboard-activity-feed');
if (!feedContainer) return;
if (activities.length === 0) {
if (_lastActivityFeedSig === 'empty') return;
_lastActivityFeedSig = 'empty';
feedContainer.innerHTML = `
📊
System Started
Dashboard initialized successfully
Just now
`;
return;
}
const items = activities.slice(0, 5);
// Build signature from titles+subtitles to detect actual changes
const sig = items.map(a => a.title + a.subtitle).join('|');
const feedChanged = sig !== _lastActivityFeedSig;
_lastActivityFeedSig = sig;
if (!feedChanged) {
// Just update timestamps without rebuilding DOM
const timeEls = feedContainer.querySelectorAll('.activity-time');
items.forEach((activity, i) => {
if (timeEls[i]) timeEls[i].textContent = _activityTimeAgo(activity);
});
return;
}
// Full rebuild only when feed content actually changed
feedContainer.innerHTML = '';
items.forEach((activity, index) => {
const activityElement = document.createElement('div');
activityElement.className = 'activity-item';
activityElement.innerHTML = `
${escapeHtml(activity.icon)}
${escapeHtml(activity.title)}
${escapeHtml(activity.subtitle)}
${_activityTimeAgo(activity)}
`;
feedContainer.appendChild(activityElement);
if (index < items.length - 1) {
const separator = document.createElement('div');
separator.className = 'activity-separator';
feedContainer.appendChild(separator);
}
});
}
async function checkForActivityToasts() {
if (socketConnected) return; // WebSocket handles this (instant push)
if (document.hidden) return; // Skip polling when tab is not visible
try {
const response = await fetch('/api/activity/toasts');
if (!response.ok) return;
const data = await response.json();
const toasts = data.toasts || [];
toasts.forEach(activity => {
// Convert activity to toast type based on icon/title
let toastType = 'info';
if (activity.icon === '✅' || activity.title.includes('Complete')) {
toastType = 'success';
} else if (activity.icon === '❌' || activity.title.includes('Failed') || activity.title.includes('Error')) {
toastType = 'error';
} else if (activity.icon === '🚫' || activity.title.includes('Cancelled')) {
toastType = 'warning';
}
// Show toast with activity info
showToast(`${activity.title}: ${activity.subtitle}`, toastType);
});
} catch (error) {
// Silently fail for toast checking to avoid spam
}
}
// --- Watchlist Functions ---
/**
* Toggle an artist's watchlist status
*/
async function toggleWatchlist(event, artistId, artistName) {
// Prevent event bubbling to parent card
event.stopPropagation();
const button = event.currentTarget;
const icon = button.querySelector('.watchlist-icon');
const text = button.querySelector('.watchlist-text');
// Show loading state
const originalText = text.textContent;
text.textContent = 'Loading...';
button.disabled = true;
try {
// Check current status
const checkResponse = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const checkData = await checkResponse.json();
if (!checkData.success) {
throw new Error(checkData.error || 'Failed to check watchlist status');
}
const isWatching = checkData.is_watching;
// Toggle watchlist status
const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add';
const payload = isWatching ?
{ artist_id: artistId } :
{ artist_id: artistId, artist_name: artistName };
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to update watchlist');
}
// Update button appearance
const gearBtn = button.parentElement?.querySelector('.watchlist-settings-btn');
if (isWatching) {
// Was watching, now removed
icon.textContent = '👁️';
text.textContent = 'Add to Watchlist';
button.classList.remove('watching');
if (gearBtn) gearBtn.classList.add('hidden');
console.log(`❌ Removed ${artistName} from watchlist`);
} else {
// Was not watching, now added
icon.textContent = '👁️';
text.textContent = 'Watching...';
button.classList.add('watching');
if (gearBtn) gearBtn.classList.remove('hidden');
console.log(`✅ Added ${artistName} to watchlist`);
}
// Update dashboard watchlist count
updateWatchlistButtonCount();
} catch (error) {
console.error('Error toggling watchlist:', error);
text.textContent = originalText;
// Show error feedback
const originalBackground = button.style.background;
button.style.background = 'rgba(255, 59, 48, 0.3)';
setTimeout(() => {
button.style.background = originalBackground;
}, 2000);
} finally {
button.disabled = false;
}
}
/**
* Update the watchlist button count on dashboard
*/
async function updateWatchlistButtonCount() {
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('/api/watchlist/count');
const data = await response.json();
if (data.success) {
_updateHeroBtnCount('watchlist-button', 'watchlist-badge', data.count);
// Update sidebar nav badge
const wlNavBadge = document.getElementById('watchlist-nav-badge');
if (wlNavBadge) {
wlNavBadge.textContent = data.count;
wlNavBadge.classList.toggle('hidden', data.count === 0);
}
const watchlistButton = document.getElementById('watchlist-button');
if (watchlistButton) {
const countdownText = data.next_run_in_seconds ? formatCountdownTime(data.next_run_in_seconds) : '';
if (countdownText) {
watchlistButton.title = `Next auto-scan in ${countdownText}`;
}
}
}
} catch (error) {
console.error('Error updating watchlist count:', error);
}
}
/**
* Check and update watchlist status for all visible artist cards
*/
async function updateArtistCardWatchlistStatus() {
const artistCards = document.querySelectorAll('.artist-card');
const artistIds = [];
for (const card of artistCards) {
const artistId = card.dataset.artistId;
if (artistId) artistIds.push(artistId);
}
if (!artistIds.length) return;
try {
const response = await fetch('/api/watchlist/check-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_ids: artistIds })
});
const data = await response.json();
if (data.success && data.results) {
for (const card of artistCards) {
const artistId = card.dataset.artistId;
if (!artistId) continue;
const button = card.querySelector('.watchlist-toggle-btn');
if (!button) continue;
const icon = button.querySelector('.watchlist-icon');
const text = button.querySelector('.watchlist-text');
const gearBtn = button.parentElement?.querySelector('.watchlist-settings-btn');
if (data.results[artistId]) {
if (icon) icon.textContent = '👁️';
if (text) text.textContent = 'Watching...';
button.classList.add('watching');
if (gearBtn) gearBtn.classList.remove('hidden');
} else {
if (icon) icon.textContent = '👁️';
if (text) text.textContent = 'Add to Watchlist';
button.classList.remove('watching');
if (gearBtn) gearBtn.classList.add('hidden');
}
}
}
} catch (error) {
console.error('Error batch checking watchlist status:', error);
}
}
/**
* Initialize/refresh the watchlist sidebar page
*/
async function initializeWatchlistPage() {
try {
const emptyEl = document.getElementById('watchlist-page-empty');
const gridEl = document.getElementById('watchlist-artists-list');
const countEl = document.getElementById('watchlist-page-count');
const overrideBanner = document.getElementById('watchlist-page-override-banner');
// Fetch count, artists, scan status, global config in parallel
const [countRes, artistsRes, statusRes, globalRes] = await Promise.all([
fetch('/api/watchlist/count').then(r => r.json()),
fetch('/api/watchlist/artists').then(r => r.json()),
fetch('/api/watchlist/scan/status').then(r => r.json()),
fetch('/api/watchlist/global-config').then(r => r.json()).catch(() => ({ success: false })),
]);
const count = countRes.success ? countRes.count : 0;
const artists = artistsRes.success ? artistsRes.artists : [];
const scanStatus = statusRes.success ? statusRes.status : 'idle';
const globalOverrideActive = globalRes.success && globalRes.config && globalRes.config.global_override_enabled;
// Update count
if (countEl) countEl.textContent = `${count} artist${count !== 1 ? 's' : ''}`;
// Update nav badge
const navBadge = document.getElementById('watchlist-nav-badge');
if (navBadge) {
navBadge.textContent = count;
navBadge.classList.toggle('hidden', count === 0);
}
// Empty state
if (count === 0) {
if (emptyEl) emptyEl.style.display = '';
if (gridEl) gridEl.style.display = 'none';
watchlistPageState.isInitialized = true;
return;
}
if (emptyEl) emptyEl.style.display = 'none';
if (gridEl) gridEl.style.display = '';
// Store artists for sorting
watchlistPageState.artists = artists;
// Last scan summary strip
const scanStrip = document.getElementById('watchlist-last-scan-strip');
const scanText = document.getElementById('watchlist-last-scan-text');
if (scanStrip && scanText && statusRes.completed_at && statusRes.summary) {
const completedDate = new Date(statusRes.completed_at);
const ago = _formatTimeAgo(completedDate);
const found = statusRes.summary.new_tracks_found || 0;
const added = statusRes.summary.tracks_added_to_wishlist || 0;
scanText.textContent = `Last scan: ${ago} — ${found} new track${found !== 1 ? 's' : ''} found, ${added} added to wishlist`;
scanStrip.style.display = '';
} else if (scanStrip) {
scanStrip.style.display = 'none';
}
// Global override banner
if (overrideBanner) overrideBanner.style.display = globalOverrideActive ? '' : 'none';
const settingsBtn = document.getElementById('watchlist-page-settings-btn');
if (settingsBtn) {
settingsBtn.classList.toggle('watchlist-global-settings-active', globalOverrideActive);
settingsBtn.innerHTML = ` ${globalOverrideActive ? 'Global Override ON' : 'Global Settings'}`;
}
// Render artist cards
if (gridEl) {
gridEl.innerHTML = artists.map(artist => {
const pills = [];
if (artist.include_albums) pills.push('Albums ');
if (artist.include_eps) pills.push('EPs ');
if (artist.include_singles) pills.push('Singles ');
if (artist.include_live) pills.push('Live ');
if (artist.include_remixes) pills.push('Remixes ');
if (artist.include_acoustic) pills.push('Acoustic ');
if (artist.include_compilations) pills.push('Compilations ');
const sourceBadges = [];
if (artist.spotify_artist_id) sourceBadges.push('Spotify ');
if (artist.itunes_artist_id) sourceBadges.push('iTunes ');
if (artist.deezer_artist_id) sourceBadges.push('Deezer ');
if (artist.discogs_artist_id) sourceBadges.push('Discogs ');
if (artist.musicbrainz_artist_id) sourceBadges.push('MusicBrainz ');
if (artist.amazon_artist_id) sourceBadges.push('Amazon ');
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.musicbrainz_artist_id || artist.amazon_artist_id;
return `
${artist.image_url ? `
` : '
🎤
'}
${escapeHtml(artist.artist_name)}
${formatRelativeScanTime(artist.last_scan_timestamp)}
${sourceBadges.length > 0 ? `
${sourceBadges.join('')}
` : ''}
${pills.length > 0 ? `
${pills.join('')}
` : ''}
`;
}).join('');
// Wire up gear buttons
gridEl.querySelectorAll('.watchlist-card-gear').forEach(button => {
button.addEventListener('click', () => {
openWatchlistArtistConfigModal(button.getAttribute('data-artist-id'), button.getAttribute('data-artist-name'));
});
});
// Wire up artist card clicks
gridEl.querySelectorAll('.watchlist-artist-card').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.watchlist-card-gear') || e.target.closest('.watchlist-card-checkbox')) return;
const artistId = item.getAttribute('data-artist-id');
const artistName = item.querySelector('.watchlist-card-name').textContent;
openWatchlistArtistDetailView(artistId, artistName);
});
});
}
// Scan status
const scanStatusEl = document.getElementById('watchlist-scan-status');
const liveActivityEl = document.getElementById('watchlist-live-activity');
const scanBtn = document.getElementById('scan-watchlist-btn');
const cancelBtn = document.getElementById('cancel-watchlist-scan-btn');
if (scanStatus === 'scanning') {
if (scanStatusEl) scanStatusEl.style.display = '';
if (liveActivityEl) liveActivityEl.style.display = 'flex';
if (scanBtn) { scanBtn.disabled = true; scanBtn.classList.add('btn-processing'); scanBtn.innerHTML = ' Scanning...'; }
if (cancelBtn) cancelBtn.style.display = '';
pollWatchlistScanStatus();
} else {
if (scanStatusEl && statusRes.summary) {
scanStatusEl.style.display = '';
const summaryEl = document.getElementById('watchlist-page-scan-summary');
if (summaryEl) {
summaryEl.style.display = '';
summaryEl.innerHTML = `Artists: ${statusRes.summary.total_artists || 0} • New tracks: ${statusRes.summary.new_tracks_found || 0} • Added to wishlist: ${statusRes.summary.tracks_added_to_wishlist || 0} `;
}
}
}
// Start countdown timer
const nextRunSeconds = countRes.next_run_in_seconds || 0;
startWatchlistCountdownTimer(nextRunSeconds);
watchlistPageState.isInitialized = true;
} catch (error) {
console.error('Error initializing watchlist page:', error);
showToast('Failed to load watchlist', 'error');
}
}
/**
* Initialize/refresh the wishlist sidebar page
*/
async function initializeWishlistPage() {
try {
const emptyEl = document.getElementById('wishlist-page-empty');
const nebulaEl = document.getElementById('wishlist-nebula');
const countEl = document.getElementById('wishlist-page-count');
const tracksSection = document.getElementById('wishlist-category-tracks');
const statsStrip = document.getElementById('wishlist-stats-strip');
const [statsRes, cycleRes, albumRes, singleRes, watchlistRes] = await Promise.all([
fetch('/api/wishlist/stats').then(r => r.json()),
fetch('/api/wishlist/cycle').then(r => r.json()),
fetch('/api/wishlist/tracks?category=albums').then(r => r.json()),
fetch('/api/wishlist/tracks?category=singles').then(r => r.json()),
fetch('/api/watchlist/artists').then(r => r.json()).catch(() => ({ success: false })),
]);
// Build artist name → image URL map from watchlist
const _artistImageMap = new Map();
if (watchlistRes.success && watchlistRes.artists) {
for (const wa of watchlistRes.artists) {
if (wa.artist_name && wa.image_url) _artistImageMap.set(wa.artist_name.toLowerCase(), wa.image_url);
}
}
const { singles = 0, albums = 0, total = 0 } = statsRes;
const currentCycle = cycleRes.cycle || 'albums';
if (countEl) countEl.textContent = `${total} track${total !== 1 ? 's' : ''}`;
const navBadge = document.getElementById('wishlist-nav-badge');
if (navBadge) { navBadge.textContent = total; navBadge.classList.toggle('hidden', total === 0); }
const statAlbums = document.getElementById('wishlist-stat-albums');
const statSingles = document.getElementById('wishlist-stat-singles');
const statCycle = document.getElementById('wishlist-stat-cycle');
if (statAlbums) statAlbums.textContent = albums;
if (statSingles) statSingles.textContent = singles;
if (statCycle) statCycle.textContent = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles';
if (total === 0) {
if (emptyEl) emptyEl.style.display = '';
if (nebulaEl) nebulaEl.style.display = 'none';
if (tracksSection) tracksSection.style.display = 'none';
if (statsStrip) statsStrip.style.display = 'none';
wishlistPageState.isInitialized = true;
return;
}
if (emptyEl) emptyEl.style.display = 'none';
if (nebulaEl) nebulaEl.style.display = '';
if (tracksSection) tracksSection.style.display = 'none';
if (statsStrip) statsStrip.style.display = '';
_renderWishlistNebula(albumRes.tracks || [], singleRes.tracks || [], _artistImageMap, currentCycle);
startWishlistCountdownTimer(currentCycle, statsRes.next_run_in_seconds || 0);
// Live processing: check if wishlist download is active and start polling
_startNebulaLivePolling(currentCycle, _artistImageMap);
wishlistPageState.isInitialized = true;
} catch (error) {
console.error('Error initializing wishlist page:', error);
showToast('Failed to load wishlist', 'error');
}
}
/* ═══════════════════════════════════════════════════════════════════
WISHLIST NEBULA — Artist orbs with album/single satellites
═══════════════════════════════════════════════════════════════════ */
function _renderWishlistNebula(albumTracks, singleTracks, artistImageMap, currentCycle) {
const field = document.getElementById('wl-nebula-field');
if (!field) return;
artistImageMap = artistImageMap || new Map();
const artistMap = new Map();
function _parse(track, type) {
let sd = track.spotify_data;
if (typeof sd === 'string') { try { sd = JSON.parse(sd); } catch (e) { return null; } }
if (!sd) return null;
const raw = sd.album;
const albumName = (typeof raw === 'string' ? raw : raw?.name) || 'Unknown';
const albumImage = (typeof raw === 'object' && raw?.images?.[0]?.url) || '';
let artist = 'Unknown Artist';
if (sd.artists?.[0]?.name) artist = sd.artists[0].name;
else if (typeof sd.artists?.[0] === 'string') artist = sd.artists[0];
return { track: sd.name || 'Unknown', artist, album: albumName, image: albumImage, type, id: track.spotify_track_id || track.id || '' };
}
for (const t of albumTracks) { const p = _parse(t, 'album'); if (p) { if (!artistMap.has(p.artist)) artistMap.set(p.artist, { albums: new Map(), singles: [] }); const a = artistMap.get(p.artist); if (!a.albums.has(p.album)) a.albums.set(p.album, { image: p.image, tracks: [] }); a.albums.get(p.album).tracks.push(p); } }
for (const t of singleTracks) { const p = _parse(t, 'single'); if (p) { if (!artistMap.has(p.artist)) artistMap.set(p.artist, { albums: new Map(), singles: [] }); artistMap.get(p.artist).singles.push(p); } }
if (artistMap.size === 0) { field.innerHTML = 'Your wishlist is empty
'; return; }
const sorted = [...artistMap.entries()].sort((a, b) => {
const ac = [...a[1].albums.values()].reduce((s, al) => s + al.tracks.length, 0) + a[1].singles.length;
const bc = [...b[1].albums.values()].reduce((s, al) => s + al.tracks.length, 0) + b[1].singles.length;
return bc - ac;
});
function _hue(n) { let h = 0; for (let i = 0; i < n.length; i++) h = n.charCodeAt(i) + ((h << 5) - h); return Math.abs(h) % 360; }
let html = '';
sorted.forEach(([name, data], idx) => {
const total = [...data.albums.values()].reduce((s, a) => s + a.tracks.length, 0) + data.singles.length;
const hasAlbums = data.albums.size > 0;
const hue = _hue(name);
const sz = total >= 10 ? 'orb-lg' : total >= 4 ? 'orb-md' : 'orb-sm';
// Enhancement 1: prefer watchlist artist photo over album cover
let img = artistImageMap.get(name.toLowerCase()) || '';
if (!img) { for (const [, ad] of data.albums) { if (ad.image) { img = ad.image; break; } } }
if (!img && data.singles.length) img = data.singles[0].image || '';
// Enhancement 3: pulse if this artist has albums and current cycle is albums
const pulseClass = (hasAlbums && currentCycle === 'albums') ? ' orb-pulse' : '';
// Enhancement 7: staggered entry animation
const delay = Math.min(idx * 60, 800);
html += ``;
// Enhancement 2: hover tooltip
html += `
${escapeHtml(name)}${total} track${total !== 1 ? 's' : ''}
`;
html += `
`;
html += `
`;
html += img ? `
` : `
${escapeHtml(name.substring(0, 2).toUpperCase())}
`;
html += `
`;
// Enhancement 5: album art ring (show up to 6 album covers around the orb)
const ringCovers = [];
for (const [, ad] of data.albums) { if (ad.image && ringCovers.length < 6) ringCovers.push(ad.image); }
for (const s of data.singles) { if (s.image && ringCovers.length < 6) ringCovers.push(s.image); }
if (ringCovers.length >= 3) {
html += `
`;
ringCovers.forEach((url, i) => {
const angle = (360 / ringCovers.length) * i;
html += `
`;
});
html += `
`;
}
html += `
`; // /orb
// Enhancement 8: clickable artist name → navigate to artist detail
html += `
${escapeHtml(name)}
`;
html += `
${total} track${total !== 1 ? 's' : ''}
`;
// Expanded content
html += `
`;
if (data.albums.size > 0) {
html += `
`;
for (const [an, ad] of data.albums) {
const tileId = 'wl-tile-' + an.replace(/\W/g, '_') + '_' + idx;
html += `
`;
html += `
${ad.image ? `
` : `
💿
`}
`;
html += `
${escapeHtml(an)}
${ad.tracks.length} track${ad.tracks.length !== 1 ? 's' : ''}
`;
html += `
${ad.tracks.length} `;
html += `
✕ `;
// Track list (hidden until tile clicked)
html += `
`;
for (const tr of ad.tracks) {
html += `
`;
html += `${escapeHtml(tr.track)} `;
html += `✕ `;
html += `
`;
}
html += `
`;
html += `
`;
}
html += `
`;
}
if (data.singles.length > 0) {
html += `
`;
for (const s of data.singles) {
html += `
`;
html += s.image ? `
` : `
⭐ `;
html += `
${escapeHtml(s.track)}
`;
html += `
✕ `;
html += `
`;
}
html += `
`;
}
html += `
`; // /expanded, /group
});
field.innerHTML = html;
}
// Enhancement 8: navigate to the Search page pre-filled with this artist's name
function _navigateToArtistFromWishlist(artistName) {
navigateToPage('search');
setTimeout(() => {
const searchInput = document.getElementById('enhanced-search-input');
if (searchInput) {
searchInput.value = artistName;
searchInput.dispatchEvent(new Event('input'));
searchInput.focus();
}
}, 300);
}
function _toggleAlbumTile(tileEl) {
const wasExpanded = tileEl.classList.contains('tile-expanded');
// Collapse all tiles in this group
tileEl.closest('.wl-album-fan')?.querySelectorAll('.wl-album-tile.tile-expanded').forEach(t => t.classList.remove('tile-expanded'));
if (!wasExpanded) tileEl.classList.add('tile-expanded');
}
function _toggleOrbExpand(el) {
const g = el.closest('.wl-orb-group');
if (!g) return;
const was = g.classList.contains('expanded');
document.querySelectorAll('.wl-orb-group.expanded').forEach(o => o.classList.remove('expanded'));
if (!was) g.classList.add('expanded');
}
async function _removeWishlistAlbum(albumName) {
if (!await showConfirmDialog({ title: 'Remove Album', message: `Remove all tracks from "${albumName}"?`, confirmText: 'Remove', destructive: true })) return;
try {
const res = await fetch('/api/wishlist/remove-album', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ album_name: albumName }) });
const data = await res.json();
if (data.success) { showToast(`Removed "${albumName}"`, 'success'); wishlistPageState.isInitialized = false; await initializeWishlistPage(); await updateWishlistCount(); }
else showToast(data.error || 'Failed', 'error');
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
async function _removeWishlistTrack(trackId) {
try {
const res = await fetch('/api/wishlist/remove-track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ spotify_track_id: trackId }) });
const data = await res.json();
if (data.success) {
showToast('Removed', 'success');
await updateWishlistCount();
// Re-render nebula to reflect removal
wishlistPageState.isInitialized = false;
await initializeWishlistPage();
}
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
function _filterNebula() {
const q = (document.getElementById('wl-nebula-search')?.value || '').toLowerCase().trim();
document.querySelectorAll('.wl-orb-group').forEach(g => {
const a = (g.dataset.artist || '').toLowerCase();
const albums = [...g.querySelectorAll('.wl-satellite')].map(s => (s.dataset.album || '').toLowerCase());
const match = !q || a.includes(q) || albums.some(al => al.includes(q));
g.style.display = match ? '' : 'none';
if (!match) g.classList.remove('expanded');
});
}
async function _nebulaDownload() {
// Check if wishlist is already processing
try {
const statsResp = await fetch('/api/wishlist/stats');
if (statsResp.ok) {
const stats = await statsResp.json();
if (stats.is_auto_processing) {
// Navigate to downloads page so the user can see progress
navigateToPage('active-downloads');
showToast('Wishlist is currently being auto-processed', 'info');
return;
}
}
const procResp = await fetch('/api/active-processes');
if (procResp.ok) {
const procData = await procResp.json();
const wishlistBatch = (procData.active_processes || []).find(p => p.playlist_id === 'wishlist');
if (wishlistBatch) {
// Show the existing download modal
WishlistModalState.clearUserClosed();
const clientProcess = activeDownloadProcesses['wishlist'];
if (clientProcess && clientProcess.modalElement && document.body.contains(clientProcess.modalElement)) {
clientProcess.modalElement.style.display = 'flex';
WishlistModalState.setVisible();
} else {
await rehydrateModal(wishlistBatch, true);
}
return;
}
}
} catch (e) {}
// No active process — show category choice
const choice = await _showNebulaDownloadChoice();
if (choice) await openDownloadMissingWishlistModal(choice);
}
function _showNebulaDownloadChoice() {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } };
const albumCount = document.getElementById('wishlist-stat-albums')?.textContent || '0';
const singleCount = document.getElementById('wishlist-stat-singles')?.textContent || '0';
overlay.innerHTML = `
Download Wishlist
Choose which category to process
💿 Albums & EPs ${albumCount} tracks
⭐ Singles ${singleCount} tracks
Cancel
`;
overlay.querySelector('#ndc-albums').onclick = () => { overlay.remove(); resolve('albums'); };
overlay.querySelector('#ndc-singles').onclick = () => { overlay.remove(); resolve('singles'); };
overlay.querySelector('#ndc-cancel').onclick = () => { overlay.remove(); resolve(null); };
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { overlay.remove(); resolve(null); document.removeEventListener('keydown', esc); }
});
document.body.appendChild(overlay);
});
}
function _nebulaBack() {
const t = document.getElementById('wishlist-category-tracks');
const n = document.getElementById('wishlist-nebula');
if (t) t.style.display = 'none';
if (n) n.style.display = '';
window.selectedWishlistCategory = null;
wishlistPageState.isInitialized = false;
initializeWishlistPage();
}
// ── Live processing state for nebula ──
let _nebulaLivePollInterval = null;
let _nebulaLastTotal = null;
function _startNebulaLivePolling(currentCycle, artistImageMap) {
_stopNebulaLivePolling();
_nebulaLastTotal = null;
_nebulaLivePollInterval = setInterval(async () => {
if (currentPage !== 'wishlist') { _stopNebulaLivePolling(); return; }
try {
// Use wishlist stats which has is_auto_processing flag
const statsResp = await fetch('/api/wishlist/stats');
if (!statsResp.ok) return;
const stats = await statsResp.json();
const isProcessing = stats.is_auto_processing || false;
const newTotal = stats.total || 0;
// Also check for manual wishlist download batches
let hasBatch = false;
try {
const procResp = await fetch('/api/active-processes');
if (procResp.ok) {
const procData = await procResp.json();
hasBatch = (procData.active_processes || []).some(p => p.playlist_id === 'wishlist');
}
} catch (e) {}
const active = isProcessing || hasBatch;
const nebulaField = document.getElementById('wl-nebula-field');
if (!nebulaField) return;
if (active) {
nebulaField.classList.add('nebula-processing');
document.querySelectorAll('.wl-orb-group').forEach(g => g.classList.add('orb-processing'));
// Tracks completed — re-render
if (_nebulaLastTotal !== null && newTotal < _nebulaLastTotal) {
const [albumRes, singleRes] = await Promise.all([
fetch('/api/wishlist/tracks?category=albums').then(r => r.json()),
fetch('/api/wishlist/tracks?category=singles').then(r => r.json()),
]);
_renderWishlistNebula(albumRes.tracks || [], singleRes.tracks || [], artistImageMap, currentCycle);
const countEl = document.getElementById('wishlist-page-count');
if (countEl) countEl.textContent = `${newTotal} track${newTotal !== 1 ? 's' : ''}`;
const sa = document.getElementById('wishlist-stat-albums');
const ss = document.getElementById('wishlist-stat-singles');
if (sa) sa.textContent = stats.albums || 0;
if (ss) ss.textContent = stats.singles || 0;
// Re-add processing classes after re-render
document.getElementById('wl-nebula-field')?.classList.add('nebula-processing');
document.querySelectorAll('.wl-orb-group').forEach(g => g.classList.add('orb-processing'));
}
_nebulaLastTotal = newTotal;
} else {
nebulaField.classList.remove('nebula-processing');
document.querySelectorAll('.wl-orb-group.orb-processing').forEach(g => g.classList.remove('orb-processing'));
if (_nebulaLastTotal !== null) {
_nebulaLastTotal = null;
wishlistPageState.isInitialized = false;
await initializeWishlistPage();
await updateWishlistCount();
}
}
} catch (e) {}
}, 5000);
}
function _stopNebulaLivePolling() {
if (_nebulaLivePollInterval) {
clearInterval(_nebulaLivePollInterval);
_nebulaLivePollInterval = null;
}
_nebulaLastTotal = null;
}
/**
* Sort the watchlist artist grid by the selected criteria.
*/
function sortWatchlistArtists(sortBy) {
const grid = document.getElementById('watchlist-artists-list');
if (!grid) return;
const cards = Array.from(grid.querySelectorAll('.watchlist-artist-card'));
if (cards.length === 0) return;
cards.sort((a, b) => {
switch (sortBy) {
case 'name-asc':
return (a.dataset.artistName || '').localeCompare(b.dataset.artistName || '');
case 'name-desc':
return (b.dataset.artistName || '').localeCompare(a.dataset.artistName || '');
case 'scan-oldest': {
const aTime = a.dataset.lastScan ? new Date(a.dataset.lastScan).getTime() : 0;
const bTime = b.dataset.lastScan ? new Date(b.dataset.lastScan).getTime() : 0;
return aTime - bTime; // oldest first (never scanned = 0 = top)
}
case 'scan-newest': {
const aTime = a.dataset.lastScan ? new Date(a.dataset.lastScan).getTime() : 0;
const bTime = b.dataset.lastScan ? new Date(b.dataset.lastScan).getTime() : 0;
return bTime - aTime;
}
case 'added-newest': {
const aTime = a.dataset.added ? new Date(a.dataset.added).getTime() : 0;
const bTime = b.dataset.added ? new Date(b.dataset.added).getTime() : 0;
return bTime - aTime;
}
default:
return 0;
}
});
// Re-append in sorted order (preserves event listeners)
cards.forEach(card => grid.appendChild(card));
}
/**
* Filter wishlist tracks by search query within the active track list.
*/
function filterWishlistTracks() {
const input = document.getElementById('wishlist-track-search-input');
if (!input) return;
const query = input.value.toLowerCase().trim();
const tracksList = document.getElementById('wishlist-tracks-list');
if (!tracksList) return;
// For albums view: filter album cards by album name or track names within
const albumCards = tracksList.querySelectorAll('.wishlist-album-card');
if (albumCards.length > 0) {
albumCards.forEach(card => {
const albumHeader = card.querySelector('.wishlist-album-header');
const albumName = (albumHeader?.querySelector('.wishlist-album-name')?.textContent || '').toLowerCase();
const artistName = (albumHeader?.querySelector('.wishlist-album-artist')?.textContent || '').toLowerCase();
const tracks = card.querySelectorAll('.wishlist-album-track');
let albumHasMatch = !query || albumName.includes(query) || artistName.includes(query);
// Also check individual track names
if (!albumHasMatch && tracks.length > 0) {
tracks.forEach(track => {
const trackName = (track.textContent || '').toLowerCase();
if (trackName.includes(query)) albumHasMatch = true;
});
}
card.style.display = albumHasMatch ? '' : 'none';
});
return;
}
// For singles view: filter individual track rows
const trackRows = tracksList.querySelectorAll('.playlist-track-item-with-image, .playlist-track-item');
trackRows.forEach(row => {
const text = (row.textContent || '').toLowerCase();
row.style.display = (!query || text.includes(query)) ? '' : 'none';
});
}
/**
* Format a Date object as a relative time string (e.g. "2 hours ago")
*/
function _formatTimeAgo(date) {
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays === 1) return 'yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
/**
* Show watchlist modal (legacy — kept for backward compatibility)
*/
async function showWatchlistModal() {
try {
// Check if watchlist has any artists
const countResponse = await fetch('/api/watchlist/count');
const countData = await countResponse.json();
if (!countData.success) {
console.error('Error getting watchlist count:', countData.error);
return;
}
if (countData.count === 0) {
// Show empty state message
alert('Your watchlist is empty!\n\nAdd artists to your watchlist from the Artists page to monitor them for new releases.');
return;
}
// Get watchlist artists
const artistsResponse = await fetch('/api/watchlist/artists');
const artistsData = await artistsResponse.json();
if (!artistsData.success) {
console.error('Error getting watchlist artists:', artistsData.error);
return;
}
// Create modal if it doesn't exist
let modal = document.getElementById('watchlist-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'watchlist-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
}
// Get scan status and global config
const statusResponse = await fetch('/api/watchlist/scan/status');
const statusData = await statusResponse.json();
const scanStatus = statusData.success ? statusData.status : 'idle';
let globalOverrideActive = false;
try {
const globalConfigResponse = await fetch('/api/watchlist/global-config');
const globalConfigData = await globalConfigResponse.json();
globalOverrideActive = globalConfigData.success && globalConfigData.config.global_override_enabled;
} catch (e) {
console.debug('Could not fetch global config:', e);
}
// Format countdown timer
const nextRunSeconds = countData.next_run_in_seconds || 0;
const countdownText = formatCountdownTime(nextRunSeconds);
// Build modal content
modal.innerHTML = `
${scanStatus === 'scanning' ? 'Scanning...' : 'Scan for New Releases'}
Cancel Scan
Update Similar Artists
${globalOverrideActive ? 'Global Override ON' : 'Global Settings'}
Download Origins
Blocklist
${globalOverrideActive ? `
⚠️
Global override is active — per-artist settings are being ignored during scans.
` : ''}
Select All
Remove Selected
${artistsData.artists.map(artist => {
const pills = [];
if (artist.include_albums) pills.push('
Albums ');
if (artist.include_eps) pills.push('
EPs ');
if (artist.include_singles) pills.push('
Singles ');
if (artist.include_live) pills.push('
Live ');
if (artist.include_remixes) pills.push('
Remixes ');
if (artist.include_acoustic) pills.push('
Acoustic ');
if (artist.include_compilations) pills.push('
Compilations ');
const sourceBadges = [];
if (artist.spotify_artist_id) sourceBadges.push('
Spotify ');
if (artist.itunes_artist_id) sourceBadges.push('
iTunes ');
if (artist.deezer_artist_id) sourceBadges.push('
Deezer ');
if (artist.discogs_artist_id) sourceBadges.push('
Discogs ');
if (artist.musicbrainz_artist_id) sourceBadges.push('
MusicBrainz ');
if (artist.amazon_artist_id) sourceBadges.push('
Amazon ');
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.musicbrainz_artist_id || artist.amazon_artist_id;
return `
${artist.image_url ? `
` : `
🎤
`}
${escapeHtml(artist.artist_name)}
${formatRelativeScanTime(artist.last_scan_timestamp)}
${sourceBadges.length > 0 ? `
${sourceBadges.join('')}
` : ''}
${pills.length > 0 ? `
${pills.join('')}
` : ''}
`}).join('')}
`;
// Add event listeners for gear buttons
modal.querySelectorAll('.watchlist-card-gear').forEach(button => {
button.addEventListener('click', () => {
const artistId = button.getAttribute('data-artist-id');
const artistName = button.getAttribute('data-artist-name');
openWatchlistArtistConfigModal(artistId, artistName);
});
});
// Add click handlers to artist cards (except for gear button or checkbox)
modal.querySelectorAll('.watchlist-artist-card').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.watchlist-card-gear') || e.target.closest('.watchlist-card-checkbox')) {
return;
}
const artistId = item.getAttribute('data-artist-id');
const artistName = item.querySelector('.watchlist-card-name').textContent;
console.log(`🎵 Artist card clicked: ${artistName} (${artistId})`);
openWatchlistArtistDetailView(artistId, artistName);
});
});
// Show modal
modal.style.display = 'flex';
// Start countdown timer update interval
startWatchlistCountdownTimer(nextRunSeconds);
// Start polling for scan status if scanning
if (scanStatus === 'scanning') {
pollWatchlistScanStatus();
}
} catch (error) {
console.error('Error showing watchlist modal:', error);
}
}
function startWatchlistCountdownTimer(initialSeconds) {
// Clear any existing interval
if (watchlistCountdownInterval) {
clearInterval(watchlistCountdownInterval);
}
let remainingSeconds = initialSeconds;
const timerElement = document.getElementById('watchlist-next-auto-timer');
const updateTimerText = (seconds) => {
if (!timerElement) return;
const countdownText = seconds > 0 ? formatCountdownTime(seconds) : '';
timerElement.textContent = `Next Auto${countdownText ? ': ' + countdownText : ''}`;
};
// If there is no scheduled next run, don't keep polling the endpoint every second.
if (remainingSeconds <= 0) {
updateTimerText(0);
watchlistCountdownInterval = null;
return;
}
watchlistCountdownInterval = setInterval(async () => {
remainingSeconds--;
if (remainingSeconds <= 0) {
// Timer expired, fetch fresh data
try {
const response = await fetch('/api/watchlist/count');
const data = await response.json();
const nextRunSeconds = data.next_run_in_seconds || 0;
if (nextRunSeconds > 0) {
remainingSeconds = nextRunSeconds;
updateTimerText(remainingSeconds);
} else {
// No scheduled auto-run; stop polling until the page is refreshed
clearInterval(watchlistCountdownInterval);
watchlistCountdownInterval = null;
updateTimerText(0);
}
} catch (error) {
console.debug('Error updating watchlist countdown:', error);
}
} else {
// Update the display
updateTimerText(remainingSeconds);
}
}, 1000); // Update every second
}
/**
* Close watchlist modal
*/
function closeWatchlistModal() {
// Stop countdown timer
if (watchlistCountdownInterval) {
clearInterval(watchlistCountdownInterval);
watchlistCountdownInterval = null;
}
const modal = document.getElementById('watchlist-modal');
if (modal) {
modal.style.display = 'none';
}
}
/**
* Populate the linked provider section in the watchlist config modal.
* Shows which Spotify/iTunes/Deezer artist is linked and allows changing it.
*/
function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo, deezerId, discogsId, amazonId, musicbrainzId) {
const section = document.getElementById('watchlist-linked-provider-section');
const content = document.getElementById('watchlist-linked-provider-content');
if (!section || !content) return;
section.style.display = '';
const sources = [
{ key: 'spotify', label: 'Spotify', icon: '🟢', id: spotifyId || '', color: '#1db954' },
{ key: 'itunes', label: 'Apple Music', icon: '🔴', id: itunesId || '', color: '#fc3c44' },
{ key: 'deezer', label: 'Deezer', icon: '🟣', id: deezerId || '', color: '#a238ff' },
{ key: 'discogs', label: 'Discogs', icon: '🟤', id: discogsId || '', color: '#b08968' },
{ key: 'musicbrainz', label: 'MusicBrainz', icon: 'MB', id: musicbrainzId || '', color: '#ba478f' },
{ key: 'amazon', label: 'Amazon Music', icon: '🟠', id: amazonId || '', color: '#FF9900' },
];
let html = '';
for (const src of sources) {
const matched = !!src.id;
const shortId = src.id ? (src.id.length > 16 ? src.id.substring(0, 14) + '...' : src.id) : '';
html += `
${src.icon}
${src.label}
${matched
? `${shortId} `
: 'Not matched '
}
${matched ? 'Fix' : 'Match'}
${matched ? `× ` : ''}
`;
}
html += '
';
// Per-source search panel (hidden, populated on Fix/Match click)
html += ``;
content.innerHTML = html;
}
/**
* Open per-source search panel for fixing a specific provider match.
*/
function _openSourceSearch(sourceKey, artistId, artistName) {
const panel = document.getElementById('wl-linked-search-panel');
if (!panel) return;
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', amazon: 'Amazon Music', musicbrainz: 'MusicBrainz' };
document.getElementById('wl-linked-search-title').textContent = `Search ${labels[sourceKey] || sourceKey}`;
const input = document.getElementById('wl-linked-search-input');
input.value = artistName;
document.getElementById('wl-linked-search-results').innerHTML = '';
panel.style.display = '';
panel.dataset.source = sourceKey;
panel.dataset.artistId = artistId;
panel.dataset.artistName = artistName;
input.focus();
input.select();
const doSearch = () => _searchSourceArtists(sourceKey, artistId);
document.getElementById('wl-linked-search-go').onclick = doSearch;
input.onkeydown = (e) => { if (e.key === 'Enter') doSearch(); };
}
async function _searchSourceArtists(sourceKey, watchlistArtistId) {
const input = document.getElementById('wl-linked-search-input');
const container = document.getElementById('wl-linked-search-results');
const query = input?.value?.trim();
if (!query || !container) return;
container.innerHTML = 'Searching...
';
try {
const response = await fetch('/api/library/search-service', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: sourceKey, entity_type: 'artist', query })
});
const data = await response.json();
if (!data.success) throw new Error(data.error);
const results = data.results || [];
if (!results.length) {
container.innerHTML = 'No artists found
';
return;
}
let html = '';
for (const r of results) {
html += `
${r.image ? `
` :
`
🎵
`}
${escapeHtml(r.name)}
${escapeHtml(r.extra || '')}
Select
`;
}
container.innerHTML = html;
container.querySelectorAll('.watchlist-linked-search-result').forEach(el => {
el.querySelector('.watchlist-linked-select-btn').onclick = async (e) => {
e.stopPropagation();
await _linkSourceArtist(sourceKey, watchlistArtistId, el.dataset.id, el.dataset.name);
};
});
} catch (err) {
console.error(`Error searching ${sourceKey}:`, err);
container.innerHTML = 'Search error
';
}
}
async function _linkSourceArtist(sourceKey, watchlistArtistId, newId, newName) {
try {
const response = await fetch(`/api/watchlist/artist/${watchlistArtistId}/link-provider`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider_id: newId, provider: sourceKey })
});
const data = await response.json();
if (!data.success) {
showToast(`Failed to link: ${data.error}`, 'error');
return;
}
showToast(`Linked to "${newName}" on ${sourceKey}`, 'success');
// Refresh the modal
const panel = document.getElementById('wl-linked-search-panel');
const artistName = panel?.dataset?.artistName || newName;
closeWatchlistArtistConfigModal();
setTimeout(() => openWatchlistArtistConfigModal(watchlistArtistId, artistName), 300);
} catch (err) {
showToast('Failed to link artist', 'error');
}
}
async function _clearSourceMatch(sourceKey, watchlistArtistId, artistName) {
try {
const response = await fetch(`/api/watchlist/artist/${watchlistArtistId}/link-provider`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider_id: '', provider: sourceKey })
});
const data = await response.json();
if (!data.success) {
showToast(`Failed to clear: ${data.error}`, 'error');
return;
}
showToast(`Cleared ${sourceKey} match`, 'success');
closeWatchlistArtistConfigModal();
setTimeout(() => openWatchlistArtistConfigModal(watchlistArtistId, artistName), 300);
} catch (err) {
showToast('Failed to clear match', 'error');
}
}
/**
* Open watchlist artist configuration modal
* @param {string} artistId - Spotify artist ID
* @param {string} artistName - Artist name
*/
async function openWatchlistArtistConfigModal(artistId, artistName) {
try {
console.log(`🎨 Opening config modal for artist: ${artistName} (${artistId})`);
// Fetch artist config and info
const response = await fetch(`/api/watchlist/artist/${artistId}/config`);
const data = await response.json();
if (!data.success) {
console.error('Error loading artist config:', data.error);
showToast(`Error loading artist configuration: ${data.error}`, 'error');
return;
}
const { config, artist, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, amazon_artist_id, musicbrainz_artist_id, watchlist_name } = data;
// Populate linked provider section (use DB watchlist_name for mismatch comparison)
_populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist, deezer_artist_id, discogs_artist_id, amazon_artist_id, musicbrainz_artist_id);
// Check if global override is active
let globalOverrideActive = false;
try {
const globalResponse = await fetch('/api/watchlist/global-config');
const globalData = await globalResponse.json();
globalOverrideActive = globalData.success && globalData.config.global_override_enabled;
} catch (e) {
console.debug('Could not check global config:', e);
}
// Generate hero section
const heroHTML = `
${artist.image_url ? `
` : ''}
${escapeHtml(artist.name)}
${formatNumber(artist.followers)}
Followers
${artist.popularity}/100
Popularity
${artist.genres && artist.genres.length > 0 ? `
${artist.genres.slice(0, 3).map(genre =>
`${escapeHtml(genre)} `
).join('')}
` : ''}
`;
// Populate hero section
const heroContainer = document.getElementById('watchlist-artist-config-hero');
if (heroContainer) {
heroContainer.innerHTML = heroHTML;
}
// Set checkbox states
// Default to true (auto-download) when absent (older configs).
const _autoDlToggle = document.getElementById('config-auto-download');
if (_autoDlToggle) _autoDlToggle.checked = config.auto_download !== false;
document.getElementById('config-include-albums').checked = config.include_albums;
document.getElementById('config-include-eps').checked = config.include_eps;
document.getElementById('config-include-singles').checked = config.include_singles;
document.getElementById('config-include-live').checked = config.include_live || false;
document.getElementById('config-include-remixes').checked = config.include_remixes || false;
document.getElementById('config-include-acoustic').checked = config.include_acoustic || false;
document.getElementById('config-include-compilations').checked = config.include_compilations || false;
document.getElementById('config-include-instrumentals').checked = config.include_instrumentals || false;
document.getElementById('config-lookback-days').value = config.lookback_days != null ? String(config.lookback_days) : '';
// Populate metadata source selector
const sourceSelector = document.getElementById('config-metadata-source-selector');
if (sourceSelector) {
const sources = [
{ key: 'spotify', label: 'Spotify', id: spotify_artist_id, color: '#1DB954' },
{ key: 'deezer', label: 'Deezer', id: deezer_artist_id, color: '#A238FF' },
{ key: 'itunes', label: 'Apple Music', id: itunes_artist_id, color: '#FC3C44' },
{ key: 'discogs', label: 'Discogs', id: discogs_artist_id, color: '#333' },
{ key: 'musicbrainz', label: 'MusicBrainz', id: musicbrainz_artist_id, color: '#BA478F' },
];
const globalSource = data.global_metadata_source || 'deezer';
const currentOverride = config.preferred_metadata_source;
const globalLabel = { spotify: 'Spotify', deezer: 'Deezer', itunes: 'Apple Music', discogs: 'Discogs', musicbrainz: 'MusicBrainz' }[globalSource] || globalSource;
let html = `
🌐 Default (${globalLabel})
`;
for (const src of sources) {
if (!src.id) continue;
const isActive = currentOverride === src.key;
html += `
${src.label}
`;
}
sourceSelector.innerHTML = html;
sourceSelector.querySelectorAll('.config-msrc-btn').forEach(btn => {
btn.addEventListener('click', () => {
sourceSelector.querySelectorAll('.config-msrc-btn').forEach(b => {
b.classList.remove('active');
b.style.borderColor = '';
});
btn.classList.add('active');
const color = sources.find(s => s.key === btn.dataset.source)?.color;
if (color) btn.style.borderColor = color;
});
});
}
// Show global override notice if active
const existingNotice = document.querySelector('.global-override-notice');
if (existingNotice) existingNotice.remove();
if (globalOverrideActive) {
const notice = document.createElement('div');
notice.className = 'global-override-notice watchlist-global-override-banner';
notice.innerHTML = '⚠️ Global override is active — these per-artist settings are currently ignored during scans. ';
const configBody = document.querySelector('.watchlist-artist-config-body');
if (configBody) configBody.insertBefore(notice, configBody.firstChild);
}
// Store artist ID for saving
const modal = document.getElementById('watchlist-artist-config-modal');
if (modal) {
modal.setAttribute('data-artist-id', artistId);
}
// Show modal
const overlay = document.getElementById('watchlist-artist-config-modal-overlay');
if (overlay) {
overlay.classList.remove('hidden');
}
// Add save button handler
const saveBtn = document.getElementById('save-artist-config-btn');
if (saveBtn) {
// Remove old listeners
const newSaveBtn = saveBtn.cloneNode(true);
saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn);
// Add new listener
newSaveBtn.addEventListener('click', () => saveWatchlistArtistConfig(artistId));
}
} catch (error) {
console.error('Error opening watchlist artist config modal:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
/**
* Close watchlist artist configuration modal
*/
function closeWatchlistArtistConfigModal() {
const overlay = document.getElementById('watchlist-artist-config-modal-overlay');
if (overlay) {
overlay.classList.add('hidden');
}
// Clear hero content
const heroContainer = document.getElementById('watchlist-artist-config-hero');
if (heroContainer) {
heroContainer.innerHTML = '';
}
// Clear linked provider section
const linkedContent = document.getElementById('watchlist-linked-provider-content');
if (linkedContent) linkedContent.innerHTML = '';
const linkedSection = document.getElementById('watchlist-linked-provider-section');
if (linkedSection) linkedSection.style.display = 'none';
}
/**
* Open watchlist artist detail view (slides in from right)
*/
async function openWatchlistArtistDetailView(artistId, artistName) {
try {
const response = await fetch(`/api/watchlist/artist/${artistId}/config`);
const data = await response.json();
if (!data.success) {
showToast(`Error loading artist info: ${data.error}`, 'error');
return;
}
const { config, artist, recent_releases, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, musicbrainz_artist_id } = data;
// Remove existing overlay if any
const existing = document.querySelector('.watchlist-artist-detail-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'watchlist-artist-detail-overlay';
// Build pills
const pills = [];
if (config.include_albums) pills.push('Albums ');
if (config.include_eps) pills.push('EPs ');
if (config.include_singles) pills.push('Singles ');
if (config.include_live) pills.push('Live ');
if (config.include_remixes) pills.push('Remixes ');
if (config.include_acoustic) pills.push('Acoustic ');
if (config.include_compilations) pills.push('Compilations ');
// Build scan info
const scanTimeText = config.last_scan_timestamp ? formatRelativeScanTime(config.last_scan_timestamp) : 'Never scanned';
const dateAddedText = config.date_added ? `Added ${new Date(config.date_added).toLocaleDateString()}` : '';
// Build metadata tags (style, mood, label)
const metaTags = [];
if (artist.style) metaTags.push(`${escapeHtml(artist.style)} `);
if (artist.mood) metaTags.push(`${escapeHtml(artist.mood)} `);
if (artist.label) metaTags.push(`${escapeHtml(artist.label)} `);
let discogId = null;
let discogSource = null;
const activeSrc = (currentMusicSourceName || '').toLowerCase();
if (activeSrc.includes('spotify') && spotify_artist_id) {
discogId = spotify_artist_id; discogSource = 'spotify';
} else if (activeSrc.includes('discogs') && discogs_artist_id) {
discogId = discogs_artist_id; discogSource = 'discogs';
} else if (activeSrc.includes('deezer') && deezer_artist_id) {
discogId = deezer_artist_id; discogSource = 'deezer';
} else if (activeSrc.includes('musicbrainz') && musicbrainz_artist_id) {
discogId = musicbrainz_artist_id; discogSource = 'musicbrainz';
} else if (itunes_artist_id) {
discogId = itunes_artist_id; discogSource = 'itunes';
} else {
discogId = spotify_artist_id || discogs_artist_id || deezer_artist_id || musicbrainz_artist_id || itunes_artist_id;
discogSource = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : musicbrainz_artist_id ? 'musicbrainz' : 'itunes';
}
const discogHref = discogId ? buildArtistDetailPath(discogId, discogSource) : '#';
overlay.innerHTML = `
${artist.banner_url ? `
` : ''}
← Back to Watchlist
${artist.image_url ? `
` : ''}
${escapeHtml(artist.name)}
${artist.followers || artist.popularity ? `
${artist.followers ? `
${formatNumber(artist.followers)}
Followers
` : ''}
${artist.popularity ? `
${artist.popularity}/100
Popularity
` : ''}
` : ''}
${artist.genres && artist.genres.length > 0 ? `
${artist.genres.map(g => `${escapeHtml(g)} `).join('')}
` : ''}
${artist.summary ? `
About
${escapeHtml(artist.summary)}
` : ''}
${metaTags.length > 0 ? `
Info
${metaTags.join('')}
` : ''}
${recent_releases && recent_releases.length > 0 ? `
Recent Releases
${recent_releases.map(r => `
${r.album_cover_url ? `
` : ''}
${escapeHtml(r.album_name)}
${r.release_date}${r.track_count ? ` · ${r.track_count} tracks` : ''}
`).join('')}
` : ''}
Watchlist
${scanTimeText}
${dateAddedText ? `· ${dateAddedText} ` : ''}
${pills.length > 0 ? pills.join('') : 'No release types enabled '}
`;
// Wire up event listeners (avoids inline onclick escaping issues)
overlay.querySelector('.watchlist-detail-back-btn').addEventListener('click', () => {
closeWatchlistArtistDetailView();
});
overlay.querySelector('.watchlist-detail-settings-action').addEventListener('click', () => {
// Remove overlay immediately so it doesn't block the config modal
const detailOverlay = document.querySelector('.watchlist-artist-detail-overlay');
if (detailOverlay) detailOverlay.remove();
openWatchlistArtistConfigModal(artistId, artistName);
});
overlay.querySelector('.watchlist-detail-remove-action').addEventListener('click', () => {
removeFromWatchlistModal(artistId, artistName);
});
// Append to body as a fixed overlay
document.body.appendChild(overlay);
// Trigger slide-in animation
requestAnimationFrame(() => overlay.classList.add('visible'));
} catch (error) {
console.error('Error opening artist detail view:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
/**
* Close watchlist artist detail view (slides out)
*/
function closeWatchlistArtistDetailView() {
const overlay = document.querySelector('.watchlist-artist-detail-overlay');
if (overlay) {
overlay.classList.remove('visible');
overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
}
}
/**
* Open global watchlist settings modal
*/
async function openWatchlistGlobalSettingsModal() {
try {
const response = await fetch('/api/watchlist/global-config');
const data = await response.json();
if (!data.success) {
showToast(`Error loading global settings: ${data.error}`, 'error');
return;
}
const config = data.config;
// Populate checkboxes
document.getElementById('global-override-enabled').checked = config.global_override_enabled;
document.getElementById('global-include-albums').checked = config.include_albums;
document.getElementById('global-include-eps').checked = config.include_eps;
document.getElementById('global-include-singles').checked = config.include_singles;
document.getElementById('global-include-live').checked = config.include_live;
document.getElementById('global-include-remixes').checked = config.include_remixes;
document.getElementById('global-include-acoustic').checked = config.include_acoustic;
document.getElementById('global-include-compilations').checked = config.include_compilations;
document.getElementById('global-include-instrumentals').checked = config.include_instrumentals;
document.getElementById('global-exclude-terms').value = config.exclude_terms || '';
// Sync "Include Everything" checkbox
syncGlobalIncludeAllCheckbox();
// Update options visibility based on toggle state
toggleGlobalOverrideOptions();
// Reflect enabled state on the toggle card (styled in CSS)
const toggleLabel = document.getElementById('global-override-toggle-label');
if (toggleLabel) {
toggleLabel.classList.toggle('enabled', !!config.global_override_enabled);
}
// Show modal
const overlay = document.getElementById('watchlist-global-config-modal-overlay');
if (overlay) overlay.classList.remove('hidden');
} catch (error) {
console.error('Error opening global watchlist settings:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
/**
* Close global watchlist settings modal
*/
function closeWatchlistGlobalSettingsModal() {
const overlay = document.getElementById('watchlist-global-config-modal-overlay');
if (overlay) overlay.classList.add('hidden');
}
/**
* Toggle global override options visibility
*/
function toggleGlobalOverrideOptions() {
const enabled = document.getElementById('global-override-enabled').checked;
const options = document.getElementById('global-override-options');
if (options) {
options.style.opacity = enabled ? '1' : '0.4';
options.style.pointerEvents = enabled ? 'auto' : 'none';
}
// Reflect enabled state on the toggle card (styled in CSS)
const toggleLabel = document.getElementById('global-override-toggle-label');
if (toggleLabel) {
toggleLabel.classList.toggle('enabled', enabled);
}
}
/**
* Toggle all global include checkboxes
*/
function toggleGlobalIncludeAll() {
const checked = document.getElementById('global-include-all').checked;
['global-include-albums', 'global-include-eps', 'global-include-singles',
'global-include-live', 'global-include-remixes', 'global-include-acoustic',
'global-include-compilations', 'global-include-instrumentals'].forEach(id => {
const el = document.getElementById(id);
if (el) el.checked = checked;
});
}
/**
* Sync the "Include Everything" checkbox based on individual checkbox states
*/
function syncGlobalIncludeAllCheckbox() {
const allIds = ['global-include-albums', 'global-include-eps', 'global-include-singles',
'global-include-live', 'global-include-remixes', 'global-include-acoustic',
'global-include-compilations', 'global-include-instrumentals'];
const allChecked = allIds.every(id => {
const el = document.getElementById(id);
return el && el.checked;
});
const includeAllEl = document.getElementById('global-include-all');
if (includeAllEl) includeAllEl.checked = allChecked;
}
/**
* Save global watchlist configuration
*/
async function saveWatchlistGlobalConfig() {
try {
const globalOverrideEnabled = document.getElementById('global-override-enabled').checked;
const includeAlbums = document.getElementById('global-include-albums').checked;
const includeEps = document.getElementById('global-include-eps').checked;
const includeSingles = document.getElementById('global-include-singles').checked;
const includeLive = document.getElementById('global-include-live').checked;
const includeRemixes = document.getElementById('global-include-remixes').checked;
const includeAcoustic = document.getElementById('global-include-acoustic').checked;
const includeCompilations = document.getElementById('global-include-compilations').checked;
const includeInstrumentals = document.getElementById('global-include-instrumentals').checked;
const excludeTerms = (document.getElementById('global-exclude-terms').value || '').trim();
if (globalOverrideEnabled && !includeAlbums && !includeEps && !includeSingles) {
showToast('Please select at least one release type', 'error');
return;
}
const saveBtn = document.getElementById('save-global-config-btn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
}
const response = await fetch('/api/watchlist/global-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
global_override_enabled: globalOverrideEnabled,
include_albums: includeAlbums,
include_eps: includeEps,
include_singles: includeSingles,
include_live: includeLive,
include_remixes: includeRemixes,
include_acoustic: includeAcoustic,
include_compilations: includeCompilations,
include_instrumentals: includeInstrumentals,
exclude_terms: excludeTerms,
})
});
const data = await response.json();
if (data.success) {
showToast('Global watchlist settings saved', 'success');
closeWatchlistGlobalSettingsModal();
// Refresh the watchlist page to update the grid
if (currentPage === 'watchlist') {
watchlistPageState.isInitialized = false;
await initializeWatchlistPage();
}
} else {
showToast(`Error: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error saving global config:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
const saveBtn = document.getElementById('save-global-config-btn');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Global Settings';
}
}
}
/**
* Save watchlist artist configuration
* @param {string} artistId - Spotify artist ID
*/
async function saveWatchlistArtistConfig(artistId) {
try {
const includeAlbums = document.getElementById('config-include-albums').checked;
const includeEps = document.getElementById('config-include-eps').checked;
const includeSingles = document.getElementById('config-include-singles').checked;
const includeLive = document.getElementById('config-include-live').checked;
const includeRemixes = document.getElementById('config-include-remixes').checked;
const includeAcoustic = document.getElementById('config-include-acoustic').checked;
const includeCompilations = document.getElementById('config-include-compilations').checked;
const includeInstrumentals = document.getElementById('config-include-instrumentals').checked;
const autoDownloadEl = document.getElementById('config-auto-download');
const autoDownload = autoDownloadEl ? autoDownloadEl.checked : true;
const lookbackDaysVal = document.getElementById('config-lookback-days').value;
const lookbackDays = lookbackDaysVal !== '' ? parseInt(lookbackDaysVal) : null;
const activeSourceBtn = document.querySelector('#config-metadata-source-selector .config-msrc-btn.active');
const preferredMetadataSource = activeSourceBtn ? (activeSourceBtn.dataset.source || null) : null;
// Validate at least one release type is selected
if (!includeAlbums && !includeEps && !includeSingles) {
showToast('Please select at least one release type', 'error');
return;
}
// Disable save button
const saveBtn = document.getElementById('save-artist-config-btn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
}
// Send update to backend
const response = await fetch(`/api/watchlist/artist/${artistId}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
include_albums: includeAlbums,
include_eps: includeEps,
include_singles: includeSingles,
include_live: includeLive,
include_remixes: includeRemixes,
include_acoustic: includeAcoustic,
include_compilations: includeCompilations,
include_instrumentals: includeInstrumentals,
auto_download: autoDownload,
lookback_days: lookbackDays,
preferred_metadata_source: preferredMetadataSource,
})
});
const data = await response.json();
if (data.success) {
showToast('Artist preferences saved successfully', 'success');
closeWatchlistArtistConfigModal();
// Refresh watchlist page if we're on it
if (currentPage === 'watchlist') {
watchlistPageState.isInitialized = false;
await initializeWatchlistPage();
}
} else {
showToast(`Error saving preferences: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error saving watchlist artist config:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
// Re-enable save button
const saveBtn = document.getElementById('save-artist-config-btn');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Preferences';
}
}
}
/**
* Format large numbers with commas
* @param {number} num - Number to format
* @returns {string} Formatted number
*/
function formatNumber(num) {
if (!num) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
/**
* Format last scan timestamp as relative time
*/
function formatRelativeScanTime(isoString) {
if (!isoString) return 'Never scanned';
const diff = Date.now() - new Date(isoString).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Scanned just now';
if (mins < 60) return `Scanned ${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `Scanned ${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `Scanned ${days}d ago`;
const months = Math.floor(days / 30);
return `Scanned ${months}mo ago`;
}
/**
* Filter watchlist artists based on search input
*/
function filterWatchlistArtists() {
const searchInput = document.getElementById('watchlist-search-input');
const artistsList = document.getElementById('watchlist-artists-list');
if (!searchInput || !artistsList) return;
const searchTerm = searchInput.value.toLowerCase().trim();
const artistItems = artistsList.querySelectorAll('.watchlist-artist-card');
artistItems.forEach(item => {
const artistName = item.getAttribute('data-artist-name');
if (!searchTerm || artistName.includes(searchTerm)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
// Refresh batch bar in case visible selection changed
updateWatchlistBatchBar();
}
/**
* Start watchlist scan
*/
async function cancelWatchlistScan() {
try {
const cancelBtn = document.getElementById('cancel-watchlist-scan-btn');
if (cancelBtn) {
cancelBtn.disabled = true;
cancelBtn.textContent = 'Cancelling...';
}
const response = await fetch('/api/watchlist/scan/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to cancel scan');
}
showToast('Cancel request sent — scan will stop after current artist', 'info');
} catch (error) {
console.error('Error cancelling watchlist scan:', error);
showToast(`Error cancelling scan: ${error.message}`, 'error');
const cancelBtn = document.getElementById('cancel-watchlist-scan-btn');
if (cancelBtn) {
cancelBtn.disabled = false;
cancelBtn.textContent = 'Cancel Scan';
}
}
}
async function startWatchlistScan() {
try {
const button = document.getElementById('scan-watchlist-btn');
button.disabled = true;
_wlSetChipLabel(button, 'Starting scan...');
button.classList.add('btn-processing');
const response = await fetch('/api/watchlist/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to start scan');
}
_wlSetChipLabel(button, 'Scanning...');
// Show cancel button
const cancelBtn = document.getElementById('cancel-watchlist-scan-btn');
if (cancelBtn) {
cancelBtn.style.display = '';
cancelBtn.disabled = false;
cancelBtn.textContent = 'Cancel Scan';
}
// Show scan status
const statusDiv = document.getElementById('watchlist-scan-status');
if (statusDiv) {
statusDiv.style.display = 'flex';
}
// Start polling for updates
pollWatchlistScanStatus();
} catch (error) {
console.error('Error starting watchlist scan:', error);
const button = document.getElementById('scan-watchlist-btn');
button.disabled = false;
_wlSetChipLabel(button, 'Scan for New Releases');
button.classList.remove('btn-processing');
alert(`Error starting scan: ${error.message}`);
}
}
/**
* Poll watchlist scan status
*/
// #831 (Tacobell444): the scan summary said "New tracks: 19 • Added to
// wishlist: 10" with no way to see WHICH tracks. The scan now ships a per-run
// ledger (scan_track_events: track/artist/album/thumb + added|skipped) and
// this renders it as an expandable list under the completion summary.
function renderWatchlistScanTrackLedger(events) {
if (!Array.isArray(events) || events.length === 0) return '';
const added = events.filter(e => e.status === 'added');
const skipped = events.filter(e => e.status !== 'added');
const row = (e) => `
${escapeHtml(e.track_name || '')}
${escapeHtml(e.artist_name || '')}${e.album_name ? ' — ' + escapeHtml(e.album_name) : ''}
${e.status === 'added'
? '
added '
: '
skipped '}
`;
const section = (label, list) => list.length
? `${label} (${list.length})
${list.map(row).join('')}`
: '';
return `
Show tracks ▾
${section('Added to wishlist', added)}
${section('Found but skipped — already queued or blocklisted', skipped)}
`;
}
// Human-readable phase line for the scan deck ("checking_album_2_of_5" →
// "Checking album 2 of 5").
// Set a wl-chip button's label without destroying its icon/shimmer children
// (textContent wipes them; these buttons carry an svg + shimmer span).
function _wlSetChipLabel(btn, text) {
if (!btn) return;
const svg = btn.querySelector('svg');
const shimmer = btn.querySelector('.wl-chip-shimmer');
btn.textContent = '';
if (svg) btn.appendChild(svg);
btn.appendChild(document.createTextNode(' ' + text));
if (shimmer) btn.appendChild(shimmer);
}
function _wlPrettyPhase(data) {
const phase = data.current_phase || '';
if (!phase) return 'Working…';
const m = phase.match(/^checking_album_(\d+)_of_(\d+)$/);
if (m) return `Checking album ${m[1]} of ${m[2]}`;
const map = {
starting: 'Starting…',
fetching_discography: 'Fetching releases…',
populating_discovery_pool: 'Populating discovery…',
updating_listenbrainz: 'Updating ListenBrainz…',
};
return map[phase] || phase.replace(/_/g, ' ');
}
// Update a live counter and replay its pop animation when the value changes.
function _wlBumpCounter(id, value) {
const el = document.getElementById(id);
if (!el) return;
const text = String(value);
if (el.textContent !== text) {
el.textContent = text;
el.classList.remove('pop');
void el.offsetWidth; // restart the CSS animation
el.classList.add('pop');
}
}
function toggleWatchlistScanTracks(btn) {
const list = btn.parentElement.querySelector('.watchlist-scan-tracks');
if (!list) return;
const open = list.style.display !== 'none';
list.style.display = open ? 'none' : 'block';
btn.innerHTML = (open ? 'Show tracks ' : 'Hide tracks ')
+ `${open ? '▾' : '▴'} `;
}
function handleWatchlistScanData(data) {
const button = document.getElementById('scan-watchlist-btn');
const liveActivity = document.getElementById('watchlist-live-activity');
// Show/hide cancel button based on scan status
const cancelBtn = document.getElementById('cancel-watchlist-scan-btn');
if (cancelBtn) {
cancelBtn.style.display = data.status === 'scanning' ? '' : 'none';
}
// Update live visual activity display
if (liveActivity && data.status === 'scanning') {
liveActivity.style.display = liveActivity.classList.contains('wl-scan-deck') ? 'block' : 'flex';
// Update artist image and name (hide the img when THIS artist has no
// photo, so the previous artist's portrait doesn't linger and the
// glyph placeholder shows instead)
const artistImg = document.getElementById('watchlist-artist-img');
const artistName = document.getElementById('watchlist-artist-name');
if (artistImg) {
if (data.current_artist_image_url) {
artistImg.src = data.current_artist_image_url;
artistImg.style.display = 'block';
} else {
artistImg.style.display = 'none';
}
}
if (artistName) {
artistName.textContent = data.current_artist_name || 'Starting…';
}
// Update album image and name
const albumImg = document.getElementById('watchlist-album-img');
const albumName = document.getElementById('watchlist-album-name');
if (albumImg && data.current_album_image_url) {
albumImg.src = data.current_album_image_url;
albumImg.style.display = 'block';
} else if (albumImg) {
albumImg.style.display = 'none';
}
if (albumName) {
albumName.textContent = data.current_album
|| (data.current_phase === 'fetching_discography' ? 'Fetching releases…' : 'Looking for new releases…');
}
// Update current track ('' keeps the slot without echoing the album line)
const trackName = document.getElementById('watchlist-track-name');
if (trackName) {
trackName.textContent = data.current_track_name || '—';
}
// #831 round 2: scan-deck extras — artist progress bar, live counters,
// readable phase. All optional elements, so the legacy modal markup
// (which lacks them) keeps working untouched.
const total = data.total_artists || 0;
const idx = total ? Math.min((data.current_artist_index || 0) + 1, total) : 0;
const progText = document.getElementById('wl-scan-progress-text');
if (progText) progText.textContent = total ? `${idx} / ${total} artists` : '';
const progBar = document.getElementById('wl-scan-progress-bar');
if (progBar && total) progBar.style.width = `${Math.round((100 * idx) / total)}%`;
const phaseEl = document.getElementById('wl-scan-phase');
if (phaseEl) phaseEl.textContent = _wlPrettyPhase(data);
_wlBumpCounter('wl-scan-found', data.tracks_found_this_scan || 0);
_wlBumpCounter('wl-scan-added', data.tracks_added_this_scan || 0);
// Update wishlist additions feed — only re-render when it actually
// changed, so the slide-in animation plays once per new track instead
// of replaying on every poll.
const additionsFeed = document.getElementById('watchlist-additions-feed');
if (additionsFeed) {
const additions = data.recent_wishlist_additions || [];
const feedKey = additions.map(a => a.track_name).join('');
if (additionsFeed.dataset.feedKey !== feedKey) {
const grew = additions.length > 0 && additionsFeed.dataset.feedKey !== undefined
&& feedKey.length > (additionsFeed.dataset.feedKey || '').length;
additionsFeed.dataset.feedKey = feedKey;
if (additions.length > 0) {
additionsFeed.innerHTML = additions.map((item, i) => `
${escapeHtml(item.track_name || '')}
${escapeHtml(item.artist_name || '')}
`).join('');
} else {
additionsFeed.innerHTML = 'No tracks added yet…
';
}
}
}
} else if (liveActivity && data.status !== 'scanning') {
liveActivity.style.display = 'none';
}
if (data.status === 'completed') {
if (button) {
button.disabled = false;
_wlSetChipLabel(button, 'Scan for New Releases');
button.classList.remove('btn-processing');
}
// Hide live activity
if (liveActivity) {
liveActivity.style.display = 'none';
}
// Show completion message in status div
const statusDiv = document.getElementById('watchlist-scan-status');
if (statusDiv && data.summary) {
const newTracks = data.summary.new_tracks_found || 0;
const addedTracks = data.summary.tracks_added_to_wishlist || 0;
const totalArtists = data.summary.total_artists || 0;
const successfulScans = data.summary.successful_scans || 0;
let completionMessage = `Scan completed: ${successfulScans}/${totalArtists} artists scanned`;
if (newTracks > 0) {
completionMessage += `, found ${newTracks} new track${newTracks !== 1 ? 's' : ''}`;
if (addedTracks > 0) {
completionMessage += `, added ${addedTracks} to wishlist`;
}
} else {
completionMessage += ', no new tracks found';
}
// Update the scan status display with completion message and summary
statusDiv.innerHTML = `
${completionMessage}
Artists: ${totalArtists}
•
New tracks: ${newTracks}
•
Added to wishlist: ${addedTracks}
${renderWatchlistScanTrackLedger(data.scan_track_events)}
`;
}
// Update watchlist count
updateWatchlistButtonCount();
console.log('Watchlist scan completed:', data.summary);
} else if (data.status === 'cancelled') {
if (button) {
button.disabled = false;
_wlSetChipLabel(button, 'Scan for New Releases');
button.classList.remove('btn-processing');
}
// Hide cancel button
const cancelBtn = document.getElementById('cancel-watchlist-scan-btn');
if (cancelBtn) {
cancelBtn.style.display = 'none';
cancelBtn.disabled = false;
cancelBtn.textContent = 'Cancel Scan';
}
// Hide live activity
if (liveActivity) {
liveActivity.style.display = 'none';
}
// Show cancellation message
const statusDiv = document.getElementById('watchlist-scan-status');
if (statusDiv && data.summary) {
const scanned = data.summary.total_artists || 0;
const newTracks = data.summary.new_tracks_found || 0;
const addedTracks = data.summary.tracks_added_to_wishlist || 0;
statusDiv.innerHTML = `
Scan cancelled after ${scanned} artist${scanned !== 1 ? 's' : ''}
Scanned: ${scanned}
•
New tracks: ${newTracks}
•
Added to wishlist: ${addedTracks}
${renderWatchlistScanTrackLedger(data.scan_track_events)}
`;
}
// Update watchlist count
updateWatchlistButtonCount();
showToast('Watchlist scan cancelled', 'info');
console.log('Watchlist scan cancelled:', data.summary);
} else if (data.status === 'error') {
if (button) {
button.disabled = false;
_wlSetChipLabel(button, 'Scan for New Releases');
button.classList.remove('btn-processing');
}
// Hide cancel button
const cancelBtn = document.getElementById('cancel-watchlist-scan-btn');
if (cancelBtn) {
cancelBtn.style.display = 'none';
}
// Hide live activity
if (liveActivity) {
liveActivity.style.display = 'none';
}
console.error('Watchlist scan error:', data.error);
}
}
async function pollWatchlistScanStatus() {
if (socketConnected) return; // Phase 5: WS handles scan updates
try {
const response = await fetch('/api/watchlist/scan/status');
const data = await response.json();
if (data.success) {
handleWatchlistScanData(data);
if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') {
return; // Stop polling
}
}
// Continue polling if still scanning
if (data.success && data.status === 'scanning') {
setTimeout(pollWatchlistScanStatus, 2000); // Poll every 2 seconds
}
} catch (error) {
console.error('Error polling watchlist scan status:', error);
}
}
/**
* Update similar artists for discovery feature
*/
async function updateSimilarArtists() {
try {
const button = document.getElementById('update-similar-artists-btn');
const scanButton = document.getElementById('scan-watchlist-btn');
button.disabled = true;
_wlSetChipLabel(button, 'Updating...');
button.classList.add('btn-processing');
if (scanButton) scanButton.disabled = true;
const response = await fetch('/api/watchlist/update-similar-artists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to update similar artists');
}
showToast('Updating similar artists in background...', 'success');
// Poll for completion
pollSimilarArtistsUpdate();
} catch (error) {
console.error('Error updating similar artists:', error);
const button = document.getElementById('update-similar-artists-btn');
const scanButton = document.getElementById('scan-watchlist-btn');
button.disabled = false;
_wlSetChipLabel(button, 'Update Similar Artists');
button.classList.remove('btn-processing');
if (scanButton) scanButton.disabled = false;
showToast(`Error: ${error.message}`, 'error');
}
}
/**
* Poll similar artists update status
*/
async function pollSimilarArtistsUpdate() {
try {
const response = await fetch('/api/watchlist/similar-artists-status');
const data = await response.json();
if (data.success) {
const button = document.getElementById('update-similar-artists-btn');
const scanButton = document.getElementById('scan-watchlist-btn');
if (data.status === 'completed') {
if (button) {
button.disabled = false;
_wlSetChipLabel(button, 'Update Similar Artists');
button.classList.remove('btn-processing');
}
if (scanButton) scanButton.disabled = false;
showToast(`Updated similar artists for ${data.artists_processed || 0} artists!`, 'success');
return; // Stop polling
} else if (data.status === 'error') {
if (button) {
button.disabled = false;
_wlSetChipLabel(button, 'Update Similar Artists');
button.classList.remove('btn-processing');
}
if (scanButton) scanButton.disabled = false;
showToast('Error updating similar artists', 'error');
return; // Stop polling
} else if (data.status === 'running') {
// Update button text with progress
if (button && data.current_artist) {
button.textContent = `Updating... (${data.artists_processed || 0}/${data.total_artists || 0})`;
}
}
}
// Continue polling if still running
if (data.success && data.status === 'running') {
setTimeout(pollSimilarArtistsUpdate, 1000); // Poll every 1 second
}
} catch (error) {
console.error('Error polling similar artists update:', error);
const button = document.getElementById('update-similar-artists-btn');
const scanButton = document.getElementById('scan-watchlist-btn');
if (button) {
button.disabled = false;
_wlSetChipLabel(button, 'Update Similar Artists');
button.classList.remove('btn-processing');
}
if (scanButton) scanButton.disabled = false;
}
}
/**
* Remove artist from watchlist via modal
*/
async function removeFromWatchlistModal(artistId, artistName) {
try {
const response = await fetch('/api/watchlist/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to remove from watchlist');
}
console.log(`❌ Removed ${artistName} from watchlist`);
// Close detail view if open
closeWatchlistArtistDetailView();
// Refresh the watchlist page
watchlistPageState.isInitialized = false;
await initializeWatchlistPage();
// Update button count
updateWatchlistButtonCount();
// Update any visible artist cards
updateArtistCardWatchlistStatus();
} catch (error) {
console.error('Error removing from watchlist:', error);
alert(`Error removing ${artistName} from watchlist: ${error.message}`);
}
}
/**
* Get visible checked checkboxes (not hidden by search filter)
*/
function getVisibleCheckedWatchlist() {
return Array.from(document.querySelectorAll('.watchlist-select-cb:checked')).filter(cb => {
const item = cb.closest('.watchlist-artist-card');
return item && item.style.display !== 'none';
});
}
/**
* Update the batch action bar based on checkbox selection
*/
function updateWatchlistBatchBar() {
const checked = getVisibleCheckedWatchlist();
const countEl = document.getElementById('watchlist-batch-count');
const removeBtn = document.getElementById('watchlist-batch-remove-btn');
const selectAllCb = document.getElementById('watchlist-select-all-cb');
if (checked.length > 0) {
countEl.textContent = `${checked.length} selected`;
removeBtn.style.display = '';
} else {
countEl.textContent = '';
removeBtn.style.display = 'none';
}
// Update select-all checkbox state
if (selectAllCb) {
const visible = Array.from(document.querySelectorAll('.watchlist-select-cb')).filter(cb => {
const card = cb.closest('.watchlist-artist-card');
return card && card.style.display !== 'none';
});
selectAllCb.checked = visible.length > 0 && checked.length === visible.length;
selectAllCb.indeterminate = checked.length > 0 && checked.length < visible.length;
}
}
function toggleWatchlistSelectAll(checked) {
const checkboxes = document.querySelectorAll('.watchlist-select-cb');
checkboxes.forEach(cb => {
const card = cb.closest('.watchlist-artist-card');
if (card && card.style.display !== 'none') {
cb.checked = checked;
}
});
updateWatchlistBatchBar();
}
/**
* Batch remove selected artists from watchlist
*/
async function batchRemoveFromWatchlist() {
const checked = getVisibleCheckedWatchlist();
if (checked.length === 0) return;
const count = checked.length;
if (!await showConfirmDialog({ title: 'Remove Artists', message: `Remove ${count} artist${count !== 1 ? 's' : ''} from your watchlist?`, confirmText: 'Remove', destructive: true })) return;
const artistIds = checked.map(cb => cb.getAttribute('data-artist-id'));
try {
const response = await fetch('/api/watchlist/remove-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_ids: artistIds })
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to remove artists');
}
console.log(`❌ Batch removed ${data.removed} artists from watchlist`);
// Refresh the watchlist page
watchlistPageState.isInitialized = false;
await initializeWatchlistPage();
// Update button count
updateWatchlistButtonCount();
// Update any visible artist cards
updateArtistCardWatchlistStatus();
} catch (error) {
console.error('Error batch removing from watchlist:', error);
alert(`Error removing artists: ${error.message}`);
}
}
// --- Metadata Updater Functions ---
// Global state for metadata update polling
let metadataUpdatePolling = false;
let metadataUpdateInterval = null;
/**
* Handle metadata update button click
*/
async function handleMetadataUpdateButtonClick() {
const button = document.getElementById('metadata-update-button');
const currentAction = button.textContent;
if (currentAction === 'Begin Update') {
// Get refresh interval from dropdown
const refreshSelect = document.getElementById('metadata-refresh-interval');
const refreshIntervalDays = refreshSelect.value !== undefined ? parseInt(refreshSelect.value) : 30;
try {
button.disabled = true;
button.textContent = 'Starting...';
const response = await fetch('/api/metadata/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_interval_days: refreshIntervalDays })
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to start metadata update');
}
showToast('Metadata update started!', 'success');
// Start polling for status updates
startMetadataUpdatePolling();
} catch (error) {
console.error('Error starting metadata update:', error);
button.disabled = false;
button.textContent = 'Begin Update';
showToast(`Error: ${error.message}`, 'error');
}
} else {
// Stop metadata update
try {
button.disabled = true;
button.textContent = 'Stopping...';
const response = await fetch('/api/metadata/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error('Failed to stop metadata update');
}
} catch (error) {
console.error('Error stopping metadata update:', error);
button.disabled = false;
button.textContent = 'Stop Update';
}
}
}
/**
* Start polling for metadata update status
*/
function startMetadataUpdatePolling() {
if (metadataUpdatePolling) return; // Already polling
metadataUpdatePolling = true;
metadataUpdateInterval = setInterval(checkMetadataUpdateStatus, 1000); // Poll every second
// Also check immediately
checkMetadataUpdateStatus();
}
/**
* Stop polling for metadata update status
*/
function stopMetadataUpdatePolling() {
metadataUpdatePolling = false;
if (metadataUpdateInterval) {
clearInterval(metadataUpdateInterval);
metadataUpdateInterval = null;
}
}
/**
* Check current metadata update status and update UI
*/
async function checkMetadataUpdateStatus() {
if (socketConnected) return; // WebSocket handles this
try {
const response = await fetch('/api/metadata/status');
const data = await response.json();
if (data.success && data.status) {
updateMetadataProgressUI(data.status);
// Stop polling if completed or error
if (data.status.status === 'completed' || data.status.status === 'error') {
stopMetadataUpdatePolling();
}
}
} catch (error) {
console.warn('Could not fetch metadata update status:', error);
}
}
function updateMetadataStatusFromData(data) {
if (!data.success || !data.status) return;
const prev = _lastToolStatus['metadata'];
_lastToolStatus['metadata'] = data.status.status;
if (prev !== undefined && data.status.status === prev && data.status.status !== 'running' && data.status.status !== 'stopping') return;
updateMetadataProgressUI(data.status);
if (data.status.status === 'completed' || data.status.status === 'error') {
stopMetadataUpdatePolling();
}
}
/**
* Update metadata progress UI elements
*/
function updateMetadataProgressUI(status) {
const button = document.getElementById('metadata-update-button');
const phaseLabel = document.getElementById('metadata-phase-label');
const progressLabel = document.getElementById('metadata-progress-label');
const progressBar = document.getElementById('metadata-progress-bar');
const refreshSelect = document.getElementById('metadata-refresh-interval');
if (!button || !phaseLabel || !progressLabel || !progressBar || !refreshSelect) return;
if (status.status === 'running') {
button.textContent = 'Stop Update';
button.disabled = false;
refreshSelect.disabled = true;
// Update current artist display
const currentArtist = status.current_artist || 'Processing...';
phaseLabel.textContent = `Current Artist: ${currentArtist}`;
// Update progress
const processed = status.processed || 0;
const total = status.total || 0;
const percentage = status.percentage || 0;
progressLabel.textContent = `${processed} / ${total} artists (${percentage.toFixed(1)}%)`;
progressBar.style.width = `${percentage}%`;
} else if (status.status === 'stopping') {
button.textContent = 'Stopping...';
button.disabled = true;
phaseLabel.textContent = 'Current Artist: Stopping...';
} else if (status.status === 'completed') {
button.textContent = 'Begin Update';
button.disabled = false;
refreshSelect.disabled = false;
phaseLabel.textContent = 'Current Artist: Completed';
const processed = status.processed || 0;
const successful = status.successful || 0;
const failed = status.failed || 0;
progressLabel.textContent = `Completed: ${processed} processed, ${successful} successful, ${failed} failed`;
progressBar.style.width = '100%';
showToast(`Metadata update completed: ${successful} artists updated, ${failed} failed`, 'success');
} else if (status.status === 'error') {
button.textContent = 'Begin Update';
button.disabled = false;
refreshSelect.disabled = false;
phaseLabel.textContent = 'Current Artist: Error occurred';
progressLabel.textContent = status.error || 'Unknown error';
progressBar.style.width = '0%';
} else {
// Idle state
button.textContent = 'Begin Update';
button.disabled = false;
refreshSelect.disabled = false;
phaseLabel.textContent = 'Current Artist: Not running';
progressLabel.textContent = '0 / 0 artists (0.0%)';
progressBar.style.width = '0%';
}
}
/**
* Check active media server and hide metadata updater if not Plex
*/
async function checkAndHideMetadataUpdaterForNonPlex() {
try {
const response = await fetch('/api/active-media-server');
const data = await response.json();
if (data.success) {
const metadataCard = document.getElementById('metadata-updater-card');
if (metadataCard) {
// Show metadata updater only for Plex and Jellyfin
if (data.active_server === 'plex' || data.active_server === 'jellyfin') {
metadataCard.style.display = 'flex';
console.log(`Metadata updater shown: ${data.active_server} is active server`);
// Update the header text to reflect the current server
const headerElement = metadataCard.querySelector('.card-header h3');
if (headerElement) {
const serverDisplayName = data.active_server.charAt(0).toUpperCase() + data.active_server.slice(1);
headerElement.textContent = `${serverDisplayName} Metadata Updater`;
}
// Update the description based on the server type
const descElement = metadataCard.querySelector('.metadata-updater-description');
if (descElement) {
if (data.active_server === 'jellyfin') {
descElement.textContent = 'Download and upload high-quality artist images from Spotify to your Jellyfin server for artists without photos.';
} else {
descElement.textContent = 'Download and upload high-quality artist images from Spotify to your Plex server for artists without photos.';
}
}
} else {
// Hide metadata updater for Navidrome
metadataCard.style.display = 'none';
console.log(`Metadata updater hidden: ${data.active_server} does not support image uploads`);
}
}
}
} catch (error) {
console.warn('Could not check active media server for metadata updater visibility:', error);
}
}
async function checkAndShowMediaScanForPlex() {
/**
* Show media scan tool only for Plex (Jellyfin/Navidrome auto-scan)
*/
try {
const response = await fetch('/api/active-media-server');
const data = await response.json();
if (data.success) {
const mediaScanCard = document.getElementById('media-scan-card');
if (mediaScanCard) {
// Show media scan tool only for Plex
if (data.active_server === 'plex') {
mediaScanCard.style.display = 'flex';
console.log('Media scan tool shown: Plex is active server');
} else {
// Hide for Jellyfin/Navidrome (they auto-scan)
mediaScanCard.style.display = 'none';
console.log(`Media scan tool hidden: ${data.active_server} auto-scans`);
}
}
}
} catch (error) {
console.warn('Could not check active media server for media scan visibility:', error);
}
}
async function handleMediaScanButtonClick() {
/**
* Trigger a manual Plex media library scan
*/
const button = document.getElementById('media-scan-button');
const phaseLabel = document.getElementById('media-scan-phase-label');
const progressBar = document.getElementById('media-scan-progress-bar');
const progressLabel = document.getElementById('media-scan-progress-label');
const statusValue = document.getElementById('media-scan-status');
if (!button) return;
try {
// Disable button and update UI
button.disabled = true;
phaseLabel.textContent = 'Requesting scan...';
progressBar.style.width = '30%';
progressLabel.textContent = 'Sending scan request to Plex';
statusValue.textContent = 'Scanning...';
statusValue.style.color = 'rgb(var(--accent-rgb))';
// Request scan (database update handled by system automation)
const response = await fetch('/api/scan/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reason: 'Manual scan triggered from dashboard'
})
});
const result = await response.json();
if (result.success) {
// Get delay from API response (graceful fallback to 60 if not provided)
const delaySeconds = (result.scan_info && result.scan_info.delay_seconds) || 60;
let remainingSeconds = delaySeconds;
let countdownInterval = null;
let pollInterval = null;
// Update last scan time
const lastTimeEl = document.getElementById('media-scan-last-time');
if (lastTimeEl) {
const now = new Date();
lastTimeEl.textContent = now.toLocaleTimeString();
}
// Start countdown timer (visual feedback during delay)
phaseLabel.textContent = 'Scan scheduled...';
progressBar.style.width = '0%';
countdownInterval = setInterval(() => {
remainingSeconds--;
// Update progress bar (0% -> 100% over delay period)
const progress = ((delaySeconds - remainingSeconds) / delaySeconds) * 100;
progressBar.style.width = `${progress}%`;
// Update progress label with countdown
if (remainingSeconds > 0) {
progressLabel.textContent = `Starting scan in ${remainingSeconds}s...`;
} else {
progressLabel.textContent = 'Scan starting now...';
}
// When countdown reaches 0, start polling
if (remainingSeconds <= 0) {
clearInterval(countdownInterval);
// Transition to scanning phase
phaseLabel.textContent = 'Scan in progress...';
progressBar.style.width = '100%';
progressLabel.textContent = 'Media server is scanning library...';
showToast('📡 Media scan started', 'success', 3000);
// Start polling for scan completion (5 minutes = 150 polls × 2s)
let pollCount = 0;
const maxPolls = 150; // 5 minutes
pollInterval = setInterval(async () => {
if (socketConnected) return; // Phase 5: WS handles scan status
pollCount++;
if (pollCount > maxPolls) {
// Polling timeout after 5 minutes
clearInterval(pollInterval);
button.disabled = false;
phaseLabel.textContent = 'Scan completed';
progressBar.style.width = '0%';
progressLabel.textContent = 'Ready for next scan';
statusValue.textContent = 'Idle';
statusValue.style.color = '#b3b3b3';
showToast('✅ Media scan completed', 'success', 3000);
return;
}
try {
const statusResponse = await fetch('/api/scan/status');
const statusData = await statusResponse.json();
if (statusData.success && statusData.status) {
const status = statusData.status;
// Update status display
if (status.is_scanning) {
phaseLabel.textContent = 'Media server scanning...';
progressLabel.textContent = status.progress_message || 'Scan in progress';
} else if (status.status === 'idle') {
// Scan completed
clearInterval(pollInterval);
button.disabled = false;
phaseLabel.textContent = 'Scan completed successfully';
progressBar.style.width = '0%';
progressLabel.textContent = 'Ready for next scan';
statusValue.textContent = 'Idle';
statusValue.style.color = '#b3b3b3';
showToast('✅ Media scan completed', 'success', 3000);
}
}
} catch (pollError) {
console.debug('Scan status poll error:', pollError);
}
}, 2000); // Poll every 2 seconds
}
}, 1000); // Update countdown every second
} else {
// Error occurred
showToast(`❌ Scan request failed: ${result.error}`, 'error', 5000);
button.disabled = false;
phaseLabel.textContent = 'Scan failed';
progressBar.style.width = '0%';
progressLabel.textContent = result.error || 'Unknown error';
statusValue.textContent = 'Error';
statusValue.style.color = '#f44336';
}
} catch (error) {
console.error('Error requesting media scan:', error);
showToast('❌ Failed to request media scan', 'error', 3000);
button.disabled = false;
phaseLabel.textContent = 'Error';
progressBar.style.width = '0%';
progressLabel.textContent = error.message;
statusValue.textContent = 'Error';
statusValue.style.color = '#f44336';
}
}
/**
* Check for ongoing metadata update and restore state on page load
*/
async function checkAndRestoreMetadataUpdateState() {
try {
const response = await fetch('/api/metadata/status');
const data = await response.json();
if (data.success && data.status) {
const status = data.status;
// If metadata update is running, restore the UI state and start polling
if (status.status === 'running') {
console.log('Found ongoing metadata update, restoring state...');
updateMetadataProgressUI(status);
startMetadataUpdatePolling();
} else if (status.status === 'completed' || status.status === 'error') {
// Show final state but don't start polling
updateMetadataProgressUI(status);
}
}
} catch (error) {
console.warn('Could not check metadata update state on page load:', error);
}
}
// --- Live Log Viewer Functions ---
// Global state for log polling
let logPolling = false;
let logInterval = null;
let lastLogCount = 0;
/**
* Initialize the live log viewer for sync page
*/
function initializeLiveLogViewer() {
const logArea = document.getElementById('sync-log-area');
if (!logArea) return;
// Set initial content
logArea.value = 'Loading activity feed...';
// Start log polling
startLogPolling();
// Initial load
loadLogs();
}
/**
* Start polling for logs
*/
function startLogPolling() {
if (logPolling) return; // Already polling
logPolling = true;
logInterval = setInterval(loadLogs, 3000); // Poll every 3 seconds
console.log('📝 Started activity feed polling for sync page');
}
/**
* Stop polling for logs
*/
function stopLogPolling() {
logPolling = false;
if (logInterval) {
clearInterval(logInterval);
logInterval = null;
console.log('📝 Stopped log polling');
}
}
/**
* Load and display activity feed as logs
*/
async function loadLogs() {
if (socketConnected) return; // WebSocket handles this
try {
const response = await fetch('/api/logs');
const data = await response.json();
updateLogsFromData(data);
} catch (error) {
console.warn('Could not load activity logs for sync page:', error);
const logArea = document.getElementById('sync-log-area');
if (logArea && (logArea.value === 'Loading logs...' || logArea.value === '')) {
logArea.value = 'Error loading activity feed. Check console for details.';
}
}
}
function updateLogsFromData(data) {
if (!data.logs || !Array.isArray(data.logs)) return;
const logArea = document.getElementById('sync-log-area');
if (!logArea) return;
const logText = data.logs.join('\n');
// Store current scroll state
const wasAtTop = logArea.scrollTop <= 10;
const wasUserScrolled = logArea.scrollTop < logArea.scrollHeight - logArea.clientHeight - 10;
// Update content only if it has changed
if (logArea.value !== logText) {
logArea.value = logText;
// Smart scrolling: stay at top for new entries, preserve user position if scrolled
if (wasAtTop || !wasUserScrolled) {
logArea.scrollTop = 0; // Stay at top since newest entries are now at top
}
}
}
/**
* Stop log polling when leaving sync page
*/
function cleanupSyncPageLogs() {
stopLogPolling();
}
// --- Global Cleanup on Page Unload ---
// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed
// ===============================