MusicBrainz: Re-enable real artist search (was returning empty)

`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.
This commit is contained in:
Broque Thomas 2026-04-24 08:09:51 -07:00
parent e2a5a38cd2
commit 434d1c382c
2 changed files with 78 additions and 13 deletions

View file

@ -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 []

View file

@ -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)."""