library: metadata-enrichment plumbing (cache table + worker lifecycle) — P7 (#707)

* library: metadata-enrichment plumbing (cache table + worker lifecycle) — P7

The pipeline around a deliberately NO-OP matcher, so the real
MusicBrainz text matcher (next slice) replaces exactly one function and
inherits the queue, throttle, lifecycle, and safety contracts:

- song_enrichment cache table: one row per song holding the match
  lifecycle (unscanned -> matched(source,score) | manual | failed) plus
  the canonical values a confident match supplies. A DISPLAY cache -
  canonical values are never auto-written into pack files. Never purged
  on rescan (only by the explicit per-song delete); dead rows filtered
  at read time; re-derivable, so a lost row just re-enriches.
- Identity hashing: sha1 of normalized artist|title|album|duration.
  Filename-free, so a renamed pack keeps its enrichment; unchanged hash
  makes re-enrichment a no-op (idempotent).
- Queue rules (test-pinned): no row / unscanned / identity-changed ->
  re-match; matched + current hash = settled; a MANUAL row is the
  user's pinned pick and is never auto-reset (state and hash both
  survive metadata edits); failed waits for the matcher's backoff
  policy (attempts column ready).
- Worker: _kick_enrich/_enrich_runner mirror the scan's single-flight +
  coalescing pattern, kicked when a scan pass fully completes (the scan
  pool is a no-network process pool by design; the 5-minute periodic
  rescan is the natural retry hook). One bounded pass per kick - no
  drain-loop, since the no-op matcher legitimately leaves rows
  unscanned. _enrich_throttle() is the <=1 req/s seam every matcher
  must call before a network request, and the never-hold-meta_db._lock-
  across-a-fetch rule is documented at the seam.
- CONFIG_DIR/art_cache dir helper (the cover-art slice adds the LRU
  cap) + GET /api/enrichment/status (worker flags + counts by state).

