mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +00:00
library: MusicBrainz text matching + Match-Review UI (P8) (#710)
* library: MusicBrainz text matching + Match-Review UI — P8
Replaces the enrichment plumbing's no-op matcher (P7) with the real
pipeline, per the library-metadata design: a wrong match is worse than a
slow one, so medium confidence goes to a human review queue and never
straight to canonical values.
- lib/mb_match.py (new, pure — no network/DB/server imports): denoise
(author credits, (440Hz)/(Live)/(No Lead)/(v2) parentheticals,
diacritics/punctuation, ACDC / AC DC / AC/DC folding via compacted
token equality), token-set similarity, scoring with year/duration
corroboration bonuses, tier classification (auto needs combined
>= 0.95 AND per-field floors — a perfect-title cover by the wrong
artist, or a chart with no artist, can never auto-match), Lucene
query building, MusicBrainz response normalization.
- Matcher precedence in _enrich_one: content-hash cache copy (another
chart of the same recording matches with no network) -> manifest
mbid (tier 0) / isrc (tier 1) exact keys, feature-detected and
strictly shape-validated, read-only -> text search tiers
(auto / review / failed).
- Lifecycle: review rows store their ranked candidate list (JSON) and
write NO canonical fields until a human accepts; failed rows retry on
an exponential backoff (1 h doubling, 7 d cap) via the attempts
column; user-rejected rows never auto-retry; an identity edit
re-queues anything and resets the backoff; never-overwrite-manual is
enforced inside the single writer (apply_enrichment_match) so no call
path can forget it.
- Network: _mb_http_get is the one transport seam — throttled to
<= 1 req/s through P7's _enrich_throttle, identified with a real
User-Agent from VERSION, and a 503 pauses the whole pass without
burning attempts. Offline guard: no sockets under
FEEDBACK_ENRICH_OFFLINE or FEEDBACK_SKIP_STARTUP_TASKS, so pytest can
never reach MusicBrainz; the pass still stamps identity hashes
(two-phase), which is why every P7 test passes unchanged.
- Routes: GET /api/enrichment/review, POST
/api/enrichment/review/{filename}/accept|reject|pick, GET
/api/enrichment/search (throttled manual-search proxy). All four are
demo-mode blocked.
- Match facet: match= CSV accepted by /api/library AND
/api/library/stats (the A-Z rail's letter counts stay lockstep with
the grid) — review / matched (incl. manual) / unmatched / pending,
the same EXISTS idiom as the mastery facet.
- UI: static/v3/match-review.js (new, self-contained) — an ambient
"N to review" chip beside the song count (rendered only when
non-zero; silent on success, no toasts), and a review drawer on the
filter-drawer slide idiom (Escape + focus trap; row click accepts,
"Not a match" rejects, "Search instead" is the fix-match escape
hatch). songs.js gets the chip mount, a Match filter section, and
session-only match state; also fixes the latent applySavedPrefs bug
where restored filters dropped the mastery key, which made the
filter drawer throw for anyone with saved prefs.
- static/tailwind.min.css regenerated (scripts/build-tailwind.sh) for
the new utility classes; conflicts with sibling PRs resolve by
re-running the script.
Nothing is ever written to pack files — canonical values live only in
the song_enrichment display cache. Cover art caching and acoustic
fingerprinting are follow-up slices.
22 pure unit tests + 19 server tests (fake transport injected over the
_mb_http_get seam) + demo-mode route cases; full-suite failure set
A/B-identical with the change stashed vs applied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* library: match-review modal + configurable auto-apply confidence (P8 R0)
Follow-up to the initial P8 commit, folding in the first round of tester
feedback on the review surface and the matcher's knobs:
- Review GUI is a centred MODAL now, not a sidebar — one chart at a
time (the scraper-review model from media-server / emulation-frontend
apps): the chart's current metadata with explicit amber
"Missing: album / year / cover art" chips (art detected via the art
request failing), candidates each carrying "Adds: year - genres -
ISRC" / "Shows as: ACDC -> AC/DC" per-field chips, and Skip /
Not a match / Search instead / Use selected with prev-next + arrow-key
navigation. Chip + window API surface unchanged, so songs.js needed no
edits for the rework.
- Auto-apply confidence is a SETTING: default drops 0.95 -> 0.90
(mb_match.AUTO_MIN; classify() takes an auto_min override). The
per-field floors are untouched and threshold-independent — a
perfect-title cover by the wrong artist still can't auto-match at any
setting. New validated settings keys: enrich_enabled (bool) +
enrich_auto_threshold (0.5–1.01; >1.0 = "Always review", since a
capped score can equal exactly 1.0). Read once per pass; disabling
gates only the BACKGROUND matcher — manual search/fix stays available.
- Settings -> Library -> "Metadata matching" card: enable toggle,
confidence select (85 / 90 / 95 / Always review), a Match Now button
(new POST /api/enrichment/kick, single-flight like every other kick,
demo-mode blocked), and a live status line fed by the same fetch as
the review chip. Markup in index.html per the v3 settings pattern,
wired by match-review.js, null-guarded so v2 no-ops.
- Review queue orders missing-data charts first — confirming those has
the most to gain; complete charts only stand to be re-labelled.
Tests: threshold moves the auto/review boundary via settings; the
enable toggle gates matching but not the manual proxy; settings
validation; kick route; queue ordering; classify(auto_min=...) floors.
Full-suite failure set byte-identical to the pre-change baseline.
tailwind.min.css regenerated for the modal's utility classes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* fix(library): lock MusicBrainz throttle across sleep + de-dup enrich queue (PR #710 review)
Hold a module-level lock across _enrich_throttle's read/sleep/write so the
background daemon and threadpooled sync search route serialize outbound MB
requests instead of bursting past the 1 req/s limit. De-dup the enrich queue
by filename so a changed-hash failed row isn't processed twice per pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
byrongamatos
parent
7c15cdda66
commit
74cd08f765
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -681,6 +681,28 @@
|
||||
<span id="rescan-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Metadata matching (P8 — wired by static/v3/match-review.js) -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Metadata matching</div>
|
||||
<div class="fb-srow-desc">Matches your charts against MusicBrainz in the background to tidy names, years and genres — display only, your files are never modified. Matches at or above the confidence level apply automatically; the rest wait in the library's review queue. Turning this off never disables manual match fixes.</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-enabled" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Match songs against MusicBrainz</label>
|
||||
<label class="flex items-center gap-2">Auto-apply confidence
|
||||
<select id="enrich-threshold" class="bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<option value="0.85">85% — more auto-matches</option>
|
||||
<option value="0.9" selected>90% (recommended)</option>
|
||||
<option value="0.95">95% — cautious</option>
|
||||
<option value="1.01">Always review</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<button id="enrich-match-now" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Match Now</button>
|
||||
<span id="enrich-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Backup -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
@@ -1118,6 +1140,9 @@
|
||||
<script src="/static/v3/pedal-cables.js"></script>
|
||||
<script src="/static/v3/plugins-page.js"></script>
|
||||
<script src="/static/v3/card-actions-core.js"></script>
|
||||
<!-- Before songs.js: the songs toolbar calls the match-review chip hook
|
||||
on build, so the module must already be registered. -->
|
||||
<script src="/static/v3/match-review.js"></script>
|
||||
<script src="/static/v3/songs.js"></script>
|
||||
<script src="/static/v3/lessons.js"></script>
|
||||
<script src="/static/v3/dashboard.js"></script>
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
// 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 = '<div id="v3-match-panel" class="pointer-events-auto w-full max-w-2xl max-h-[85vh] bg-fb-sidebar border border-fb-border/50 rounded-xl shadow-2xl flex flex-col" role="dialog" aria-label="Match review"></div>';
|
||||
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
|
||||
? '<span class="flex items-center gap-1 text-xs text-fb-textDim">' +
|
||||
'<button data-mr-prev class="px-2 py-1 rounded hover:text-fb-text' + (_idx === 0 ? ' opacity-30' : '') + '" aria-label="Previous">‹</button>' +
|
||||
(_idx + 1) + ' of ' + _queue.length +
|
||||
'<button data-mr-next class="px-2 py-1 rounded hover:text-fb-text' + (_idx >= _queue.length - 1 ? ' opacity-30' : '') + '" aria-label="Next">›</button></span>'
|
||||
: '';
|
||||
return '<div class="flex items-center justify-between gap-3 p-5 pb-3 border-b border-fb-border/40 shrink-0">' +
|
||||
'<h3 class="text-lg font-semibold text-fb-text">Match review</h3>' + counter +
|
||||
'<button data-mr-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>';
|
||||
}
|
||||
|
||||
function renderLoading() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = headerHtml() +
|
||||
'<div class="p-5"><p class="text-sm text-fb-textDim">Loading…</p></div>';
|
||||
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
function renderDone() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = headerHtml() +
|
||||
'<div class="p-5 space-y-2"><p class="text-sm text-fb-text">Nothing waiting for review.</p>' +
|
||||
'<p class="text-xs text-fb-textDim">Medium-confidence matches queue here while the library is matched in the background. Matching options live in Settings → Library.</p></div>';
|
||||
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 <img> 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 '<div class="flex flex-wrap items-center gap-1 pt-1">' +
|
||||
'<span class="text-xs text-fb-textDim">Missing:</span>' +
|
||||
missing.map((f) => '<span class="text-xs px-1.5 py-0.5 rounded border border-amber-400/40 text-amber-300/90 bg-amber-400/10">' + esc(f) + '</span>').join('') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 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 += '<span class="text-xs text-fb-good">Adds: ' + esc(adds.join(' · ')) + '</span>';
|
||||
if (changes.length) html += (html ? ' ' : '') + '<span class="text-xs text-fb-textDim">Shows as: ' + esc(changes.join(' · ')) + '</span>';
|
||||
return html ? '<span class="block truncate pt-0.5">' + html + '</span>' : '';
|
||||
}
|
||||
|
||||
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 '<button data-mr-cand="' + i + '" role="radio" aria-checked="' + (selected ? 'true' : 'false') + '" class="w-full text-left px-3 py-2 rounded-md border ' +
|
||||
(selected ? 'border-fb-primary bg-fb-primary/10' : 'border-fb-border/50 bg-gray-800/50 hover:border-fb-primary/60') + '">' +
|
||||
'<span class="flex items-baseline justify-between gap-2">' +
|
||||
'<span class="text-sm text-fb-text truncate">' + esc(c.title) + '</span>' +
|
||||
'<span class="text-xs text-fb-textDim shrink-0">' + esc(pct) + '</span></span>' +
|
||||
'<span class="block text-xs text-fb-textDim truncate">' + esc(meta) + '</span>' +
|
||||
diffChips(song, c) +
|
||||
'</button>';
|
||||
}
|
||||
|
||||
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() +
|
||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll">' +
|
||||
// The chart being matched
|
||||
'<div class="flex items-start gap-3">' +
|
||||
'<img data-mr-art src="' + esc(artUrl(song)) + '" alt="" loading="lazy" class="w-16 h-16 rounded-lg object-cover bg-fb-card shrink-0">' +
|
||||
'<div class="min-w-0">' +
|
||||
'<div class="text-base text-fb-text font-medium truncate">' + esc(song.title) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim truncate">' + esc(sub) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim/70 truncate" title="' + esc(song.filename) + '">' + esc(song.filename) + '</div>' +
|
||||
missingChips(song) +
|
||||
'</div></div>' +
|
||||
// Candidates
|
||||
'<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Candidates (MusicBrainz)</div>' +
|
||||
(song.candidates || []).map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') +
|
||||
'</div>' +
|
||||
// Search-instead panel
|
||||
'<div data-mr-search-panel class="hidden space-y-2">' +
|
||||
'<div class="flex gap-2">' +
|
||||
'<input data-mr-search-input type="text" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary" placeholder="Artist – Title">' +
|
||||
'<button data-mr-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button></div>' +
|
||||
'<div data-mr-search-results class="space-y-1"></div></div>' +
|
||||
'</div>' +
|
||||
// Footer actions
|
||||
'<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
|
||||
'<div class="flex items-center gap-3">' +
|
||||
'<button data-mr-reject class="text-sm text-fb-textDim hover:text-fb-text">Not a match</button>' +
|
||||
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button></div>' +
|
||||
'<div class="flex items-center gap-2">' +
|
||||
'<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>' +
|
||||
'<button data-mr-accept class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Use selected</button>' +
|
||||
'</div></div>';
|
||||
|
||||
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 = '<p class="text-xs text-fb-textDim">Searching…</p>';
|
||||
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 = '<p class="text-xs text-fb-textDim">MusicBrainz is unavailable — try again later.</p>';
|
||||
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 = '<p class="text-xs text-fb-textDim">No results.</p>';
|
||||
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;
|
||||
})();
|
||||
+24
-3
@@ -59,7 +59,7 @@
|
||||
artist: '', album: '',
|
||||
grouping: true, // one card per song (multi-chart grouping); persisted
|
||||
|
||||
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [] },
|
||||
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [] },
|
||||
page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [],
|
||||
artistCatalog: [], renderedHash: '',
|
||||
scrollBound: false,
|
||||
@@ -110,6 +110,7 @@
|
||||
const f = state.filters;
|
||||
return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length +
|
||||
(f.lyrics ? 1 : 0) + f.tunings.length + (f.mastery ? f.mastery.length : 0) +
|
||||
(f.match ? f.match.length : 0) +
|
||||
(state.artist ? 1 : 0) + (state.album ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -133,6 +134,7 @@
|
||||
lyrics: f.lyrics || '',
|
||||
tunings: [...(f.tunings || [])].sort(),
|
||||
mastery: [...(f.mastery || [])].sort(),
|
||||
match: [...(f.match || [])].sort(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -155,10 +157,15 @@
|
||||
const f = saved.filters;
|
||||
if (f && typeof f === 'object') {
|
||||
const arr = (x) => (Array.isArray(x) ? x.slice() : []);
|
||||
// mastery + match are session-only facets (deliberately not
|
||||
// persisted), but the restored object must still CARRY the keys —
|
||||
// the filter drawer indexes f.mastery/f.match unconditionally, so
|
||||
// dropping them here breaks the drawer for anyone with saved prefs.
|
||||
state.filters = {
|
||||
arr_has: arr(f.arr_has), arr_lacks: arr(f.arr_lacks),
|
||||
stem_has: arr(f.stem_has), stem_lacks: arr(f.stem_lacks),
|
||||
lyrics: f.lyrics || '', tunings: arr(f.tunings),
|
||||
mastery: [], match: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -263,6 +270,7 @@
|
||||
if (f.lyrics) p.set('has_lyrics', f.lyrics);
|
||||
if (f.tunings.length) p.set('tunings', f.tunings.join(','));
|
||||
if (f.mastery && f.mastery.length) p.set('mastery', f.mastery.join(','));
|
||||
if (f.match && f.match.length) p.set('match', f.match.join(','));
|
||||
Object.entries(extra || {}).forEach(([k, v]) => p.set(k, v));
|
||||
return p;
|
||||
}
|
||||
@@ -2029,6 +2037,9 @@
|
||||
section('Lyrics', ['', '1', '0'].map((v) => '<button data-lyrics="' + v + '" class="px-2 py-1 rounded-md text-xs border ' + (f.lyrics === v ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + (v === '' ? 'Any' : v === '1' ? 'Has lyrics' : 'No lyrics') + '</button>').join('')) +
|
||||
// Progress (mastery bands) — multi-select; server filters via song_stats.
|
||||
section('Progress', [['mastered', 'Mastered'], ['in_progress', 'In progress'], ['not_started', 'Not started']].map((it) => '<button data-mastery="' + it[0] + '" class="px-2 py-1 rounded-md text-xs border ' + (f.mastery.includes(it[0]) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + it[1] + '</button>').join('')) +
|
||||
// Match (P8) — the song's metadata-match lifecycle state, a triage
|
||||
// facet for the enrichment layer. Session-only, like Progress.
|
||||
section('Match', [['review', 'To review'], ['matched', 'Matched'], ['unmatched', 'Unmatched'], ['pending', 'Not scanned']].map((it) => '<button data-match="' + it[0] + '" class="px-2 py-1 rounded-md text-xs border ' + (f.match.includes(it[0]) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + it[1] + '</button>').join('')) +
|
||||
section('Tuning', (state.tuningNames || []).map((t) => {
|
||||
// Filter on the server's grouping key (raw offsets for customs)
|
||||
// so two "Custom Tuning" entries are distinct; show their target
|
||||
@@ -2080,11 +2091,12 @@
|
||||
renderDrawer();
|
||||
reload(); // re-fetches grid + rail with/without group=1, saves prefs
|
||||
});
|
||||
d.querySelectorAll('[data-match]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-match'); const i = f.match.indexOf(v); if (i >= 0) f.match.splice(i, 1); else f.match.push(v); renderDrawer(); }));
|
||||
d.querySelector('[data-drawer-save]')?.addEventListener('click', saveCurrentAsCollection);
|
||||
d.querySelector('[data-drawer-tidy]')?.addEventListener('click', openArtistTidyUp);
|
||||
d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
|
||||
d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => {
|
||||
state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [] };
|
||||
state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [] };
|
||||
state.artist = '';
|
||||
state.album = '';
|
||||
renderDrawer();
|
||||
@@ -2540,7 +2552,12 @@
|
||||
'<div class="max-w-7xl mx-auto px-6 md:px-8 pb-8">' +
|
||||
'<div id="v3-songs-toolbar" class="sticky z-20 -mx-6 md:-mx-8 px-6 md:px-8 py-3 mb-4 bg-fb-sidebar/95 backdrop-blur border-b border-fb-border/40">' +
|
||||
'<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">' +
|
||||
'<div><p class="text-fb-textDim text-sm" id="v3-songs-count"></p></div>' +
|
||||
// The match-review chip is ambient tool-state (design §11): only
|
||||
// rendered when matches are waiting, silent otherwise. Populated +
|
||||
// shown by match-review.js (window.__fbMatchReviewChip), which
|
||||
// also owns the drawer the click opens.
|
||||
'<div class="flex items-baseline gap-3"><p class="text-fb-textDim text-sm" id="v3-songs-count"></p>' +
|
||||
'<button id="v3-songs-match-review" class="hidden text-xs text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-full px-2.5 py-0.5"></button></div>' +
|
||||
'<div class="flex flex-wrap gap-2">' +
|
||||
(providers.length > 1 ? '<select id="v3-songs-provider" class="' + ctrl + '">' + provOpts + '</select>' : '') +
|
||||
'<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
|
||||
@@ -2599,6 +2616,10 @@
|
||||
});
|
||||
byId('v3-songs-filters').addEventListener('click', openDrawer);
|
||||
byId('v3-songs-overlay').addEventListener('click', closeDrawer);
|
||||
// Match-review chip: feature-detected (match-review.js owns the
|
||||
// drawer + the count; absent → the chip just stays hidden).
|
||||
byId('v3-songs-match-review')?.addEventListener('click', () => { if (window.__fbOpenMatchReview) window.__fbOpenMatchReview(); });
|
||||
if (window.__fbMatchReviewChip) window.__fbMatchReviewChip();
|
||||
byId('v3-songs-upload').addEventListener('click', () => {
|
||||
const legacy = document.getElementById('upload-songs-file');
|
||||
// Upload targets the LOCAL library + scan; watchUploadScan refreshes
|
||||
|
||||
Reference in New Issue
Block a user