From e2a5a38cd2bf9449c7f447fbaee302e4a2d92960 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:07:47 -0700 Subject: [PATCH] MusicBrainz: Add browse endpoints for release-groups + recordings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `browse_artist_release_groups(mbid)` and `browse_artist_recordings(mbid)` to MusicBrainzClient. These hit `/ws/2/release-group?artist=` and `/ws/2/recording?artist=` respectively — the correct MusicBrainz pattern for "give me everything linked to this artist." Why this matters: our current search adapter calls text-search (`release?query=...` / `recording?query=...`) for albums and tracks, which matches entity titles literally. Typing "metallica" hits unrelated releases titled "Metallica" and recordings named "Metallica" by obscure bands — every garbage match scores 100 because they're all exact title matches on the wrong field. Browse walks the artist→release-group and artist→recording links directly. Once we know the artist's MBID (from `search_artist`), browse returns their actual discography instead of title collisions. No behavior change yet — search adapter still uses the old path. Follow- up commit wires the new endpoints in. Reference: https://musicbrainz.org/doc/MusicBrainz_API — "Browse queries retrieve entities linked to a known entity" vs search. --- core/musicbrainz_client.py | 87 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/core/musicbrainz_client.py b/core/musicbrainz_client.py index f6a4ef47..a7b1b5b9 100644 --- a/core/musicbrainz_client.py +++ b/core/musicbrainz_client.py @@ -200,6 +200,93 @@ class MusicBrainzClient: logger.error(f"Error searching for recording '{track_name}': {e}") return [] + @rate_limited + def browse_artist_release_groups(self, artist_mbid: str, + release_types: Optional[List[str]] = None, + limit: int = 100, + offset: int = 0) -> List[Dict[str, Any]]: + """Browse release-groups linked to an artist MBID. + + This is the correct MusicBrainz pattern for "give me this artist's + discography" — text-based `/release?query=...` search would look at + release TITLES (matching unrelated releases literally titled after + the artist name), while browse walks the artist→release-group link + directly. + + Args: + artist_mbid: Artist's MusicBrainz ID + release_types: Filter by primary type — any of 'album', 'single', + 'ep', 'compilation', 'soundtrack', 'live', etc. Combined with + `|` per MB spec, e.g. `['album', 'ep']` → `type=album|ep`. + None returns all types. + limit: 1-100 (MB hard cap) + offset: Pagination offset + + Returns: + List of release-group dicts. Each has `id`, `title`, `primary-type`, + `secondary-types`, `first-release-date`, `disambiguation`. + """ + try: + params = {'artist': artist_mbid, 'fmt': 'json', 'limit': min(limit, 100), 'offset': offset} + if release_types: + params['type'] = '|'.join(release_types) + + response = self.session.get( + f"{self.BASE_URL}/release-group", + params=params, + timeout=10 + ) + response.raise_for_status() + + data = response.json() + rgs = data.get('release-groups', []) + logger.debug(f"Browsed {len(rgs)} release-groups for artist {artist_mbid}") + return rgs + except Exception as e: + logger.error(f"Error browsing release-groups for artist {artist_mbid}: {e}") + return [] + + @rate_limited + def browse_artist_recordings(self, artist_mbid: str, + limit: int = 100, + offset: int = 0, + includes: Optional[List[str]] = None) -> List[Dict[str, Any]]: + """Browse recordings (tracks) linked to an artist MBID. + + Counterpart to `browse_artist_release_groups` — text search on + `/recording?query=...` matches recording TITLES, while browse follows + the artist→recording link directly. + + Args: + artist_mbid: Artist's MusicBrainz ID + limit: 1-100 (MB hard cap) + offset: Pagination offset + includes: e.g. ['releases', 'artist-credits'] to embed linked entities + + Returns: + List of recording dicts with `id`, `title`, `length`, `disambiguation`, + and optionally `releases` / `artist-credit` per includes. + """ + try: + params = {'artist': artist_mbid, 'fmt': 'json', 'limit': min(limit, 100), 'offset': offset} + if includes: + params['inc'] = '+'.join(includes) + + response = self.session.get( + f"{self.BASE_URL}/recording", + params=params, + timeout=10 + ) + response.raise_for_status() + + data = response.json() + recs = data.get('recordings', []) + logger.debug(f"Browsed {len(recs)} recordings for artist {artist_mbid}") + return recs + except Exception as e: + logger.error(f"Error browsing recordings for artist {artist_mbid}: {e}") + return [] + @rate_limited def get_artist(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]: """