diff --git a/server.py b/server.py index 3118317..9e670f8 100644 --- a/server.py +++ b/server.py @@ -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 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: """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 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}") def api_enrichment_refresh(filename: str): """The context menu's "Refresh metadata": reset THIS song's match to diff --git a/static/v3/songs.js b/static/v3/songs.js index 0093d37..845c954 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -490,6 +490,31 @@ '' + pct + '%'; } + // ── 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 '' + + conf[1] + ''; + } + // 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 // of any currently-rendered card/row in place (grid + tree). `_dirtyScores` @@ -845,7 +870,7 @@ return '
' + '
' + '' + - tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + overlay + + tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key) + overlay + '
' + inlineBtns + '' + @@ -3402,7 +3427,13 @@ // shown by match-review.js (window.__fbMatchReviewChip), which // also owns the drawer the click opens. '

' + - '
' + + '' + + // Batch progress for the Refresh Metadata button (shown only while a + // pass runs). A real songs-processed ratio, not a fake per-song %. + '
' + '
' + (providers.length > 1 ? '' : '') + '' + @@ -3413,6 +3444,7 @@ '' + '' + '' + + '' + '' + '
' + // Practice-aware library home: a repertoire progress meter + a @@ -3450,6 +3482,7 @@ state.artist = ''; state.album = ''; try { sm.libraryProviders && await sm.libraryProviders.select(state.provider); } catch (err) { /* */ } + _updateMetaBtnVisibility(); // enrichment is local-only await loadArtistCatalog(); refreshArtistAlbumSelects(); reload(); @@ -3479,6 +3512,10 @@ }); byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode)); 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 // pass) on the Refresh button, so its state isn't just tied to clicks here. (async () => { @@ -3488,6 +3525,15 @@ if (sd && sd.running) { _setRefreshState(sd); _watchScan({ announce: false }); } } 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 // it, clicking a card/row (or its arrangement chip) in select mode falls @@ -3706,6 +3752,187 @@ }, 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. async function search(q) { state.q = q || ''; diff --git a/tests/test_enrichment_plumbing.py b/tests/test_enrichment_plumbing.py index 6c1a290..eb88f77 100644 --- a/tests/test_enrichment_plumbing.py +++ b/tests/test_enrichment_plumbing.py @@ -150,3 +150,152 @@ def test_art_cache_dir_created(server): d = server._enrichment_art_dir() assert d.is_dir() 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