soulsync/tests/metadata/test_musicbrainz_search.py
BoulderBadgeDad b14d504cc1 Fix: MusicBrainz artist discography capped at 25 releases
Artist-detail discography from MusicBrainz fetched releases via the
artist lookup (`/artist/<mbid>?inc=release-groups`), which MusicBrainz
hard-caps at 25 embedded release-groups and which ignores the `limit`
param entirely. Prolific artists had ~85% of their catalogue silently
dropped — Kendrick Lamar has 167 release-groups on the site but only the
first 25 ever reached SoulSync. Reported by Sokhi: "a lot of albums are
missing when searching vs what's showing on the site."

Switch `get_artist_albums` to walk the paginated browse endpoint
(`/release-group?artist=<mbid>`, offset loop) — the same pattern the
basic-search path already uses — fetching the full catalogue up to the
caller's limit. No type filter and no studio-only filter here: the
artist-detail page wants every primary/secondary type so its tabs mirror
musicbrainz.org. Verified live: now returns all 167 for Kendrick.

Adds 7 tests covering pagination past the cap, offset advance,
short-page stop, limit cap, cross-page dedup, type->bucket mapping, and
a regression pin asserting the capped inc=release-groups lookup is no
longer the discography source.
2026-05-28 12:00:05 -07:00

