mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-15 05:07:15 +00:00
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:
co-authored by
Claude Fable 5
byrongamatos
parent
0a8c8945ea
commit
c7497c758d
@@ -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 = "",
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+45
-11
@@ -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;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user