// 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 // and, while a pass is running, a quiet toolbar progress line (below). // Silent on failure — surfaces just stay as they are. let _chipBusy = false; let _pollTimer = null; // 5s status poll, alive ONLY while a pass runs // Quiet library-visible progress (launch polish): a plain text line next // to the review chip while the background pass is working through the // queue — "Matching your library — X of Y". No toast, no sound; it simply // disappears when the pass finishes (hearing-safe, design §11). function _setProgressLine(running, states, total) { let el = document.getElementById('v3-songs-match-progress'); const unscanned = states.unscanned || 0; if (!running || unscanned <= 0 || total <= 0) { if (el) el.remove(); return; } if (!el) { const chip = document.getElementById('v3-songs-match-review'); if (!chip || !chip.parentElement) return; // songs toolbar not on screen el = document.createElement('span'); el.id = 'v3-songs-match-progress'; el.className = 'text-xs text-fb-textDim'; chip.insertAdjacentElement('afterend', el); } el.textContent = 'Matching your library — ' + Math.max(0, total - unscanned) + ' of ' + total; } // One-time transparency toast (launch polish): the first time this // install is observed actually matching a real library, say plainly what // is contacted, where results live, and where the switch is. Wrapped like // app.js's fbNotify calls so a blocked localStorage / absent notifier can // never break the chip. function _announceOnce(running, total) { try { if (!running || total <= 0) return; if (localStorage.getItem('fb_enrich_announce_v1')) return; localStorage.setItem('fb_enrich_announce_v1', '1'); window.fbNotify?.show({ title: 'Library matching is on', message: 'Song info and covers come from MusicBrainz and Cover Art Archive, stored locally. Your files are never changed unless you choose to write to them. Adjust in Settings → Library.', icon: '📚', }); } catch (_) { /* storage/notifier unavailable — skip quietly */ } } 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(' · '); } const running = !!body.running; const total = body.total_songs || 0; _setProgressLine(running, st, total); _announceOnce(running, total); // Poll only while a pass is actually running; a single guarded // interval, cleared the moment the pass stops (no leaks). if (running && !_pollTimer) { _pollTimer = setInterval(refreshChip, 5000); } else if (!running && _pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } } catch (_) { // Offline — leave surfaces as they are, but stop any poll so a // dead server isn't pinged every 5s forever (the next toolbar // build / settings open restarts it if a pass is still running). if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } } finally { _chipBusy = false; } } // ── Review modal (body-appended singleton, one chart at a time) ───────── let _queue = []; let _idx = 0; let _lastFocus = null; let _single = false; // Fix-match mode: one song, no queue navigation 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; _single = false; const m = ensureModal(); renderLoading(); m.classList.remove('hidden'); document.getElementById('v3-match-overlay')?.classList.remove('hidden'); loadQueue(); } // Fix-match (R2): the same modal for ONE song — the escape hatch for a // wrong (or missing) match, reachable from the card's ⋮ / right-click // menu. No stored candidates are required: the search panel opens // pre-filled, and a pick pins the match exactly like the review flow. function fixMatch(song) { if (!song || !song.filename) return; _lastFocus = document.activeElement; _single = true; _queue = [{ filename: song.filename, title: song.title || song.filename, artist: song.artist || '', album: song.album || '', year: song.year || '', duration: song.duration, mtime: song.mtime, candidates: [], }]; _idx = 0; const m = ensureModal(); m.classList.remove('hidden'); document.getElementById('v3-match-overlay')?.classList.remove('hidden'); renderCurrent(); // Straight to the point: the search panel is why this mode exists. document.getElementById('v3-match-panel') ?.querySelector('[data-mr-search-toggle]')?.click(); } function closeModal() { document.getElementById('v3-match-modal')?.classList.add('hidden'); document.getElementById('v3-match-overlay')?.classList.add('hidden'); _single = false; 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 && !_single) ? '' + '' + (_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 sel = document.getElementById('enrich-threshold'); const order = document.getElementById('enrich-review-order'); const btn = document.getElementById('enrich-match-now'); // Boolean toggles, element id → settings key. enrich-enabled is the // master background switch; the rest are the R1 scraper options // (per-source + per-field auto-apply). const toggles = [ ['enrich-enabled', 'enrich_enabled'], ['enrich-src-musicbrainz', 'enrich_src_musicbrainz'], ['enrich-src-caa', 'enrich_src_caa'], ['enrich-apply-names', 'enrich_apply_names'], ['enrich-apply-year', 'enrich_apply_year'], ['enrich-apply-genres', 'enrich_apply_genres'], ['enrich-apply-art', 'enrich_apply_art'], // Artist pages (PR-B): the page itself — local-only, default ON. ['artist-pages-enabled', 'artist_pages_enabled'], ].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el); // Default-OFF toggles load with the opposite absent-key semantic // (checked only when explicitly true): the external-links row is // opt-IN per the dev-chat thread. const optInToggles = [ ['artist-external-links', 'artist_external_links'], ].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el); if (!toggles.length && !optInToggles.length && !sel && !btn) return; (async () => { try { const r = await fetch('/api/settings'); if (r.ok) { const cfg = await r.json(); for (const [el, key] of toggles) el.checked = cfg[key] !== false; for (const [el, key] of optInToggles) el.checked = cfg[key] === true; 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; } if (order) { const v = String(cfg.enrich_review_order || 'missing_first'); order.value = ['missing_first', 'artist', 'recent'].includes(v) ? v : 'missing_first'; } } } catch (_) { /* leave markup defaults */ } refreshChip(); // also fills #enrich-status })(); const save = (key, value) => post('/api/settings', { [key]: value }); for (const [el, key] of toggles.concat(optInToggles)) { el.addEventListener('change', () => save(key, !!el.checked)); } sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value))); order?.addEventListener('change', () => save('enrich_review_order', order.value)); btn?.addEventListener('click', async () => { await post('/api/enrichment/kick'); const line = document.getElementById('enrich-status'); if (line) line.textContent = 'Matching…'; setTimeout(refreshChip, 1500); }); } // Stop the 5s poll when the library screen is left — the progress line and // chip only live in the songs toolbar, so polling off-screen is pure waste // (benign but tidy). Re-entering v3-songs re-arms it: songs.js re-calls // window.__fbMatchReviewChip() on screen enter, and we also refresh here so // this stays self-contained. Same single-guarded-interval invariant as // refreshChip — no double-interval, cleared to null. function wireScreenTeardown() { const sm = window.feedBack; if (!sm || typeof sm.on !== 'function') return; sm.on('screen:changed', (e) => { const id = e && e.detail && e.detail.id; if (id === 'v3-songs') { refreshChip(); // returning while a pass runs re-arms the poll } else if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { wireSettingsCard(); wireScreenTeardown(); }, { once: true }); } else { wireSettingsCard(); wireScreenTeardown(); } window.__fbMatchReviewChip = refreshChip; window.__fbOpenMatchReview = openModal; window.__fbFixMatch = fixMatch; })();