// 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 '
' + '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 ''; } // 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 '
' + '
' + (_single ? '' : '') + '' + '
' + '
' + (_single ? '' : '') + ((song.candidates || []).length ? '' : '') + '
'; } function renderCurrent() { const panel = document.getElementById('v3-match-panel'); if (!panel) return; if (_single) { renderTabbed(); return; } // popup: the tabbed shell if (!_queue.length) { renderDone(); return; } _idx = Math.min(_idx, _queue.length - 1); const song = _queue[_idx]; if (song._sel == null) song._sel = 0; panel.innerHTML = headerHtml() + reviewBodyHtml(song) + footerHtml(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)); wireReviewBody(panel, song); } // Candidate / search / accept-reject wiring shared by the queue render and // the popup's Match tab. Scoped to `root` so the tabbed shell can wire just // its tab body — its close + tab chrome live in the header (wired once by // renderTabbed), so wiring here must NOT touch close/prev/next/skip. function wireReviewBody(root, song) { // Art failure → flag + re-render once so the "cover art" chip shows. const img = root.querySelector('[data-mr-art]'); if (img) img.onerror = () => { img.style.visibility = 'hidden'; if (!song._artMissing) { song._artMissing = true; renderCurrent(); } }; root.querySelectorAll('[data-mr-cand]').forEach((btn) => { btn.addEventListener('click', () => { song._sel = Number(btn.getAttribute('data-mr-cand')); renderCurrent(); }); }); root.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); }); root.querySelector('[data-mr-reject]')?.addEventListener('click', async () => { await post('/api/enrichment/review/' + enc(song.filename) + '/reject'); settle(song); }); const sp = root.querySelector('[data-mr-search-panel]'); const input = root.querySelector('[data-mr-search-input]'); root.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(root, song); root.querySelector('[data-mr-search-go]')?.addEventListener('click', go); input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } }); // Identify-by-audio (AcoustID, #759) renders its hits into the same // search-results area — scope to `root` (the tab body / panel), not the // out-of-scope `panel` the pre-refactor #759 wiring referenced. root.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(root, song)); } // ── Tabbed single-song popup (slice 4) ─────────────────────────────────── // Header + tab bar, then the active tab's body. The queue-review render // above is untouched; this is only reached in _single mode. function tabHeaderHtml() { const tab = (id, label) => ''; return '
' + '

Fix metadata

' + '
' + '
' + tab('details', 'Details') + tab('cover', 'Cover art') + tab('match', 'Match') + '
'; } 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'], ['genre', 'Genre']]; // Only these four are written into the pack file; genre is a library-only // overlay (drives the genre filter/facet + the auto-match lock), never baked // to the file — so Write to file leaves genre's override in place. const WRITE_FIELDS = ['title', 'artist', 'album', '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; // Match→Details bridge: a candidate picked with "Use these values" lands // its fields here as the pending (unsaved) input values, shown pre-filled // for review — the grid never adopts a match silently, so the user still // Saves (or Writes to file). const adopted = song._pendingDetails; if (adopted) { for (const [f] of DETAIL_FIELDS) { if (f in adopted) st[f].value = String(adopted[f] || ''); } song._pendingDetails = null; } paintDetails(body, song); if (adopted) { const s = body.querySelector('[data-df-status]'); if (s) { s.className = 'text-xs leading-relaxed text-fb-textDim'; s.textContent = 'Filled from the match — review, then Save or Write to file.'; } } } // Match→Details bridge: adopt a candidate's display fields into the Details // tab (opt-in — never silent). Pin the match too so the art/canon follow, // then land on Details pre-filled for review. async function useTheseValues(song, cand) { if (!cand) return; // Smart adopt for an English base: KEEP the readable name + title the card // already shows (the author's romaji, e.g. "Junko Yagami / BAY CITY") — the // match is often native script (kanji/kana). Take only what the pack lacks // — album / year / genre — from the match; the pin below still brings the // correct art + identity. The user can still edit any field. song._pendingDetails = { artist: String(song.artist || cand.artist || ''), title: String(song.title || cand.title || ''), album: String(cand.album || song.album || ''), year: String(cand.year || song.year || ''), genre: String((Array.isArray(cand.genres) && cand.genres[0]) || cand.genre || ''), }; try { await post('/api/enrichment/review/' + enc(song.filename) + '/pick', { candidate: cand }); } catch (_) { /* pin is best-effort; the values still populate Details */ } try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { } _tab = 'details'; renderTabbed(); } 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 = '
' + '
' + '' + '

