feat(worker): share the MusicBrainz cache from scans/discovery

The worker's MB browser (discography + artist search, used by monitor sweeps,
library scans, and Last.fm discovery) re-fetched every time. Route it through
the same ApiCache table + logical keys the web app uses, so a discography one
process fetched warms the other, and repeat lookups stop hitting MB.

- apicache.py cached_api(): read-through, serve-stale-on-outage, never caches
  null, best-effort (a cache-DB error degrades to a live fetch, never breaks
  scan/sweep/discovery). Its own DEDICATED autocommit connection so it can't
  commit the worker's in-flight transaction, and autocommit avoids freezing
  Postgres now() (which would break TTL math).
- CachingMbBrowser decorates MusicBrainzBrowser, serializing to the SAME
  camelCase JSON shape the web writes (round-trip tested). registry.build_browser
  wraps it when a DSN is present; the Last.fm source's browser rides it too.
- Hourly prune drops ApiCache rows > 90d so the table stays bounded.
- worker 213 tests / 7-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 11:17:51 +02:00
parent bdbf9d237e
commit 034ae40084
7 changed files with 291 additions and 9 deletions
+11 -7
View File
@@ -1,4 +1,4 @@
from lyra_worker._mbbrowser import MusicBrainzBrowser
from lyra_worker._mbbrowser import CachingMbBrowser, MusicBrainzBrowser
from lyra_worker._musicbrainz import MusicBrainzResolver
from lyra_worker._mutagen import MutagenTagger
from lyra_worker._probe import MutagenProbe
@@ -42,9 +42,12 @@ def build_tagger() -> Tagger:
return MutagenTagger()
def build_browser() -> MbBrowser:
"""The MusicBrainz browser used by the background monitor."""
return MusicBrainzBrowser()
def build_browser(dsn: str | None = None) -> MbBrowser:
"""The MusicBrainz browser used by the background monitor. When a DSN is given, wrap it
in the shared ApiCache read-through so scans/discovery stop re-fetching discographies
(and warm the same cache the web modals read)."""
inner = MusicBrainzBrowser()
return CachingMbBrowser(inner, dsn) if dsn else inner
def build_probe() -> AudioProbe:
@@ -52,14 +55,15 @@ def build_probe() -> AudioProbe:
return MutagenProbe()
def build_similarity_sources(config: dict) -> list[SimilaritySource]:
def build_similarity_sources(config: dict, dsn: str | None = None) -> list[SimilaritySource]:
"""Similarity sources for discovery. ListenBrainz needs no credentials, so it is
always constructed; per-run reachability is checked via each source's health().
Last.fm is added only when an API key is configured."""
Last.fm is added only when an API key is configured. Its MB name→MBID lookups ride the
shared ApiCache when a DSN is given."""
sources: list[SimilaritySource] = [
ListenBrainzSource(base_url=config.get("discover.listenBrainzUrl") or "")
]
key = config.get("lastfm.api_key")
if key:
sources.append(LastfmSource(api_key=key, browser=MusicBrainzBrowser()))
sources.append(LastfmSource(api_key=key, browser=build_browser(dsn)))
return sources