soulsync/webui/static/enrichment-manager.js
BoulderBadgeDad 0b3c3f656d Add Manage Enrichment Workers modal (v1 + polish)
Dashboard 'enrichment bubbles' could pause/hover but offered no way to
*manage* a worker. This adds a full management modal opened from a new
header button, covering all 11 enrichment sources.

Backend (testable core helper + seam tests; no live-DB dependency):
- core/enrichment/unmatched.py: pure, whitelisted SQL builders for the
  unmatched browser. service/entity validated against a support map (never
  interpolated raw); search + pagination bound as params; tracks join albums
  for artwork; limit capped at 200.
- database/music_database.py: get_enrichment_unmatched() +
  get_enrichment_breakdown() (the breakdown splits matched/not_found/pending,
  which the existing get_stats().progress lumps together).
- core/enrichment/api.py: GET /api/enrichment/<id>/{unmatched,breakdown} on
  the existing blueprint + a db_getter hook.
- web_server.py: wire db_getter=get_database.
- tests/enrichment/test_unmatched.py: 19 tests across builders, DB methods,
  and Flask routes.

Frontend (vanilla, matches app conventions):
- webui/static/enrichment-manager.js: worker rail with live status + coverage
  micro-bars, accent-themed detail panel (hero header, segmented matched/
  not_found/pending stat cards, current item, pause/resume), and a searchable
  paginated unmatched browser with inline manual match (reusing
  search-service + manual-match) and retry (clear-match re-queues).
- Polish: entrance/exit motion, scroll-lock, Escape, refresh control,
  flicker-free polling (in-place updates), skeleton loaders, relative
  timestamps, per-worker accent theming, real dashboard logos reused at
  runtime (with the same invert/circle treatment), responsive rail.
- index.html: header button + script include. style.css: full styling.

Reuses existing pause/resume, status, and manual search+assign endpoints.
Backend tests green (19 new + 11 existing enrichment tests).
2026-06-02 19:06:44 -07:00

