/* * 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//{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 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)' }, // Relationship worker (MusicMap → similar artists), not a metadata source. // No dashboard logo element, so it uses a direct MusicMap logo URL. // relationship:true tells the detail panel to drop the (inapplicable) // manual-match affordance. { id: 'similar_artists', name: 'Similar Artists', color: '#a855f7', relationship: true, logoUrl: 'https://www.music-map.com/elements/objects/og_logo.png', imgRound: true }, ]; 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) return null; if (w.logoUrl) return w.logoUrl; // direct URL (workers w/o a dashboard logo) if (!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 ? `` : `${initial}`; return `${inner}`; } const enrichmentManagerState = { open: false, selected: null, statuses: {}, // id -> last /status payload breakdown: null, // selected worker's breakdown priority: '', // pinned 'process first' entity for selected worker ('' = auto) entityTab: 'artist', statusFilter: 'unmatched', search: '', page: 0, pageSize: 25, unmatched: null, // { total, items } selectedItems: new Set(), // ids checked for bulk retry 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; } // Always present entities in the worker's real processing order, regardless of // the order the API/object keys happen to arrive in. const _EM_ENTITY_ORDER = { artist: 0, album: 1, track: 2 }; function _emOrderEntities(list) { return [...new Set(list || [])].sort( (a, b) => (_EM_ENTITY_ORDER[a] ?? 9) - (_EM_ENTITY_ORDER[b] ?? 9) ); } function _emEscape(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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(workerId) { 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 = ` `; 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(); // Selection priority: explicit deep-link arg → last-viewed (remembered) → // first running worker → first in the list. let remembered = null; try { remembered = localStorage.getItem('em-last-worker'); } catch (_e) { /* ignore */ } const valid = (wid) => wid && _emWorkerById[wid]; const running = ENRICHMENT_WORKERS.find(w => enrichmentManagerState.statuses[w.id]?.running); const initial = (valid(workerId) && workerId) || (valid(remembered) && remembered) || (running || ENRICHMENT_WORKERS[0]).id; selectEnrichmentWorker(initial); 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()]); _emRenderEntityCards(); _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); _emRenderEntityCards(); } } catch (_e) { /* transient — keep last */ } } function _emStatusInfo(status) { if (!status || !status.enabled) return { cls: 'disabled', label: 'Disabled' }; // Rate-limited on the real API but still matching via the no-creds Spotify // Free source — show as running, not stuck (#798 bridge). if (status.using_free) return { cls: 'running', label: 'Running (Spotify Free)' }; 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' }; } // Global "process first" — applies a group to EVERY worker. Like the per-worker // pin, it also re-queues that group's previously-failed items so each worker // sweeps ALL pending + failed of the group before moving on. Workers that don't // enrich the entity (Genius/album, Discogs/track) reject with 400 and are // skipped (no priority set, no re-queue). Workers run independently in parallel. async function setGlobalPriority(entity, btn) { if (btn) { document.querySelectorAll('#em-global-tabs button').forEach(b => b.classList.remove('active')); btn.classList.add('active'); } const perWorker = await Promise.all(ENRICHMENT_WORKERS.map(async (w) => { let okP = false, reset = 0; try { const r = await fetch(`/api/enrichment/${w.id}/priority`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entity: entity || 'none' }), }); okP = r.ok; } catch (_e) { /* skip */ } if (okP && entity) { try { const rr = await fetch(`/api/enrichment/${w.id}/retry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entity_type: entity, scope: 'failed' }), }); if (rr.ok) reset = (await rr.json()).reset || 0; } catch (_e) { /* priority still set */ } } return { okP, reset }; })); const n = perWorker.filter(x => x.okP).length; const totalReset = perWorker.reduce((s, x) => s + x.reset, 0); showToast(entity ? `${n} worker${n === 1 ? '' : 's'} → ${_emEntityLabel(entity, true).toLowerCase()} first` + (totalReset ? ` · re-queued ${totalReset.toLocaleString()} failed` : '') : `${n} worker${n === 1 ? '' : 's'} back to automatic order`, 'success'); // Reflect on the currently-open worker. const sel = enrichmentManagerState.selected; if (sel) { await _emLoadBreakdown(sel); await _emLoadPriority(sel); _emRenderEntityCards(); } } // ── 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; } // Rail sub-line: while running, show the group it's on ("Running · albums"); // otherwise the status + overall coverage %. function _emRailSubText(status) { const info = _emStatusInfo(status); const phase = _emCurrentPhase(status); if (info.cls === 'running' && phase) return `${info.label} · ${_emEntityLabel(phase, true).toLowerCase()}`; const pct = _emOverallPct(status); return `${info.label}${pct == null ? '' : ` · ${pct}%`}`; } function renderEnrichmentRail() { const rail = document.getElementById('em-rail'); if (!rail) return; rail.innerHTML = ENRICHMENT_WORKERS.map((w, i) => { const status = enrichmentManagerState.statuses[w.id]; const info = _emStatusInfo(status); const pct = _emOverallPct(status); const cov = pct == null ? '' : ` `; const accent = _emHexToRgb(w.color); return ` `; }).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 = _emRailSubText(status); 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; try { localStorage.setItem('em-last-worker', id); } catch (_e) { /* ignore */ } enrichmentManagerState.selectedItems.clear(); enrichmentManagerState.breakdown = null; enrichmentManagerState.unmatched = null; enrichmentManagerState.priority = ''; 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(), _emLoadPriority(id)]); renderEnrichmentPanel(); } async function _emLoadPriority(id) { try { const res = await fetch(`/api/enrichment/${id}/priority`); enrichmentManagerState.priority = res.ok ? ((await res.json()).priority || '') : ''; } catch (_e) { enrichmentManagerState.priority = ''; } } // Which phase the worker is on right now, from current_item.type. function _emCurrentPhase(status) { const t = status && status.current_item && status.current_item.type; if (!t) return ''; if (t.indexOf('artist') === 0) return 'artist'; if (t.indexOf('album') === 0) return 'album'; if (t.indexOf('track') === 0) return 'track'; return ''; } 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 = `
Coverage & processing order — click a group to enrich it first
`; _emRenderPanelHeader(); _emRenderEntityCards(); _emRenderUnmatchedControls(); _emRenderUnmatchedList(); } // Combined coverage + processing-order cards. Each entity card both visualises // coverage (matched/not_found/pending segmented bar + %) AND is the click target // to pin that group to enrich first ('now' = live phase, '📌 first' = pinned, // '✓ done' = nothing left). One section instead of two redundant ones. function _emRenderEntityCards() { const host = document.getElementById('em-cards'); if (!host) return; const id = enrichmentManagerState.selected; const bd = enrichmentManagerState.breakdown; const supported = _emOrderEntities( (enrichmentManagerState.unmatched && enrichmentManagerState.unmatched.entity_types) || (bd && Object.keys(bd)) || ['artist'] ); if (!bd) { host.innerHTML = supported.map(() => `
`).join(''); return; } const status = enrichmentManagerState.statuses[id]; const phase = _emCurrentPhase(status); const pinned = enrichmentManagerState.priority; const glyphs = { artist: '🎤', album: '💿', track: '🎵' }; host.innerHTML = supported.map(e => { const d = bd[e] || {}; const total = d.total || 0, matched = d.matched || 0, nf = d.not_found || 0, pend = d.pending || 0; const pct = total ? Math.round((matched / total) * 100) : 0; const seg = (n) => (total ? (n / total) * 100 : 0); const isPinned = pinned === e, isCurrent = phase === e, isDone = total > 0 && (pend + nf) === 0; const cls = ['em-card', isPinned ? 'em-card--pinned' : '', isCurrent ? 'em-card--current' : '', isDone ? 'em-card--done' : ''].filter(Boolean).join(' '); const badge = isPinned ? '📌 First' : isCurrent ? '● Now' : isDone ? '✓ Done' : `${(pend + nf).toLocaleString()} left`; return ` `; }).join(''); // Overall matched coverage across every entity type. const overall = document.getElementById('em-coverage-overall'); if (overall) { let m = 0, t = 0; Object.values(bd).forEach(d => { m += d.matched || 0; t += d.total || 0; }); const pct = t ? Math.round((m / t) * 100) : 0; overall.innerHTML = t ? `${pct}% matched · ${m.toLocaleString()} of ${t.toLocaleString()}` : ''; } requestAnimationFrame(() => { host.querySelectorAll('.em-seg-fill').forEach(el => { el.style.width = `${el.dataset.pct || 0}%`; }); }); } async function setEnrichmentPriority(entity) { const id = enrichmentManagerState.selected; const name = _emWorkerById[id].name; try { const res = await fetch(`/api/enrichment/${id}/priority`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entity: entity || 'none' }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { showToast(data.error || 'Could not set processing order', 'error'); return; } enrichmentManagerState.priority = data.priority || ''; if (!enrichmentManagerState.priority) { showToast(`${name} back to automatic order`, 'success'); _emRenderEntityCards(); return; } // Pin means "process the whole group". Re-queue this group's // previously-failed items (not_found -> pending) so the worker sweeps // ALL unmatched, not just never-tried ones. Safe: each is attempted // once; still-unmatched become not_found again and the pending-only // pin won't re-pick them, so no loop. let reset = 0; try { const rr = await fetch(`/api/enrichment/${id}/retry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entity_type: entity, scope: 'failed' }), }); const rd = await rr.json().catch(() => ({})); if (rr.ok) reset = rd.reset || 0; } catch (_e) { /* priority still set; failed sweep is best-effort */ } const label = _emEntityLabel(entity, true).toLowerCase(); showToast(reset ? `${name} will process all ${label} first · re-queued ${reset.toLocaleString()} previously-failed` : `${name} will process ${label} first`, 'success'); // Counts changed (failed -> pending) — refresh cards + list. await Promise.all([_emLoadBreakdown(id), _emLoadUnmatched()]); _emRenderEntityCards(); _emRenderUnmatchedControls(); _emRenderUnmatchedList(); } catch (_e) { showToast('Error setting processing order', 'error'); } } 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 = `
${_emIconHtml(id, 'lg')}
${_emEscape(worker.name)} enrichment
`; _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 hero = document.querySelector('#em-panel-header .em-hero'); if (hero) hero.classList.toggle('em-hero--live', info.cls === 'running'); const cur = document.getElementById('em-ph-current'); if (cur) { const item = status && status.current_item; cur.innerHTML = item ? `Now enriching: ${_emEscape(item.name || '')}${item.type ? ` (${_emEscape(item.type)})` : ''}` : 'No item processing'; } const budgetEl = document.getElementById('em-ph-budget'); if (budgetEl) { const b = status && status.daily_budget; budgetEl.innerHTML = (b && b.limit) ? `Budget ${b.used ?? '?'} / ${b.limit}` : ''; } const errEl = document.getElementById('em-ph-errors'); if (errEl) { const errors = (status && status.stats && status.stats.errors) || 0; errEl.innerHTML = errors ? `⚠ ${errors}` : ''; } 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'; } // #1 unconfigured + #2 rate-limit banner. const banner = document.getElementById('em-banner'); if (banner) { let html = '', cls = 'em-banner'; if (status && status.enabled === false) { cls += ' em-banner--warn'; html = '⚙️ This source isn’t configured — add its credentials in Settings. ' + 'Browsing works, but matches and retries won’t run until it’s set up.'; } else if (status && status.using_free) { // Real API banned but bridging via the no-creds Spotify Free source. html = '✓ Spotify is rate-limited — matching via Spotify Free until the ban lifts.'; } else if (status && status.rate_limited) { cls += ' em-banner--warn'; const rl = status.rate_limit || {}; const secs = rl.retry_after || rl.reset_in || rl.cooldown_seconds; const when = secs ? ` — resumes in ~${_emHumanDuration(secs)}` : ' — it will resume automatically'; html = `⏳ Rate-limited by the source${when}.`; } banner.className = cls; banner.innerHTML = html; banner.hidden = !html; } } function _emHumanDuration(seconds) { const s = Math.max(0, Math.round(Number(seconds) || 0)); if (s < 60) return `${s}s`; const m = Math.round(s / 60); return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`; } 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 entity = enrichmentManagerState.entityTab; const failed = enrichmentManagerState.breakdown?.[entity]?.not_found || 0; const tabs = supported.map(e => ` `).join(''); const bulkBtn = failed ? `` : ''; host.innerHTML = `
${tabs}
Failed lookups auto-retry after 30 days · “Retry” re-queues immediately · “Match” assigns a result by hand.
`; } function _emRenderUnmatchedList() { const host = document.getElementById('em-unmatched-list'); if (!host) return; const data = enrichmentManagerState.unmatched; if (!data) { host.innerHTML = Array.from({ length: 6 }, () => `
`).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 = `
${allMatched ? '🎉' : '🔍'}
${allMatched ? 'Every item is matched for this source.' : 'Nothing matches this filter.'}
`; } else { const id = enrichmentManagerState.selected; const entity = enrichmentManagerState.entityTab; host.innerHTML = data.items.map(item => { // Unmatched items rarely have artwork yet, so the box always shows a // subtle entity glyph; a real image (if any) layers over it and, on // error, removes itself to reveal the glyph — no ragged gaps. const phGlyph = { artist: '🎤', album: '💿', track: '🎵' }[entity] || '♪'; const pic = item.image_url ? `` : ''; const img = `
${phGlyph}${pic}
`; const rel = _emRelativeTime(item.last_attempted); const last = rel ? `tried ${rel}` : 'never tried'; const statusBadge = item.status === 'not_found' ? 'not found' : 'pending'; const safeName = _emEscape(item.name || 'Unknown'); const safeId = _emEscape(item.id); const parent = item.parent ? `· ${_emEscape(item.parent)}` : ''; const checked = enrichmentManagerState.selectedItems.has(String(item.id)) ? 'checked' : ''; const rowCls = item.status === 'not_found' ? 'em-row em-row--nf' : 'em-row em-row--pend'; return `
${img}
${safeName} ${parent}
${statusBadge} ${last}
${(_emWorkerById[id] && _emWorkerById[id].relationship) ? '' : ``}
`; }).join(''); } _emRenderPager(); _emRenderBulkBar(); } function _emRenderBulkBar() { const bar = document.getElementById('em-bulk-bar'); if (!bar) return; const n = enrichmentManagerState.selectedItems.size; if (!n) { bar.hidden = true; bar.innerHTML = ''; return; } bar.hidden = false; bar.innerHTML = ` ${n} selected `; } function toggleEnrichmentRowSelect(id, checked) { if (checked) enrichmentManagerState.selectedItems.add(String(id)); else enrichmentManagerState.selectedItems.delete(String(id)); _emRenderBulkBar(); } function clearEnrichmentSelection() { enrichmentManagerState.selectedItems.clear(); _emRenderUnmatchedList(); } // #6 keyboard nav: ↑/↓ moves focus between rows' Match buttons (Enter/Space // then activates natively). The list isn't refreshed by polling, so focus // stays put between user actions. function onEnrichmentListKey(e) { if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; const host = document.getElementById('em-unmatched-list'); if (!host) return; const btns = Array.from(host.querySelectorAll('.em-row .em-row-actions .em-btn:first-child')); if (!btns.length) return; e.preventDefault(); const idx = btns.indexOf(document.activeElement); const next = e.key === 'ArrowDown' ? (idx < 0 ? 0 : Math.min(idx + 1, btns.length - 1)) : (idx <= 0 ? 0 : idx - 1); btns[next].focus(); } async function retrySelectedEnrichment(btn) { const service = enrichmentManagerState.selected; const entity = enrichmentManagerState.entityTab; const ids = Array.from(enrichmentManagerState.selectedItems); if (!ids.length) return; if (btn) { btn.disabled = true; btn.textContent = 'Re-queuing…'; } let ok = 0; // Cap concurrency to be gentle on the server. for (let i = 0; i < ids.length; i += 5) { const slice = ids.slice(i, i + 5); const results = await Promise.all(slice.map(eid => fetch(`/api/enrichment/${service}/retry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entity_type: entity, scope: 'item', entity_id: eid }), }).then(r => r.ok).catch(() => false) )); ok += results.filter(Boolean).length; } showToast(`Re-queued ${ok.toLocaleString()} item(s)`, ok ? 'success' : 'error'); enrichmentManagerState.selectedItems.clear(); await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]); _emRenderEntityCards(); _emRenderUnmatchedList(); } 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 = ` ${from}–${to} of ${total.toLocaleString()} `; } // ── Controls ────────────────────────────────────────────────────────────────── async function setEnrichmentEntityTab(entity) { enrichmentManagerState.entityTab = entity; enrichmentManagerState.page = 0; _emRenderUnmatchedControls(); document.getElementById('em-unmatched-list').innerHTML = '
'; 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 { // Reset match_status to NULL (pending) so the worker re-attempts on its // next pass — see /retry (clearing to not_found would NOT re-queue). const res = await fetch(`/api/enrichment/${service}/retry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entity_type: entityType, scope: 'item', entity_id: entityId }), }); const data = await res.json().catch(() => ({})); if (res.ok && data.success) { showToast('Re-queued for enrichment', 'success'); await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]); _emRenderEntityCards(); _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'; } } } // Bulk: re-queue every not_found item of the current entity type. async function retryAllFailedEnrichment(btn) { const service = enrichmentManagerState.selected; const entity = enrichmentManagerState.entityTab; const bd = enrichmentManagerState.breakdown?.[entity]; const failed = bd ? (bd.not_found || 0) : 0; if (!failed) { showToast('No failed items to retry', 'info'); return; } if (!confirm(`Re-queue all ${failed.toLocaleString()} not-found ${_emEntityLabel(entity, true).toLowerCase()} for ${_emWorkerById[service].name}? The worker will retry them on its next pass.`)) return; if (btn) { btn.disabled = true; btn.textContent = 'Re-queuing…'; } try { const res = await fetch(`/api/enrichment/${service}/retry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entity_type: entity, scope: 'failed' }), }); const data = await res.json().catch(() => ({})); if (res.ok && data.success) { showToast(`Re-queued ${(data.reset || 0).toLocaleString()} item(s)`, 'success'); enrichmentManagerState.page = 0; await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]); _emRenderEntityCards(); _emRenderUnmatchedControls(); _emRenderUnmatchedList(); } else { showToast(data.error || 'Failed to re-queue', 'error'); } } catch (_e) { showToast('Error re-queuing failed items', 'error'); } finally { if (btn) { btn.disabled = false; btn.textContent = '↻ Retry all failed'; } } } // ── 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 em-overlay'; overlay.style.zIndex = '10010'; // above the manager modal overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; overlay.innerHTML = `

