// Match-Review UI (P8 — library-metadata design §5/§11). A self-contained // module: the ambient "⚑ N to review" chip lives in the songs toolbar // (songs.js renders the element and calls the hooks below); the review MODAL, // the per-field available/missing detail, and the Settings → Library // "Metadata matching" card behaviour all live here. // // The modal reviews ONE chart at a time (the scraper-review model from // media-server / emulation-frontend apps): the chart's current metadata — // with explicit "Missing: …" chips — above the candidate list, each // candidate carrying "Adds / Shows as" chips, with Skip / Not a match / // Search instead / Use selected plus ‹ › navigation. // // Engagement guardrails (§11): opt-in tool-state, not a score. The chip only // appears when there is something to review, matching is silent on success // (no toasts, no sounds — hearing-safe), and nothing here ever writes to // pack files; a confirmed match only improves the local display cache. (function () { 'use strict'; const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const enc = encodeURIComponent; function artUrl(song) { const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : ''; return '/api/song/' + enc(song.filename) + '/art' + v; } function fmtDur(sec) { if (!sec && sec !== 0) return ''; const s = Math.max(0, Math.round(sec)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); } // ── Ambient chip + the Settings card's status line ─────────────────────── // songs.js renders `#v3-songs-match-review` (hidden) in its toolbar and // calls window.__fbMatchReviewChip() after each toolbar build; review // actions here re-call it. The same fetch feeds the Settings status line. // Silent on failure — surfaces just stay as they are. let _chipBusy = false; async function refreshChip() { if (_chipBusy) return; _chipBusy = true; try { const r = await fetch('/api/enrichment/status'); if (!r.ok) return; const body = await r.json(); const st = body.states || {}; const n = st.review || 0; const chip = document.getElementById('v3-songs-match-review'); if (chip) { chip.textContent = '⚑ ' + n + ' to review'; chip.classList.toggle('hidden', !n); } const line = document.getElementById('enrich-status'); if (line) { const parts = [ ((st.matched || 0) + (st.manual || 0)) + ' matched', n + ' to review', (st.failed || 0) + ' unmatched', ]; if (st.unscanned) parts.push(st.unscanned + ' queued'); line.textContent = (body.running ? 'Matching… · ' : '') + parts.join(' · '); } } catch (_) { /* offline — leave as-is */ } finally { _chipBusy = false; } } // ── Review modal (body-appended singleton, one chart at a time) ───────── let _queue = []; let _idx = 0; let _lastFocus = null; function ensureModal() { let m = document.getElementById('v3-match-modal'); if (m) return m; const overlay = document.createElement('div'); overlay.id = 'v3-match-overlay'; overlay.className = 'fixed inset-0 bg-black/60 z-40 hidden'; overlay.addEventListener('click', closeModal); document.body.appendChild(overlay); m = document.createElement('div'); m.id = 'v3-match-modal'; m.className = 'fixed inset-0 z-50 hidden flex items-center justify-center p-4 pointer-events-none'; m.innerHTML = '
'; m.addEventListener('keydown', onModalKeydown); document.body.appendChild(m); return m; } function isTyping(e) { const t = e.target; return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA'); } function onModalKeydown(e) { if (e.key === 'Escape') { e.stopPropagation(); closeModal(); return; } if (e.key === 'ArrowLeft' && !isTyping(e)) { e.preventDefault(); nav(-1); return; } if (e.key === 'ArrowRight' && !isTyping(e)) { e.preventDefault(); nav(1); return; } if (e.key !== 'Tab') return; // Light focus trap: cycle within the panel. const panel = document.getElementById('v3-match-panel'); if (!panel) return; const foci = panel.querySelectorAll('button, input, [tabindex="0"]'); if (!foci.length) return; const first = foci[0], last = foci[foci.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } function openModal() { _lastFocus = document.activeElement; const m = ensureModal(); renderLoading(); m.classList.remove('hidden'); document.getElementById('v3-match-overlay')?.classList.remove('hidden'); loadQueue(); } function closeModal() { document.getElementById('v3-match-modal')?.classList.add('hidden'); document.getElementById('v3-match-overlay')?.classList.add('hidden'); refreshChip(); if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { } } _lastFocus = null; } function nav(step) { if (!_queue.length) return; _idx = Math.min(Math.max(_idx + step, 0), _queue.length - 1); renderCurrent(); } async function loadQueue() { try { const r = await fetch('/api/enrichment/review?limit=200'); _queue = r.ok ? ((await r.json()).songs || []) : []; } catch (_) { _queue = []; } _idx = 0; renderCurrent(); } function headerHtml() { const counter = _queue.length ? '' + '' + (_idx + 1) + ' of ' + _queue.length + '' : ''; return 'Loading…
Nothing waiting for review.
' + 'Medium-confidence matches queue here while the library is matched in the background. Matching options live in Settings → Library.
Searching…
'; let body = null; try { const r = await fetch('/api/enrichment/search?artist=' + enc(artist) + '&title=' + enc(title) + '&filename=' + enc(song.filename)); if (r.status === 503) { out.innerHTML = 'MusicBrainz is unavailable — try again later.
'; return; } if (r.ok) body = await r.json(); } catch (_) { /* falls through to the no-results line */ } const cands = (body && body.candidates) || []; if (!cands.length) { out.innerHTML = 'No results.
'; return; } out.innerHTML = cands.map((c, i) => candRowHtml(song, c, i, false)).join(''); out.querySelectorAll('[data-mr-cand]').forEach((btn) => { btn.addEventListener('click', async () => { const cand = cands[Number(btn.getAttribute('data-mr-cand'))]; if (!cand) return; await post('/api/enrichment/review/' + enc(song.filename) + '/pick', { candidate: cand }); settle(song); }); }); } async function post(url, payload) { try { await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}), }); } catch (_) { /* offline — the row simply stays queued */ } } // ── Settings → Library → "Metadata matching" card ──────────────────────── // Markup lives statically in index.html (the v3 settings pattern); this // wires it. All null-guarded so v2 (which lacks the elements) no-ops. function wireSettingsCard() { const toggle = document.getElementById('enrich-enabled'); const sel = document.getElementById('enrich-threshold'); const btn = document.getElementById('enrich-match-now'); if (!toggle && !sel && !btn) return; (async () => { try { const r = await fetch('/api/settings'); if (r.ok) { const cfg = await r.json(); if (toggle) toggle.checked = cfg.enrich_enabled !== false; if (sel) { const t = Number(cfg.enrich_auto_threshold); const want = Number.isFinite(t) ? t : 0.9; // Snap to the nearest offered option. let best = sel.options[0]; for (const o of sel.options) { if (Math.abs(Number(o.value) - want) < Math.abs(Number(best.value) - want)) best = o; } if (best) sel.value = best.value; } } } catch (_) { /* leave markup defaults */ } refreshChip(); // also fills #enrich-status })(); const save = (key, value) => post('/api/settings', { [key]: value }); toggle?.addEventListener('change', () => save('enrich_enabled', !!toggle.checked)); sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value))); btn?.addEventListener('click', async () => { await post('/api/enrichment/kick'); const line = document.getElementById('enrich-status'); if (line) line.textContent = 'Matching…'; setTimeout(refreshChip, 1500); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', wireSettingsCard, { once: true }); } else { wireSettingsCard(); } window.__fbMatchReviewChip = refreshChip; window.__fbOpenMatchReview = openModal; })();