816 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/*
* Manage Enrichment Workers modal.
*
* The dashboard "enrichment bubbles" expose hover/pause but no way to *manage*
* a worker. This modal surfaces, per worker: live status + current item,
* pause/resume, a matched/not-found/pending breakdown per entity type, and a
* searchable/paginated browser of the items that source hasn't matched — each
* with inline manual-match (reusing /api/library/search-service +
* manual-match) and retry (clear-match, which re-queues the item).
*
* Backend: GET /api/enrichment/<id>/{status,breakdown,unmatched}, POST
* .../{pause,resume}. The unmatched/breakdown routes are generic across all 11
* workers (see core/enrichment/unmatched.py).
*/
// Per-source accent + the CSS selector of that worker's logo already rendered
// in the dashboard bubble. We reuse those exact <img> sources at runtime
// (via _emLogoSrc) so the modal shows the real logos — including AudioDB's
// inline base64 — and stays in sync if the dashboard logos ever change.
// imgFilter / imgRound mirror the per-logo CSS the dashboard bubbles apply, so
// black-on-dark icons (Discogs/Tidal/Qobuz/Amazon) get inverted to white and
// square logos (Last.fm) clip to a circle here too.
const ENRICHMENT_WORKERS = [
{ id: 'spotify', name: 'Spotify', color: '#1db954', logoSel: '.spotify-enrich-logo' },
{ id: 'itunes', name: 'iTunes', color: '#fb5bc5', logoSel: '.itunes-enrich-logo' },
{ id: 'musicbrainz', name: 'MusicBrainz', color: '#ba55d3', logoSel: '.mb-logo' },
{ id: 'deezer', name: 'Deezer', color: '#a238ff', logoSel: '.deezer-logo' },
{ id: 'audiodb', name: 'AudioDB', color: '#1c8cf0', logoSel: '.audiodb-logo' },
{ id: 'discogs', name: 'Discogs', color: '#cfcfcf', logoSel: '.discogs-logo', imgFilter: 'brightness(0) invert(1)' },
{ id: 'lastfm', name: 'Last.fm', color: '#d51007', logoSel: '.lastfm-enrich-logo', imgRound: true },
{ id: 'genius', name: 'Genius', color: '#ffe600', logoSel: '.genius-enrich-logo' },
{ id: 'tidal', name: 'Tidal', color: '#00cfe6', logoSel: '.tidal-enrich-logo', imgFilter: 'invert(1) brightness(1.8)', imgRound: true },
{ id: 'qobuz', name: 'Qobuz', color: '#0070ef', logoSel: '.qobuz-enrich-logo', imgFilter: 'invert(1)', imgRound: true },
{ id: 'amazon', name: 'Amazon Music', color: '#ff9900', logoSel: '.amazon-enrich-logo', imgFilter: 'brightness(0) invert(1)' },
];
const _emWorkerById = Object.fromEntries(ENRICHMENT_WORKERS.map(w => [w.id, w]));
// '#1db954' -> '29,185,84' for rgba(var(--em-accent-rgb), a) usage.
function _emHexToRgb(hex) {
const h = String(hex || '').replace('#', '');
const full = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
const n = parseInt(full, 16);
if (isNaN(n) || full.length !== 6) return '120,120,120';
return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`;
}
// Resolve a worker's logo URL from the live dashboard bubble (null if absent).
function _emLogoSrc(workerId) {
const w = _emWorkerById[workerId];
if (!w || !w.logoSel) return null;
const img = document.querySelector(w.logoSel);
return img && img.src ? img.src : null;
}
// A circular, glowing icon chip mirroring the dashboard bubbles. Falls back to
// a colored initial if the logo is missing or fails to load.
function _emIconHtml(workerId, size) {
const w = _emWorkerById[workerId];
const src = _emLogoSrc(workerId);
const cls = `em-icon${size === 'lg' ? ' em-icon--lg' : ''}`;
const initial = w.name.charAt(0).toUpperCase();
const imgStyle = [
w.imgFilter ? `filter:${w.imgFilter}` : '',
w.imgRound ? 'border-radius:50%' : '',
].filter(Boolean).join(';');
const inner = src
? `<img src="${_emEscape(src)}" alt="" class="em-icon-img"${imgStyle ? ` style="${imgStyle}"` : ''}
onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'em-icon-letter',textContent:'${initial}'}))">`
: `<span class="em-icon-letter">${initial}</span>`;
return `<span class="${cls}" style="--em-accent:${w.color}">${inner}</span>`;
}
const enrichmentManagerState = {
open: false,
selected: null,
statuses: {}, // id -> last /status payload
breakdown: null, // selected worker's breakdown
entityTab: 'artist',
statusFilter: 'unmatched',
search: '',
page: 0,
pageSize: 25,
unmatched: null, // { total, items }
pollTimer: null,
loadToken: 0, // guards against out-of-order async renders
};
function _emEntityLabel(entity, plural) {
const map = { artist: 'Artist', album: 'Album', track: 'Track' };
const base = map[entity] || entity;
return plural ? base + 's' : base;
}
function _emEscape(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
// Human "3 days ago" for a SQLite timestamp; '' when never attempted.
function _emRelativeTime(value) {
if (!value) return '';
// SQLite stores 'YYYY-MM-DD HH:MM:SS' (UTC) — normalize to ISO.
const ts = Date.parse(String(value).replace(' ', 'T') + (String(value).includes('Z') ? '' : 'Z'));
if (isNaN(ts)) return '';
const secs = Math.max(0, (Date.now() - ts) / 1000);
if (secs < 60) return 'just now';
const mins = secs / 60;
if (mins < 60) return `${Math.floor(mins)}m ago`;
const hrs = mins / 60;
if (hrs < 24) return `${Math.floor(hrs)}h ago`;
const days = hrs / 24;
if (days < 30) return `${Math.floor(days)}d ago`;
const months = days / 30;
if (months < 12) return `${Math.floor(months)}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
// ── Open / close ──────────────────────────────────────────────────────────
async function openEnrichmentManager() {
let overlay = document.getElementById('enrichment-manager-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'enrichment-manager-overlay';
overlay.className = 'modal-overlay em-overlay hidden';
overlay.onclick = (e) => { if (e.target === overlay) closeEnrichmentManager(); };
overlay.innerHTML = `
<div class="enrichment-manager-modal" role="dialog" aria-modal="true"
aria-label="Manage Enrichment Workers" tabindex="-1">
<div class="enhanced-bulk-modal-header">
<h3>🧬 Manage Enrichment Workers</h3>
<div class="em-header-actions">
<button class="em-icon-btn" id="em-refresh-btn" title="Refresh"
onclick="refreshEnrichmentManager(this)">⟳</button>
<button class="enhanced-bulk-modal-close" onclick="closeEnrichmentManager()">&times;</button>
</div>
</div>
<div class="em-body">
<div class="em-rail" id="em-rail"></div>
<div class="em-panel" id="em-panel"></div>
</div>
</div>`;
document.body.appendChild(overlay);
}
overlay.classList.remove('hidden', 'em-closing');
// Re-trigger the entrance animation even when reusing the element.
const modal = overlay.querySelector('.enrichment-manager-modal');
if (modal) { modal.classList.remove('em-in'); void modal.offsetWidth; modal.classList.add('em-in'); }
document.body.classList.add('em-scroll-lock');
document.addEventListener('keydown', _emOnKeydown);
enrichmentManagerState.open = true;
await refreshAllEnrichmentStatuses();
renderEnrichmentRail();
// Default selection: first running worker, else first in the list.
const running = ENRICHMENT_WORKERS.find(
w => enrichmentManagerState.statuses[w.id]?.running
);
selectEnrichmentWorker((running || ENRICHMENT_WORKERS[0]).id);
if (modal) setTimeout(() => modal.focus(), 60);
if (enrichmentManagerState.pollTimer) clearInterval(enrichmentManagerState.pollTimer);
enrichmentManagerState.pollTimer = setInterval(_emPollSelected, 3000);
}
function closeEnrichmentManager() {
const overlay = document.getElementById('enrichment-manager-overlay');
enrichmentManagerState.open = false;
document.removeEventListener('keydown', _emOnKeydown);
document.body.classList.remove('em-scroll-lock');
if (enrichmentManagerState.pollTimer) {
clearInterval(enrichmentManagerState.pollTimer);
enrichmentManagerState.pollTimer = null;
}
if (!overlay) return;
// Brief fade/scale-out, then hide.
overlay.classList.add('em-closing');
setTimeout(() => {
overlay.classList.add('hidden');
overlay.classList.remove('em-closing');
}, 170);
}
// Escape closes the nested match overlay first (if open), else the manager.
function _emOnKeydown(e) {
if (e.key !== 'Escape') return;
const match = document.getElementById('enrichment-match-overlay');
if (match) { match.remove(); return; }
closeEnrichmentManager();
}
// Manual refresh: re-pull every worker's status + the selected worker's data.
async function refreshEnrichmentManager(btn) {
if (btn) btn.classList.add('em-spinning');
await refreshAllEnrichmentStatuses();
renderEnrichmentRail();
const sel = enrichmentManagerState.selected;
if (sel) await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]);
_emRenderStats();
_emRenderUnmatchedList();
_emRenderPanelHeader();
if (btn) setTimeout(() => btn.classList.remove('em-spinning'), 400);
}
// ── Status loading ──────────────────────────────────────────────────────────
async function refreshAllEnrichmentStatuses() {
const results = await Promise.all(ENRICHMENT_WORKERS.map(async (w) => {
try {
const res = await fetch(`/api/enrichment/${w.id}/status`);
return [w.id, res.ok ? await res.json() : null];
} catch (_e) {
return [w.id, null];
}
}));
for (const [id, status] of results) enrichmentManagerState.statuses[id] = status;
}
async function _emPollSelected() {
const id = enrichmentManagerState.selected;
if (!id || !enrichmentManagerState.open) return;
try {
const res = await fetch(`/api/enrichment/${id}/status`);
if (res.ok) {
enrichmentManagerState.statuses[id] = await res.json();
_emUpdateHeaderLive(); // in-place — no logo reflow/flicker
_emUpdateRailRow(id);
}
} catch (_e) { /* transient — keep last */ }
}
function _emStatusInfo(status) {
if (!status || !status.enabled) return { cls: 'disabled', label: 'Disabled' };
if (status.rate_limited) return { cls: 'ratelimited', label: 'Rate-limited' };
if (status.paused) return { cls: 'paused', label: 'Paused' };
if (status.idle) return { cls: 'idle', label: 'Idle' };
if (status.running) return { cls: 'running', label: 'Running' };
return { cls: 'stopped', label: 'Stopped' };
}
// ── Left rail ───────────────────────────────────────────────────────────────
// Overall library coverage (% of items this source has attempted) from the
// status payload's progress block — a cheap at-a-glance rail signal.
function _emOverallPct(status) {
const p = status && status.progress;
if (!p) return null;
let matched = 0, total = 0;
for (const k of ['artists', 'albums', 'tracks']) {
if (p[k]) { matched += p[k].matched || 0; total += p[k].total || 0; }
}
return total ? Math.round((matched / total) * 100) : 0;
}
function renderEnrichmentRail() {
const rail = document.getElementById('em-rail');
if (!rail) return;
rail.innerHTML = ENRICHMENT_WORKERS.map(w => {
const status = enrichmentManagerState.statuses[w.id];
const info = _emStatusInfo(status);
const pct = _emOverallPct(status);
const cov = pct == null ? '' : `
<span class="em-rail-cov"><span class="em-rail-cov-fill" style="width:${pct}%"></span></span>`;
return `
<button class="em-worker-row" id="em-row-${w.id}"
onclick="selectEnrichmentWorker('${w.id}')">
${_emIconHtml(w.id)}
<span class="em-worker-meta">
<span class="em-worker-name">${_emEscape(w.name)}</span>
<span class="em-worker-sub">${info.label}${pct == null ? '' : ` · ${pct}%`}</span>
${cov}
</span>
<span class="em-dot em-dot--${info.cls}" title="${info.label}"></span>
</button>`;
}).join('');
_emHighlightRail();
}
function _emHighlightRail() {
ENRICHMENT_WORKERS.forEach(w => {
const row = document.getElementById(`em-row-${w.id}`);
if (row) row.classList.toggle('active', w.id === enrichmentManagerState.selected);
});
}
function _emUpdateRailRow(id) {
const row = document.getElementById(`em-row-${id}`);
if (!row) return;
const status = enrichmentManagerState.statuses[id];
const info = _emStatusInfo(status);
const pct = _emOverallPct(status);
const dot = row.querySelector('.em-dot');
if (dot) { dot.className = `em-dot em-dot--${info.cls}`; dot.title = info.label; }
const sub = row.querySelector('.em-worker-sub');
if (sub) sub.textContent = `${info.label}${pct == null ? '' : ` · ${pct}%`}`;
const cov = row.querySelector('.em-rail-cov-fill');
if (cov && pct != null) cov.style.width = `${pct}%`;
}
// ── Worker selection ──────────────────────────────────────────────────────────
async function selectEnrichmentWorker(id) {
enrichmentManagerState.selected = id;
enrichmentManagerState.breakdown = null;
enrichmentManagerState.unmatched = null;
enrichmentManagerState.search = '';
enrichmentManagerState.page = 0;
enrichmentManagerState.statusFilter = 'unmatched';
_emHighlightRail();
// Pick a default entity tab the worker actually supports (filled after the
// unmatched call returns entity_types; default to artist meanwhile).
enrichmentManagerState.entityTab = 'artist';
renderEnrichmentPanel();
await Promise.all([_emLoadBreakdown(id), _emLoadUnmatched()]);
renderEnrichmentPanel();
}
async function _emLoadBreakdown(id) {
try {
const res = await fetch(`/api/enrichment/${id}/breakdown`);
enrichmentManagerState.breakdown = res.ok ? (await res.json()).breakdown : null;
} catch (_e) {
enrichmentManagerState.breakdown = null;
}
}
async function _emLoadUnmatched() {
const id = enrichmentManagerState.selected;
const token = ++enrichmentManagerState.loadToken;
const { entityTab, statusFilter, search, page, pageSize } = enrichmentManagerState;
const params = new URLSearchParams({
entity_type: entityTab,
status: statusFilter,
limit: String(pageSize),
offset: String(page * pageSize),
});
if (search) params.set('q', search);
try {
const res = await fetch(`/api/enrichment/${id}/unmatched?${params}`);
const data = res.ok ? await res.json() : { total: 0, items: [] };
if (token !== enrichmentManagerState.loadToken) return; // stale
enrichmentManagerState.unmatched = data;
} catch (_e) {
if (token === enrichmentManagerState.loadToken) {
enrichmentManagerState.unmatched = { total: 0, items: [] };
}
}
}
// ── Detail panel ──────────────────────────────────────────────────────────────
function renderEnrichmentPanel() {
const panel = document.getElementById('em-panel');
if (!panel) return;
const id = enrichmentManagerState.selected;
const worker = _emWorkerById[id];
if (!worker) { panel.innerHTML = ''; return; }
// Theme the whole panel to the selected worker's accent colour.
panel.style.setProperty('--em-accent', worker.color);
panel.style.setProperty('--em-accent-rgb', _emHexToRgb(worker.color));
panel.innerHTML = `
<div class="em-panel-header" id="em-panel-header"></div>
<div class="em-section-label">Enrichment coverage</div>
<div class="em-stats" id="em-stats"></div>
<div class="em-unmatched">
<div class="em-unmatched-controls" id="em-unmatched-controls"></div>
<div class="em-unmatched-list" id="em-unmatched-list"></div>
<div class="em-pager" id="em-pager"></div>
</div>`;
_emRenderPanelHeader();
_emRenderStats();
_emRenderUnmatchedControls();
_emRenderUnmatchedList();
}
function _emRenderPanelHeader() {
const host = document.getElementById('em-panel-header');
if (!host) return;
const id = enrichmentManagerState.selected;
const worker = _emWorkerById[id];
// Structure is rendered once per worker selection; the live bits below
// (pill / current-item / errors / toggle) are updated in place by
// _emUpdateHeaderLive on each poll so the logo never reflows or flickers.
host.innerHTML = `
<div class="em-hero">
<div class="em-hero-glow"></div>
${_emIconHtml(id, 'lg')}
<div class="em-ph-titles">
<div class="em-ph-name">${_emEscape(worker.name)} <span class="em-ph-name-sub">enrichment</span></div>
<div class="em-ph-sub" id="em-ph-current"></div>
</div>
<div class="em-hero-metric" id="em-ph-metric"></div>
<div class="em-ph-actions">
<span class="em-pill" id="em-ph-pill"></span>
<span id="em-ph-budget"></span>
<span id="em-ph-errors"></span>
<button class="em-btn" id="em-ph-toggle" onclick="toggleEnrichmentWorker('${id}')"></button>
</div>
</div>`;
_emUpdateHeaderLive();
}
function _emUpdateHeaderLive() {
const id = enrichmentManagerState.selected;
const status = enrichmentManagerState.statuses[id];
const info = _emStatusInfo(status);
const pill = document.getElementById('em-ph-pill');
if (pill) { pill.className = `em-pill em-pill--${info.cls}`; pill.textContent = info.label; }
const metric = document.getElementById('em-ph-metric');
if (metric) {
const pct = _emOverallPct(status);
metric.innerHTML = pct == null
? ''
: `<span class="em-hero-pct">${pct}<span class="em-hero-pct-sym">%</span></span>
<span class="em-hero-pct-label">enriched</span>`;
}
const cur = document.getElementById('em-ph-current');
if (cur) {
const item = status && status.current_item;
cur.innerHTML = item
? `Now enriching: <strong>${_emEscape(item.name || '')}</strong>${item.type ? ` <span class="em-muted">(${_emEscape(item.type)})</span>` : ''}`
: '<span class="em-muted">No item processing</span>';
}
const budgetEl = document.getElementById('em-ph-budget');
if (budgetEl) {
const b = status && status.daily_budget;
budgetEl.innerHTML = (b && b.limit)
? `<span class="em-chip" title="Daily API budget">Budget ${b.used ?? '?'} / ${b.limit}</span>` : '';
}
const errEl = document.getElementById('em-ph-errors');
if (errEl) {
const errors = (status && status.stats && status.stats.errors) || 0;
errEl.innerHTML = errors ? `<span class="em-chip em-chip--err" title="Errors this run">⚠ ${errors}</span>` : '';
}
const toggle = document.getElementById('em-ph-toggle');
if (toggle) {
const isPaused = status && status.paused;
toggle.disabled = !(status && status.enabled);
toggle.classList.toggle('em-btn--go', !!isPaused);
toggle.textContent = isPaused ? '▶ Resume' : '⏸ Pause';
}
}
function _emRenderStats() {
const host = document.getElementById('em-stats');
if (!host) return;
const bd = enrichmentManagerState.breakdown;
if (!bd) {
// Skeleton cards (count unknown yet — 3 covers the common case).
host.innerHTML = Array.from({ length: 3 }, () => `
<div class="em-stat-card em-skel-card">
<div class="em-skel em-skel-line" style="width:40%"></div>
<div class="em-skel em-skel-bar"></div>
<div class="em-skel em-skel-line" style="width:75%"></div>
</div>`).join('');
return;
}
const glyphs = { artist: '🎤', album: '💿', track: '🎵' };
host.innerHTML = Object.keys(bd).map(entity => {
const d = bd[entity] || {};
const total = d.total || 0;
const matched = d.matched || 0;
const notFound = d.not_found || 0;
const pending = d.pending || 0;
const pct = total ? Math.round((matched / total) * 100) : 0;
const seg = (n) => (total ? (n / total) * 100 : 0);
return `
<div class="em-stat-card">
<div class="em-stat-head">
<span class="em-stat-title"><span class="em-stat-ico">${glyphs[entity] || '•'}</span>${_emEntityLabel(entity, true)}</span>
<span class="em-stat-pct">${pct}<span class="em-stat-pct-sym">%</span></span>
</div>
<div class="em-seg" title="${matched.toLocaleString()} matched · ${notFound.toLocaleString()} not found · ${pending.toLocaleString()} pending">
<div class="em-seg-fill em-seg--matched" data-pct="${seg(matched)}" style="width:0%"></div>
<div class="em-seg-fill em-seg--nf" data-pct="${seg(notFound)}" style="width:0%"></div>
<div class="em-seg-fill em-seg--pend" data-pct="${seg(pending)}" style="width:0%"></div>
</div>
<div class="em-stat-legend">
<span class="em-leg em-leg--matched"><i></i>${matched.toLocaleString()} matched</span>
<span class="em-leg em-leg--nf"><i></i>${notFound.toLocaleString()} missed</span>
<span class="em-leg em-leg--pend"><i></i>${pending.toLocaleString()} pending</span>
</div>
</div>`;
}).join('');
// Animate the segments in from 0 on the next frame (CSS transition does the rest).
requestAnimationFrame(() => {
host.querySelectorAll('.em-seg-fill').forEach(el => {
el.style.width = `${el.dataset.pct || 0}%`;
});
});
}
function _emRenderUnmatchedControls() {
const host = document.getElementById('em-unmatched-controls');
if (!host) return;
const data = enrichmentManagerState.unmatched;
const supported = (data && data.entity_types) || ['artist'];
const total = data ? (data.total || 0) : null;
const tabs = supported.map(e => `
<button class="em-seg-tab ${e === enrichmentManagerState.entityTab ? 'active' : ''}"
onclick="setEnrichmentEntityTab('${e}')">${_emEntityLabel(e, true)}</button>`).join('');
host.innerHTML = `
<div class="em-unmatched-bar">
<div class="em-section-label em-section-label--inline">
Needs matching
${total == null ? '' : `<span class="em-count">${total.toLocaleString()}</span>`}
</div>
<div class="em-filter-row">
<div class="em-seg-tabs">${tabs}</div>
<select class="em-select" onchange="setEnrichmentStatusFilter(this.value)">
<option value="unmatched" ${enrichmentManagerState.statusFilter === 'unmatched' ? 'selected' : ''}>All unmatched</option>
<option value="not_found" ${enrichmentManagerState.statusFilter === 'not_found' ? 'selected' : ''}>Not found</option>
<option value="pending" ${enrichmentManagerState.statusFilter === 'pending' ? 'selected' : ''}>Pending</option>
</select>
<div class="em-search-wrap">
<span class="em-search-ico">⌕</span>
<input class="em-search" type="text" placeholder="Search name…"
value="${_emEscape(enrichmentManagerState.search)}"
oninput="onEnrichmentSearchInput(this.value)">
</div>
</div>
</div>`;
}
function _emRenderUnmatchedList() {
const host = document.getElementById('em-unmatched-list');
if (!host) return;
const data = enrichmentManagerState.unmatched;
if (!data) {
host.innerHTML = Array.from({ length: 6 }, () => `
<div class="em-row em-skel-row">
<div class="em-skel em-row-img"></div>
<div class="em-row-info">
<div class="em-skel em-skel-line" style="width:55%"></div>
<div class="em-skel em-skel-line" style="width:30%;margin-top:6px"></div>
</div>
</div>`).join('');
return;
}
// Keep the count badge in sync without re-rendering the controls (would
// steal focus from the search box mid-type).
const countEl = document.querySelector('#em-unmatched-controls .em-count');
if (countEl) countEl.textContent = (data.total || 0).toLocaleString();
if (!data.items.length) {
const allMatched = enrichmentManagerState.statusFilter === 'unmatched';
host.innerHTML = `<div class="em-empty">
<div class="em-empty-emoji">${allMatched ? '🎉' : '🔍'}</div>
<div>${allMatched
? 'Every item is matched for this source.'
: 'Nothing matches this filter.'}</div>
</div>`;
} else {
const id = enrichmentManagerState.selected;
const entity = enrichmentManagerState.entityTab;
host.innerHTML = data.items.map(item => {
const img = item.image_url
? `<img class="em-row-img" src="${_emEscape(item.image_url)}" alt="" loading="lazy" onerror="this.style.display='none'">`
: '<div class="em-row-img em-row-img--ph">♪</div>';
const rel = _emRelativeTime(item.last_attempted);
const last = rel
? `<span class="em-muted">tried ${rel}</span>`
: '<span class="em-muted">never tried</span>';
const statusBadge = item.status === 'not_found'
? '<span class="em-chip em-chip--nf">not found</span>'
: '<span class="em-chip em-chip--pend">pending</span>';
const safeName = _emEscape(item.name || 'Unknown');
return `
<div class="em-row">
${img}
<div class="em-row-info">
<div class="em-row-name" title="${safeName}">${safeName}</div>
<div class="em-row-meta">${statusBadge} ${last}</div>
</div>
<div class="em-row-actions">
<button class="em-btn em-btn--sm" onclick="openEnrichmentMatch('${id}','${entity}','${_emEscape(item.id)}', this)">Match</button>
<button class="em-btn em-btn--sm em-btn--ghost" title="Re-queue for the worker to try again"
onclick="retryEnrichmentItem('${id}','${entity}','${_emEscape(item.id)}', this)">Retry</button>
</div>
</div>`;
}).join('');
}
_emRenderPager();
}
function _emRenderPager() {
const host = document.getElementById('em-pager');
if (!host) return;
const data = enrichmentManagerState.unmatched;
if (!data) { host.innerHTML = ''; return; }
const { page, pageSize } = enrichmentManagerState;
const total = data.total || 0;
const from = total ? page * pageSize + 1 : 0;
const to = Math.min((page + 1) * pageSize, total);
const hasPrev = page > 0;
const hasNext = to < total;
host.innerHTML = `
<button class="em-btn em-btn--sm" ${hasPrev ? '' : 'disabled'} onclick="changeEnrichmentPage(-1)"> Prev</button>
<span class="em-muted">${from}${to} of ${total.toLocaleString()}</span>
<button class="em-btn em-btn--sm" ${hasNext ? '' : 'disabled'} onclick="changeEnrichmentPage(1)">Next </button>`;
}
// ── Controls ──────────────────────────────────────────────────────────────────
async function setEnrichmentEntityTab(entity) {
enrichmentManagerState.entityTab = entity;
enrichmentManagerState.page = 0;
_emRenderUnmatchedControls();
document.getElementById('em-unmatched-list').innerHTML = '<div class="enhanced-loading"><div class="spinner"></div></div>';
await _emLoadUnmatched();
_emRenderUnmatchedList();
}
async function setEnrichmentStatusFilter(value) {
enrichmentManagerState.statusFilter = value;
enrichmentManagerState.page = 0;
await _emLoadUnmatched();
_emRenderUnmatchedList();
}
let _emSearchDebounce = null;
function onEnrichmentSearchInput(value) {
enrichmentManagerState.search = value;
enrichmentManagerState.page = 0;
if (_emSearchDebounce) clearTimeout(_emSearchDebounce);
_emSearchDebounce = setTimeout(async () => {
await _emLoadUnmatched();
_emRenderUnmatchedList();
}, 300);
}
async function changeEnrichmentPage(delta) {
enrichmentManagerState.page = Math.max(0, enrichmentManagerState.page + delta);
await _emLoadUnmatched();
_emRenderUnmatchedList();
}
async function toggleEnrichmentWorker(id) {
const status = enrichmentManagerState.statuses[id];
const action = status?.paused ? 'resume' : 'pause';
try {
const res = await fetch(`/api/enrichment/${id}/${action}`, { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data.error || `Could not ${action} worker`, 'error');
return;
}
showToast(`${_emWorkerById[id].name} ${action === 'pause' ? 'paused' : 'resumed'}`, 'success');
await _emPollSelected();
} catch (_e) {
showToast(`Error trying to ${action} worker`, 'error');
}
}
async function retryEnrichmentItem(service, entityType, entityId, btn) {
if (btn) { btn.disabled = true; btn.textContent = '…'; }
try {
const res = await fetch('/api/library/clear-match', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, service }),
});
const data = await res.json().catch(() => ({}));
if (data.success) {
showToast('Re-queued for enrichment', 'success');
await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]);
_emRenderStats();
_emRenderUnmatchedList();
} else {
showToast(data.error || 'Failed to re-queue', 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Retry'; }
}
} catch (_e) {
showToast('Error re-queuing item', 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Retry'; }
}
}
// ── Inline manual match (decoupled from the library artist-detail page) ───────
function openEnrichmentMatch(service, entityType, entityId, anchorBtn) {
const defaultQuery = anchorBtn
? (anchorBtn.closest('.em-row')?.querySelector('.em-row-name')?.textContent || '')
: '';
const existing = document.getElementById('enrichment-match-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'enrichment-match-overlay';
overlay.className = 'modal-overlay';
overlay.style.zIndex = '10010'; // above the manager modal
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
<div class="enhanced-manual-match-modal">
<div class="enhanced-bulk-modal-header">
<h3>Match ${_emEntityLabel(entityType)} on ${_emEscape(_emWorkerById[service]?.name || service)}</h3>
<button class="enhanced-bulk-modal-close">&times;</button>
</div>
<div class="enhanced-match-search-row">
<input type="text" class="enhanced-match-search-input" placeholder="Search…" value="${_emEscape(defaultQuery)}">
<button class="enhanced-enrich-btn em-match-go">Search</button>
</div>
<div class="enhanced-match-results" id="enrichment-match-results">
<div class="enhanced-match-results-hint">Search to find a match.</div>
</div>
</div>`;
document.body.appendChild(overlay);
const input = overlay.querySelector('.enhanced-match-search-input');
const results = overlay.querySelector('#enrichment-match-results');
overlay.querySelector('.enhanced-bulk-modal-close').onclick = () => overlay.remove();
const run = () => _emRunMatchSearch(service, entityType, entityId, input.value, results, overlay);
overlay.querySelector('.em-match-go').onclick = run;
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
if (defaultQuery.trim()) run();
setTimeout(() => input.focus(), 50);
}
async function _emRunMatchSearch(service, entityType, entityId, query, container, overlay) {
if (!query.trim()) {
container.innerHTML = '<div class="enhanced-match-results-hint">Enter a search term</div>';
return;
}
container.innerHTML = '<div class="enhanced-loading"><div class="spinner"></div></div>';
try {
const res = await fetch('/api/library/search-service', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service, entity_type: entityType, query: query.trim() }),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Search failed');
const list = data.results || [];
if (!list.length) {
container.innerHTML = '<div class="enhanced-match-results-hint">No results. Try a different search.</div>';
return;
}
container.innerHTML = '';
list.forEach(r => {
const row = document.createElement('div');
row.className = 'enhanced-match-result-row';
const imgHtml = r.image
? `<img class="enhanced-match-result-img" src="${_emEscape(r.image)}" alt="" onerror="this.style.display='none'">`
: '<div class="enhanced-match-result-img-placeholder">&#127925;</div>';
const providerLabel = r.provider && r.provider !== service ? ` (${_emEscape(r.provider)})` : '';
row.innerHTML = `
${imgHtml}
<div class="enhanced-match-result-info">
<div class="enhanced-match-result-name">${_emEscape(r.name || 'Unknown')}</div>
${r.extra ? `<div class="enhanced-match-result-extra">${_emEscape(r.extra)}</div>` : ''}
<div class="enhanced-match-result-id">ID: ${_emEscape(r.id)}${providerLabel}</div>
</div>`;
const btn = document.createElement('button');
btn.className = 'enhanced-meta-save-btn';
btn.textContent = 'Match';
btn.onclick = () => _emApplyMatch(entityType, entityId, r.provider || service, r.id, overlay);
row.appendChild(btn);
container.appendChild(row);
});
} catch (e) {
container.innerHTML = `<div class="enhanced-match-results-hint">Search error: ${_emEscape(e.message)}</div>`;
}
}
async function _emApplyMatch(entityType, entityId, service, serviceId, overlay) {
try {
const res = await fetch('/api/library/manual-match', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, service, service_id: serviceId }),
});
const data = await res.json();
if (data.success) {
showToast('Matched ✓', 'success');
if (overlay) overlay.remove();
// Refresh the manager's stats + list for the *selected* worker.
const sel = enrichmentManagerState.selected;
await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]);
_emRenderStats();
_emRenderUnmatchedList();
} else {
showToast(data.error || 'Failed to match', 'error');
}
} catch (_e) {
showToast('Error applying match', 'error');
}
}
// Expose for inline onclick handlers.
window.openEnrichmentManager = openEnrichmentManager;
window.closeEnrichmentManager = closeEnrichmentManager;
window.refreshEnrichmentManager = refreshEnrichmentManager;
window.selectEnrichmentWorker = selectEnrichmentWorker;
window.setEnrichmentEntityTab = setEnrichmentEntityTab;
window.setEnrichmentStatusFilter = setEnrichmentStatusFilter;
window.onEnrichmentSearchInput = onEnrichmentSearchInput;
window.changeEnrichmentPage = changeEnrichmentPage;
window.toggleEnrichmentWorker = toggleEnrichmentWorker;
window.retryEnrichmentItem = retryEnrichmentItem;
window.openEnrichmentMatch = openEnrichmentMatch;