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