Save keeps edits as a reversible library overlay — the song files aren\'t touched. Write to file bakes the title, artist, album and year into the pack (genre stays a library-only tag). Lock a field to keep an auto-match from changing 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)); body.querySelector('[data-df-write]')?.addEventListener('click', () => writeToFile(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.'; } } // "Write to file" — bake the shown title/artist/album/year INTO the pack // itself (the one action here that touches the file), via the existing // POST /api/song/{fn}/meta (writes the manifest, re-stats, coalesces a // rescan). On a real file write the display overrides for those fields are // now redundant, so clear their VALUES (keeping any locks) and re-render — // the field then reads from the file as "Pack". Loose-folder / unwritable // packs fall back to a DB-only update: we say so and keep the overlay. async function writeToFile(body, song) { const st = song._detailsState; const fields = {}; for (const f of WRITE_FIELDS) fields[f] = String(st[f].value || '').trim(); const status = body.querySelector('[data-df-status]'); const writeBtn = body.querySelector('[data-df-write]'); const saveBtn = body.querySelector('[data-df-save]'); if (writeBtn) writeBtn.disabled = true; if (saveBtn) saveBtn.disabled = true; if (status) { status.className = 'text-xs leading-relaxed text-fb-textDim'; status.textContent = 'Writing to the song file…'; } let ok = false, persisted = false; try { const r = await fetch('/api/song/' + enc(song.filename) + '/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fields), }); ok = r.ok; const j = await r.json().catch(() => ({})); persisted = !!(j && j.persisted); } catch (_) { ok = false; } if (writeBtn) writeBtn.disabled = false; if (saveBtn) saveBtn.disabled = false; if (!ok) { if (status) { status.className = 'text-xs leading-relaxed text-fb-accent'; status.textContent = 'Could not write to the file — try again.'; } return; } // Keep the in-memory song + grid in step with what was *persisted*, not // the raw input: the server coerces a non-numeric/empty year to "" (see // update_song_meta), so mirror that here or the grid card flashes the // typed text (e.g. "abcd") until the next natural refresh corrects it. const applied = { ...fields }; if ('year' in applied) { const yr = /^[+-]?\d+$/.test(applied.year) ? parseInt(applied.year, 10) : 0; applied.year = yr ? String(yr) : ''; } for (const f of WRITE_FIELDS) song[f] = applied[f]; try { window.feedBack?.emit('library:changed', { reason: 'write' }); } catch (_) { } if (persisted) { const clear = {}; for (const f of WRITE_FIELDS) clear[f] = { value: null, locked: !!st[f].locked }; try { await fetch('/api/song/' + enc(song.filename) + '/overrides', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ overrides: clear }), }); } catch (_) { /* the file write still succeeded; the overlay just lingers */ } await renderDetailsTab(body, song); // re-fetch: pack now = written values, overrides cleared const s2 = body.querySelector('[data-df-status]'); if (s2) { s2.className = 'text-xs leading-relaxed text-fb-good'; s2.textContent = 'Written to the song file.'; } } else if (status) { status.className = 'text-xs leading-relaxed text-fb-textDim'; status.textContent = 'Saved to the library — this pack’s file couldn’t be written, so it may revert on a full rescan.'; } } // 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, artist: song.artist, album: song.album }); } }); } // 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; if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details 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. Each says plainly WHICH outcome this // is, so an empty result reads as "it ran, found nothing" (not "broken") // and points at the manual fallback when there's nothing to pick. const note = (html) => { out.innerHTML = '

' + html + '

'; }; const manual = _single ? ' Try Search, or just set the album in Details and the cover in Cover art by hand.' : ' Try Search instead.'; if (status === 412 || (body && body.needs_setup)) { note('Audio identification is off. Turn it on and add a free AcoustID API key in Settings → Library to use it.'); return; } if (status === 404) { note('This pack has no full mix to fingerprint (it\'s chart-only or stems-only).' + manual); return; } if (status === 503) { note('Could not run the fingerprint right now — the audio tool or network is unavailable. Try again in a moment.'); return; } const cands = (body && body.candidates) || []; if (!cands.length) { note('✓ Fingerprinted the audio — but AcoustID has no match for this exact recording (common for obscure or import tracks).' + manual); return; } out.innerHTML = '
✓ Fingerprint matches (AcoustID)
' + 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; if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details 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'], // Audio fingerprinting is opt-in (needs a key + fpcalc), default OFF. ['acoustid-enabled', 'acoustid_enabled'], ].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el); const acoustidKeyEl = document.getElementById('acoustid-api-key'); 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 (acoustidKeyEl) acoustidKeyEl.value = cfg.acoustid_api_key || ''; 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)); acoustidKeyEl?.addEventListener('change', () => save('acoustid_api_key', acoustidKeyEl.value.trim())); 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; } }); } // `defer` runs this at readyState 'interactive' — later scripts have not // evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html). if (document.readyState !== 'complete') { document.addEventListener('DOMContentLoaded', () => { wireSettingsCard(); wireScreenTeardown(); }, { once: true }); } else { wireSettingsCard(); wireScreenTeardown(); } window.__fbMatchReviewChip = refreshChip; window.__fbOpenMatchReview = openModal; window.__fbFixMatch = fixMatch; })();