// 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 '
' + '

Match review

' + counter + '
'; } function renderLoading() { const panel = document.getElementById('v3-match-panel'); if (!panel) return; panel.innerHTML = headerHtml() + '

Loading…

'; panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal); } function renderDone() { const panel = document.getElementById('v3-match-panel'); if (!panel) return; panel.innerHTML = headerHtml() + '

Nothing waiting for review.

' + '

Medium-confidence matches queue here while the library is matched in the background. Matching options live in Settings → Library.

'; panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal); } // Amber "what this chart lacks" chips. Album/year come from the library // row; cover art is detected from the art request failing (flagged onto // the song object by the onerror handler, then re-rendered). function missingChips(song) { const missing = []; if (!String(song.album || '').trim()) missing.push('album'); if (!String(song.year || '').trim()) missing.push('year'); if (song._artMissing) missing.push('cover art'); if (!missing.length) return ''; return '
' + 'Missing:' + missing.map((f) => '' + esc(f) + '').join('') + '
'; } // Per-candidate "what accepting this gets you": fields the chart lacks // that the candidate supplies, and fields whose DISPLAYED value would // change (never the file). function diffChips(song, cand) { const adds = []; const changes = []; const have = (v) => String(v == null ? '' : v).trim(); const differ = (a, b) => have(a) && have(b) && have(a).toLowerCase() !== have(b).toLowerCase(); if (have(cand.album)) { if (!have(song.album)) adds.push('album'); else if (differ(song.album, cand.album)) changes.push('album'); } if (have(cand.year)) { if (!have(song.year)) adds.push('year'); else if (differ(song.year, cand.year)) changes.push('year'); } if (cand.genres && cand.genres.length) adds.push('genres'); if (have(cand.isrc)) adds.push('ISRC'); if (differ(song.artist, cand.artist)) changes.push(have(song.artist) + ' → ' + have(cand.artist)); if (differ(song.title, cand.title)) changes.push('title'); let html = ''; if (adds.length) html += 'Adds: ' + esc(adds.join(' · ')) + ''; if (changes.length) html += (html ? ' ' : '') + 'Shows as: ' + esc(changes.join(' · ')) + ''; return html ? '' + html + '' : ''; } function candRowHtml(song, c, i, selected) { const meta = [c.artist, c.album, c.year, fmtDur(c.duration)].filter(Boolean).join(' · '); const pct = c.score != null ? Math.round(c.score * 100) + '%' : ''; return ''; } function renderCurrent() { const panel = document.getElementById('v3-match-panel'); if (!panel) return; if (!_queue.length) { renderDone(); return; } _idx = Math.min(_idx, _queue.length - 1); const song = _queue[_idx]; if (song._sel == null) song._sel = 0; const sub = [song.artist, song.album, song.year, fmtDur(song.duration)].filter(Boolean).join(' · '); panel.innerHTML = headerHtml() + '
' + // The chart being matched '
' + '' + '
' + '
' + esc(song.title) + '
' + '
' + esc(sub) + '
' + '
' + esc(song.filename) + '
' + missingChips(song) + '
' + // Candidates '
' + '
Candidates (MusicBrainz)
' + (song.candidates || []).map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') + '
' + // Search-instead panel '' + '
' + // Footer actions '
' + '
' + '' + '
' + '
' + '' + '' + '
'; wireCurrent(panel, song); } function wireCurrent(panel, song) { panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal); panel.querySelector('[data-mr-prev]')?.addEventListener('click', () => nav(-1)); panel.querySelector('[data-mr-next]')?.addEventListener('click', () => nav(1)); panel.querySelector('[data-mr-skip]')?.addEventListener('click', () => nav(1)); // Art failure → flag + re-render once so the "cover art" chip shows. const img = panel.querySelector('[data-mr-art]'); if (img) img.onerror = () => { img.style.visibility = 'hidden'; if (!song._artMissing) { song._artMissing = true; renderCurrent(); } }; panel.querySelectorAll('[data-mr-cand]').forEach((btn) => { btn.addEventListener('click', () => { song._sel = Number(btn.getAttribute('data-mr-cand')); renderCurrent(); }); }); panel.querySelector('[data-mr-accept]')?.addEventListener('click', async () => { const cand = (song.candidates || [])[song._sel || 0]; if (!cand) return; await post('/api/enrichment/review/' + enc(song.filename) + '/accept', { recording_id: cand.recording_id }); settle(song); }); panel.querySelector('[data-mr-reject]')?.addEventListener('click', async () => { await post('/api/enrichment/review/' + enc(song.filename) + '/reject'); settle(song); }); const sp = panel.querySelector('[data-mr-search-panel]'); const input = panel.querySelector('[data-mr-search-input]'); panel.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => { sp?.classList.toggle('hidden'); if (sp && !sp.classList.contains('hidden') && input && !input.value) { input.value = [song.artist, song.title].filter(Boolean).join(' – '); input.focus(); } }); const go = () => runSearch(panel, song); panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go); input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } }); } // Silent-on-success: the chart just leaves the queue and the next one // renders; the last one renders the done state. No toasts, no sounds. function settle(song) { const i = _queue.indexOf(song); if (i >= 0) _queue.splice(i, 1); if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1); refreshChip(); renderCurrent(); } async function runSearch(panel, song) { const input = panel.querySelector('[data-mr-search-input]'); const out = panel.querySelector('[data-mr-search-results]'); if (!input || !out) return; const qRaw = input.value.trim(); if (!qRaw) return; // "Artist – Title" splits on the first dash; a plain phrase searches // as a title, which MusicBrainz handles well enough. const m = qRaw.split(/\s+[–—-]\s+/); const artist = m.length > 1 ? m[0] : ''; const title = m.length > 1 ? m.slice(1).join(' - ') : qRaw; out.innerHTML = '

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; })();