MusicBrainz: Dedupe same-named homonyms in artist search results

Typing "michael jackson" returned 7 identical-looking cards because
MusicBrainz has many different PEOPLE sharing a canonical name — the
King of Pop plus a NZ poet, a photographer, a mashup artist, a
didgeridoo player, and more, all scoring 80+ on exact-name match.
All 7 passed the score filter. All 7 rendered with the same
fallback image because iTunes/Deezer only know the famous one.

Fix dedupes by normalized (lowercase, whitespace-trimmed) name before
building Artist dataclasses. Keeps the highest-scoring entry per name,
so the King of Pop (score 100) wins over the others (all score 80-81).
Artists with genuinely different names stay separate — a search for
"the beatles" still surfaces tribute bands if they're above threshold.

Implementation note: fetch `max(limit*3, 10)` from MB instead of
`limit` directly, so the dedup pool is large enough to still return
`limit` distinct artists after collapsing duplicates. Previously the
raw fetch was capped at the caller's limit, which would have left
fewer-than-requested results after dedup for common names.

3 new tests (49 total):
- Dedupe collapses 5 same-named entries to 1 (keeps highest score).
- Dedup key is case-insensitive and whitespace-normalized.
- Dedup preserves distinct names ("The Beatles" vs "The Beatles Revival"
  stay separate).

Live-verified: "michael jackson" now returns 1 card, "kendrick lamar"
returns 1 card.

Credit: kettui spotted duplicate Michael Jackson cards in the search UI.
This commit is contained in:
Broque Thomas 2026-04-24 10:27:45 -07:00
parent b3722449fc
commit c454b1ebaf
2 changed files with 85 additions and 6 deletions

View file

@ -177,17 +177,42 @@ class MusicBrainzSearchClient:
lookalikes.
"""
try:
raw = self._client.search_artist(query, limit=limit, strict=False)
artists = []
# Fetch extra so dedup below has enough to pick from. For
# common names (Michael Jackson, John Williams, etc.) MB returns
# many same-named people; without a larger pool, capping at
# `limit` before dedup can leave us with fewer results than
# requested.
raw = self._client.search_artist(query, limit=max(limit * 3, 10), strict=False)
# Dedupe by normalized name. MusicBrainz has many different
# people with the same canonical name (7 entries for "Michael
# Jackson" — the singer + poet + photographer + didgeridoo
# player + ...), all scoring 80+ on exact-name match. Rendered
# as identical cards since the fallback image lookup hits the
# same fallback-source result for each. Keep the highest-
# scoring entry per normalized name so the user sees one card
# per distinct artist.
seen = {}
for a in raw:
score = a.get('score', 0) or 0
if score < self._MIN_SCORE:
continue
mbid = a.get('id', '')
name = a.get('name', '')
if not mbid or not name:
continue
key = name.lower().strip()
if key not in seen or (seen[key].get('score', 0) or 0) < score:
seen[key] = a
# Sort the survivors score-descending and cap at the caller's
# limit. `seen` only holds top-per-name, so ordering is stable.
top = sorted(seen.values(), key=lambda r: -(r.get('score', 0) or 0))[:limit]
artists = []
for a in top:
mbid = a.get('id', '')
name = a.get('name', '')
# Genres from MB tags (user-applied categorical labels). Each
# tag has {name, count}; keep the top-weighted ones.
@ -201,7 +226,7 @@ class MusicBrainzSearchClient:
artists.append(Artist(
id=mbid,
name=name,
popularity=score, # Reuse score as popularity (0-100)
popularity=a.get('score', 0) or 0, # Reuse score as popularity (0-100)
genres=genres,
followers=0, # MusicBrainz doesn't track followers
image_url=None, # MB doesn't store artist images directly

View file

@ -100,12 +100,66 @@ def test_search_artists_filters_by_score_threshold():
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."""
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=10, strict=False)
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():