From c454b1ebafb7c68593c73e847837948a9fa52f68 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:27:45 -0700 Subject: [PATCH] MusicBrainz: Dedupe same-named homonyms in artist search results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/musicbrainz_search.py | 33 +++++++++++++++--- tests/test_musicbrainz_search.py | 58 ++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/core/musicbrainz_search.py b/core/musicbrainz_search.py index c396662b..965317be 100644 --- a/core/musicbrainz_search.py +++ b/core/musicbrainz_search.py @@ -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 diff --git a/tests/test_musicbrainz_search.py b/tests/test_musicbrainz_search.py index f9933a11..719f02ae 100644 --- a/tests/test_musicbrainz_search.py +++ b/tests/test_musicbrainz_search.py @@ -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():