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:
parent
e2a5a38cd2
commit
434d1c382c
2 changed files with 78 additions and 13 deletions
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue