034ae40084
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>
111 lines
4.3 KiB
Python
111 lines
4.3 KiB
Python
from lyra_worker.apicache import DAY, MONTH, cached_api, norm_key, open_cache_conn
|
|
from lyra_worker.browser import ArtistHit, ReleaseGroupInfo
|
|
|
|
|
|
# Serialize to the SAME camelCase JSON shape the web app stores (web/src/lib/musicbrainz.ts),
|
|
# so a discography one process fetched warms the cache for the other.
|
|
def _artist_hit_to_json(a: ArtistHit) -> dict:
|
|
return {"mbid": a.mbid, "name": a.name, "disambiguation": a.disambiguation}
|
|
|
|
|
|
def _artist_hit_from_json(d: dict) -> ArtistHit:
|
|
return ArtistHit(mbid=d["mbid"], name=d.get("name", ""), disambiguation=d.get("disambiguation", ""))
|
|
|
|
|
|
def _rg_to_json(r: ReleaseGroupInfo) -> dict:
|
|
return {
|
|
"rgMbid": r.rg_mbid,
|
|
"title": r.title,
|
|
"primaryType": r.primary_type or None,
|
|
"secondaryTypes": list(r.secondary_types),
|
|
"firstReleaseDate": r.first_release_date or None,
|
|
}
|
|
|
|
|
|
def _rg_from_json(d: dict) -> ReleaseGroupInfo:
|
|
return ReleaseGroupInfo(
|
|
rg_mbid=d["rgMbid"],
|
|
title=d.get("title", "") or "",
|
|
primary_type=d.get("primaryType") or "",
|
|
secondary_types=tuple(d.get("secondaryTypes") or ()),
|
|
first_release_date=d.get("firstReleaseDate") or "",
|
|
)
|
|
|
|
|
|
class CachingMbBrowser:
|
|
"""Wraps an MbBrowser with the shared ApiCache read-through, keyed on the same logical
|
|
strings the web side uses (`artist-search:{q}`, `artist-rgs:{mbid}`). Uses a dedicated,
|
|
lazily (re)opened cache connection; if it can't connect, it just calls through uncached."""
|
|
|
|
def __init__(self, inner, dsn: str):
|
|
self._inner = inner
|
|
self._dsn = dsn
|
|
self._conn = None
|
|
|
|
def _cache_conn(self):
|
|
if self._conn is not None and not self._conn.closed:
|
|
return self._conn
|
|
self._conn = open_cache_conn(self._dsn)
|
|
return self._conn
|
|
|
|
def search_artist(self, name: str) -> list[ArtistHit]:
|
|
conn = self._cache_conn()
|
|
if conn is None:
|
|
return self._inner.search_artist(name)
|
|
key = f"artist-search:{norm_key(name)}"
|
|
rows = cached_api(conn, key, DAY,
|
|
lambda: [_artist_hit_to_json(a) for a in self._inner.search_artist(name)])
|
|
return [_artist_hit_from_json(d) for d in rows]
|
|
|
|
def browse_release_groups(self, artist_mbid: str) -> list[ReleaseGroupInfo]:
|
|
conn = self._cache_conn()
|
|
if conn is None:
|
|
return self._inner.browse_release_groups(artist_mbid)
|
|
key = f"artist-rgs:{artist_mbid}"
|
|
rows = cached_api(conn, key, MONTH,
|
|
lambda: [_rg_to_json(r) for r in self._inner.browse_release_groups(artist_mbid)])
|
|
return [_rg_from_json(d) for d in rows]
|
|
|
|
|
|
class MusicBrainzBrowser:
|
|
"""Real MbBrowser via musicbrainzngs (imported lazily; not unit-tested offline)."""
|
|
|
|
def __init__(self, app_name: str = "Lyra", version: str = "0.1", contact: str = "lyra@localhost"):
|
|
self._app, self._version, self._contact = app_name, version, contact
|
|
|
|
def _ua(self):
|
|
import musicbrainzngs
|
|
|
|
musicbrainzngs.set_useragent(self._app, self._version, self._contact)
|
|
return musicbrainzngs
|
|
|
|
def search_artist(self, name: str) -> list[ArtistHit]:
|
|
mb = self._ua()
|
|
res = mb.search_artists(query=name, limit=8)
|
|
return [
|
|
ArtistHit(mbid=a["id"], name=a.get("name", ""), disambiguation=a.get("disambiguation", ""))
|
|
for a in res.get("artist-list", [])
|
|
]
|
|
|
|
def browse_release_groups(self, artist_mbid: str) -> list[ReleaseGroupInfo]:
|
|
mb = self._ua()
|
|
out: list[ReleaseGroupInfo] = []
|
|
offset = 0
|
|
while True:
|
|
res = mb.browse_release_groups(artist=artist_mbid, limit=100, offset=offset)
|
|
groups = res.get("release-group-list", [])
|
|
for g in groups:
|
|
out.append(
|
|
ReleaseGroupInfo(
|
|
rg_mbid=g["id"],
|
|
title=g.get("title", ""),
|
|
primary_type=g.get("primary-type", "") or "",
|
|
secondary_types=tuple(g.get("secondary-type-list", []) or ()),
|
|
first_release_date=g.get("first-release-date", "") or "",
|
|
)
|
|
)
|
|
offset += len(groups)
|
|
if len(groups) < 100 or offset >= int(res.get("release-group-count", offset)):
|
|
break
|
|
return out
|