Match ${_emEntityLabel(entityType)} on ${_emEscape(_emWorkerById[service]?.name || service)}

Search to find a match.
`; 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 = '
Enter a search term
'; return; } container.innerHTML = '
'; 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 = '
No results. Try a different search.
'; return; } container.innerHTML = ''; list.forEach(r => { const row = document.createElement('div'); row.className = 'enhanced-match-result-row'; const imgHtml = r.image ? `` : '
🎵
'; const providerLabel = r.provider && r.provider !== service ? ` (${_emEscape(r.provider)})` : ''; row.innerHTML = ` ${imgHtml}
${_emEscape(r.name || 'Unknown')}
${r.extra ? `
${_emEscape(r.extra)}
` : ''}
ID: ${_emEscape(r.id)}${providerLabel}
`; 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 = `
Search error: ${_emEscape(e.message)}
`; } } 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()]); _emRenderEntityCards(); _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.setEnrichmentPriority = setEnrichmentPriority; window.setGlobalPriority = setGlobalPriority; window.retryEnrichmentItem = retryEnrichmentItem; window.retryAllFailedEnrichment = retryAllFailedEnrichment; window.toggleEnrichmentRowSelect = toggleEnrichmentRowSelect; window.clearEnrichmentSelection = clearEnrichmentSelection; window.retrySelectedEnrichment = retrySelectedEnrichment; window.onEnrichmentListKey = onEnrichmentListKey; window.openEnrichmentMatch = openEnrichmentMatch;