// 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-metadata mode: one song, no queue navigation
let _tab = 'details'; // active tab in single mode: details | cover | match
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 metadata (R2 → popup slice 4): the tabbed per-song editor for ONE
// song, reachable from the card's ⋮ / right-click menu. Three tabs —
// Details (type + lock the displayed fields), Cover art (launch the picker),
// Match (pin a MusicBrainz identity). Opens on Details: for the obscure /
// blank-artist packs this exists to fix, typing the right title is the tool,
// and Match is the escape hatch when text search can surface a record.
function fixMatch(song) {
if (!song || !song.filename) return;
_lastFocus = document.activeElement;
_single = true;
_tab = 'details';
_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(); // _single ⇒ renderTabbed()
}
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 (_single || !_queue.length) return; // single mode has no queue to page
_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 '
' +
'
' + (_single ? 'Fix match' : '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 '
';
}
// 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 '';
}
// The middle content shared by the queue-review render and the single-song
// popup's Match tab: the chart being matched, its candidate list, and the
// "search instead" panel. Header + footer differ per surface. When there
// are no stored candidates (a manual fix), the search panel opens pre-filled
// — searching IS the point in that case.
function reviewBodyHtml(song) {
const sub = [song.artist, song.album, song.year, fmtDur(song.duration)].filter(Boolean).join(' · ');
const noCands = !(song.candidates || []).length;
const prefill = noCands ? [song.artist, song.title].filter(Boolean).join(' – ') : '';
return '
' +
// The chart being matched
'
' +
'' +
'
' +
'
' + esc(song.title) + '
' +
'
' + esc(sub) + '
' +
'
' + esc(song.filename) + '
' +
missingChips(song) +
'
' +
(noCands
? ''
: '
' +
'
Candidates (MusicBrainz)
' +
song.candidates.map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') +
'
') +
// Search panel — hidden when candidates exist (a "Search instead…"
// toggle reveals it); open + pre-filled when there are none.
'
' +
'
' +
'' +
'
' +
'
' +
'
';
}
// Footer actions. Single mode drops Skip / Not-a-match (no queue); the
// accept button only shows when there is a stored candidate to accept —
// search-result rows carry their own pick action.
function footerHtml(song) {
return '
';
}
function renderTabbed() {
const panel = document.getElementById('v3-match-panel');
if (!panel) return;
const song = _queue[0];
if (!song) { closeModal(); return; }
panel.innerHTML = tabHeaderHtml() +
'';
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
panel.querySelectorAll('[data-mr-tab]').forEach((b) => b.addEventListener('click', () => {
const t = b.getAttribute('data-mr-tab');
if (t !== _tab) { _tab = t; renderTabbed(); }
}));
const body = panel.querySelector('[data-mr-tabbody]');
if (_tab === 'details') { renderDetailsTab(body, song); }
else if (_tab === 'cover') { renderCoverTab(body, song); }
else {
body.innerHTML = reviewBodyHtml(song) + footerHtml(song);
wireReviewBody(body, song);
if (!(song.candidates || []).length) body.querySelector('[data-mr-search-input]')?.focus();
}
}
// Details tab: type + lock the DISPLAYED fields. Values ride the reversible
// override store (GET/PUT /api/song/{fn}/overrides) — never the pack file.
// Each field sits on its pack value: editing above the pack makes it an
// override ("Yours"); a lock pins it so an auto-match can't recanonicalize
// it; revert (↺) drops back to the pack value.
const DETAIL_FIELDS = [['title', 'Title'], ['artist', 'Artist'], ['album', 'Album'], ['year', 'Year']];
async function renderDetailsTab(body, song) {
body.innerHTML = '
Loading…
';
let data = { overrides: {}, pack: {} };
try {
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides');
if (r.ok) data = await r.json();
} catch (_) { /* offline — fall back to the empty baseline */ }
if (!_single || _tab !== 'details') return; // tab/modal changed while fetching
const pack = data.pack || {};
const ov = data.overrides || {};
const st = {};
for (const [f] of DETAIL_FIELDS) {
const o = ov[f] || {};
st[f] = {
pack: pack[f] || '',
value: (o.value != null ? o.value : (pack[f] || '')),
locked: !!o.locked,
};
}
song._detailsState = st;
paintDetails(body, song);
}
function paintDetails(body, song) {
const st = song._detailsState;
const row = ([f, label]) => {
const s = st[f];
const isYours = !!(String(s.value).trim() && String(s.value).trim() !== String(s.pack).trim());
return '
' +
'
' +
'' +
(isYours
? 'Yours'
: 'Pack') +
'
' +
'
' +
'' +
'' +
'' +
'
';
};
body.innerHTML =
'
' +
'
' +
'' +
'
Your edits show in the library right away and are never written to the song files. Use Match to pull info from MusicBrainz, or lock a field to keep it.
' +
'
' +
DETAIL_FIELDS.map(row).join('') +
'' +
'
' +
'
' +
'' +
'
';
body.querySelectorAll('[data-df-input]').forEach((inp) => {
inp.addEventListener('input', () => { st[inp.getAttribute('data-df-input')].value = inp.value; });
});
body.querySelectorAll('[data-df-lock]').forEach((b) => {
b.addEventListener('click', () => { const f = b.getAttribute('data-df-lock'); st[f].locked = !st[f].locked; paintDetails(body, song); });
});
body.querySelectorAll('[data-df-revert]').forEach((b) => {
b.addEventListener('click', () => { const f = b.getAttribute('data-df-revert'); st[f].value = st[f].pack || ''; st[f].locked = false; paintDetails(body, song); });
});
body.querySelector('[data-df-save]')?.addEventListener('click', () => saveDetails(body, song));
}
async function saveDetails(body, song) {
const st = song._detailsState;
const overrides = {};
for (const [f] of DETAIL_FIELDS) {
const v = String(st[f].value || '').trim();
const p = String(st[f].pack || '').trim();
// Only store a value that differs from the pack; equal / blank clears
// the override (the server drops a value-less, unlocked row).
overrides[f] = { value: (v && v !== p) ? v : null, locked: !!st[f].locked };
}
const status = body.querySelector('[data-df-status]');
const saveBtn = body.querySelector('[data-df-save]');
if (saveBtn) saveBtn.disabled = true;
let ok = false;
try {
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ overrides }),
});
ok = r.ok;
} catch (_) { ok = false; }
if (saveBtn) saveBtn.disabled = false;
if (!ok) {
if (status) { status.className = 'text-xs h-4 text-fb-accent'; status.textContent = 'Could not save — try again.'; }
return;
}
// Reflect the new effective values on the in-memory song (keeps the Match
// tab header consistent) and repaint the library so the card shows them —
// the grid reloads on library:changed (slice 3 overlay does the rest).
for (const [f] of DETAIL_FIELDS) {
const v = String(st[f].value || '').trim(); const p = String(st[f].pack || '').trim();
song[f] = (v && v !== p) ? v : (st[f].pack || '');
}
try { window.feedBack?.emit('library:changed', { reason: 'override' }); } catch (_) { }
if (status) { status.className = 'text-xs h-4 text-fb-good'; status.textContent = 'Saved.'; }
}
// Cover-art tab: the current art + a button that hands off to the shared
// cover picker (image-picker.js, its own z-[200] modal). A pick there
// refreshes every for this song's art — including this thumbnail — so
// there's nothing to wire back.
function renderCoverTab(body, song) {
body.innerHTML =
'
' +
'' +
'
Choose from the Cover Art Archive, paste an image link, or upload your own. Your song files are never changed.
' +
'' +
'
';
body.querySelector('[data-cover-open]')?.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
}
});
}
// 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) {
if (_single) {
// Popup Match tab: a pinned identity can change the art/canon — nudge
// the grid to repaint (silent otherwise, like the queue flow).
try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { }
closeModal();
return;
}
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);
});
});
}
// "Identify by audio" — fingerprint the song's OWN master audio (AcoustID)
// and render the hits into the same search-results area. The reliable path
// when text search can't tell the studio take from live/comp versions.
async function runIdentify(panel, song) {
const out = panel.querySelector('[data-mr-search-results]');
const sp = panel.querySelector('[data-mr-search-panel]');
if (!out) return;
sp?.classList.remove('hidden'); // give the results somewhere to render
out.innerHTML = '
Fingerprinting audio…
';
let body = null, status = 0;
try {
const r = await fetch('/api/enrichment/identify/' + enc(song.filename), { method: 'POST' });
status = r.status;
body = await r.json().catch(() => null);
} catch (_) { /* falls through to the no-results line */ }
// Honest states — never a fake hit.
if (status === 412 || (body && body.needs_setup)) {
out.innerHTML = '
Audio identification is off — enable AcoustID and add a free API key to use it.