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).
("POST", re.compile(r"^/api/enrichment/review/.+$")),
("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$")),
# AcoustID audio fingerprinting: both identify endpoints run fpcalc (CPU)
# 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()
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:
"""The identity fields the matcher/scorer keys on, for one song."""
row = self.conn.execute(
@@ -6184,7 +6206,16 @@ def _scan_runner():
_enrich_kick_lock = threading.Lock()
_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).
_ENRICH_MIN_INTERVAL = 1.1
_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}
# 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,
apply_mask: str = "") -> None:
"""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)
return
# 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"):
cands = mb_match.rank_candidates(row, _mb_lookup_isrc(ids["isrc"]))
cands = mb_match.rank_candidates(ref, _mb_lookup_isrc(ids["isrc"]))
if cands:
meta_db.apply_enrichment_match(fn, chash, "matched", source="isrc",
score=1.0, apply_mask=apply_mask,
cand=field_filter(cands[0]) if field_filter else cands[0])
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
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":
meta_db.apply_enrichment_match(fn, chash, "matched", source="text",
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
(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
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["total"] = 0
_enrich_status["matched"] = 0
_enrich_status["current"] = None
# User settings gate the BACKGROUND matcher only (the review modal's
# 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
@@ -6961,11 +7038,17 @@ def _background_enrich():
continue
seen_filenames.add(fn)
queue.append(row)
_enrich_status["total"] = len(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:
_enrich_one(row, auto_min=auto_min, field_filter=field_filter,
apply_mask=apply_mask)
matched += 1
_enrich_status["matched"] = matched
except EnrichTransportError as e:
log.info("enrichment: network unavailable, pass paused (%s)", e)
break
@@ -6979,6 +7062,7 @@ def _background_enrich():
source="error", bump_attempts=True)
except Exception:
pass
_enrich_status["current"] = None
if mb_on and (pending or retriable):
log.info("Enrichment pass: %d rows stamped, %d matched", len(pending), matched)
@@ -6998,6 +7082,9 @@ def _background_enrich():
return
fetched = 0
for row in art_rows:
if _enrich_cancel.is_set():
log.info("enrichment: art pass cancelled by user after %d fetched", fetched)
break
try:
fetched += 1 if _enrich_art_one(row) else 0
except EnrichTransportError as e:
@@ -7022,6 +7109,10 @@ def _kick_enrich() -> bool:
if _enrich_status["running"]:
_enrich_pending_pass = True
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_thread = threading.Thread(target=_enrich_runner, daemon=True)
_enrich_thread.start()
@@ -7036,6 +7127,15 @@ def _enrich_runner():
except Exception:
log.exception("background enrichment failed unexpectedly")
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:
_enrich_status["running"] = False
return
@@ -7523,6 +7623,13 @@ def enrichment_status():
"last_pass_at": _enrich_status["last_pass_at"],
"states": meta_db.enrichment_state_counts(),
"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")
def api_enrichment_kick():
"""The Settings "Match now" button: request an enrichment pass without
waiting for a scan to complete. Single-flight + coalescing like every
other kick spamming it queues at most one follow-up pass."""
"""The Settings "Match now" button AND the library's "Refresh Metadata"
button: request an enrichment pass without waiting for a scan to complete.
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()}
@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}")
def api_enrichment_refresh(filename: str):
"""The context menu's "Refresh metadata": reset THIS song's match to