8 new tests; full-suite failure set identical to unmodified main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(library): lock enrichment reads on shared conn + skip redundant stub writes (PR #707 review)

Issue 1: wrap the SELECT+fetch in enrichment_pending, get_enrichment and
enrichment_state_counts in self._lock so request-thread reads no longer
interleave with the worker's execute+commit on the shared connection.

Issue 2: guard upsert_enrichment_stub so an already-settled row (manual
pick, or a non-manual row whose content_hash already matches) skips the
UPDATE/commit — stops the no-op matcher re-writing every song each pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-02 13:39:10 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent f6d8e241eb
commit 7c15cdda66
2 changed files with 420 additions and 1 deletions
+269 -1
View File
@@ -813,6 +813,43 @@ class MetadataDB:
"CREATE UNIQUE INDEX IF NOT EXISTS idx_wanted_identity " "CREATE UNIQUE INDEX IF NOT EXISTS idx_wanted_identity "
"ON wanted(artist COLLATE NOCASE, title COLLATE NOCASE, source, source_ref)" "ON wanted(artist COLLATE NOCASE, title COLLATE NOCASE, source, source_ref)"
) )
# Metadata-enrichment cache (P7, library-metadata design §4/§5/§6): one
# row per song holding its match lifecycle + the canonical values a
# confident match supplies. A CACHE/OVERRIDE layer — canonical values
# are displayed, NEVER auto-written into the pack file. Never purged on
# rescan (only by the explicit per-song delete); re-derivable, so a lost
# row just re-enriches. `content_hash` keys the row to the metadata a
# match depends on (normalized artist|title|album|duration — NOT the
# filename), which makes enrichment idempotent AND rename-survivable.
# match_state lifecycle: unscanned → matched(source,score) | manual |
# failed. A `manual` row is the user's pinned pick — NEVER auto-reset;
# `failed` retries on backoff via `attempts` (the matcher, P8, owns
# that policy). Additive + idempotent.
self.conn.execute("""
CREATE TABLE IF NOT EXISTS song_enrichment (
filename TEXT PRIMARY KEY,
content_hash TEXT,
match_state TEXT NOT NULL DEFAULT 'unscanned',
match_source TEXT,
match_score REAL,
attempts INTEGER NOT NULL DEFAULT 0,
mb_recording_id TEXT,
mb_release_id TEXT,
mb_artist_id TEXT,
isrc TEXT,
canon_artist TEXT,
canon_album TEXT,
canon_title TEXT,
canon_year TEXT,
canon_artist_sort TEXT,
genres TEXT,
art_cache_path TEXT,
art_state TEXT,
fetched_at TEXT
)
""")
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_enrichment_hash ON song_enrichment(content_hash)")
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_enrichment_state ON song_enrichment(match_state)")
# Progression (spec 010): instrument paths, challenges, quests, the # Progression (spec 010): instrument paths, challenges, quests, the
# Decibels wallet, and the cosmetics shop. Targets/titles live in the # Decibels wallet, and the cosmetics shop. Targets/titles live in the
# bundled content (data/progression/); these tables hold only player # bundled content (data/progression/); these tables hold only player
@@ -2528,6 +2565,120 @@ class MetadataDB:
self._work_display_dirty = True # membership changed → regroup self._work_display_dirty = True # membership changed → regroup
return len(stale) return len(stale)
# ── Metadata enrichment (P7 — plumbing; the matcher itself is the next
# slice) ─────────────────────────────────────────────────────────────────
@staticmethod
def enrichment_content_hash(artist, title, album, duration) -> str:
"""Identity hash of the metadata a match keys on — normalized
artist|title|album|duration. Deliberately excludes the filename, so a
renamed pack keeps its enrichment (rename-survivable), and an unchanged
hash makes re-enrichment a no-op (idempotent). Whitespace/case-folded
so trivial edits don't invalidate a match; duration is rounded to whole
seconds for the same reason."""
def norm(s):
return " ".join(str(s or "").lower().split())
try:
dur = str(int(round(float(duration or 0))))
except (TypeError, ValueError):
dur = "0"
raw = "|".join([norm(artist), norm(title), norm(album), dur])
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
def enrichment_pending(self, limit: int = 500) -> list[dict]:
"""Songs whose enrichment row needs (re)matching: no row yet, or an
`unscanned`/`matched` row whose content_hash no longer matches the
song's current metadata (an edit changed the identity → re-match).
`manual` rows are the user's pinned pick and are NEVER re-queued;
`failed` rows wait for the matcher's backoff policy (next slice) rather
than being re-queued here every pass."""
# Read under _lock: the worker commits on this shared connection under
# _lock, so an unlocked SELECT could interleave with its execute+commit.
with self._lock:
rows = self.conn.execute(
"SELECT s.filename, s.artist, s.title, s.album, s.duration, "
"e.content_hash, e.match_state "
"FROM songs s LEFT JOIN song_enrichment e ON e.filename = s.filename "
"WHERE s.title != '' AND (e.filename IS NULL OR e.match_state IN ('unscanned', 'matched')) "
"ORDER BY s.filename LIMIT ?", (max(1, int(limit)),)).fetchall()
out = []
for fn, artist, title, album, duration, ehash, state in rows:
h = self.enrichment_content_hash(artist, title, album, duration)
# No row yet, still unmatched, or the identity changed under a
# match → needs the matcher. A matched row with an unchanged hash
# is settled (idempotence).
if state is None or state == "unscanned" or ehash != h:
out.append({"filename": fn, "artist": artist, "title": title,
"album": album, "duration": duration,
"content_hash": h, "match_state": state})
return out
def upsert_enrichment_stub(self, filename: str, content_hash: str) -> None:
"""Write/refresh a row's identity hash ahead of matching. A row whose
hash changed drops back to `unscanned` (the old match no longer applies)
EXCEPT a `manual` row, which is the user's explicit pick and survives
metadata edits untouched."""
with self._lock:
# Idempotence: skip the UPDATE/commit when the upsert would be a
# no-op. The no-op matcher (P7) re-stamps every pending row each
# pass; without this guard an already-settled row would be
# rewritten every ~5 min, N commits/pass contending with request
# writes. A `manual` pick never changes here, and a non-manual row
# whose hash already matches keeps its state+hash — both no-ops.
cur = self.conn.execute(
"SELECT content_hash, match_state FROM song_enrichment WHERE filename = ?",
(filename,)).fetchone()
if cur is not None:
old_hash, state = cur
if state == "manual" or old_hash == content_hash:
return
self.conn.execute(
"INSERT INTO song_enrichment (filename, content_hash, match_state) "
"VALUES (?, ?, 'unscanned') "
"ON CONFLICT(filename) DO UPDATE SET "
" match_state = CASE WHEN song_enrichment.match_state = 'manual' "
" THEN song_enrichment.match_state "
" WHEN song_enrichment.content_hash IS NOT excluded.content_hash "
" THEN 'unscanned' "
" ELSE song_enrichment.match_state END, "
" content_hash = CASE WHEN song_enrichment.match_state = 'manual' "
" THEN song_enrichment.content_hash "
" ELSE excluded.content_hash END",
(filename, content_hash))
self.conn.commit()
def get_enrichment(self, filename: str) -> dict | None:
# Read under _lock (shared write connection — see enrichment_pending).
with self._lock:
row = self.conn.execute(
"SELECT filename, content_hash, match_state, match_source, match_score, attempts, "
"mb_recording_id, mb_release_id, mb_artist_id, isrc, "
"canon_artist, canon_album, canon_title, canon_year, canon_artist_sort, "
"genres, art_cache_path, art_state, fetched_at "
"FROM song_enrichment WHERE filename = ?", (filename,)).fetchone()
if not row:
return None
keys = ("filename", "content_hash", "match_state", "match_source", "match_score",
"attempts", "mb_recording_id", "mb_release_id", "mb_artist_id", "isrc",
"canon_artist", "canon_album", "canon_title", "canon_year",
"canon_artist_sort", "genres", "art_cache_path", "art_state", "fetched_at")
out = dict(zip(keys, row))
try:
out["genres"] = json.loads(out["genres"]) if out["genres"] else []
except (ValueError, TypeError):
out["genres"] = []
return out
def enrichment_state_counts(self) -> dict:
"""{match_state: count} over rows whose song still exists (dead rows are
filtered at read time, matching the never-purged-on-rescan contract)."""
# Read under _lock (shared write connection — see enrichment_pending).
with self._lock:
rows = self.conn.execute(
"SELECT e.match_state, COUNT(*) FROM song_enrichment e "
"JOIN songs s ON s.filename = e.filename GROUP BY e.match_state").fetchall()
return {r[0]: r[1] for r in rows}
def _estd_set(self) -> set[str]: def _estd_set(self) -> set[str]:
"""Get set of filenames that have a retuned variant (_EStd_ or _DropD_) in the DB.""" """Get set of filenames that have a retuned variant (_EStd_ or _DropD_) in the DB."""
rows = self.conn.execute( rows = self.conn.execute(
@@ -5067,9 +5218,109 @@ def _scan_runner():
with _scan_kick_lock: with _scan_kick_lock:
if not _scan_rescan_pending: if not _scan_rescan_pending:
_scan_status["running"] = False _scan_status["running"] = False
return break
_scan_rescan_pending = False _scan_rescan_pending = False
_scan_status["running"] = True _scan_status["running"] = True
# Enrichment rides scan completion (library-metadata design §6): the scan
# pool is a side-effect-free, no-network process pool by design, so
# enrichment is a SEPARATE post-scan pass — non-blocking, the library is
# usable immediately. The 5-minute periodic rescan re-kicks it, which is
# the natural low-priority retry hook.
_kick_enrich()
# ── Metadata enrichment worker (P7 — plumbing) ────────────────────────────────
# A single throttled daemon thread + queue, mirroring _kick_scan/_scan_runner
# (single-flight + coalescing; NOT a pool — external lookups are rate-limited
# to ~1/s, which makes a pool pointless). This slice ships the full lifecycle
# around a NO-OP matcher: the queue walk, the identity hashing, the throttle
# seam, and the status surface — so the real text matcher (next slice) replaces
# exactly one function (_enrich_one) and inherits everything else.
_enrich_kick_lock = threading.Lock()
_enrich_pending_pass = False
_enrich_status = {"running": False, "processed": 0, "last_pass_at": None}
# Minimum spacing between EXTERNAL lookups (design: ≤1 req/s + local cache).
_ENRICH_MIN_INTERVAL = 1.1
_enrich_last_fetch = 0.0
def _enrichment_art_dir() -> Path:
"""The size-capped art cache dir (populated by the Cover Art slice; the
LRU cap policy lands with it). Under CONFIG_DIR so Settings backup/restore
and the docker volume already cover it."""
d = CONFIG_DIR / "art_cache"
d.mkdir(parents=True, exist_ok=True)
return d
def _enrich_throttle():
"""Block until an external lookup is allowed. Matchers MUST call this
before every network request and must NOT hold meta_db._lock across the
request (fetch outside the lock, write inside)."""
global _enrich_last_fetch
wait = _ENRICH_MIN_INTERVAL - (time.monotonic() - _enrich_last_fetch)
if wait > 0:
time.sleep(wait)
_enrich_last_fetch = time.monotonic()
def _enrich_one(row: dict) -> None:
"""P7's NO-OP matcher: stamp/refresh the row's identity hash (which also
drops a stale match back to `unscanned`, never a `manual` pick) and stop.
The text-match pipeline replaces this function; anything that reaches the
network must go through _enrich_throttle() and must not hold meta_db._lock
across the fetch."""
meta_db.upsert_enrichment_stub(row["filename"], row["content_hash"])
def _background_enrich():
"""One pass over the rows needing (re)matching. A single bounded pass —
with the no-op matcher, `unscanned` rows legitimately stay unscanned, so
looping until the queue drains would spin forever."""
_enrich_status["processed"] = 0
try:
pending = meta_db.enrichment_pending(limit=100000)
except Exception:
log.exception("enrichment: pending query failed")
return
for row in pending:
try:
_enrich_one(row)
except Exception as e:
log.warning("enrichment failed for %s: %s", row.get("filename"), e)
_enrich_status["processed"] += 1
_enrich_status["last_pass_at"] = time.time()
if pending:
log.info("Enrichment pass: %d rows refreshed", len(pending))
def _kick_enrich() -> bool:
"""Request an enrichment pass, single-flight + coalescing (the _kick_scan
contract): True = a worker thread was started, False = one is running and
a follow-up pass was queued."""
global _enrich_pending_pass
with _enrich_kick_lock:
if _enrich_status["running"]:
_enrich_pending_pass = True
return False
_enrich_status["running"] = True
threading.Thread(target=_enrich_runner, daemon=True).start()
return True
def _enrich_runner():
global _enrich_pending_pass
while True:
try:
_background_enrich()
except Exception:
log.exception("background enrichment failed unexpectedly")
with _enrich_kick_lock:
if not _enrich_pending_pass:
_enrich_status["running"] = False
return
_enrich_pending_pass = False
# ── Register plugin API endpoints (lightweight, before app starts) ─────────── # ── Register plugin API endpoints (lightweight, before app starts) ───────────
@@ -5542,6 +5793,20 @@ def scan_status():
return _scan_status return _scan_status
@app.get("/api/enrichment/status")
def enrichment_status():
"""Enrichment pipeline state: worker flags + row counts by match_state.
Ambient tool-state for the match-review UI (never a home-screen score
design §11); also what tests poke."""
return {
"running": _enrich_status["running"],
"processed": _enrich_status["processed"],
"last_pass_at": _enrich_status["last_pass_at"],
"states": meta_db.enrichment_state_counts(),
"total_songs": meta_db.count(),
}
@app.get("/api/startup-status") @app.get("/api/startup-status")
def startup_status(): def startup_status():
return _get_startup_status() return _get_startup_status()
@@ -6035,6 +6300,9 @@ def delete_song(filename: str):
meta_db.conn.execute("DELETE FROM work_display WHERE filename = ?", (cache_key,)) meta_db.conn.execute("DELETE FROM work_display WHERE filename = ?", (cache_key,))
meta_db.conn.execute("DELETE FROM chart_group_pref WHERE preferred_filename = ?", (cache_key,)) meta_db.conn.execute("DELETE FROM chart_group_pref WHERE preferred_filename = ?", (cache_key,))
meta_db._work_display_dirty = True meta_db._work_display_dirty = True
# Enrichment is never purged on rescan (delete_missing), only here
# on the explicit per-song delete — the never-clobber contract.
meta_db.conn.execute("DELETE FROM song_enrichment WHERE filename = ?", (cache_key,))
meta_db.conn.commit() meta_db.conn.commit()
_invalidate_song_caches(cache_key) _invalidate_song_caches(cache_key)
+151
View File
@@ -0,0 +1,151 @@
"""Tests for the metadata-enrichment plumbing (P7): the song_enrichment cache
table + identity hashing + the queue/lifecycle around the (for now) no-op
matcher. The text matcher itself is the next slice; these tests pin the
contracts it will inherit: rename-survivable idempotent hashing, manual rows
never auto-reset, never purged on rescan, purged on explicit delete."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _put(server, fn, title="Song", artist="Artist", album="", duration=100):
server.meta_db.put(fn, 0, 0, {
"title": title, "artist": artist, "album": album, "duration": duration,
"arrangements": [{"name": "Lead", "index": 0}],
})
# ── identity hash ─────────────────────────────────────────────────────────────
def test_hash_is_rename_survivable_and_normalized(server):
h = server.meta_db.enrichment_content_hash
# keyed on metadata, not the filename → a renamed pack keeps its hash
assert h("Artist", "Song", "Album", 100) == h("Artist", "Song", "Album", 100)
# case + whitespace folded; duration rounded to whole seconds
assert h(" artist ", "SONG", "album", 100.4) == h("Artist", "Song", "Album", 100)
# a real identity change means a different hash
assert h("Artist", "Song", "Album", 100) != h("Artist", "Other", "Album", 100)
assert h("Artist", "Song", "Album", None) == h("Artist", "Song", "Album", 0)
# ── queue selection ───────────────────────────────────────────────────────────
def test_pending_covers_new_unscanned_and_changed(server):
_put(server, "a.archive")
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
# stubbed → still unscanned → still pending (the matcher hasn't run)
server._background_enrich()
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
# a matched row with the CURRENT hash is settled…
h = server.meta_db.enrichment_content_hash("Artist", "Song", "", 100)
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state = 'matched', content_hash = ? "
"WHERE filename = 'a.archive'", (h,))
server.meta_db.conn.commit()
assert server.meta_db.enrichment_pending() == []
# …until the song's identity changes, which re-queues it
_put(server, "a.archive", title="Song (Remastered)")
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
def test_hash_change_resets_matched_but_never_manual(server):
_put(server, "a.archive")
_put(server, "b.archive", title="Other")
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 = 'manual' WHERE filename = 'b.archive'")
server.meta_db.conn.commit()
# identity edits…
_put(server, "a.archive", title="Song v2")
_put(server, "b.archive", title="Other v2")
server._background_enrich()
a = server.meta_db.get_enrichment("a.archive")
b = server.meta_db.get_enrichment("b.archive")
# …drop a stale MATCH back to unscanned with the fresh hash
assert a["match_state"] == "unscanned"
assert a["content_hash"] == server.meta_db.enrichment_content_hash("Artist", "Song v2", "", 100)
# …but a MANUAL pin survives untouched (state AND hash)
assert b["match_state"] == "manual"
assert b["content_hash"] == server.meta_db.enrichment_content_hash("Artist", "Other", "", 100)
def test_failed_rows_not_requeued_by_pending(server):
_put(server, "a.archive")
server._background_enrich()
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state = 'failed' WHERE filename = 'a.archive'")
server.meta_db.conn.commit()
# backoff/retry policy belongs to the matcher slice, not the queue walk
assert server.meta_db.enrichment_pending() == []
# ── worker pass ───────────────────────────────────────────────────────────────
def test_enrich_pass_stamps_every_song(server):
for i in range(5):
_put(server, f"s{i}.archive", title=f"Song {i}")
server._background_enrich()
for i in range(5):
row = server.meta_db.get_enrichment(f"s{i}.archive")
assert row is not None
assert row["match_state"] == "unscanned"
assert row["content_hash"] == server.meta_db.enrichment_content_hash(
"Artist", f"Song {i}", "", 100)
# ── lifecycle: rescan survival vs explicit delete ─────────────────────────────
def test_rescan_never_purges_enrichment(server):
_put(server, "a.archive")
server._background_enrich()
server.meta_db.delete_missing(set()) # file vanished from a scan snapshot
assert server.meta_db.get_enrichment("a.archive") is not None # row survives
# …and is invisible in the read-time-filtered counts
assert server.meta_db.enrichment_state_counts() == {}
_put(server, "a.archive") # the file comes back
assert server.meta_db.enrichment_state_counts() == {"unscanned": 1}
def test_status_endpoint_counts(client, server):
_put(server, "a.archive")
_put(server, "b.archive", title="Other")
server._background_enrich()
body = client.get("/api/enrichment/status").json()
assert body["states"] == {"unscanned": 2}
assert body["total_songs"] == 2
assert body["running"] is False
assert body["processed"] == 2
def test_art_cache_dir_created(server):
d = server._enrichment_art_dir()
assert d.is_dir()
assert d.name == "art_cache"