MusicBrainz: Switch track lookup from browse to arid: search

The previous commit's `browse_artist_recordings` call passed
`inc=releases+artist-credits` — but MusicBrainz's recording browse
endpoint rejects `inc=releases` with HTTP 400. The adapter's error
handler returned an empty list, so the Tracks section stayed empty
even though the fix was supposed to populate it.

Browse without release info is useless for our search UI (tracks
would render with no album), so swap to the fielded Lucene search
`arid:<mbid>` on the `/recording` endpoint. That's the canonical MB
pattern for "find recordings by this artist WITH release context":
- arid: search accepts the artist MBID and returns recordings with
  `releases` (release-group, date, media) embedded in each result.
- One API call per lookup, same as browse would have been.

Renamed the method to `search_recordings_by_artist_mbid` so the name
matches its behaviour — it's a search, not a browse. Adapter updated
to call the new name; tests updated to match.

Verified against the live API: Metallica's MBID returns 5 recordings
in ~1.8 seconds (vs the previous 400 error).
This commit is contained in:
Broque Thomas 2026-04-24 08:25:09 -07:00
parent 394ac73877
commit 8523724b03
3 changed files with 31 additions and 27 deletions

View file

@ -264,30 +264,35 @@ class MusicBrainzClient:
return [] return []
@rate_limited @rate_limited
def browse_artist_recordings(self, artist_mbid: str, def search_recordings_by_artist_mbid(self, artist_mbid: str,
limit: int = 100, limit: int = 100) -> List[Dict[str, Any]]:
offset: int = 0, """Search for recordings linked to an artist via Lucene `arid:` query.
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 This is the counterpart to `browse_artist_release_groups` for tracks.
`/recording?query=...` matches recording TITLES, while browse follows The proper "browse" endpoint (`/recording?artist=<mbid>`) rejects
the artistrecording link directly. `inc=releases`, so we can't get album context per recording from
browse only the track title/length/MBID. Without release info the
user would see tracks with no album, which is useless.
The search endpoint with a fielded `arid:<mbid>` query returns
recordings with the `releases` array already embedded (including
release-group, date, and media info), which is what the search-tab
UI needs.
Args: Args:
artist_mbid: Artist's MusicBrainz ID artist_mbid: Artist's MusicBrainz ID
limit: 1-100 (MB hard cap) limit: 1-100 (MB hard cap)
offset: Pagination offset
includes: e.g. ['releases', 'artist-credits'] to embed linked entities
Returns: Returns:
List of recording dicts with `id`, `title`, `length`, `disambiguation`, List of recording dicts with `id`, `title`, `length`, `score`,
and optionally `releases` / `artist-credit` per includes. `artist-credit`, and `releases` (each with release-group + date).
""" """
try: try:
params = {'artist': artist_mbid, 'fmt': 'json', 'limit': min(limit, 100), 'offset': offset} params = {
if includes: 'query': f'arid:{artist_mbid}',
params['inc'] = '+'.join(includes) 'fmt': 'json',
'limit': min(limit, 100),
}
response = self.session.get( response = self.session.get(
f"{self.BASE_URL}/recording", f"{self.BASE_URL}/recording",
@ -298,10 +303,10 @@ class MusicBrainzClient:
data = response.json() data = response.json()
recs = data.get('recordings', []) recs = data.get('recordings', [])
logger.debug(f"Browsed {len(recs)} recordings for artist {artist_mbid}") logger.debug(f"Found {len(recs)} recordings for artist {artist_mbid}")
return recs return recs
except Exception as e: except Exception as e:
logger.error(f"Error browsing recordings for artist {artist_mbid}: {e}") logger.error(f"Error searching recordings for artist {artist_mbid}: {e}")
return [] return []
@rate_limited @rate_limited

View file

@ -429,16 +429,15 @@ class MusicBrainzSearchClient:
if artist_name: if artist_name:
return self._search_tracks_text(title, artist_name, limit) return self._search_tracks_text(title, artist_name, limit)
# Bare name → artist-first → browse. # Bare name → artist-first → arid: search.
top = self._resolve_top_artist(query) top = self._resolve_top_artist(query)
if top: if top:
mbid = top.get('id', '') mbid = top.get('id', '')
tname = top.get('name', '') or query tname = top.get('name', '') or query
recs = self._client.browse_artist_recordings( # /recording?artist=<mbid> (browse) rejects inc=releases,
mbid, # so we use the fielded Lucene search arid:<mbid> instead —
limit=100, # that returns recordings with release context inline.
includes=['releases', 'artist-credits'], recs = self._client.search_recordings_by_artist_mbid(mbid, limit=100)
)
# Browse returns recordings unsorted. Dedupe by normalized # Browse returns recordings unsorted. Dedupe by normalized
# title (MB has many live/compilation variants of the same # title (MB has many live/compilation variants of the same
# song), then sort by release date desc so "newest" tracks # song), then sort by release date desc so "newest" tracks

View file

@ -274,7 +274,7 @@ def test_search_tracks_bare_query_uses_browse_path():
client = MusicBrainzSearchClient() client = MusicBrainzSearchClient()
client._client = MagicMock() client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)] client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.browse_artist_recordings.return_value = [ client._client.search_recordings_by_artist_mbid.return_value = [
{'id': 'rec-1', 'title': 'One', 'length': 446000, {'id': 'rec-1', 'title': 'One', 'length': 446000,
'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988', 'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988',
'release-group': {'id': 'rg-1', 'primary-type': 'Album'}}], 'release-group': {'id': 'rg-1', 'primary-type': 'Album'}}],
@ -287,7 +287,7 @@ def test_search_tracks_bare_query_uses_browse_path():
tracks = client.search_tracks('metallica', limit=10) tracks = client.search_tracks('metallica', limit=10)
client._client.browse_artist_recordings.assert_called_once() client._client.search_recordings_by_artist_mbid.assert_called_once()
client._client.search_recording.assert_not_called() client._client.search_recording.assert_not_called()
assert len(tracks) == 2 assert len(tracks) == 2
assert {t.name for t in tracks} == {'One', 'Battery'} assert {t.name for t in tracks} == {'One', 'Battery'}
@ -300,7 +300,7 @@ def test_search_tracks_dedupes_by_title():
client = MusicBrainzSearchClient() client = MusicBrainzSearchClient()
client._client = MagicMock() client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)] client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.browse_artist_recordings.return_value = [ client._client.search_recordings_by_artist_mbid.return_value = [
{'id': 'rec-1', 'title': 'One', 'length': 446000, {'id': 'rec-1', 'title': 'One', 'length': 446000,
'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988'}], 'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988'}],
'artist-credit': [{'name': 'Metallica'}]}, 'artist-credit': [{'name': 'Metallica'}]},
@ -328,7 +328,7 @@ def test_search_tracks_structured_query_uses_text_path():
client._client.search_recording.assert_called_once() client._client.search_recording.assert_called_once()
client._client.search_artist.assert_not_called() client._client.search_artist.assert_not_called()
client._client.browse_artist_recordings.assert_not_called() client._client.search_recordings_by_artist_mbid.assert_not_called()
assert len(tracks) == 1 assert len(tracks) == 1