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

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

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

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ChrisBeWithYou
2026-07-05 00:18:18 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 73c5ab149e
commit fa2d12222a
3 changed files with 553 additions and 10 deletions
+175 -8
View File
@@ -246,6 +246,8 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
# anonymous demo visitors (they'd spend the shared rate limit). # anonymous demo visitors (they'd spend the shared rate limit).
("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$")),
("POST", re.compile(r"^/api/enrichment/cancel$")),
("POST", re.compile(r"^/api/enrichment/rematch$")),
("GET", re.compile(r"^/api/enrichment/search$")), ("GET", re.compile(r"^/api/enrichment/search$")),
# AcoustID audio fingerprinting: both identify endpoints run fpcalc (CPU) # AcoustID audio fingerprinting: both identify endpoints run fpcalc (CPU)
# and spend the shared AcoustID rate budget on the caller's behalf — same # and spend the shared AcoustID rate budget on the caller's behalf — same
@@ -3016,6 +3018,26 @@ class MetadataDB:
"JOIN songs s ON s.filename = e.filename GROUP BY e.match_state").fetchall() "JOIN songs s ON s.filename = e.filename GROUP BY e.match_state").fetchall()
return {r[0]: r[1] for r in rows} return {r[0]: r[1] for r in rows}
def enrichment_states_for(self, filenames: list[str]) -> dict:
"""{filename: match_state} for the given songs — a never-enriched (or
unknown) filename is simply absent from the result. Powers the per-tile
badges on the "Refresh Metadata" batch: the grid polls only the
filenames in its visible window, not the whole library, so a card can
animate queuedworkingresult without a per-song round-trip."""
if not filenames:
return {}
out: dict = {}
with self._lock:
# Chunk under SQLite's variable limit so a huge visible window (or a
# hostile caller) can't overflow the single IN (...) parameter list.
for i in range(0, len(filenames), 400):
chunk = filenames[i:i + 400]
q = ("SELECT filename, match_state FROM song_enrichment "
"WHERE filename IN (%s)" % ",".join("?" * len(chunk)))
for fn, st in self.conn.execute(q, chunk).fetchall():
out[fn] = st
return out
def enrichment_song_row(self, filename: str) -> dict | None: def enrichment_song_row(self, filename: str) -> dict | None:
"""The identity fields the matcher/scorer keys on, for one song.""" """The identity fields the matcher/scorer keys on, for one song."""
row = self.conn.execute( row = self.conn.execute(
@@ -6184,7 +6206,16 @@ def _scan_runner():
_enrich_kick_lock = threading.Lock() _enrich_kick_lock = threading.Lock()
_enrich_pending_pass = False _enrich_pending_pass = False
_enrich_status = {"running": False, "processed": 0, "last_pass_at": None} # processed = phase-1 stubs stamped this pass (legacy field). total/matched =
# the phase-2 MATCHING progress the "Refresh Metadata" batch bar reads (the
# slow, rate-limited part worth a progress readout); current = the song being
# matched right now, which drives the per-tile "working" badge.
_enrich_status = {"running": False, "processed": 0, "last_pass_at": None,
"total": 0, "matched": 0, "current": None}
# Cooperative cancel for the Stop button: the matching/art loops check it
# between songs (an in-flight ≤1/s lookup can't be interrupted, but no new one
# is started). Set by /api/enrichment/cancel, cleared when a fresh pass kicks.
_enrich_cancel = threading.Event()
# Minimum spacing between EXTERNAL lookups (design: ≤1 req/s + local cache). # Minimum spacing between EXTERNAL lookups (design: ≤1 req/s + local cache).
_ENRICH_MIN_INTERVAL = 1.1 _ENRICH_MIN_INTERVAL = 1.1
_enrich_last_fetch = 0.0 _enrich_last_fetch = 0.0
@@ -6817,6 +6848,35 @@ def _enrich_field_filter(cfg: dict):
return lambda cand: {k: v for k, v in cand.items() if k not in blocked} return lambda cand: {k: v for k, v in cand.items() if k not in blocked}
# Strips a trailing tag parenthetical from a filename stem — "(440Hz)",
# "(Live)", "(No Lead)", the retune/arrangement noise CDLC names carry.
_FN_TAG_RE = re.compile(r"\s*\([^)]*\)")
def _artist_title_from_filename(filename: str) -> dict | None:
"""Derive artist + title from the CDLC filename convention
'Artist_Song-Title_v1_p.feedpak' spaces written as hyphens WITHIN a
field, underscores separating Artist | Title | version/arrangement. Used
ONLY as a match SEED for packs whose own `artist` field is blank (a large
slice of community charts): text search needs an artist, and the filename
reliably carries it. This never becomes displayed metadata the shown
values still come from the confirmed MusicBrainz match (provenance
'matched'), so nothing estimated is presented as author-set; if no match is
found, the pack stays exactly as-is. Returns None when the name doesn't fit
the convention (so a non-CDLC pack falls through untouched)."""
base = filename.replace("\\", "/").rsplit("/", 1)[-1]
base = base.rsplit(".", 1)[0] # drop the extension
base = _FN_TAG_RE.sub("", base).strip() # drop "(440Hz)" etc.
parts = [p for p in base.split("_") if p]
if len(parts) < 2:
return None
artist = parts[0].replace("-", " ").strip()
title = parts[1].replace("-", " ").strip()
if not artist or not title:
return None
return {"artist": artist, "title": title}
def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None, def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None,
apply_mask: str = "") -> None: apply_mask: str = "") -> None:
"""The matcher (P8; replaces P7's no-op). Precedence per design §5: """The matcher (P8; replaces P7's no-op). Precedence per design §5:
@@ -6861,17 +6921,29 @@ def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None,
cand=field_filter(cand) if field_filter else cand) cand=field_filter(cand) if field_filter else cand)
return return
# A 404'd mbid (typo'd manifest) falls through to the text tiers. # A 404'd mbid (typo'd manifest) falls through to the text tiers.
# A pack that left `artist` blank can't be text-matched (search needs an
# artist, and the per-field floor rejects a blank one) — so when it's blank,
# seed the query/scoring from the filename's Artist_Song convention. Seed
# only: fn/chash and the stored row are untouched, and the DISPLAYED values
# still come from the confirmed match. The exact-key tiers above don't need
# it (mbid/isrc identify without text).
ref = row
if not (row.get("artist") or "").strip():
derived = _artist_title_from_filename(fn)
if derived:
ref = {**row, **derived}
if ids.get("isrc"): if ids.get("isrc"):
cands = mb_match.rank_candidates(row, _mb_lookup_isrc(ids["isrc"])) cands = mb_match.rank_candidates(ref, _mb_lookup_isrc(ids["isrc"]))
if cands: if cands:
meta_db.apply_enrichment_match(fn, chash, "matched", source="isrc", meta_db.apply_enrichment_match(fn, chash, "matched", source="isrc",
score=1.0, apply_mask=apply_mask, score=1.0, apply_mask=apply_mask,
cand=field_filter(cands[0]) if field_filter else cands[0]) cand=field_filter(cands[0]) if field_filter else cands[0])
return return
ranked = mb_match.rank_candidates(row, _mb_search_recordings(row.get("artist"), row.get("title"))) ranked = mb_match.rank_candidates(ref, _mb_search_recordings(ref.get("artist"), ref.get("title")))
best = ranked[0] if ranked else None best = ranked[0] if ranked else None
tier = mb_match.classify(row, best, best["score"], auto_min=auto_min) if best else "none" tier = mb_match.classify(ref, best, best["score"], auto_min=auto_min) if best else "none"
if tier == "auto": if tier == "auto":
meta_db.apply_enrichment_match(fn, chash, "matched", source="text", meta_db.apply_enrichment_match(fn, chash, "matched", source="text",
score=best["score"], apply_mask=apply_mask, score=best["score"], apply_mask=apply_mask,
@@ -6895,8 +6967,13 @@ def _background_enrich():
`failed` rows whose backoff has elapsed; a transport failure pauses it `failed` rows whose backoff has elapsed; a transport failure pauses it
(state untouched, no attempt burned) and the next kick retries. Offline (state untouched, no attempt burned) and the next kick retries. Offline
(kill-switch or the test env) skips phase 2 entirely. Never drains in a (kill-switch or the test env) skips phase 2 entirely. Never drains in a
loop a dead network would make that spin forever.""" loop a dead network would make that spin forever. Between songs it
honours the Stop button's cancel flag (phases 2 and 3), so a long trickle
can be halted without waiting for the whole queue to drain."""
_enrich_status["processed"] = 0 _enrich_status["processed"] = 0
_enrich_status["total"] = 0
_enrich_status["matched"] = 0
_enrich_status["current"] = None
# User settings gate the BACKGROUND matcher only (the review modal's # User settings gate the BACKGROUND matcher only (the review modal's
# manual search/fix stays available when it's off); read once per pass, # manual search/fix stays available when it's off); read once per pass,
# up front so the pending query can honour the per-field apply mask # up front so the pending query can honour the per-field apply mask
@@ -6961,11 +7038,17 @@ def _background_enrich():
continue continue
seen_filenames.add(fn) seen_filenames.add(fn)
queue.append(row) queue.append(row)
_enrich_status["total"] = len(queue)
for row in queue: for row in queue:
if _enrich_cancel.is_set():
log.info("enrichment: pass cancelled by user after %d matched", matched)
break
_enrich_status["current"] = row.get("filename")
try: try:
_enrich_one(row, auto_min=auto_min, field_filter=field_filter, _enrich_one(row, auto_min=auto_min, field_filter=field_filter,
apply_mask=apply_mask) apply_mask=apply_mask)
matched += 1 matched += 1
_enrich_status["matched"] = matched
except EnrichTransportError as e: except EnrichTransportError as e:
log.info("enrichment: network unavailable, pass paused (%s)", e) log.info("enrichment: network unavailable, pass paused (%s)", e)
break break
@@ -6979,6 +7062,7 @@ def _background_enrich():
source="error", bump_attempts=True) source="error", bump_attempts=True)
except Exception: except Exception:
pass pass
_enrich_status["current"] = None
if mb_on and (pending or retriable): if mb_on and (pending or retriable):
log.info("Enrichment pass: %d rows stamped, %d matched", len(pending), matched) log.info("Enrichment pass: %d rows stamped, %d matched", len(pending), matched)
@@ -6998,6 +7082,9 @@ def _background_enrich():
return return
fetched = 0 fetched = 0
for row in art_rows: for row in art_rows:
if _enrich_cancel.is_set():
log.info("enrichment: art pass cancelled by user after %d fetched", fetched)
break
try: try:
fetched += 1 if _enrich_art_one(row) else 0 fetched += 1 if _enrich_art_one(row) else 0
except EnrichTransportError as e: except EnrichTransportError as e:
@@ -7022,6 +7109,10 @@ def _kick_enrich() -> bool:
if _enrich_status["running"]: if _enrich_status["running"]:
_enrich_pending_pass = True _enrich_pending_pass = True
return False return False
# A fresh pass supersedes any prior Stop — clear the flag so the new
# pass isn't cancelled the instant it checks (a stale set() from a
# cancelled-then-re-kicked run would otherwise abort it immediately).
_enrich_cancel.clear()
_enrich_status["running"] = True _enrich_status["running"] = True
_enrich_thread = threading.Thread(target=_enrich_runner, daemon=True) _enrich_thread = threading.Thread(target=_enrich_runner, daemon=True)
_enrich_thread.start() _enrich_thread.start()
@@ -7036,6 +7127,15 @@ def _enrich_runner():
except Exception: except Exception:
log.exception("background enrichment failed unexpectedly") log.exception("background enrichment failed unexpectedly")
with _enrich_kick_lock: with _enrich_kick_lock:
_enrich_status["current"] = None
if _enrich_cancel.is_set():
# Stop: abandon any coalesced follow-up and clear the flag so the
# next kick starts clean. The current pass already broke out of
# its loop between songs (see _background_enrich).
_enrich_pending_pass = False
_enrich_cancel.clear()
_enrich_status["running"] = False
return
if not _enrich_pending_pass: if not _enrich_pending_pass:
_enrich_status["running"] = False _enrich_status["running"] = False
return return
@@ -7523,6 +7623,13 @@ def enrichment_status():
"last_pass_at": _enrich_status["last_pass_at"], "last_pass_at": _enrich_status["last_pass_at"],
"states": meta_db.enrichment_state_counts(), "states": meta_db.enrichment_state_counts(),
"total_songs": meta_db.count(), "total_songs": meta_db.count(),
# Per-pass matching progress for the "Refresh Metadata" batch bar +
# per-tile badges (total = songs queued to match this pass, matched =
# done so far, current = the one being matched now).
"total": _enrich_status.get("total", 0),
"matched": _enrich_status.get("matched", 0),
"current": _enrich_status.get("current"),
"cancelling": _enrich_cancel.is_set(),
} }
@@ -7541,12 +7648,72 @@ def api_enrichment_song(filename: str):
@app.post("/api/enrichment/kick") @app.post("/api/enrichment/kick")
def api_enrichment_kick(): def api_enrichment_kick():
"""The Settings "Match now" button: request an enrichment pass without """The Settings "Match now" button AND the library's "Refresh Metadata"
waiting for a scan to complete. Single-flight + coalescing like every button: request an enrichment pass without waiting for a scan to complete.
other kick spamming it queues at most one follow-up pass.""" Processes the songs that still need it (unscanned/changed + retriable
failures) already-matched songs are left alone, so on a fully-matched
library this is a fast no-op. Single-flight + coalescing like every other
kick spamming it queues at most one follow-up pass."""
return {"started": _kick_enrich()} return {"started": _kick_enrich()}
@app.post("/api/enrichment/cancel")
def api_enrichment_cancel():
"""Stop button on the "Refresh Metadata" batch: signal the running pass to
halt after the current song (an in-flight 1/s lookup can't be interrupted,
but no new one is started) and drop any coalesced follow-up. A no-op when
nothing is running."""
was_running = _enrich_status["running"]
if was_running:
_enrich_cancel.set()
return {"ok": True, "was_running": was_running}
@app.post("/api/enrichment/rematch")
def api_enrichment_rematch(data: dict = Body(...)):
"""The library "Refresh Metadata" button: force a fresh re-match of the
songs the grid is SHOWING (its visible/filtered window). Resets each to
`unscanned` so the next pass re-fetches it from scratch EXCEPT user-pinned
`manual` rows, which are never auto-overwritten (apply_enrichment_match
guards that) then kicks one pass. Scoped to the visible set on purpose:
fast (dozens of songs), visible (tiles animate), and it can't blow the whole
1/s rate budget on a 1000-song library the way a full re-sweep would.
Returns the filenames actually queued so the UI badges exactly those."""
raw = (data or {}).get("filenames") or []
fns = [str(f) for f in raw if isinstance(f, str)][:500]
queued: list[str] = []
for fn in fns:
song = meta_db.enrichment_song_row(fn)
if not song:
continue
h = meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
# allow_manual_overwrite=False → a manual pin is left as-is (returns
# False), everything else resets to unscanned (returns True).
if meta_db.apply_enrichment_match(fn, h, "unscanned",
allow_manual_overwrite=False):
queued.append(fn)
started = _kick_enrich() if queued else False
return {"queued": queued, "count": len(queued), "started": started}
@app.post("/api/enrichment/states")
def api_enrichment_states(data: dict = Body(...)):
"""Per-tile match states for the grid's VISIBLE window during a metadata
refresh: the client posts the filenames it is showing and gets back each
one's match_state (+ the song being matched right now, + whether a pass is
running), so a card can animate queuedworkingresult without a per-song
round-trip. Read-only safe for demo visitors (no network, no mutation)."""
raw = (data or {}).get("filenames") or []
# Bound the batch: a visible grid window is dozens of cards; cap defensively.
fns = [str(f) for f in raw if isinstance(f, str)][:500]
return {
"states": meta_db.enrichment_states_for(fns),
"current": _enrich_status.get("current"),
"running": _enrich_status["running"],
}
@app.post("/api/enrichment/refresh/{filename:path}") @app.post("/api/enrichment/refresh/{filename:path}")
def api_enrichment_refresh(filename: str): def api_enrichment_refresh(filename: str):
"""The context menu's "Refresh metadata": reset THIS song's match to """The context menu's "Refresh metadata": reset THIS song's match to
+229 -2
View File
@@ -490,6 +490,31 @@
'<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/></svg>' + pct + '%</span>'; '<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/></svg>' + pct + '%</span>';
} }
// ── Metadata-refresh per-tile state (the "Refresh Metadata" batch) ─────────
// A transient badge painted ONLY while a metadata refresh is running: the
// songs actually being (re)matched animate queued → working → done. Keyed by
// the card's data-fn (= the local filename the enrichment cache keys on).
// Empty for every song outside a refresh, so an idle card is byte-identical
// to before (keeps the windowed grid's height math untouched). Honest state
// transitions, NOT a fake per-song %: a match is binary (design §11).
const _metaTile = {}; // fn -> 'queued' | 'working' | 'done' | 'nochange'
function enrichBadge(fn) {
const st = _metaTile[fn];
if (!st) return '';
const M = {
queued: ['bg-black/60 text-fb-textDim', '• Queued'],
working: ['bg-fb-primary text-white', '⟳ Matching…'],
done: ['bg-fb-good/90 text-black', '✓ Updated'],
nochange: ['bg-black/60 text-fb-textDim', '— No match'],
};
const conf = M[st] || M.queued;
// top-10 clears the tuning chip (top-2) in both normal and select mode;
// z-20 sits it above the art. Non-interactive so it never eats a click.
return '<span class="v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight pointer-events-none">' +
conf[1] + '</span>';
}
// After a song is scored, the badge for that card is stale until the next // After a song is scored, the badge for that card is stale until the next
// full render(). Refresh state.accuracy from the server and patch the badge // full render(). Refresh state.accuracy from the server and patch the badge
// of any currently-rendered card/row in place (grid + tree). `_dirtyScores` // of any currently-rendered card/row in place (grid + tree). `_dirtyScores`
@@ -845,7 +870,7 @@
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' + return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' + '<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
'<img src="' + esc(artUrl(shown)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' + '<img src="' + esc(artUrl(shown)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + overlay + tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key) + overlay +
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' + '<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
inlineBtns + inlineBtns +
'<button data-fav data-fav-idle="text-white" title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' + '<button data-fav data-fav-idle="text-white" title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
@@ -3402,7 +3427,13 @@
// shown by match-review.js (window.__fbMatchReviewChip), which // shown by match-review.js (window.__fbMatchReviewChip), which
// also owns the drawer the click opens. // also owns the drawer the click opens.
'<div class="flex items-baseline gap-3"><p class="text-fb-textDim text-sm" id="v3-songs-count"></p>' + '<div class="flex items-baseline gap-3"><p class="text-fb-textDim text-sm" id="v3-songs-count"></p>' +
'<button id="v3-songs-match-review" class="hidden text-xs text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-full px-2.5 py-0.5"></button></div>' + '<button id="v3-songs-match-review" class="hidden text-xs text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-full px-2.5 py-0.5"></button>' +
// Batch progress for the Refresh Metadata button (shown only while a
// pass runs). A real songs-processed ratio, not a fake per-song %.
'<span id="v3-meta-progress" class="hidden items-center gap-2 text-xs text-fb-textDim">' +
'<span id="v3-meta-progress-label"></span>' +
'<span class="inline-block w-24 rounded-full bg-fb-border/40 overflow-hidden align-middle" style="height:6px"><span id="v3-meta-progress-fill" class="block h-full bg-fb-primary transition-all" style="width:0%"></span></span>' +
'</span></div>' +
'<div class="flex flex-wrap gap-2">' + '<div class="flex flex-wrap gap-2">' +
(providers.length > 1 ? '<select id="v3-songs-provider" class="' + ctrl + '">' + provOpts + '</select>' : '') + (providers.length > 1 ? '<select id="v3-songs-provider" class="' + ctrl + '">' + provOpts + '</select>' : '') +
'<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' + '<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
@@ -3413,6 +3444,7 @@
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' + '<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' + '<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
'<button id="v3-songs-refresh" title="Refresh library (scan for new songs)" class="' + ctrl + '">⟳ Refresh</button>' + '<button id="v3-songs-refresh" title="Refresh library (scan for new songs)" class="' + ctrl + '">⟳ Refresh</button>' +
'<button id="v3-songs-refresh-meta" title="Refresh metadata for the songs shown (re-match titles, artwork &amp; more)" class="' + ctrl + '">🏷 Metadata</button>' +
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' + '<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
'</div></div></div>' + '</div></div></div>' +
// Practice-aware library home: a repertoire progress meter + a // Practice-aware library home: a repertoire progress meter + a
@@ -3450,6 +3482,7 @@
state.artist = ''; state.artist = '';
state.album = ''; state.album = '';
try { sm.libraryProviders && await sm.libraryProviders.select(state.provider); } catch (err) { /* */ } try { sm.libraryProviders && await sm.libraryProviders.select(state.provider); } catch (err) { /* */ }
_updateMetaBtnVisibility(); // enrichment is local-only
await loadArtistCatalog(); await loadArtistCatalog();
refreshArtistAlbumSelects(); refreshArtistAlbumSelects();
reload(); reload();
@@ -3479,6 +3512,10 @@
}); });
byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode)); byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode));
byId('v3-songs-refresh')?.addEventListener('click', refreshLibrary); byId('v3-songs-refresh')?.addEventListener('click', refreshLibrary);
// Refresh Metadata: local-only, so hide it for remote providers. The
// button doubles as its own Stop while a pass runs (see onMetaBtnClick).
byId('v3-songs-refresh-meta')?.addEventListener('click', onMetaBtnClick);
_updateMetaBtnVisibility();
// Reflect a scan already in progress (Settings button or a background // Reflect a scan already in progress (Settings button or a background
// pass) on the Refresh button, so its state isn't just tied to clicks here. // pass) on the Refresh button, so its state isn't just tied to clicks here.
(async () => { (async () => {
@@ -3488,6 +3525,15 @@
if (sd && sd.running) { _setRefreshState(sd); _watchScan({ announce: false }); } if (sd && sd.running) { _setRefreshState(sd); _watchScan({ announce: false }); }
} catch (e) { /* */ } } catch (e) { /* */ }
})(); })();
// Reflect an enrichment pass already running (Settings "Match now" or a
// post-scan background pass) on the Metadata button + bar.
(async () => {
try {
const r = await fetch('/api/enrichment/status');
const es = r.ok ? await r.json() : null;
if (es && es.running) { _setMetaState(es); _watchEnrich({ announce: false }); }
} catch (e) { /* */ }
})();
// Capture-phase select-mode guard on each persistent list host. Without // Capture-phase select-mode guard on each persistent list host. Without
// it, clicking a card/row (or its arrangement chip) in select mode falls // it, clicking a card/row (or its arrangement chip) in select mode falls
@@ -3706,6 +3752,187 @@
}, 1000); }, 1000);
} }
// ── Refresh Metadata (batch enrichment) from the Songs toolbar ─────────────
// The metadata counterpart to ⟳ Refresh (which scans FILES): matches
// titles/artist/album/artwork against MusicBrainz for the songs that still
// need it — the ambient background matcher, run on demand (a media-server's
// "Refresh Metadata" vs "Scan Files"). Mirrors the scan machinery: a 1 Hz
// poll of /api/enrichment/status drives the button + batch bar, while
// /api/enrichment/states drives per-tile badges on the visible window.
// Enrichment is local-only, so the button hides for remote providers.
let _metaPoll = null;
let _metaRunning = false;
function _updateMetaBtnVisibility() {
const btn = document.getElementById('v3-songs-refresh-meta');
if (btn) btn.style.display = (state.provider === 'local') ? '' : 'none';
}
// The local filenames the grid is currently SHOWING (data-fn is the local
// filename the enrichment cache keys on). The grid is windowed, so this is
// the visible slice only — exactly what the per-tile poll should cover.
function _visibleLocalFilenames() {
const grid = document.getElementById('v3-songs-grid');
if (!grid) return [];
return [...grid.querySelectorAll('[data-fn]')]
.map((el) => el.getAttribute('data-fn')).filter(Boolean);
}
// Set/clear one card's live badge (recycled cards re-derive from _metaTile on
// the next paint, so update the map too — mirrors _patchCardFav).
function _patchCardEnrich(fn, st) {
if (st) _metaTile[fn] = st; else delete _metaTile[fn];
const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => {
const el = play.querySelector('.v3-meta-tile');
const html = enrichBadge(fn);
if (!html) { if (el) el.remove(); return; }
if (el) el.outerHTML = html; else play.insertAdjacentHTML('beforeend', html);
});
}
function _clearMetaTiles() {
Object.keys(_metaTile).forEach((fn) => { delete _metaTile[fn]; });
document.querySelectorAll('.v3-meta-tile').forEach((el) => el.remove());
}
// Drive the button (which doubles as Stop) + the batch bar from a status body.
function _setMetaState(es) {
const btn = document.getElementById('v3-songs-refresh-meta');
const prog = document.getElementById('v3-meta-progress');
const fill = document.getElementById('v3-meta-progress-fill');
const label = document.getElementById('v3-meta-progress-label');
if (!btn) return;
const running = !!(es && es.running);
_metaRunning = running;
if (running) {
const total = (es && es.total) || 0, done = (es && es.matched) || 0;
const cancelling = !!(es && es.cancelling);
btn.textContent = cancelling ? 'Stopping…' : ('⏹ Stop' + (total ? ' · ' + done + '/' + total : ''));
btn.disabled = cancelling;
btn.classList.toggle('opacity-70', cancelling);
btn.title = cancelling ? 'Stopping after the current song…' : 'Stop refreshing metadata';
if (prog) {
prog.classList.remove('hidden'); prog.classList.add('flex');
if (label) label.textContent = total ? ('Matching metadata ' + done + '/' + total) : 'Matching metadata…';
// Real songs-processed ratio; a tiny sliver while the queue size
// is still being computed (phase 1) so the bar isn't dead-empty.
if (fill) fill.style.width = (total ? Math.round((done / total) * 100) : 6) + '%';
}
} else {
btn.textContent = '🏷 Metadata';
btn.disabled = false;
btn.classList.remove('opacity-70');
btn.title = 'Refresh metadata for the songs shown (re-match titles, artwork & more)';
if (prog) { prog.classList.add('hidden'); prog.classList.remove('flex'); }
}
}
// Completion toast — reuse the shared fbNotify surface (visual-only, so
// hearing-safe for free). Honest + never-punishing copy, in-game suppressed.
function _metaCompleteToast(es) {
const active = document.querySelector('.screen.active');
if (active && active.id === 'player') return;
if (!window.fbNotify) return;
const matched = (es && es.matched) || 0;
const msg = matched
? (matched + ' song' + (matched === 1 ? '' : 's') + ' matched')
: 'Your library metadata is up to date';
try { window.fbNotify.show({ title: 'Metadata refresh complete', message: msg, icon: '🏷️', accent: '#22C55E' }); } catch (e) { /* */ }
}
// Poll enrichment status (button + bar) AND the visible window's per-song
// states (tile badges) until the pass finishes. announce:false = we only
// attached to a pass we didn't start (no toast unless it actually changed
// something).
function _watchEnrich(opts) {
if (_metaPoll) return;
const announce = !opts || opts.announce !== false;
let sawRunning = false, ticks = 0, lastStatus = null;
_metaPoll = setInterval(async () => {
ticks++;
let es = null;
try { const r = await fetch('/api/enrichment/status'); if (r.ok) es = await r.json(); } catch (e) { /* */ }
if (es) { lastStatus = es; _setMetaState(es); if (es.running) sawRunning = true; }
// Per-tile badges: only songs we're tracking (seeded 'queued'). A
// tile flips to 'working' when it's the current song, then to
// 'done' (matched) / 'nochange' (failed) once it leaves unscanned.
if (Object.keys(_metaTile).length) {
const fns = _visibleLocalFilenames();
try {
const r = await fetch('/api/enrichment/states', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames: fns }),
});
if (r.ok) {
const j = await r.json();
const states = j.states || {}, current = j.current;
fns.forEach((fn) => {
if (!(fn in _metaTile)) return;
if (fn === current) { _patchCardEnrich(fn, 'working'); return; }
const s = states[fn];
if (s && s !== 'unscanned' && s !== 'pending') {
_patchCardEnrich(fn, s === 'failed' ? 'nochange' : 'done');
}
});
}
} catch (e) { /* */ }
}
// Cap at 20 min (a ~1000-song trickle at ≤1/s is ~17 min); a
// user-initiated no-op that never saw a running pass ends quickly.
const noopDone = announce && !sawRunning && ticks >= 3;
if ((sawRunning && es && !es.running) || noopDone || ticks >= 1200) {
clearInterval(_metaPoll); _metaPoll = null;
_setMetaState(null);
const changed = sawRunning && lastStatus && (lastStatus.matched || 0) > 0;
if (announce || changed) _metaCompleteToast(lastStatus);
// Let the final 'done' badges register, then clear + (if anything
// matched) reload so new canonical titles/art show.
setTimeout(() => {
_clearMetaTiles();
if (changed && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'enrich', matched: lastStatus.matched }); } catch (e) { /* */ } }
}, 1600);
}
}, 1000);
}
// Force a fresh re-match of the songs currently SHOWN (the visible grid
// window) — a media-server-style per-view "Refresh Metadata". Resets those
// songs and re-fetches, so it's visible even on an already-matched library.
// Manual pins are skipped server-side; scoped to the visible set so it's
// fast + can't blow the whole rate budget.
async function refreshMetadata() {
if (_metaRunning || _metaPoll) return; // already running
const fns = _visibleLocalFilenames();
_clearMetaTiles();
if (!fns.length) { _metaCompleteToast({ matched: 0 }); return; }
let queued = [];
try {
const r = await fetch('/api/enrichment/rematch', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames: fns }),
});
if (r.ok) queued = (await r.json()).queued || [];
} catch (e) { /* offline → nothing queued */ }
// Badge exactly what the server queued (everything visible except your
// manual pins). Nothing queued = all visible songs are pinned/unknown.
queued.forEach((fn) => _patchCardEnrich(fn, 'queued'));
if (!queued.length) { _metaCompleteToast({ matched: 0 }); return; }
_watchEnrich({ announce: true });
}
async function stopMetadata() {
try { await fetch('/api/enrichment/cancel', { method: 'POST' }); } catch (e) { /* */ }
_setMetaState({ running: true, cancelling: true }); // optimistic; the poll confirms
}
// The Metadata button toggles role: kick a refresh when idle, Stop when a
// pass is running.
function onMetaBtnClick() {
if (_metaRunning) stopMetadata(); else refreshMetadata();
}
// Topbar search drives this screen. // Topbar search drives this screen.
async function search(q) { async function search(q) {
state.q = q || ''; state.q = q || '';
+149
View File
@@ -150,3 +150,152 @@ def test_art_cache_dir_created(server):
d = server._enrichment_art_dir() d = server._enrichment_art_dir()
assert d.is_dir() assert d.is_dir()
assert d.name == "art_cache" assert d.name == "art_cache"
# ── Refresh Metadata batch: per-tile states, progress, Stop ───────────────────
def test_states_for_returns_only_known_filenames(server):
_put(server, "a.archive")
server._background_enrich()
got = server.meta_db.enrichment_states_for(["a.archive", "nope.archive"])
assert got == {"a.archive": "unscanned"} # unknown filename absent
assert server.meta_db.enrichment_states_for([]) == {}
def test_states_endpoint(client, server):
_put(server, "a.archive")
_put(server, "b.archive", title="Other")
server._background_enrich()
body = client.post("/api/enrichment/states",
json={"filenames": ["a.archive", "zzz.missing"]}).json()
assert body["states"] == {"a.archive": "unscanned"}
assert body["running"] is False
assert body["current"] is None
def test_status_exposes_progress_fields(client, server):
_put(server, "a.archive")
server._background_enrich()
body = client.get("/api/enrichment/status").json()
for k in ("total", "matched", "current", "cancelling"):
assert k in body
assert body["cancelling"] is False
def test_cancel_is_noop_when_idle(client, server):
body = client.post("/api/enrichment/cancel").json()
assert body == {"ok": True, "was_running": False}
# A no-op must not arm the flag (which would then poison the next pass).
assert server._enrich_cancel.is_set() is False
def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
for i in range(4):
_put(server, f"s{i}.archive", title=f"Song {i}")
# Force the matcher path on (the test env is offline by default) and stub the
# per-song matcher so nothing touches the network — it just trips Stop after
# the first song, exactly as the /cancel route would mid-pass.
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
calls = []
def fake_enrich_one(row, **_kw):
calls.append(row["filename"])
server._enrich_cancel.set()
monkeypatch.setattr(server, "_enrich_one", fake_enrich_one)
server._enrich_cancel.clear()
server._background_enrich()
# The loop checks cancel BEFORE each song, so exactly one is processed before
# it breaks — not the whole 4-row queue.
assert calls == ["s0.archive"]
assert server._enrich_status["total"] == 4
assert server._enrich_status["matched"] == 1
def test_rematch_requeues_visible_but_skips_manual(server, client):
_put(server, "a.archive") # will be 'matched'
_put(server, "b.archive", title="Other") # will be 'failed'
_put(server, "c.archive", title="Pinned") # will be 'manual' — untouchable
server._background_enrich()
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state='matched' WHERE filename='a.archive'")
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state='failed' WHERE filename='b.archive'")
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state='manual' WHERE filename='c.archive'")
server.meta_db.conn.commit()
body = client.post("/api/enrichment/rematch", json={
"filenames": ["a.archive", "b.archive", "c.archive", "nope.archive"]}).json()
# A per-view refresh re-runs everything shown EXCEPT the manual pin (and an
# unknown filename); matched + failed are both re-queued.
assert set(body["queued"]) == {"a.archive", "b.archive"}
assert body["count"] == 2
server._join_background_db_threads()
assert server.meta_db.get_enrichment("a.archive")["match_state"] == "unscanned"
assert server.meta_db.get_enrichment("b.archive")["match_state"] == "unscanned"
assert server.meta_db.get_enrichment("c.archive")["match_state"] == "manual"
# ── filename-derived artist/title fallback (blank-artist packs) ───────────────
def test_filename_artist_title_parse(server):
f = server._artist_title_from_filename
assert f("CDLC/0 - City Pop/Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak") == \
{"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
assert f("Anri_Windy-Summer_v1_p.feedpak") == {"artist": "Anri", "title": "Windy Summer"}
# a trailing "(440Hz)" retune tag is stripped before parsing
assert f("Cindy_Watashitachi-o-Shinjite-Ite_v1_p (440Hz).feedpak") == \
{"artist": "Cindy", "title": "Watashitachi o Shinjite Ite"}
# doesn't fit the convention → no guess
assert f("nounderscore.feedpak") is None
def test_blank_artist_seeds_match_from_filename(server, monkeypatch):
server.meta_db.put("Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak", 0, 0, {
"title": "Tatsuro-Yamashita_Ride-On-Time_v1_p", "artist": "", "album": "",
"duration": 240, "arrangements": [{"name": "Bass", "index": 0}]})
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
seen = {}
def fake_search(artist, title, limit=8):
seen["artist"], seen["title"] = artist, title
return []
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
row = next(r for r in server.meta_db.enrichment_pending()
if r["filename"].startswith("Tatsuro"))
server._enrich_one(row)
# the blank pack artist was replaced by the filename-derived identity for
# the search (this is exactly what rescues the 'failed' pile)
assert seen == {"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
def test_present_artist_is_not_overridden_by_filename(server, monkeypatch):
server.meta_db.put("Weird-Filename_x_y.feedpak", 0, 0, {
"title": "Real Title", "artist": "Real Artist", "album": "", "duration": 100,
"arrangements": [{"name": "Lead", "index": 0}]})
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
seen = {}
def fake_search(artist, title, limit=8):
seen["artist"], seen["title"] = artist, title
return []
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
row = next(r for r in server.meta_db.enrichment_pending()
if r["filename"].startswith("Weird"))
server._enrich_one(row)
# a pack that DOES carry an artist keeps it — the filename is never consulted
assert seen == {"artist": "Real Artist", "title": "Real Title"}
def test_kick_clears_a_stale_cancel(server):
# A cancelled-then-rekicked pass must start clean: _kick_enrich clears the
# flag so the fresh pass isn't aborted the instant it checks.
server._enrich_cancel.set()
server._kick_enrich()
server._join_background_db_threads()
assert server._enrich_cancel.is_set() is False