1328 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the MusicBrainz search adapter (core/musicbrainz_search.py).
Covers the behavior changes from the search-overhaul PR:
- Artist search is re-enabled and score-filtered
- Bare name queries route through artist-first → browse
- Structured 'Artist - Title' queries stay on text search
- Top-artist resolution is memoized per instance
- Cover Art URLs are constructed, not probed
"""
from unittest.mock import MagicMock, patch
import pytest
from core.musicbrainz_search import (
MusicBrainzSearchClient,
_cover_art_url,
_extract_title_hint,
)
# ---------------------------------------------------------------------------
# Cover art URL construction
# ---------------------------------------------------------------------------
def test_cover_art_url_release_scope():
assert _cover_art_url('abc-123') == 'https://coverartarchive.org/release/abc-123/front-250'
def test_cover_art_url_release_group_scope():
assert _cover_art_url('abc-123', scope='release-group') == \
'https://coverartarchive.org/release-group/abc-123/front-250'
def test_cover_art_url_empty_mbid_returns_none():
assert _cover_art_url('') is None
assert _cover_art_url(None) is None
def test_cover_art_url_unknown_scope_falls_back_to_release():
assert _cover_art_url('abc', scope='garbage') == 'https://coverartarchive.org/release/abc/front-250'
# ---------------------------------------------------------------------------
# Structured query splitting
# ---------------------------------------------------------------------------
def test_split_structured_query_hyphen():
client = MusicBrainzSearchClient()
assert client._split_structured_query('Metallica - Master of Puppets') == ('Metallica', 'Master of Puppets')
def test_split_structured_query_en_dash():
client = MusicBrainzSearchClient()
assert client._split_structured_query('Metallica One') == ('Metallica', 'One')
def test_split_structured_query_em_dash():
client = MusicBrainzSearchClient()
assert client._split_structured_query('Metallica — Battery') == ('Metallica', 'Battery')
def test_split_structured_query_bare_name():
client = MusicBrainzSearchClient()
assert client._split_structured_query('metallica') == (None, 'metallica')
def test_split_structured_query_no_separator_with_hyphens_in_word():
# A hyphen inside a word (no surrounding spaces) should not split.
client = MusicBrainzSearchClient()
assert client._split_structured_query('t-pain') == (None, 't-pain')
# ---------------------------------------------------------------------------
# Artist search — score filtering and shape
# ---------------------------------------------------------------------------
def _mk_artist(name, mbid, score=100, tags=None):
return {
'id': mbid,
'name': name,
'score': score,
'tags': tags or [],
}
def test_search_artists_filters_by_score_threshold():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
_mk_artist('Metallica', 'mb-real', score=100),
_mk_artist('Metallica Tribute', 'mb-tribute', score=60),
_mk_artist('Metallica Jam', 'mb-jam', score=58),
]
results = client.search_artists('metallica', limit=10)
assert len(results) == 1
assert results[0].name == 'Metallica'
assert results[0].id == 'mb-real'
def test_search_artists_uses_strict_false_for_fuzzy_match():
"""The adapter must use strict=False so MusicBrainz searches
alias+artist+sortname together — strict mode would miss aliased names.
Adapter fetches `limit * 3` (min 10) so dedup-by-name below has enough
candidates to pick from.
"""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = []
client.search_artists('metallica')
client._client.search_artist.assert_called_once_with('metallica', limit=30, strict=False)
def test_search_artists_dedupes_same_named_homonyms():
"""MusicBrainz has many different PEOPLE sharing a canonical name
(7 Michael Jacksons: singer, poet, photographer, mashup artist, ...).
Since they all render as "Michael Jackson" with the same fallback image,
dedupe to the highest-scoring entry per name."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
{'id': 'mb-king', 'name': 'Michael Jackson', 'score': 100,
'tags': [{'name': 'pop'}]},
{'id': 'mb-poet', 'name': 'Michael Jackson', 'score': 81},
{'id': 'mb-mashup', 'name': 'Michael Jackson', 'score': 80},
{'id': 'mb-photog', 'name': 'Michael Jackson', 'score': 80},
{'id': 'mb-other', 'name': 'Michael Jackson', 'score': 80},
]
results = client.search_artists('michael jackson', limit=10)
# Should collapse to one entry — the highest-scoring one.
assert len(results) == 1
assert results[0].id == 'mb-king'
assert results[0].popularity == 100
def test_search_artists_dedup_normalized_case_and_whitespace():
"""Dedup key is case-insensitive and whitespace-normalized."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
{'id': 'mb-1', 'name': 'The Band', 'score': 100},
{'id': 'mb-2', 'name': 'THE BAND', 'score': 85},
{'id': 'mb-3', 'name': 'the band', 'score': 82},
]
results = client.search_artists('the band', limit=5)
assert len(results) == 1
assert results[0].id == 'mb-1'
def test_search_artists_keeps_distinct_names():
"""Dedup only collapses identical normalized names, not similar names."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
{'id': 'mb-1', 'name': 'The Beatles', 'score': 100},
{'id': 'mb-2', 'name': 'The Beatles Revival', 'score': 85},
]
results = client.search_artists('the beatles', limit=5)
assert {r.name for r in results} == {'The Beatles', 'The Beatles Revival'}
def test_search_artists_returns_empty_on_exception():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.side_effect = RuntimeError('network down')
assert client.search_artists('metallica') == []
def test_search_artists_extracts_tags_as_genres():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
_mk_artist('Metallica', 'mb-real', score=100,
tags=[{'name': 'thrash metal', 'count': 20},
{'name': 'heavy metal', 'count': 15}]),
]
results = client.search_artists('metallica')
assert results[0].genres == ['thrash metal', 'heavy metal']
def test_search_artists_skips_entries_without_mbid_or_name():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
{'id': 'mb-1', 'name': 'Good', 'score': 100},
{'id': '', 'name': 'Missing MBID', 'score': 100},
{'id': 'mb-2', 'name': '', 'score': 100},
]
results = client.search_artists('x')
assert [r.name for r in results] == ['Good']
# ---------------------------------------------------------------------------
# Top-artist resolution — memoization
# ---------------------------------------------------------------------------
def test_resolve_top_artist_memoizes_by_normalized_query():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
first = client._resolve_top_artist('metallica')
second = client._resolve_top_artist(' Metallica ') # Whitespace / case variant
assert first is not None
assert first['id'] == 'mb-1'
assert first is second
# HTTP call happens once despite two resolve calls.
assert client._client.search_artist.call_count == 1
def test_resolve_top_artist_returns_none_below_threshold():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Tribute', 'mb-trib', score=50)]
assert client._resolve_top_artist('obscure') is None
def test_resolve_top_artist_caches_negative_result():
"""After a lookup finds no good match, subsequent calls don't refetch."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = []
first = client._resolve_top_artist('nonexistent band')
second = client._resolve_top_artist('nonexistent band')
assert first is None
assert second is None
assert client._client.search_artist.call_count == 1
def test_resolve_top_artist_empty_query_returns_none_without_http():
client = MusicBrainzSearchClient()
client._client = MagicMock()
assert client._resolve_top_artist('') is None
client._client.search_artist.assert_not_called()
# ---------------------------------------------------------------------------
# Album search — routing
# ---------------------------------------------------------------------------
def test_search_albums_bare_query_uses_browse_path():
"""When a bare name resolves to an artist, we browse their release-groups
instead of text-searching release titles."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-1', 'title': 'Master of Puppets', 'primary-type': 'Album',
'first-release-date': '1986-03-03', 'secondary-types': []},
{'id': 'rg-2', 'title': 'Ride the Lightning', 'primary-type': 'Album',
'first-release-date': '1984-07-27', 'secondary-types': []},
]
albums = client.search_albums('metallica', limit=10)
client._client.browse_artist_release_groups.assert_called_once()
# Text-search path must NOT be taken.
client._client.search_release.assert_not_called()
# Chronological ASC — debut first, so the album list reads like a
# standard discography (Wikipedia-style: earliest release on top).
assert [a.name for a in albums] == ['Ride the Lightning', 'Master of Puppets']
assert all(a.artists == ['Metallica'] for a in albums)
def test_search_albums_structured_query_uses_text_path():
"""'Artist - Title' shape should text-search the title rather than
browsing all of the artist's discography."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_release.return_value = [
{'id': 'rel-1', 'title': 'Master of Puppets', 'score': 100,
'date': '1986', 'media': [{'track-count': 8}],
'release-group': {'id': 'rg-1', 'primary-type': 'Album'},
'artist-credit': [{'name': 'Metallica'}]},
]
albums = client.search_albums('Metallica - Master of Puppets', limit=10)
client._client.search_release.assert_called_once()
# Artist-first path must NOT be taken.
client._client.search_artist.assert_not_called()
client._client.browse_artist_release_groups.assert_not_called()
assert len(albums) == 1
assert albums[0].name == 'Master of Puppets'
def test_search_albums_falls_back_to_text_when_no_artist_match():
"""No artist above threshold → text-search the whole query."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
# Artist lookup returns nothing above threshold.
client._client.search_artist.return_value = [_mk_artist('X', 'mb-x', score=40)]
client._client.search_release.return_value = []
client.search_albums('very obscure band')
client._client.search_release.assert_called_once_with('very obscure band', artist_name=None, limit=10)
client._client.browse_artist_release_groups.assert_not_called()
def test_search_albums_filters_live_and_compilation_secondary_types():
"""Mega-artists' browse results are dominated by live bootlegs and
best-of compilations — they should be filtered out so the studio
discography surfaces."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-live-1', 'title': 'Live Bootleg 2019', 'primary-type': 'Album',
'first-release-date': '2019-01-01', 'secondary-types': ['Live']},
{'id': 'rg-studio-1', 'title': 'Kill Em All', 'primary-type': 'Album',
'first-release-date': '1983-07-25', 'secondary-types': []},
{'id': 'rg-comp-1', 'title': 'Greatest Hits', 'primary-type': 'Album',
'first-release-date': '2010-01-01', 'secondary-types': ['Compilation']},
{'id': 'rg-studio-2', 'title': 'Master of Puppets', 'primary-type': 'Album',
'first-release-date': '1986-03-03', 'secondary-types': []},
]
albums = client.search_albums('metallica', limit=10)
titles = [a.name for a in albums]
assert titles == ['Kill Em All', 'Master of Puppets']
assert 'Live Bootleg 2019' not in titles
assert 'Greatest Hits' not in titles
def test_search_albums_falls_back_to_all_when_no_studio():
"""Niche live-only artist: if no studio releases exist, show live ones
rather than returning empty."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('LiveBand', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-live-1', 'title': 'Live at X', 'primary-type': 'Album',
'first-release-date': '2019-01-01', 'secondary-types': ['Live']},
{'id': 'rg-live-2', 'title': 'Live at Y', 'primary-type': 'Album',
'first-release-date': '2020-01-01', 'secondary-types': ['Live']},
]
albums = client.search_albums('liveband', limit=10)
assert len(albums) == 2
def test_search_tracks_prefers_studio_release_in_album_field():
"""When a recording has both a studio release and a live release, the
Track.album should reflect the studio release (canonical album),
regardless of the order MB returned them in."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.search_recordings_by_artist_mbid.return_value = [
{
'id': 'rec-master',
'title': 'Master of Puppets',
'length': 516000,
'artist-credit': [{'name': 'Metallica'}],
# Live release first (what MB often returns), studio second.
'releases': [
{'id': 'rel-live', 'title': 'Live Bootleg', 'date': '2023-01-01',
'release-group': {'id': 'rg-live', 'primary-type': 'Album',
'secondary-types': ['Live']}},
{'id': 'rel-studio', 'title': 'Master of Puppets', 'date': '1986-03-03',
'release-group': {'id': 'rg-studio', 'primary-type': 'Album',
'secondary-types': []}},
],
},
]
tracks = client.search_tracks('metallica', limit=10)
assert len(tracks) == 1
# Album must be the studio release, not the live bootleg.
assert tracks[0].album == 'Master of Puppets'
assert tracks[0].release_date == '1986-03-03'
def test_search_tracks_filters_recordings_without_studio_releases():
"""A recording that only exists on live/compilation releases should be
dropped when we have studio alternatives."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.search_recordings_by_artist_mbid.return_value = [
{'id': 'rec-studio', 'title': 'Seek and Destroy', 'length': 390000,
'artist-credit': [{'name': 'Metallica'}],
'releases': [
{'id': 'rel-studio', 'title': 'Kill Em All', 'date': '1983-07-25',
'release-group': {'id': 'rg-studio', 'primary-type': 'Album',
'secondary-types': []}},
]},
{'id': 'rec-live-only', 'title': 'Fight Fire With Fire', 'length': 450000,
'artist-credit': [{'name': 'Metallica'}],
'releases': [
{'id': 'rel-live', 'title': 'Live Shit', 'date': '1993-01-01',
'release-group': {'id': 'rg-live', 'primary-type': 'Album',
'secondary-types': ['Live']}},
]},
]
tracks = client.search_tracks('metallica', limit=10)
titles = [t.name for t in tracks]
assert 'Seek and Destroy' in titles
assert 'Fight Fire With Fire' not in titles
def test_search_albums_text_path_filters_by_score():
client = MusicBrainzSearchClient()
client._client = MagicMock()
# Force text-search path by using a structured query.
client._client.search_release.return_value = [
{'id': 'rel-good', 'title': 'Good', 'score': 95,
'release-group': {'id': 'rg-1', 'primary-type': 'Album'},
'artist-credit': [{'name': 'Foo'}]},
{'id': 'rel-bad', 'title': 'Bad', 'score': 40,
'release-group': {'id': 'rg-2', 'primary-type': 'Album'},
'artist-credit': [{'name': 'Foo'}]},
]
albums = client.search_albums('Foo - Good', limit=10)
titles = [a.name for a in albums]
assert 'Good' in titles
assert 'Bad' not in titles
def test_search_albums_text_path_keeps_release_variants():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_release.return_value = [
{'id': 'rel-clean', 'title': 'Shock Value', 'score': 100,
'date': '2007-04-03', 'country': 'US', 'status': 'Official',
'disambiguation': 'clean',
'media': [{'format': 'CD', 'track-count': 17}],
'release-group': {'id': 'rg-shock', 'primary-type': 'Album'},
'artist-credit': [{'name': 'Timbaland'}]},
{'id': 'rel-explicit', 'title': 'Shock Value', 'score': 100,
'date': '2007-04-03', 'country': 'US', 'status': 'Official',
'disambiguation': 'explicit',
'media': [{'format': 'CD', 'track-count': 18}],
'release-group': {'id': 'rg-shock', 'primary-type': 'Album'},
'artist-credit': [{'name': 'Timbaland'}]},
]
albums = client.search_albums('Timbaland - Shock Value', limit=10)
assert [a.id for a in albums] == ['rel-clean', 'rel-explicit']
assert [a.total_tracks for a in albums] == [17, 18]
assert albums[1].disambiguation == 'explicit'
def test_search_albums_title_hint_expands_release_group_to_releases():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Spiderbait', 'artist-spiderbait', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-tonight', 'title': 'Tonight Alright', 'primary-type': 'Album',
'first-release-date': '2004-03-29', 'secondary-types': []},
]
client._client.browse_release_group_releases.return_value = [
{'id': 'rel-cd', 'title': 'Tonight Alright', 'date': '2004-03-29',
'country': 'AU', 'status': 'Official',
'media': [{'format': 'CD', 'track-count': 12}],
'artist-credit': [{'name': 'Spiderbait'}]},
{'id': 'rel-vinyl', 'title': 'Tonight Alright', 'date': '2024-07-26',
'country': 'AU', 'status': 'Official',
'media': [{'format': '12\" Vinyl', 'track-count': 13}],
'artist-credit': [{'name': 'Spiderbait'}]},
]
albums = client.search_albums('Spiderbait Tonight Alright', limit=10)
client._client.browse_release_group_releases.assert_called_once_with('rg-tonight', limit=25)
assert [a.id for a in albums] == ['rel-cd', 'rel-vinyl']
assert [a.total_tracks for a in albums] == [12, 13]
assert albums[0].format == 'CD'
# ---------------------------------------------------------------------------
# Track search — routing
# ---------------------------------------------------------------------------
def test_search_tracks_bare_query_uses_browse_path():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.search_recordings_by_artist_mbid.return_value = [
{'id': 'rec-1', 'title': 'One', 'length': 446000,
'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988',
'release-group': {'id': 'rg-1', 'primary-type': 'Album'}}],
'artist-credit': [{'name': 'Metallica'}]},
{'id': 'rec-2', 'title': 'Battery', 'length': 312000,
'releases': [{'id': 'rel-2', 'title': 'Master of Puppets', 'date': '1986',
'release-group': {'id': 'rg-2', 'primary-type': 'Album'}}],
'artist-credit': [{'name': 'Metallica'}]},
]
tracks = client.search_tracks('metallica', limit=10)
client._client.search_recordings_by_artist_mbid.assert_called_once()
client._client.search_recording.assert_not_called()
assert len(tracks) == 2
assert {t.name for t in tracks} == {'One', 'Battery'}
def test_search_tracks_dedupes_by_title():
"""MusicBrainz has many live/compilation variants of the same song.
Browse results should be deduped by normalized title so we don't show
'One' three times."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.search_recordings_by_artist_mbid.return_value = [
{'id': 'rec-1', 'title': 'One', 'length': 446000,
'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988'}],
'artist-credit': [{'name': 'Metallica'}]},
{'id': 'rec-1-live', 'title': 'One', 'length': 490000,
'releases': [{'id': 'rel-live', 'title': 'Live Shit', 'date': '1993'}],
'artist-credit': [{'name': 'Metallica'}]},
]
tracks = client.search_tracks('metallica', limit=10)
assert len(tracks) == 1
assert tracks[0].name == 'One'
def test_search_tracks_structured_query_uses_text_path():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
{'id': 'rec-1', 'title': 'One', 'score': 100,
'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988'}],
'artist-credit': [{'name': 'Metallica'}]},
]
tracks = client.search_tracks('Metallica - One', limit=10)
client._client.search_recording.assert_called_once()
client._client.search_artist.assert_not_called()
client._client.search_recordings_by_artist_mbid.assert_not_called()
assert len(tracks) == 1
def test_get_album_resolves_release_group_mbid_to_release():
"""When the album ID is a release-group MBID (from the browse path),
get_album must look up the release-group, pick a canonical release,
and fetch that release's tracklist. Fetching /release/<rg-mbid>
directly 404s."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
# Release-group lookup returns two editions — an Official release and
# a promo. The Official earlier release should win.
client._client.get_release_group.return_value = {
'id': 'rg-damn',
'title': 'DAMN.',
'primary-type': 'Album',
'secondary-types': [],
'first-release-date': '2017-04-14',
'artist-credit': [{'name': 'Kendrick Lamar'}],
'releases': [
{'id': 'rel-promo', 'status': 'Promotion', 'date': '2017-04-01',
'media': [{'track-count': 14, 'tracks': []}]},
{'id': 'rel-official', 'status': 'Official', 'date': '2017-04-14',
'media': [{'track-count': 14, 'tracks': []}]},
],
}
# Release lookup returns a full release with tracklist.
client._client.get_release.return_value = {
'id': 'rel-official',
'title': 'DAMN.',
'date': '2017-04-14',
'artist-credit': [{'name': 'Kendrick Lamar'}],
'release-group': {'id': 'rg-damn', 'primary-type': 'Album', 'secondary-types': []},
'media': [
{'position': 1, 'tracks': [
{'id': 't1', 'number': '1', 'position': 1, 'length': 50000,
'recording': {'id': 'rec-1', 'title': 'BLOOD.',
'artist-credit': [{'name': 'Kendrick Lamar'}], 'length': 50000}},
]},
],
}
album = client.get_album('rg-damn')
# Must have called release-group first, then release for the picked edition.
client._client.get_release_group.assert_called_once_with(
'rg-damn', includes=['releases', 'artist-credits']
)
client._client.get_release.assert_called_once_with(
'rel-official', includes=['recordings', 'artist-credits', 'release-groups']
)
assert album is not None
assert album['id'] == 'rg-damn' # Canonical ID stays the release-group MBID.
assert album['name'] == 'DAMN.'
assert len(album['tracks']) == 1
assert album['tracks'][0]['name'] == 'BLOOD.'
assert 'release-group' in album['external_urls']['musicbrainz']
def test_get_album_falls_back_to_release_lookup_on_rg_miss():
"""When the MBID is a release (from the text-search fallback path) the
release-group lookup 404s, but the direct release lookup works."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
# Release-group lookup returns None (simulating 404).
client._client.get_release_group.return_value = None
client._client.get_release.return_value = {
'id': 'rel-abc',
'title': 'Some Album',
'date': '2020-01-01',
'artist-credit': [{'name': 'Some Artist'}],
'release-group': {'id': 'rg-abc', 'primary-type': 'Album', 'secondary-types': []},
'media': [{'position': 1, 'tracks': []}],
}
album = client.get_album('rel-abc')
client._client.get_release_group.assert_called_once()
client._client.get_release.assert_called_once()
assert album is not None
assert album['id'] == 'rel-abc' # Falls back to release MBID since rg lookup missed.
# ---------------------------------------------------------------------------
# Title-hint extraction — for "Artist Album Title" bare queries
# ---------------------------------------------------------------------------
def test_extract_title_hint_basic():
assert _extract_title_hint('The Beatles Abbey Road', 'The Beatles') == 'Abbey Road'
def test_extract_title_hint_case_insensitive():
assert _extract_title_hint('the beatles abbey road', 'The Beatles') == 'abbey road'
def test_extract_title_hint_preserves_original_casing():
# Query slicing should return the original casing of the title portion.
assert _extract_title_hint('The Beatles Abbey Road', 'The Beatles') == 'Abbey Road'
def test_extract_title_hint_whitespace_tolerant():
assert _extract_title_hint('The Beatles Abbey Road', 'The Beatles') == 'Abbey Road'
def test_extract_title_hint_bare_artist_returns_none():
assert _extract_title_hint('The Beatles', 'The Beatles') is None
def test_extract_title_hint_artist_not_prefix_returns_none():
# Query where the artist name isn't the prefix — nothing to extract.
assert _extract_title_hint('Abbey Road', 'The Beatles') is None
def test_extract_title_hint_word_boundary_required():
# "Metallicasomething" shouldn't split as artist=Metallica + hint=something
assert _extract_title_hint('Metallicasomething', 'Metallica') is None
def test_search_albums_filters_browse_results_by_title_hint():
"""Regression: 'The Beatles Abbey Road' used to return the whole
discography; should now narrow to Abbey Road specifically."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('The Beatles', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-abbey', 'title': 'Abbey Road', 'primary-type': 'Album',
'first-release-date': '1969-09-26', 'secondary-types': []},
{'id': 'rg-white', 'title': 'The Beatles', 'primary-type': 'Album',
'first-release-date': '1968-11-22', 'secondary-types': []},
{'id': 'rg-revolver', 'title': 'Revolver', 'primary-type': 'Album',
'first-release-date': '1966-08-05', 'secondary-types': []},
]
albums = client.search_albums('The Beatles Abbey Road', limit=10)
# Filtered to only the album whose title matches the hint.
assert [a.name for a in albums] == ['Abbey Road']
def test_search_albums_falls_back_to_text_when_hint_matches_nothing():
"""If the title hint doesn't match any browse result, fall back to
text-search rather than returning the full discography or nothing."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('The Beatles', 'mb-1', score=100)]
# Browse returns albums that don't match the hint.
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-1', 'title': 'Some Other Album', 'primary-type': 'Album',
'first-release-date': '1965-01-01', 'secondary-types': []},
]
# Text-search fallback (_search_albums_text → search_release) returns the album.
client._client.search_release.return_value = [
{'id': 'rel-abbey', 'title': 'Abbey Road', 'score': 100,
'release-group': {'id': 'rg-abbey', 'primary-type': 'Album'},
'artist-credit': [{'name': 'The Beatles'}]},
]
albums = client.search_albums('The Beatles Totally Fake Album Name', limit=10)
# Browse had no hit for the title hint, then fallback kicks in when
# the filter results are also empty (after studio-pref filter etc.).
# NOTE: in this test the hint filter returns empty, so we fall through
# to search_release.
client._client.search_release.assert_called_once()
assert any(a.name == 'Abbey Road' for a in albums)
def test_search_albums_bare_artist_no_hint_no_filter():
"""Bare artist name (no title hint) returns full discography — the
filter only kicks in when the user types extra words."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('The Beatles', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-abbey', 'title': 'Abbey Road', 'primary-type': 'Album',
'first-release-date': '1969-09-26', 'secondary-types': []},
{'id': 'rg-revolver', 'title': 'Revolver', 'primary-type': 'Album',
'first-release-date': '1966-08-05', 'secondary-types': []},
]
albums = client.search_albums('the beatles', limit=10)
# No filter — full discography.
titles = {a.name for a in albums}
assert 'Abbey Road' in titles
assert 'Revolver' in titles
# ---------------------------------------------------------------------------
# Issue #650 — 'Other' primary-type release-groups must surface
# ---------------------------------------------------------------------------
def test_search_albums_browse_filter_requests_other_primary_type():
"""Issue #650: pre-fix the MB browse filter requested only
`album|ep|single`, dropping every primary-type=`Other` release-group
at the API layer. For artists like Vocaloid producers and JP indie
acts whose music videos / one-off web releases are tagged Other,
that hid legitimate tracks. Pin that the filter now includes
'other' so those release-groups round-trip into the discography."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Inabakumori', 'mb-i', score=100)]
client._client.browse_artist_release_groups.return_value = []
client.search_albums('inabakumori', limit=10)
# Inspect the actual call args — the API filter is the lever that
# decides whether MB returns Other-typed groups at all.
args, kwargs = client._client.browse_artist_release_groups.call_args
requested_types = kwargs.get('release_types') or (args[1] if len(args) > 1 else None)
assert requested_types is not None, \
"browse_artist_release_groups must receive an explicit release_types filter"
assert 'other' in requested_types, \
f"'other' must be in the requested types so #650 Other-typed releases surface; got {requested_types}"
def test_search_albums_other_type_release_groups_appear_as_singles():
"""When MB returns an Other-typed release-group (music video,
one-off web release), it must arrive in the discography as an
Album dataclass with album_type='single' — so the downstream
binner in `core/metadata/discography.py` routes it to the Singles
section rather than burying it among LPs."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Inabakumori', 'mb-i', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-mv', 'title': 'ロストアンブレラ', 'primary-type': 'Other',
'first-release-date': '2018-02-27', 'secondary-types': []},
{'id': 'rg-single', 'title': 'ラグトレイン', 'primary-type': 'Single',
'first-release-date': '2020-01-01', 'secondary-types': []},
]
albums = client.search_albums('inabakumori', limit=10)
by_id = {a.id: a for a in albums}
assert 'rg-mv' in by_id, "Other-typed release-group must survive the filter and arrive in the result"
assert by_id['rg-mv'].album_type == 'single', \
"Other-typed release-group must map to album_type='single' so it lands in the Singles section"
# Pre-existing single behaviour unchanged.
assert by_id['rg-single'].album_type == 'single'
def test_recording_to_track_total_tracks_matches_media_count():
"""Regression: total_tracks was initialized at 1 and summed with media
track-counts, producing an off-by-one. An 11-track album reported 12."""
client = MusicBrainzSearchClient()
recording = {
'id': 'rec-1',
'title': 'Song',
'length': 300000,
'artist-credit': [{'name': 'Band'}],
'releases': [{
'id': 'rel-1',
'title': 'Album',
'date': '2020-01-01',
'release-group': {'id': 'rg-1', 'primary-type': 'Album', 'secondary-types': []},
'media': [{'track-count': 11}],
}],
}
track = client._recording_to_track(recording, 'Band')
assert track is not None
assert track.total_tracks == 11
def test_recording_to_track_multi_disc_sums_media():
"""Two-disc album with 14 tracks total should report 14, not 15 (off by one)
or 3 (missing the sum)."""
client = MusicBrainzSearchClient()
recording = {
'id': 'rec-1',
'title': 'Song',
'artist-credit': [{'name': 'Band'}],
'releases': [{
'id': 'rel-1', 'title': 'Album',
'release-group': {'id': 'rg-1', 'primary-type': 'Album'},
'media': [{'track-count': 7}, {'track-count': 7}],
}],
}
track = client._recording_to_track(recording, 'Band')
assert track.total_tracks == 14
def test_recording_to_track_no_release_defaults_total_tracks_to_one():
"""A recording with no release info is a standalone track — report 1."""
client = MusicBrainzSearchClient()
recording = {
'id': 'rec-1',
'title': 'Standalone',
'artist-credit': [{'name': 'Band'}],
'releases': [],
}
track = client._recording_to_track(recording, 'Band')
assert track.total_tracks == 1
def test_pick_representative_release_prefers_official_with_media():
"""The release picker should skip stub releases (no media) and pick
Official over Promotion status."""
client = MusicBrainzSearchClient()
releases = [
{'id': 'stub', 'status': 'Official', 'date': '2020-01-01'}, # No media
{'id': 'promo', 'status': 'Promotion', 'date': '2019-12-01',
'media': [{'track-count': 10}]},
{'id': 'official', 'status': 'Official', 'date': '2020-01-05',
'media': [{'track-count': 10}]},
]
picked = client._pick_representative_release(releases)
assert picked['id'] == 'official'
def test_search_tracks_text_path_filters_by_score():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
{'id': 'rec-good', 'title': 'Good', 'score': 95,
'releases': [{'id': 'rel-1', 'title': 'X', 'date': '2020'}],
'artist-credit': [{'name': 'Foo'}]},
{'id': 'rec-bad', 'title': 'Bad', 'score': 40,
'releases': [{'id': 'rel-2', 'title': 'Y', 'date': '2021'}],
'artist-credit': [{'name': 'Foo'}]},
]
tracks = client.search_tracks('Foo - Good', limit=10)
titles = [t.name for t in tracks]
assert 'Good' in titles
assert 'Bad' not in titles
# ---------------------------------------------------------------------------
# get_recording_flat — Fix-popup MBID paste adapter
# ---------------------------------------------------------------------------
def test_get_recording_flat_happy_path():
"""Recording with a release returns flat shape with album + image."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_recording.return_value = {
'id': 'rec-abc',
'title': 'Army of Me',
'length': 234000,
'artist-credit': [{'artist': {'name': 'Björk'}}],
'releases': [{
'id': 'rel-xyz',
'title': 'Post',
'date': '1995-06-13',
'status': 'Official',
'media': [{'track-count': 11}],
'release-group': {'id': 'rg-post', 'primary-type': 'Album', 'secondary-types': []},
}],
}
track = client.get_recording_flat('rec-abc')
assert track is not None
assert track['id'] == 'rec-abc'
assert track['name'] == 'Army of Me'
assert track['artists'] == ['Björk'] # flat list of strings, not Spotify-shaped objects
assert track['album'] == 'Post' # flat string, not nested dict
assert track['duration_ms'] == 234000
assert track['image_url'] # CAA URL present
assert 'musicbrainz.org/recording/rec-abc' in track['external_urls']['musicbrainz']
def test_get_recording_flat_missing_mbid_returns_none():
"""No MBID → no API call, returns None."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
assert client.get_recording_flat('') is None
assert client.get_recording_flat(None) is None
client._client.get_recording.assert_not_called()
def test_get_recording_flat_mb_returns_no_recording():
"""MB returns None (404 / missing) → adapter returns None."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_recording.return_value = None
assert client.get_recording_flat('rec-missing') is None
def test_get_recording_flat_recording_without_release():
"""Standalone recording (no releases) — album stays empty,
image_url empty, but the rest of the shape is intact."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_recording.return_value = {
'id': 'rec-standalone',
'title': 'Untitled Demo',
'length': 120000,
'artist-credit': [{'artist': {'name': 'Unknown'}}],
'releases': [],
}
track = client.get_recording_flat('rec-standalone')
assert track is not None
assert track['name'] == 'Untitled Demo'
assert track['album'] == ''
assert track['image_url'] == ''
assert track['artists'] == ['Unknown']
assert track['duration_ms'] == 120000
def test_get_recording_flat_multi_artist_credit():
"""Recording with multiple credited artists — all flatten to list of strings."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_recording.return_value = {
'id': 'rec-collab',
'title': 'Collab Track',
'length': 180000,
'artist-credit': [
{'artist': {'name': 'Artist A'}},
{'artist': {'name': 'Artist B'}},
],
'releases': [],
}
track = client.get_recording_flat('rec-collab')
assert track['artists'] == ['Artist A', 'Artist B']
def test_get_recording_flat_includes_match_get_track_details():
"""Sanity: passes the same includes list so the API call is cacheable
against the same key as get_track_details (one network request can
serve both surfaces if MB ever adds response caching upstream)."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_recording.return_value = None
client.get_recording_flat('rec-x')
client._client.get_recording.assert_called_once_with(
'rec-x', includes=['releases', 'artist-credits', 'release-groups']
)
def test_get_recording_flat_swallows_client_errors():
"""MB client raising must not propagate to the route handler — return
None so the endpoint can render a friendly 404 instead of 500."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_recording.side_effect = RuntimeError('boom')
assert client.get_recording_flat('rec-err') is None
# ---------------------------------------------------------------------------
# search_tracks_with_artist — Fix-popup cascade adapter
# ---------------------------------------------------------------------------
def test_search_tracks_with_artist_strict_first_when_both_fields():
"""Both fields present → strict field-scoped Lucene query first
(`recording:"<t>" AND artist:"<a>"`). Fixes the "Coffee Break" +
"Zeds Dead" case where bare query lets MB's title-text-biased
scorer surface unrelated covers ahead of the canonical recording."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
{'id': 'rec-1', 'title': 'Coffee Break', 'score': 95,
'length': 184000,
'releases': [{'id': 'rel-1', 'title': 'Coffee Break', 'date': '2015'}],
'artist-credit': [{'name': 'Zeds Dead'}]},
]
tracks = client.search_tracks_with_artist('Coffee Break', 'Zeds Dead', limit=10)
# strict=True is the critical bit — anchors artist via Lucene AND clause
client._client.search_recording.assert_called_once_with(
'Coffee Break', artist_name='Zeds Dead', limit=10, strict=True
)
assert len(tracks) == 1
assert tracks[0].name == 'Coffee Break'
assert 'Zeds Dead' in tracks[0].artists
def test_search_tracks_with_artist_falls_back_to_bare_when_strict_empty():
"""Strict phrase match misses diacritic / alias cases ("Bjork" query
vs canonical "Björk" artist). When strict returns nothing, fall
through to bare query so rerank can still surface the right answer."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.side_effect = [
[], # strict pass → no hits (Lucene phrase match fails on diacritic)
[ # bare pass → recall via alias index
{'id': 'rec-canonical', 'title': 'Army of Me', 'score': 28,
'releases': [], 'artist-credit': [{'name': 'Björk'}]},
],
]
tracks = client.search_tracks_with_artist('Army of Me', 'Bjork', limit=10)
assert client._client.search_recording.call_count == 2
first_call = client._client.search_recording.call_args_list[0]
second_call = client._client.search_recording.call_args_list[1]
assert first_call.kwargs['strict'] is True
assert second_call.kwargs['strict'] is False
assert len(tracks) == 1
assert tracks[0].id == 'rec-canonical'
def test_search_tracks_with_artist_does_not_resort_by_length():
"""Length-preference ordering lives downstream in
``rerank_tracks(..., prefer_known_duration=True)`` — sorting here
would be re-sorted away by rerank anyway, so this method preserves
the order MB returned. Pin the contract: this method does not
re-shuffle by duration_ms."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
{'id': 'rec-no-length', 'title': 'Coffee Break', 'score': 100,
'releases': [], 'artist-credit': [{'name': 'Zeds Dead'}]},
{'id': 'rec-with-length', 'title': 'Coffee Break', 'score': 90,
'length': 184000,
'releases': [], 'artist-credit': [{'name': 'Zeds Dead'}]},
]
tracks = client.search_tracks_with_artist('Coffee Break', 'Zeds Dead', limit=10)
# MB's order is preserved here — rerank applies length-pref downstream.
assert tracks[0].id == 'rec-no-length'
assert tracks[1].id == 'rec-with-length'
def test_search_tracks_with_artist_handles_missing_artist():
"""Track-only query (no artist) still works — single-field path takes
bare-query mode directly (no strict-first round-trip since there's no
artist to anchor). Empty string becomes None so MB drops the AND
clause."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
{'id': 'rec-1', 'title': 'Some Song', 'score': 90,
'releases': [], 'artist-credit': [{'name': 'Unknown'}]},
]
client.search_tracks_with_artist('Some Song', '', limit=5)
client._client.search_recording.assert_called_once_with(
'Some Song', artist_name=None, limit=5, strict=False
)
def test_search_tracks_with_artist_empty_returns_empty_list():
"""No track and no artist → return [] without hitting the network."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
assert client.search_tracks_with_artist('', '', limit=10) == []
client._client.search_recording.assert_not_called()
def test_search_tracks_with_artist_bare_fallback_keeps_low_score_for_rerank():
"""When strict returns nothing and we fall through to bare, the bare
pass uses a low score floor (20) so MB recordings whose title doesn't
literally contain the artist name still enter the candidate pool —
the endpoint's rerank pass surfaces them by artist-match relevance.
Real example: "Army of Me" + "Bjork" — strict fails on the diacritic
mismatch, bare picks up the canonical Björk recording at score 28
while filtering true noise at score 5."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.side_effect = [
[], # strict pass → no hits
[ # bare pass
{'id': 'rec-cover', 'title': 'Army of Me (Bjork)', 'score': 100,
'releases': [], 'artist-credit': [{'name': 'HIRS Collective'}]},
{'id': 'rec-canonical', 'title': 'Army of Me', 'score': 28,
'releases': [], 'artist-credit': [{'name': 'Björk'}]},
{'id': 'rec-noise', 'title': 'Bjork', 'score': 5,
'releases': [], 'artist-credit': [{'name': 'Random'}]},
],
]
tracks = client.search_tracks_with_artist('Army of Me', 'Bjork', limit=50)
ids = [t.id for t in tracks]
assert 'rec-canonical' in ids
assert 'rec-cover' in ids
assert 'rec-noise' not in ids
def test_search_tracks_text_keeps_min_score_default_80_for_enhanced_search():
"""The enhanced search tab path keeps the historical 80 floor because
it has no downstream rerank — unfiltered MB results would be noisy
for free-text user search."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
{'id': 'rec-good', 'title': 'Good', 'score': 95,
'releases': [], 'artist-credit': [{'name': 'A'}]},
{'id': 'rec-mid', 'title': 'Mid', 'score': 40,
'releases': [], 'artist-credit': [{'name': 'A'}]},
]
# No min_score → defaults to _MIN_SCORE (80)
tracks = client._search_tracks_text('Good', 'A', limit=10)
titles = [t.name for t in tracks]
assert 'Good' in titles
assert 'Mid' not in titles
def test_search_tracks_text_strict_param_default_true():
"""Default strict=True preserves the historical behaviour of the
structured-query text-search fallback path — important so the
enrichment-style `search_tracks('Artist - Track')` flow stays on
field-scoped Lucene phrase matching as before."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = []
client._search_tracks_text('Track', 'Artist', limit=10)
client._client.search_recording.assert_called_once_with(
'Track', artist_name='Artist', limit=10, strict=True
)
def test_search_tracks_with_artist_swallows_client_errors():
"""MB client raising must not crash the endpoint — return [] so the
Fix-popup cascade falls through to the next source. Both strict and
bare passes swallow exceptions independently, so a strict-pass raise
still lets the bare-pass run; a bare-pass raise after empty strict
returns []."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.side_effect = RuntimeError('network down')
assert client.search_tracks_with_artist('Track', 'Artist', limit=10) == []
# ---------------------------------------------------------------------------
# get_artist_albums — full-discography browse pagination
#
# Regression for Sokhi's report ("a lot of albums are missing vs the site").
# The old impl read the artist *lookup*'s embedded release-groups
# (`/artist/<mbid>?inc=release-groups`), which MusicBrainz hard-caps at 25
# and which ignores the `limit` param — so ~85% of a prolific artist's
# catalogue (Kendrick Lamar: 167 release-groups) was silently dropped.
# The fix walks the paginated browse endpoint instead.
# ---------------------------------------------------------------------------
def _mk_rg(rg_id, title, primary='Album', secondary=None, date='2000-01-01'):
return {
'id': rg_id,
'title': title,
'primary-type': primary,
'secondary-types': secondary or [],
'first-release-date': date,
}
def test_get_artist_albums_does_not_use_capped_lookup_release_groups():
"""The capped `inc=release-groups` lookup must NOT be the source of the
discography. We still do a lightweight name lookup, but never request
the embedded (25-capped) release-groups."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Kendrick Lamar'}
client._client.browse_artist_release_groups.return_value = [
_mk_rg('rg-1', 'DAMN.'),
]
client.get_artist_albums('mbid-kdot')
# browse is the discography source.
assert client._client.browse_artist_release_groups.called
# The name lookup must not pull the capped embedded release-groups.
for call in client._client.get_artist.call_args_list:
assert 'release-groups' not in (call.kwargs.get('includes') or [])
assert all('release-groups' not in (a or []) for a in call.args[1:])
def test_get_artist_albums_paginates_past_25_cap():
"""Walks multiple browse pages until a short page, returning the FULL
catalogue — the whole point of the fix. A single full page (100) must
trigger a follow-up fetch."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Prolific Artist'}
page1 = [_mk_rg(f'rg-{i}', f'Album {i}') for i in range(100)]
page2 = [_mk_rg(f'rg-{i}', f'Album {i}') for i in range(100, 164)]
client._client.browse_artist_release_groups.side_effect = [page1, page2, []]
albums = client.get_artist_albums('mbid-x', limit=200)
assert len(albums) == 164 # not truncated to 25
# Second page fetched at offset=100.
offsets = [c.kwargs.get('offset') for c in client._client.browse_artist_release_groups.call_args_list]
assert 0 in offsets and 100 in offsets
# No `type` filter — the detail page wants the whole catalogue.
for c in client._client.browse_artist_release_groups.call_args_list:
assert c.kwargs.get('release_types') is None
def test_get_artist_albums_stops_on_short_page():
"""A page shorter than the page size is the last page — don't fetch
a spurious extra page."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Small Artist'}
client._client.browse_artist_release_groups.return_value = [
_mk_rg('rg-1', 'Only Album'),
]
albums = client.get_artist_albums('mbid-small', limit=200)
assert len(albums) == 1
client._client.browse_artist_release_groups.assert_called_once()
def test_get_artist_albums_respects_limit():
"""`limit` caps the returned list even when more release-groups exist."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Prolific Artist'}
client._client.browse_artist_release_groups.return_value = [
_mk_rg(f'rg-{i}', f'Album {i}') for i in range(100)
]
albums = client.get_artist_albums('mbid-x', limit=50)
assert len(albums) == 50
def test_get_artist_albums_dedupes_release_group_ids():
"""A release-group id repeated across pages is collapsed to one card.
First page is full (100) so a second page is fetched; 'dup' appears on
both and must surface only once."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Artist'}
page1 = [_mk_rg('dup', 'A')] + [_mk_rg(f'rg-{i}', f'B{i}') for i in range(99)]
page2 = [_mk_rg('dup', 'A again'), _mk_rg('rg-last', 'C')]
client._client.browse_artist_release_groups.side_effect = [page1, page2, []]
albums = client.get_artist_albums('mbid-x', limit=200)
ids = [a.id for a in albums]
assert ids.count('dup') == 1
assert 'rg-last' in ids
assert len(ids) == len(set(ids))
def test_get_artist_albums_maps_types_into_buckets():
"""Primary/secondary types map to the album_type the discography binning
expects: EP→ep, Single→single, Album+Compilation→compilation, plain
Album→album."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Artist'}
client._client.browse_artist_release_groups.return_value = [
_mk_rg('rg-lp', 'The LP', primary='Album'),
_mk_rg('rg-ep', 'The EP', primary='EP'),
_mk_rg('rg-single', 'The Single', primary='Single'),
_mk_rg('rg-comp', 'Greatest Hits', primary='Album', secondary=['Compilation']),
]
albums = {a.id: a for a in client.get_artist_albums('mbid-x')}
assert albums['rg-lp'].album_type == 'album'
assert albums['rg-ep'].album_type == 'ep'
assert albums['rg-single'].album_type == 'single'
assert albums['rg-comp'].album_type == 'compilation'
def test_get_artist_albums_swallows_browse_errors():
"""Browse raising must not crash the discography endpoint — return []
so the source-priority cascade falls through to the next provider."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Artist'}
client._client.browse_artist_release_groups.side_effect = RuntimeError('mb down')
assert client.get_artist_albums('mbid-x') == []