v3 library: context-menu unification — Fix match, Refresh metadata, Get info, multi-version remove (#718)

* v3 library: context-menu unification — Fix match, Refresh metadata, Get info, multi-version remove (R2)

The ⋮ overflow and the native right-click menu already render from one
builder (openCardMenu), so every entry here lands in BOTH surfaces on
grid cards and tree rows alike — parity is structural, not maintained.

New entries (local library):
- Fix match… — opens the match modal in a single-song mode: no queue,
  no Skip, the search panel open and pre-filled; a pick pins the match
  exactly like the review flow (window.__fbFixMatch).
- Refresh metadata — POST /api/enrichment/refresh/{fn}: resets the
  song's match to unscanned (canonical values + candidates cleared,
  backoff zeroed) and kicks a pass. An EXPLICIT user action, so it may
  discard a manual pin — the automation never does, but the user asking
  for a re-match is the one party who owns that pin. Silent on success.
- Get info… — GET /api/chart/{fn}/fileinfo: file location + folder
  (selectable/copyable under the v3 no-select default), format, size,
  modified; for feedpaks the manifest summary (arrangements, stems,
  cover/lyrics presence, authors, and whichever identity keys are
  actually authored — mbid/isrc/genres/track/disc); plus the match
  verdict ("Matched (text, 96%)" / "Pinned by you" / "Not scanned").
  Under /api/chart because the GET /api/song/{path} catch-all would
  swallow the suffix.
