soulsync/webui/static/api-monitor.js
nick2000713 84d6208bc9 perf: add Max Performance mode + stop password-manager autofill storm
Two CPU regressions surfaced in software-rendered / no-GPU containers
(Docker), where transform/opacity and canvas radial-gradient fills
rasterize on the CPU instead of a compositor:

1. Worker-orbs canvas + decorative motion saturate a core and freeze the
   UI. A new opt-in "Max Performance" mode is the nuclear low-power switch:
   body.max-performance CSS kills blur/shadow/filter AND all
   animation/transition (spinners go static), and JS halts every canvas
   loop (orbs, particles, cursor-glow, API sparks) via window._maxPerfActive.
   Reduce Visual Effects is now decoupled from the orbs — they follow their
   own toggle; only Max Performance force-kills them. While Max Performance
   is on, the Orbs/Particles/Reduce-Effects checkboxes lock + grey out, and
   save reads the runtime flags so prefs aren't clobbered.

2. Password managers (Bitwarden et al.) rebuild their autofill overlay on
   every DOM mutation; a captured trace showed Bitwarden using ~6x the CPU
   of the whole app (~400 setupOverlayOnField/sec). suppressPasswordManager-
   Autofill() tags non-credential inputs with data-bwignore / data-1p-ignore
   / data-lpignore / data-form-type=other so the managers skip them; real
   login/PIN fields are left alone.

Wired through: web_server.py (_initial_appearance_context), index.html
(inline flag + body class + checkbox), init.js (applyMaxPerformance +
bootstrap + listener + autofill suppression), settings.js (load/save),
worker-orbs.js / particles.js / api-monitor.js (gates), style.css.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 11:06:13 +02:00

