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}")
|
logger.info(f"MusicBrainz client initialized with user agent: {self.user_agent}")
|
||||||
|
|
||||||
@rate_limited
|
@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:
|
Args:
|
||||||
artist_name: Name of the artist to search for
|
artist_name: Name of the artist to search for
|
||||||
limit: Maximum number of results to return
|
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:
|
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:
|
try:
|
||||||
# Escape quotes and backslashes for Lucene query
|
# Escape quotes and backslashes for Lucene query
|
||||||
safe_name = artist_name.replace('\\', '\\\\').replace('"', '\\"')
|
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 = {
|
params = {
|
||||||
'query': f'artist:"{safe_name}"',
|
'query': query,
|
||||||
'fmt': 'json',
|
'fmt': 'json',
|
||||||
'limit': limit
|
'limit': limit
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.session.get(
|
response = self.session.get(
|
||||||
f"{self.BASE_URL}/artist",
|
f"{self.BASE_URL}/artist",
|
||||||
params=params,
|
params=params,
|
||||||
timeout=10
|
timeout=10
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
artists = data.get('artists', [])
|
artists = data.get('artists', [])
|
||||||
|
|
||||||
logger.debug(f"Found {len(artists)} artists for query: {artist_name}")
|
logger.debug(f"Found {len(artists)} artists for query: {artist_name}")
|
||||||
return artists
|
return artists
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error searching for artist '{artist_name}': {e}")
|
logger.error(f"Error searching for artist '{artist_name}': {e}")
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -133,9 +133,57 @@ class MusicBrainzSearchClient:
|
||||||
self._art_cache[release_mbid] = url
|
self._art_cache[release_mbid] = url
|
||||||
return 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]:
|
def search_artists(self, query: str, limit: int = 10) -> List[Artist]:
|
||||||
"""MusicBrainz search tab doesn't show artists — only albums and tracks."""
|
"""Search MusicBrainz for artists by name.
|
||||||
return []
|
|
||||||
|
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]:
|
def search_albums(self, query: str, limit: int = 10) -> List[Album]:
|
||||||
"""Search MusicBrainz for releases (albums)."""
|
"""Search MusicBrainz for releases (albums)."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue