mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
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:
co-authored by
Claude Opus 4.8
parent
73c5ab149e
commit
fa2d12222a
@@ -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 queued→working→result 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 queued→working→result 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
@@ -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 & 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 || '';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user