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
+98 -1
View File
@@ -240,6 +240,10 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
("POST", re.compile(r"^/api/enrichment/review/.+$")), ("POST", re.compile(r"^/api/enrichment/review/.+$")),
("POST", re.compile(r"^/api/enrichment/kick$")), ("POST", re.compile(r"^/api/enrichment/kick$")),
("GET", re.compile(r"^/api/enrichment/search$")), ("GET", re.compile(r"^/api/enrichment/search$")),
# Context menus (R2): the per-song re-match mutates the cache + spends
# rate limit; Get-info exposes filesystem paths.
("POST", re.compile(r"^/api/enrichment/refresh/.+$")),
("GET", re.compile(r"^/api/chart/.+/fileinfo$")),
] ]
@@ -2810,7 +2814,10 @@ class MetadataDB:
"FROM song_enrichment WHERE filename = ?", (filename,)).fetchone() "FROM song_enrichment WHERE filename = ?", (filename,)).fetchone()
if cur and cur[0] == "manual" and not allow_manual_overwrite: if cur and cur[0] == "manual" and not allow_manual_overwrite:
return False return False
attempts = int(cur[1] or 0) if cur else 0 # An explicit reset to `unscanned` (Refresh metadata) is a fresh
# start — the failure backoff restarts with the identity, same as
# the stub upsert's hash-change rule.
attempts = 0 if state == "unscanned" else (int(cur[1] or 0) if cur else 0)
if bump_attempts: if bump_attempts:
attempts += 1 attempts += 1
fetched_at = (time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) fetched_at = (time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@@ -6367,6 +6374,23 @@ def api_enrichment_kick():
return {"started": _kick_enrich()} return {"started": _kick_enrich()}
@app.post("/api/enrichment/refresh/{filename:path}")
def api_enrichment_refresh(filename: str):
"""The context menu's "Refresh metadata": reset THIS song's match to
unscanned (canonical values + candidates cleared, backoff zeroed) and
kick a pass so it re-matches immediately. 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."""
song = meta_db.enrichment_song_row(filename)
if not song:
raise HTTPException(status_code=404, detail="unknown song")
h = meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
meta_db.apply_enrichment_match(filename, h, "unscanned",
allow_manual_overwrite=True)
return {"ok": True, "started": _kick_enrich()}
@app.get("/api/enrichment/review") @app.get("/api/enrichment/review")
def api_enrichment_review(limit: int = 200): def api_enrichment_review(limit: int = 200):
"""The Match-Review queue: songs whose text match landed in the medium- """The Match-Review queue: songs whose text match landed in the medium-
@@ -7169,6 +7193,79 @@ def api_get_chart_work(filename: str):
return meta_db.chart_work(filename) return meta_db.chart_work(filename)
@app.get("/api/chart/{filename:path}/fileinfo")
def api_chart_fileinfo(filename: str):
"""The context menu's "Get info": where the file lives + what the pack
contains. Under /api/chart the GET /api/song/{path} catch-all would
swallow a /api/song//fileinfo suffix. Read-only; demo-mode blocks it
because it exposes filesystem paths."""
dlc = _get_dlc_dir()
if not dlc:
raise HTTPException(status_code=404, detail="not configured")
p = _resolve_dlc_path(dlc, filename)
if p is None:
raise HTTPException(status_code=403, detail="forbidden")
if not p.exists():
raise HTTPException(status_code=404, detail="not found")
# Restrict to actual charts — sloppak or loose song. Without this the route
# would stat ANY file the user happens to keep under DLC_DIR (e.g. notes),
# leaking its path/size; the app only recognises these two song formats.
is_pak = sloppak_mod.is_sloppak(p)
is_loose = loosefolder_mod.is_loose_song(p)
if not (is_pak or is_loose):
raise HTTPException(status_code=404, detail="not a chart")
st = p.stat()
info = {
"filename": filename,
"path": str(p),
"folder": str(p.parent),
"format": "sloppak" if is_pak else "loose",
# Directory-form songs report the tree's total (covers loose folders
# and dir-form paks); zip-form paks report the archive size. Symlinked
# entries are skipped so a link inside the folder can't pull in — or
# leak the size of — a file outside it.
"size": (st.st_size if p.is_file()
else sum(f.stat().st_size for f in p.rglob("*")
if f.is_file() and not f.is_symlink())),
"mtime": st.st_mtime,
}
if is_pak:
try:
m = sloppak_mod.load_manifest(p) or {}
except Exception:
m = {}
arrs = [str(a.get("name", a.get("id", ""))) for a in (m.get("arrangements") or [])
if isinstance(a, dict)]
stems = [str(s.get("id", "")) for s in (m.get("stems") or []) if isinstance(s, dict)]
try:
has_cover = sloppak_mod.read_cover_bytes(p, m) is not None
except Exception:
has_cover = False
# The optional identity/catalog keys, listed only when present — the
# Get-info panel's "what this pack carries vs what's missing" readout.
identity = {k: m.get(k) for k in
("mbid", "isrc", "genres", "track", "disc", "album_artist",
"feedpak_version", "language")
if m.get(k) not in (None, "", [])}
info["manifest"] = {
"title": str(m.get("title", "")), "artist": str(m.get("artist", "")),
"album": str(m.get("album", "")), "year": str(m.get("year", "") or ""),
"arrangements": arrs, "stems": stems,
"has_cover": has_cover, "has_lyrics": bool(m.get("lyrics")),
"authors": [a.get("name", "") if isinstance(a, dict) else str(a)
for a in (m.get("authors") or [])],
"identity": identity,
}
# The enrichment verdict, so Get info can say "Matched (auto, 96%)" /
# "Pinned by you" / "Not matched" alongside the file facts.
row = meta_db.get_enrichment(filename)
if row:
info["match"] = {k: row.get(k) for k in
("match_state", "match_source", "match_score",
"canon_artist", "canon_title", "canon_album", "canon_year")}
return info
@app.get("/api/library/albums") @app.get("/api/library/albums")
async def list_library_albums(q: str = "", page: int = 0, size: int = 120, async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
favorites: int = 0, format: str = "", favorites: int = 0, format: str = "",
+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 _queue = [];
let _idx = 0; let _idx = 0;
let _lastFocus = null; let _lastFocus = null;
let _single = false; // Fix-match mode: one song, no queue navigation
function ensureModal() { function ensureModal() {
let m = document.getElementById('v3-match-modal'); let m = document.getElementById('v3-match-modal');
@@ -111,6 +112,7 @@
function openModal() { function openModal() {
_lastFocus = document.activeElement; _lastFocus = document.activeElement;
_single = false;
const m = ensureModal(); const m = ensureModal();
renderLoading(); renderLoading();
m.classList.remove('hidden'); m.classList.remove('hidden');
@@ -118,9 +120,34 @@
loadQueue(); 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() { function closeModal() {
document.getElementById('v3-match-modal')?.classList.add('hidden'); document.getElementById('v3-match-modal')?.classList.add('hidden');
document.getElementById('v3-match-overlay')?.classList.add('hidden'); document.getElementById('v3-match-overlay')?.classList.add('hidden');
_single = false;
refreshChip(); refreshChip();
if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { } } if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { } }
_lastFocus = null; _lastFocus = null;
@@ -142,14 +169,14 @@
} }
function headerHtml() { function headerHtml() {
const counter = _queue.length const counter = (_queue.length && !_single)
? '<span class="flex items-center gap-1 text-xs text-fb-textDim">' + ? '<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>' + '<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 + (_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>' '<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">' + 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>'; '<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>' + '<div class="text-xs text-fb-textDim/70 truncate" title="' + esc(song.filename) + '">' + esc(song.filename) + '</div>' +
missingChips(song) + missingChips(song) +
'</div></div>' + '</div></div>' +
// Candidates // Candidates (Fix-match mode arrives with none — the search panel
'<div class="space-y-1" role="radiogroup" aria-label="Candidates">' + // is its whole point, so the empty header is suppressed).
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Candidates (MusicBrainz)</div>' + ((song.candidates || []).length
(song.candidates || []).map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') + ? '<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
'</div>' + '<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 // Search-instead panel
'<div data-mr-search-panel class="hidden space-y-2">' + '<div data-mr-search-panel class="hidden space-y-2">' +
'<div class="flex gap-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>' + '<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 data-mr-search-results class="space-y-1"></div></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 justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
'<div class="flex items-center gap-3">' + '<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>' + '<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">' + '<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>' + (_single ? '' : '<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>' + ((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>'; '</div></div>';
wireCurrent(panel, song); wireCurrent(panel, song);
@@ -308,6 +340,7 @@
// Silent-on-success: the chart just leaves the queue and the next one // 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. // renders; the last one renders the done state. No toasts, no sounds.
function settle(song) { function settle(song) {
if (_single) { closeModal(); return; } // Fix-match: done means done
const i = _queue.indexOf(song); const i = _queue.indexOf(song);
if (i >= 0) _queue.splice(i, 1); if (i >= 0) _queue.splice(i, 1);
if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1); if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1);
@@ -411,4 +444,5 @@
window.__fbMatchReviewChip = refreshChip; window.__fbMatchReviewChip = refreshChip;
window.__fbOpenMatchReview = openModal; window.__fbOpenMatchReview = openModal;
window.__fbFixMatch = fixMatch;
})(); })();
+185
View File
@@ -866,6 +866,15 @@
{ id: '__playlist', label: 'Add to playlist' }, { id: '__playlist', label: 'Add to playlist' },
{ id: '__save', label: 'Save for later' }, { id: '__save', label: 'Save for later' },
...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })), ...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) => 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 ' + '<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 === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; }
if (id === '__save') { if (window.v3Saved) await window.v3Saved.toggle(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' }); if (reg) await reg.run(id, song, { source: 'v3-songs' });
})); }));
// Tree rows ride the (ungrouped) artists endpoint, so they don't carry // 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) ──────────────────────────────── // ── Charts drawer (P5d, design §7.1 UX-2/3) ────────────────────────────────
// The single deep-management surface for a work's charts. A body-appended // 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 // slide-in panel (the filter-drawer idiom; body-appended like the playlist
+141
View File
@@ -0,0 +1,141 @@
"""Tests for the R2 context-menu backend: per-song "Refresh metadata"
(explicit re-match reset + kick) and "Get info" (file location + pack
contents). The refresh flow reuses the P8 fake-transport pattern nothing
here opens a socket."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _put(server, fn, title="Thunderstruck", artist="AC/DC", album="", year="1990",
duration=292):
server.meta_db.put(fn, 0, 0, {
"title": title, "artist": artist, "album": album, "year": year,
"duration": duration, "arrangements": [{"name": "Lead", "index": 0}],
})
def make_sloppak(server, name, extra_yaml="", title="Thunderstruck", artist="AC/DC"):
d = server.DLC_DIR / name
d.mkdir(parents=True)
(d / "manifest.yaml").write_text(
f"title: {title}\nartist: {artist}\nduration: 292\n"
"arrangements:\n - name: Lead\n id: lead\n"
"stems:\n - id: full\n file: stems/full.ogg\n" + extra_yaml,
encoding="utf-8")
_put(server, name, title=title, artist=artist)
return d
# ── Refresh metadata ──────────────────────────────────────────────────────────
def test_refresh_resets_even_a_manual_pin_and_rematches(server, client, monkeypatch):
_put(server, "a.sloppak")
server.meta_db.set_enrichment_manual(
"a.sloppak", {"recording_id": "old-pin", "title": "Thunderstruck",
"artist": "AC/DC"}, source="search")
# The reset itself is synchronous; the kicked pass runs on a daemon
# thread, so assert the reset here and drive the re-match inline below.
r = client.post("/api/enrichment/refresh/a.sloppak")
assert r.status_code == 200
for _ in range(200):
if not client.get("/api/enrichment/status").json()["running"]:
break
import time as _t
_t.sleep(0.02)
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "unscanned" # the pin was discarded
assert row["mb_recording_id"] is None
assert row["attempts"] == 0
# …and the normal pass re-matches it (fake transport, network flag on).
def fake(path, params):
return {"recordings": [{
"id": "rec-new", "score": 100, "title": "Thunderstruck",
"length": 292000,
"artist-credit": [{"name": "AC/DC", "artist": {
"id": "art-1", "name": "AC/DC", "sort-name": "AC/DC"}}],
"releases": [{"id": "rel-1", "title": "The Razors Edge",
"status": "Official", "date": "1990-09-24",
"release-group": {"primary-type": "Album"}}],
}]}
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["mb_recording_id"] == "rec-new"
def test_refresh_unknown_song_404(server, client):
assert client.post("/api/enrichment/refresh/ghost.sloppak").status_code == 404
# ── Get info ──────────────────────────────────────────────────────────────────
def test_fileinfo_sloppak_contents(server, client):
make_sloppak(server, "a.sloppak",
extra_yaml="mbid: 12345678-abcd-4ef0-9876-0123456789ab\n"
"genres: [rock, hard rock]\ntrack: 3\n")
body = client.get("/api/chart/a.sloppak/fileinfo").json()
assert body["format"] == "sloppak"
assert body["filename"] == "a.sloppak"
assert body["path"].endswith("a.sloppak")
assert body["size"] > 0
m = body["manifest"]
assert m["title"] == "Thunderstruck"
assert m["arrangements"] == ["Lead"]
assert m["stems"] == ["full"]
assert m["has_cover"] is False
assert m["identity"]["mbid"] == "12345678-abcd-4ef0-9876-0123456789ab"
assert m["identity"]["genres"] == ["rock", "hard rock"]
assert m["identity"]["track"] == 3
assert "isrc" not in m["identity"] # only keys actually present
# No enrichment row yet → no match block (the panel shows "Not scanned").
assert "match" not in body
def test_fileinfo_includes_match_verdict(server, client):
make_sloppak(server, "a.sloppak")
server.meta_db.set_enrichment_manual(
"a.sloppak", {"recording_id": "rec-1", "title": "Thunderstruck",
"artist": "AC/DC", "album": "The Razors Edge"},
source="search")
body = client.get("/api/chart/a.sloppak/fileinfo").json()
assert body["match"]["match_state"] == "manual"
assert body["match"]["canon_album"] == "The Razors Edge"
def test_fileinfo_missing_and_traversal(server, client):
assert client.get("/api/chart/ghost.sloppak/fileinfo").status_code == 404
assert client.get("/api/chart/..%2f..%2fetc%2fpasswd/fileinfo").status_code in (403, 404)
def test_fileinfo_non_chart_file_is_404(server, client):
"""A stray non-song file the user keeps under DLC_DIR must not have its
path/size/mtime exposed the route is charts only, not a filesystem stat."""
(server.DLC_DIR / "private-notes.txt").write_text("secret", encoding="utf-8")
assert client.get("/api/chart/private-notes.txt/fileinfo").status_code == 404
+3
View File
@@ -98,6 +98,9 @@ def test_demo_off_settings_post_not_blocked(tmp_path, monkeypatch):
("POST", "/api/enrichment/review/some-file/pick"), ("POST", "/api/enrichment/review/some-file/pick"),
("POST", "/api/enrichment/kick"), ("POST", "/api/enrichment/kick"),
("GET", "/api/enrichment/search"), ("GET", "/api/enrichment/search"),
# Context menus (R2): per-song re-match + the path-exposing Get info.
("POST", "/api/enrichment/refresh/some-file"),
("GET", "/api/chart/some-file/fileinfo"),
]) ])
def test_demo_on_blocked_routes_return_403(tmp_path, monkeypatch, method, path): def test_demo_on_blocked_routes_return_403(tmp_path, monkeypatch, method, path):
server, client = _make_client(tmp_path, monkeypatch, demo=True) server, client = _make_client(tmp_path, monkeypatch, demo=True)