Include a capped recent tail of database-backed download history in the unified Downloads page so completed Deezer and other streaming downloads remain visible after runtime tasks are cleaned up or the container restarts. Use persistent download history for the dashboard finished count, keep live tasks authoritative for active rows, avoid showing the local clear-completed action for persisted history rows, and cover history hydration/deduping/capping in status tests.
3832 lines
172 KiB
JavaScript
3832 lines
172 KiB
JavaScript
// == 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',
|
||
};
|
||
|
||
// 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;
|
||
|
||
if (!grid.children.length) {
|
||
for (const svc of _RATE_GAUGE_SERVICES) {
|
||
const div = document.createElement('div');
|
||
div.className = 'rate-gauge-card';
|
||
div.id = `rate-gauge-${svc}`;
|
||
div.onclick = () => _openRateModal(svc);
|
||
grid.appendChild(div);
|
||
}
|
||
}
|
||
|
||
for (const svc of _RATE_GAUGE_SERVICES) {
|
||
const d = data[svc];
|
||
if (!d) continue;
|
||
_rateMonitorState[svc] = d;
|
||
const container = document.getElementById(`rate-gauge-${svc}`);
|
||
if (!container) continue;
|
||
|
||
const value = d.cpm || 0;
|
||
const max = d.limit || 60;
|
||
const pct = Math.min(value / max, 1);
|
||
const accent = _RATE_GAUGE_COLORS[svc] || '#888';
|
||
const label = _RATE_GAUGE_LABELS[svc] || svc;
|
||
const worker = d.worker || {};
|
||
const wStatus = worker.status || 'stopped';
|
||
const isRateLimited = d.rate_limited === true;
|
||
|
||
// Build or update the card content
|
||
let gaugeWrap = container.querySelector('.gauge-arc-wrap');
|
||
if (!gaugeWrap) {
|
||
// Full rebuild
|
||
container.innerHTML = `
|
||
<div class="gauge-card-header">
|
||
<span class="gauge-card-dot" style="background:${accent}"></span>
|
||
<span class="gauge-card-name">${label}</span>
|
||
<span class="gauge-card-status" data-status="${wStatus}">${_workerStatusLabel(wStatus, worker)}</span>
|
||
</div>
|
||
<div class="gauge-arc-wrap">${_buildGaugeSVG(svc, value, max)}</div>
|
||
<div class="gauge-card-stats">
|
||
<div class="gauge-card-stat"><span class="gauge-card-stat-val">${value.toFixed(0)}</span><span class="gauge-card-stat-label">calls/min</span></div>
|
||
<div class="gauge-card-stat"><span class="gauge-card-stat-val">${worker.calls_1h || 0}</span><span class="gauge-card-stat-label">last hour</span></div>
|
||
<div class="gauge-card-stat"><span class="gauge-card-stat-val">${(worker.calls_24h || 0).toLocaleString()}</span><span class="gauge-card-stat-label">24h</span></div>
|
||
</div>
|
||
${svc === 'spotify' && worker.daily_budget ? _buildBudgetBar(worker.daily_budget) : ''}
|
||
${isRateLimited ? _buildRateLimitBadge(d) : ''}
|
||
`;
|
||
} else {
|
||
// Fast update — only change values
|
||
_updateGauge(gaugeWrap, value, max, svc);
|
||
|
||
// Update status
|
||
const statusEl = container.querySelector('.gauge-card-status');
|
||
if (statusEl) {
|
||
statusEl.dataset.status = wStatus;
|
||
statusEl.textContent = _workerStatusLabel(wStatus, worker);
|
||
}
|
||
|
||
// Update stats
|
||
const statVals = container.querySelectorAll('.gauge-card-stat-val');
|
||
if (statVals[0]) statVals[0].textContent = value.toFixed(0);
|
||
if (statVals[1]) statVals[1].textContent = worker.calls_1h || 0;
|
||
if (statVals[2]) statVals[2].textContent = (worker.calls_24h || 0).toLocaleString();
|
||
|
||
// Budget bar (Spotify)
|
||
if (svc === 'spotify' && worker.daily_budget) {
|
||
let budgetEl = container.querySelector('.gauge-budget-bar');
|
||
if (!budgetEl) {
|
||
const div = document.createElement('div');
|
||
div.innerHTML = _buildBudgetBar(worker.daily_budget);
|
||
const statsEl = container.querySelector('.gauge-card-stats');
|
||
if (statsEl) statsEl.after(div.firstElementChild);
|
||
} else {
|
||
const b = worker.daily_budget;
|
||
const pctB = Math.min(100, Math.round((b.used / b.limit) * 100));
|
||
const fill = budgetEl.querySelector('.gauge-budget-fill');
|
||
if (fill) { fill.style.width = pctB + '%'; }
|
||
const label = budgetEl.querySelector('.gauge-budget-label');
|
||
if (label) label.textContent = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()} daily`;
|
||
}
|
||
}
|
||
|
||
// Rate limit badge
|
||
let badge = container.querySelector('.gauge-rl-badge');
|
||
if (isRateLimited) {
|
||
if (!badge) {
|
||
const div = document.createElement('div');
|
||
div.innerHTML = _buildRateLimitBadge(d);
|
||
container.appendChild(div.firstElementChild);
|
||
} else {
|
||
const mins = Math.ceil((d.rl_remaining || 0) / 60);
|
||
const timeEl = badge.querySelector('.gauge-rl-time');
|
||
if (timeEl) timeEl.textContent = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
|
||
}
|
||
} else if (badge) {
|
||
badge.remove();
|
||
}
|
||
}
|
||
|
||
container.classList.toggle('danger', pct > 0.8 || isRateLimited);
|
||
container.classList.toggle('active', value > 0 || wStatus === 'running');
|
||
container.classList.toggle('rate-limited', isRateLimited);
|
||
}
|
||
}
|
||
|
||
function _workerStatusLabel(status, worker) {
|
||
if (status === 'not_configured') return 'Not configured';
|
||
if (status === 'paused') return worker.yield_reason === 'downloads' ? 'Yielding' : 'Paused';
|
||
if (status === 'idle') return 'Idle';
|
||
if (status === 'running') return 'Running';
|
||
return 'Stopped';
|
||
}
|
||
|
||
function _buildBudgetBar(budget) {
|
||
const pct = Math.min(100, Math.round((budget.used / budget.limit) * 100));
|
||
const cls = budget.exhausted ? 'exhausted' : pct > 80 ? 'high' : '';
|
||
return `<div class="gauge-budget-bar ${cls}">
|
||
<div class="gauge-budget-fill" style="width:${pct}%"></div>
|
||
<span class="gauge-budget-label">${budget.used.toLocaleString()} / ${budget.limit.toLocaleString()} daily</span>
|
||
</div>`;
|
||
}
|
||
|
||
function _buildRateLimitBadge(d) {
|
||
const mins = Math.ceil((d.rl_remaining || 0) / 60);
|
||
const text = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
|
||
return `<div class="gauge-rl-badge"><span class="gauge-rl-dot"></span>RATE LIMITED<span class="gauge-rl-time">${text}</span></div>`;
|
||
}
|
||
|
||
function _buildGaugeSVG(svc, value, max) {
|
||
const { size, cx, cy, r, stroke, startAngle, totalArc } = _G;
|
||
const label = _RATE_GAUGE_LABELS[svc] || svc;
|
||
const accent = _RATE_GAUGE_COLORS[svc] || '#888';
|
||
const pct = Math.min(value / max, 1);
|
||
const endAngle = startAngle + pct * totalArc;
|
||
const arcEnd = startAngle + totalArc;
|
||
const glowId = `glow-${svc}`;
|
||
|
||
// Endpoint dot position
|
||
const dot = pct > 0 ? _gPt(endAngle, r) : null;
|
||
|
||
// Gradient ID for the colored arc
|
||
const gradId = `grad-${svc}`;
|
||
|
||
const color = pct > 0.8 ? '#ef4444' : pct > 0.6 ? '#eab308' : accent;
|
||
|
||
return `
|
||
<svg viewBox="0 0 ${size} ${size}" class="rate-gauge-svg">
|
||
<!-- Background track -->
|
||
<path d="${_gArc(startAngle, arcEnd)}" fill="none" stroke="rgba(255,255,255,0.05)" stroke-width="${stroke}" stroke-linecap="round"/>
|
||
|
||
<!-- Danger zone marker (last 20% of arc, subtle) -->
|
||
<path d="${_gArc(startAngle + totalArc * 0.8, arcEnd)}" fill="none" stroke="rgba(239,68,68,0.1)" stroke-width="${stroke}" stroke-linecap="round"/>
|
||
|
||
<!-- Active arc (CSS handles glow via drop-shadow) -->
|
||
${pct > 0 ? `<path class="gauge-active-arc" data-color="${color}" d="${_gArc(startAngle, endAngle)}" fill="none" stroke="${color}" stroke-width="${stroke}" stroke-linecap="round" style="filter:drop-shadow(0 0 6px ${color}60)"/>` : ''}
|
||
|
||
<!-- Endpoint dot -->
|
||
${dot ? `<circle class="gauge-dot" cx="${dot.x}" cy="${dot.y}" r="5" fill="${color}" style="filter:drop-shadow(0 0 4px ${color}80)"/><circle cx="${dot.x}" cy="${dot.y}" r="2.5" fill="#fff" opacity="0.9"/>` : ''}
|
||
|
||
<!-- Scale labels: 0 and max -->
|
||
<text x="${_gPt(startAngle, r + 16).x}" y="${_gPt(startAngle, r + 16).y}" text-anchor="middle" dominant-baseline="middle" fill="rgba(255,255,255,0.2)" font-size="9" font-family="-apple-system,sans-serif">0</text>
|
||
<text x="${_gPt(arcEnd, r + 16).x}" y="${_gPt(arcEnd, r + 16).y}" text-anchor="middle" dominant-baseline="middle" fill="rgba(255,255,255,0.2)" font-size="9" font-family="-apple-system,sans-serif">${max}</text>
|
||
|
||
<!-- Center content -->
|
||
<text class="gauge-value" x="${cx}" y="${cy - 12}" text-anchor="middle" dominant-baseline="middle">${Math.round(value)}</text>
|
||
<text class="gauge-unit" x="${cx}" y="${cy + 6}" text-anchor="middle">/min</text>
|
||
|
||
<!-- Service label -->
|
||
<text class="gauge-label" x="${cx}" y="${cy + 28}" text-anchor="middle">${label}</text>
|
||
</svg>
|
||
`;
|
||
}
|
||
|
||
function _updateGauge(container, value, max, svc) {
|
||
const { r, stroke, startAngle, totalArc } = _G;
|
||
const accent = _RATE_GAUGE_COLORS[svc] || '#888';
|
||
const pct = Math.min(value / max, 1);
|
||
const endAngle = startAngle + pct * totalArc;
|
||
const color = pct > 0.8 ? '#ef4444' : pct > 0.6 ? '#eab308' : accent;
|
||
|
||
// Update center value
|
||
const valText = container.querySelector('.gauge-value');
|
||
if (valText) valText.textContent = Math.round(value);
|
||
|
||
// Update active arc
|
||
const activeArc = container.querySelector('.gauge-active-arc');
|
||
if (pct > 0) {
|
||
const d = _gArc(startAngle, endAngle);
|
||
if (activeArc) {
|
||
activeArc.setAttribute('d', d);
|
||
activeArc.setAttribute('stroke', color);
|
||
activeArc.style.filter = `drop-shadow(0 0 6px ${color}60)`;
|
||
} else {
|
||
// Rebuild the whole gauge when transitioning from 0 to active
|
||
container.innerHTML = _buildGaugeSVG(svc, value, max);
|
||
return;
|
||
}
|
||
} else if (activeArc) {
|
||
activeArc.remove();
|
||
// Also remove dots
|
||
container.querySelectorAll('.gauge-dot').forEach(d => d.remove());
|
||
const innerDot = container.querySelector('.gauge-dot + circle');
|
||
if (innerDot) innerDot.remove();
|
||
return;
|
||
}
|
||
|
||
// Update endpoint dot
|
||
const gaugeDot = container.querySelector('.gauge-dot');
|
||
if (pct > 0 && gaugeDot) {
|
||
const dot = _gPt(endAngle, r);
|
||
gaugeDot.setAttribute('cx', dot.x);
|
||
gaugeDot.setAttribute('cy', dot.y);
|
||
gaugeDot.setAttribute('fill', color);
|
||
gaugeDot.style.filter = `drop-shadow(0 0 4px ${color}80)`;
|
||
const inner = gaugeDot.nextElementSibling;
|
||
if (inner && inner.tagName === 'circle') {
|
||
inner.setAttribute('cx', dot.x);
|
||
inner.setAttribute('cy', dot.y);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Rate Monitor Detail Modal ──
|
||
|
||
let _rateModalService = null;
|
||
let _rateModalInterval = null;
|
||
|
||
function _openRateModal(serviceKey) {
|
||
_rateModalService = serviceKey;
|
||
const label = _RATE_GAUGE_LABELS[serviceKey] || serviceKey;
|
||
const accent = _RATE_GAUGE_COLORS[serviceKey] || '#888';
|
||
|
||
let overlay = document.getElementById('rate-modal-overlay');
|
||
if (overlay) overlay.remove();
|
||
|
||
overlay = document.createElement('div');
|
||
overlay.id = 'rate-modal-overlay';
|
||
overlay.className = 'modal-overlay';
|
||
overlay.onclick = (e) => { if (e.target === overlay) _closeRateModal(); };
|
||
|
||
const isSpotify = serviceKey === 'spotify';
|
||
const currentData = _rateMonitorState[serviceKey] || {};
|
||
|
||
overlay.innerHTML = `
|
||
<div class="rate-modal">
|
||
<div class="rate-modal-header">
|
||
<div class="rate-modal-header-info">
|
||
<div class="rate-modal-header-dot" style="background:${accent}"></div>
|
||
<div>
|
||
<h3>${label}</h3>
|
||
<span class="rate-modal-header-sub">${currentData.cpm || 0} calls/min — limit ${currentData.limit || '?'}/min</span>
|
||
</div>
|
||
</div>
|
||
<button class="watch-all-close" onclick="_closeRateModal()">×</button>
|
||
</div>
|
||
<div class="rate-modal-body">
|
||
<div class="rate-modal-section-title">24-Hour Call History</div>
|
||
<div class="rate-modal-chart-wrap">
|
||
<canvas id="rate-modal-chart" width="700" height="280"></canvas>
|
||
<div class="rate-modal-chart-legend" id="rate-modal-chart-legend"></div>
|
||
</div>
|
||
${isSpotify ? '<div class="rate-modal-section-title">Per-Endpoint Breakdown</div><div class="rate-modal-endpoints" id="rate-modal-endpoints"></div>' : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
|
||
// Fetch main history + per-endpoint histories for Spotify
|
||
const historyPromises = [
|
||
fetch(`/api/rate-monitor/history/${serviceKey}`).then(r => r.json())
|
||
];
|
||
if (isSpotify) {
|
||
const activeEps = Object.keys(_rateMonitorState.spotify?.endpoints || {});
|
||
for (const ep of activeEps) {
|
||
historyPromises.push(
|
||
fetch(`/api/rate-monitor/history/spotify:${ep}`).then(r => r.json()).catch(() => null)
|
||
);
|
||
}
|
||
}
|
||
Promise.all(historyPromises).then(results => {
|
||
const main = results[0];
|
||
const epHistories = isSpotify ? results.slice(1).filter(Boolean) : [];
|
||
_renderRateChart(main.history || [], main.rate_limit || 60, accent, epHistories);
|
||
}).catch(() => { });
|
||
|
||
if (isSpotify) {
|
||
_updateSpotifyEndpoints();
|
||
_rateModalInterval = setInterval(_updateSpotifyEndpoints, 1000);
|
||
}
|
||
}
|
||
|
||
function _closeRateModal() {
|
||
const overlay = document.getElementById('rate-modal-overlay');
|
||
if (overlay) overlay.remove();
|
||
if (_rateModalInterval) { clearInterval(_rateModalInterval); _rateModalInterval = null; }
|
||
_rateModalService = null;
|
||
}
|
||
|
||
function _renderRateChart(history, rateLimit, accent, epHistories = []) {
|
||
const canvas = document.getElementById('rate-modal-chart');
|
||
if (!canvas) return;
|
||
|
||
// HiDPI support
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const W = 700, H = 280;
|
||
canvas.width = W * dpr;
|
||
canvas.height = H * dpr;
|
||
canvas.style.width = W + 'px';
|
||
canvas.style.height = H + 'px';
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.scale(dpr, dpr);
|
||
|
||
const pad = { top: 24, right: 24, bottom: 36, left: 50 };
|
||
const plotW = W - pad.left - pad.right;
|
||
const plotH = H - pad.top - pad.bottom;
|
||
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
// Build data points
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const start = now - 86400;
|
||
const points = [];
|
||
|
||
if (history.length > 0) {
|
||
const histMap = new Map(history.map(h => [h[0], h[1]]));
|
||
for (let t = start; t <= now; t += 300) {
|
||
const bucket = Math.floor(t / 60) * 60;
|
||
let sum = 0;
|
||
for (let m = bucket; m < bucket + 300; m += 60) sum += histMap.get(m) || 0;
|
||
points.push({ t, v: sum / 5 });
|
||
}
|
||
}
|
||
|
||
const maxVal = Math.max(rateLimit * 1.15, ...points.map(p => p.v), 1);
|
||
|
||
// Grid lines (horizontal)
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||
ctx.lineWidth = 1;
|
||
for (let i = 1; i <= 4; i++) {
|
||
const y = pad.top + plotH * (1 - i / 4);
|
||
ctx.beginPath();
|
||
ctx.moveTo(pad.left, y);
|
||
ctx.lineTo(pad.left + plotW, y);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Danger zone band
|
||
const dangerY = pad.top + plotH * (1 - rateLimit / maxVal);
|
||
const grad = ctx.createLinearGradient(0, pad.top, 0, dangerY);
|
||
grad.addColorStop(0, 'rgba(239, 68, 68, 0.08)');
|
||
grad.addColorStop(1, 'rgba(239, 68, 68, 0.02)');
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(pad.left, pad.top, plotW, dangerY - pad.top);
|
||
|
||
// Rate limit line
|
||
ctx.strokeStyle = 'rgba(239, 68, 68, 0.5)';
|
||
ctx.setLineDash([8, 5]);
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(pad.left, dangerY);
|
||
ctx.lineTo(pad.left + plotW, dangerY);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
ctx.fillStyle = 'rgba(239, 68, 68, 0.6)';
|
||
ctx.font = '10px -apple-system, sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(`Rate limit: ${rateLimit}/min`, pad.left + 6, dangerY - 6);
|
||
|
||
// Draw area fill + line
|
||
if (points.length > 1) {
|
||
// Area gradient fill
|
||
const areaGrad = ctx.createLinearGradient(0, pad.top, 0, pad.top + plotH);
|
||
// Parse accent to rgba
|
||
areaGrad.addColorStop(0, accent + '30');
|
||
areaGrad.addColorStop(1, accent + '05');
|
||
|
||
ctx.beginPath();
|
||
points.forEach((p, i) => {
|
||
const x = pad.left + (i / (points.length - 1)) * plotW;
|
||
const y = pad.top + plotH * (1 - p.v / maxVal);
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
});
|
||
ctx.lineTo(pad.left + plotW, pad.top + plotH);
|
||
ctx.lineTo(pad.left, pad.top + plotH);
|
||
ctx.closePath();
|
||
ctx.fillStyle = areaGrad;
|
||
ctx.fill();
|
||
|
||
// Line
|
||
ctx.beginPath();
|
||
points.forEach((p, i) => {
|
||
const x = pad.left + (i / (points.length - 1)) * plotW;
|
||
const y = pad.top + plotH * (1 - p.v / maxVal);
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
});
|
||
ctx.strokeStyle = accent;
|
||
ctx.lineWidth = 2;
|
||
ctx.lineJoin = 'round';
|
||
ctx.stroke();
|
||
|
||
// Glow effect
|
||
ctx.shadowColor = accent;
|
||
ctx.shadowBlur = 8;
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
}
|
||
|
||
// Per-endpoint lines (Spotify breakdown)
|
||
const legendEl = document.getElementById('rate-modal-chart-legend');
|
||
if (epHistories.length > 0) {
|
||
const epColors = ['#1DB954', '#FF6B6B', '#4ECDC4', '#FFE66D', '#A78BFA', '#F97316', '#06B6D4', '#EC4899', '#F472B6', '#34D399'];
|
||
const legendItems = [];
|
||
|
||
epHistories.forEach((epData, idx) => {
|
||
if (!epData || !epData.history || epData.history.length === 0) return;
|
||
const epName = (epData.service || '').replace('spotify:', '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||
const color = epColors[idx % epColors.length];
|
||
legendItems.push({ name: epName, color });
|
||
|
||
const histMap = new Map(epData.history.map(h => [h[0], h[1]]));
|
||
const epPoints = [];
|
||
for (let t = start; t <= now; t += 300) {
|
||
const bucket = Math.floor(t / 60) * 60;
|
||
let sum = 0;
|
||
for (let m = bucket; m < bucket + 300; m += 60) sum += histMap.get(m) || 0;
|
||
epPoints.push({ t, v: sum / 5 });
|
||
}
|
||
|
||
if (epPoints.length > 1) {
|
||
ctx.beginPath();
|
||
epPoints.forEach((p, i) => {
|
||
const x = pad.left + (i / (epPoints.length - 1)) * plotW;
|
||
const y = pad.top + plotH * (1 - p.v / maxVal);
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
});
|
||
ctx.strokeStyle = color + 'BB';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.lineJoin = 'round';
|
||
ctx.stroke();
|
||
}
|
||
});
|
||
|
||
// HTML legend below chart
|
||
if (legendEl && legendItems.length > 0) {
|
||
legendEl.innerHTML = legendItems.map(item =>
|
||
`<span class="rate-chart-legend-item"><span class="rate-chart-legend-dot" style="background:${item.color}"></span>${item.name}</span>`
|
||
).join('');
|
||
}
|
||
} else if (legendEl) {
|
||
legendEl.innerHTML = '';
|
||
}
|
||
|
||
// X-axis labels
|
||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||
ctx.font = '10px -apple-system, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
for (let i = 0; i <= 6; i++) {
|
||
const t = start + (86400 * i / 6);
|
||
const x = pad.left + (i / 6) * plotW;
|
||
const d = new Date(t * 1000);
|
||
const hr = d.getHours();
|
||
const label = hr === 0 ? '12am' : hr < 12 ? `${hr}am` : hr === 12 ? '12pm' : `${hr - 12}pm`;
|
||
ctx.fillText(label, x, H - 10);
|
||
// Subtle vertical grid
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, pad.top);
|
||
ctx.lineTo(x, pad.top + plotH);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Y-axis labels
|
||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||
ctx.textAlign = 'right';
|
||
ctx.font = '10px -apple-system, sans-serif';
|
||
for (let i = 0; i <= 4; i++) {
|
||
const v = maxVal * i / 4;
|
||
const y = pad.top + plotH * (1 - i / 4);
|
||
ctx.fillText(Math.round(v), pad.left - 8, y + 4);
|
||
}
|
||
|
||
// Empty state
|
||
if (points.length === 0) {
|
||
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||
ctx.font = '13px -apple-system, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('No call history yet — data populates as API calls are made', W / 2, H / 2);
|
||
}
|
||
}
|
||
|
||
function _updateSpotifyEndpoints() {
|
||
const container = document.getElementById('rate-modal-endpoints');
|
||
if (!container) return;
|
||
const endpoints = _rateMonitorState.spotify?.endpoints || {};
|
||
const entries = Object.entries(endpoints).sort((a, b) => b[1] - a[1]);
|
||
|
||
if (entries.length === 0) {
|
||
container.innerHTML = '<div class="rate-modal-ep-empty">No active Spotify endpoints — start an enrichment worker or search to see activity</div>';
|
||
return;
|
||
}
|
||
|
||
const limit = _rateMonitorState.spotify?.limit || 171;
|
||
container.innerHTML = entries.map(([ep, cpm]) => {
|
||
const pct = Math.min(cpm / limit * 100, 100);
|
||
const name = ep.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||
const color = pct > 80 ? '#ef4444' : pct > 60 ? '#eab308' : '#1DB954';
|
||
return `<div class="rate-modal-ep">
|
||
<span class="rate-modal-ep-name">${name}</span>
|
||
<div class="rate-modal-ep-bar"><div class="rate-modal-ep-fill" style="width:${pct}%;background:${color}"></div></div>
|
||
<span class="rate-modal-ep-value">${Math.round(cpm)}/min</span>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function fetchAndUpdateSystemStats() {
|
||
if (socketConnected) return; // WebSocket handles this
|
||
if (document.hidden) return; // Skip polling when tab is not visible
|
||
try {
|
||
const response = await fetch('/api/system/stats');
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
|
||
// Update all stat cards
|
||
updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading');
|
||
updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed downloads');
|
||
updateStatCard('download-speed-card', data.download_speed, 'Combined speed');
|
||
updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing');
|
||
updateStatCard('uptime-card', data.uptime, 'Application runtime');
|
||
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 = `
|
||
<div class="activity-item">
|
||
<span class="activity-icon">📊</span>
|
||
<div class="activity-text-content">
|
||
<p class="activity-title">System Started</p>
|
||
<p class="activity-subtitle">Dashboard initialized successfully</p>
|
||
</div>
|
||
<p class="activity-time">Just now</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const items = activities.slice(0, 5);
|
||
// Build signature from titles+subtitles to detect actual changes
|
||
const sig = items.map(a => a.title + a.subtitle).join('|');
|
||
const feedChanged = sig !== _lastActivityFeedSig;
|
||
_lastActivityFeedSig = sig;
|
||
|
||
if (!feedChanged) {
|
||
// Just update timestamps without rebuilding DOM
|
||
const timeEls = feedContainer.querySelectorAll('.activity-time');
|
||
items.forEach((activity, i) => {
|
||
if (timeEls[i]) timeEls[i].textContent = _activityTimeAgo(activity);
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Full rebuild only when feed content actually changed
|
||
feedContainer.innerHTML = '';
|
||
items.forEach((activity, index) => {
|
||
const activityElement = document.createElement('div');
|
||
activityElement.className = 'activity-item';
|
||
activityElement.innerHTML = `
|
||
<span class="activity-icon">${escapeHtml(activity.icon)}</span>
|
||
<div class="activity-text-content">
|
||
<p class="activity-title">${escapeHtml(activity.title)}</p>
|
||
<p class="activity-subtitle">${escapeHtml(activity.subtitle)}</p>
|
||
</div>
|
||
<p class="activity-time">${_activityTimeAgo(activity)}</p>
|
||
`;
|
||
feedContainer.appendChild(activityElement);
|
||
|
||
if (index < items.length - 1) {
|
||
const separator = document.createElement('div');
|
||
separator.className = 'activity-separator';
|
||
feedContainer.appendChild(separator);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function checkForActivityToasts() {
|
||
if (socketConnected) return; // WebSocket handles this (instant push)
|
||
if (document.hidden) return; // Skip polling when tab is not visible
|
||
try {
|
||
const response = await fetch('/api/activity/toasts');
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
const toasts = data.toasts || [];
|
||
|
||
toasts.forEach(activity => {
|
||
// Convert activity to toast type based on icon/title
|
||
let toastType = 'info';
|
||
if (activity.icon === '✅' || activity.title.includes('Complete')) {
|
||
toastType = 'success';
|
||
} else if (activity.icon === '❌' || activity.title.includes('Failed') || activity.title.includes('Error')) {
|
||
toastType = 'error';
|
||
} else if (activity.icon === '🚫' || activity.title.includes('Cancelled')) {
|
||
toastType = 'warning';
|
||
}
|
||
|
||
// Show toast with activity info
|
||
showToast(`${activity.title}: ${activity.subtitle}`, toastType);
|
||
});
|
||
|
||
} catch (error) {
|
||
// Silently fail for toast checking to avoid spam
|
||
}
|
||
}
|
||
|
||
// --- Watchlist Functions ---
|
||
|
||
/**
|
||
* Toggle an artist's watchlist status
|
||
*/
|
||
async function toggleWatchlist(event, artistId, artistName) {
|
||
// Prevent event bubbling to parent card
|
||
event.stopPropagation();
|
||
|
||
const button = event.currentTarget;
|
||
const icon = button.querySelector('.watchlist-icon');
|
||
const text = button.querySelector('.watchlist-text');
|
||
|
||
// Show loading state
|
||
const originalText = text.textContent;
|
||
text.textContent = 'Loading...';
|
||
button.disabled = true;
|
||
|
||
try {
|
||
// Check current status
|
||
const checkResponse = await fetch('/api/watchlist/check', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ artist_id: artistId })
|
||
});
|
||
|
||
const checkData = await checkResponse.json();
|
||
if (!checkData.success) {
|
||
throw new Error(checkData.error || 'Failed to check watchlist status');
|
||
}
|
||
|
||
const isWatching = checkData.is_watching;
|
||
|
||
// Toggle watchlist status
|
||
const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add';
|
||
const payload = isWatching ?
|
||
{ artist_id: artistId } :
|
||
{ artist_id: artistId, artist_name: artistName };
|
||
|
||
const response = await fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (!data.success) {
|
||
throw new Error(data.error || 'Failed to update watchlist');
|
||
}
|
||
|
||
// Update button appearance
|
||
const gearBtn = button.parentElement?.querySelector('.watchlist-settings-btn');
|
||
if (isWatching) {
|
||
// Was watching, now removed
|
||
icon.textContent = '👁️';
|
||
text.textContent = 'Add to Watchlist';
|
||
button.classList.remove('watching');
|
||
if (gearBtn) gearBtn.classList.add('hidden');
|
||
console.log(`❌ Removed ${artistName} from watchlist`);
|
||
} else {
|
||
// Was not watching, now added
|
||
icon.textContent = '👁️';
|
||
text.textContent = 'Watching...';
|
||
button.classList.add('watching');
|
||
if (gearBtn) gearBtn.classList.remove('hidden');
|
||
console.log(`✅ Added ${artistName} to watchlist`);
|
||
}
|
||
|
||
// Update dashboard watchlist count
|
||
updateWatchlistButtonCount();
|
||
|
||
} catch (error) {
|
||
console.error('Error toggling watchlist:', error);
|
||
text.textContent = originalText;
|
||
|
||
// Show error feedback
|
||
const originalBackground = button.style.background;
|
||
button.style.background = 'rgba(255, 59, 48, 0.3)';
|
||
setTimeout(() => {
|
||
button.style.background = originalBackground;
|
||
}, 2000);
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update the watchlist button count on dashboard
|
||
*/
|
||
async function updateWatchlistButtonCount() {
|
||
if (document.hidden) return; // Skip polling when tab is not visible
|
||
if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll
|
||
try {
|
||
const response = await fetch('/api/watchlist/count');
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
_updateHeroBtnCount('watchlist-button', 'watchlist-badge', data.count);
|
||
// Update sidebar nav badge
|
||
const wlNavBadge = document.getElementById('watchlist-nav-badge');
|
||
if (wlNavBadge) {
|
||
wlNavBadge.textContent = data.count;
|
||
wlNavBadge.classList.toggle('hidden', data.count === 0);
|
||
}
|
||
const watchlistButton = document.getElementById('watchlist-button');
|
||
if (watchlistButton) {
|
||
const countdownText = data.next_run_in_seconds ? formatCountdownTime(data.next_run_in_seconds) : '';
|
||
if (countdownText) {
|
||
watchlistButton.title = `Next auto-scan in ${countdownText}`;
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating watchlist count:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check and update watchlist status for all visible artist cards
|
||
*/
|
||
async function updateArtistCardWatchlistStatus() {
|
||
const artistCards = document.querySelectorAll('.artist-card');
|
||
const artistIds = [];
|
||
for (const card of artistCards) {
|
||
const artistId = card.dataset.artistId;
|
||
if (artistId) artistIds.push(artistId);
|
||
}
|
||
if (!artistIds.length) return;
|
||
|
||
try {
|
||
const response = await fetch('/api/watchlist/check-batch', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ artist_ids: artistIds })
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.success && data.results) {
|
||
for (const card of artistCards) {
|
||
const artistId = card.dataset.artistId;
|
||
if (!artistId) continue;
|
||
|
||
const button = card.querySelector('.watchlist-toggle-btn');
|
||
if (!button) continue;
|
||
const icon = button.querySelector('.watchlist-icon');
|
||
const text = button.querySelector('.watchlist-text');
|
||
|
||
const gearBtn = button.parentElement?.querySelector('.watchlist-settings-btn');
|
||
if (data.results[artistId]) {
|
||
if (icon) icon.textContent = '👁️';
|
||
if (text) text.textContent = 'Watching...';
|
||
button.classList.add('watching');
|
||
if (gearBtn) gearBtn.classList.remove('hidden');
|
||
} else {
|
||
if (icon) icon.textContent = '👁️';
|
||
if (text) text.textContent = 'Add to Watchlist';
|
||
button.classList.remove('watching');
|
||
if (gearBtn) gearBtn.classList.add('hidden');
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error batch checking watchlist status:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initialize/refresh the watchlist sidebar page
|
||
*/
|
||
async function initializeWatchlistPage() {
|
||
try {
|
||
const emptyEl = document.getElementById('watchlist-page-empty');
|
||
const gridEl = document.getElementById('watchlist-artists-list');
|
||
const countEl = document.getElementById('watchlist-page-count');
|
||
const overrideBanner = document.getElementById('watchlist-page-override-banner');
|
||
|
||
// Fetch count, artists, scan status, global config in parallel
|
||
const [countRes, artistsRes, statusRes, globalRes] = await Promise.all([
|
||
fetch('/api/watchlist/count').then(r => r.json()),
|
||
fetch('/api/watchlist/artists').then(r => r.json()),
|
||
fetch('/api/watchlist/scan/status').then(r => r.json()),
|
||
fetch('/api/watchlist/global-config').then(r => r.json()).catch(() => ({ success: false })),
|
||
]);
|
||
|
||
const count = countRes.success ? countRes.count : 0;
|
||
const artists = artistsRes.success ? artistsRes.artists : [];
|
||
const scanStatus = statusRes.success ? statusRes.status : 'idle';
|
||
const globalOverrideActive = globalRes.success && globalRes.config && globalRes.config.global_override_enabled;
|
||
|
||
// Update count
|
||
if (countEl) countEl.textContent = `${count} artist${count !== 1 ? 's' : ''}`;
|
||
|
||
// Update nav badge
|
||
const navBadge = document.getElementById('watchlist-nav-badge');
|
||
if (navBadge) {
|
||
navBadge.textContent = count;
|
||
navBadge.classList.toggle('hidden', count === 0);
|
||
}
|
||
|
||
// Empty state
|
||
if (count === 0) {
|
||
if (emptyEl) emptyEl.style.display = '';
|
||
if (gridEl) gridEl.style.display = 'none';
|
||
watchlistPageState.isInitialized = true;
|
||
return;
|
||
}
|
||
if (emptyEl) emptyEl.style.display = 'none';
|
||
if (gridEl) gridEl.style.display = '';
|
||
|
||
// Store artists for sorting
|
||
watchlistPageState.artists = artists;
|
||
|
||
// Last scan summary strip
|
||
const scanStrip = document.getElementById('watchlist-last-scan-strip');
|
||
const scanText = document.getElementById('watchlist-last-scan-text');
|
||
if (scanStrip && scanText && statusRes.completed_at && statusRes.summary) {
|
||
const completedDate = new Date(statusRes.completed_at);
|
||
const ago = _formatTimeAgo(completedDate);
|
||
const found = statusRes.summary.new_tracks_found || 0;
|
||
const added = statusRes.summary.tracks_added_to_wishlist || 0;
|
||
scanText.textContent = `Last scan: ${ago} — ${found} new track${found !== 1 ? 's' : ''} found, ${added} added to wishlist`;
|
||
scanStrip.style.display = '';
|
||
} else if (scanStrip) {
|
||
scanStrip.style.display = 'none';
|
||
}
|
||
|
||
// Global override banner
|
||
if (overrideBanner) overrideBanner.style.display = globalOverrideActive ? '' : 'none';
|
||
const settingsBtn = document.getElementById('watchlist-page-settings-btn');
|
||
if (settingsBtn) {
|
||
settingsBtn.classList.toggle('watchlist-global-settings-active', globalOverrideActive);
|
||
settingsBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> ${globalOverrideActive ? 'Global Override ON' : 'Global Settings'}`;
|
||
}
|
||
|
||
// Render artist cards
|
||
if (gridEl) {
|
||
gridEl.innerHTML = artists.map(artist => {
|
||
const pills = [];
|
||
if (artist.include_albums) pills.push('<span class="watchlist-pill watchlist-pill-active">Albums</span>');
|
||
if (artist.include_eps) pills.push('<span class="watchlist-pill watchlist-pill-active">EPs</span>');
|
||
if (artist.include_singles) pills.push('<span class="watchlist-pill watchlist-pill-active">Singles</span>');
|
||
if (artist.include_live) pills.push('<span class="watchlist-pill watchlist-pill-filter">Live</span>');
|
||
if (artist.include_remixes) pills.push('<span class="watchlist-pill watchlist-pill-filter">Remixes</span>');
|
||
if (artist.include_acoustic) pills.push('<span class="watchlist-pill watchlist-pill-filter">Acoustic</span>');
|
||
if (artist.include_compilations) pills.push('<span class="watchlist-pill watchlist-pill-filter">Compilations</span>');
|
||
const sourceBadges = [];
|
||
if (artist.spotify_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-spotify">Spotify</span>');
|
||
if (artist.itunes_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-itunes">iTunes</span>');
|
||
if (artist.deezer_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-deezer">Deezer</span>');
|
||
if (artist.discogs_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-discogs">Discogs</span>');
|
||
if (artist.musicbrainz_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-musicbrainz">MusicBrainz</span>');
|
||
if (artist.amazon_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-amazon">Amazon</span>');
|
||
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.musicbrainz_artist_id || artist.amazon_artist_id;
|
||
return `
|
||
<div class="watchlist-artist-card"
|
||
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '"')}"
|
||
data-artist-id="${artistPrimaryId}"
|
||
data-last-scan="${artist.last_scan_timestamp || ''}"
|
||
data-added="${artist.date_added || ''}">
|
||
<label class="watchlist-card-checkbox" onclick="event.stopPropagation();">
|
||
<input type="checkbox" class="watchlist-select-cb"
|
||
data-artist-id="${artistPrimaryId}"
|
||
data-artist-name="${escapeHtml(artist.artist_name)}"
|
||
onchange="updateWatchlistBatchBar()">
|
||
<span class="watchlist-checkbox-custom"></span>
|
||
</label>
|
||
<button class="watchlist-card-gear"
|
||
data-artist-id="${artistPrimaryId}"
|
||
data-artist-name="${escapeHtml(artist.artist_name)}"
|
||
onclick="event.stopPropagation();"
|
||
title="Artist settings">
|
||
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.48.48 0 00-.48-.41h-3.84a.48.48 0 00-.48.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87a.48.48 0 00.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.26.41.48.41h3.84c.24 0 .44-.17.48-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.6 3.6 0 0112 15.6z"/></svg>
|
||
</button>
|
||
<div class="watchlist-card-image">
|
||
${artist.image_url ? `<img src="${artist.image_url}" alt="${escapeHtml(artist.artist_name)}" onerror="if(!this.dataset.retried){this.dataset.retried='1';this.src=this.src;}else{this.parentElement.innerHTML='<div class=\\'watchlist-card-image-fallback\\'>🎤</div>';}">` : '<div class="watchlist-card-image-fallback">🎤</div>'}
|
||
</div>
|
||
<div class="watchlist-card-info">
|
||
<span class="watchlist-card-name">${escapeHtml(artist.artist_name)}</span>
|
||
<span class="watchlist-card-meta">${formatRelativeScanTime(artist.last_scan_timestamp)}</span>
|
||
</div>
|
||
${sourceBadges.length > 0 ? `<div class="watchlist-card-sources">${sourceBadges.join('')}</div>` : ''}
|
||
${pills.length > 0 ? `<div class="watchlist-card-pills">${pills.join('')}</div>` : ''}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
// Wire up gear buttons
|
||
gridEl.querySelectorAll('.watchlist-card-gear').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
openWatchlistArtistConfigModal(button.getAttribute('data-artist-id'), button.getAttribute('data-artist-name'));
|
||
});
|
||
});
|
||
|
||
// Wire up artist card clicks
|
||
gridEl.querySelectorAll('.watchlist-artist-card').forEach(item => {
|
||
item.addEventListener('click', (e) => {
|
||
if (e.target.closest('.watchlist-card-gear') || e.target.closest('.watchlist-card-checkbox')) return;
|
||
const artistId = item.getAttribute('data-artist-id');
|
||
const artistName = item.querySelector('.watchlist-card-name').textContent;
|
||
openWatchlistArtistDetailView(artistId, artistName);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Scan status
|
||
const scanStatusEl = document.getElementById('watchlist-scan-status');
|
||
const liveActivityEl = document.getElementById('watchlist-live-activity');
|
||
const scanBtn = document.getElementById('scan-watchlist-btn');
|
||
const cancelBtn = document.getElementById('cancel-watchlist-scan-btn');
|
||
|
||
if (scanStatus === 'scanning') {
|
||
if (scanStatusEl) scanStatusEl.style.display = '';
|
||
if (liveActivityEl) liveActivityEl.style.display = 'flex';
|
||
if (scanBtn) { scanBtn.disabled = true; scanBtn.classList.add('btn-processing'); scanBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> Scanning...'; }
|
||
if (cancelBtn) cancelBtn.style.display = '';
|
||
pollWatchlistScanStatus();
|
||
} else {
|
||
if (scanStatusEl && statusRes.summary) {
|
||
scanStatusEl.style.display = '';
|
||
const summaryEl = document.getElementById('watchlist-page-scan-summary');
|
||
if (summaryEl) {
|
||
summaryEl.style.display = '';
|
||
summaryEl.innerHTML = `<span class="sync-stat">Artists: ${statusRes.summary.total_artists || 0}</span><span class="sync-separator"> • </span><span class="sync-stat">New tracks: ${statusRes.summary.new_tracks_found || 0}</span><span class="sync-separator"> • </span><span class="sync-stat">Added to wishlist: ${statusRes.summary.tracks_added_to_wishlist || 0}</span>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Start countdown timer
|
||
const nextRunSeconds = countRes.next_run_in_seconds || 0;
|
||
startWatchlistCountdownTimer(nextRunSeconds);
|
||
|
||
watchlistPageState.isInitialized = true;
|
||
|
||
} catch (error) {
|
||
console.error('Error initializing watchlist page:', error);
|
||
showToast('Failed to load watchlist', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initialize/refresh the wishlist sidebar page
|
||
*/
|
||
async function initializeWishlistPage() {
|
||
try {
|
||
const emptyEl = document.getElementById('wishlist-page-empty');
|
||
const nebulaEl = document.getElementById('wishlist-nebula');
|
||
const countEl = document.getElementById('wishlist-page-count');
|
||
const tracksSection = document.getElementById('wishlist-category-tracks');
|
||
const statsStrip = document.getElementById('wishlist-stats-strip');
|
||
|
||
const [statsRes, cycleRes, albumRes, singleRes, watchlistRes] = await Promise.all([
|
||
fetch('/api/wishlist/stats').then(r => r.json()),
|
||
fetch('/api/wishlist/cycle').then(r => r.json()),
|
||
fetch('/api/wishlist/tracks?category=albums').then(r => r.json()),
|
||
fetch('/api/wishlist/tracks?category=singles').then(r => r.json()),
|
||
fetch('/api/watchlist/artists').then(r => r.json()).catch(() => ({ success: false })),
|
||
]);
|
||
|
||
// Build artist name → image URL map 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 = '<div class="wl-nebula-empty">Your wishlist is empty</div>'; return; }
|
||
|
||
const sorted = [...artistMap.entries()].sort((a, b) => {
|
||
const ac = [...a[1].albums.values()].reduce((s, al) => s + al.tracks.length, 0) + a[1].singles.length;
|
||
const bc = [...b[1].albums.values()].reduce((s, al) => s + al.tracks.length, 0) + b[1].singles.length;
|
||
return bc - ac;
|
||
});
|
||
|
||
function _hue(n) { let h = 0; for (let i = 0; i < n.length; i++) h = n.charCodeAt(i) + ((h << 5) - h); return Math.abs(h) % 360; }
|
||
|
||
let html = '';
|
||
sorted.forEach(([name, data], idx) => {
|
||
const total = [...data.albums.values()].reduce((s, a) => s + a.tracks.length, 0) + data.singles.length;
|
||
const hasAlbums = data.albums.size > 0;
|
||
const hue = _hue(name);
|
||
const sz = total >= 10 ? 'orb-lg' : total >= 4 ? 'orb-md' : 'orb-sm';
|
||
|
||
// Enhancement 1: prefer watchlist artist photo over album cover
|
||
let img = artistImageMap.get(name.toLowerCase()) || '';
|
||
if (!img) { for (const [, ad] of data.albums) { if (ad.image) { img = ad.image; break; } } }
|
||
if (!img && data.singles.length) img = data.singles[0].image || '';
|
||
|
||
// Enhancement 3: pulse if this artist has albums and current cycle is albums
|
||
const pulseClass = (hasAlbums && currentCycle === 'albums') ? ' orb-pulse' : '';
|
||
|
||
// Enhancement 7: staggered entry animation
|
||
const delay = Math.min(idx * 60, 800);
|
||
|
||
html += `<div class="wl-orb-group" data-artist="${escapeHtml(name)}" style="animation-delay:${delay}ms">`;
|
||
|
||
// Enhancement 2: hover tooltip
|
||
html += `<div class="wl-orb-tooltip">${escapeHtml(name)}<br><span>${total} track${total !== 1 ? 's' : ''}</span></div>`;
|
||
|
||
html += `<div class="wl-orb ${sz}${pulseClass}" style="--orb-hue:${hue}" onclick="_toggleOrbExpand(this)">`;
|
||
html += `<div class="wl-orb-glow"></div>`;
|
||
html += img ? `<img class="wl-orb-img" src="${img}" alt="">` : `<div class="wl-orb-initials">${escapeHtml(name.substring(0, 2).toUpperCase())}</div>`;
|
||
html += `<div class="wl-orb-ring"></div>`;
|
||
|
||
// Enhancement 5: album art ring (show up to 6 album covers around the orb)
|
||
const ringCovers = [];
|
||
for (const [, ad] of data.albums) { if (ad.image && ringCovers.length < 6) ringCovers.push(ad.image); }
|
||
for (const s of data.singles) { if (s.image && ringCovers.length < 6) ringCovers.push(s.image); }
|
||
if (ringCovers.length >= 3) {
|
||
html += `<div class="wl-orb-art-ring">`;
|
||
ringCovers.forEach((url, i) => {
|
||
const angle = (360 / ringCovers.length) * i;
|
||
html += `<img class="wl-art-ring-item" src="${url}" style="--ring-angle:${angle}deg" alt="">`;
|
||
});
|
||
html += `</div>`;
|
||
}
|
||
|
||
html += `</div>`; // /orb
|
||
|
||
// Enhancement 8: clickable artist name → navigate to artist detail
|
||
html += `<div class="wl-orb-label" onclick="event.stopPropagation(); _navigateToArtistFromWishlist('${escapeHtml(name)}')" title="View artist">${escapeHtml(name)}</div>`;
|
||
html += `<div class="wl-orb-meta">${total} track${total !== 1 ? 's' : ''}</div>`;
|
||
|
||
// Expanded content
|
||
html += `<div class="wl-orb-expanded">`;
|
||
if (data.albums.size > 0) {
|
||
html += `<div class="wl-album-fan">`;
|
||
for (const [an, ad] of data.albums) {
|
||
const tileId = 'wl-tile-' + an.replace(/\W/g, '_') + '_' + idx;
|
||
html += `<div class="wl-album-tile" data-album="${escapeHtml(an)}" onclick="_toggleAlbumTile(this)">`;
|
||
html += `<div class="wl-album-tile-art">${ad.image ? `<img src="${ad.image}" alt="">` : `<div class="wl-album-tile-fallback">💿</div>`}</div>`;
|
||
html += `<div class="wl-album-tile-info"><div class="wl-album-tile-name">${escapeHtml(an)}</div><div class="wl-album-tile-count">${ad.tracks.length} track${ad.tracks.length !== 1 ? 's' : ''}</div></div>`;
|
||
html += `<span class="wl-album-tile-badge">${ad.tracks.length}</span>`;
|
||
html += `<button class="wl-album-tile-remove" onclick="event.stopPropagation();_removeWishlistAlbum('${escapeHtml(an)}')" title="Remove album">✕</button>`;
|
||
// Track list (hidden until tile clicked)
|
||
html += `<div class="wl-tile-tracks">`;
|
||
for (const tr of ad.tracks) {
|
||
html += `<div class="wl-tile-track">`;
|
||
html += `<span class="wl-tile-track-name">${escapeHtml(tr.track)}</span>`;
|
||
html += `<button class="wl-tile-track-remove" onclick="event.stopPropagation();_removeWishlistTrack('${escapeHtml(tr.id)}')" title="Remove track">✕</button>`;
|
||
html += `</div>`;
|
||
}
|
||
html += `</div>`;
|
||
html += `</div>`;
|
||
}
|
||
html += `</div>`;
|
||
}
|
||
if (data.singles.length > 0) {
|
||
html += `<div class="wl-singles-orbit">`;
|
||
for (const s of data.singles) {
|
||
html += `<div class="wl-single-moon" data-track-id="${escapeHtml(s.id)}">`;
|
||
html += s.image ? `<img src="${s.image}" alt="">` : `<span class="wl-moon-fallback">⭐</span>`;
|
||
html += `<div class="wl-moon-label">${escapeHtml(s.track)}</div>`;
|
||
html += `<button class="wl-moon-remove-btn" onclick="event.stopPropagation();_removeWishlistTrack('${escapeHtml(s.id)}')" title="Remove">✕</button>`;
|
||
html += `</div>`;
|
||
}
|
||
html += `</div>`;
|
||
}
|
||
html += `</div></div>`; // /expanded, /group
|
||
});
|
||
|
||
field.innerHTML = html;
|
||
}
|
||
|
||
// Enhancement 8: navigate to the Search page pre-filled with this artist's name
|
||
function _navigateToArtistFromWishlist(artistName) {
|
||
navigateToPage('search');
|
||
setTimeout(() => {
|
||
const searchInput = document.getElementById('enhanced-search-input');
|
||
if (searchInput) {
|
||
searchInput.value = artistName;
|
||
searchInput.dispatchEvent(new Event('input'));
|
||
searchInput.focus();
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
function _toggleAlbumTile(tileEl) {
|
||
const wasExpanded = tileEl.classList.contains('tile-expanded');
|
||
// Collapse all tiles in this group
|
||
tileEl.closest('.wl-album-fan')?.querySelectorAll('.wl-album-tile.tile-expanded').forEach(t => t.classList.remove('tile-expanded'));
|
||
if (!wasExpanded) tileEl.classList.add('tile-expanded');
|
||
}
|
||
|
||
function _toggleOrbExpand(el) {
|
||
const g = el.closest('.wl-orb-group');
|
||
if (!g) return;
|
||
const was = g.classList.contains('expanded');
|
||
document.querySelectorAll('.wl-orb-group.expanded').forEach(o => o.classList.remove('expanded'));
|
||
if (!was) g.classList.add('expanded');
|
||
}
|
||
|
||
async function _removeWishlistAlbum(albumName) {
|
||
if (!await showConfirmDialog({ title: 'Remove Album', message: `Remove all tracks from "${albumName}"?`, confirmText: 'Remove', destructive: true })) return;
|
||
try {
|
||
const res = await fetch('/api/wishlist/remove-album', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ album_name: albumName }) });
|
||
const data = await res.json();
|
||
if (data.success) { showToast(`Removed "${albumName}"`, 'success'); wishlistPageState.isInitialized = false; await initializeWishlistPage(); await updateWishlistCount(); }
|
||
else showToast(data.error || 'Failed', 'error');
|
||
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
||
}
|
||
|
||
async function _removeWishlistTrack(trackId) {
|
||
try {
|
||
const res = await fetch('/api/wishlist/remove-track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ spotify_track_id: trackId }) });
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
showToast('Removed', 'success');
|
||
await updateWishlistCount();
|
||
// Re-render nebula to reflect removal
|
||
wishlistPageState.isInitialized = false;
|
||
await initializeWishlistPage();
|
||
}
|
||
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
||
}
|
||
|
||
function _filterNebula() {
|
||
const q = (document.getElementById('wl-nebula-search')?.value || '').toLowerCase().trim();
|
||
document.querySelectorAll('.wl-orb-group').forEach(g => {
|
||
const a = (g.dataset.artist || '').toLowerCase();
|
||
const albums = [...g.querySelectorAll('.wl-satellite')].map(s => (s.dataset.album || '').toLowerCase());
|
||
const match = !q || a.includes(q) || albums.some(al => al.includes(q));
|
||
g.style.display = match ? '' : 'none';
|
||
if (!match) g.classList.remove('expanded');
|
||
});
|
||
}
|
||
|
||
async function _nebulaDownload() {
|
||
// Check if wishlist is already processing
|
||
try {
|
||
const statsResp = await fetch('/api/wishlist/stats');
|
||
if (statsResp.ok) {
|
||
const stats = await statsResp.json();
|
||
if (stats.is_auto_processing) {
|
||
// Navigate to downloads page so the user can see progress
|
||
navigateToPage('active-downloads');
|
||
showToast('Wishlist is currently being auto-processed', 'info');
|
||
return;
|
||
}
|
||
}
|
||
const procResp = await fetch('/api/active-processes');
|
||
if (procResp.ok) {
|
||
const procData = await procResp.json();
|
||
const wishlistBatch = (procData.active_processes || []).find(p => p.playlist_id === 'wishlist');
|
||
if (wishlistBatch) {
|
||
// Show the existing download modal
|
||
WishlistModalState.clearUserClosed();
|
||
const clientProcess = activeDownloadProcesses['wishlist'];
|
||
if (clientProcess && clientProcess.modalElement && document.body.contains(clientProcess.modalElement)) {
|
||
clientProcess.modalElement.style.display = 'flex';
|
||
WishlistModalState.setVisible();
|
||
} else {
|
||
await rehydrateModal(wishlistBatch, true);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
|
||
// No active process — show category choice
|
||
const choice = await _showNebulaDownloadChoice();
|
||
if (choice) await openDownloadMissingWishlistModal(choice);
|
||
}
|
||
|
||
function _showNebulaDownloadChoice() {
|
||
return new Promise((resolve) => {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.style.display = 'flex';
|
||
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } };
|
||
|
||
const albumCount = document.getElementById('wishlist-stat-albums')?.textContent || '0';
|
||
const singleCount = document.getElementById('wishlist-stat-singles')?.textContent || '0';
|
||
|
||
overlay.innerHTML = `
|
||
<div class="delete-group-dialog">
|
||
<div class="delete-group-icon">
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--accent-rgb))" stroke-width="1.8"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||
</div>
|
||
<h3 class="delete-group-title">Download Wishlist</h3>
|
||
<p class="delete-group-message">Choose which category to process</p>
|
||
<div class="delete-group-actions">
|
||
<button class="delete-group-btn delete-group-keep" id="ndc-albums">
|
||
💿 Albums & EPs <span style="opacity:0.5;margin-left:6px">${albumCount} tracks</span>
|
||
</button>
|
||
<button class="delete-group-btn delete-group-keep" id="ndc-singles" style="border-color: rgba(var(--accent-rgb), 0.15); background: rgba(var(--accent-rgb), 0.06);">
|
||
⭐ Singles <span style="opacity:0.5;margin-left:6px">${singleCount} tracks</span>
|
||
</button>
|
||
<button class="delete-group-btn delete-group-cancel" id="ndc-cancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
overlay.querySelector('#ndc-albums').onclick = () => { overlay.remove(); resolve('albums'); };
|
||
overlay.querySelector('#ndc-singles').onclick = () => { overlay.remove(); resolve('singles'); };
|
||
overlay.querySelector('#ndc-cancel').onclick = () => { overlay.remove(); resolve(null); };
|
||
|
||
document.addEventListener('keydown', function esc(e) {
|
||
if (e.key === 'Escape') { overlay.remove(); resolve(null); document.removeEventListener('keydown', esc); }
|
||
});
|
||
|
||
document.body.appendChild(overlay);
|
||
});
|
||
}
|
||
|
||
function _nebulaBack() {
|
||
const t = document.getElementById('wishlist-category-tracks');
|
||
const n = document.getElementById('wishlist-nebula');
|
||
if (t) t.style.display = 'none';
|
||
if (n) n.style.display = '';
|
||
window.selectedWishlistCategory = null;
|
||
wishlistPageState.isInitialized = false;
|
||
initializeWishlistPage();
|
||
}
|
||
|
||
// ── Live processing state for nebula ──
|
||
let _nebulaLivePollInterval = null;
|
||
let _nebulaLastTotal = null;
|
||
|
||
function _startNebulaLivePolling(currentCycle, artistImageMap) {
|
||
_stopNebulaLivePolling();
|
||
_nebulaLastTotal = null;
|
||
|
||
_nebulaLivePollInterval = setInterval(async () => {
|
||
if (currentPage !== 'wishlist') { _stopNebulaLivePolling(); return; }
|
||
|
||
try {
|
||
// Use wishlist stats which has is_auto_processing flag
|
||
const statsResp = await fetch('/api/wishlist/stats');
|
||
if (!statsResp.ok) return;
|
||
const stats = await statsResp.json();
|
||
const isProcessing = stats.is_auto_processing || false;
|
||
const newTotal = stats.total || 0;
|
||
|
||
// Also check for manual wishlist download batches
|
||
let hasBatch = false;
|
||
try {
|
||
const procResp = await fetch('/api/active-processes');
|
||
if (procResp.ok) {
|
||
const procData = await procResp.json();
|
||
hasBatch = (procData.active_processes || []).some(p => p.playlist_id === 'wishlist');
|
||
}
|
||
} catch (e) {}
|
||
|
||
const active = isProcessing || hasBatch;
|
||
const nebulaField = document.getElementById('wl-nebula-field');
|
||
if (!nebulaField) return;
|
||
|
||
if (active) {
|
||
nebulaField.classList.add('nebula-processing');
|
||
document.querySelectorAll('.wl-orb-group').forEach(g => g.classList.add('orb-processing'));
|
||
|
||
// Tracks completed — re-render
|
||
if (_nebulaLastTotal !== null && newTotal < _nebulaLastTotal) {
|
||
const [albumRes, singleRes] = await Promise.all([
|
||
fetch('/api/wishlist/tracks?category=albums').then(r => r.json()),
|
||
fetch('/api/wishlist/tracks?category=singles').then(r => r.json()),
|
||
]);
|
||
_renderWishlistNebula(albumRes.tracks || [], singleRes.tracks || [], artistImageMap, currentCycle);
|
||
|
||
const countEl = document.getElementById('wishlist-page-count');
|
||
if (countEl) countEl.textContent = `${newTotal} track${newTotal !== 1 ? 's' : ''}`;
|
||
const sa = document.getElementById('wishlist-stat-albums');
|
||
const ss = document.getElementById('wishlist-stat-singles');
|
||
if (sa) sa.textContent = stats.albums || 0;
|
||
if (ss) ss.textContent = stats.singles || 0;
|
||
|
||
// Re-add processing classes after re-render
|
||
document.getElementById('wl-nebula-field')?.classList.add('nebula-processing');
|
||
document.querySelectorAll('.wl-orb-group').forEach(g => g.classList.add('orb-processing'));
|
||
}
|
||
_nebulaLastTotal = newTotal;
|
||
} else {
|
||
nebulaField.classList.remove('nebula-processing');
|
||
document.querySelectorAll('.wl-orb-group.orb-processing').forEach(g => g.classList.remove('orb-processing'));
|
||
|
||
if (_nebulaLastTotal !== null) {
|
||
_nebulaLastTotal = null;
|
||
wishlistPageState.isInitialized = false;
|
||
await initializeWishlistPage();
|
||
await updateWishlistCount();
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}, 5000);
|
||
}
|
||
|
||
function _stopNebulaLivePolling() {
|
||
if (_nebulaLivePollInterval) {
|
||
clearInterval(_nebulaLivePollInterval);
|
||
_nebulaLivePollInterval = null;
|
||
}
|
||
_nebulaLastTotal = null;
|
||
}
|
||
|
||
/**
|
||
* Sort the watchlist artist grid by the selected criteria.
|
||
*/
|
||
function sortWatchlistArtists(sortBy) {
|
||
const grid = document.getElementById('watchlist-artists-list');
|
||
if (!grid) return;
|
||
const cards = Array.from(grid.querySelectorAll('.watchlist-artist-card'));
|
||
if (cards.length === 0) return;
|
||
|
||
cards.sort((a, b) => {
|
||
switch (sortBy) {
|
||
case 'name-asc':
|
||
return (a.dataset.artistName || '').localeCompare(b.dataset.artistName || '');
|
||
case 'name-desc':
|
||
return (b.dataset.artistName || '').localeCompare(a.dataset.artistName || '');
|
||
case 'scan-oldest': {
|
||
const aTime = a.dataset.lastScan ? new Date(a.dataset.lastScan).getTime() : 0;
|
||
const bTime = b.dataset.lastScan ? new Date(b.dataset.lastScan).getTime() : 0;
|
||
return aTime - bTime; // oldest first (never scanned = 0 = top)
|
||
}
|
||
case 'scan-newest': {
|
||
const aTime = a.dataset.lastScan ? new Date(a.dataset.lastScan).getTime() : 0;
|
||
const bTime = b.dataset.lastScan ? new Date(b.dataset.lastScan).getTime() : 0;
|
||
return bTime - aTime;
|
||
}
|
||
case 'added-newest': {
|
||
const aTime = a.dataset.added ? new Date(a.dataset.added).getTime() : 0;
|
||
const bTime = b.dataset.added ? new Date(b.dataset.added).getTime() : 0;
|
||
return bTime - aTime;
|
||
}
|
||
default:
|
||
return 0;
|
||
}
|
||
});
|
||
|
||
// Re-append in sorted order (preserves event listeners)
|
||
cards.forEach(card => grid.appendChild(card));
|
||
}
|
||
|
||
/**
|
||
* Filter wishlist tracks by search query within the active track list.
|
||
*/
|
||
function filterWishlistTracks() {
|
||
const input = document.getElementById('wishlist-track-search-input');
|
||
if (!input) return;
|
||
const query = input.value.toLowerCase().trim();
|
||
const tracksList = document.getElementById('wishlist-tracks-list');
|
||
if (!tracksList) return;
|
||
|
||
// For albums view: filter album cards by album name or track names within
|
||
const albumCards = tracksList.querySelectorAll('.wishlist-album-card');
|
||
if (albumCards.length > 0) {
|
||
albumCards.forEach(card => {
|
||
const albumHeader = card.querySelector('.wishlist-album-header');
|
||
const albumName = (albumHeader?.querySelector('.wishlist-album-name')?.textContent || '').toLowerCase();
|
||
const artistName = (albumHeader?.querySelector('.wishlist-album-artist')?.textContent || '').toLowerCase();
|
||
const tracks = card.querySelectorAll('.wishlist-album-track');
|
||
let albumHasMatch = !query || albumName.includes(query) || artistName.includes(query);
|
||
|
||
// Also check individual track names
|
||
if (!albumHasMatch && tracks.length > 0) {
|
||
tracks.forEach(track => {
|
||
const trackName = (track.textContent || '').toLowerCase();
|
||
if (trackName.includes(query)) albumHasMatch = true;
|
||
});
|
||
}
|
||
|
||
card.style.display = albumHasMatch ? '' : 'none';
|
||
});
|
||
return;
|
||
}
|
||
|
||
// For singles view: filter individual track rows
|
||
const trackRows = tracksList.querySelectorAll('.playlist-track-item-with-image, .playlist-track-item');
|
||
trackRows.forEach(row => {
|
||
const text = (row.textContent || '').toLowerCase();
|
||
row.style.display = (!query || text.includes(query)) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Format a Date object as a relative time string (e.g. "2 hours ago")
|
||
*/
|
||
function _formatTimeAgo(date) {
|
||
const now = new Date();
|
||
const diffMs = now - date;
|
||
const diffMins = Math.floor(diffMs / 60000);
|
||
if (diffMins < 1) return 'just now';
|
||
if (diffMins < 60) return `${diffMins}m ago`;
|
||
const diffHours = Math.floor(diffMins / 60);
|
||
if (diffHours < 24) return `${diffHours}h ago`;
|
||
const diffDays = Math.floor(diffHours / 24);
|
||
if (diffDays === 1) return 'yesterday';
|
||
if (diffDays < 7) return `${diffDays}d ago`;
|
||
return date.toLocaleDateString();
|
||
}
|
||
|
||
/**
|
||
* Show watchlist modal (legacy — kept for backward compatibility)
|
||
*/
|
||
async function showWatchlistModal() {
|
||
try {
|
||
// Check if watchlist has any artists
|
||
const countResponse = await fetch('/api/watchlist/count');
|
||
const countData = await countResponse.json();
|
||
|
||
if (!countData.success) {
|
||
console.error('Error getting watchlist count:', countData.error);
|
||
return;
|
||
}
|
||
|
||
if (countData.count === 0) {
|
||
// Show empty state message
|
||
alert('Your watchlist is empty!\n\nAdd artists to your watchlist from the Artists page to monitor them for new releases.');
|
||
return;
|
||
}
|
||
|
||
// Get watchlist artists
|
||
const artistsResponse = await fetch('/api/watchlist/artists');
|
||
const artistsData = await artistsResponse.json();
|
||
|
||
if (!artistsData.success) {
|
||
console.error('Error getting watchlist artists:', artistsData.error);
|
||
return;
|
||
}
|
||
|
||
// Create modal if it doesn't exist
|
||
let modal = document.getElementById('watchlist-modal');
|
||
if (!modal) {
|
||
modal = document.createElement('div');
|
||
modal.id = 'watchlist-modal';
|
||
modal.className = 'modal-overlay';
|
||
document.body.appendChild(modal);
|
||
}
|
||
|
||
// Get scan status and global config
|
||
const statusResponse = await fetch('/api/watchlist/scan/status');
|
||
const statusData = await statusResponse.json();
|
||
const scanStatus = statusData.success ? statusData.status : 'idle';
|
||
|
||
let globalOverrideActive = false;
|
||
try {
|
||
const globalConfigResponse = await fetch('/api/watchlist/global-config');
|
||
const globalConfigData = await globalConfigResponse.json();
|
||
globalOverrideActive = globalConfigData.success && globalConfigData.config.global_override_enabled;
|
||
} catch (e) {
|
||
console.debug('Could not fetch global config:', e);
|
||
}
|
||
|
||
// Format countdown timer
|
||
const nextRunSeconds = countData.next_run_in_seconds || 0;
|
||
const countdownText = formatCountdownTime(nextRunSeconds);
|
||
|
||
// Build modal content
|
||
modal.innerHTML = `
|
||
<div class="modal-container playlist-modal watchlist-fullscreen">
|
||
<div class="playlist-modal-header">
|
||
<div class="playlist-header-content" style="width: 100%;">
|
||
<h2><svg class="watchlist-header-icon" width="24" height="24" viewBox="0 0 24 24" fill="rgb(var(--accent-rgb))"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg> Watchlist</h2>
|
||
<div class="playlist-quick-info">
|
||
<span class="playlist-track-count">${countData.count} artist${countData.count !== 1 ? 's' : ''}</span>
|
||
<span class="playlist-owner" id="watchlist-next-auto-timer">Next Auto${countdownText ? ': ' + countdownText : ''}</span>
|
||
</div>
|
||
<div class="playlist-modal-sync-status" id="watchlist-scan-status" style="display: ${scanStatus !== 'idle' ? 'flex' : 'none'}; flex-direction: column; align-items: center;">
|
||
<!-- Live Visual Activity Display -->
|
||
<div id="watchlist-live-activity" class="watchlist-live-activity" style="display: ${scanStatus === 'scanning' ? 'flex' : 'none'};">
|
||
<!-- Artist Photo -->
|
||
<div class="watchlist-live-activity-col">
|
||
<img id="watchlist-artist-img" class="watchlist-live-activity-artist-img" src="" alt="Artist" onerror="this.style.display='none';" />
|
||
<div id="watchlist-artist-name" class="watchlist-live-activity-label">Waiting...</div>
|
||
</div>
|
||
|
||
<!-- Album Cover -->
|
||
<div class="watchlist-live-activity-col">
|
||
<img id="watchlist-album-img" class="watchlist-live-activity-album-img" src="" alt="Album" onerror="this.style.display='none';" />
|
||
<div id="watchlist-album-name" class="watchlist-live-activity-label">Waiting...</div>
|
||
</div>
|
||
|
||
<!-- Track and Wishlist Feed -->
|
||
<div class="watchlist-live-activity-feed">
|
||
<div class="watchlist-live-activity-feed-label">Current Track:</div>
|
||
<div id="watchlist-track-name" class="watchlist-live-activity-track">Waiting...</div>
|
||
|
||
<div class="watchlist-live-activity-feed-label-orange">✨ Recently Added:</div>
|
||
<div id="watchlist-additions-feed" style="max-height: 80px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; font-size: 10px;">
|
||
<!-- Populated by JavaScript -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${statusData.summary ? `
|
||
<div class="scan-status-summary" style="margin-top: 8px; font-size: 13px; opacity: 0.8;">
|
||
<span class="sync-stat">Artists: ${statusData.summary.total_artists || 0}</span>
|
||
<span class="sync-separator"> • </span>
|
||
<span class="sync-stat">New tracks: ${statusData.summary.new_tracks_found || 0}</span>
|
||
<span class="sync-separator"> • </span>
|
||
<span class="sync-stat">Added to wishlist: ${statusData.summary.tracks_added_to_wishlist || 0}</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
<span class="playlist-modal-close" onclick="closeWatchlistModal()">×</span>
|
||
</div>
|
||
|
||
<div class="playlist-modal-body">
|
||
<div class="watchlist-actions" style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center; padding: 12px 32px; flex-wrap: wrap;">
|
||
<button class="playlist-modal-btn playlist-modal-btn-primary watchlist-btn-scan ${scanStatus === 'scanning' ? 'btn-processing' : ''}"
|
||
id="scan-watchlist-btn"
|
||
onclick="startWatchlistScan()"
|
||
${scanStatus === 'scanning' ? 'disabled' : ''}>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||
${scanStatus === 'scanning' ? 'Scanning...' : 'Scan for New Releases'}
|
||
</button>
|
||
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-btn-cancel"
|
||
id="cancel-watchlist-scan-btn"
|
||
onclick="cancelWatchlistScan()"
|
||
style="display: ${scanStatus === 'scanning' ? '' : 'none'};">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||
Cancel Scan
|
||
</button>
|
||
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-btn-similar"
|
||
id="update-similar-artists-btn"
|
||
onclick="updateSimilarArtists()"
|
||
${scanStatus === 'scanning' ? 'disabled' : ''}>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||
Update Similar Artists
|
||
</button>
|
||
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-btn-settings ${globalOverrideActive ? 'watchlist-global-settings-active' : ''}"
|
||
id="watchlist-global-settings-btn"
|
||
onclick="openWatchlistGlobalSettingsModal()">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||
${globalOverrideActive ? 'Global Override ON' : 'Global Settings'}
|
||
</button>
|
||
</div>
|
||
|
||
${globalOverrideActive ? `
|
||
<div class="watchlist-global-override-banner" style="margin: 0 32px 12px;">
|
||
<span>⚠️</span>
|
||
<span>Global override is active — per-artist settings are being ignored during scans.</span>
|
||
</div>
|
||
` : ''}
|
||
|
||
<!-- Search Bar -->
|
||
<div class="watchlist-search-container" style="margin-bottom: 12px;">
|
||
<svg class="watchlist-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="rgba(255,255,255,0.35)"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||
<input type="text"
|
||
id="watchlist-search-input"
|
||
class="watchlist-search-input"
|
||
placeholder="Search watchlist..."
|
||
oninput="filterWatchlistArtists()">
|
||
</div>
|
||
|
||
<!-- Batch action bar -->
|
||
<div class="watchlist-batch-bar" id="watchlist-batch-bar">
|
||
<label class="watchlist-select-all-label" onclick="event.stopPropagation();">
|
||
<input type="checkbox" id="watchlist-select-all-cb" onchange="toggleWatchlistSelectAll(this.checked)">
|
||
<span>Select All</span>
|
||
</label>
|
||
<span class="watchlist-batch-count" id="watchlist-batch-count"></span>
|
||
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-batch-remove-btn"
|
||
id="watchlist-batch-remove-btn"
|
||
onclick="batchRemoveFromWatchlist()"
|
||
style="display: none;">
|
||
Remove Selected
|
||
</button>
|
||
</div>
|
||
|
||
<div class="watchlist-artists-grid" id="watchlist-artists-list">
|
||
${artistsData.artists.map(artist => {
|
||
const pills = [];
|
||
if (artist.include_albums) pills.push('<span class="watchlist-pill watchlist-pill-active">Albums</span>');
|
||
if (artist.include_eps) pills.push('<span class="watchlist-pill watchlist-pill-active">EPs</span>');
|
||
if (artist.include_singles) pills.push('<span class="watchlist-pill watchlist-pill-active">Singles</span>');
|
||
if (artist.include_live) pills.push('<span class="watchlist-pill watchlist-pill-filter">Live</span>');
|
||
if (artist.include_remixes) pills.push('<span class="watchlist-pill watchlist-pill-filter">Remixes</span>');
|
||
if (artist.include_acoustic) pills.push('<span class="watchlist-pill watchlist-pill-filter">Acoustic</span>');
|
||
if (artist.include_compilations) pills.push('<span class="watchlist-pill watchlist-pill-filter">Compilations</span>');
|
||
const sourceBadges = [];
|
||
if (artist.spotify_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-spotify">Spotify</span>');
|
||
if (artist.itunes_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-itunes">iTunes</span>');
|
||
if (artist.deezer_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-deezer">Deezer</span>');
|
||
if (artist.discogs_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-discogs">Discogs</span>');
|
||
if (artist.musicbrainz_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-musicbrainz">MusicBrainz</span>');
|
||
if (artist.amazon_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-amazon">Amazon</span>');
|
||
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.musicbrainz_artist_id || artist.amazon_artist_id;
|
||
return `
|
||
<div class="watchlist-artist-card"
|
||
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '"')}"
|
||
data-artist-id="${artistPrimaryId}">
|
||
|
||
<label class="watchlist-card-checkbox" onclick="event.stopPropagation();">
|
||
<input type="checkbox" class="watchlist-select-cb"
|
||
data-artist-id="${artistPrimaryId}"
|
||
data-artist-name="${escapeHtml(artist.artist_name)}"
|
||
onchange="updateWatchlistBatchBar()">
|
||
<span class="watchlist-checkbox-custom"></span>
|
||
</label>
|
||
|
||
<button class="watchlist-card-gear"
|
||
data-artist-id="${artistPrimaryId}"
|
||
data-artist-name="${escapeHtml(artist.artist_name)}"
|
||
onclick="event.stopPropagation();"
|
||
title="Artist settings">
|
||
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.48.48 0 00-.48-.41h-3.84a.48.48 0 00-.48.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87a.48.48 0 00.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.26.41.48.41h3.84c.24 0 .44-.17.48-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.6 3.6 0 0112 15.6z"/></svg>
|
||
</button>
|
||
|
||
<div class="watchlist-card-image">
|
||
${artist.image_url ? `
|
||
<img src="${artist.image_url}"
|
||
alt="${escapeHtml(artist.artist_name)}"
|
||
loading="lazy"
|
||
onerror="this.parentElement.innerHTML='<div class=\\'watchlist-card-image-fallback\\'>🎤</div>';">
|
||
` : `
|
||
<div class="watchlist-card-image-fallback">🎤</div>
|
||
`}
|
||
</div>
|
||
|
||
<div class="watchlist-card-info">
|
||
<span class="watchlist-card-name">${escapeHtml(artist.artist_name)}</span>
|
||
<span class="watchlist-card-meta">${formatRelativeScanTime(artist.last_scan_timestamp)}</span>
|
||
</div>
|
||
${sourceBadges.length > 0 ? `<div class="watchlist-card-sources">${sourceBadges.join('')}</div>` : ''}
|
||
${pills.length > 0 ? `<div class="watchlist-card-pills">${pills.join('')}</div>` : ''}
|
||
</div>
|
||
`}).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Add event listeners for gear buttons
|
||
modal.querySelectorAll('.watchlist-card-gear').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const artistId = button.getAttribute('data-artist-id');
|
||
const artistName = button.getAttribute('data-artist-name');
|
||
openWatchlistArtistConfigModal(artistId, artistName);
|
||
});
|
||
});
|
||
|
||
// Add click handlers to artist cards (except for gear button or checkbox)
|
||
modal.querySelectorAll('.watchlist-artist-card').forEach(item => {
|
||
item.addEventListener('click', (e) => {
|
||
if (e.target.closest('.watchlist-card-gear') || e.target.closest('.watchlist-card-checkbox')) {
|
||
return;
|
||
}
|
||
|
||
const artistId = item.getAttribute('data-artist-id');
|
||
const artistName = item.querySelector('.watchlist-card-name').textContent;
|
||
|
||
console.log(`🎵 Artist card clicked: ${artistName} (${artistId})`);
|
||
openWatchlistArtistDetailView(artistId, artistName);
|
||
});
|
||
});
|
||
|
||
// Show modal
|
||
modal.style.display = 'flex';
|
||
|
||
// Start countdown timer update interval
|
||
startWatchlistCountdownTimer(nextRunSeconds);
|
||
|
||
// Start polling for scan status if scanning
|
||
if (scanStatus === 'scanning') {
|
||
pollWatchlistScanStatus();
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error showing watchlist modal:', error);
|
||
}
|
||
}
|
||
|
||
function startWatchlistCountdownTimer(initialSeconds) {
|
||
// Clear any existing interval
|
||
if (watchlistCountdownInterval) {
|
||
clearInterval(watchlistCountdownInterval);
|
||
}
|
||
|
||
let remainingSeconds = initialSeconds;
|
||
const timerElement = document.getElementById('watchlist-next-auto-timer');
|
||
const updateTimerText = (seconds) => {
|
||
if (!timerElement) return;
|
||
const countdownText = seconds > 0 ? formatCountdownTime(seconds) : '';
|
||
timerElement.textContent = `Next Auto${countdownText ? ': ' + countdownText : ''}`;
|
||
};
|
||
|
||
// If there is no scheduled next run, don't keep polling the endpoint every second.
|
||
if (remainingSeconds <= 0) {
|
||
updateTimerText(0);
|
||
watchlistCountdownInterval = null;
|
||
return;
|
||
}
|
||
|
||
watchlistCountdownInterval = setInterval(async () => {
|
||
remainingSeconds--;
|
||
|
||
if (remainingSeconds <= 0) {
|
||
// Timer expired, fetch fresh data
|
||
try {
|
||
const response = await fetch('/api/watchlist/count');
|
||
const data = await response.json();
|
||
const nextRunSeconds = data.next_run_in_seconds || 0;
|
||
|
||
if (nextRunSeconds > 0) {
|
||
remainingSeconds = nextRunSeconds;
|
||
updateTimerText(remainingSeconds);
|
||
} else {
|
||
// No scheduled auto-run; stop polling until the page is refreshed
|
||
clearInterval(watchlistCountdownInterval);
|
||
watchlistCountdownInterval = null;
|
||
updateTimerText(0);
|
||
}
|
||
} catch (error) {
|
||
console.debug('Error updating watchlist countdown:', error);
|
||
}
|
||
} else {
|
||
// Update the display
|
||
updateTimerText(remainingSeconds);
|
||
}
|
||
}, 1000); // Update every second
|
||
}
|
||
|
||
/**
|
||
* Close watchlist modal
|
||
*/
|
||
function closeWatchlistModal() {
|
||
// Stop countdown timer
|
||
if (watchlistCountdownInterval) {
|
||
clearInterval(watchlistCountdownInterval);
|
||
watchlistCountdownInterval = null;
|
||
}
|
||
|
||
const modal = document.getElementById('watchlist-modal');
|
||
if (modal) {
|
||
modal.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Populate the linked provider section in the watchlist config modal.
|
||
* Shows which Spotify/iTunes/Deezer artist is linked and allows changing it.
|
||
*/
|
||
function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo, deezerId, discogsId, amazonId, musicbrainzId) {
|
||
const section = document.getElementById('watchlist-linked-provider-section');
|
||
const content = document.getElementById('watchlist-linked-provider-content');
|
||
if (!section || !content) return;
|
||
|
||
section.style.display = '';
|
||
|
||
const sources = [
|
||
{ key: 'spotify', label: 'Spotify', icon: '🟢', id: spotifyId || '', color: '#1db954' },
|
||
{ key: 'itunes', label: 'Apple Music', icon: '🔴', id: itunesId || '', color: '#fc3c44' },
|
||
{ key: 'deezer', label: 'Deezer', icon: '🟣', id: deezerId || '', color: '#a238ff' },
|
||
{ key: 'discogs', label: 'Discogs', icon: '🟤', id: discogsId || '', color: '#b08968' },
|
||
{ key: 'musicbrainz', label: 'MusicBrainz', icon: 'MB', id: musicbrainzId || '', color: '#ba478f' },
|
||
{ key: 'amazon', label: 'Amazon Music', icon: '🟠', id: amazonId || '', color: '#FF9900' },
|
||
];
|
||
|
||
let html = '<div class="wl-linked-sources">';
|
||
for (const src of sources) {
|
||
const matched = !!src.id;
|
||
const shortId = src.id ? (src.id.length > 16 ? src.id.substring(0, 14) + '...' : src.id) : '';
|
||
html += `
|
||
<div class="wl-linked-row ${matched ? 'matched' : 'unmatched'}" data-source="${src.key}">
|
||
<span class="wl-linked-icon">${src.icon}</span>
|
||
<span class="wl-linked-label">${src.label}</span>
|
||
<span class="wl-linked-status">${matched
|
||
? `<span class="wl-linked-id" title="${escapeHtml(src.id)}">${shortId}</span>`
|
||
: '<span class="wl-linked-none">Not matched</span>'
|
||
}</span>
|
||
<button class="wl-linked-fix-btn" onclick="_openSourceSearch('${src.key}', '${escapeForInlineJs(artistId)}', '${escapeForInlineJs(artistName)}')">${matched ? 'Fix' : 'Match'}</button>
|
||
${matched ? `<button class="wl-linked-clear-btn" onclick="_clearSourceMatch('${src.key}', '${escapeForInlineJs(artistId)}', '${escapeForInlineJs(artistName)}')" title="Clear this match">×</button>` : ''}
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
|
||
// Per-source search panel (hidden, populated on Fix/Match click)
|
||
html += `<div class="wl-linked-search-panel" id="wl-linked-search-panel" style="display:none">
|
||
<div class="wl-linked-search-header">
|
||
<span id="wl-linked-search-title">Search</span>
|
||
<button class="wl-linked-search-close" onclick="document.getElementById('wl-linked-search-panel').style.display='none'">×</button>
|
||
</div>
|
||
<div class="wl-linked-search-input-row">
|
||
<input type="text" id="wl-linked-search-input" class="watchlist-linked-search-input"
|
||
placeholder="Search..." value="${escapeHtml(artistName)}">
|
||
<button class="watchlist-linked-search-btn" id="wl-linked-search-go">Search</button>
|
||
</div>
|
||
<div class="wl-linked-search-results" id="wl-linked-search-results"></div>
|
||
</div>`;
|
||
|
||
content.innerHTML = html;
|
||
}
|
||
|
||
/**
|
||
* Open per-source search panel for fixing a specific provider match.
|
||
*/
|
||
function _openSourceSearch(sourceKey, artistId, artistName) {
|
||
const panel = document.getElementById('wl-linked-search-panel');
|
||
if (!panel) return;
|
||
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', amazon: 'Amazon Music', musicbrainz: 'MusicBrainz' };
|
||
document.getElementById('wl-linked-search-title').textContent = `Search ${labels[sourceKey] || sourceKey}`;
|
||
const input = document.getElementById('wl-linked-search-input');
|
||
input.value = artistName;
|
||
document.getElementById('wl-linked-search-results').innerHTML = '';
|
||
panel.style.display = '';
|
||
panel.dataset.source = sourceKey;
|
||
panel.dataset.artistId = artistId;
|
||
panel.dataset.artistName = artistName;
|
||
input.focus();
|
||
input.select();
|
||
|
||
const doSearch = () => _searchSourceArtists(sourceKey, artistId);
|
||
document.getElementById('wl-linked-search-go').onclick = doSearch;
|
||
input.onkeydown = (e) => { if (e.key === 'Enter') doSearch(); };
|
||
}
|
||
|
||
async function _searchSourceArtists(sourceKey, watchlistArtistId) {
|
||
const input = document.getElementById('wl-linked-search-input');
|
||
const container = document.getElementById('wl-linked-search-results');
|
||
const query = input?.value?.trim();
|
||
if (!query || !container) return;
|
||
|
||
container.innerHTML = '<div style="padding:12px;color:#888;text-align:center">Searching...</div>';
|
||
|
||
try {
|
||
const response = await fetch('/api/library/search-service', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ service: sourceKey, entity_type: 'artist', query })
|
||
});
|
||
const data = await response.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
|
||
const results = data.results || [];
|
||
if (!results.length) {
|
||
container.innerHTML = '<div style="padding:12px;color:#888;text-align:center">No artists found</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
for (const r of results) {
|
||
html += `<div class="watchlist-linked-search-result" data-id="${escapeHtml(r.id)}" data-name="${escapeHtml(r.name)}">
|
||
${r.image ? `<img src="${r.image}" alt="" class="watchlist-linked-result-img">` :
|
||
`<div class="watchlist-linked-result-img-placeholder">🎵</div>`}
|
||
<div class="watchlist-linked-result-info">
|
||
<div class="watchlist-linked-result-name">${escapeHtml(r.name)}</div>
|
||
<div class="watchlist-linked-result-meta">${escapeHtml(r.extra || '')}</div>
|
||
</div>
|
||
<button class="watchlist-linked-select-btn">Select</button>
|
||
</div>`;
|
||
}
|
||
container.innerHTML = html;
|
||
|
||
container.querySelectorAll('.watchlist-linked-search-result').forEach(el => {
|
||
el.querySelector('.watchlist-linked-select-btn').onclick = async (e) => {
|
||
e.stopPropagation();
|
||
await _linkSourceArtist(sourceKey, watchlistArtistId, el.dataset.id, el.dataset.name);
|
||
};
|
||
});
|
||
} catch (err) {
|
||
console.error(`Error searching ${sourceKey}:`, err);
|
||
container.innerHTML = '<div style="padding:12px;color:#f44;text-align:center">Search error</div>';
|
||
}
|
||
}
|
||
|
||
async function _linkSourceArtist(sourceKey, watchlistArtistId, newId, newName) {
|
||
try {
|
||
const response = await fetch(`/api/watchlist/artist/${watchlistArtistId}/link-provider`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ provider_id: newId, provider: sourceKey })
|
||
});
|
||
const data = await response.json();
|
||
if (!data.success) {
|
||
showToast(`Failed to link: ${data.error}`, 'error');
|
||
return;
|
||
}
|
||
showToast(`Linked to "${newName}" on ${sourceKey}`, 'success');
|
||
// Refresh the modal
|
||
const panel = document.getElementById('wl-linked-search-panel');
|
||
const artistName = panel?.dataset?.artistName || newName;
|
||
closeWatchlistArtistConfigModal();
|
||
setTimeout(() => openWatchlistArtistConfigModal(watchlistArtistId, artistName), 300);
|
||
} catch (err) {
|
||
showToast('Failed to link artist', 'error');
|
||
}
|
||
}
|
||
|
||
async function _clearSourceMatch(sourceKey, watchlistArtistId, artistName) {
|
||
try {
|
||
const response = await fetch(`/api/watchlist/artist/${watchlistArtistId}/link-provider`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ provider_id: '', provider: sourceKey })
|
||
});
|
||
const data = await response.json();
|
||
if (!data.success) {
|
||
showToast(`Failed to clear: ${data.error}`, 'error');
|
||
return;
|
||
}
|
||
showToast(`Cleared ${sourceKey} match`, 'success');
|
||
closeWatchlistArtistConfigModal();
|
||
setTimeout(() => openWatchlistArtistConfigModal(watchlistArtistId, artistName), 300);
|
||
} catch (err) {
|
||
showToast('Failed to clear match', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Open watchlist artist configuration modal
|
||
* @param {string} artistId - Spotify artist ID
|
||
* @param {string} artistName - Artist name
|
||
*/
|
||
async function openWatchlistArtistConfigModal(artistId, artistName) {
|
||
try {
|
||
console.log(`🎨 Opening config modal for artist: ${artistName} (${artistId})`);
|
||
|
||
// Fetch artist config and info
|
||
const response = await fetch(`/api/watchlist/artist/${artistId}/config`);
|
||
const data = await response.json();
|
||
|
||
if (!data.success) {
|
||
console.error('Error loading artist config:', data.error);
|
||
showToast(`Error loading artist configuration: ${data.error}`, 'error');
|
||
return;
|
||
}
|
||
|
||
const { config, artist, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, amazon_artist_id, musicbrainz_artist_id, watchlist_name } = data;
|
||
|
||
// Populate linked provider section (use DB watchlist_name for mismatch comparison)
|
||
_populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist, deezer_artist_id, discogs_artist_id, amazon_artist_id, musicbrainz_artist_id);
|
||
|
||
// Check if global override is active
|
||
let globalOverrideActive = false;
|
||
try {
|
||
const globalResponse = await fetch('/api/watchlist/global-config');
|
||
const globalData = await globalResponse.json();
|
||
globalOverrideActive = globalData.success && globalData.config.global_override_enabled;
|
||
} catch (e) {
|
||
console.debug('Could not check global config:', e);
|
||
}
|
||
|
||
// Generate hero section
|
||
const heroHTML = `
|
||
${artist.image_url ? `
|
||
<img src="${artist.image_url}"
|
||
alt="${escapeHtml(artist.name)}"
|
||
class="watchlist-artist-config-hero-image"
|
||
loading="lazy">
|
||
` : ''}
|
||
<div class="watchlist-artist-config-hero-info">
|
||
<h2 class="watchlist-artist-config-hero-name">${escapeHtml(artist.name)}</h2>
|
||
<div class="watchlist-artist-config-hero-stats">
|
||
<div class="watchlist-artist-config-stat">
|
||
<span class="watchlist-artist-config-stat-value">${formatNumber(artist.followers)}</span>
|
||
<span class="watchlist-artist-config-stat-label">Followers</span>
|
||
</div>
|
||
<div class="watchlist-artist-config-stat">
|
||
<span class="watchlist-artist-config-stat-value">${artist.popularity}/100</span>
|
||
<span class="watchlist-artist-config-stat-label">Popularity</span>
|
||
</div>
|
||
</div>
|
||
${artist.genres && artist.genres.length > 0 ? `
|
||
<div class="watchlist-artist-config-hero-genres">
|
||
${artist.genres.slice(0, 3).map(genre =>
|
||
`<span class="watchlist-artist-config-genre-tag">${escapeHtml(genre)}</span>`
|
||
).join('')}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
|
||
// Populate hero section
|
||
const heroContainer = document.getElementById('watchlist-artist-config-hero');
|
||
if (heroContainer) {
|
||
heroContainer.innerHTML = heroHTML;
|
||
}
|
||
|
||
// Set checkbox states
|
||
document.getElementById('config-include-albums').checked = config.include_albums;
|
||
document.getElementById('config-include-eps').checked = config.include_eps;
|
||
document.getElementById('config-include-singles').checked = config.include_singles;
|
||
document.getElementById('config-include-live').checked = config.include_live || false;
|
||
document.getElementById('config-include-remixes').checked = config.include_remixes || false;
|
||
document.getElementById('config-include-acoustic').checked = config.include_acoustic || false;
|
||
document.getElementById('config-include-compilations').checked = config.include_compilations || false;
|
||
document.getElementById('config-include-instrumentals').checked = config.include_instrumentals || false;
|
||
document.getElementById('config-lookback-days').value = config.lookback_days != null ? String(config.lookback_days) : '';
|
||
|
||
// Populate metadata source selector
|
||
const sourceSelector = document.getElementById('config-metadata-source-selector');
|
||
if (sourceSelector) {
|
||
const sources = [
|
||
{ key: 'spotify', label: 'Spotify', id: spotify_artist_id, color: '#1DB954' },
|
||
{ key: 'deezer', label: 'Deezer', id: deezer_artist_id, color: '#A238FF' },
|
||
{ key: 'itunes', label: 'Apple Music', id: itunes_artist_id, color: '#FC3C44' },
|
||
{ key: 'discogs', label: 'Discogs', id: discogs_artist_id, color: '#333' },
|
||
{ key: 'musicbrainz', label: 'MusicBrainz', id: musicbrainz_artist_id, color: '#BA478F' },
|
||
];
|
||
const globalSource = data.global_metadata_source || 'deezer';
|
||
const currentOverride = config.preferred_metadata_source;
|
||
const globalLabel = { spotify: 'Spotify', deezer: 'Deezer', itunes: 'Apple Music', discogs: 'Discogs', musicbrainz: 'MusicBrainz' }[globalSource] || globalSource;
|
||
|
||
let html = `<button class="config-msrc-btn ${!currentOverride ? 'active' : ''}" data-source="" title="Use global default (${globalLabel})">
|
||
<span class="config-msrc-icon">🌐</span><span class="config-msrc-label">Default (${globalLabel})</span>
|
||
</button>`;
|
||
for (const src of sources) {
|
||
if (!src.id) continue;
|
||
const isActive = currentOverride === src.key;
|
||
html += `<button class="config-msrc-btn ${isActive ? 'active' : ''}" data-source="${src.key}" style="${isActive ? 'border-color:' + src.color : ''}" title="${src.label}">
|
||
<span class="config-msrc-label">${src.label}</span>
|
||
</button>`;
|
||
}
|
||
sourceSelector.innerHTML = html;
|
||
sourceSelector.querySelectorAll('.config-msrc-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
sourceSelector.querySelectorAll('.config-msrc-btn').forEach(b => {
|
||
b.classList.remove('active');
|
||
b.style.borderColor = '';
|
||
});
|
||
btn.classList.add('active');
|
||
const color = sources.find(s => s.key === btn.dataset.source)?.color;
|
||
if (color) btn.style.borderColor = color;
|
||
});
|
||
});
|
||
}
|
||
|
||
// Show global override notice if active
|
||
const existingNotice = document.querySelector('.global-override-notice');
|
||
if (existingNotice) existingNotice.remove();
|
||
|
||
if (globalOverrideActive) {
|
||
const notice = document.createElement('div');
|
||
notice.className = 'global-override-notice watchlist-global-override-banner';
|
||
notice.innerHTML = '<span>⚠️</span><span>Global override is active — these per-artist settings are currently ignored during scans.</span>';
|
||
const configBody = document.querySelector('.watchlist-artist-config-body');
|
||
if (configBody) configBody.insertBefore(notice, configBody.firstChild);
|
||
}
|
||
|
||
// Store artist ID for saving
|
||
const modal = document.getElementById('watchlist-artist-config-modal');
|
||
if (modal) {
|
||
modal.setAttribute('data-artist-id', artistId);
|
||
}
|
||
|
||
// Show modal
|
||
const overlay = document.getElementById('watchlist-artist-config-modal-overlay');
|
||
if (overlay) {
|
||
overlay.classList.remove('hidden');
|
||
}
|
||
|
||
// Add save button handler
|
||
const saveBtn = document.getElementById('save-artist-config-btn');
|
||
if (saveBtn) {
|
||
// Remove old listeners
|
||
const newSaveBtn = saveBtn.cloneNode(true);
|
||
saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn);
|
||
|
||
// Add new listener
|
||
newSaveBtn.addEventListener('click', () => saveWatchlistArtistConfig(artistId));
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error opening watchlist artist config modal:', error);
|
||
showToast(`Error: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Close watchlist artist configuration modal
|
||
*/
|
||
function closeWatchlistArtistConfigModal() {
|
||
const overlay = document.getElementById('watchlist-artist-config-modal-overlay');
|
||
if (overlay) {
|
||
overlay.classList.add('hidden');
|
||
}
|
||
|
||
// Clear hero content
|
||
const heroContainer = document.getElementById('watchlist-artist-config-hero');
|
||
if (heroContainer) {
|
||
heroContainer.innerHTML = '';
|
||
}
|
||
|
||
// Clear linked provider section
|
||
const linkedContent = document.getElementById('watchlist-linked-provider-content');
|
||
if (linkedContent) linkedContent.innerHTML = '';
|
||
const linkedSection = document.getElementById('watchlist-linked-provider-section');
|
||
if (linkedSection) linkedSection.style.display = 'none';
|
||
}
|
||
|
||
/**
|
||
* Open watchlist artist detail view (slides in from right)
|
||
*/
|
||
async function openWatchlistArtistDetailView(artistId, artistName) {
|
||
try {
|
||
const response = await fetch(`/api/watchlist/artist/${artistId}/config`);
|
||
const data = await response.json();
|
||
|
||
if (!data.success) {
|
||
showToast(`Error loading artist info: ${data.error}`, 'error');
|
||
return;
|
||
}
|
||
|
||
const { config, artist, recent_releases, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, musicbrainz_artist_id } = data;
|
||
|
||
// Remove existing overlay if any
|
||
const existing = document.querySelector('.watchlist-artist-detail-overlay');
|
||
if (existing) existing.remove();
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'watchlist-artist-detail-overlay';
|
||
|
||
// Build pills
|
||
const pills = [];
|
||
if (config.include_albums) pills.push('<span class="watchlist-pill watchlist-pill-active">Albums</span>');
|
||
if (config.include_eps) pills.push('<span class="watchlist-pill watchlist-pill-active">EPs</span>');
|
||
if (config.include_singles) pills.push('<span class="watchlist-pill watchlist-pill-active">Singles</span>');
|
||
if (config.include_live) pills.push('<span class="watchlist-pill watchlist-pill-filter">Live</span>');
|
||
if (config.include_remixes) pills.push('<span class="watchlist-pill watchlist-pill-filter">Remixes</span>');
|
||
if (config.include_acoustic) pills.push('<span class="watchlist-pill watchlist-pill-filter">Acoustic</span>');
|
||
if (config.include_compilations) pills.push('<span class="watchlist-pill watchlist-pill-filter">Compilations</span>');
|
||
|
||
// Build scan info
|
||
const scanTimeText = config.last_scan_timestamp ? formatRelativeScanTime(config.last_scan_timestamp) : 'Never scanned';
|
||
const dateAddedText = config.date_added ? `Added ${new Date(config.date_added).toLocaleDateString()}` : '';
|
||
|
||
// Build metadata tags (style, mood, label)
|
||
const metaTags = [];
|
||
if (artist.style) metaTags.push(`<span class="watchlist-detail-genre-tag">${escapeHtml(artist.style)}</span>`);
|
||
if (artist.mood) metaTags.push(`<span class="watchlist-detail-genre-tag">${escapeHtml(artist.mood)}</span>`);
|
||
if (artist.label) metaTags.push(`<span class="watchlist-detail-genre-tag">${escapeHtml(artist.label)}</span>`);
|
||
|
||
let discogId = null;
|
||
let discogSource = null;
|
||
const activeSrc = (currentMusicSourceName || '').toLowerCase();
|
||
if (activeSrc.includes('spotify') && spotify_artist_id) {
|
||
discogId = spotify_artist_id; discogSource = 'spotify';
|
||
} else if (activeSrc.includes('discogs') && discogs_artist_id) {
|
||
discogId = discogs_artist_id; discogSource = 'discogs';
|
||
} else if (activeSrc.includes('deezer') && deezer_artist_id) {
|
||
discogId = deezer_artist_id; discogSource = 'deezer';
|
||
} else if (activeSrc.includes('musicbrainz') && musicbrainz_artist_id) {
|
||
discogId = musicbrainz_artist_id; discogSource = 'musicbrainz';
|
||
} else if (itunes_artist_id) {
|
||
discogId = itunes_artist_id; discogSource = 'itunes';
|
||
} else {
|
||
discogId = spotify_artist_id || discogs_artist_id || deezer_artist_id || musicbrainz_artist_id || itunes_artist_id;
|
||
discogSource = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : musicbrainz_artist_id ? 'musicbrainz' : 'itunes';
|
||
}
|
||
const discogHref = discogId ? buildArtistDetailPath(discogId, discogSource) : '#';
|
||
|
||
overlay.innerHTML = `
|
||
${artist.banner_url ? `
|
||
<div class="watchlist-detail-banner">
|
||
<img src="${artist.banner_url}" alt="" onerror="this.parentElement.remove();">
|
||
<div class="watchlist-detail-banner-fade"></div>
|
||
</div>
|
||
` : ''}
|
||
|
||
<div class="watchlist-detail-content ${artist.banner_url ? 'has-banner' : ''}">
|
||
<button class="watchlist-detail-back watchlist-detail-back-btn">
|
||
← Back to Watchlist
|
||
</button>
|
||
|
||
<div class="watchlist-detail-hero">
|
||
${artist.image_url ? `<img src="${artist.image_url}" alt="${escapeHtml(artist.name)}" onerror="this.style.display='none';">` : ''}
|
||
<div class="watchlist-detail-hero-info">
|
||
<h2 class="watchlist-detail-hero-name">${escapeHtml(artist.name)}</h2>
|
||
${artist.followers || artist.popularity ? `
|
||
<div class="watchlist-detail-hero-stats">
|
||
${artist.followers ? `
|
||
<div class="watchlist-detail-stat">
|
||
<span class="watchlist-detail-stat-value">${formatNumber(artist.followers)}</span>
|
||
<span class="watchlist-detail-stat-label">Followers</span>
|
||
</div>` : ''}
|
||
${artist.popularity ? `
|
||
<div class="watchlist-detail-stat">
|
||
<span class="watchlist-detail-stat-value">${artist.popularity}/100</span>
|
||
<span class="watchlist-detail-stat-label">Popularity</span>
|
||
</div>` : ''}
|
||
</div>
|
||
` : ''}
|
||
${artist.genres && artist.genres.length > 0 ? `
|
||
<div class="watchlist-detail-hero-genres">
|
||
${artist.genres.map(g => `<span class="watchlist-detail-genre-tag">${escapeHtml(g)}</span>`).join('')}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
|
||
${artist.summary ? `
|
||
<div class="watchlist-detail-section">
|
||
<div class="watchlist-detail-section-title">About</div>
|
||
<p class="watchlist-detail-bio">${escapeHtml(artist.summary)}</p>
|
||
</div>
|
||
` : ''}
|
||
|
||
${metaTags.length > 0 ? `
|
||
<div class="watchlist-detail-section">
|
||
<div class="watchlist-detail-section-title">Info</div>
|
||
<div class="watchlist-detail-hero-genres">${metaTags.join('')}</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
${recent_releases && recent_releases.length > 0 ? `
|
||
<div class="watchlist-detail-section">
|
||
<div class="watchlist-detail-section-title">Recent Releases</div>
|
||
<div class="watchlist-detail-releases">
|
||
${recent_releases.map(r => `
|
||
<div class="watchlist-detail-release">
|
||
${r.album_cover_url ? `<img src="${r.album_cover_url}" alt="" onerror="this.style.display='none';">` : ''}
|
||
<div class="watchlist-detail-release-info">
|
||
<span class="watchlist-detail-release-name">${escapeHtml(r.album_name)}</span>
|
||
<span class="watchlist-detail-release-meta">${r.release_date}${r.track_count ? ` · ${r.track_count} tracks` : ''}</span>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
<div class="watchlist-detail-section">
|
||
<div class="watchlist-detail-section-title">Watchlist</div>
|
||
<div class="watchlist-detail-watchlist-info">
|
||
<span class="watchlist-card-meta">${scanTimeText}</span>
|
||
${dateAddedText ? `<span class="watchlist-detail-info-sep">·</span><span class="watchlist-card-meta">${dateAddedText}</span>` : ''}
|
||
</div>
|
||
<div class="watchlist-card-pills" style="padding: 0; margin-top: 8px;">
|
||
${pills.length > 0 ? pills.join('') : '<span class="watchlist-card-meta">No release types enabled</span>'}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="watchlist-detail-actions">
|
||
<a class="watchlist-detail-discog-btn watchlist-detail-discog-action" href="${discogHref}" ${discogId ? 'onclick="closeWatchlistArtistDetailView()"' : 'aria-disabled="true" tabindex="-1" style="pointer-events:none;opacity:0.5;text-decoration:none;color:inherit;"'}>View Discography</a>
|
||
<button class="watchlist-detail-settings-btn watchlist-detail-settings-action">Settings</button>
|
||
<button class="watchlist-detail-remove-btn watchlist-detail-remove-action">Remove from Watchlist</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Wire up event listeners (avoids inline onclick escaping issues)
|
||
overlay.querySelector('.watchlist-detail-back-btn').addEventListener('click', () => {
|
||
closeWatchlistArtistDetailView();
|
||
});
|
||
|
||
overlay.querySelector('.watchlist-detail-settings-action').addEventListener('click', () => {
|
||
// Remove overlay immediately so it doesn't block the config modal
|
||
const detailOverlay = document.querySelector('.watchlist-artist-detail-overlay');
|
||
if (detailOverlay) detailOverlay.remove();
|
||
openWatchlistArtistConfigModal(artistId, artistName);
|
||
});
|
||
|
||
overlay.querySelector('.watchlist-detail-remove-action').addEventListener('click', () => {
|
||
removeFromWatchlistModal(artistId, artistName);
|
||
});
|
||
|
||
// Append to body as a fixed overlay
|
||
document.body.appendChild(overlay);
|
||
// Trigger slide-in animation
|
||
requestAnimationFrame(() => overlay.classList.add('visible'));
|
||
|
||
} catch (error) {
|
||
console.error('Error opening artist detail view:', error);
|
||
showToast(`Error: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Close watchlist artist detail view (slides out)
|
||
*/
|
||
function closeWatchlistArtistDetailView() {
|
||
const overlay = document.querySelector('.watchlist-artist-detail-overlay');
|
||
if (overlay) {
|
||
overlay.classList.remove('visible');
|
||
overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Open global watchlist settings modal
|
||
*/
|
||
async function openWatchlistGlobalSettingsModal() {
|
||
try {
|
||
const response = await fetch('/api/watchlist/global-config');
|
||
const data = await response.json();
|
||
|
||
if (!data.success) {
|
||
showToast(`Error loading global settings: ${data.error}`, 'error');
|
||
return;
|
||
}
|
||
|
||
const config = data.config;
|
||
|
||
// Populate checkboxes
|
||
document.getElementById('global-override-enabled').checked = config.global_override_enabled;
|
||
document.getElementById('global-include-albums').checked = config.include_albums;
|
||
document.getElementById('global-include-eps').checked = config.include_eps;
|
||
document.getElementById('global-include-singles').checked = config.include_singles;
|
||
document.getElementById('global-include-live').checked = config.include_live;
|
||
document.getElementById('global-include-remixes').checked = config.include_remixes;
|
||
document.getElementById('global-include-acoustic').checked = config.include_acoustic;
|
||
document.getElementById('global-include-compilations').checked = config.include_compilations;
|
||
document.getElementById('global-include-instrumentals').checked = config.include_instrumentals;
|
||
document.getElementById('global-exclude-terms').value = config.exclude_terms || '';
|
||
|
||
// Sync "Include Everything" checkbox
|
||
syncGlobalIncludeAllCheckbox();
|
||
|
||
// Update options visibility based on toggle state
|
||
toggleGlobalOverrideOptions();
|
||
|
||
// Update toggle label border
|
||
const toggleLabel = document.getElementById('global-override-toggle-label');
|
||
if (toggleLabel) {
|
||
toggleLabel.style.border = config.global_override_enabled
|
||
? '2px solid rgba(29, 185, 84, 0.5)'
|
||
: '2px solid rgba(255, 255, 255, 0.1)';
|
||
}
|
||
|
||
// 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';
|
||
}
|
||
|
||
// Update toggle label border
|
||
const toggleLabel = document.getElementById('global-override-toggle-label');
|
||
if (toggleLabel) {
|
||
toggleLabel.style.border = enabled
|
||
? '2px solid rgba(29, 185, 84, 0.5)'
|
||
: '2px solid rgba(255, 255, 255, 0.1)';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 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,
|
||
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;
|
||
button.textContent = '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');
|
||
}
|
||
|
||
button.textContent = '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;
|
||
button.textContent = 'Scan for New Releases';
|
||
button.classList.remove('btn-processing');
|
||
alert(`Error starting scan: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Poll watchlist scan status
|
||
*/
|
||
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 = 'flex';
|
||
|
||
// Update artist image and name
|
||
const artistImg = document.getElementById('watchlist-artist-img');
|
||
const artistName = document.getElementById('watchlist-artist-name');
|
||
if (artistImg && data.current_artist_image_url) {
|
||
artistImg.src = data.current_artist_image_url;
|
||
artistImg.style.display = 'block';
|
||
}
|
||
if (artistName) {
|
||
artistName.textContent = data.current_artist_name || 'Processing...';
|
||
}
|
||
|
||
// 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...' : 'Processing...');
|
||
}
|
||
|
||
// Update current track
|
||
const trackName = document.getElementById('watchlist-track-name');
|
||
if (trackName) {
|
||
trackName.textContent = data.current_track_name || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...');
|
||
}
|
||
|
||
// Update wishlist additions feed
|
||
const additionsFeed = document.getElementById('watchlist-additions-feed');
|
||
if (additionsFeed) {
|
||
if (data.recent_wishlist_additions && data.recent_wishlist_additions.length > 0) {
|
||
additionsFeed.innerHTML = data.recent_wishlist_additions.map(item => `
|
||
<div class="watchlist-live-addition-item">
|
||
<img src="${item.album_image_url || ''}" alt="" onerror="this.style.display='none';" />
|
||
<div class="watchlist-live-addition-item-info">
|
||
<div class="watchlist-live-addition-item-track">${item.track_name}</div>
|
||
<div class="watchlist-live-addition-item-artist">${item.artist_name}</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
additionsFeed.innerHTML = '<div class="watchlist-live-addition-empty">No tracks added yet...</div>';
|
||
}
|
||
}
|
||
} else if (liveActivity && data.status !== 'scanning') {
|
||
liveActivity.style.display = 'none';
|
||
}
|
||
|
||
if (data.status === 'completed') {
|
||
if (button) {
|
||
button.disabled = false;
|
||
button.textContent = 'Scan for New Releases';
|
||
button.classList.remove('btn-processing');
|
||
}
|
||
|
||
// Hide live activity
|
||
if (liveActivity) {
|
||
liveActivity.style.display = 'none';
|
||
}
|
||
|
||
// Show completion message in status div
|
||
const statusDiv = document.getElementById('watchlist-scan-status');
|
||
if (statusDiv && data.summary) {
|
||
const newTracks = data.summary.new_tracks_found || 0;
|
||
const addedTracks = data.summary.tracks_added_to_wishlist || 0;
|
||
const totalArtists = data.summary.total_artists || 0;
|
||
const successfulScans = data.summary.successful_scans || 0;
|
||
|
||
let completionMessage = `Scan completed: ${successfulScans}/${totalArtists} artists scanned`;
|
||
if (newTracks > 0) {
|
||
completionMessage += `, found ${newTracks} new track${newTracks !== 1 ? 's' : ''}`;
|
||
if (addedTracks > 0) {
|
||
completionMessage += `, added ${addedTracks} to wishlist`;
|
||
}
|
||
} else {
|
||
completionMessage += ', no new tracks found';
|
||
}
|
||
|
||
// Update the scan status display with completion message and summary
|
||
statusDiv.innerHTML = `
|
||
<div class="watchlist-scan-completion">
|
||
<div class="watchlist-scan-completion-message">${completionMessage}</div>
|
||
<div style="font-size: 13px; opacity: 0.8;">
|
||
<span class="sync-stat">Artists: ${totalArtists}</span>
|
||
<span class="sync-separator"> • </span>
|
||
<span class="sync-stat">New tracks: ${newTracks}</span>
|
||
<span class="sync-separator"> • </span>
|
||
<span class="sync-stat">Added to wishlist: ${addedTracks}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Update watchlist count
|
||
updateWatchlistButtonCount();
|
||
|
||
console.log('Watchlist scan completed:', data.summary);
|
||
|
||
} else if (data.status === 'cancelled') {
|
||
if (button) {
|
||
button.disabled = false;
|
||
button.textContent = 'Scan for New Releases';
|
||
button.classList.remove('btn-processing');
|
||
}
|
||
|
||
// Hide cancel button
|
||
const cancelBtn = document.getElementById('cancel-watchlist-scan-btn');
|
||
if (cancelBtn) {
|
||
cancelBtn.style.display = 'none';
|
||
cancelBtn.disabled = false;
|
||
cancelBtn.textContent = 'Cancel Scan';
|
||
}
|
||
|
||
// Hide live activity
|
||
if (liveActivity) {
|
||
liveActivity.style.display = 'none';
|
||
}
|
||
|
||
// Show cancellation message
|
||
const statusDiv = document.getElementById('watchlist-scan-status');
|
||
if (statusDiv && data.summary) {
|
||
const scanned = data.summary.total_artists || 0;
|
||
const newTracks = data.summary.new_tracks_found || 0;
|
||
const addedTracks = data.summary.tracks_added_to_wishlist || 0;
|
||
|
||
statusDiv.innerHTML = `
|
||
<div class="watchlist-scan-completion">
|
||
<div class="watchlist-scan-completion-message">Scan cancelled after ${scanned} artist${scanned !== 1 ? 's' : ''}</div>
|
||
<div style="font-size: 13px; opacity: 0.8;">
|
||
<span class="sync-stat">Scanned: ${scanned}</span>
|
||
<span class="sync-separator"> • </span>
|
||
<span class="sync-stat">New tracks: ${newTracks}</span>
|
||
<span class="sync-separator"> • </span>
|
||
<span class="sync-stat">Added to wishlist: ${addedTracks}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Update watchlist count
|
||
updateWatchlistButtonCount();
|
||
|
||
showToast('Watchlist scan cancelled', 'info');
|
||
console.log('Watchlist scan cancelled:', data.summary);
|
||
|
||
} else if (data.status === 'error') {
|
||
if (button) {
|
||
button.disabled = false;
|
||
button.textContent = '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;
|
||
button.textContent = '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;
|
||
button.textContent = '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;
|
||
button.textContent = '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;
|
||
button.textContent = '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;
|
||
button.textContent = '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
|
||
// ===============================
|