// == 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 = `
${label} ${_workerStatusLabel(wStatus, worker)}
${_buildGaugeSVG(svc, value, max)}
${value.toFixed(0)}calls/min
${worker.calls_1h || 0}last hour
${(worker.calls_24h || 0).toLocaleString()}24h
${svc === 'spotify' && worker.daily_budget ? _buildBudgetBar(worker.daily_budget) : ''} ${isRateLimited ? _buildRateLimitBadge(d) : ''} `; } else { // Fast update — only change values _updateGauge(gaugeWrap, value, max, svc); // Update status const statusEl = container.querySelector('.gauge-card-status'); if (statusEl) { statusEl.dataset.status = wStatus; statusEl.textContent = _workerStatusLabel(wStatus, worker); } // Update stats const statVals = container.querySelectorAll('.gauge-card-stat-val'); if (statVals[0]) statVals[0].textContent = value.toFixed(0); if (statVals[1]) statVals[1].textContent = worker.calls_1h || 0; if (statVals[2]) statVals[2].textContent = (worker.calls_24h || 0).toLocaleString(); // Budget bar (Spotify) if (svc === 'spotify' && worker.daily_budget) { let budgetEl = container.querySelector('.gauge-budget-bar'); if (!budgetEl) { const div = document.createElement('div'); div.innerHTML = _buildBudgetBar(worker.daily_budget); const statsEl = container.querySelector('.gauge-card-stats'); if (statsEl) statsEl.after(div.firstElementChild); } else { const b = worker.daily_budget; const pctB = Math.min(100, Math.round((b.used / b.limit) * 100)); const fill = budgetEl.querySelector('.gauge-budget-fill'); if (fill) { fill.style.width = pctB + '%'; } const label = budgetEl.querySelector('.gauge-budget-label'); if (label) label.textContent = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()} daily`; } } // Rate limit badge let badge = container.querySelector('.gauge-rl-badge'); if (isRateLimited) { if (!badge) { const div = document.createElement('div'); div.innerHTML = _buildRateLimitBadge(d); container.appendChild(div.firstElementChild); } else { const mins = Math.ceil((d.rl_remaining || 0) / 60); const timeEl = badge.querySelector('.gauge-rl-time'); if (timeEl) timeEl.textContent = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`; } } else if (badge) { badge.remove(); } } container.classList.toggle('danger', pct > 0.8 || isRateLimited); container.classList.toggle('active', value > 0 || wStatus === 'running'); container.classList.toggle('rate-limited', isRateLimited); } } // ── Equalizer-bar renderer (dashboard) ───────────────────────────────── // // VU-meter aesthetic: one vertical bar per service. Bar height = current // rate / limit. Service brand color fades up the bar with an animated // glow at the tip when active. Click opens the same detail modal the // speedometer used. Symmetric by design — any service count fits one // flex row regardless of viewport. function _renderEqualizerBars(grid, data) { if (!grid.children.length) { for (const svc of _RATE_GAUGE_SERVICES) { const accent = _RATE_GAUGE_COLORS[svc] || '#888'; const label = _RATE_GAUGE_LABELS[svc] || svc; const logoSrc = _RATE_GAUGE_LOGOS[svc] || ''; const fallbackGlyph = (label[0] || '?').toUpperCase(); const bar = document.createElement('button'); bar.type = 'button'; bar.className = 'rate-eq'; bar.id = `rate-eq-${svc}`; bar.dataset.svc = svc; bar.style.setProperty('--eq-accent', accent); bar.setAttribute('aria-label', `${label} rate detail`); bar.onclick = () => _openRateModal(svc); // Layout, top-to-bottom: // - avatar disc (brand logo) anchored above the track — // ``onerror`` swap to the initial-letter glyph keeps the // UI legible if a CDN URL ever breaks // - track (with reflection puddle as ::after) holding the // fill / shimmer / peak tip / live count // - meta column (state pill + service name) const avatar = logoSrc ? `` : ``; bar.innerHTML = `
${avatar}
0
Stopped ${label}
`; grid.appendChild(bar); _eqDisplay[svc] = { value: 0, pct: 0.04 }; } } for (const svc of _RATE_GAUGE_SERVICES) { const d = data[svc]; if (!d) continue; _rateMonitorState[svc] = d; const bar = document.getElementById(`rate-eq-${svc}`); if (!bar) continue; const value = d.cpm || 0; const max = d.limit || 60; // Clamp the visual fill to [4%, 100%]. The 4% floor lets idle // bars still show a sliver of accent so the row reads as // present-but-quiet instead of empty — critical for the // "everything alive" vibe; an actual zero would make most // services disappear most of the time. const pct = Math.max(0.04, Math.min(value / max, 1)); const worker = d.worker || {}; const wStatus = worker.status || 'stopped'; const isRateLimited = d.rate_limited === true; const prev = _eqDisplay[svc] || { value: 0, pct: 0.04 }; const fill = bar.querySelector('.rate-eq-fill'); if (fill) fill.style.height = `${pct * 100}%`; // Peak-hold tick — the VU-meter idiom: a thin marker sticks at the // recent maximum, holds ~1.2s, then falls a few %/update until it // rests on the live fill. A burst stays readable after it's over. const nowTs = performance.now(); let peak = prev.peak ?? pct; let peakAt = prev.peakAt ?? nowTs; if (pct >= peak) { peak = pct; peakAt = nowTs; } else if (nowTs - peakAt > 1200) { peak = Math.max(pct, peak - 0.045); } const peakEl = bar.querySelector('.rate-eq-peak'); if (peakEl) { peakEl.style.bottom = `${peak * 100}%`; // Only show while meaningfully above the live fill — idle bars // shouldn't carry a stray line. peakEl.classList.toggle('visible', peak > pct + 0.015 && peak > 0.06); } // The reflection puddle (CSS ::after on the track) fades in // proportional to the real (unclamped) rate so idle services // don't pollute the row with a floor of glow. Bound to a // CSS variable so the puddle and any future glow-aware // styling can share the same source-of-truth value. const realPct = max > 0 ? value / max : 0; bar.style.setProperty('--eq-glow', String(Math.min(1, realPct + 0.04))); // Rolling counter — animate the digit change instead of // snapping. Premium feel; matches the smooth height // transition the fill already has. const val = bar.querySelector('.rate-eq-value'); if (val) { const rounded = Math.round(value); const prevRounded = Math.round(prev.value); if (rounded !== prevRounded) { _animateRollingNumber(val, prevRounded, rounded); } else { val.textContent = rounded; } } // Peak-flash detector: if the rate stepped UPWARD between // socket updates, briefly trigger the .peak-flash class on // the bar so the tip emits a quick accent burst. Mimics a // hardware VU meter's peak-detect LED — sells the "alive" // feeling and ties bar movement to actual call activity. // Only fires on real increases above a small noise floor // (jitter on near-zero rates would otherwise pulse the // bar constantly). const PEAK_JITTER_THRESHOLD = 1; if (value - prev.value > PEAK_JITTER_THRESHOLD) { bar.classList.remove('peak-flash'); // Force a reflow so re-adding the class restarts the // animation even if it was already mid-cycle. void bar.offsetWidth; // eslint-disable-line no-unused-expressions bar.classList.add('peak-flash'); window.setTimeout(() => bar.classList.remove('peak-flash'), 700); } const state = bar.querySelector('.rate-eq-state'); if (state) { state.dataset.status = wStatus; const text = state.querySelector('.rate-eq-state-text'); if (text) text.textContent = _workerStatusLabel(wStatus, worker); } // Danger threshold uses the REAL (unclamped) ratio so the // 4% visual floor for idle bars doesn't push everything into // a permanent green state — the threshold reads true rate. bar.classList.toggle('danger', realPct > 0.8 || isRateLimited); bar.classList.toggle('active', value > 0 || wStatus === 'running'); bar.classList.toggle('rate-limited', isRateLimited); // Cooldown drain — a ban isn't just "red", it's a COUNTDOWN. The // payload only carries seconds remaining, so latch the largest value // seen this ban as the denominator; the red column then drains away // as the ban ticks down and the timer shows m:ss until recovery. const rlRemaining = isRateLimited ? Math.max(0, Math.round(d.rl_remaining || 0)) : 0; let rlTotal = prev.rlTotal || 0; const cooling = rlRemaining > 0; if (cooling) { rlTotal = Math.max(rlTotal, rlRemaining); const cdFill = bar.querySelector('.rate-eq-cooldown-fill'); if (cdFill) cdFill.style.height = `${(rlRemaining / rlTotal) * 100}%`; const cdTime = bar.querySelector('.rate-eq-cooldown-time'); if (cdTime) { const m = Math.floor(rlRemaining / 60); const s = String(rlRemaining % 60).padStart(2, '0'); cdTime.textContent = `${m}:${s}`; } } else { rlTotal = 0; // Recovery moment: the ban just ended — flash the bar back alive. if (prev.cooling) { bar.classList.remove('recovered'); void bar.offsetWidth; // restart the animation bar.classList.add('recovered'); window.setTimeout(() => bar.classList.remove('recovered'), 1300); } } bar.classList.toggle('cooldown', cooling); // Call embers: tiny accent sparks rise off the fill tip, spawned per // socket update in proportion to REAL traffic — motion strictly means // API calls are happening right now. Suppressed during cooldown and // under reduced-effects. if (!window._reduceEffectsActive && !cooling && realPct > 0.03) { _spawnEmbers(bar, pct, realPct > 0.6 ? 3 : realPct > 0.25 ? 2 : 1); } // Daily-budget ring (Spotify is the only service with a real daily // cap): a conic rim inside the avatar disc fills as the budget is // spent — green → amber → red, purple once the worker has bridged // to Spotify Free for the rest of the day. const budget = worker.daily_budget; if (budget && budget.limit > 0) { const bPct = Math.min(1, (budget.used || 0) / budget.limit); const bridged = !!worker.using_free && !!budget.exhausted; bar.classList.add('has-budget'); bar.style.setProperty('--eq-budget', String(Math.max(bPct, 0.02))); bar.style.setProperty('--eq-budget-color', bridged ? '#a78bfa' : bPct < 0.7 ? '#4ade80' : bPct < 0.95 ? '#fbbf24' : '#ef4444'); const avatar = bar.querySelector('.rate-eq-avatar'); if (avatar) { avatar.title = bridged ? `Daily budget spent — running on Spotify Free (${budget.used}/${budget.limit})` : `Daily API budget: ${budget.used}/${budget.limit}`; } } else { bar.classList.remove('has-budget'); } _eqDisplay[svc] = { value, pct, peak, peakAt, rlTotal, cooling }; } } // Spawn ember particles at a bar's fill tip. Self-removing DOM sparks with // a per-bar live cap so a busy hour can't accumulate nodes. function _spawnEmbers(bar, pct, count) { const track = bar.querySelector('.rate-eq-track'); if (!track || track.querySelectorAll('.rate-eq-ember').length > 6) return; for (let i = 0; i < count; i++) { const e = document.createElement('span'); e.className = 'rate-eq-ember'; e.style.left = `${20 + Math.random() * 60}%`; e.style.bottom = `${Math.min(96, pct * 100)}%`; e.style.setProperty('--ember-drift', `${(Math.random() - 0.5) * 14}px`); e.style.animationDuration = `${1.1 + Math.random() * 0.7}s`; e.addEventListener('animationend', () => e.remove()); track.appendChild(e); } } // Animate a single integer counter from `from` to `to` with an // easeOutCubic curve. Used by the equalizer bars so the live count // digit-rolls instead of snapping when sockets push a new value. const _eqRollingHandles = new WeakMap(); function _animateRollingNumber(el, from, to, duration = 520) { // Cancel any animation already running on this element so we // don't get two RAF loops fighting over its textContent. const prev = _eqRollingHandles.get(el); if (prev) cancelAnimationFrame(prev); if (from === to) { el.textContent = String(to); return; } const start = performance.now(); const span = to - from; function step(now) { const t = Math.min(1, (now - start) / duration); const eased = 1 - Math.pow(1 - t, 3); el.textContent = String(Math.round(from + span * eased)); if (t < 1) { _eqRollingHandles.set(el, requestAnimationFrame(step)); } else { _eqRollingHandles.delete(el); } } _eqRollingHandles.set(el, requestAnimationFrame(step)); } function _workerStatusLabel(status, worker) { if (status === 'not_configured') return 'Not configured'; if (status === 'paused') return worker.yield_reason === 'downloads' ? 'Yielding' : 'Paused'; if (status === 'idle') return 'Idle'; if (status === 'running') return 'Running'; return 'Stopped'; } function _buildBudgetBar(budget) { const pct = Math.min(100, Math.round((budget.used / budget.limit) * 100)); const cls = budget.exhausted ? 'exhausted' : pct > 80 ? 'high' : ''; return `
${budget.used.toLocaleString()} / ${budget.limit.toLocaleString()} daily
`; } function _buildRateLimitBadge(d) { const mins = Math.ceil((d.rl_remaining || 0) / 60); const text = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`; return `
RATE LIMITED${text}
`; } function _buildGaugeSVG(svc, value, max) { const { size, cx, cy, r, stroke, startAngle, totalArc } = _G; const label = _RATE_GAUGE_LABELS[svc] || svc; const accent = _RATE_GAUGE_COLORS[svc] || '#888'; const pct = Math.min(value / max, 1); const endAngle = startAngle + pct * totalArc; const arcEnd = startAngle + totalArc; const glowId = `glow-${svc}`; // Endpoint dot position const dot = pct > 0 ? _gPt(endAngle, r) : null; // Gradient ID for the colored arc const gradId = `grad-${svc}`; const color = pct > 0.8 ? '#ef4444' : pct > 0.6 ? '#eab308' : accent; return ` ${pct > 0 ? `` : ''} ${dot ? `` : ''} 0 ${max} ${Math.round(value)} /min ${label} `; } function _updateGauge(container, value, max, svc) { const { r, stroke, startAngle, totalArc } = _G; const accent = _RATE_GAUGE_COLORS[svc] || '#888'; const pct = Math.min(value / max, 1); const endAngle = startAngle + pct * totalArc; const color = pct > 0.8 ? '#ef4444' : pct > 0.6 ? '#eab308' : accent; // Update center value const valText = container.querySelector('.gauge-value'); if (valText) valText.textContent = Math.round(value); // Update active arc const activeArc = container.querySelector('.gauge-active-arc'); if (pct > 0) { const d = _gArc(startAngle, endAngle); if (activeArc) { activeArc.setAttribute('d', d); activeArc.setAttribute('stroke', color); activeArc.style.filter = `drop-shadow(0 0 6px ${color}60)`; } else { // Rebuild the whole gauge when transitioning from 0 to active container.innerHTML = _buildGaugeSVG(svc, value, max); return; } } else if (activeArc) { activeArc.remove(); // Also remove dots container.querySelectorAll('.gauge-dot').forEach(d => d.remove()); const innerDot = container.querySelector('.gauge-dot + circle'); if (innerDot) innerDot.remove(); return; } // Update endpoint dot const gaugeDot = container.querySelector('.gauge-dot'); if (pct > 0 && gaugeDot) { const dot = _gPt(endAngle, r); gaugeDot.setAttribute('cx', dot.x); gaugeDot.setAttribute('cy', dot.y); gaugeDot.setAttribute('fill', color); gaugeDot.style.filter = `drop-shadow(0 0 4px ${color}80)`; const inner = gaugeDot.nextElementSibling; if (inner && inner.tagName === 'circle') { inner.setAttribute('cx', dot.x); inner.setAttribute('cy', dot.y); } } } // ── Rate Monitor Detail Modal ── let _rateModalService = null; let _rateModalInterval = null; function _openRateModal(serviceKey) { _rateModalService = serviceKey; const label = _RATE_GAUGE_LABELS[serviceKey] || serviceKey; const accent = _RATE_GAUGE_COLORS[serviceKey] || '#888'; let overlay = document.getElementById('rate-modal-overlay'); if (overlay) overlay.remove(); overlay = document.createElement('div'); overlay.id = 'rate-modal-overlay'; overlay.className = 'modal-overlay'; overlay.onclick = (e) => { if (e.target === overlay) _closeRateModal(); }; const isSpotify = serviceKey === 'spotify'; const currentData = _rateMonitorState[serviceKey] || {}; overlay.innerHTML = `

${label}

${currentData.cpm || 0} calls/min — limit ${currentData.limit || '?'}/min
24-Hour Call History
${isSpotify ? '
Per-Endpoint Breakdown
' : ''}
`; document.body.appendChild(overlay); // Fetch main history + per-endpoint histories for Spotify const historyPromises = [ fetch(`/api/rate-monitor/history/${serviceKey}`).then(r => r.json()) ]; if (isSpotify) { const activeEps = Object.keys(_rateMonitorState.spotify?.endpoints || {}); for (const ep of activeEps) { historyPromises.push( fetch(`/api/rate-monitor/history/spotify:${ep}`).then(r => r.json()).catch(() => null) ); } } Promise.all(historyPromises).then(results => { const main = results[0]; const epHistories = isSpotify ? results.slice(1).filter(Boolean) : []; _renderRateChart(main.history || [], main.rate_limit || 60, accent, epHistories); }).catch(() => { }); if (isSpotify) { _updateSpotifyEndpoints(); _rateModalInterval = setInterval(_updateSpotifyEndpoints, 1000); } } function _closeRateModal() { const overlay = document.getElementById('rate-modal-overlay'); if (overlay) overlay.remove(); if (_rateModalInterval) { clearInterval(_rateModalInterval); _rateModalInterval = null; } _rateModalService = null; } function _renderRateChart(history, rateLimit, accent, epHistories = []) { const canvas = document.getElementById('rate-modal-chart'); if (!canvas) return; // HiDPI support const dpr = window.devicePixelRatio || 1; const W = 700, H = 280; canvas.width = W * dpr; canvas.height = H * dpr; canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); const pad = { top: 24, right: 24, bottom: 36, left: 50 }; const plotW = W - pad.left - pad.right; const plotH = H - pad.top - pad.bottom; ctx.clearRect(0, 0, W, H); // Build data points const now = Math.floor(Date.now() / 1000); const start = now - 86400; const points = []; if (history.length > 0) { const histMap = new Map(history.map(h => [h[0], h[1]])); for (let t = start; t <= now; t += 300) { const bucket = Math.floor(t / 60) * 60; let sum = 0; for (let m = bucket; m < bucket + 300; m += 60) sum += histMap.get(m) || 0; points.push({ t, v: sum / 5 }); } } const maxVal = Math.max(rateLimit * 1.15, ...points.map(p => p.v), 1); // Grid lines (horizontal) ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1; for (let i = 1; i <= 4; i++) { const y = pad.top + plotH * (1 - i / 4); ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + plotW, y); ctx.stroke(); } // Danger zone band const dangerY = pad.top + plotH * (1 - rateLimit / maxVal); const grad = ctx.createLinearGradient(0, pad.top, 0, dangerY); grad.addColorStop(0, 'rgba(239, 68, 68, 0.08)'); grad.addColorStop(1, 'rgba(239, 68, 68, 0.02)'); ctx.fillStyle = grad; ctx.fillRect(pad.left, pad.top, plotW, dangerY - pad.top); // Rate limit line ctx.strokeStyle = 'rgba(239, 68, 68, 0.5)'; ctx.setLineDash([8, 5]); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(pad.left, dangerY); ctx.lineTo(pad.left + plotW, dangerY); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = 'rgba(239, 68, 68, 0.6)'; ctx.font = '10px -apple-system, sans-serif'; ctx.textAlign = 'left'; ctx.fillText(`Rate limit: ${rateLimit}/min`, pad.left + 6, dangerY - 6); // Draw area fill + line if (points.length > 1) { // Area gradient fill const areaGrad = ctx.createLinearGradient(0, pad.top, 0, pad.top + plotH); // Parse accent to rgba areaGrad.addColorStop(0, accent + '30'); areaGrad.addColorStop(1, accent + '05'); ctx.beginPath(); points.forEach((p, i) => { const x = pad.left + (i / (points.length - 1)) * plotW; const y = pad.top + plotH * (1 - p.v / maxVal); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.lineTo(pad.left + plotW, pad.top + plotH); ctx.lineTo(pad.left, pad.top + plotH); ctx.closePath(); ctx.fillStyle = areaGrad; ctx.fill(); // Line ctx.beginPath(); points.forEach((p, i) => { const x = pad.left + (i / (points.length - 1)) * plotW; const y = pad.top + plotH * (1 - p.v / maxVal); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.strokeStyle = accent; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.stroke(); // Glow effect ctx.shadowColor = accent; ctx.shadowBlur = 8; ctx.stroke(); ctx.shadowBlur = 0; } // Per-endpoint lines (Spotify breakdown) const legendEl = document.getElementById('rate-modal-chart-legend'); if (epHistories.length > 0) { const epColors = ['#1DB954', '#FF6B6B', '#4ECDC4', '#FFE66D', '#A78BFA', '#F97316', '#06B6D4', '#EC4899', '#F472B6', '#34D399']; const legendItems = []; epHistories.forEach((epData, idx) => { if (!epData || !epData.history || epData.history.length === 0) return; const epName = (epData.service || '').replace('spotify:', '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); const color = epColors[idx % epColors.length]; legendItems.push({ name: epName, color }); const histMap = new Map(epData.history.map(h => [h[0], h[1]])); const epPoints = []; for (let t = start; t <= now; t += 300) { const bucket = Math.floor(t / 60) * 60; let sum = 0; for (let m = bucket; m < bucket + 300; m += 60) sum += histMap.get(m) || 0; epPoints.push({ t, v: sum / 5 }); } if (epPoints.length > 1) { ctx.beginPath(); epPoints.forEach((p, i) => { const x = pad.left + (i / (epPoints.length - 1)) * plotW; const y = pad.top + plotH * (1 - p.v / maxVal); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.strokeStyle = color + 'BB'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke(); } }); // HTML legend below chart if (legendEl && legendItems.length > 0) { legendEl.innerHTML = legendItems.map(item => `${item.name}` ).join(''); } } else if (legendEl) { legendEl.innerHTML = ''; } // X-axis labels ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = '10px -apple-system, sans-serif'; ctx.textAlign = 'center'; for (let i = 0; i <= 6; i++) { const t = start + (86400 * i / 6); const x = pad.left + (i / 6) * plotW; const d = new Date(t * 1000); const hr = d.getHours(); const label = hr === 0 ? '12am' : hr < 12 ? `${hr}am` : hr === 12 ? '12pm' : `${hr - 12}pm`; ctx.fillText(label, x, H - 10); // Subtle vertical grid ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, pad.top); ctx.lineTo(x, pad.top + plotH); ctx.stroke(); } // Y-axis labels ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.textAlign = 'right'; ctx.font = '10px -apple-system, sans-serif'; for (let i = 0; i <= 4; i++) { const v = maxVal * i / 4; const y = pad.top + plotH * (1 - i / 4); ctx.fillText(Math.round(v), pad.left - 8, y + 4); } // Empty state if (points.length === 0) { ctx.fillStyle = 'rgba(255,255,255,0.15)'; ctx.font = '13px -apple-system, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('No call history yet — data populates as API calls are made', W / 2, H / 2); } } function _updateSpotifyEndpoints() { const container = document.getElementById('rate-modal-endpoints'); if (!container) return; const endpoints = _rateMonitorState.spotify?.endpoints || {}; const entries = Object.entries(endpoints).sort((a, b) => b[1] - a[1]); if (entries.length === 0) { container.innerHTML = '
No active Spotify endpoints — start an enrichment worker or search to see activity
'; return; } const limit = _rateMonitorState.spotify?.limit || 171; container.innerHTML = entries.map(([ep, cpm]) => { const pct = Math.min(cpm / limit * 100, 100); const name = ep.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); const color = pct > 80 ? '#ef4444' : pct > 60 ? '#eab308' : '#1DB954'; return `
${name}
${Math.round(cpm)}/min
`; }).join(''); } async function fetchAndUpdateSystemStats() { if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/system/stats'); if (!response.ok) return; const data = await response.json(); // Update all stat cards updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading'); updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed downloads'); updateStatCard('download-speed-card', data.download_speed, 'Combined speed'); updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing'); updateStatCard('uptime-card', data.uptime, 'Application runtime'); updateStatCard('memory-card', data.memory_usage, 'Current usage'); } catch (error) { console.warn('Could not fetch system stats:', error); } } function updateStatCard(cardId, value, subtitle) { const card = document.getElementById(cardId); if (card) { const valueElement = card.querySelector('.stat-card-value'); const subtitleElement = card.querySelector('.stat-card-subtitle'); if (valueElement) { valueElement.textContent = value; } if (subtitleElement) { subtitleElement.textContent = subtitle; } } } async function fetchAndUpdateActivityFeed() { if (socketConnected) return; // WebSocket handles this if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/activity/feed'); if (!response.ok) { console.warn('Activity feed response not ok:', response.status, response.statusText); return; } const data = await response.json(); console.log('Activity feed data received:', data); updateActivityFeed(data.activities || []); } catch (error) { console.warn('Could not fetch activity feed:', error); } } // Cache last feed signature to avoid unnecessary DOM rebuilds (prevents blink) let _lastActivityFeedSig = ''; // Activity items carry `timestamp` (Unix epoch seconds) — `activity.time` // is a human label like "Now" that doesn't parse as a date. Use the // epoch for relative-time formatting; fall back to the label only // when no timestamp is present (legacy items, future shapes). function _activityTimeAgo(activity) { const ts = activity && activity.timestamp; if (typeof ts !== 'number' || !isFinite(ts)) { return (activity && activity.time) || ''; } const diffMs = Date.now() - ts * 1000; if (diffMs < 60000) return 'Just now'; const mins = Math.floor(diffMs / 60000); if (mins < 60) return `${mins}m ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); if (days < 30) return `${days}d ago`; return `${Math.floor(days / 30)}mo ago`; } function updateActivityFeed(activities) { const feedContainer = document.getElementById('dashboard-activity-feed'); if (!feedContainer) return; if (activities.length === 0) { if (_lastActivityFeedSig === 'empty') return; _lastActivityFeedSig = 'empty'; feedContainer.innerHTML = `
📊

System Started

Dashboard initialized successfully

Just now

`; return; } const items = activities.slice(0, 5); // Build signature from titles+subtitles to detect actual changes const sig = items.map(a => a.title + a.subtitle).join('|'); const feedChanged = sig !== _lastActivityFeedSig; _lastActivityFeedSig = sig; if (!feedChanged) { // Just update timestamps without rebuilding DOM const timeEls = feedContainer.querySelectorAll('.activity-time'); items.forEach((activity, i) => { if (timeEls[i]) timeEls[i].textContent = _activityTimeAgo(activity); }); return; } // Full rebuild only when feed content actually changed feedContainer.innerHTML = ''; items.forEach((activity, index) => { const activityElement = document.createElement('div'); activityElement.className = 'activity-item'; activityElement.innerHTML = ` ${escapeHtml(activity.icon)}

${escapeHtml(activity.title)}

${escapeHtml(activity.subtitle)}

${_activityTimeAgo(activity)}

`; feedContainer.appendChild(activityElement); if (index < items.length - 1) { const separator = document.createElement('div'); separator.className = 'activity-separator'; feedContainer.appendChild(separator); } }); } async function checkForActivityToasts() { if (socketConnected) return; // WebSocket handles this (instant push) if (document.hidden) return; // Skip polling when tab is not visible try { const response = await fetch('/api/activity/toasts'); if (!response.ok) return; const data = await response.json(); const toasts = data.toasts || []; toasts.forEach(activity => { // Convert activity to toast type based on icon/title let toastType = 'info'; if (activity.icon === '✅' || activity.title.includes('Complete')) { toastType = 'success'; } else if (activity.icon === '❌' || activity.title.includes('Failed') || activity.title.includes('Error')) { toastType = 'error'; } else if (activity.icon === '🚫' || activity.title.includes('Cancelled')) { toastType = 'warning'; } // Show toast with activity info showToast(`${activity.title}: ${activity.subtitle}`, toastType); }); } catch (error) { // Silently fail for toast checking to avoid spam } } // --- Watchlist Functions --- /** * Toggle an artist's watchlist status */ async function toggleWatchlist(event, artistId, artistName) { // Prevent event bubbling to parent card event.stopPropagation(); const button = event.currentTarget; const icon = button.querySelector('.watchlist-icon'); const text = button.querySelector('.watchlist-text'); // Show loading state const originalText = text.textContent; text.textContent = 'Loading...'; button.disabled = true; try { // Check current status const checkResponse = await fetch('/api/watchlist/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ artist_id: artistId }) }); const checkData = await checkResponse.json(); if (!checkData.success) { throw new Error(checkData.error || 'Failed to check watchlist status'); } const isWatching = checkData.is_watching; // Toggle watchlist status const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add'; const payload = isWatching ? { artist_id: artistId } : { artist_id: artistId, artist_name: artistName }; const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to update watchlist'); } // Update button appearance const gearBtn = button.parentElement?.querySelector('.watchlist-settings-btn'); if (isWatching) { // Was watching, now removed icon.textContent = '👁️'; text.textContent = 'Add to Watchlist'; button.classList.remove('watching'); if (gearBtn) gearBtn.classList.add('hidden'); console.log(`❌ Removed ${artistName} from watchlist`); } else { // Was not watching, now added icon.textContent = '👁️'; text.textContent = 'Watching...'; button.classList.add('watching'); if (gearBtn) gearBtn.classList.remove('hidden'); console.log(`✅ Added ${artistName} to watchlist`); } // Update dashboard watchlist count updateWatchlistButtonCount(); } catch (error) { console.error('Error toggling watchlist:', error); text.textContent = originalText; // Show error feedback const originalBackground = button.style.background; button.style.background = 'rgba(255, 59, 48, 0.3)'; setTimeout(() => { button.style.background = originalBackground; }, 2000); } finally { button.disabled = false; } } /** * Update the watchlist button count on dashboard */ async function updateWatchlistButtonCount() { if (document.hidden) return; // Skip polling when tab is not visible if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll try { const response = await fetch('/api/watchlist/count'); const data = await response.json(); if (data.success) { _updateHeroBtnCount('watchlist-button', 'watchlist-badge', data.count); // Update sidebar nav badge const wlNavBadge = document.getElementById('watchlist-nav-badge'); if (wlNavBadge) { wlNavBadge.textContent = data.count; wlNavBadge.classList.toggle('hidden', data.count === 0); } const watchlistButton = document.getElementById('watchlist-button'); if (watchlistButton) { const countdownText = data.next_run_in_seconds ? formatCountdownTime(data.next_run_in_seconds) : ''; if (countdownText) { watchlistButton.title = `Next auto-scan in ${countdownText}`; } } } } catch (error) { console.error('Error updating watchlist count:', error); } } /** * Check and update watchlist status for all visible artist cards */ async function updateArtistCardWatchlistStatus() { const artistCards = document.querySelectorAll('.artist-card'); const artistIds = []; for (const card of artistCards) { const artistId = card.dataset.artistId; if (artistId) artistIds.push(artistId); } if (!artistIds.length) return; try { const response = await fetch('/api/watchlist/check-batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ artist_ids: artistIds }) }); const data = await response.json(); if (data.success && data.results) { for (const card of artistCards) { const artistId = card.dataset.artistId; if (!artistId) continue; const button = card.querySelector('.watchlist-toggle-btn'); if (!button) continue; const icon = button.querySelector('.watchlist-icon'); const text = button.querySelector('.watchlist-text'); const gearBtn = button.parentElement?.querySelector('.watchlist-settings-btn'); if (data.results[artistId]) { if (icon) icon.textContent = '👁️'; if (text) text.textContent = 'Watching...'; button.classList.add('watching'); if (gearBtn) gearBtn.classList.remove('hidden'); } else { if (icon) icon.textContent = '👁️'; if (text) text.textContent = 'Add to Watchlist'; button.classList.remove('watching'); if (gearBtn) gearBtn.classList.add('hidden'); } } } } catch (error) { console.error('Error batch checking watchlist status:', error); } } /** * Initialize/refresh the watchlist sidebar page */ async function initializeWatchlistPage() { try { const emptyEl = document.getElementById('watchlist-page-empty'); const gridEl = document.getElementById('watchlist-artists-list'); const countEl = document.getElementById('watchlist-page-count'); const overrideBanner = document.getElementById('watchlist-page-override-banner'); // Fetch count, artists, scan status, global config in parallel const [countRes, artistsRes, statusRes, globalRes] = await Promise.all([ fetch('/api/watchlist/count').then(r => r.json()), fetch('/api/watchlist/artists').then(r => r.json()), fetch('/api/watchlist/scan/status').then(r => r.json()), fetch('/api/watchlist/global-config').then(r => r.json()).catch(() => ({ success: false })), ]); const count = countRes.success ? countRes.count : 0; const artists = artistsRes.success ? artistsRes.artists : []; const scanStatus = statusRes.success ? statusRes.status : 'idle'; const globalOverrideActive = globalRes.success && globalRes.config && globalRes.config.global_override_enabled; // Update count if (countEl) countEl.textContent = `${count} artist${count !== 1 ? 's' : ''}`; // Update nav badge const navBadge = document.getElementById('watchlist-nav-badge'); if (navBadge) { navBadge.textContent = count; navBadge.classList.toggle('hidden', count === 0); } // Empty state if (count === 0) { if (emptyEl) emptyEl.style.display = ''; if (gridEl) gridEl.style.display = 'none'; watchlistPageState.isInitialized = true; return; } if (emptyEl) emptyEl.style.display = 'none'; if (gridEl) gridEl.style.display = ''; // Store artists for sorting watchlistPageState.artists = artists; // Last scan summary strip const scanStrip = document.getElementById('watchlist-last-scan-strip'); const scanText = document.getElementById('watchlist-last-scan-text'); if (scanStrip && scanText && statusRes.completed_at && statusRes.summary) { const completedDate = new Date(statusRes.completed_at); const ago = _formatTimeAgo(completedDate); const found = statusRes.summary.new_tracks_found || 0; const added = statusRes.summary.tracks_added_to_wishlist || 0; scanText.textContent = `Last scan: ${ago} — ${found} new track${found !== 1 ? 's' : ''} found, ${added} added to wishlist`; scanStrip.style.display = ''; } else if (scanStrip) { scanStrip.style.display = 'none'; } // Global override banner if (overrideBanner) overrideBanner.style.display = globalOverrideActive ? '' : 'none'; const settingsBtn = document.getElementById('watchlist-page-settings-btn'); if (settingsBtn) { settingsBtn.classList.toggle('watchlist-global-settings-active', globalOverrideActive); settingsBtn.innerHTML = ` ${globalOverrideActive ? 'Global Override ON' : 'Global Settings'}`; } // Render artist cards if (gridEl) { gridEl.innerHTML = artists.map(artist => { const pills = []; if (artist.include_albums) pills.push('Albums'); if (artist.include_eps) pills.push('EPs'); if (artist.include_singles) pills.push('Singles'); if (artist.include_live) pills.push('Live'); if (artist.include_remixes) pills.push('Remixes'); if (artist.include_acoustic) pills.push('Acoustic'); if (artist.include_compilations) pills.push('Compilations'); const sourceBadges = []; if (artist.spotify_artist_id) sourceBadges.push('Spotify'); if (artist.itunes_artist_id) sourceBadges.push('iTunes'); if (artist.deezer_artist_id) sourceBadges.push('Deezer'); if (artist.discogs_artist_id) sourceBadges.push('Discogs'); if (artist.musicbrainz_artist_id) sourceBadges.push('MusicBrainz'); if (artist.amazon_artist_id) sourceBadges.push('Amazon'); const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.musicbrainz_artist_id || artist.amazon_artist_id; return `
${artist.image_url ? `${escapeHtml(artist.artist_name)}` : '
🎤
'}
${escapeHtml(artist.artist_name)} ${formatRelativeScanTime(artist.last_scan_timestamp)}
${sourceBadges.length > 0 ? `
${sourceBadges.join('')}
` : ''} ${pills.length > 0 ? `
${pills.join('')}
` : ''}
`; }).join(''); // Wire up gear buttons gridEl.querySelectorAll('.watchlist-card-gear').forEach(button => { button.addEventListener('click', () => { openWatchlistArtistConfigModal(button.getAttribute('data-artist-id'), button.getAttribute('data-artist-name')); }); }); // Wire up artist card clicks gridEl.querySelectorAll('.watchlist-artist-card').forEach(item => { item.addEventListener('click', (e) => { if (e.target.closest('.watchlist-card-gear') || e.target.closest('.watchlist-card-checkbox')) return; const artistId = item.getAttribute('data-artist-id'); const artistName = item.querySelector('.watchlist-card-name').textContent; openWatchlistArtistDetailView(artistId, artistName); }); }); } // Scan status const scanStatusEl = document.getElementById('watchlist-scan-status'); const liveActivityEl = document.getElementById('watchlist-live-activity'); const scanBtn = document.getElementById('scan-watchlist-btn'); const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); if (scanStatus === 'scanning') { if (scanStatusEl) scanStatusEl.style.display = ''; if (liveActivityEl) liveActivityEl.style.display = 'flex'; if (scanBtn) { scanBtn.disabled = true; scanBtn.classList.add('btn-processing'); scanBtn.innerHTML = ' Scanning...'; } if (cancelBtn) cancelBtn.style.display = ''; pollWatchlistScanStatus(); } else { if (scanStatusEl && statusRes.summary) { scanStatusEl.style.display = ''; const summaryEl = document.getElementById('watchlist-page-scan-summary'); if (summaryEl) { summaryEl.style.display = ''; summaryEl.innerHTML = `Artists: ${statusRes.summary.total_artists || 0}New tracks: ${statusRes.summary.new_tracks_found || 0}Added to wishlist: ${statusRes.summary.tracks_added_to_wishlist || 0}`; } } } // Start countdown timer const nextRunSeconds = countRes.next_run_in_seconds || 0; startWatchlistCountdownTimer(nextRunSeconds); watchlistPageState.isInitialized = true; } catch (error) { console.error('Error initializing watchlist page:', error); showToast('Failed to load watchlist', 'error'); } } /** * Initialize/refresh the wishlist sidebar page */ async function initializeWishlistPage() { try { const emptyEl = document.getElementById('wishlist-page-empty'); const nebulaEl = document.getElementById('wishlist-nebula'); const countEl = document.getElementById('wishlist-page-count'); const tracksSection = document.getElementById('wishlist-category-tracks'); const statsStrip = document.getElementById('wishlist-stats-strip'); const [statsRes, cycleRes, albumRes, singleRes, watchlistRes] = await Promise.all([ fetch('/api/wishlist/stats').then(r => r.json()), fetch('/api/wishlist/cycle').then(r => r.json()), fetch('/api/wishlist/tracks?category=albums').then(r => r.json()), fetch('/api/wishlist/tracks?category=singles').then(r => r.json()), fetch('/api/watchlist/artists').then(r => r.json()).catch(() => ({ success: false })), ]); // Build artist name → image URL map from watchlist const _artistImageMap = new Map(); if (watchlistRes.success && watchlistRes.artists) { for (const wa of watchlistRes.artists) { if (wa.artist_name && wa.image_url) _artistImageMap.set(wa.artist_name.toLowerCase(), wa.image_url); } } const { singles = 0, albums = 0, total = 0 } = statsRes; const currentCycle = cycleRes.cycle || 'albums'; if (countEl) countEl.textContent = `${total} track${total !== 1 ? 's' : ''}`; const navBadge = document.getElementById('wishlist-nav-badge'); if (navBadge) { navBadge.textContent = total; navBadge.classList.toggle('hidden', total === 0); } const statAlbums = document.getElementById('wishlist-stat-albums'); const statSingles = document.getElementById('wishlist-stat-singles'); const statCycle = document.getElementById('wishlist-stat-cycle'); if (statAlbums) statAlbums.textContent = albums; if (statSingles) statSingles.textContent = singles; if (statCycle) statCycle.textContent = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles'; if (total === 0) { if (emptyEl) emptyEl.style.display = ''; if (nebulaEl) nebulaEl.style.display = 'none'; if (tracksSection) tracksSection.style.display = 'none'; if (statsStrip) statsStrip.style.display = 'none'; wishlistPageState.isInitialized = true; return; } if (emptyEl) emptyEl.style.display = 'none'; if (nebulaEl) nebulaEl.style.display = ''; if (tracksSection) tracksSection.style.display = 'none'; if (statsStrip) statsStrip.style.display = ''; _renderWishlistNebula(albumRes.tracks || [], singleRes.tracks || [], _artistImageMap, currentCycle); startWishlistCountdownTimer(currentCycle, statsRes.next_run_in_seconds || 0); // Live processing: check if wishlist download is active and start polling _startNebulaLivePolling(currentCycle, _artistImageMap); wishlistPageState.isInitialized = true; } catch (error) { console.error('Error initializing wishlist page:', error); showToast('Failed to load wishlist', 'error'); } } /* ═══════════════════════════════════════════════════════════════════ WISHLIST NEBULA — Artist orbs with album/single satellites ═══════════════════════════════════════════════════════════════════ */ function _renderWishlistNebula(albumTracks, singleTracks, artistImageMap, currentCycle) { const field = document.getElementById('wl-nebula-field'); if (!field) return; artistImageMap = artistImageMap || new Map(); const artistMap = new Map(); function _parse(track, type) { let sd = track.spotify_data; if (typeof sd === 'string') { try { sd = JSON.parse(sd); } catch (e) { return null; } } if (!sd) return null; const raw = sd.album; const albumName = (typeof raw === 'string' ? raw : raw?.name) || 'Unknown'; const albumImage = (typeof raw === 'object' && raw?.images?.[0]?.url) || ''; let artist = 'Unknown Artist'; if (sd.artists?.[0]?.name) artist = sd.artists[0].name; else if (typeof sd.artists?.[0] === 'string') artist = sd.artists[0]; return { track: sd.name || 'Unknown', artist, album: albumName, image: albumImage, type, id: track.spotify_track_id || track.id || '' }; } for (const t of albumTracks) { const p = _parse(t, 'album'); if (p) { if (!artistMap.has(p.artist)) artistMap.set(p.artist, { albums: new Map(), singles: [] }); const a = artistMap.get(p.artist); if (!a.albums.has(p.album)) a.albums.set(p.album, { image: p.image, tracks: [] }); a.albums.get(p.album).tracks.push(p); } } for (const t of singleTracks) { const p = _parse(t, 'single'); if (p) { if (!artistMap.has(p.artist)) artistMap.set(p.artist, { albums: new Map(), singles: [] }); artistMap.get(p.artist).singles.push(p); } } if (artistMap.size === 0) { field.innerHTML = '
Your wishlist is empty
'; return; } const sorted = [...artistMap.entries()].sort((a, b) => { const ac = [...a[1].albums.values()].reduce((s, al) => s + al.tracks.length, 0) + a[1].singles.length; const bc = [...b[1].albums.values()].reduce((s, al) => s + al.tracks.length, 0) + b[1].singles.length; return bc - ac; }); function _hue(n) { let h = 0; for (let i = 0; i < n.length; i++) h = n.charCodeAt(i) + ((h << 5) - h); return Math.abs(h) % 360; } let html = ''; sorted.forEach(([name, data], idx) => { const total = [...data.albums.values()].reduce((s, a) => s + a.tracks.length, 0) + data.singles.length; const hasAlbums = data.albums.size > 0; const hue = _hue(name); const sz = total >= 10 ? 'orb-lg' : total >= 4 ? 'orb-md' : 'orb-sm'; // Enhancement 1: prefer watchlist artist photo over album cover let img = artistImageMap.get(name.toLowerCase()) || ''; if (!img) { for (const [, ad] of data.albums) { if (ad.image) { img = ad.image; break; } } } if (!img && data.singles.length) img = data.singles[0].image || ''; // Enhancement 3: pulse if this artist has albums and current cycle is albums const pulseClass = (hasAlbums && currentCycle === 'albums') ? ' orb-pulse' : ''; // Enhancement 7: staggered entry animation const delay = Math.min(idx * 60, 800); html += `
`; // Enhancement 2: hover tooltip html += `
${escapeHtml(name)}
${total} track${total !== 1 ? 's' : ''}
`; html += `
`; html += `
`; html += img ? `` : `
${escapeHtml(name.substring(0, 2).toUpperCase())}
`; html += `
`; // Enhancement 5: album art ring (show up to 6 album covers around the orb) const ringCovers = []; for (const [, ad] of data.albums) { if (ad.image && ringCovers.length < 6) ringCovers.push(ad.image); } for (const s of data.singles) { if (s.image && ringCovers.length < 6) ringCovers.push(s.image); } if (ringCovers.length >= 3) { html += `
`; ringCovers.forEach((url, i) => { const angle = (360 / ringCovers.length) * i; html += ``; }); html += `
`; } html += `
`; // /orb // Enhancement 8: clickable artist name → navigate to artist detail html += `
${escapeHtml(name)}
`; html += `
${total} track${total !== 1 ? 's' : ''}
`; // Expanded content html += `
`; if (data.albums.size > 0) { html += `
`; for (const [an, ad] of data.albums) { const tileId = 'wl-tile-' + an.replace(/\W/g, '_') + '_' + idx; html += `
`; html += `
${ad.image ? `` : `
💿
`}
`; html += `
${escapeHtml(an)}
${ad.tracks.length} track${ad.tracks.length !== 1 ? 's' : ''}
`; html += `${ad.tracks.length}`; html += ``; // Track list (hidden until tile clicked) html += `
`; for (const tr of ad.tracks) { html += `
`; html += `${escapeHtml(tr.track)}`; html += ``; html += `
`; } html += `
`; html += `
`; } html += `
`; } if (data.singles.length > 0) { html += `
`; for (const s of data.singles) { html += `
`; html += s.image ? `` : ``; html += `
${escapeHtml(s.track)}
`; html += ``; html += `
`; } html += `
`; } html += `
`; // /expanded, /group }); field.innerHTML = html; } // Enhancement 8: navigate to the Search page pre-filled with this artist's name function _navigateToArtistFromWishlist(artistName) { navigateToPage('search'); setTimeout(() => { const searchInput = document.getElementById('enhanced-search-input'); if (searchInput) { searchInput.value = artistName; searchInput.dispatchEvent(new Event('input')); searchInput.focus(); } }, 300); } function _toggleAlbumTile(tileEl) { const wasExpanded = tileEl.classList.contains('tile-expanded'); // Collapse all tiles in this group tileEl.closest('.wl-album-fan')?.querySelectorAll('.wl-album-tile.tile-expanded').forEach(t => t.classList.remove('tile-expanded')); if (!wasExpanded) tileEl.classList.add('tile-expanded'); } function _toggleOrbExpand(el) { const g = el.closest('.wl-orb-group'); if (!g) return; const was = g.classList.contains('expanded'); document.querySelectorAll('.wl-orb-group.expanded').forEach(o => o.classList.remove('expanded')); if (!was) g.classList.add('expanded'); } async function _removeWishlistAlbum(albumName) { if (!await showConfirmDialog({ title: 'Remove Album', message: `Remove all tracks from "${albumName}"?`, confirmText: 'Remove', destructive: true })) return; try { const res = await fetch('/api/wishlist/remove-album', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ album_name: albumName }) }); const data = await res.json(); if (data.success) { showToast(`Removed "${albumName}"`, 'success'); wishlistPageState.isInitialized = false; await initializeWishlistPage(); await updateWishlistCount(); } else showToast(data.error || 'Failed', 'error'); } catch (err) { showToast('Error: ' + err.message, 'error'); } } async function _removeWishlistTrack(trackId) { try { const res = await fetch('/api/wishlist/remove-track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ spotify_track_id: trackId }) }); const data = await res.json(); if (data.success) { showToast('Removed', 'success'); await updateWishlistCount(); // Re-render nebula to reflect removal wishlistPageState.isInitialized = false; await initializeWishlistPage(); } } catch (err) { showToast('Error: ' + err.message, 'error'); } } function _filterNebula() { const q = (document.getElementById('wl-nebula-search')?.value || '').toLowerCase().trim(); document.querySelectorAll('.wl-orb-group').forEach(g => { const a = (g.dataset.artist || '').toLowerCase(); const albums = [...g.querySelectorAll('.wl-satellite')].map(s => (s.dataset.album || '').toLowerCase()); const match = !q || a.includes(q) || albums.some(al => al.includes(q)); g.style.display = match ? '' : 'none'; if (!match) g.classList.remove('expanded'); }); } async function _nebulaDownload() { // Check if wishlist is already processing try { const statsResp = await fetch('/api/wishlist/stats'); if (statsResp.ok) { const stats = await statsResp.json(); if (stats.is_auto_processing) { // Navigate to downloads page so the user can see progress navigateToPage('active-downloads'); showToast('Wishlist is currently being auto-processed', 'info'); return; } } const procResp = await fetch('/api/active-processes'); if (procResp.ok) { const procData = await procResp.json(); const wishlistBatch = (procData.active_processes || []).find(p => p.playlist_id === 'wishlist'); if (wishlistBatch) { // Show the existing download modal WishlistModalState.clearUserClosed(); const clientProcess = activeDownloadProcesses['wishlist']; if (clientProcess && clientProcess.modalElement && document.body.contains(clientProcess.modalElement)) { clientProcess.modalElement.style.display = 'flex'; WishlistModalState.setVisible(); } else { await rehydrateModal(wishlistBatch, true); } return; } } } catch (e) {} // No active process — show category choice const choice = await _showNebulaDownloadChoice(); if (choice) await openDownloadMissingWishlistModal(choice); } function _showNebulaDownloadChoice() { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.display = 'flex'; overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; const albumCount = document.getElementById('wishlist-stat-albums')?.textContent || '0'; const singleCount = document.getElementById('wishlist-stat-singles')?.textContent || '0'; overlay.innerHTML = `

Download Wishlist

Choose which category to process

`; 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 = ` `; // Add event listeners for gear buttons modal.querySelectorAll('.watchlist-card-gear').forEach(button => { button.addEventListener('click', () => { const artistId = button.getAttribute('data-artist-id'); const artistName = button.getAttribute('data-artist-name'); openWatchlistArtistConfigModal(artistId, artistName); }); }); // Add click handlers to artist cards (except for gear button or checkbox) modal.querySelectorAll('.watchlist-artist-card').forEach(item => { item.addEventListener('click', (e) => { if (e.target.closest('.watchlist-card-gear') || e.target.closest('.watchlist-card-checkbox')) { return; } const artistId = item.getAttribute('data-artist-id'); const artistName = item.querySelector('.watchlist-card-name').textContent; console.log(`🎵 Artist card clicked: ${artistName} (${artistId})`); openWatchlistArtistDetailView(artistId, artistName); }); }); // Show modal modal.style.display = 'flex'; // Start countdown timer update interval startWatchlistCountdownTimer(nextRunSeconds); // Start polling for scan status if scanning if (scanStatus === 'scanning') { pollWatchlistScanStatus(); } } catch (error) { console.error('Error showing watchlist modal:', error); } } function startWatchlistCountdownTimer(initialSeconds) { // Clear any existing interval if (watchlistCountdownInterval) { clearInterval(watchlistCountdownInterval); } let remainingSeconds = initialSeconds; const timerElement = document.getElementById('watchlist-next-auto-timer'); const updateTimerText = (seconds) => { if (!timerElement) return; const countdownText = seconds > 0 ? formatCountdownTime(seconds) : ''; timerElement.textContent = `Next Auto${countdownText ? ': ' + countdownText : ''}`; }; // If there is no scheduled next run, don't keep polling the endpoint every second. if (remainingSeconds <= 0) { updateTimerText(0); watchlistCountdownInterval = null; return; } watchlistCountdownInterval = setInterval(async () => { remainingSeconds--; if (remainingSeconds <= 0) { // Timer expired, fetch fresh data try { const response = await fetch('/api/watchlist/count'); const data = await response.json(); const nextRunSeconds = data.next_run_in_seconds || 0; if (nextRunSeconds > 0) { remainingSeconds = nextRunSeconds; updateTimerText(remainingSeconds); } else { // No scheduled auto-run; stop polling until the page is refreshed clearInterval(watchlistCountdownInterval); watchlistCountdownInterval = null; updateTimerText(0); } } catch (error) { console.debug('Error updating watchlist countdown:', error); } } else { // Update the display updateTimerText(remainingSeconds); } }, 1000); // Update every second } /** * Close watchlist modal */ function closeWatchlistModal() { // Stop countdown timer if (watchlistCountdownInterval) { clearInterval(watchlistCountdownInterval); watchlistCountdownInterval = null; } const modal = document.getElementById('watchlist-modal'); if (modal) { modal.style.display = 'none'; } } /** * Populate the linked provider section in the watchlist config modal. * Shows which Spotify/iTunes/Deezer artist is linked and allows changing it. */ function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo, deezerId, discogsId, amazonId, musicbrainzId) { const section = document.getElementById('watchlist-linked-provider-section'); const content = document.getElementById('watchlist-linked-provider-content'); if (!section || !content) return; section.style.display = ''; const sources = [ { key: 'spotify', label: 'Spotify', icon: '🟢', id: spotifyId || '', color: '#1db954' }, { key: 'itunes', label: 'Apple Music', icon: '🔴', id: itunesId || '', color: '#fc3c44' }, { key: 'deezer', label: 'Deezer', icon: '🟣', id: deezerId || '', color: '#a238ff' }, { key: 'discogs', label: 'Discogs', icon: '🟤', id: discogsId || '', color: '#b08968' }, { key: 'musicbrainz', label: 'MusicBrainz', icon: 'MB', id: musicbrainzId || '', color: '#ba478f' }, { key: 'amazon', label: 'Amazon Music', icon: '🟠', id: amazonId || '', color: '#FF9900' }, ]; let html = '
'; for (const src of sources) { const matched = !!src.id; const shortId = src.id ? (src.id.length > 16 ? src.id.substring(0, 14) + '...' : src.id) : ''; html += `
${src.icon} ${src.label} ${matched ? `${shortId}` : 'Not matched' } ${matched ? `` : ''}
`; } html += '
'; // Per-source search panel (hidden, populated on Fix/Match click) html += ``; content.innerHTML = html; } /** * Open per-source search panel for fixing a specific provider match. */ function _openSourceSearch(sourceKey, artistId, artistName) { const panel = document.getElementById('wl-linked-search-panel'); if (!panel) return; const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', amazon: 'Amazon Music', musicbrainz: 'MusicBrainz' }; document.getElementById('wl-linked-search-title').textContent = `Search ${labels[sourceKey] || sourceKey}`; const input = document.getElementById('wl-linked-search-input'); input.value = artistName; document.getElementById('wl-linked-search-results').innerHTML = ''; panel.style.display = ''; panel.dataset.source = sourceKey; panel.dataset.artistId = artistId; panel.dataset.artistName = artistName; input.focus(); input.select(); const doSearch = () => _searchSourceArtists(sourceKey, artistId); document.getElementById('wl-linked-search-go').onclick = doSearch; input.onkeydown = (e) => { if (e.key === 'Enter') doSearch(); }; } async function _searchSourceArtists(sourceKey, watchlistArtistId) { const input = document.getElementById('wl-linked-search-input'); const container = document.getElementById('wl-linked-search-results'); const query = input?.value?.trim(); if (!query || !container) return; container.innerHTML = '
Searching...
'; try { const response = await fetch('/api/library/search-service', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service: sourceKey, entity_type: 'artist', query }) }); const data = await response.json(); if (!data.success) throw new Error(data.error); const results = data.results || []; if (!results.length) { container.innerHTML = '
No artists found
'; return; } let html = ''; for (const r of results) { html += `
${r.image ? `` : `
🎵
`}
${escapeHtml(r.name)}
${escapeHtml(r.extra || '')}
`; } container.innerHTML = html; container.querySelectorAll('.watchlist-linked-search-result').forEach(el => { el.querySelector('.watchlist-linked-select-btn').onclick = async (e) => { e.stopPropagation(); await _linkSourceArtist(sourceKey, watchlistArtistId, el.dataset.id, el.dataset.name); }; }); } catch (err) { console.error(`Error searching ${sourceKey}:`, err); container.innerHTML = '
Search error
'; } } async function _linkSourceArtist(sourceKey, watchlistArtistId, newId, newName) { try { const response = await fetch(`/api/watchlist/artist/${watchlistArtistId}/link-provider`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider_id: newId, provider: sourceKey }) }); const data = await response.json(); if (!data.success) { showToast(`Failed to link: ${data.error}`, 'error'); return; } showToast(`Linked to "${newName}" on ${sourceKey}`, 'success'); // Refresh the modal const panel = document.getElementById('wl-linked-search-panel'); const artistName = panel?.dataset?.artistName || newName; closeWatchlistArtistConfigModal(); setTimeout(() => openWatchlistArtistConfigModal(watchlistArtistId, artistName), 300); } catch (err) { showToast('Failed to link artist', 'error'); } } async function _clearSourceMatch(sourceKey, watchlistArtistId, artistName) { try { const response = await fetch(`/api/watchlist/artist/${watchlistArtistId}/link-provider`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider_id: '', provider: sourceKey }) }); const data = await response.json(); if (!data.success) { showToast(`Failed to clear: ${data.error}`, 'error'); return; } showToast(`Cleared ${sourceKey} match`, 'success'); closeWatchlistArtistConfigModal(); setTimeout(() => openWatchlistArtistConfigModal(watchlistArtistId, artistName), 300); } catch (err) { showToast('Failed to clear match', 'error'); } } /** * Open watchlist artist configuration modal * @param {string} artistId - Spotify artist ID * @param {string} artistName - Artist name */ async function openWatchlistArtistConfigModal(artistId, artistName) { try { console.log(`🎨 Opening config modal for artist: ${artistName} (${artistId})`); // Fetch artist config and info const response = await fetch(`/api/watchlist/artist/${artistId}/config`); const data = await response.json(); if (!data.success) { console.error('Error loading artist config:', data.error); showToast(`Error loading artist configuration: ${data.error}`, 'error'); return; } const { config, artist, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, amazon_artist_id, musicbrainz_artist_id, watchlist_name } = data; // Populate linked provider section (use DB watchlist_name for mismatch comparison) _populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist, deezer_artist_id, discogs_artist_id, amazon_artist_id, musicbrainz_artist_id); // Check if global override is active let globalOverrideActive = false; try { const globalResponse = await fetch('/api/watchlist/global-config'); const globalData = await globalResponse.json(); globalOverrideActive = globalData.success && globalData.config.global_override_enabled; } catch (e) { console.debug('Could not check global config:', e); } // Generate hero section const heroHTML = ` ${artist.image_url ? ` ${escapeHtml(artist.name)} ` : ''}

${escapeHtml(artist.name)}

${formatNumber(artist.followers)} Followers
${artist.popularity}/100 Popularity
${artist.genres && artist.genres.length > 0 ? `
${artist.genres.slice(0, 3).map(genre => `${escapeHtml(genre)}` ).join('')}
` : ''}
`; // Populate hero section const heroContainer = document.getElementById('watchlist-artist-config-hero'); if (heroContainer) { heroContainer.innerHTML = heroHTML; } // Set checkbox states // Default to true (auto-download) when absent (older configs). const _autoDlToggle = document.getElementById('config-auto-download'); if (_autoDlToggle) _autoDlToggle.checked = config.auto_download !== false; document.getElementById('config-include-albums').checked = config.include_albums; document.getElementById('config-include-eps').checked = config.include_eps; document.getElementById('config-include-singles').checked = config.include_singles; document.getElementById('config-include-live').checked = config.include_live || false; document.getElementById('config-include-remixes').checked = config.include_remixes || false; document.getElementById('config-include-acoustic').checked = config.include_acoustic || false; document.getElementById('config-include-compilations').checked = config.include_compilations || false; document.getElementById('config-include-instrumentals').checked = config.include_instrumentals || false; document.getElementById('config-lookback-days').value = config.lookback_days != null ? String(config.lookback_days) : ''; // Populate metadata source selector const sourceSelector = document.getElementById('config-metadata-source-selector'); if (sourceSelector) { const sources = [ { key: 'spotify', label: 'Spotify', id: spotify_artist_id, color: '#1DB954' }, { key: 'deezer', label: 'Deezer', id: deezer_artist_id, color: '#A238FF' }, { key: 'itunes', label: 'Apple Music', id: itunes_artist_id, color: '#FC3C44' }, { key: 'discogs', label: 'Discogs', id: discogs_artist_id, color: '#333' }, { key: 'musicbrainz', label: 'MusicBrainz', id: musicbrainz_artist_id, color: '#BA478F' }, ]; const globalSource = data.global_metadata_source || 'deezer'; const currentOverride = config.preferred_metadata_source; const globalLabel = { spotify: 'Spotify', deezer: 'Deezer', itunes: 'Apple Music', discogs: 'Discogs', musicbrainz: 'MusicBrainz' }[globalSource] || globalSource; let html = ``; for (const src of sources) { if (!src.id) continue; const isActive = currentOverride === src.key; html += ``; } sourceSelector.innerHTML = html; sourceSelector.querySelectorAll('.config-msrc-btn').forEach(btn => { btn.addEventListener('click', () => { sourceSelector.querySelectorAll('.config-msrc-btn').forEach(b => { b.classList.remove('active'); b.style.borderColor = ''; }); btn.classList.add('active'); const color = sources.find(s => s.key === btn.dataset.source)?.color; if (color) btn.style.borderColor = color; }); }); } // Show global override notice if active const existingNotice = document.querySelector('.global-override-notice'); if (existingNotice) existingNotice.remove(); if (globalOverrideActive) { const notice = document.createElement('div'); notice.className = 'global-override-notice watchlist-global-override-banner'; notice.innerHTML = '⚠️Global override is active — these per-artist settings are currently ignored during scans.'; const configBody = document.querySelector('.watchlist-artist-config-body'); if (configBody) configBody.insertBefore(notice, configBody.firstChild); } // Store artist ID for saving const modal = document.getElementById('watchlist-artist-config-modal'); if (modal) { modal.setAttribute('data-artist-id', artistId); } // Show modal const overlay = document.getElementById('watchlist-artist-config-modal-overlay'); if (overlay) { overlay.classList.remove('hidden'); } // Add save button handler const saveBtn = document.getElementById('save-artist-config-btn'); if (saveBtn) { // Remove old listeners const newSaveBtn = saveBtn.cloneNode(true); saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn); // Add new listener newSaveBtn.addEventListener('click', () => saveWatchlistArtistConfig(artistId)); } } catch (error) { console.error('Error opening watchlist artist config modal:', error); showToast(`Error: ${error.message}`, 'error'); } } /** * Close watchlist artist configuration modal */ function closeWatchlistArtistConfigModal() { const overlay = document.getElementById('watchlist-artist-config-modal-overlay'); if (overlay) { overlay.classList.add('hidden'); } // Clear hero content const heroContainer = document.getElementById('watchlist-artist-config-hero'); if (heroContainer) { heroContainer.innerHTML = ''; } // Clear linked provider section const linkedContent = document.getElementById('watchlist-linked-provider-content'); if (linkedContent) linkedContent.innerHTML = ''; const linkedSection = document.getElementById('watchlist-linked-provider-section'); if (linkedSection) linkedSection.style.display = 'none'; } /** * Open watchlist artist detail view (slides in from right) */ async function openWatchlistArtistDetailView(artistId, artistName) { try { const response = await fetch(`/api/watchlist/artist/${artistId}/config`); const data = await response.json(); if (!data.success) { showToast(`Error loading artist info: ${data.error}`, 'error'); return; } const { config, artist, recent_releases, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, musicbrainz_artist_id } = data; // Remove existing overlay if any const existing = document.querySelector('.watchlist-artist-detail-overlay'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.className = 'watchlist-artist-detail-overlay'; // Build pills const pills = []; if (config.include_albums) pills.push('Albums'); if (config.include_eps) pills.push('EPs'); if (config.include_singles) pills.push('Singles'); if (config.include_live) pills.push('Live'); if (config.include_remixes) pills.push('Remixes'); if (config.include_acoustic) pills.push('Acoustic'); if (config.include_compilations) pills.push('Compilations'); // Build scan info const scanTimeText = config.last_scan_timestamp ? formatRelativeScanTime(config.last_scan_timestamp) : 'Never scanned'; const dateAddedText = config.date_added ? `Added ${new Date(config.date_added).toLocaleDateString()}` : ''; // Build metadata tags (style, mood, label) const metaTags = []; if (artist.style) metaTags.push(`${escapeHtml(artist.style)}`); if (artist.mood) metaTags.push(`${escapeHtml(artist.mood)}`); if (artist.label) metaTags.push(`${escapeHtml(artist.label)}`); let discogId = null; let discogSource = null; const activeSrc = (currentMusicSourceName || '').toLowerCase(); if (activeSrc.includes('spotify') && spotify_artist_id) { discogId = spotify_artist_id; discogSource = 'spotify'; } else if (activeSrc.includes('discogs') && discogs_artist_id) { discogId = discogs_artist_id; discogSource = 'discogs'; } else if (activeSrc.includes('deezer') && deezer_artist_id) { discogId = deezer_artist_id; discogSource = 'deezer'; } else if (activeSrc.includes('musicbrainz') && musicbrainz_artist_id) { discogId = musicbrainz_artist_id; discogSource = 'musicbrainz'; } else if (itunes_artist_id) { discogId = itunes_artist_id; discogSource = 'itunes'; } else { discogId = spotify_artist_id || discogs_artist_id || deezer_artist_id || musicbrainz_artist_id || itunes_artist_id; discogSource = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : musicbrainz_artist_id ? 'musicbrainz' : 'itunes'; } const discogHref = discogId ? buildArtistDetailPath(discogId, discogSource) : '#'; overlay.innerHTML = ` ${artist.banner_url ? `
` : ''}
${artist.image_url ? `${escapeHtml(artist.name)}` : ''}

${escapeHtml(artist.name)}

${artist.followers || artist.popularity ? `
${artist.followers ? `
${formatNumber(artist.followers)} Followers
` : ''} ${artist.popularity ? `
${artist.popularity}/100 Popularity
` : ''}
` : ''} ${artist.genres && artist.genres.length > 0 ? `
${artist.genres.map(g => `${escapeHtml(g)}`).join('')}
` : ''}
${artist.summary ? `
About

${escapeHtml(artist.summary)}

` : ''} ${metaTags.length > 0 ? `
Info
${metaTags.join('')}
` : ''} ${recent_releases && recent_releases.length > 0 ? `
Recent Releases
${recent_releases.map(r => `
${r.album_cover_url ? `` : ''}
${escapeHtml(r.album_name)} ${r.release_date}${r.track_count ? ` · ${r.track_count} tracks` : ''}
`).join('')}
` : ''}
Watchlist
${scanTimeText} ${dateAddedText ? `·${dateAddedText}` : ''}
${pills.length > 0 ? pills.join('') : 'No release types enabled'}
View Discography
`; // Wire up event listeners (avoids inline onclick escaping issues) overlay.querySelector('.watchlist-detail-back-btn').addEventListener('click', () => { closeWatchlistArtistDetailView(); }); overlay.querySelector('.watchlist-detail-settings-action').addEventListener('click', () => { // Remove overlay immediately so it doesn't block the config modal const detailOverlay = document.querySelector('.watchlist-artist-detail-overlay'); if (detailOverlay) detailOverlay.remove(); openWatchlistArtistConfigModal(artistId, artistName); }); overlay.querySelector('.watchlist-detail-remove-action').addEventListener('click', () => { removeFromWatchlistModal(artistId, artistName); }); // Append to body as a fixed overlay document.body.appendChild(overlay); // Trigger slide-in animation requestAnimationFrame(() => overlay.classList.add('visible')); } catch (error) { console.error('Error opening artist detail view:', error); showToast(`Error: ${error.message}`, 'error'); } } /** * Close watchlist artist detail view (slides out) */ function closeWatchlistArtistDetailView() { const overlay = document.querySelector('.watchlist-artist-detail-overlay'); if (overlay) { overlay.classList.remove('visible'); overlay.addEventListener('transitionend', () => overlay.remove(), { once: true }); } } /** * Open global watchlist settings modal */ async function openWatchlistGlobalSettingsModal() { try { const response = await fetch('/api/watchlist/global-config'); const data = await response.json(); if (!data.success) { showToast(`Error loading global settings: ${data.error}`, 'error'); return; } const config = data.config; // Populate checkboxes document.getElementById('global-override-enabled').checked = config.global_override_enabled; document.getElementById('global-include-albums').checked = config.include_albums; document.getElementById('global-include-eps').checked = config.include_eps; document.getElementById('global-include-singles').checked = config.include_singles; document.getElementById('global-include-live').checked = config.include_live; document.getElementById('global-include-remixes').checked = config.include_remixes; document.getElementById('global-include-acoustic').checked = config.include_acoustic; document.getElementById('global-include-compilations').checked = config.include_compilations; document.getElementById('global-include-instrumentals').checked = config.include_instrumentals; document.getElementById('global-exclude-terms').value = config.exclude_terms || ''; // Sync "Include Everything" checkbox syncGlobalIncludeAllCheckbox(); // Update options visibility based on toggle state toggleGlobalOverrideOptions(); // Reflect enabled state on the toggle card (styled in CSS) const toggleLabel = document.getElementById('global-override-toggle-label'); if (toggleLabel) { toggleLabel.classList.toggle('enabled', !!config.global_override_enabled); } // Show modal const overlay = document.getElementById('watchlist-global-config-modal-overlay'); if (overlay) overlay.classList.remove('hidden'); } catch (error) { console.error('Error opening global watchlist settings:', error); showToast(`Error: ${error.message}`, 'error'); } } /** * Close global watchlist settings modal */ function closeWatchlistGlobalSettingsModal() { const overlay = document.getElementById('watchlist-global-config-modal-overlay'); if (overlay) overlay.classList.add('hidden'); } /** * Toggle global override options visibility */ function toggleGlobalOverrideOptions() { const enabled = document.getElementById('global-override-enabled').checked; const options = document.getElementById('global-override-options'); if (options) { options.style.opacity = enabled ? '1' : '0.4'; options.style.pointerEvents = enabled ? 'auto' : 'none'; } // Reflect enabled state on the toggle card (styled in CSS) const toggleLabel = document.getElementById('global-override-toggle-label'); if (toggleLabel) { toggleLabel.classList.toggle('enabled', enabled); } } /** * Toggle all global include checkboxes */ function toggleGlobalIncludeAll() { const checked = document.getElementById('global-include-all').checked; ['global-include-albums', 'global-include-eps', 'global-include-singles', 'global-include-live', 'global-include-remixes', 'global-include-acoustic', 'global-include-compilations', 'global-include-instrumentals'].forEach(id => { const el = document.getElementById(id); if (el) el.checked = checked; }); } /** * Sync the "Include Everything" checkbox based on individual checkbox states */ function syncGlobalIncludeAllCheckbox() { const allIds = ['global-include-albums', 'global-include-eps', 'global-include-singles', 'global-include-live', 'global-include-remixes', 'global-include-acoustic', 'global-include-compilations', 'global-include-instrumentals']; const allChecked = allIds.every(id => { const el = document.getElementById(id); return el && el.checked; }); const includeAllEl = document.getElementById('global-include-all'); if (includeAllEl) includeAllEl.checked = allChecked; } /** * Save global watchlist configuration */ async function saveWatchlistGlobalConfig() { try { const globalOverrideEnabled = document.getElementById('global-override-enabled').checked; const includeAlbums = document.getElementById('global-include-albums').checked; const includeEps = document.getElementById('global-include-eps').checked; const includeSingles = document.getElementById('global-include-singles').checked; const includeLive = document.getElementById('global-include-live').checked; const includeRemixes = document.getElementById('global-include-remixes').checked; const includeAcoustic = document.getElementById('global-include-acoustic').checked; const includeCompilations = document.getElementById('global-include-compilations').checked; const includeInstrumentals = document.getElementById('global-include-instrumentals').checked; const excludeTerms = (document.getElementById('global-exclude-terms').value || '').trim(); if (globalOverrideEnabled && !includeAlbums && !includeEps && !includeSingles) { showToast('Please select at least one release type', 'error'); return; } const saveBtn = document.getElementById('save-global-config-btn'); if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; } const response = await fetch('/api/watchlist/global-config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ global_override_enabled: globalOverrideEnabled, include_albums: includeAlbums, include_eps: includeEps, include_singles: includeSingles, include_live: includeLive, include_remixes: includeRemixes, include_acoustic: includeAcoustic, include_compilations: includeCompilations, include_instrumentals: includeInstrumentals, exclude_terms: excludeTerms, }) }); const data = await response.json(); if (data.success) { showToast('Global watchlist settings saved', 'success'); closeWatchlistGlobalSettingsModal(); // Refresh the watchlist page to update the grid if (currentPage === 'watchlist') { watchlistPageState.isInitialized = false; await initializeWatchlistPage(); } } else { showToast(`Error: ${data.error}`, 'error'); } } catch (error) { console.error('Error saving global config:', error); showToast(`Error: ${error.message}`, 'error'); } finally { const saveBtn = document.getElementById('save-global-config-btn'); if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Global Settings'; } } } /** * Save watchlist artist configuration * @param {string} artistId - Spotify artist ID */ async function saveWatchlistArtistConfig(artistId) { try { const includeAlbums = document.getElementById('config-include-albums').checked; const includeEps = document.getElementById('config-include-eps').checked; const includeSingles = document.getElementById('config-include-singles').checked; const includeLive = document.getElementById('config-include-live').checked; const includeRemixes = document.getElementById('config-include-remixes').checked; const includeAcoustic = document.getElementById('config-include-acoustic').checked; const includeCompilations = document.getElementById('config-include-compilations').checked; const includeInstrumentals = document.getElementById('config-include-instrumentals').checked; const autoDownloadEl = document.getElementById('config-auto-download'); const autoDownload = autoDownloadEl ? autoDownloadEl.checked : true; const lookbackDaysVal = document.getElementById('config-lookback-days').value; const lookbackDays = lookbackDaysVal !== '' ? parseInt(lookbackDaysVal) : null; const activeSourceBtn = document.querySelector('#config-metadata-source-selector .config-msrc-btn.active'); const preferredMetadataSource = activeSourceBtn ? (activeSourceBtn.dataset.source || null) : null; // Validate at least one release type is selected if (!includeAlbums && !includeEps && !includeSingles) { showToast('Please select at least one release type', 'error'); return; } // Disable save button const saveBtn = document.getElementById('save-artist-config-btn'); if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; } // Send update to backend const response = await fetch(`/api/watchlist/artist/${artistId}/config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ include_albums: includeAlbums, include_eps: includeEps, include_singles: includeSingles, include_live: includeLive, include_remixes: includeRemixes, include_acoustic: includeAcoustic, include_compilations: includeCompilations, include_instrumentals: includeInstrumentals, auto_download: autoDownload, lookback_days: lookbackDays, preferred_metadata_source: preferredMetadataSource, }) }); const data = await response.json(); if (data.success) { showToast('Artist preferences saved successfully', 'success'); closeWatchlistArtistConfigModal(); // Refresh watchlist page if we're on it if (currentPage === 'watchlist') { watchlistPageState.isInitialized = false; await initializeWatchlistPage(); } } else { showToast(`Error saving preferences: ${data.error}`, 'error'); } } catch (error) { console.error('Error saving watchlist artist config:', error); showToast(`Error: ${error.message}`, 'error'); } finally { // Re-enable save button const saveBtn = document.getElementById('save-artist-config-btn'); if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save Preferences'; } } } /** * Format large numbers with commas * @param {number} num - Number to format * @returns {string} Formatted number */ function formatNumber(num) { if (!num) return '0'; return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); } /** * Format last scan timestamp as relative time */ function formatRelativeScanTime(isoString) { if (!isoString) return 'Never scanned'; const diff = Date.now() - new Date(isoString).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return 'Scanned just now'; if (mins < 60) return `Scanned ${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `Scanned ${hrs}h ago`; const days = Math.floor(hrs / 24); if (days < 30) return `Scanned ${days}d ago`; const months = Math.floor(days / 30); return `Scanned ${months}mo ago`; } /** * Filter watchlist artists based on search input */ function filterWatchlistArtists() { const searchInput = document.getElementById('watchlist-search-input'); const artistsList = document.getElementById('watchlist-artists-list'); if (!searchInput || !artistsList) return; const searchTerm = searchInput.value.toLowerCase().trim(); const artistItems = artistsList.querySelectorAll('.watchlist-artist-card'); artistItems.forEach(item => { const artistName = item.getAttribute('data-artist-name'); if (!searchTerm || artistName.includes(searchTerm)) { item.style.display = ''; } else { item.style.display = 'none'; } }); // Refresh batch bar in case visible selection changed updateWatchlistBatchBar(); } /** * Start watchlist scan */ async function cancelWatchlistScan() { try { const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); if (cancelBtn) { cancelBtn.disabled = true; cancelBtn.textContent = 'Cancelling...'; } const response = await fetch('/api/watchlist/scan/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to cancel scan'); } showToast('Cancel request sent — scan will stop after current artist', 'info'); } catch (error) { console.error('Error cancelling watchlist scan:', error); showToast(`Error cancelling scan: ${error.message}`, 'error'); const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); if (cancelBtn) { cancelBtn.disabled = false; cancelBtn.textContent = 'Cancel Scan'; } } } async function startWatchlistScan() { try { const button = document.getElementById('scan-watchlist-btn'); button.disabled = true; _wlSetChipLabel(button, 'Starting scan...'); button.classList.add('btn-processing'); const response = await fetch('/api/watchlist/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to start scan'); } _wlSetChipLabel(button, 'Scanning...'); // Show cancel button const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); if (cancelBtn) { cancelBtn.style.display = ''; cancelBtn.disabled = false; cancelBtn.textContent = 'Cancel Scan'; } // Show scan status const statusDiv = document.getElementById('watchlist-scan-status'); if (statusDiv) { statusDiv.style.display = 'flex'; } // Start polling for updates pollWatchlistScanStatus(); } catch (error) { console.error('Error starting watchlist scan:', error); const button = document.getElementById('scan-watchlist-btn'); button.disabled = false; _wlSetChipLabel(button, 'Scan for New Releases'); button.classList.remove('btn-processing'); alert(`Error starting scan: ${error.message}`); } } /** * Poll watchlist scan status */ // #831 (Tacobell444): the scan summary said "New tracks: 19 • Added to // wishlist: 10" with no way to see WHICH tracks. The scan now ships a per-run // ledger (scan_track_events: track/artist/album/thumb + added|skipped) and // this renders it as an expandable list under the completion summary. function renderWatchlistScanTrackLedger(events) { if (!Array.isArray(events) || events.length === 0) return ''; const added = events.filter(e => e.status === 'added'); const skipped = events.filter(e => e.status !== 'added'); const row = (e) => `
${escapeHtml(e.track_name || '')}
${escapeHtml(e.artist_name || '')}${e.album_name ? ' — ' + escapeHtml(e.album_name) : ''}
${e.status === 'added' ? 'added' : 'skipped'}
`; const section = (label, list) => list.length ? `
${label} (${list.length})
${list.map(row).join('')}` : ''; return ` `; } // Human-readable phase line for the scan deck ("checking_album_2_of_5" → // "Checking album 2 of 5"). // Set a wl-chip button's label without destroying its icon/shimmer children // (textContent wipes them; these buttons carry an svg + shimmer span). function _wlSetChipLabel(btn, text) { if (!btn) return; const svg = btn.querySelector('svg'); const shimmer = btn.querySelector('.wl-chip-shimmer'); btn.textContent = ''; if (svg) btn.appendChild(svg); btn.appendChild(document.createTextNode(' ' + text)); if (shimmer) btn.appendChild(shimmer); } function _wlPrettyPhase(data) { const phase = data.current_phase || ''; if (!phase) return 'Working…'; const m = phase.match(/^checking_album_(\d+)_of_(\d+)$/); if (m) return `Checking album ${m[1]} of ${m[2]}`; const map = { starting: 'Starting…', fetching_discography: 'Fetching releases…', populating_discovery_pool: 'Populating discovery…', updating_listenbrainz: 'Updating ListenBrainz…', }; return map[phase] || phase.replace(/_/g, ' '); } // Update a live counter and replay its pop animation when the value changes. function _wlBumpCounter(id, value) { const el = document.getElementById(id); if (!el) return; const text = String(value); if (el.textContent !== text) { el.textContent = text; el.classList.remove('pop'); void el.offsetWidth; // restart the CSS animation el.classList.add('pop'); } } function toggleWatchlistScanTracks(btn) { const list = btn.parentElement.querySelector('.watchlist-scan-tracks'); if (!list) return; const open = list.style.display !== 'none'; list.style.display = open ? 'none' : 'block'; btn.innerHTML = (open ? 'Show tracks ' : 'Hide tracks ') + `${open ? '▾' : '▴'}`; } function handleWatchlistScanData(data) { const button = document.getElementById('scan-watchlist-btn'); const liveActivity = document.getElementById('watchlist-live-activity'); // Show/hide cancel button based on scan status const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); if (cancelBtn) { cancelBtn.style.display = data.status === 'scanning' ? '' : 'none'; } // Update live visual activity display if (liveActivity && data.status === 'scanning') { liveActivity.style.display = liveActivity.classList.contains('wl-scan-deck') ? 'block' : 'flex'; // Update artist image and name (hide the img when THIS artist has no // photo, so the previous artist's portrait doesn't linger and the // glyph placeholder shows instead) const artistImg = document.getElementById('watchlist-artist-img'); const artistName = document.getElementById('watchlist-artist-name'); if (artistImg) { if (data.current_artist_image_url) { artistImg.src = data.current_artist_image_url; artistImg.style.display = 'block'; } else { artistImg.style.display = 'none'; } } if (artistName) { artistName.textContent = data.current_artist_name || 'Starting…'; } // Update album image and name const albumImg = document.getElementById('watchlist-album-img'); const albumName = document.getElementById('watchlist-album-name'); if (albumImg && data.current_album_image_url) { albumImg.src = data.current_album_image_url; albumImg.style.display = 'block'; } else if (albumImg) { albumImg.style.display = 'none'; } if (albumName) { albumName.textContent = data.current_album || (data.current_phase === 'fetching_discography' ? 'Fetching releases…' : 'Looking for new releases…'); } // Update current track ('' keeps the slot without echoing the album line) const trackName = document.getElementById('watchlist-track-name'); if (trackName) { trackName.textContent = data.current_track_name || '—'; } // #831 round 2: scan-deck extras — artist progress bar, live counters, // readable phase. All optional elements, so the legacy modal markup // (which lacks them) keeps working untouched. const total = data.total_artists || 0; const idx = total ? Math.min((data.current_artist_index || 0) + 1, total) : 0; const progText = document.getElementById('wl-scan-progress-text'); if (progText) progText.textContent = total ? `${idx} / ${total} artists` : ''; const progBar = document.getElementById('wl-scan-progress-bar'); if (progBar && total) progBar.style.width = `${Math.round((100 * idx) / total)}%`; const phaseEl = document.getElementById('wl-scan-phase'); if (phaseEl) phaseEl.textContent = _wlPrettyPhase(data); _wlBumpCounter('wl-scan-found', data.tracks_found_this_scan || 0); _wlBumpCounter('wl-scan-added', data.tracks_added_this_scan || 0); // Update wishlist additions feed — only re-render when it actually // changed, so the slide-in animation plays once per new track instead // of replaying on every poll. const additionsFeed = document.getElementById('watchlist-additions-feed'); if (additionsFeed) { const additions = data.recent_wishlist_additions || []; const feedKey = additions.map(a => a.track_name).join(''); if (additionsFeed.dataset.feedKey !== feedKey) { const grew = additions.length > 0 && additionsFeed.dataset.feedKey !== undefined && feedKey.length > (additionsFeed.dataset.feedKey || '').length; additionsFeed.dataset.feedKey = feedKey; if (additions.length > 0) { additionsFeed.innerHTML = additions.map((item, i) => `
${escapeHtml(item.track_name || '')}
${escapeHtml(item.artist_name || '')}
`).join(''); } else { additionsFeed.innerHTML = '
No tracks added yet…
'; } } } } else if (liveActivity && data.status !== 'scanning') { liveActivity.style.display = 'none'; } if (data.status === 'completed') { if (button) { button.disabled = false; _wlSetChipLabel(button, 'Scan for New Releases'); button.classList.remove('btn-processing'); } // Hide live activity if (liveActivity) { liveActivity.style.display = 'none'; } // Show completion message in status div const statusDiv = document.getElementById('watchlist-scan-status'); if (statusDiv && data.summary) { const newTracks = data.summary.new_tracks_found || 0; const addedTracks = data.summary.tracks_added_to_wishlist || 0; const totalArtists = data.summary.total_artists || 0; const successfulScans = data.summary.successful_scans || 0; let completionMessage = `Scan completed: ${successfulScans}/${totalArtists} artists scanned`; if (newTracks > 0) { completionMessage += `, found ${newTracks} new track${newTracks !== 1 ? 's' : ''}`; if (addedTracks > 0) { completionMessage += `, added ${addedTracks} to wishlist`; } } else { completionMessage += ', no new tracks found'; } // Update the scan status display with completion message and summary statusDiv.innerHTML = `
${completionMessage}
Artists: ${totalArtists} New tracks: ${newTracks} Added to wishlist: ${addedTracks}
${renderWatchlistScanTrackLedger(data.scan_track_events)}
`; } // Update watchlist count updateWatchlistButtonCount(); console.log('Watchlist scan completed:', data.summary); } else if (data.status === 'cancelled') { if (button) { button.disabled = false; _wlSetChipLabel(button, 'Scan for New Releases'); button.classList.remove('btn-processing'); } // Hide cancel button const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); if (cancelBtn) { cancelBtn.style.display = 'none'; cancelBtn.disabled = false; cancelBtn.textContent = 'Cancel Scan'; } // Hide live activity if (liveActivity) { liveActivity.style.display = 'none'; } // Show cancellation message const statusDiv = document.getElementById('watchlist-scan-status'); if (statusDiv && data.summary) { const scanned = data.summary.total_artists || 0; const newTracks = data.summary.new_tracks_found || 0; const addedTracks = data.summary.tracks_added_to_wishlist || 0; statusDiv.innerHTML = `
Scan cancelled after ${scanned} artist${scanned !== 1 ? 's' : ''}
Scanned: ${scanned} New tracks: ${newTracks} Added to wishlist: ${addedTracks}
${renderWatchlistScanTrackLedger(data.scan_track_events)}
`; } // Update watchlist count updateWatchlistButtonCount(); showToast('Watchlist scan cancelled', 'info'); console.log('Watchlist scan cancelled:', data.summary); } else if (data.status === 'error') { if (button) { button.disabled = false; _wlSetChipLabel(button, 'Scan for New Releases'); button.classList.remove('btn-processing'); } // Hide cancel button const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); if (cancelBtn) { cancelBtn.style.display = 'none'; } // Hide live activity if (liveActivity) { liveActivity.style.display = 'none'; } console.error('Watchlist scan error:', data.error); } } async function pollWatchlistScanStatus() { if (socketConnected) return; // Phase 5: WS handles scan updates try { const response = await fetch('/api/watchlist/scan/status'); const data = await response.json(); if (data.success) { handleWatchlistScanData(data); if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') { return; // Stop polling } } // Continue polling if still scanning if (data.success && data.status === 'scanning') { setTimeout(pollWatchlistScanStatus, 2000); // Poll every 2 seconds } } catch (error) { console.error('Error polling watchlist scan status:', error); } } /** * Update similar artists for discovery feature */ async function updateSimilarArtists() { try { const button = document.getElementById('update-similar-artists-btn'); const scanButton = document.getElementById('scan-watchlist-btn'); button.disabled = true; _wlSetChipLabel(button, 'Updating...'); button.classList.add('btn-processing'); if (scanButton) scanButton.disabled = true; const response = await fetch('/api/watchlist/update-similar-artists', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to update similar artists'); } showToast('Updating similar artists in background...', 'success'); // Poll for completion pollSimilarArtistsUpdate(); } catch (error) { console.error('Error updating similar artists:', error); const button = document.getElementById('update-similar-artists-btn'); const scanButton = document.getElementById('scan-watchlist-btn'); button.disabled = false; _wlSetChipLabel(button, 'Update Similar Artists'); button.classList.remove('btn-processing'); if (scanButton) scanButton.disabled = false; showToast(`Error: ${error.message}`, 'error'); } } /** * Poll similar artists update status */ async function pollSimilarArtistsUpdate() { try { const response = await fetch('/api/watchlist/similar-artists-status'); const data = await response.json(); if (data.success) { const button = document.getElementById('update-similar-artists-btn'); const scanButton = document.getElementById('scan-watchlist-btn'); if (data.status === 'completed') { if (button) { button.disabled = false; _wlSetChipLabel(button, 'Update Similar Artists'); button.classList.remove('btn-processing'); } if (scanButton) scanButton.disabled = false; showToast(`Updated similar artists for ${data.artists_processed || 0} artists!`, 'success'); return; // Stop polling } else if (data.status === 'error') { if (button) { button.disabled = false; _wlSetChipLabel(button, 'Update Similar Artists'); button.classList.remove('btn-processing'); } if (scanButton) scanButton.disabled = false; showToast('Error updating similar artists', 'error'); return; // Stop polling } else if (data.status === 'running') { // Update button text with progress if (button && data.current_artist) { button.textContent = `Updating... (${data.artists_processed || 0}/${data.total_artists || 0})`; } } } // Continue polling if still running if (data.success && data.status === 'running') { setTimeout(pollSimilarArtistsUpdate, 1000); // Poll every 1 second } } catch (error) { console.error('Error polling similar artists update:', error); const button = document.getElementById('update-similar-artists-btn'); const scanButton = document.getElementById('scan-watchlist-btn'); if (button) { button.disabled = false; _wlSetChipLabel(button, 'Update Similar Artists'); button.classList.remove('btn-processing'); } if (scanButton) scanButton.disabled = false; } } /** * Remove artist from watchlist via modal */ async function removeFromWatchlistModal(artistId, artistName) { try { const response = await fetch('/api/watchlist/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ artist_id: artistId }) }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to remove from watchlist'); } console.log(`❌ Removed ${artistName} from watchlist`); // Close detail view if open closeWatchlistArtistDetailView(); // Refresh the watchlist page watchlistPageState.isInitialized = false; await initializeWatchlistPage(); // Update button count updateWatchlistButtonCount(); // Update any visible artist cards updateArtistCardWatchlistStatus(); } catch (error) { console.error('Error removing from watchlist:', error); alert(`Error removing ${artistName} from watchlist: ${error.message}`); } } /** * Get visible checked checkboxes (not hidden by search filter) */ function getVisibleCheckedWatchlist() { return Array.from(document.querySelectorAll('.watchlist-select-cb:checked')).filter(cb => { const item = cb.closest('.watchlist-artist-card'); return item && item.style.display !== 'none'; }); } /** * Update the batch action bar based on checkbox selection */ function updateWatchlistBatchBar() { const checked = getVisibleCheckedWatchlist(); const countEl = document.getElementById('watchlist-batch-count'); const removeBtn = document.getElementById('watchlist-batch-remove-btn'); const selectAllCb = document.getElementById('watchlist-select-all-cb'); if (checked.length > 0) { countEl.textContent = `${checked.length} selected`; removeBtn.style.display = ''; } else { countEl.textContent = ''; removeBtn.style.display = 'none'; } // Update select-all checkbox state if (selectAllCb) { const visible = Array.from(document.querySelectorAll('.watchlist-select-cb')).filter(cb => { const card = cb.closest('.watchlist-artist-card'); return card && card.style.display !== 'none'; }); selectAllCb.checked = visible.length > 0 && checked.length === visible.length; selectAllCb.indeterminate = checked.length > 0 && checked.length < visible.length; } } function toggleWatchlistSelectAll(checked) { const checkboxes = document.querySelectorAll('.watchlist-select-cb'); checkboxes.forEach(cb => { const card = cb.closest('.watchlist-artist-card'); if (card && card.style.display !== 'none') { cb.checked = checked; } }); updateWatchlistBatchBar(); } /** * Batch remove selected artists from watchlist */ async function batchRemoveFromWatchlist() { const checked = getVisibleCheckedWatchlist(); if (checked.length === 0) return; const count = checked.length; if (!await showConfirmDialog({ title: 'Remove Artists', message: `Remove ${count} artist${count !== 1 ? 's' : ''} from your watchlist?`, confirmText: 'Remove', destructive: true })) return; const artistIds = checked.map(cb => cb.getAttribute('data-artist-id')); try { const response = await fetch('/api/watchlist/remove-batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ artist_ids: artistIds }) }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to remove artists'); } console.log(`❌ Batch removed ${data.removed} artists from watchlist`); // Refresh the watchlist page watchlistPageState.isInitialized = false; await initializeWatchlistPage(); // Update button count updateWatchlistButtonCount(); // Update any visible artist cards updateArtistCardWatchlistStatus(); } catch (error) { console.error('Error batch removing from watchlist:', error); alert(`Error removing artists: ${error.message}`); } } // --- Metadata Updater Functions --- // Global state for metadata update polling let metadataUpdatePolling = false; let metadataUpdateInterval = null; /** * Handle metadata update button click */ async function handleMetadataUpdateButtonClick() { const button = document.getElementById('metadata-update-button'); const currentAction = button.textContent; if (currentAction === 'Begin Update') { // Get refresh interval from dropdown const refreshSelect = document.getElementById('metadata-refresh-interval'); const refreshIntervalDays = refreshSelect.value !== undefined ? parseInt(refreshSelect.value) : 30; try { button.disabled = true; button.textContent = 'Starting...'; const response = await fetch('/api/metadata/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_interval_days: refreshIntervalDays }) }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to start metadata update'); } showToast('Metadata update started!', 'success'); // Start polling for status updates startMetadataUpdatePolling(); } catch (error) { console.error('Error starting metadata update:', error); button.disabled = false; button.textContent = 'Begin Update'; showToast(`Error: ${error.message}`, 'error'); } } else { // Stop metadata update try { button.disabled = true; button.textContent = 'Stopping...'; const response = await fetch('/api/metadata/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error('Failed to stop metadata update'); } } catch (error) { console.error('Error stopping metadata update:', error); button.disabled = false; button.textContent = 'Stop Update'; } } } /** * Start polling for metadata update status */ function startMetadataUpdatePolling() { if (metadataUpdatePolling) return; // Already polling metadataUpdatePolling = true; metadataUpdateInterval = setInterval(checkMetadataUpdateStatus, 1000); // Poll every second // Also check immediately checkMetadataUpdateStatus(); } /** * Stop polling for metadata update status */ function stopMetadataUpdatePolling() { metadataUpdatePolling = false; if (metadataUpdateInterval) { clearInterval(metadataUpdateInterval); metadataUpdateInterval = null; } } /** * Check current metadata update status and update UI */ async function checkMetadataUpdateStatus() { if (socketConnected) return; // WebSocket handles this try { const response = await fetch('/api/metadata/status'); const data = await response.json(); if (data.success && data.status) { updateMetadataProgressUI(data.status); // Stop polling if completed or error if (data.status.status === 'completed' || data.status.status === 'error') { stopMetadataUpdatePolling(); } } } catch (error) { console.warn('Could not fetch metadata update status:', error); } } function updateMetadataStatusFromData(data) { if (!data.success || !data.status) return; const prev = _lastToolStatus['metadata']; _lastToolStatus['metadata'] = data.status.status; if (prev !== undefined && data.status.status === prev && data.status.status !== 'running' && data.status.status !== 'stopping') return; updateMetadataProgressUI(data.status); if (data.status.status === 'completed' || data.status.status === 'error') { stopMetadataUpdatePolling(); } } /** * Update metadata progress UI elements */ function updateMetadataProgressUI(status) { const button = document.getElementById('metadata-update-button'); const phaseLabel = document.getElementById('metadata-phase-label'); const progressLabel = document.getElementById('metadata-progress-label'); const progressBar = document.getElementById('metadata-progress-bar'); const refreshSelect = document.getElementById('metadata-refresh-interval'); if (!button || !phaseLabel || !progressLabel || !progressBar || !refreshSelect) return; if (status.status === 'running') { button.textContent = 'Stop Update'; button.disabled = false; refreshSelect.disabled = true; // Update current artist display const currentArtist = status.current_artist || 'Processing...'; phaseLabel.textContent = `Current Artist: ${currentArtist}`; // Update progress const processed = status.processed || 0; const total = status.total || 0; const percentage = status.percentage || 0; progressLabel.textContent = `${processed} / ${total} artists (${percentage.toFixed(1)}%)`; progressBar.style.width = `${percentage}%`; } else if (status.status === 'stopping') { button.textContent = 'Stopping...'; button.disabled = true; phaseLabel.textContent = 'Current Artist: Stopping...'; } else if (status.status === 'completed') { button.textContent = 'Begin Update'; button.disabled = false; refreshSelect.disabled = false; phaseLabel.textContent = 'Current Artist: Completed'; const processed = status.processed || 0; const successful = status.successful || 0; const failed = status.failed || 0; progressLabel.textContent = `Completed: ${processed} processed, ${successful} successful, ${failed} failed`; progressBar.style.width = '100%'; showToast(`Metadata update completed: ${successful} artists updated, ${failed} failed`, 'success'); } else if (status.status === 'error') { button.textContent = 'Begin Update'; button.disabled = false; refreshSelect.disabled = false; phaseLabel.textContent = 'Current Artist: Error occurred'; progressLabel.textContent = status.error || 'Unknown error'; progressBar.style.width = '0%'; } else { // Idle state button.textContent = 'Begin Update'; button.disabled = false; refreshSelect.disabled = false; phaseLabel.textContent = 'Current Artist: Not running'; progressLabel.textContent = '0 / 0 artists (0.0%)'; progressBar.style.width = '0%'; } } /** * Check active media server and hide metadata updater if not Plex */ async function checkAndHideMetadataUpdaterForNonPlex() { try { const response = await fetch('/api/active-media-server'); const data = await response.json(); if (data.success) { const metadataCard = document.getElementById('metadata-updater-card'); if (metadataCard) { // Show metadata updater only for Plex and Jellyfin if (data.active_server === 'plex' || data.active_server === 'jellyfin') { metadataCard.style.display = 'flex'; console.log(`Metadata updater shown: ${data.active_server} is active server`); // Update the header text to reflect the current server const headerElement = metadataCard.querySelector('.card-header h3'); if (headerElement) { const serverDisplayName = data.active_server.charAt(0).toUpperCase() + data.active_server.slice(1); headerElement.textContent = `${serverDisplayName} Metadata Updater`; } // Update the description based on the server type const descElement = metadataCard.querySelector('.metadata-updater-description'); if (descElement) { if (data.active_server === 'jellyfin') { descElement.textContent = 'Download and upload high-quality artist images from Spotify to your Jellyfin server for artists without photos.'; } else { descElement.textContent = 'Download and upload high-quality artist images from Spotify to your Plex server for artists without photos.'; } } } else { // Hide metadata updater for Navidrome metadataCard.style.display = 'none'; console.log(`Metadata updater hidden: ${data.active_server} does not support image uploads`); } } } } catch (error) { console.warn('Could not check active media server for metadata updater visibility:', error); } } async function checkAndShowMediaScanForPlex() { /** * Show media scan tool only for Plex (Jellyfin/Navidrome auto-scan) */ try { const response = await fetch('/api/active-media-server'); const data = await response.json(); if (data.success) { const mediaScanCard = document.getElementById('media-scan-card'); if (mediaScanCard) { // Show media scan tool only for Plex if (data.active_server === 'plex') { mediaScanCard.style.display = 'flex'; console.log('Media scan tool shown: Plex is active server'); } else { // Hide for Jellyfin/Navidrome (they auto-scan) mediaScanCard.style.display = 'none'; console.log(`Media scan tool hidden: ${data.active_server} auto-scans`); } } } } catch (error) { console.warn('Could not check active media server for media scan visibility:', error); } } async function handleMediaScanButtonClick() { /** * Trigger a manual Plex media library scan */ const button = document.getElementById('media-scan-button'); const phaseLabel = document.getElementById('media-scan-phase-label'); const progressBar = document.getElementById('media-scan-progress-bar'); const progressLabel = document.getElementById('media-scan-progress-label'); const statusValue = document.getElementById('media-scan-status'); if (!button) return; try { // Disable button and update UI button.disabled = true; phaseLabel.textContent = 'Requesting scan...'; progressBar.style.width = '30%'; progressLabel.textContent = 'Sending scan request to Plex'; statusValue.textContent = 'Scanning...'; statusValue.style.color = 'rgb(var(--accent-rgb))'; // Request scan (database update handled by system automation) const response = await fetch('/api/scan/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: 'Manual scan triggered from dashboard' }) }); const result = await response.json(); if (result.success) { // Get delay from API response (graceful fallback to 60 if not provided) const delaySeconds = (result.scan_info && result.scan_info.delay_seconds) || 60; let remainingSeconds = delaySeconds; let countdownInterval = null; let pollInterval = null; // Update last scan time const lastTimeEl = document.getElementById('media-scan-last-time'); if (lastTimeEl) { const now = new Date(); lastTimeEl.textContent = now.toLocaleTimeString(); } // Start countdown timer (visual feedback during delay) phaseLabel.textContent = 'Scan scheduled...'; progressBar.style.width = '0%'; countdownInterval = setInterval(() => { remainingSeconds--; // Update progress bar (0% -> 100% over delay period) const progress = ((delaySeconds - remainingSeconds) / delaySeconds) * 100; progressBar.style.width = `${progress}%`; // Update progress label with countdown if (remainingSeconds > 0) { progressLabel.textContent = `Starting scan in ${remainingSeconds}s...`; } else { progressLabel.textContent = 'Scan starting now...'; } // When countdown reaches 0, start polling if (remainingSeconds <= 0) { clearInterval(countdownInterval); // Transition to scanning phase phaseLabel.textContent = 'Scan in progress...'; progressBar.style.width = '100%'; progressLabel.textContent = 'Media server is scanning library...'; showToast('📡 Media scan started', 'success', 3000); // Start polling for scan completion (5 minutes = 150 polls × 2s) let pollCount = 0; const maxPolls = 150; // 5 minutes pollInterval = setInterval(async () => { if (socketConnected) return; // Phase 5: WS handles scan status pollCount++; if (pollCount > maxPolls) { // Polling timeout after 5 minutes clearInterval(pollInterval); button.disabled = false; phaseLabel.textContent = 'Scan completed'; progressBar.style.width = '0%'; progressLabel.textContent = 'Ready for next scan'; statusValue.textContent = 'Idle'; statusValue.style.color = '#b3b3b3'; showToast('✅ Media scan completed', 'success', 3000); return; } try { const statusResponse = await fetch('/api/scan/status'); const statusData = await statusResponse.json(); if (statusData.success && statusData.status) { const status = statusData.status; // Update status display if (status.is_scanning) { phaseLabel.textContent = 'Media server scanning...'; progressLabel.textContent = status.progress_message || 'Scan in progress'; } else if (status.status === 'idle') { // Scan completed clearInterval(pollInterval); button.disabled = false; phaseLabel.textContent = 'Scan completed successfully'; progressBar.style.width = '0%'; progressLabel.textContent = 'Ready for next scan'; statusValue.textContent = 'Idle'; statusValue.style.color = '#b3b3b3'; showToast('✅ Media scan completed', 'success', 3000); } } } catch (pollError) { console.debug('Scan status poll error:', pollError); } }, 2000); // Poll every 2 seconds } }, 1000); // Update countdown every second } else { // Error occurred showToast(`❌ Scan request failed: ${result.error}`, 'error', 5000); button.disabled = false; phaseLabel.textContent = 'Scan failed'; progressBar.style.width = '0%'; progressLabel.textContent = result.error || 'Unknown error'; statusValue.textContent = 'Error'; statusValue.style.color = '#f44336'; } } catch (error) { console.error('Error requesting media scan:', error); showToast('❌ Failed to request media scan', 'error', 3000); button.disabled = false; phaseLabel.textContent = 'Error'; progressBar.style.width = '0%'; progressLabel.textContent = error.message; statusValue.textContent = 'Error'; statusValue.style.color = '#f44336'; } } /** * Check for ongoing metadata update and restore state on page load */ async function checkAndRestoreMetadataUpdateState() { try { const response = await fetch('/api/metadata/status'); const data = await response.json(); if (data.success && data.status) { const status = data.status; // If metadata update is running, restore the UI state and start polling if (status.status === 'running') { console.log('Found ongoing metadata update, restoring state...'); updateMetadataProgressUI(status); startMetadataUpdatePolling(); } else if (status.status === 'completed' || status.status === 'error') { // Show final state but don't start polling updateMetadataProgressUI(status); } } } catch (error) { console.warn('Could not check metadata update state on page load:', error); } } // --- Live Log Viewer Functions --- // Global state for log polling let logPolling = false; let logInterval = null; let lastLogCount = 0; /** * Initialize the live log viewer for sync page */ function initializeLiveLogViewer() { const logArea = document.getElementById('sync-log-area'); if (!logArea) return; // Set initial content logArea.value = 'Loading activity feed...'; // Start log polling startLogPolling(); // Initial load loadLogs(); } /** * Start polling for logs */ function startLogPolling() { if (logPolling) return; // Already polling logPolling = true; logInterval = setInterval(loadLogs, 3000); // Poll every 3 seconds console.log('📝 Started activity feed polling for sync page'); } /** * Stop polling for logs */ function stopLogPolling() { logPolling = false; if (logInterval) { clearInterval(logInterval); logInterval = null; console.log('📝 Stopped log polling'); } } /** * Load and display activity feed as logs */ async function loadLogs() { if (socketConnected) return; // WebSocket handles this try { const response = await fetch('/api/logs'); const data = await response.json(); updateLogsFromData(data); } catch (error) { console.warn('Could not load activity logs for sync page:', error); const logArea = document.getElementById('sync-log-area'); if (logArea && (logArea.value === 'Loading logs...' || logArea.value === '')) { logArea.value = 'Error loading activity feed. Check console for details.'; } } } function updateLogsFromData(data) { if (!data.logs || !Array.isArray(data.logs)) return; const logArea = document.getElementById('sync-log-area'); if (!logArea) return; const logText = data.logs.join('\n'); // Store current scroll state const wasAtTop = logArea.scrollTop <= 10; const wasUserScrolled = logArea.scrollTop < logArea.scrollHeight - logArea.clientHeight - 10; // Update content only if it has changed if (logArea.value !== logText) { logArea.value = logText; // Smart scrolling: stay at top for new entries, preserve user position if scrolled if (wasAtTop || !wasUserScrolled) { logArea.scrollTop = 0; // Stay at top since newest entries are now at top } } } /** * Stop log polling when leaving sync page */ function cleanupSyncPageLogs() { stopLogPolling(); } // --- Global Cleanup on Page Unload --- // Note: Automatic wishlist processing now runs server-side and continues even when browser is closed // ===============================