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:
parent
b3722449fc
commit
c454b1ebaf
2 changed files with 85 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in a new issue