- Remove from library — with the multi-version interstitial: on a
  multi-chart work, "remove the song" is ambiguous (a grouped card
  stands for several files), so a modal lists EVERY version with
  checkboxes (the card's own chart pre-ticked) and deletes exactly
  what was picked — one file or the batch. Single-chart songs keep the
  plain confirm.

Refresh + Get info are demo-mode blocked (cache mutation / path
exposure). apply_enrichment_match now zeroes `attempts` on an explicit
reset to unscanned, matching the stub upsert's identity-change rule.

5 new tests (refresh resets even a manual pin then re-matches via the
fake transport; 404s; fileinfo manifest/identity/match shapes;
traversal guard) + the demo-mode route list. Full-suite failure set
identical to the same-main baseline. tailwind.min.css regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* v3 context menu: review fixes — target displayed chart, harden Get-info

Three fixes from the PR review round:

- songs.js: Fix match / Refresh metadata / Get info now act on the DISPLAYED
  chart (playTarget) rather than the group representative, matching Play. On a
  grouped card where an intrinsic (tuning/arrangement) filter attached a
  display_chart, these three previously fixed/refreshed/showed-info for the
  wrong file. (__remove stays on `song`: it needs the group's work_key/
  chart_count and already pre-ticks the shown chart.)

- server.py fileinfo: 404 ("not a chart") unless the path is a sloppak or a
  loose song. The route previously stat'd ANY file under DLC_DIR, leaking its
  path/size/mtime for e.g. a notes.txt the user keeps there. `format` can no
  longer be "other".

- server.py fileinfo: the directory size sum skips symlinked entries so a link
  inside a song folder can't pull in (or leak the size of) a file outside it.
  Verified on the runtime that rglob does not descend symlinked subdirs.

+1 regression test (non-chart file -> 404).

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:
ChrisBeWithYou
2026-07-02 18:36:00 +02:00
committed by GitHub
co-authored by Claude Fable 5 byrongamatos
parent 0a8c8945ea
commit c7497c758d
6 changed files with 473 additions and 13 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+45 -11
View File
@@ -71,6 +71,7 @@
let _queue = [];
let _idx = 0;
let _lastFocus = null;
let _single = false; // Fix-match mode: one song, no queue navigation
function ensureModal() {
let m = document.getElementById('v3-match-modal');
@@ -111,6 +112,7 @@
function openModal() {
_lastFocus = document.activeElement;
_single = false;
const m = ensureModal();
renderLoading();
m.classList.remove('hidden');
@@ -118,9 +120,34 @@
loadQueue();
}
// Fix-match (R2): the same modal for ONE song — the escape hatch for a
// wrong (or missing) match, reachable from the card's ⋮ / right-click
// menu. No stored candidates are required: the search panel opens
// pre-filled, and a pick pins the match exactly like the review flow.
function fixMatch(song) {
if (!song || !song.filename) return;
_lastFocus = document.activeElement;
_single = true;
_queue = [{
filename: song.filename, title: song.title || song.filename,
artist: song.artist || '', album: song.album || '',
year: song.year || '', duration: song.duration,
mtime: song.mtime, candidates: [],
}];
_idx = 0;
const m = ensureModal();
m.classList.remove('hidden');
document.getElementById('v3-match-overlay')?.classList.remove('hidden');
renderCurrent();
// Straight to the point: the search panel is why this mode exists.
document.getElementById('v3-match-panel')
?.querySelector('[data-mr-search-toggle]')?.click();
}
function closeModal() {
document.getElementById('v3-match-modal')?.classList.add('hidden');
document.getElementById('v3-match-overlay')?.classList.add('hidden');
_single = false;
refreshChip();
if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { } }
_lastFocus = null;
@@ -142,14 +169,14 @@
}
function headerHtml() {
const counter = _queue.length
const counter = (_queue.length && !_single)
? '<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 +
'<h3 class="text-lg font-semibold text-fb-text">' + (_single ? 'Fix match' : 'Match review') + '</h3>' + counter +
'<button data-mr-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>';
}
@@ -238,11 +265,14 @@
'<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>' +
// Candidates (Fix-match mode arrives with none — the search panel
// is its whole point, so the empty header is suppressed).
((song.candidates || []).length
? '<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">' +
@@ -250,14 +280,16 @@
'<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
// Footer actions. Fix-match mode drops Skip (no queue) and the
// accept button when there is nothing to accept — search-result
// rows carry their own pick action.
'<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>' +
(_single ? '' : '<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>' +
(_single ? '' : '<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>') +
((song.candidates || []).length ? '<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);
@@ -308,6 +340,7 @@
// 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) { closeModal(); return; } // Fix-match: done means done
const i = _queue.indexOf(song);
if (i >= 0) _queue.splice(i, 1);
if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1);
@@ -411,4 +444,5 @@
window.__fbMatchReviewChip = refreshChip;
window.__fbOpenMatchReview = openModal;
window.__fbFixMatch = fixMatch;
})();
+185
View File
@@ -866,6 +866,15 @@
{ id: '__playlist', label: 'Add to playlist' },
{ id: '__save', label: 'Save for later' },
...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })),
// Metadata + file actions (R2) — local library only (they all
// address the local DB / filesystem). Both openers (⋮ and
// right-click) share this list, so parity is structural.
...(state.provider === 'local' && song.filename ? [
{ id: '__fixmatch', label: 'Fix match…' },
{ id: '__refreshmeta', label: 'Refresh metadata' },
{ id: '__getinfo', label: 'Get info…' },
{ id: '__remove', label: 'Remove from library', destructive: true },
] : []),
];
menu.innerHTML = rows.map((r) =>
'<button data-act="' + esc(r.id) + '" class="w-full text-left px-3 py-1.5 hover:bg-fb-card/60 ' +
@@ -904,6 +913,19 @@
}
if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; }
if (id === '__save') { if (window.v3Saved) await window.v3Saved.toggle(song.filename); return; }
// Per-chart metadata actions follow the DISPLAYED chart (playTarget),
// like Play — under an intrinsic filter that's the matching member,
// not the group representative. (__remove stays on `song`: it needs
// the group's work_key/chart_count and pre-ticks the shown chart.)
if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; }
if (id === '__refreshmeta') {
// Silent on success (hearing-safe, like the rest of the match
// layer) — the re-match trickles in through the normal pass.
await jsend('POST', '/api/enrichment/refresh/' + enc(playTarget.filename));
return;
}
if (id === '__getinfo') { openGetInfo(playTarget); return; }
if (id === '__remove') { await removeSongsFlow(song); return; }
if (reg) await reg.run(id, song, { source: 'v3-songs' });
}));
// Tree rows ride the (ungrouped) artists endpoint, so they don't carry
@@ -957,6 +979,169 @@
}));
}
// ── Remove from library (R2) ───────────────────────────────────────────--
// On a single-chart song: confirm + delete, as the Details drawer does.
// On a multi-chart work: "remove the song" is ambiguous — a grouped card
// stands for several files — so an interstitial lists EVERY version for
// select/multi-select and deletes exactly what the user picked, one file
// or the whole set.
async function removeSongsFlow(song) {
let wk = song.work_key, count = song.chart_count;
if (count === undefined && song.filename) {
// Flat-mode grid / tree rows don't carry the group annotation.
const w = await jget('/api/chart/' + enc(song.filename) + '/work');
if (w) { wk = w.work_key; count = w.chart_count; }
}
if (wk && count >= 2) {
const data = await jget('/api/work/' + enc(wk) + '/charts');
const charts = (data && data.charts) || [];
if (charts.length >= 2) { openVersionRemoveModal(song, charts); return; }
}
if (!(await _confirmRemove(song.title || song.filename, 1))) return;
await _deleteFiles([song.filename]);
}
async function _confirmRemove(label, n) {
const what = n === 1 ? '"' + label + '"' : n + ' versions of "' + label + '"';
if (typeof window.uiConfirm === 'function') {
return window.uiConfirm({
title: 'Remove from library?',
html: 'Remove ' + esc(what) + ' from your library?' +
'<p class="text-xs text-red-400/90 mt-2">This permanently deletes the file' + (n === 1 ? '' : 's') + ' from disk. This cannot be undone.</p>',
confirmText: 'Remove', cancelText: 'Cancel', danger: true,
});
}
return window.confirm('Remove ' + what + ' from your library? This deletes the file' + (n === 1 ? '' : 's') + ' from disk.');
}
async function _deleteFiles(files) {
for (const fn of files) {
try { await fetch('/api/song/' + enc(fn), { method: 'DELETE' }); } catch (_) { /* keep going */ }
}
try { _groupChanged(); } catch (_) { try { reload(); } catch (_) { /* */ } }
}
// The multi-version interstitial: a centred modal (the Tidy-up idiom)
// listing all charts of the work with checkboxes — the card's own chart
// pre-checked — so "delete" does exactly what the user means, whether
// that's one file or the batch.
function openVersionRemoveModal(song, charts) {
const sel = new Set([song.display_chart ? song.display_chart.filename : song.filename]);
const overlay = document.createElement('div');
overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(); } };
function done() { overlay.remove(); document.removeEventListener('keydown', onKey); }
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(); });
document.body.appendChild(overlay);
function render() {
const rows = charts.map((c) => {
const tl = (typeof window.displayTuningName === 'function')
? window.displayTuningName(c.tuning_name || c.tuning) : (c.tuning_name || '');
const meta = [tl, (c.arrangements || []).map((a) => a.name).join('/'), c.format]
.filter(Boolean).join(' · ');
return '<label class="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-fb-card/50 cursor-pointer">' +
'<input type="checkbox" data-rm="' + esc(c.filename) + '"' + (sel.has(c.filename) ? ' checked' : '') + ' class="w-4 h-4 mt-0.5 accent-fb-primary shrink-0">' +
'<span class="min-w-0"><span class="block text-sm text-fb-text truncate">' + esc(c.title) +
(c.is_representative ? ' <span class="text-fb-primary">●</span>' : '') + '</span>' +
(meta ? '<span class="block text-xs text-fb-textDim truncate">' + esc(meta) + '</span>' : '') +
'<span class="block text-xs text-fb-textDim/70 truncate fb-selectable">' + esc(c.filename) + '</span></span></label>';
}).join('');
const n = sel.size;
overlay.innerHTML =
'<div class="bg-fb-sidebar border border-fb-border/60 rounded-2xl w-full max-w-md shadow-2xl max-h-[85vh] flex flex-col">' +
'<div class="p-5 pb-3"><h3 class="text-base font-semibold text-fb-text">Remove versions of “' + esc(song.title || '') + '”</h3>' +
'<p class="text-xs text-fb-textDim mt-1">This song has ' + charts.length + ' charts. Tick the ones to remove — files are deleted from disk and this cannot be undone.</p></div>' +
'<div class="px-3 overflow-y-auto v3-scroll flex-1 min-h-[6rem]">' + rows + '</div>' +
'<div class="p-5 pt-3 flex items-center justify-between gap-3">' +
'<button data-rm-cancel class="text-sm px-4 py-2 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-xl text-fb-text">Cancel</button>' +
'<button data-rm-go ' + (n ? '' : 'disabled') + ' class="text-sm px-4 py-2 rounded-xl ' + (n ? 'bg-red-900/60 hover:bg-red-900/80 text-red-100' : 'bg-fb-card/50 text-fb-textDim cursor-not-allowed') + '">Remove selected (' + n + ')</button>' +
'</div></div>';
overlay.querySelectorAll('[data-rm]').forEach((cb) => cb.addEventListener('change', () => {
const fn = cb.getAttribute('data-rm');
if (cb.checked) sel.add(fn); else sel.delete(fn);
render();
}));
overlay.querySelector('[data-rm-cancel]')?.addEventListener('click', done);
overlay.querySelector('[data-rm-go]')?.addEventListener('click', async () => {
if (!sel.size) return;
done();
await _deleteFiles([...sel]);
});
}
render();
}
// ── Get info (R2) ──────────────────────────────────────────────────────--
// File location + pack contents + the match verdict, from
// GET /api/chart/{fn}/fileinfo. Paths and identity values are rendered
// with .fb-selectable so they stay copyable under the v3 no-select default.
function _fmtBytes(n) {
if (!Number.isFinite(n)) return '';
const u = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return (i ? n.toFixed(1) : n) + ' ' + u[i];
}
async function openGetInfo(song) {
const info = await jget('/api/chart/' + enc(song.filename) + '/fileinfo');
if (!info) return;
const overlay = document.createElement('div');
overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(); } };
function done() { overlay.remove(); document.removeEventListener('keydown', onKey); }
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(); });
const row = (label, value, selectable) => value
? '<div class="flex gap-3 py-1"><span class="text-xs text-fb-textDim w-24 shrink-0 pt-0.5">' + label + '</span>' +
'<span class="text-sm text-fb-text min-w-0 break-all' + (selectable ? ' fb-selectable' : '') + '">' + esc(value) + '</span></div>'
: '';
const m = info.manifest || {};
const ident = m.identity || {};
const identLine = Object.keys(ident).map((k) =>
k + ': ' + (Array.isArray(ident[k]) ? ident[k].join(', ') : ident[k])).join(' · ');
const match = info.match || {};
const matchLine = match.match_state === 'manual' ? 'Pinned by you'
: match.match_state === 'matched' ? ('Matched (' + (match.match_source || 'auto') +
(match.match_score != null ? ', ' + Math.round(match.match_score * 100) + '%' : '') + ')')
: match.match_state === 'review' ? 'Waiting for review'
: match.match_state === 'failed' ? 'Not matched'
: 'Not scanned yet';
const contents = [
(m.arrangements || []).length ? (m.arrangements.length + ' arrangement' + (m.arrangements.length === 1 ? '' : 's') + ' (' + m.arrangements.join(', ') + ')') : '',
(m.stems || []).length ? ('stems: ' + m.stems.join(', ')) : '',
m.has_cover ? 'cover art' : 'no cover art',
m.has_lyrics ? 'lyrics' : '',
].filter(Boolean).join(' · ');
overlay.innerHTML =
'<div class="bg-fb-sidebar border border-fb-border/60 rounded-2xl w-full max-w-lg shadow-2xl max-h-[85vh] flex flex-col">' +
'<div class="p-5 pb-3 flex items-center justify-between gap-3">' +
'<h3 class="text-base font-semibold text-fb-text truncate">' + esc(song.title || info.filename) + '</h3>' +
'<button data-gi-x aria-label="Close" class="text-fb-textDim hover:text-fb-text text-xl leading-none shrink-0">✕</button></div>' +
'<div class="px-5 pb-5 overflow-y-auto v3-scroll space-y-1">' +
row('Location', info.path, true) +
row('Folder', info.folder, true) +
row('Format', info.format === 'sloppak' ? 'Feedpak' : info.format) +
row('Size', _fmtBytes(info.size)) +
row('Modified', info.mtime ? new Date(info.mtime * 1000).toLocaleString() : '') +
(info.manifest ? (
'<div class="pt-2 mt-2 border-t border-fb-border/50"></div>' +
row('Contents', contents) +
row('Authors', (m.authors || []).filter(Boolean).join(', ')) +
row('Identity', identLine || 'no identity keys authored', !!identLine)
) : '') +
'<div class="pt-2 mt-2 border-t border-fb-border/50"></div>' +
row('Match', matchLine) +
(match.canon_artist ? row('Canonical', [match.canon_artist, match.canon_title, match.canon_album, match.canon_year].filter(Boolean).join(' — '), true) : '') +
'</div></div>';
document.body.appendChild(overlay);
overlay.querySelector('[data-gi-x]')?.addEventListener('click', done);
}
// ── Charts drawer (P5d, design §7.1 UX-2/3) ────────────────────────────────
// The single deep-management surface for a work's charts. A body-appended
// slide-in panel (the filter-drawer idiom; body-appended like the playlist