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.
1328 lines
55 KiB
Python
1328 lines
55 KiB
Python
"""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') == []
|