From 434d1c382c2aae219c001c911ad0c5738d1acbd5 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:09:51 -0700 Subject: [PATCH] MusicBrainz: Re-enable real artist search (was returning empty) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MusicBrainzSearchClient.search_artists` has been a `return []` stub since the feature landed, with a comment claiming the MB tab 'doesn't show artists.' That's why kettui saw a missing Artists section on the search page — not a missing render, a hardcoded empty list. Re-enable it properly: - New `strict=False` parameter on `MusicBrainzClient.search_artist` sends a bare Lucene query instead of `artist:"..."`. MusicBrainz matches bare queries against alias+artist+sortname indexes together, which is the right behavior for user-facing fuzzy search (finds typos, aliases, sortname variants). `strict=True` remains the default for enrichment/AcoustID callers that want exact matches. - Adapter filters results to `score >= 80`. MB assigns a 0-100 Lucene score on every hit; the true artist + close variants score 100, tribute bands and lookalikes typically land in the 40-65 range. The cutoff keeps "Metallica" (100) and drops "Black Metallica Tribute Band" (60) without hand-curated lists. - Results returned as the same `Artist` dataclass used elsewhere in the search-tab adapter layer. `popularity` carries the MB score (0-100) so the frontend can sort/highlight top matches if desired. --- core/musicbrainz_client.py | 39 ++++++++++++++++++++-------- core/musicbrainz_search.py | 52 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/core/musicbrainz_client.py b/core/musicbrainz_client.py index a7b1b5b9..3bfeb5a9 100644 --- a/core/musicbrainz_client.py +++ b/core/musicbrainz_client.py @@ -72,40 +72,57 @@ class MusicBrainzClient: logger.info(f"MusicBrainz client initialized with user agent: {self.user_agent}") @rate_limited - def search_artist(self, artist_name: str, limit: int = 10) -> List[Dict[str, Any]]: + def search_artist(self, artist_name: str, limit: int = 10, strict: bool = True) -> List[Dict[str, Any]]: """ - Search for artists by name - + Search for artists by name. + Args: artist_name: Name of the artist to search for limit: Maximum number of results to return - + strict: When True (default), builds a phrase-match query against + the `artist` field only — correct for enrichment flows that + already know the exact name. When False, sends a bare query + which MusicBrainz matches against the alias, artist, AND + sortname indexes — the right behavior for user-facing fuzzy + search (finds "Metallica" from typing "metalica", matches + aliased names, etc.). + Returns: - List of artist results with id, name, score, etc. + List of artist results with id, name, score, etc. MusicBrainz + assigns each result a `score` 0-100; the list is pre-sorted + score-descending by the server. """ try: # Escape quotes and backslashes for Lucene query safe_name = artist_name.replace('\\', '\\\\').replace('"', '\\"') - + + if strict: + query = f'artist:"{safe_name}"' + else: + # Bare query hits alias/artist/sortname indexes — much better + # recall for user typing. Still Lucene-escaped via the API's + # query parser. + query = safe_name + params = { - 'query': f'artist:"{safe_name}"', + 'query': query, 'fmt': 'json', 'limit': limit } - + response = self.session.get( f"{self.BASE_URL}/artist", params=params, timeout=10 ) response.raise_for_status() - + data = response.json() artists = data.get('artists', []) - + logger.debug(f"Found {len(artists)} artists for query: {artist_name}") return artists - + except Exception as e: logger.error(f"Error searching for artist '{artist_name}': {e}") return [] diff --git a/core/musicbrainz_search.py b/core/musicbrainz_search.py index cf6e6e8c..017f0cdb 100644 --- a/core/musicbrainz_search.py +++ b/core/musicbrainz_search.py @@ -133,9 +133,57 @@ class MusicBrainzSearchClient: self._art_cache[release_mbid] = url return url + # Score threshold for user-facing search results. MusicBrainz returns a + # Lucene score 0-100 on every match; exact name/alias hits score 100, + # partial/typo matches trend lower, and tribute bands / random + # lookalikes score 40-65. 80 is the cutoff that keeps the true artist + # and close variants while dropping unrelated noise. + _MIN_SCORE = 80 + def search_artists(self, query: str, limit: int = 10) -> List[Artist]: - """MusicBrainz search tab doesn't show artists — only albums and tracks.""" - return [] + """Search MusicBrainz for artists by name. + + Uses a bare Lucene query (no field prefix) so MusicBrainz searches + the alias, artist, AND sortname indexes together — much better + recall than strict `artist:"..."` phrase matching. Results are + filtered by score (>= 80) to drop tribute bands and unrelated + lookalikes. + """ + try: + raw = self._client.search_artist(query, limit=limit, strict=False) + artists = [] + 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 + + # Genres from MB tags (user-applied categorical labels). Each + # tag has {name, count}; keep the top-weighted ones. + tags = a.get('tags', []) or [] + genres = [t.get('name') for t in tags if t.get('name')][:5] + + external_urls = { + 'musicbrainz': f'https://musicbrainz.org/artist/{mbid}' + } + + artists.append(Artist( + id=mbid, + name=name, + popularity=score, # 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 + external_urls=external_urls, + )) + return artists + except Exception as e: + logger.warning(f"MusicBrainz artist search failed: {e}") + return [] def search_albums(self, query: str, limit: int = 10) -> List[Album]: """Search MusicBrainz for releases (albums)."""