feat(v3 library): "Refresh Metadata" button + per-view re-match + filename-artist seed (#764)

Adds a media-server-style "Refresh Metadata" control to the Songs toolbar
(beside "⟳ Refresh") — the metadata counterpart to a file scan.

- Re-matches the songs currently SHOWN (the visible grid window) against
  MusicBrainz: a per-view refresh that's visible even on an already-matched
  library. The button doubles as Stop while a pass runs; a batch progress bar
  + per-tile queued→working→done badges show what's happening. User-pinned
  `manual` matches are never re-matched.
- Backend: POST /api/enrichment/{cancel,states,rematch}; /status gains
  total/matched/current/cancelling; a cooperative cancel Event is checked
  between songs in the match + art phases so Stop halts without waiting for
  the whole queue. `states` is read-only (open); `cancel`/`rematch` are
  demo-blocked.
- Matcher: when a pack's `artist` field is blank (common in community
  charts), derive artist/title from the CDLC `Artist_Song-Title` filename
  convention as a SEARCH SEED so text matching can identify it — the displayed
  values still come only from the confirmed MusicBrainz match, nothing
  estimated is shown as author-set. Rescues blank-artist packs that otherwise
  always failed.

Tests: enrichment_states_for, the three new routes, cancel-halts-a-pass,
kick-clears-stale-cancel, filename parse, blank-artist seeding, and
present-artist-not-overridden.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ChrisBeWithYou
2026-07-05 00:18:18 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 73c5ab149e
commit fa2d12222a
3 changed files with 553 additions and 10 deletions
+229 -2
View File
@@ -490,6 +490,31 @@
'<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/></svg>' + pct + '%</span>';
}
// ── Metadata-refresh per-tile state (the "Refresh Metadata" batch) ─────────
// A transient badge painted ONLY while a metadata refresh is running: the
// songs actually being (re)matched animate queued → working → done. Keyed by
// the card's data-fn (= the local filename the enrichment cache keys on).
// Empty for every song outside a refresh, so an idle card is byte-identical
// to before (keeps the windowed grid's height math untouched). Honest state
// transitions, NOT a fake per-song %: a match is binary (design §11).
const _metaTile = {}; // fn -> 'queued' | 'working' | 'done' | 'nochange'
function enrichBadge(fn) {
const st = _metaTile[fn];
if (!st) return '';
const M = {
queued: ['bg-black/60 text-fb-textDim', '• Queued'],
working: ['bg-fb-primary text-white', '⟳ Matching…'],
done: ['bg-fb-good/90 text-black', '✓ Updated'],
nochange: ['bg-black/60 text-fb-textDim', '— No match'],
};
const conf = M[st] || M.queued;
// top-10 clears the tuning chip (top-2) in both normal and select mode;
// z-20 sits it above the art. Non-interactive so it never eats a click.
return '<span class="v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight pointer-events-none">' +
conf[1] + '</span>';
}
// After a song is scored, the badge for that card is stale until the next
// full render(). Refresh state.accuracy from the server and patch the badge
// of any currently-rendered card/row in place (grid + tree). `_dirtyScores`
@@ -845,7 +870,7 @@
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
'<img src="' + esc(artUrl(shown)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + overlay +
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key) + overlay +
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
inlineBtns +
'<button data-fav data-fav-idle="text-white" title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
@@ -3402,7 +3427,13 @@
// 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>' +
'<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>' +
// Batch progress for the Refresh Metadata button (shown only while a
// pass runs). A real songs-processed ratio, not a fake per-song %.
'<span id="v3-meta-progress" class="hidden items-center gap-2 text-xs text-fb-textDim">' +
'<span id="v3-meta-progress-label"></span>' +
'<span class="inline-block w-24 rounded-full bg-fb-border/40 overflow-hidden align-middle" style="height:6px"><span id="v3-meta-progress-fill" class="block h-full bg-fb-primary transition-all" style="width:0%"></span></span>' +
'</span></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>' +
@@ -3413,6 +3444,7 @@
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
'<button id="v3-songs-refresh" title="Refresh library (scan for new songs)" class="' + ctrl + '">⟳ Refresh</button>' +
'<button id="v3-songs-refresh-meta" title="Refresh metadata for the songs shown (re-match titles, artwork &amp; more)" class="' + ctrl + '">🏷 Metadata</button>' +
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
'</div></div></div>' +
// Practice-aware library home: a repertoire progress meter + a
@@ -3450,6 +3482,7 @@
state.artist = '';
state.album = '';
try { sm.libraryProviders && await sm.libraryProviders.select(state.provider); } catch (err) { /* */ }
_updateMetaBtnVisibility(); // enrichment is local-only
await loadArtistCatalog();
refreshArtistAlbumSelects();
reload();
@@ -3479,6 +3512,10 @@
});
byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode));
byId('v3-songs-refresh')?.addEventListener('click', refreshLibrary);
// Refresh Metadata: local-only, so hide it for remote providers. The
// button doubles as its own Stop while a pass runs (see onMetaBtnClick).
byId('v3-songs-refresh-meta')?.addEventListener('click', onMetaBtnClick);
_updateMetaBtnVisibility();
// Reflect a scan already in progress (Settings button or a background
// pass) on the Refresh button, so its state isn't just tied to clicks here.
(async () => {
@@ -3488,6 +3525,15 @@
if (sd && sd.running) { _setRefreshState(sd); _watchScan({ announce: false }); }
} catch (e) { /* */ }
})();
// Reflect an enrichment pass already running (Settings "Match now" or a
// post-scan background pass) on the Metadata button + bar.
(async () => {
try {
const r = await fetch('/api/enrichment/status');
const es = r.ok ? await r.json() : null;
if (es && es.running) { _setMetaState(es); _watchEnrich({ announce: false }); }
} catch (e) { /* */ }
})();
// Capture-phase select-mode guard on each persistent list host. Without
// it, clicking a card/row (or its arrangement chip) in select mode falls
@@ -3706,6 +3752,187 @@
}, 1000);
}
// ── Refresh Metadata (batch enrichment) from the Songs toolbar ─────────────
// The metadata counterpart to ⟳ Refresh (which scans FILES): matches
// titles/artist/album/artwork against MusicBrainz for the songs that still
// need it — the ambient background matcher, run on demand (a media-server's
// "Refresh Metadata" vs "Scan Files"). Mirrors the scan machinery: a 1 Hz
// poll of /api/enrichment/status drives the button + batch bar, while
// /api/enrichment/states drives per-tile badges on the visible window.
// Enrichment is local-only, so the button hides for remote providers.
let _metaPoll = null;
let _metaRunning = false;
function _updateMetaBtnVisibility() {
const btn = document.getElementById('v3-songs-refresh-meta');
if (btn) btn.style.display = (state.provider === 'local') ? '' : 'none';
}
// The local filenames the grid is currently SHOWING (data-fn is the local
// filename the enrichment cache keys on). The grid is windowed, so this is
// the visible slice only — exactly what the per-tile poll should cover.
function _visibleLocalFilenames() {
const grid = document.getElementById('v3-songs-grid');
if (!grid) return [];
return [...grid.querySelectorAll('[data-fn]')]
.map((el) => el.getAttribute('data-fn')).filter(Boolean);
}
// Set/clear one card's live badge (recycled cards re-derive from _metaTile on
// the next paint, so update the map too — mirrors _patchCardFav).
function _patchCardEnrich(fn, st) {
if (st) _metaTile[fn] = st; else delete _metaTile[fn];
const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => {
const el = play.querySelector('.v3-meta-tile');
const html = enrichBadge(fn);
if (!html) { if (el) el.remove(); return; }
if (el) el.outerHTML = html; else play.insertAdjacentHTML('beforeend', html);
});
}
function _clearMetaTiles() {
Object.keys(_metaTile).forEach((fn) => { delete _metaTile[fn]; });
document.querySelectorAll('.v3-meta-tile').forEach((el) => el.remove());
}
// Drive the button (which doubles as Stop) + the batch bar from a status body.
function _setMetaState(es) {
const btn = document.getElementById('v3-songs-refresh-meta');
const prog = document.getElementById('v3-meta-progress');
const fill = document.getElementById('v3-meta-progress-fill');
const label = document.getElementById('v3-meta-progress-label');
if (!btn) return;
const running = !!(es && es.running);
_metaRunning = running;
if (running) {
const total = (es && es.total) || 0, done = (es && es.matched) || 0;
const cancelling = !!(es && es.cancelling);
btn.textContent = cancelling ? 'Stopping…' : ('⏹ Stop' + (total ? ' · ' + done + '/' + total : ''));
btn.disabled = cancelling;
btn.classList.toggle('opacity-70', cancelling);
btn.title = cancelling ? 'Stopping after the current song…' : 'Stop refreshing metadata';
if (prog) {
prog.classList.remove('hidden'); prog.classList.add('flex');
if (label) label.textContent = total ? ('Matching metadata ' + done + '/' + total) : 'Matching metadata…';
// Real songs-processed ratio; a tiny sliver while the queue size
// is still being computed (phase 1) so the bar isn't dead-empty.
if (fill) fill.style.width = (total ? Math.round((done / total) * 100) : 6) + '%';
}
} else {
btn.textContent = '🏷 Metadata';
btn.disabled = false;
btn.classList.remove('opacity-70');
btn.title = 'Refresh metadata for the songs shown (re-match titles, artwork & more)';
if (prog) { prog.classList.add('hidden'); prog.classList.remove('flex'); }
}
}
// Completion toast — reuse the shared fbNotify surface (visual-only, so
// hearing-safe for free). Honest + never-punishing copy, in-game suppressed.
function _metaCompleteToast(es) {
const active = document.querySelector('.screen.active');
if (active && active.id === 'player') return;
if (!window.fbNotify) return;
const matched = (es && es.matched) || 0;
const msg = matched
? (matched + ' song' + (matched === 1 ? '' : 's') + ' matched')
: 'Your library metadata is up to date';
try { window.fbNotify.show({ title: 'Metadata refresh complete', message: msg, icon: '🏷️', accent: '#22C55E' }); } catch (e) { /* */ }
}
// Poll enrichment status (button + bar) AND the visible window's per-song
// states (tile badges) until the pass finishes. announce:false = we only
// attached to a pass we didn't start (no toast unless it actually changed
// something).
function _watchEnrich(opts) {
if (_metaPoll) return;
const announce = !opts || opts.announce !== false;
let sawRunning = false, ticks = 0, lastStatus = null;
_metaPoll = setInterval(async () => {
ticks++;
let es = null;
try { const r = await fetch('/api/enrichment/status'); if (r.ok) es = await r.json(); } catch (e) { /* */ }
if (es) { lastStatus = es; _setMetaState(es); if (es.running) sawRunning = true; }
// Per-tile badges: only songs we're tracking (seeded 'queued'). A
// tile flips to 'working' when it's the current song, then to
// 'done' (matched) / 'nochange' (failed) once it leaves unscanned.
if (Object.keys(_metaTile).length) {
const fns = _visibleLocalFilenames();
try {
const r = await fetch('/api/enrichment/states', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames: fns }),
});
if (r.ok) {
const j = await r.json();
const states = j.states || {}, current = j.current;
fns.forEach((fn) => {
if (!(fn in _metaTile)) return;
if (fn === current) { _patchCardEnrich(fn, 'working'); return; }
const s = states[fn];
if (s && s !== 'unscanned' && s !== 'pending') {
_patchCardEnrich(fn, s === 'failed' ? 'nochange' : 'done');
}
});
}
} catch (e) { /* */ }
}
// Cap at 20 min (a ~1000-song trickle at ≤1/s is ~17 min); a
// user-initiated no-op that never saw a running pass ends quickly.
const noopDone = announce && !sawRunning && ticks >= 3;
if ((sawRunning && es && !es.running) || noopDone || ticks >= 1200) {
clearInterval(_metaPoll); _metaPoll = null;
_setMetaState(null);
const changed = sawRunning && lastStatus && (lastStatus.matched || 0) > 0;
if (announce || changed) _metaCompleteToast(lastStatus);
// Let the final 'done' badges register, then clear + (if anything
// matched) reload so new canonical titles/art show.
setTimeout(() => {
_clearMetaTiles();
if (changed && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'enrich', matched: lastStatus.matched }); } catch (e) { /* */ } }
}, 1600);
}
}, 1000);
}
// Force a fresh re-match of the songs currently SHOWN (the visible grid
// window) — a media-server-style per-view "Refresh Metadata". Resets those
// songs and re-fetches, so it's visible even on an already-matched library.
// Manual pins are skipped server-side; scoped to the visible set so it's
// fast + can't blow the whole rate budget.
async function refreshMetadata() {
if (_metaRunning || _metaPoll) return; // already running
const fns = _visibleLocalFilenames();
_clearMetaTiles();
if (!fns.length) { _metaCompleteToast({ matched: 0 }); return; }
let queued = [];
try {
const r = await fetch('/api/enrichment/rematch', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames: fns }),
});
if (r.ok) queued = (await r.json()).queued || [];
} catch (e) { /* offline → nothing queued */ }
// Badge exactly what the server queued (everything visible except your
// manual pins). Nothing queued = all visible songs are pinned/unknown.
queued.forEach((fn) => _patchCardEnrich(fn, 'queued'));
if (!queued.length) { _metaCompleteToast({ matched: 0 }); return; }
_watchEnrich({ announce: true });
}
async function stopMetadata() {
try { await fetch('/api/enrichment/cancel', { method: 'POST' }); } catch (e) { /* */ }
_setMetaState({ running: true, cancelling: true }); // optimistic; the poll confirms
}
// The Metadata button toggles role: kick a refresh when idle, Stop when a
// pass is running.
function onMetaBtnClick() {
if (_metaRunning) stopMetadata(); else refreshMetadata();
}
// Topbar search drives this screen.
async function search(q) {
state.q = q || '';