4296 lines
196 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// == 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 = `
<div class="gauge-card-header">
<span class="gauge-card-dot" style="background:${accent}"></span>
<span class="gauge-card-name">${label}</span>
<span class="gauge-card-status" data-status="${wStatus}">${_workerStatusLabel(wStatus, worker)}</span>
</div>
<div class="gauge-arc-wrap">${_buildGaugeSVG(svc, value, max)}</div>
<div class="gauge-card-stats">
<div class="gauge-card-stat"><span class="gauge-card-stat-val">${value.toFixed(0)}</span><span class="gauge-card-stat-label">calls/min</span></div>
<div class="gauge-card-stat"><span class="gauge-card-stat-val">${worker.calls_1h || 0}</span><span class="gauge-card-stat-label">last hour</span></div>
<div class="gauge-card-stat"><span class="gauge-card-stat-val">${(worker.calls_24h || 0).toLocaleString()}</span><span class="gauge-card-stat-label">24h</span></div>
</div>
${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
? `<div class="rate-eq-avatar" aria-hidden="true">
<img class="rate-eq-avatar-logo" src="${logoSrc}" alt=""
onerror="this.parentElement.classList.add('rate-eq-avatar--fallback');this.replaceWith(Object.assign(document.createElement('span'),{className:'rate-eq-avatar-glyph',textContent:'${fallbackGlyph}'}));">
</div>`
: `<div class="rate-eq-avatar rate-eq-avatar--fallback" aria-hidden="true">
<span class="rate-eq-avatar-glyph">${fallbackGlyph}</span>
</div>`;
bar.innerHTML = `
<div class="rate-eq-avatar-wrap">${avatar}</div>
<div class="rate-eq-track">
<div class="rate-eq-ticks"></div>
<div class="rate-eq-fill">
<div class="rate-eq-shimmer"></div>
<div class="rate-eq-tip"></div>
</div>
<div class="rate-eq-peak"></div>
<div class="rate-eq-cooldown">
<div class="rate-eq-cooldown-fill"></div>
<div class="rate-eq-cooldown-time"></div>
</div>
<div class="rate-eq-value">0</div>
</div>
<div class="rate-eq-meta">
<span class="rate-eq-state" data-status="stopped">
<span class="rate-eq-state-dot"></span>
<span class="rate-eq-state-text">Stopped</span>
</span>
<span class="rate-eq-name">${label}</span>
</div>
`;
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 / max-performance.
if (!window._reduceEffectsActive && !window._maxPerfActive && !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 `<div class="gauge-budget-bar ${cls}">
<div class="gauge-budget-fill" style="width:${pct}%"></div>
<span class="gauge-budget-label">${budget.used.toLocaleString()} / ${budget.limit.toLocaleString()} daily</span>
</div>`;
}
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 `<div class="gauge-rl-badge"><span class="gauge-rl-dot"></span>RATE LIMITED<span class="gauge-rl-time">${text}</span></div>`;
}
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 `
<svg viewBox="0 0 ${size} ${size}" class="rate-gauge-svg">
<!-- Background track -->
<path d="${_gArc(startAngle, arcEnd)}" fill="none" stroke="rgba(255,255,255,0.05)" stroke-width="${stroke}" stroke-linecap="round"/>
<!-- Danger zone marker (last 20% of arc, subtle) -->
<path d="${_gArc(startAngle + totalArc * 0.8, arcEnd)}" fill="none" stroke="rgba(239,68,68,0.1)" stroke-width="${stroke}" stroke-linecap="round"/>
<!-- Active arc (CSS handles glow via drop-shadow) -->
${pct > 0 ? `<path class="gauge-active-arc" data-color="${color}" d="${_gArc(startAngle, endAngle)}" fill="none" stroke="${color}" stroke-width="${stroke}" stroke-linecap="round" style="filter:drop-shadow(0 0 6px ${color}60)"/>` : ''}
<!-- Endpoint dot -->
${dot ? `<circle class="gauge-dot" cx="${dot.x}" cy="${dot.y}" r="5" fill="${color}" style="filter:drop-shadow(0 0 4px ${color}80)"/><circle cx="${dot.x}" cy="${dot.y}" r="2.5" fill="#fff" opacity="0.9"/>` : ''}
<!-- Scale labels: 0 and max -->
<text x="${_gPt(startAngle, r + 16).x}" y="${_gPt(startAngle, r + 16).y}" text-anchor="middle" dominant-baseline="middle" fill="rgba(255,255,255,0.2)" font-size="9" font-family="-apple-system,sans-serif">0</text>
<text x="${_gPt(arcEnd, r + 16).x}" y="${_gPt(arcEnd, r + 16).y}" text-anchor="middle" dominant-baseline="middle" fill="rgba(255,255,255,0.2)" font-size="9" font-family="-apple-system,sans-serif">${max}</text>
<!-- Center content -->
<text class="gauge-value" x="${cx}" y="${cy - 12}" text-anchor="middle" dominant-baseline="middle">${Math.round(value)}</text>
<text class="gauge-unit" x="${cx}" y="${cy + 6}" text-anchor="middle">/min</text>
<!-- Service label -->
<text class="gauge-label" x="${cx}" y="${cy + 28}" text-anchor="middle">${label}</text>
</svg>
`;
}
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 = `
<div class="rate-modal">
<div class="rate-modal-header">
<div class="rate-modal-header-info">
<div class="rate-modal-header-dot" style="background:${accent}"></div>
<div>
<h3>${label}</h3>
<span class="rate-modal-header-sub">${currentData.cpm || 0} calls/min — limit ${currentData.limit || '?'}/min</span>
</div>
</div>
<button class="watch-all-close" onclick="_closeRateModal()">&times;</button>
</div>
<div class="rate-modal-body">
<div class="rate-modal-section-title">24-Hour Call History</div>
<div class="rate-modal-chart-wrap">
<canvas id="rate-modal-chart" width="700" height="280"></canvas>
<div class="rate-modal-chart-legend" id="rate-modal-chart-legend"></div>
</div>
${isSpotify ? '<div class="rate-modal-section-title">Per-Endpoint Breakdown</div><div class="rate-modal-endpoints" id="rate-modal-endpoints"></div>' : ''}
</div>
</div>
`;
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 =>
`<span class="rate-chart-legend-item"><span class="rate-chart-legend-dot" style="background:${item.color}"></span>${item.name}</span>`
).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 = '<div class="rate-modal-ep-empty">No active Spotify endpoints — start an enrichment worker or search to see activity</div>';
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 `<div class="rate-modal-ep">
<span class="rate-modal-ep-name">${name}</span>
<div class="rate-modal-ep-bar"><div class="rate-modal-ep-fill" style="width:${pct}%;background:${color}"></div></div>
<span class="rate-modal-ep-value">${Math.round(cpm)}/min</span>
</div>`;
}).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');
// system memory % headline + SoulSync's own RSS in the subtitle (#935 follow-up)
updateStatCard('memory-card', data.memory_usage,
data.process_memory ? `SoulSync · ${data.process_memory}` : '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 = `
<div class="activity-item">
<span class="activity-icon">📊</span>
<div class="activity-text-content">
<p class="activity-title">System Started</p>
<p class="activity-subtitle">Dashboard initialized successfully</p>
</div>
<p class="activity-time">Just now</p>
</div>
`;
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 = `
<span class="activity-icon">${escapeHtml(activity.icon)}</span>
<div class="activity-text-content">
<p class="activity-title">${escapeHtml(activity.title)}</p>
<p class="activity-subtitle">${escapeHtml(activity.subtitle)}</p>
</div>
<p class="activity-time">${_activityTimeAgo(activity)}</p>
`;
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 = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> ${globalOverrideActive ? 'Global Override ON' : 'Global Settings'}`;
}
// Render artist cards
if (gridEl) {
gridEl.innerHTML = artists.map(artist => {
const pills = [];
if (artist.include_albums) pills.push('<span class="watchlist-pill watchlist-pill-active">Albums</span>');
if (artist.include_eps) pills.push('<span class="watchlist-pill watchlist-pill-active">EPs</span>');
if (artist.include_singles) pills.push('<span class="watchlist-pill watchlist-pill-active">Singles</span>');
if (artist.include_live) pills.push('<span class="watchlist-pill watchlist-pill-filter">Live</span>');
if (artist.include_remixes) pills.push('<span class="watchlist-pill watchlist-pill-filter">Remixes</span>');
if (artist.include_acoustic) pills.push('<span class="watchlist-pill watchlist-pill-filter">Acoustic</span>');
if (artist.include_compilations) pills.push('<span class="watchlist-pill watchlist-pill-filter">Compilations</span>');
const sourceBadges = [];
if (artist.spotify_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-spotify">Spotify</span>');
if (artist.itunes_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-itunes">iTunes</span>');
if (artist.deezer_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-deezer">Deezer</span>');
if (artist.discogs_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-discogs">Discogs</span>');
if (artist.musicbrainz_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-musicbrainz">MusicBrainz</span>');
if (artist.amazon_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-amazon">Amazon</span>');
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 `
<div class="watchlist-artist-card"
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '&quot;')}"
data-artist-id="${artistPrimaryId}"
data-last-scan="${artist.last_scan_timestamp || ''}"
data-added="${artist.date_added || ''}">
<label class="watchlist-card-checkbox" onclick="event.stopPropagation();">
<input type="checkbox" class="watchlist-select-cb"
data-artist-id="${artistPrimaryId}"
data-artist-name="${escapeHtml(artist.artist_name)}"
onchange="updateWatchlistBatchBar()">
<span class="watchlist-checkbox-custom"></span>
</label>
<button class="watchlist-card-gear"
data-artist-id="${artistPrimaryId}"
data-artist-name="${escapeHtml(artist.artist_name)}"
onclick="event.stopPropagation();"
title="Artist settings">
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.48.48 0 00-.48-.41h-3.84a.48.48 0 00-.48.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87a.48.48 0 00.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.26.41.48.41h3.84c.24 0 .44-.17.48-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.6 3.6 0 0112 15.6z"/></svg>
</button>
<div class="watchlist-card-image">
${artist.image_url ? `<img src="${artist.image_url}" alt="${escapeHtml(artist.artist_name)}" onerror="if(!this.dataset.retried){this.dataset.retried='1';this.src=this.src;}else{this.parentElement.innerHTML='<div class=\\'watchlist-card-image-fallback\\'>🎤</div>';}">` : '<div class="watchlist-card-image-fallback">🎤</div>'}
</div>
<div class="watchlist-card-info">
<span class="watchlist-card-name">${escapeHtml(artist.artist_name)}</span>
<span class="watchlist-card-meta">${formatRelativeScanTime(artist.last_scan_timestamp)}</span>
</div>
${sourceBadges.length > 0 ? `<div class="watchlist-card-sources">${sourceBadges.join('')}</div>` : ''}
${pills.length > 0 ? `<div class="watchlist-card-pills">${pills.join('')}</div>` : ''}
</div>
`;
}).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 = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> 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 = `<span class="sync-stat">Artists: ${statusRes.summary.total_artists || 0}</span><span class="sync-separator"> • </span><span class="sync-stat">New tracks: ${statusRes.summary.new_tracks_found || 0}</span><span class="sync-separator"> • </span><span class="sync-stat">Added to wishlist: ${statusRes.summary.tracks_added_to_wishlist || 0}</span>`;
}
}
}
// 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. Library photos (from the wishlist endpoint, covering
// every wishlist artist) seed it first; curated watchlist photos override where present.
const _artistImageMap = new Map();
for (const res of [albumRes, singleRes]) {
if (res && res.artist_images) {
for (const [name, url] of Object.entries(res.artist_images)) {
if (name && url) _artistImageMap.set(name.toLowerCase(), url);
}
}
}
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 = '<div class="wl-nebula-empty">Your wishlist is empty</div>'; 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 += `<div class="wl-orb-group" data-artist="${escapeHtml(name)}" style="animation-delay:${delay}ms">`;
// Enhancement 2: hover tooltip
html += `<div class="wl-orb-tooltip">${escapeHtml(name)}<br><span>${total} track${total !== 1 ? 's' : ''}</span></div>`;
html += `<div class="wl-orb ${sz}${pulseClass}" style="--orb-hue:${hue}" onclick="_toggleOrbExpand(this)">`;
html += `<div class="wl-orb-glow"></div>`;
html += img ? `<img class="wl-orb-img" src="${img}" alt="">` : `<div class="wl-orb-initials">${escapeHtml(name.substring(0, 2).toUpperCase())}</div>`;
html += `<div class="wl-orb-ring"></div>`;
// 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 += `<div class="wl-orb-art-ring">`;
ringCovers.forEach((url, i) => {
const angle = (360 / ringCovers.length) * i;
html += `<img class="wl-art-ring-item" src="${url}" style="--ring-angle:${angle}deg" alt="">`;
});
html += `</div>`;
}
html += `</div>`; // /orb
// Enhancement 8: clickable artist name → navigate to artist detail
html += `<div class="wl-orb-label" onclick="event.stopPropagation(); _navigateToArtistFromWishlist('${escapeHtml(name)}')" title="View artist">${escapeHtml(name)}</div>`;
html += `<div class="wl-orb-meta">${total} track${total !== 1 ? 's' : ''}</div>`;
// Expanded content
html += `<div class="wl-orb-expanded">`;
if (data.albums.size > 0) {
html += `<div class="wl-album-fan">`;
for (const [an, ad] of data.albums) {
const tileId = 'wl-tile-' + an.replace(/\W/g, '_') + '_' + idx;
html += `<div class="wl-album-tile" data-album="${escapeHtml(an)}" onclick="_toggleAlbumTile(this)">`;
html += `<div class="wl-album-tile-art">${ad.image ? `<img src="${ad.image}" alt="">` : `<div class="wl-album-tile-fallback">&#128191;</div>`}</div>`;
html += `<div class="wl-album-tile-info"><div class="wl-album-tile-name">${escapeHtml(an)}</div><div class="wl-album-tile-count">${ad.tracks.length} track${ad.tracks.length !== 1 ? 's' : ''}</div></div>`;
html += `<span class="wl-album-tile-badge">${ad.tracks.length}</span>`;
html += `<button class="wl-album-tile-remove" onclick="event.stopPropagation();_removeWishlistAlbum('${escapeHtml(an)}')" title="Remove album">&#10005;</button>`;
// Track list (hidden until tile clicked)
html += `<div class="wl-tile-tracks">`;
for (const tr of ad.tracks) {
html += `<div class="wl-tile-track">`;
html += `<span class="wl-tile-track-name">${escapeHtml(tr.track)}</span>`;
html += `<button class="wl-tile-track-remove" onclick="event.stopPropagation();_removeWishlistTrack('${escapeHtml(tr.id)}')" title="Remove track">&#10005;</button>`;
html += `</div>`;
}
html += `</div>`;
html += `</div>`;
}
html += `</div>`;
}
if (data.singles.length > 0) {
html += `<div class="wl-singles-orbit">`;
for (const s of data.singles) {
html += `<div class="wl-single-moon" data-track-id="${escapeHtml(s.id)}">`;
html += s.image ? `<img src="${s.image}" alt="">` : `<span class="wl-moon-fallback">&#11088;</span>`;
html += `<div class="wl-moon-label">${escapeHtml(s.track)}</div>`;
html += `<button class="wl-moon-remove-btn" onclick="event.stopPropagation();_removeWishlistTrack('${escapeHtml(s.id)}')" title="Remove">&#10005;</button>`;
html += `</div>`;
}
html += `</div>`;
}
html += `</div></div>`; // /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 = `
<div class="delete-group-dialog">
<div class="delete-group-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--accent-rgb))" stroke-width="1.8"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</div>
<h3 class="delete-group-title">Download Wishlist</h3>
<p class="delete-group-message">Choose which category to process</p>
<div class="delete-group-actions">
<button class="delete-group-btn delete-group-keep" id="ndc-albums">
&#128191; Albums &amp; EPs <span style="opacity:0.5;margin-left:6px">${albumCount} tracks</span>
</button>
<button class="delete-group-btn delete-group-keep" id="ndc-singles" style="border-color: rgba(var(--accent-rgb), 0.15); background: rgba(var(--accent-rgb), 0.06);">
&#11088; Singles <span style="opacity:0.5;margin-left:6px">${singleCount} tracks</span>
</button>
<button class="delete-group-btn delete-group-cancel" id="ndc-cancel">Cancel</button>
</div>
</div>
`;
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 = `
<div class="modal-container playlist-modal watchlist-fullscreen">
<div class="playlist-modal-header">
<div class="playlist-header-content" style="width: 100%;">
<h2><svg class="watchlist-header-icon" width="24" height="24" viewBox="0 0 24 24" fill="rgb(var(--accent-rgb))"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg> Watchlist</h2>
<div class="playlist-quick-info">
<span class="playlist-track-count">${countData.count} artist${countData.count !== 1 ? 's' : ''}</span>
<span class="playlist-owner" id="watchlist-next-auto-timer">Next Auto${countdownText ? ': ' + countdownText : ''}</span>
</div>
<div class="playlist-modal-sync-status" id="watchlist-scan-status" style="display: ${scanStatus !== 'idle' ? 'flex' : 'none'}; flex-direction: column; align-items: center;">
<!-- Live Visual Activity Display -->
<div id="watchlist-live-activity" class="watchlist-live-activity" style="display: ${scanStatus === 'scanning' ? 'flex' : 'none'};">
<!-- Artist Photo -->
<div class="watchlist-live-activity-col">
<img id="watchlist-artist-img" class="watchlist-live-activity-artist-img" src="" alt="Artist" onerror="this.style.display='none';" />
<div id="watchlist-artist-name" class="watchlist-live-activity-label">Waiting...</div>
</div>
<!-- Album Cover -->
<div class="watchlist-live-activity-col">
<img id="watchlist-album-img" class="watchlist-live-activity-album-img" src="" alt="Album" onerror="this.style.display='none';" />
<div id="watchlist-album-name" class="watchlist-live-activity-label">Waiting...</div>
</div>
<!-- Track and Wishlist Feed -->
<div class="watchlist-live-activity-feed">
<div class="watchlist-live-activity-feed-label">Current Track:</div>
<div id="watchlist-track-name" class="watchlist-live-activity-track">Waiting...</div>
<div class="watchlist-live-activity-feed-label-orange">✨ Recently Added:</div>
<div id="watchlist-additions-feed" style="max-height: 80px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; font-size: 10px;">
<!-- Populated by JavaScript -->
</div>
</div>
</div>
${statusData.summary ? `
<div class="scan-status-summary" style="margin-top: 8px; font-size: 13px; opacity: 0.8;">
<span class="sync-stat">Artists: ${statusData.summary.total_artists || 0}</span>
<span class="sync-separator"> • </span>
<span class="sync-stat">New tracks: ${statusData.summary.new_tracks_found || 0}</span>
<span class="sync-separator"> • </span>
<span class="sync-stat">Added to wishlist: ${statusData.summary.tracks_added_to_wishlist || 0}</span>
</div>
` : ''}
</div>
</div>
<span class="playlist-modal-close" onclick="closeWatchlistModal()">&times;</span>
</div>
<div class="playlist-modal-body">
<div class="watchlist-actions" style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center; padding: 12px 32px; flex-wrap: wrap;">
<button class="playlist-modal-btn playlist-modal-btn-primary watchlist-btn-scan ${scanStatus === 'scanning' ? 'btn-processing' : ''}"
id="scan-watchlist-btn"
onclick="startWatchlistScan()"
${scanStatus === 'scanning' ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
${scanStatus === 'scanning' ? 'Scanning...' : 'Scan for New Releases'}
</button>
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-btn-cancel"
id="cancel-watchlist-scan-btn"
onclick="cancelWatchlistScan()"
style="display: ${scanStatus === 'scanning' ? '' : 'none'};">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
Cancel Scan
</button>
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-btn-similar"
id="update-similar-artists-btn"
onclick="updateSimilarArtists()"
${scanStatus === 'scanning' ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Update Similar Artists
</button>
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-btn-settings ${globalOverrideActive ? 'watchlist-global-settings-active' : ''}"
id="watchlist-global-settings-btn"
onclick="openWatchlistGlobalSettingsModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
${globalOverrideActive ? 'Global Override ON' : 'Global Settings'}
</button>
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-btn-origins"
id="watchlist-download-origins-btn"
onclick="openDownloadOriginsModal('watchlist')"
title="See every track your watchlist downloaded">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download Origins
</button>
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-btn-blocklist"
id="watchlist-blocklist-btn"
onclick="openBlocklistModal('artist')"
title="Block artists, albums or tracks from ever being downloaded">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg>
Blocklist
</button>
</div>
${globalOverrideActive ? `
<div class="watchlist-global-override-banner" style="margin: 0 32px 12px;">
<span>⚠️</span>
<span>Global override is active — per-artist settings are being ignored during scans.</span>
</div>
` : ''}
<!-- Search Bar -->
<div class="watchlist-search-container" style="margin-bottom: 12px;">
<svg class="watchlist-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="rgba(255,255,255,0.35)"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text"
id="watchlist-search-input"
class="watchlist-search-input"
placeholder="Search watchlist..."
oninput="filterWatchlistArtists()">
</div>
<!-- Batch action bar -->
<div class="watchlist-batch-bar" id="watchlist-batch-bar">
<label class="watchlist-select-all-label" onclick="event.stopPropagation();">
<input type="checkbox" id="watchlist-select-all-cb" onchange="toggleWatchlistSelectAll(this.checked)">
<span>Select All</span>
</label>
<span class="watchlist-batch-count" id="watchlist-batch-count"></span>
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-batch-remove-btn"
id="watchlist-batch-remove-btn"
onclick="batchRemoveFromWatchlist()"
style="display: none;">
Remove Selected
</button>
</div>
<div class="watchlist-artists-grid" id="watchlist-artists-list">
${artistsData.artists.map(artist => {
const pills = [];
if (artist.include_albums) pills.push('<span class="watchlist-pill watchlist-pill-active">Albums</span>');
if (artist.include_eps) pills.push('<span class="watchlist-pill watchlist-pill-active">EPs</span>');
if (artist.include_singles) pills.push('<span class="watchlist-pill watchlist-pill-active">Singles</span>');
if (artist.include_live) pills.push('<span class="watchlist-pill watchlist-pill-filter">Live</span>');
if (artist.include_remixes) pills.push('<span class="watchlist-pill watchlist-pill-filter">Remixes</span>');
if (artist.include_acoustic) pills.push('<span class="watchlist-pill watchlist-pill-filter">Acoustic</span>');
if (artist.include_compilations) pills.push('<span class="watchlist-pill watchlist-pill-filter">Compilations</span>');
const sourceBadges = [];
if (artist.spotify_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-spotify">Spotify</span>');
if (artist.itunes_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-itunes">iTunes</span>');
if (artist.deezer_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-deezer">Deezer</span>');
if (artist.discogs_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-discogs">Discogs</span>');
if (artist.musicbrainz_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-musicbrainz">MusicBrainz</span>');
if (artist.amazon_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-amazon">Amazon</span>');
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 `
<div class="watchlist-artist-card"
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '&quot;')}"
data-artist-id="${artistPrimaryId}">
<label class="watchlist-card-checkbox" onclick="event.stopPropagation();">
<input type="checkbox" class="watchlist-select-cb"
data-artist-id="${artistPrimaryId}"
data-artist-name="${escapeHtml(artist.artist_name)}"
onchange="updateWatchlistBatchBar()">
<span class="watchlist-checkbox-custom"></span>
</label>
<button class="watchlist-card-gear"
data-artist-id="${artistPrimaryId}"
data-artist-name="${escapeHtml(artist.artist_name)}"
onclick="event.stopPropagation();"
title="Artist settings">
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.48.48 0 00-.48-.41h-3.84a.48.48 0 00-.48.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87a.48.48 0 00.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.26.41.48.41h3.84c.24 0 .44-.17.48-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.6 3.6 0 0112 15.6z"/></svg>
</button>
<div class="watchlist-card-image">
${artist.image_url ? `
<img src="${artist.image_url}"
alt="${escapeHtml(artist.artist_name)}"
loading="lazy"
onerror="this.parentElement.innerHTML='<div class=\\'watchlist-card-image-fallback\\'>🎤</div>';">
` : `
<div class="watchlist-card-image-fallback">🎤</div>
`}
</div>
<div class="watchlist-card-info">
<span class="watchlist-card-name">${escapeHtml(artist.artist_name)}</span>
<span class="watchlist-card-meta">${formatRelativeScanTime(artist.last_scan_timestamp)}</span>
</div>
${sourceBadges.length > 0 ? `<div class="watchlist-card-sources">${sourceBadges.join('')}</div>` : ''}
${pills.length > 0 ? `<div class="watchlist-card-pills">${pills.join('')}</div>` : ''}
</div>
`}).join('')}
</div>
</div>
</div>
`;
// 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 = '<div class="wl-linked-sources">';
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 += `
<div class="wl-linked-row ${matched ? 'matched' : 'unmatched'}" data-source="${src.key}">
<span class="wl-linked-icon">${src.icon}</span>
<span class="wl-linked-label">${src.label}</span>
<span class="wl-linked-status">${matched
? `<span class="wl-linked-id" title="${escapeHtml(src.id)}">${shortId}</span>`
: '<span class="wl-linked-none">Not matched</span>'
}</span>
<button class="wl-linked-fix-btn" onclick="_openSourceSearch('${src.key}', '${escapeForInlineJs(artistId)}', '${escapeForInlineJs(artistName)}')">${matched ? 'Fix' : 'Match'}</button>
${matched ? `<button class="wl-linked-clear-btn" onclick="_clearSourceMatch('${src.key}', '${escapeForInlineJs(artistId)}', '${escapeForInlineJs(artistName)}')" title="Clear this match">&times;</button>` : ''}
</div>`;
}
html += '</div>';
// Per-source search panel (hidden, populated on Fix/Match click)
html += `<div class="wl-linked-search-panel" id="wl-linked-search-panel" style="display:none">
<div class="wl-linked-search-header">
<span id="wl-linked-search-title">Search</span>
<button class="wl-linked-search-close" onclick="document.getElementById('wl-linked-search-panel').style.display='none'">&times;</button>
</div>
<div class="wl-linked-search-input-row">
<input type="text" id="wl-linked-search-input" class="watchlist-linked-search-input"
placeholder="Search..." value="${escapeHtml(artistName)}">
<button class="watchlist-linked-search-btn" id="wl-linked-search-go">Search</button>
</div>
<div class="wl-linked-search-results" id="wl-linked-search-results"></div>
</div>`;
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 = '<div style="padding:12px;color:#888;text-align:center">Searching...</div>';
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 = '<div style="padding:12px;color:#888;text-align:center">No artists found</div>';
return;
}
let html = '';
for (const r of results) {
html += `<div class="watchlist-linked-search-result" data-id="${escapeHtml(r.id)}" data-name="${escapeHtml(r.name)}">
${r.image ? `<img src="${r.image}" alt="" class="watchlist-linked-result-img">` :
`<div class="watchlist-linked-result-img-placeholder">🎵</div>`}
<div class="watchlist-linked-result-info">
<div class="watchlist-linked-result-name">${escapeHtml(r.name)}</div>
<div class="watchlist-linked-result-meta">${escapeHtml(r.extra || '')}</div>
</div>
<button class="watchlist-linked-select-btn">Select</button>
</div>`;
}
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 = '<div style="padding:12px;color:#f44;text-align:center">Search error</div>';
}
}
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 ? `
<img src="${artist.image_url}"
alt="${escapeHtml(artist.name)}"
class="watchlist-artist-config-hero-image"
loading="lazy">
` : ''}
<div class="watchlist-artist-config-hero-info">
<h2 class="watchlist-artist-config-hero-name">${escapeHtml(artist.name)}</h2>
<div class="watchlist-artist-config-hero-stats">
<div class="watchlist-artist-config-stat">
<span class="watchlist-artist-config-stat-value">${formatNumber(artist.followers)}</span>
<span class="watchlist-artist-config-stat-label">Followers</span>
</div>
<div class="watchlist-artist-config-stat">
<span class="watchlist-artist-config-stat-value">${artist.popularity}/100</span>
<span class="watchlist-artist-config-stat-label">Popularity</span>
</div>
</div>
${artist.genres && artist.genres.length > 0 ? `
<div class="watchlist-artist-config-hero-genres">
${artist.genres.slice(0, 3).map(genre =>
`<span class="watchlist-artist-config-genre-tag">${escapeHtml(genre)}</span>`
).join('')}
</div>
` : ''}
</div>
`;
// 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 = `<button class="config-msrc-btn ${!currentOverride ? 'active' : ''}" data-source="" title="Use global default (${globalLabel})">
<span class="config-msrc-icon">🌐</span><span class="config-msrc-label">Default (${globalLabel})</span>
</button>`;
for (const src of sources) {
if (!src.id) continue;
const isActive = currentOverride === src.key;
html += `<button class="config-msrc-btn ${isActive ? 'active' : ''}" data-source="${src.key}" style="${isActive ? 'border-color:' + src.color : ''}" title="${src.label}">
<span class="config-msrc-label">${src.label}</span>
</button>`;
}
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 = '<span>⚠️</span><span>Global override is active — these per-artist settings are currently ignored during scans.</span>';
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('<span class="watchlist-pill watchlist-pill-active">Albums</span>');
if (config.include_eps) pills.push('<span class="watchlist-pill watchlist-pill-active">EPs</span>');
if (config.include_singles) pills.push('<span class="watchlist-pill watchlist-pill-active">Singles</span>');
if (config.include_live) pills.push('<span class="watchlist-pill watchlist-pill-filter">Live</span>');
if (config.include_remixes) pills.push('<span class="watchlist-pill watchlist-pill-filter">Remixes</span>');
if (config.include_acoustic) pills.push('<span class="watchlist-pill watchlist-pill-filter">Acoustic</span>');
if (config.include_compilations) pills.push('<span class="watchlist-pill watchlist-pill-filter">Compilations</span>');
// 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(`<span class="watchlist-detail-genre-tag">${escapeHtml(artist.style)}</span>`);
if (artist.mood) metaTags.push(`<span class="watchlist-detail-genre-tag">${escapeHtml(artist.mood)}</span>`);
if (artist.label) metaTags.push(`<span class="watchlist-detail-genre-tag">${escapeHtml(artist.label)}</span>`);
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 ? `
<div class="watchlist-detail-banner">
<img src="${artist.banner_url}" alt="" onerror="this.parentElement.remove();">
<div class="watchlist-detail-banner-fade"></div>
</div>
` : ''}
<div class="watchlist-detail-content ${artist.banner_url ? 'has-banner' : ''}">
<button class="watchlist-detail-back watchlist-detail-back-btn">
← Back to Watchlist
</button>
<div class="watchlist-detail-hero">
${artist.image_url ? `<img src="${artist.image_url}" alt="${escapeHtml(artist.name)}" onerror="this.style.display='none';">` : ''}
<div class="watchlist-detail-hero-info">
<h2 class="watchlist-detail-hero-name">${escapeHtml(artist.name)}</h2>
${artist.followers || artist.popularity ? `
<div class="watchlist-detail-hero-stats">
${artist.followers ? `
<div class="watchlist-detail-stat">
<span class="watchlist-detail-stat-value">${formatNumber(artist.followers)}</span>
<span class="watchlist-detail-stat-label">Followers</span>
</div>` : ''}
${artist.popularity ? `
<div class="watchlist-detail-stat">
<span class="watchlist-detail-stat-value">${artist.popularity}/100</span>
<span class="watchlist-detail-stat-label">Popularity</span>
</div>` : ''}
</div>
` : ''}
${artist.genres && artist.genres.length > 0 ? `
<div class="watchlist-detail-hero-genres">
${artist.genres.map(g => `<span class="watchlist-detail-genre-tag">${escapeHtml(g)}</span>`).join('')}
</div>
` : ''}
</div>
</div>
${artist.summary ? `
<div class="watchlist-detail-section">
<div class="watchlist-detail-section-title">About</div>
<p class="watchlist-detail-bio">${escapeHtml(artist.summary)}</p>
</div>
` : ''}
${metaTags.length > 0 ? `
<div class="watchlist-detail-section">
<div class="watchlist-detail-section-title">Info</div>
<div class="watchlist-detail-hero-genres">${metaTags.join('')}</div>
</div>
` : ''}
${recent_releases && recent_releases.length > 0 ? `
<div class="watchlist-detail-section">
<div class="watchlist-detail-section-title">Recent Releases</div>
<div class="watchlist-detail-releases">
${recent_releases.map(r => `
<div class="watchlist-detail-release">
${r.album_cover_url ? `<img src="${r.album_cover_url}" alt="" onerror="this.style.display='none';">` : ''}
<div class="watchlist-detail-release-info">
<span class="watchlist-detail-release-name">${escapeHtml(r.album_name)}</span>
<span class="watchlist-detail-release-meta">${r.release_date}${r.track_count ? ` · ${r.track_count} tracks` : ''}</span>
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
<div class="watchlist-detail-section">
<div class="watchlist-detail-section-title">Watchlist</div>
<div class="watchlist-detail-watchlist-info">
<span class="watchlist-card-meta">${scanTimeText}</span>
${dateAddedText ? `<span class="watchlist-detail-info-sep">·</span><span class="watchlist-card-meta">${dateAddedText}</span>` : ''}
</div>
<div class="watchlist-card-pills" style="padding: 0; margin-top: 8px;">
${pills.length > 0 ? pills.join('') : '<span class="watchlist-card-meta">No release types enabled</span>'}
</div>
</div>
<div class="watchlist-detail-actions">
<a class="watchlist-detail-discog-btn watchlist-detail-discog-action" href="${discogHref}" ${discogId ? 'onclick="closeWatchlistArtistDetailView()"' : 'aria-disabled="true" tabindex="-1" style="pointer-events:none;opacity:0.5;text-decoration:none;color:inherit;"'}>View Discography</a>
<button class="watchlist-detail-settings-btn watchlist-detail-settings-action">Settings</button>
<button class="watchlist-detail-remove-btn watchlist-detail-remove-action">Remove from Watchlist</button>
</div>
</div>
`;
// 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) => `
<div class="watchlist-live-addition-item">
<img src="${escapeHtml(e.album_image_url || '')}" alt="" onerror="this.style.display='none';" />
<div class="watchlist-live-addition-item-info">
<div class="watchlist-live-addition-item-track">${escapeHtml(e.track_name || '')}</div>
<div class="watchlist-live-addition-item-artist">${escapeHtml(e.artist_name || '')}${e.album_name ? ' — ' + escapeHtml(e.album_name) : ''}</div>
</div>
${e.status === 'added'
? '<span class="watchlist-scan-track-badge added">added</span>'
: '<span class="watchlist-scan-track-badge skipped">skipped</span>'}
</div>`;
const section = (label, list) => list.length
? `<div class="watchlist-scan-tracks-section">${label} (${list.length})</div>${list.map(row).join('')}`
: '';
return `
<button type="button" class="watchlist-scan-tracks-toggle" onclick="toggleWatchlistScanTracks(this)">
Show tracks <span class="watchlist-scan-tracks-caret">▾</span>
</button>
<div class="watchlist-scan-tracks" style="display: none;">
${section('Added to wishlist', added)}
${section('Found but skipped — already queued or blocklisted', skipped)}
</div>`;
}
// 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 ')
+ `<span class="watchlist-scan-tracks-caret">${open ? '▾' : '▴'}</span>`;
}
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) => `
<div class="watchlist-live-addition-item${grew && i === 0 ? ' is-new' : ''}">
<img src="${escapeHtml(item.album_image_url || '')}" alt="" onerror="this.style.display='none';" />
<div class="watchlist-live-addition-item-info">
<div class="watchlist-live-addition-item-track">${escapeHtml(item.track_name || '')}</div>
<div class="watchlist-live-addition-item-artist">${escapeHtml(item.artist_name || '')}</div>
</div>
</div>
`).join('');
} else {
additionsFeed.innerHTML = '<div class="watchlist-live-addition-empty">No tracks added yet…</div>';
}
}
}
} 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 = `
<div class="watchlist-scan-completion">
<div class="watchlist-scan-completion-message">${completionMessage}</div>
<div style="font-size: 13px; opacity: 0.8;">
<span class="sync-stat">Artists: ${totalArtists}</span>
<span class="sync-separator"> • </span>
<span class="sync-stat">New tracks: ${newTracks}</span>
<span class="sync-separator"> • </span>
<span class="sync-stat">Added to wishlist: ${addedTracks}</span>
</div>
${renderWatchlistScanTrackLedger(data.scan_track_events)}
</div>
`;
}
// 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 = `
<div class="watchlist-scan-completion">
<div class="watchlist-scan-completion-message">Scan cancelled after ${scanned} artist${scanned !== 1 ? 's' : ''}</div>
<div style="font-size: 13px; opacity: 0.8;">
<span class="sync-stat">Scanned: ${scanned}</span>
<span class="sync-separator"> &bull; </span>
<span class="sync-stat">New tracks: ${newTracks}</span>
<span class="sync-separator"> &bull; </span>
<span class="sync-stat">Added to wishlist: ${addedTracks}</span>
</div>
${renderWatchlistScanTrackLedger(data.scan_track_events)}
</div>
`;
}
// 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
// ===============================