Merge pull request #373 from Nezreka/feature/musicbrainz-search-overhaul

Feature/musicbrainz search overhaul
This commit is contained in:
BoulderBadgeDad 2026-04-24 11:19:47 -07:00 committed by GitHub
commit c6f3bf9d84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1643 additions and 231 deletions

View file

@ -1783,8 +1783,14 @@ def get_artist_image_url(
artist_id: str,
source_override: Optional[str] = None,
plugin: Optional[str] = None,
artist_name: Optional[str] = None,
) -> Optional[str]:
"""Resolve an artist image URL using the configured source priority."""
"""Resolve an artist image URL using the configured source priority.
`artist_name` is used when the source-of-record doesn't store artist
images (MusicBrainz) the resolver then searches fallback sources
(iTunes/Deezer) by name for a matching artist and returns their image.
"""
if not artist_id:
return None
@ -1801,6 +1807,14 @@ def get_artist_image_url(
return _get_artist_image_from_source('itunes', artist_id)
return None
# MusicBrainz doesn't store artist images directly — use the artist
# name (passed by the frontend) to look up the image on a fallback
# source that does. Without a name we can't resolve.
if source_override == 'musicbrainz':
if not artist_name:
return None
return _lookup_artist_image_by_name(artist_name)
if source_override:
return _get_artist_image_from_source(source_override, artist_id)
@ -1812,6 +1826,41 @@ def get_artist_image_url(
return None
def _lookup_artist_image_by_name(name: str) -> Optional[str]:
"""Look up an artist image by NAME (not MBID) across fallback sources.
Used when the primary source doesn't store artist images (MusicBrainz).
Tries configured sources in priority order, searches each for the
artist name, and returns the first matching result's image URL.
"""
name = (name or '').strip()
if not name:
return None
# Skip sources that don't do artist-name search or don't have images.
_SKIP_SOURCES = {'musicbrainz', 'soulseek', 'youtube_videos', 'hydrabase'}
for source in get_source_priority(get_primary_source()):
if source in _SKIP_SOURCES:
continue
client = get_client_for_source(source)
if not client or not hasattr(client, 'search_artists'):
continue
try:
results = client.search_artists(name, limit=1) or []
if results:
top = results[0]
img = getattr(top, 'image_url', None) or (
top.get('image_url') if isinstance(top, dict) else None
)
if img:
return img
except Exception as exc:
logger.debug("Artist image lookup by name failed on %s for %r: %s",
source, name, exc)
continue
return None
def get_deezer_client():
"""Get cached Deezer client.

View file

@ -44,65 +44,85 @@ def rate_limited(func):
class MusicBrainzClient:
"""Client for interacting with MusicBrainz API"""
BASE_URL = "https://musicbrainz.org/ws/2"
# MusicBrainz mandates a meaningful User-Agent with contact info. Falling back
# to a bare name/version risks IP blocking under load — include the project
# URL so MB operators have a way to reach us if we misbehave.
DEFAULT_CONTACT = "https://github.com/Nezreka/SoulSync"
def __init__(self, app_name: str = "SoulSync", app_version: str = "1.0", contact_email: str = ""):
"""
Initialize MusicBrainz client
Args:
app_name: Name of the application
app_version: Version of the application
contact_email: Contact email (optional but recommended)
contact_email: Contact email or URL (defaults to project URL when empty)
"""
self.user_agent = f"{app_name}/{app_version}"
if contact_email:
self.user_agent += f" ( {contact_email} )"
contact = contact_email or self.DEFAULT_CONTACT
self.user_agent = f"{app_name}/{app_version} ( {contact} )"
self.session = requests.Session()
self.session.headers.update({
'User-Agent': self.user_agent,
'Accept': 'application/json'
})
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 []
@ -197,6 +217,98 @@ class MusicBrainzClient:
logger.error(f"Error searching for recording '{track_name}': {e}")
return []
@rate_limited
def browse_artist_release_groups(self, artist_mbid: str,
release_types: Optional[List[str]] = None,
limit: int = 100,
offset: int = 0) -> List[Dict[str, Any]]:
"""Browse release-groups linked to an artist MBID.
This is the correct MusicBrainz pattern for "give me this artist's
discography" — text-based `/release?query=...` search would look at
release TITLES (matching unrelated releases literally titled after
the artist name), while browse walks the artistrelease-group link
directly.
Args:
artist_mbid: Artist's MusicBrainz ID
release_types: Filter by primary type any of 'album', 'single',
'ep', 'compilation', 'soundtrack', 'live', etc. Combined with
`|` per MB spec, e.g. `['album', 'ep']` `type=album|ep`.
None returns all types.
limit: 1-100 (MB hard cap)
offset: Pagination offset
Returns:
List of release-group dicts. Each has `id`, `title`, `primary-type`,
`secondary-types`, `first-release-date`, `disambiguation`.
"""
try:
params = {'artist': artist_mbid, 'fmt': 'json', 'limit': min(limit, 100), 'offset': offset}
if release_types:
params['type'] = '|'.join(release_types)
response = self.session.get(
f"{self.BASE_URL}/release-group",
params=params,
timeout=10
)
response.raise_for_status()
data = response.json()
rgs = data.get('release-groups', [])
logger.debug(f"Browsed {len(rgs)} release-groups for artist {artist_mbid}")
return rgs
except Exception as e:
logger.error(f"Error browsing release-groups for artist {artist_mbid}: {e}")
return []
@rate_limited
def search_recordings_by_artist_mbid(self, artist_mbid: str,
limit: int = 100) -> List[Dict[str, Any]]:
"""Search for recordings linked to an artist via Lucene `arid:` query.
This is the counterpart to `browse_artist_release_groups` for tracks.
The proper "browse" endpoint (`/recording?artist=<mbid>`) rejects
`inc=releases`, so we can't get album context per recording from
browse only the track title/length/MBID. Without release info the
user would see tracks with no album, which is useless.
The search endpoint with a fielded `arid:<mbid>` query returns
recordings with the `releases` array already embedded (including
release-group, date, and media info), which is what the search-tab
UI needs.
Args:
artist_mbid: Artist's MusicBrainz ID
limit: 1-100 (MB hard cap)
Returns:
List of recording dicts with `id`, `title`, `length`, `score`,
`artist-credit`, and `releases` (each with release-group + date).
"""
try:
params = {
'query': f'arid:{artist_mbid}',
'fmt': 'json',
'limit': min(limit, 100),
}
response = self.session.get(
f"{self.BASE_URL}/recording",
params=params,
timeout=10
)
response.raise_for_status()
data = response.json()
recs = data.get('recordings', [])
logger.debug(f"Found {len(recs)} recordings for artist {artist_mbid}")
return recs
except Exception as e:
logger.error(f"Error searching recordings for artist {artist_mbid}: {e}")
return []
@rate_limited
def get_artist(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
"""
@ -257,6 +369,38 @@ class MusicBrainzClient:
logger.error(f"Error fetching release {mbid}: {e}")
return None
@rate_limited
def get_release_group(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
"""Get full release-group details by MBID.
Release-groups are the 'canonical album' entity in MusicBrainz
they group every edition/reissue/region-specific release of the
same logical album under one MBID. Use `inc=releases` to list the
individual releases this group contains (each with its own
tracklist); use `inc=artist-credits` for artist info.
Args:
mbid: Release-group's MusicBrainz ID
includes: Optional list, e.g. ['releases', 'artist-credits']
Returns:
Release-group data or None if not found.
"""
try:
params = {'fmt': 'json'}
if includes:
params['inc'] = '+'.join(includes)
response = self.session.get(
f"{self.BASE_URL}/release-group/{mbid}",
params=params,
timeout=10
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Error fetching release-group {mbid}: {e}")
return None
@rate_limited
def get_recording(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
"""

View file

@ -6,7 +6,7 @@ enabling MusicBrainz as a search tab in enhanced and global search.
Album art is fetched from Cover Art Archive (free, linked by release MBID).
"""
import requests
import threading
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
@ -59,29 +59,24 @@ class Album:
external_urls: Optional[Dict[str, str]] = None
def _get_cover_art_url(release_mbid: str) -> Optional[str]:
"""Fetch album art URL from Cover Art Archive. Returns None if not available."""
try:
# CAA redirects to the actual image URL — just get the front image URL
url = f"{COVER_ART_ARCHIVE_URL}/release/{release_mbid}/front-250"
resp = requests.head(url, timeout=3, allow_redirects=True)
if resp.status_code == 200:
return resp.url # The redirect target is the actual image
return None
except Exception:
return None
def _cover_art_url(mbid: str, scope: str = 'release') -> Optional[str]:
"""Build a Cover Art Archive URL without hitting the network.
CAA URLs are deterministic from the MBID: the endpoint either 307-redirects
to the image or returns 404. Previously we fired `requests.head(timeout=3)`
per result during search 10 results × 3s worst-case = up to 30s of
blocking HEAD calls before a search returned. The frontend's <img> tag
handles the 404 case via onerror fallback, so the HEAD round-trip was
pure overhead.
def _get_release_group_art(release_group_mbid: str) -> Optional[str]:
"""Fetch album art from release group (covers all editions)."""
try:
url = f"{COVER_ART_ARCHIVE_URL}/release-group/{release_group_mbid}/front-250"
resp = requests.head(url, timeout=3, allow_redirects=True)
if resp.status_code == 200:
return resp.url
return None
except Exception:
`scope` is 'release' (most specific) or 'release-group' (covers all
editions better hit rate).
"""
if not mbid:
return None
if scope not in ('release', 'release-group'):
scope = 'release'
return f"{COVER_ART_ARCHIVE_URL}/{scope}/{mbid}/front-250"
def _extract_artist_credit(artist_credit) -> List[str]:
@ -97,6 +92,28 @@ def _extract_artist_credit(artist_credit) -> List[str]:
return [n for n in names if n]
def _extract_title_hint(query: str, artist_name: str) -> Optional[str]:
"""If `query` starts with `artist_name` followed by more words, return
the trailing portion. Used to pick out the album/track title the user
typed after the artist name (e.g. "The Beatles Abbey Road" "Abbey
Road"). Returns None when the query is just the artist name.
Case-insensitive prefix match on whitespace-normalized versions of
both strings, so "the beatles abbey road" "abbey road" and
"The Beatles" None.
"""
if not query or not artist_name:
return None
q_norm = ' '.join(query.split()).lower()
a_norm = ' '.join(artist_name.split()).lower()
if q_norm == a_norm:
return None
# Require a word boundary between the artist name and the trailing bit.
if q_norm.startswith(a_norm + ' '):
return query[len(artist_name):].strip() or None
return None
def _map_release_type(primary_type: str, secondary_types: List[str] = None) -> str:
"""Map MusicBrainz release group type to standard album_type."""
pt = (primary_type or '').lower()
@ -116,49 +133,304 @@ class MusicBrainzSearchClient:
def __init__(self):
from core.musicbrainz_client import MusicBrainzClient
self._client = MusicBrainzClient("SoulSync", "2.3")
self._art_cache: Dict[str, Optional[str]] = {} # mbid -> url
# Client defaults to the project URL as its User-Agent contact,
# which is what MusicBrainz wants. Version stays generic ("2") —
# the exact UI minor version would add noise to every request.
self._client = MusicBrainzClient("SoulSync", "2")
# Per-instance cache for "top artist MBID for this query". The
# backend fires artists/albums/tracks searches in parallel against
# one client instance, and albums+tracks both need the same artist
# lookup. Without this cache, we'd fire 3 identical artist-search
# HTTP calls (each serialized by the 1-rps rate limit = 3 wasted
# seconds). The _Sentinel marks "we already looked and found
# nothing" to prevent repeat no-hit lookups.
self._artist_mbid_cache: Dict[str, Optional[Dict[str, Any]]] = {}
self._artist_mbid_lock = threading.Lock()
def _cached_art(self, release_mbid: str, release_group_mbid: str = '') -> Optional[str]:
"""Get cover art with caching. Tries release first, then release group."""
if release_mbid in self._art_cache:
return self._art_cache[release_mbid]
"""Build a Cover Art Archive URL for a release / release-group MBID.
url = _get_cover_art_url(release_mbid)
if not url and release_group_mbid:
url = _get_release_group_art(release_group_mbid)
self._art_cache[release_mbid] = url
return url
Prefers release-group scope when provided better hit rate because
it covers all editions of the same album. No network call; the
frontend's <img onerror> fallback handles 404s.
"""
preferred = release_group_mbid or release_mbid
if not preferred:
return None
scope = 'release-group' if release_group_mbid else 'release'
return _cover_art_url(preferred, scope=scope)
# 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:
# 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.
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=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
external_urls=external_urls,
))
return artists
except Exception as e:
logger.warning(f"MusicBrainz artist search failed: {e}")
return []
def _split_structured_query(self, query: str):
"""Split 'Artist - Title' / 'Artist Title' / 'Artist — Title' if
a separator is present. Returns (artist_name, title) or (None, query)."""
for sep in [' - ', ' ', '']:
if sep in query:
parts = query.split(sep, 1)
return parts[0].strip(), parts[1].strip()
return None, query
def _resolve_top_artist(self, query: str) -> Optional[Dict[str, Any]]:
"""Return the top-scoring artist for a bare-name query, or None if
nothing scores above threshold. Cached per instance so parallel
album/track searches don't each refetch."""
if not query:
return None
key = query.strip().lower()
with self._artist_mbid_lock:
if key in self._artist_mbid_cache:
return self._artist_mbid_cache[key]
# Do the HTTP call OUTSIDE the lock so other threads can still
# check the cache while we wait on the network.
raw = self._client.search_artist(query, limit=1, strict=False)
top = None
if raw and (raw[0].get('score', 0) or 0) >= self._MIN_SCORE:
top = raw[0]
with self._artist_mbid_lock:
self._artist_mbid_cache[key] = top
return top
# Secondary-type tags on MB release-groups that indicate NOT a studio
# release. Used by both the album browse (filter out) and the track
# browse (prefer studio release for album context).
_NON_STUDIO_SECONDARY_TYPES = {
'Live', 'Compilation', 'Soundtrack', 'Remix', 'Demo',
'Mixtape/Street', 'Interview', 'Audiobook', 'Audio drama',
}
def _release_preference_key(self, rel: Dict[str, Any]):
"""Sort key: studio releases first, then by date ASC.
Recordings in MB often have 10+ releases (studio album, live, best-of,
reissues, anniversary editions). The first one in the API response is
arbitrary it's often a recent live bootleg because MB users add new
live recordings all the time. Re-sorting before `_recording_to_track`
reads the first release means tracks show their canonical studio
album, not a random live compilation.
"""
rg = rel.get('release-group') or {}
secs = set(rg.get('secondary-types') or [])
is_studio = 0 if not (secs & self._NON_STUDIO_SECONDARY_TYPES) else 1
date = (rel.get('date') or '')[:4]
year = int(date) if date.isdigit() else 9999
return (is_studio, year)
def _has_studio_release(self, recording: Dict[str, Any]) -> bool:
"""True when at least one of the recording's releases is on a
release-group with no non-studio secondary type."""
for rel in (recording.get('releases') or []):
rg = rel.get('release-group') or {}
secs = set(rg.get('secondary-types') or [])
if not (secs & self._NON_STUDIO_SECONDARY_TYPES):
return True
return False
def _release_group_to_album(self, rg: Dict[str, Any], artist_name: str) -> Album:
"""Project a MusicBrainz release-group into our Album dataclass."""
rg_mbid = rg.get('id', '')
title = rg.get('title', '') or ''
primary_type = rg.get('primary-type', '') or ''
secondary_types = rg.get('secondary-types', []) or []
album_type = _map_release_type(primary_type, secondary_types)
release_date = rg.get('first-release-date', '') or ''
# Release-group browse doesn't link directly to a single release,
# so we can't get per-release track counts cheaply. Leave 0 — the
# frontend treats it as "unknown" gracefully.
image_url = self._cached_art(rg_mbid, rg_mbid)
return Album(
id=rg_mbid,
name=title,
artists=[artist_name] if artist_name else ['Unknown Artist'],
release_date=release_date,
total_tracks=0,
album_type=album_type,
image_url=image_url,
external_urls={'musicbrainz': f'https://musicbrainz.org/release-group/{rg_mbid}'} if rg_mbid else {},
)
def search_albums(self, query: str, limit: int = 10) -> List[Album]:
"""Search MusicBrainz for releases (albums)."""
"""Search MusicBrainz for releases (albums).
Primary path: when the query looks like a bare artist name, resolve
it to an artist MBID and BROWSE that artist's release-groups. This
returns the artist's actual discography instead of unrelated
releases that happen to be titled after them.
Fallback path: when the query is structured as "Artist - Album" or
the artist lookup fails, drop back to text search with the
existing Lucene strategy.
"""
try:
# Try to split "Artist Album" for better matching
artist_name = None
album_name = query
for sep in [' - ', ' ', '']:
if sep in query:
parts = query.split(sep, 1)
artist_name = parts[0].strip()
album_name = parts[1].strip()
break
artist_name, title = self._split_structured_query(query)
# Structured "Artist - Album" query → respect user's intent;
# text-search with both terms is more precise than browsing all
# of that artist's discography.
if artist_name:
return self._search_albums_text(title, artist_name, limit)
# Bare name query → try artist-first → browse path.
top = self._resolve_top_artist(query)
if top:
mbid = top.get('id', '')
tname = top.get('name', '') or query
# If the query has words beyond the artist name (e.g. "The
# Beatles Abbey Road"), extract the leftover as a title hint.
# We'll use it below to narrow browse results to the specific
# album the user typed rather than dumping the full back
# catalogue. kettui flagged the regression — bare-name browse
# was burying a specific-album query inside a discography list.
title_hint = _extract_title_hint(query, tname)
rgs = self._client.browse_artist_release_groups(
mbid,
# 'compilation' is a SECONDARY type, not a primary type
# — including it in the OR filter causes MB to return
# only 82 matches instead of the actual 1076 because
# the filter silently breaks. Actual compilations
# (primary-type=Album with secondary-types=[Compilation])
# are handled by the studio-preference filter below.
release_types=['album', 'ep', 'single'],
limit=100,
)
# Prefer studio releases — MusicBrainz tags live bootlegs
# and best-of compilations with secondary-types. For mega-
# artists like Metallica, 83 of 100 browse results are live
# broadcast bootlegs; the 12 studio albums are buried. A
# release-group with no secondary-types (or an explicit
# studio-only type) is the "original studio" shape users
# expect to see first.
def _is_studio(rg):
secs = set((rg.get('secondary-types') or []))
return not (secs & {'Live', 'Compilation', 'Soundtrack',
'Remix', 'Demo', 'Mixtape/Street',
'Interview', 'Audiobook', 'Audio drama'})
studio = [rg for rg in rgs if _is_studio(rg)]
# If filtering leaves us empty (niche live-only artist),
# fall back to the unfiltered list — better than no results.
rgs = studio or rgs
# Narrow to the title-hint if the user gave one ("The Beatles
# Abbey Road" → filter to RGs whose title contains "abbey
# road"). If no RG matches, fall back to text-search so the
# user finds the specific album instead of either seeing the
# full discography or getting zero results. (kettui flagged
# this regression — artist-first alone was burying specific-
# album queries inside the unfiltered discography list.)
if title_hint:
hint_lower = title_hint.lower()
matched = [rg for rg in rgs if hint_lower in (rg.get('title') or '').lower()]
if matched:
rgs = matched
else:
fallback = self._search_albums_text(title_hint, tname, limit)
if fallback:
return fallback
# Text-search also missed — fall through and show the
# full (unfiltered) discography rather than nothing.
# Sort by primary-type priority first (album > ep > single >
# compilation), then chronologically ASC — the standard way
# discographies are listed ("their debut was X, then Y, then Z").
type_priority = {'album': 0, 'ep': 1, 'single': 2, 'compilation': 3}
def _sort_key(rg):
pt = (rg.get('primary-type') or '').lower()
date = rg.get('first-release-date') or ''
year = int(date[:4]) if date[:4].isdigit() else 9999
return (type_priority.get(pt, 9), year)
rgs.sort(key=_sort_key)
albums = [self._release_group_to_album(rg, tname) for rg in rgs[:limit]]
return albums
# No artist match → text search on the whole query.
return self._search_albums_text(query, None, limit)
except Exception as e:
logger.warning(f"MusicBrainz album search failed: {e}")
return []
def _search_albums_text(self, album_name: str, artist_name: Optional[str], limit: int) -> List[Album]:
"""Fallback text-search path for structured/fuzzy album queries."""
try:
results = self._client.search_release(album_name, artist_name=artist_name, limit=limit)
# If no separator, try word-boundary splitting
if not results and not artist_name:
words = query.split()
for i in range(1, len(words)):
possible_artist = ' '.join(words[:i])
possible_album = ' '.join(words[i:])
if len(possible_album) >= 2:
results = self._client.search_release(possible_album, artist_name=possible_artist, limit=limit)
if results:
break
# Score filter — same threshold as artists. Drops garbage
# title-match hits from unrelated releases.
results = [r for r in results if (r.get('score', 0) or 0) >= self._MIN_SCORE]
albums = []
for r in results:
@ -223,165 +495,300 @@ class MusicBrainzSearchClient:
logger.warning(f"MusicBrainz album search failed: {e}")
return []
def _recording_to_track(self, r: Dict[str, Any], fallback_artist_name: str) -> Optional[Track]:
"""Project a MusicBrainz recording into our Track dataclass. Returns
None when the recording lacks required fields."""
mbid = r.get('id', '')
title = r.get('title', '')
if not title:
return None
artists = _extract_artist_credit(r.get('artist-credit', []))
if not artists and fallback_artist_name:
artists = [fallback_artist_name]
duration_ms = r.get('length', 0) or 0
album_name = ''
album_id = ''
release_date = ''
image_url = None
album_type = 'single'
# Initialized to 0 and summed from the release's media track-counts.
# Previously initialized to 1, which made every track-with-release
# report one more than the album actually has (kettui caught this).
total_tracks = 0
releases = r.get('releases', []) or []
if releases:
rel = releases[0]
album_name = rel.get('title', '') or ''
album_id = rel.get('id', '') or ''
release_date = rel.get('date', '') or ''
rg = rel.get('release-group', {}) or {}
primary_type = rg.get('primary-type', '') or ''
secondary_types = rg.get('secondary-types', []) or []
album_type = _map_release_type(primary_type, secondary_types)
for m in rel.get('media', []) or []:
total_tracks += m.get('track-count', 0)
rg_mbid = rg.get('id', '') or ''
image_url = self._cached_art(album_id, rg_mbid) if album_id else None
# Tracks with no release info are standalone recordings — give them
# total_tracks=1 (the track itself). Keeps the old shape for that
# edge case but fixes the off-by-one for every normal case.
if not releases:
total_tracks = 1
return Track(
id=mbid,
name=title,
artists=artists if artists else ['Unknown Artist'],
album=album_name or title,
duration_ms=duration_ms,
popularity=r.get('score', 0) or 0,
image_url=image_url,
release_date=release_date,
external_urls={'musicbrainz': f'https://musicbrainz.org/recording/{mbid}'} if mbid else {},
album_type=album_type,
total_tracks=total_tracks,
album_id=album_id,
)
def search_tracks(self, query: str, limit: int = 10) -> List[Track]:
"""Search MusicBrainz for recordings (tracks)."""
"""Search MusicBrainz for recordings (tracks).
Same strategy as `search_albums`: bare name artist-first browse
recordings; structured "Artist - Title" stays on text search so the
user's explicit title intent is respected.
"""
try:
# Try to split "Artist - Title" for better matching
artist_name = None
track_name = query
for sep in [' - ', ' ', '']:
if sep in query:
parts = query.split(sep, 1)
artist_name = parts[0].strip()
track_name = parts[1].strip()
break
artist_name, title = self._split_structured_query(query)
# Structured query → text search with both fields.
if artist_name:
return self._search_tracks_text(title, artist_name, limit)
# Bare name → artist-first → arid: search.
top = self._resolve_top_artist(query)
if top:
mbid = top.get('id', '')
tname = top.get('name', '') or query
# /recording?artist=<mbid> (browse) rejects inc=releases,
# so we use the fielded Lucene search arid:<mbid> instead —
# that returns recordings with release context inline.
recs = self._client.search_recordings_by_artist_mbid(mbid, limit=100)
# Re-order each recording's releases to prefer studio over
# live/compilation. Without this, the first release (which
# the adapter uses for album info + date) is often a random
# live bootleg — Metallica has 10+ live versions of "One"
# ranked ahead of the studio release. Mutates in place so
# `_recording_to_track` sees the preferred release first.
for r in recs:
rels = r.get('releases') or []
if not rels:
continue
rels.sort(key=self._release_preference_key)
r['releases'] = rels
# Prefer recordings that have at least one studio release.
# Falls back to the full set if the artist is live-only.
studio = [r for r in recs if self._has_studio_release(r)]
recs = studio or recs
# Dedupe by normalized title (MB has many versions of the
# same song — live, remaster, re-recording, etc.). Because
# we sorted releases above, `_recording_to_track` will pick
# the studio release for album info on the first keeper.
seen = set()
deduped = []
for r in recs:
key = (r.get('title') or '').lower().strip()
if not key or key in seen:
continue
seen.add(key)
deduped.append(r)
# Sort by studio-release year ASC so classic tracks surface
# first. For a user typing "metallica", this means "Seek
# and Destroy" (1983) before "Atlas, Rise!" (2016) — which
# matches how most discography views order by release.
def _track_sort_key(r):
rels = r.get('releases') or []
for rel in rels:
date = (rel.get('date') or '')[:4]
if date.isdigit():
return int(date)
return 9999
deduped.sort(key=_track_sort_key)
tracks = []
for r in deduped[:limit]:
t = self._recording_to_track(r, tname)
if t:
tracks.append(t)
return tracks
# No artist match → fall back to text search on whole query.
return self._search_tracks_text(query, None, limit)
except Exception as e:
logger.warning(f"MusicBrainz track search failed: {e}")
return []
def _search_tracks_text(self, track_name: str, artist_name: Optional[str], limit: int) -> List[Track]:
"""Fallback text-search path for structured/fuzzy track queries."""
try:
results = self._client.search_recording(track_name, artist_name=artist_name, limit=limit)
# Score filter matches the artist/album logic — cuts garbage
# title collisions from unrelated recordings.
results = [r for r in results if (r.get('score', 0) or 0) >= self._MIN_SCORE]
# If no separator found or structured search failed, try the full query
# as both a recording search and an artist+recording combined search
if not results and not artist_name:
# Try each word split as potential artist/title boundary
words = query.split()
for i in range(1, len(words)):
possible_artist = ' '.join(words[:i])
possible_track = ' '.join(words[i:])
if len(possible_track) >= 2:
results = self._client.search_recording(possible_track, artist_name=possible_artist, limit=limit)
if results:
break
tracks = []
for r in results:
mbid = r.get('id', '')
title = r.get('title', '')
if not title:
continue
artists = _extract_artist_credit(r.get('artist-credit', []))
duration_ms = r.get('length', 0) or 0
# Get album from first release
album_name = ''
album_id = ''
release_date = ''
image_url = None
album_type = 'single'
total_tracks = 1
track_number = None
releases = r.get('releases', [])
if releases:
rel = releases[0]
album_name = rel.get('title', '')
album_id = rel.get('id', '')
release_date = rel.get('date', '') or ''
rg = rel.get('release-group', {})
primary_type = rg.get('primary-type', '') or ''
secondary_types = rg.get('secondary-types', []) or []
album_type = _map_release_type(primary_type, secondary_types)
media = rel.get('media', [])
for m in media:
total_tracks += m.get('track-count', 0)
# Find track number
for t in m.get('tracks', []):
if t.get('id') == mbid or t.get('recording', {}).get('id') == mbid:
try:
track_number = int(t.get('number', t.get('position', 0)))
except (ValueError, TypeError):
pass
# Cover art
rg_mbid = rg.get('id', '')
image_url = self._cached_art(album_id, rg_mbid) if album_id else None
external_urls = {'musicbrainz': f'https://musicbrainz.org/recording/{mbid}'} if mbid else {}
tracks.append(Track(
id=mbid,
name=title,
artists=artists if artists else ['Unknown Artist'],
album=album_name or title,
duration_ms=duration_ms,
popularity=r.get('score', 0),
image_url=image_url,
release_date=release_date,
external_urls=external_urls,
track_number=track_number,
album_type=album_type,
total_tracks=total_tracks,
album_id=album_id,
))
t = self._recording_to_track(r, artist_name or '')
if t:
tracks.append(t)
return tracks
except Exception as e:
logger.warning(f"MusicBrainz track search failed: {e}")
return []
def get_album(self, release_mbid: str) -> Optional[Dict[str, Any]]:
"""Get full album details with track listing for download modal."""
try:
release = self._client.get_release(release_mbid, includes=['recordings', 'artist-credits', 'release-groups'])
if not release:
return None
def _pick_representative_release(self, releases: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Pick the best release out of a release-group's editions.
title = release.get('title', '')
artists_raw = _extract_artist_credit(release.get('artist-credit', []))
release_date = release.get('date', '') or ''
rg = release.get('release-group', {})
primary_type = rg.get('primary-type', '') or ''
secondary_types = rg.get('secondary-types', []) or []
album_type = _map_release_type(primary_type, secondary_types)
# Cover art
rg_mbid = rg.get('id', '')
image_url = self._cached_art(release_mbid, rg_mbid)
# Build tracks from media
tracks = []
total_tracks = 0
media_list = release.get('media', [])
for media_idx, media in enumerate(media_list):
disc_number = media.get('position', media_idx + 1)
for track in media.get('tracks', []):
total_tracks += 1
recording = track.get('recording', {})
track_artists = _extract_artist_credit(recording.get('artist-credit', []))
if not track_artists:
track_artists = artists_raw
try:
track_num = int(track.get('number', track.get('position', total_tracks)))
except (ValueError, TypeError):
track_num = total_tracks
tracks.append({
'id': recording.get('id', track.get('id', '')),
'name': recording.get('title', track.get('title', '')),
'artists': [{'name': a} for a in track_artists],
'duration_ms': recording.get('length', 0) or track.get('length', 0) or 0,
'track_number': track_num,
'disc_number': disc_number,
})
images = [{'url': image_url, 'height': 250, 'width': 250}] if image_url else []
return {
'id': release_mbid,
'name': title,
'artists': [{'name': a, 'id': ''} for a in (artists_raw or ['Unknown Artist'])],
'release_date': release_date,
'total_tracks': total_tracks,
'album_type': album_type,
'images': images,
'tracks': tracks,
'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'},
}
except Exception as e:
logger.error(f"MusicBrainz album detail failed for {release_mbid}: {e}")
Release-groups often contain 5-20+ releases (original, reissues,
remasters, regional editions, bonus-track editions). We want a
single canonical version to show the user as 'the album.' Prefer:
1. Official releases (not promo/bootleg)
2. Earliest date (the original)
3. Any release with media (skip entries that are just stubs)
"""
if not releases:
return None
def _key(r):
status = (r.get('status') or '').lower()
status_rank = 0 if status == 'official' else 1 # Official first
has_media = 0 if r.get('media') else 1 # Real tracklists first
date = (r.get('date') or '9999-99-99')[:10]
return (has_media, status_rank, date)
return sorted(releases, key=_key)[0]
def get_album(self, album_mbid: str) -> Optional[Dict[str, Any]]:
"""Get full album details with track listing for download modal.
The MBID passed in could be either:
- A release-group MBID (from `search_albums` browse path the
common case now that bare-name searches route artist-first
browse), or
- A release MBID (from the text-search fallback path).
Try release-group first since that's the majority; if it 404s,
fall back to direct release lookup. Release-group resolution adds
one extra API call (~1s at the 1-rps rate limit) to pick a
representative release and then fetch its tracklist.
"""
try:
# Path A: release-group MBID (new browse-based search default)
rg = self._client.get_release_group(
album_mbid, includes=['releases', 'artist-credits']
)
if rg:
releases = rg.get('releases') or []
rep = self._pick_representative_release(releases)
if rep and rep.get('id'):
album = self._render_release_as_album(
rep['id'],
rg_fallback=rg,
)
if album:
# Keep the release-group MBID as the canonical
# Album.id so downstream code can re-fetch with
# the same URL.
album['id'] = album_mbid
album['external_urls'] = {
'musicbrainz': f'https://musicbrainz.org/release-group/{album_mbid}'
}
return album
# Path B: release MBID (text-search fallback path)
return self._render_release_as_album(album_mbid)
except Exception as e:
logger.error(f"MusicBrainz album detail failed for {album_mbid}: {e}")
return None
def _render_release_as_album(self, release_mbid: str,
rg_fallback: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Fetch a specific release and project it to the album-detail dict
shape the download modal expects. `rg_fallback` supplies release-group
metadata (type, artist credits) when resolving from a release-group
whose releases may be lightly populated."""
release = self._client.get_release(
release_mbid, includes=['recordings', 'artist-credits', 'release-groups']
)
if not release:
return None
title = release.get('title', '')
artists_raw = _extract_artist_credit(release.get('artist-credit', []))
if not artists_raw and rg_fallback:
artists_raw = _extract_artist_credit(rg_fallback.get('artist-credit', []))
release_date = release.get('date', '') or ''
if not release_date and rg_fallback:
release_date = rg_fallback.get('first-release-date', '') or ''
rg = release.get('release-group', rg_fallback or {}) or {}
primary_type = rg.get('primary-type', '') or ''
secondary_types = rg.get('secondary-types', []) or []
album_type = _map_release_type(primary_type, secondary_types)
rg_mbid = rg.get('id', '')
image_url = self._cached_art(release_mbid, rg_mbid)
tracks = []
total_tracks = 0
media_list = release.get('media', [])
for media_idx, media in enumerate(media_list):
disc_number = media.get('position', media_idx + 1)
for track in media.get('tracks', []):
total_tracks += 1
recording = track.get('recording', {})
track_artists = _extract_artist_credit(recording.get('artist-credit', []))
if not track_artists:
track_artists = artists_raw
try:
track_num = int(track.get('number', track.get('position', total_tracks)))
except (ValueError, TypeError):
track_num = total_tracks
tracks.append({
'id': recording.get('id', track.get('id', '')),
'name': recording.get('title', track.get('title', '')),
'artists': [{'name': a} for a in track_artists],
'duration_ms': recording.get('length', 0) or track.get('length', 0) or 0,
'track_number': track_num,
'disc_number': disc_number,
})
images = [{'url': image_url, 'height': 250, 'width': 250}] if image_url else []
return {
'id': release_mbid,
'name': title,
'artists': [{'name': a, 'id': ''} for a in (artists_raw or ['Unknown Artist'])],
'release_date': release_date,
'total_tracks': total_tracks,
'album_type': album_type,
'images': images,
'tracks': tracks,
'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'},
}
def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single') -> List:
"""Get artist's releases for discography view."""
try:

View file

@ -0,0 +1,767 @@
"""Tests for the MusicBrainz search adapter (core/musicbrainz_search.py).
Covers the behavior changes from the search-overhaul PR:
- Artist search is re-enabled and score-filtered
- Bare name queries route through artist-first browse
- Structured 'Artist - Title' queries stay on text search
- Top-artist resolution is memoized per instance
- Cover Art URLs are constructed, not probed
"""
from unittest.mock import MagicMock, patch
import pytest
from core.musicbrainz_search import (
MusicBrainzSearchClient,
_cover_art_url,
_extract_title_hint,
)
# ---------------------------------------------------------------------------
# Cover art URL construction
# ---------------------------------------------------------------------------
def test_cover_art_url_release_scope():
assert _cover_art_url('abc-123') == 'https://coverartarchive.org/release/abc-123/front-250'
def test_cover_art_url_release_group_scope():
assert _cover_art_url('abc-123', scope='release-group') == \
'https://coverartarchive.org/release-group/abc-123/front-250'
def test_cover_art_url_empty_mbid_returns_none():
assert _cover_art_url('') is None
assert _cover_art_url(None) is None
def test_cover_art_url_unknown_scope_falls_back_to_release():
assert _cover_art_url('abc', scope='garbage') == 'https://coverartarchive.org/release/abc/front-250'
# ---------------------------------------------------------------------------
# Structured query splitting
# ---------------------------------------------------------------------------
def test_split_structured_query_hyphen():
client = MusicBrainzSearchClient()
assert client._split_structured_query('Metallica - Master of Puppets') == ('Metallica', 'Master of Puppets')
def test_split_structured_query_en_dash():
client = MusicBrainzSearchClient()
assert client._split_structured_query('Metallica One') == ('Metallica', 'One')
def test_split_structured_query_em_dash():
client = MusicBrainzSearchClient()
assert client._split_structured_query('Metallica — Battery') == ('Metallica', 'Battery')
def test_split_structured_query_bare_name():
client = MusicBrainzSearchClient()
assert client._split_structured_query('metallica') == (None, 'metallica')
def test_split_structured_query_no_separator_with_hyphens_in_word():
# A hyphen inside a word (no surrounding spaces) should not split.
client = MusicBrainzSearchClient()
assert client._split_structured_query('t-pain') == (None, 't-pain')
# ---------------------------------------------------------------------------
# Artist search — score filtering and shape
# ---------------------------------------------------------------------------
def _mk_artist(name, mbid, score=100, tags=None):
return {
'id': mbid,
'name': name,
'score': score,
'tags': tags or [],
}
def test_search_artists_filters_by_score_threshold():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
_mk_artist('Metallica', 'mb-real', score=100),
_mk_artist('Metallica Tribute', 'mb-tribute', score=60),
_mk_artist('Metallica Jam', 'mb-jam', score=58),
]
results = client.search_artists('metallica', limit=10)
assert len(results) == 1
assert results[0].name == 'Metallica'
assert results[0].id == 'mb-real'
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.
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=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():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.side_effect = RuntimeError('network down')
assert client.search_artists('metallica') == []
def test_search_artists_extracts_tags_as_genres():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
_mk_artist('Metallica', 'mb-real', score=100,
tags=[{'name': 'thrash metal', 'count': 20},
{'name': 'heavy metal', 'count': 15}]),
]
results = client.search_artists('metallica')
assert results[0].genres == ['thrash metal', 'heavy metal']
def test_search_artists_skips_entries_without_mbid_or_name():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
{'id': 'mb-1', 'name': 'Good', 'score': 100},
{'id': '', 'name': 'Missing MBID', 'score': 100},
{'id': 'mb-2', 'name': '', 'score': 100},
]
results = client.search_artists('x')
assert [r.name for r in results] == ['Good']
# ---------------------------------------------------------------------------
# Top-artist resolution — memoization
# ---------------------------------------------------------------------------
def test_resolve_top_artist_memoizes_by_normalized_query():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
first = client._resolve_top_artist('metallica')
second = client._resolve_top_artist(' Metallica ') # Whitespace / case variant
assert first is not None
assert first['id'] == 'mb-1'
assert first is second
# HTTP call happens once despite two resolve calls.
assert client._client.search_artist.call_count == 1
def test_resolve_top_artist_returns_none_below_threshold():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Tribute', 'mb-trib', score=50)]
assert client._resolve_top_artist('obscure') is None
def test_resolve_top_artist_caches_negative_result():
"""After a lookup finds no good match, subsequent calls don't refetch."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = []
first = client._resolve_top_artist('nonexistent band')
second = client._resolve_top_artist('nonexistent band')
assert first is None
assert second is None
assert client._client.search_artist.call_count == 1
def test_resolve_top_artist_empty_query_returns_none_without_http():
client = MusicBrainzSearchClient()
client._client = MagicMock()
assert client._resolve_top_artist('') is None
client._client.search_artist.assert_not_called()
# ---------------------------------------------------------------------------
# Album search — routing
# ---------------------------------------------------------------------------
def test_search_albums_bare_query_uses_browse_path():
"""When a bare name resolves to an artist, we browse their release-groups
instead of text-searching release titles."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-1', 'title': 'Master of Puppets', 'primary-type': 'Album',
'first-release-date': '1986-03-03', 'secondary-types': []},
{'id': 'rg-2', 'title': 'Ride the Lightning', 'primary-type': 'Album',
'first-release-date': '1984-07-27', 'secondary-types': []},
]
albums = client.search_albums('metallica', limit=10)
client._client.browse_artist_release_groups.assert_called_once()
# Text-search path must NOT be taken.
client._client.search_release.assert_not_called()
# Chronological ASC — debut first, so the album list reads like a
# standard discography (Wikipedia-style: earliest release on top).
assert [a.name for a in albums] == ['Ride the Lightning', 'Master of Puppets']
assert all(a.artists == ['Metallica'] for a in albums)
def test_search_albums_structured_query_uses_text_path():
"""'Artist - Title' shape should text-search the title rather than
browsing all of the artist's discography."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_release.return_value = [
{'id': 'rel-1', 'title': 'Master of Puppets', 'score': 100,
'date': '1986', 'media': [{'track-count': 8}],
'release-group': {'id': 'rg-1', 'primary-type': 'Album'},
'artist-credit': [{'name': 'Metallica'}]},
]
albums = client.search_albums('Metallica - Master of Puppets', limit=10)
client._client.search_release.assert_called_once()
# Artist-first path must NOT be taken.
client._client.search_artist.assert_not_called()
client._client.browse_artist_release_groups.assert_not_called()
assert len(albums) == 1
assert albums[0].name == 'Master of Puppets'
def test_search_albums_falls_back_to_text_when_no_artist_match():
"""No artist above threshold → text-search the whole query."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
# Artist lookup returns nothing above threshold.
client._client.search_artist.return_value = [_mk_artist('X', 'mb-x', score=40)]
client._client.search_release.return_value = []
client.search_albums('very obscure band')
client._client.search_release.assert_called_once_with('very obscure band', artist_name=None, limit=10)
client._client.browse_artist_release_groups.assert_not_called()
def test_search_albums_filters_live_and_compilation_secondary_types():
"""Mega-artists' browse results are dominated by live bootlegs and
best-of compilations they should be filtered out so the studio
discography surfaces."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-live-1', 'title': 'Live Bootleg 2019', 'primary-type': 'Album',
'first-release-date': '2019-01-01', 'secondary-types': ['Live']},
{'id': 'rg-studio-1', 'title': 'Kill Em All', 'primary-type': 'Album',
'first-release-date': '1983-07-25', 'secondary-types': []},
{'id': 'rg-comp-1', 'title': 'Greatest Hits', 'primary-type': 'Album',
'first-release-date': '2010-01-01', 'secondary-types': ['Compilation']},
{'id': 'rg-studio-2', 'title': 'Master of Puppets', 'primary-type': 'Album',
'first-release-date': '1986-03-03', 'secondary-types': []},
]
albums = client.search_albums('metallica', limit=10)
titles = [a.name for a in albums]
assert titles == ['Kill Em All', 'Master of Puppets']
assert 'Live Bootleg 2019' not in titles
assert 'Greatest Hits' not in titles
def test_search_albums_falls_back_to_all_when_no_studio():
"""Niche live-only artist: if no studio releases exist, show live ones
rather than returning empty."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('LiveBand', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-live-1', 'title': 'Live at X', 'primary-type': 'Album',
'first-release-date': '2019-01-01', 'secondary-types': ['Live']},
{'id': 'rg-live-2', 'title': 'Live at Y', 'primary-type': 'Album',
'first-release-date': '2020-01-01', 'secondary-types': ['Live']},
]
albums = client.search_albums('liveband', limit=10)
assert len(albums) == 2
def test_search_tracks_prefers_studio_release_in_album_field():
"""When a recording has both a studio release and a live release, the
Track.album should reflect the studio release (canonical album),
regardless of the order MB returned them in."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.search_recordings_by_artist_mbid.return_value = [
{
'id': 'rec-master',
'title': 'Master of Puppets',
'length': 516000,
'artist-credit': [{'name': 'Metallica'}],
# Live release first (what MB often returns), studio second.
'releases': [
{'id': 'rel-live', 'title': 'Live Bootleg', 'date': '2023-01-01',
'release-group': {'id': 'rg-live', 'primary-type': 'Album',
'secondary-types': ['Live']}},
{'id': 'rel-studio', 'title': 'Master of Puppets', 'date': '1986-03-03',
'release-group': {'id': 'rg-studio', 'primary-type': 'Album',
'secondary-types': []}},
],
},
]
tracks = client.search_tracks('metallica', limit=10)
assert len(tracks) == 1
# Album must be the studio release, not the live bootleg.
assert tracks[0].album == 'Master of Puppets'
assert tracks[0].release_date == '1986-03-03'
def test_search_tracks_filters_recordings_without_studio_releases():
"""A recording that only exists on live/compilation releases should be
dropped when we have studio alternatives."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.search_recordings_by_artist_mbid.return_value = [
{'id': 'rec-studio', 'title': 'Seek and Destroy', 'length': 390000,
'artist-credit': [{'name': 'Metallica'}],
'releases': [
{'id': 'rel-studio', 'title': 'Kill Em All', 'date': '1983-07-25',
'release-group': {'id': 'rg-studio', 'primary-type': 'Album',
'secondary-types': []}},
]},
{'id': 'rec-live-only', 'title': 'Fight Fire With Fire', 'length': 450000,
'artist-credit': [{'name': 'Metallica'}],
'releases': [
{'id': 'rel-live', 'title': 'Live Shit', 'date': '1993-01-01',
'release-group': {'id': 'rg-live', 'primary-type': 'Album',
'secondary-types': ['Live']}},
]},
]
tracks = client.search_tracks('metallica', limit=10)
titles = [t.name for t in tracks]
assert 'Seek and Destroy' in titles
assert 'Fight Fire With Fire' not in titles
def test_search_albums_text_path_filters_by_score():
client = MusicBrainzSearchClient()
client._client = MagicMock()
# Force text-search path by using a structured query.
client._client.search_release.return_value = [
{'id': 'rel-good', 'title': 'Good', 'score': 95,
'release-group': {'id': 'rg-1', 'primary-type': 'Album'},
'artist-credit': [{'name': 'Foo'}]},
{'id': 'rel-bad', 'title': 'Bad', 'score': 40,
'release-group': {'id': 'rg-2', 'primary-type': 'Album'},
'artist-credit': [{'name': 'Foo'}]},
]
albums = client.search_albums('Foo - Good', limit=10)
titles = [a.name for a in albums]
assert 'Good' in titles
assert 'Bad' not in titles
# ---------------------------------------------------------------------------
# Track search — routing
# ---------------------------------------------------------------------------
def test_search_tracks_bare_query_uses_browse_path():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.search_recordings_by_artist_mbid.return_value = [
{'id': 'rec-1', 'title': 'One', 'length': 446000,
'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988',
'release-group': {'id': 'rg-1', 'primary-type': 'Album'}}],
'artist-credit': [{'name': 'Metallica'}]},
{'id': 'rec-2', 'title': 'Battery', 'length': 312000,
'releases': [{'id': 'rel-2', 'title': 'Master of Puppets', 'date': '1986',
'release-group': {'id': 'rg-2', 'primary-type': 'Album'}}],
'artist-credit': [{'name': 'Metallica'}]},
]
tracks = client.search_tracks('metallica', limit=10)
client._client.search_recordings_by_artist_mbid.assert_called_once()
client._client.search_recording.assert_not_called()
assert len(tracks) == 2
assert {t.name for t in tracks} == {'One', 'Battery'}
def test_search_tracks_dedupes_by_title():
"""MusicBrainz has many live/compilation variants of the same song.
Browse results should be deduped by normalized title so we don't show
'One' three times."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.search_recordings_by_artist_mbid.return_value = [
{'id': 'rec-1', 'title': 'One', 'length': 446000,
'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988'}],
'artist-credit': [{'name': 'Metallica'}]},
{'id': 'rec-1-live', 'title': 'One', 'length': 490000,
'releases': [{'id': 'rel-live', 'title': 'Live Shit', 'date': '1993'}],
'artist-credit': [{'name': 'Metallica'}]},
]
tracks = client.search_tracks('metallica', limit=10)
assert len(tracks) == 1
assert tracks[0].name == 'One'
def test_search_tracks_structured_query_uses_text_path():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
{'id': 'rec-1', 'title': 'One', 'score': 100,
'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988'}],
'artist-credit': [{'name': 'Metallica'}]},
]
tracks = client.search_tracks('Metallica - One', limit=10)
client._client.search_recording.assert_called_once()
client._client.search_artist.assert_not_called()
client._client.search_recordings_by_artist_mbid.assert_not_called()
assert len(tracks) == 1
def test_get_album_resolves_release_group_mbid_to_release():
"""When the album ID is a release-group MBID (from the browse path),
get_album must look up the release-group, pick a canonical release,
and fetch that release's tracklist. Fetching /release/<rg-mbid>
directly 404s."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
# Release-group lookup returns two editions — an Official release and
# a promo. The Official earlier release should win.
client._client.get_release_group.return_value = {
'id': 'rg-damn',
'title': 'DAMN.',
'primary-type': 'Album',
'secondary-types': [],
'first-release-date': '2017-04-14',
'artist-credit': [{'name': 'Kendrick Lamar'}],
'releases': [
{'id': 'rel-promo', 'status': 'Promotion', 'date': '2017-04-01',
'media': [{'track-count': 14, 'tracks': []}]},
{'id': 'rel-official', 'status': 'Official', 'date': '2017-04-14',
'media': [{'track-count': 14, 'tracks': []}]},
],
}
# Release lookup returns a full release with tracklist.
client._client.get_release.return_value = {
'id': 'rel-official',
'title': 'DAMN.',
'date': '2017-04-14',
'artist-credit': [{'name': 'Kendrick Lamar'}],
'release-group': {'id': 'rg-damn', 'primary-type': 'Album', 'secondary-types': []},
'media': [
{'position': 1, 'tracks': [
{'id': 't1', 'number': '1', 'position': 1, 'length': 50000,
'recording': {'id': 'rec-1', 'title': 'BLOOD.',
'artist-credit': [{'name': 'Kendrick Lamar'}], 'length': 50000}},
]},
],
}
album = client.get_album('rg-damn')
# Must have called release-group first, then release for the picked edition.
client._client.get_release_group.assert_called_once_with(
'rg-damn', includes=['releases', 'artist-credits']
)
client._client.get_release.assert_called_once_with(
'rel-official', includes=['recordings', 'artist-credits', 'release-groups']
)
assert album is not None
assert album['id'] == 'rg-damn' # Canonical ID stays the release-group MBID.
assert album['name'] == 'DAMN.'
assert len(album['tracks']) == 1
assert album['tracks'][0]['name'] == 'BLOOD.'
assert 'release-group' in album['external_urls']['musicbrainz']
def test_get_album_falls_back_to_release_lookup_on_rg_miss():
"""When the MBID is a release (from the text-search fallback path) the
release-group lookup 404s, but the direct release lookup works."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
# Release-group lookup returns None (simulating 404).
client._client.get_release_group.return_value = None
client._client.get_release.return_value = {
'id': 'rel-abc',
'title': 'Some Album',
'date': '2020-01-01',
'artist-credit': [{'name': 'Some Artist'}],
'release-group': {'id': 'rg-abc', 'primary-type': 'Album', 'secondary-types': []},
'media': [{'position': 1, 'tracks': []}],
}
album = client.get_album('rel-abc')
client._client.get_release_group.assert_called_once()
client._client.get_release.assert_called_once()
assert album is not None
assert album['id'] == 'rel-abc' # Falls back to release MBID since rg lookup missed.
# ---------------------------------------------------------------------------
# Title-hint extraction — for "Artist Album Title" bare queries
# ---------------------------------------------------------------------------
def test_extract_title_hint_basic():
assert _extract_title_hint('The Beatles Abbey Road', 'The Beatles') == 'Abbey Road'
def test_extract_title_hint_case_insensitive():
assert _extract_title_hint('the beatles abbey road', 'The Beatles') == 'abbey road'
def test_extract_title_hint_preserves_original_casing():
# Query slicing should return the original casing of the title portion.
assert _extract_title_hint('The Beatles Abbey Road', 'The Beatles') == 'Abbey Road'
def test_extract_title_hint_whitespace_tolerant():
assert _extract_title_hint('The Beatles Abbey Road', 'The Beatles') == 'Abbey Road'
def test_extract_title_hint_bare_artist_returns_none():
assert _extract_title_hint('The Beatles', 'The Beatles') is None
def test_extract_title_hint_artist_not_prefix_returns_none():
# Query where the artist name isn't the prefix — nothing to extract.
assert _extract_title_hint('Abbey Road', 'The Beatles') is None
def test_extract_title_hint_word_boundary_required():
# "Metallicasomething" shouldn't split as artist=Metallica + hint=something
assert _extract_title_hint('Metallicasomething', 'Metallica') is None
def test_search_albums_filters_browse_results_by_title_hint():
"""Regression: 'The Beatles Abbey Road' used to return the whole
discography; should now narrow to Abbey Road specifically."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('The Beatles', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-abbey', 'title': 'Abbey Road', 'primary-type': 'Album',
'first-release-date': '1969-09-26', 'secondary-types': []},
{'id': 'rg-white', 'title': 'The Beatles', 'primary-type': 'Album',
'first-release-date': '1968-11-22', 'secondary-types': []},
{'id': 'rg-revolver', 'title': 'Revolver', 'primary-type': 'Album',
'first-release-date': '1966-08-05', 'secondary-types': []},
]
albums = client.search_albums('The Beatles Abbey Road', limit=10)
# Filtered to only the album whose title matches the hint.
assert [a.name for a in albums] == ['Abbey Road']
def test_search_albums_falls_back_to_text_when_hint_matches_nothing():
"""If the title hint doesn't match any browse result, fall back to
text-search rather than returning the full discography or nothing."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('The Beatles', 'mb-1', score=100)]
# Browse returns albums that don't match the hint.
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-1', 'title': 'Some Other Album', 'primary-type': 'Album',
'first-release-date': '1965-01-01', 'secondary-types': []},
]
# Text-search fallback (_search_albums_text → search_release) returns the album.
client._client.search_release.return_value = [
{'id': 'rel-abbey', 'title': 'Abbey Road', 'score': 100,
'release-group': {'id': 'rg-abbey', 'primary-type': 'Album'},
'artist-credit': [{'name': 'The Beatles'}]},
]
albums = client.search_albums('The Beatles Totally Fake Album Name', limit=10)
# Browse had no hit for the title hint, then fallback kicks in when
# the filter results are also empty (after studio-pref filter etc.).
# NOTE: in this test the hint filter returns empty, so we fall through
# to search_release.
client._client.search_release.assert_called_once()
assert any(a.name == 'Abbey Road' for a in albums)
def test_search_albums_bare_artist_no_hint_no_filter():
"""Bare artist name (no title hint) returns full discography — the
filter only kicks in when the user types extra words."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('The Beatles', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-abbey', 'title': 'Abbey Road', 'primary-type': 'Album',
'first-release-date': '1969-09-26', 'secondary-types': []},
{'id': 'rg-revolver', 'title': 'Revolver', 'primary-type': 'Album',
'first-release-date': '1966-08-05', 'secondary-types': []},
]
albums = client.search_albums('the beatles', limit=10)
# No filter — full discography.
titles = {a.name for a in albums}
assert 'Abbey Road' in titles
assert 'Revolver' in titles
def test_recording_to_track_total_tracks_matches_media_count():
"""Regression: total_tracks was initialized at 1 and summed with media
track-counts, producing an off-by-one. An 11-track album reported 12."""
client = MusicBrainzSearchClient()
recording = {
'id': 'rec-1',
'title': 'Song',
'length': 300000,
'artist-credit': [{'name': 'Band'}],
'releases': [{
'id': 'rel-1',
'title': 'Album',
'date': '2020-01-01',
'release-group': {'id': 'rg-1', 'primary-type': 'Album', 'secondary-types': []},
'media': [{'track-count': 11}],
}],
}
track = client._recording_to_track(recording, 'Band')
assert track is not None
assert track.total_tracks == 11
def test_recording_to_track_multi_disc_sums_media():
"""Two-disc album with 14 tracks total should report 14, not 15 (off by one)
or 3 (missing the sum)."""
client = MusicBrainzSearchClient()
recording = {
'id': 'rec-1',
'title': 'Song',
'artist-credit': [{'name': 'Band'}],
'releases': [{
'id': 'rel-1', 'title': 'Album',
'release-group': {'id': 'rg-1', 'primary-type': 'Album'},
'media': [{'track-count': 7}, {'track-count': 7}],
}],
}
track = client._recording_to_track(recording, 'Band')
assert track.total_tracks == 14
def test_recording_to_track_no_release_defaults_total_tracks_to_one():
"""A recording with no release info is a standalone track — report 1."""
client = MusicBrainzSearchClient()
recording = {
'id': 'rec-1',
'title': 'Standalone',
'artist-credit': [{'name': 'Band'}],
'releases': [],
}
track = client._recording_to_track(recording, 'Band')
assert track.total_tracks == 1
def test_pick_representative_release_prefers_official_with_media():
"""The release picker should skip stub releases (no media) and pick
Official over Promotion status."""
client = MusicBrainzSearchClient()
releases = [
{'id': 'stub', 'status': 'Official', 'date': '2020-01-01'}, # No media
{'id': 'promo', 'status': 'Promotion', 'date': '2019-12-01',
'media': [{'track-count': 10}]},
{'id': 'official', 'status': 'Official', 'date': '2020-01-05',
'media': [{'track-count': 10}]},
]
picked = client._pick_representative_release(releases)
assert picked['id'] == 'official'
def test_search_tracks_text_path_filters_by_score():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
{'id': 'rec-good', 'title': 'Good', 'score': 95,
'releases': [{'id': 'rel-1', 'title': 'X', 'date': '2020'}],
'artist-credit': [{'name': 'Foo'}]},
{'id': 'rec-bad', 'title': 'Bad', 'score': 40,
'releases': [{'id': 'rel-2', 'title': 'Y', 'date': '2021'}],
'artist-credit': [{'name': 'Foo'}]},
]
tracks = client.search_tracks('Foo - Good', limit=10)
titles = [t.name for t in tracks]
assert 'Good' in titles
assert 'Bad' not in titles

View file

@ -11735,10 +11735,15 @@ def get_artist_image(artist_id):
source_override = request.args.get('source', '').strip().lower() or None
plugin = request.args.get('plugin', '').strip().lower() or None
# `name` is optional but required for sources that don't store
# artist images directly (MusicBrainz) — the resolver falls back
# to searching iTunes/Deezer by name.
artist_name = request.args.get('name', '').strip() or None
image_url = _get_artist_image_url(
artist_id,
source_override=source_override,
plugin=plugin,
artist_name=artist_name,
)
return jsonify({"success": True, "image_url": image_url})
except Exception as e:

View file

@ -5333,13 +5333,13 @@ function _gsRenderFromState(state) {
if (dbArtists.length) {
h += '<div class="gsearch-section-header">📚 In Your Library</div><div class="gsearch-grid">';
h += dbArtists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', true)"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">Library</div></div></div>`).join('');
h += dbArtists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', true)"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎤'">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">Library</div></div></div>`).join('');
h += '</div>';
}
if (artists.length) {
h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid" id="gsearch-artists-grid">`;
h += artists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', false)" ${!a.image_url ? `data-artist-id="${a.id}" data-needs-image="true"` : ''}><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div></div></div>`).join('');
h += artists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', false)" ${!a.image_url ? `data-artist-id="${a.id}" data-needs-image="true" data-artist-name="${_escAttr(a.name)}"` : ''}><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎤'">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div></div></div>`).join('');
h += '</div>';
}
@ -5349,7 +5349,7 @@ function _gsRenderFromState(state) {
const ar = a.artist || (a.artists ? a.artists.join(', ') : '');
const yr = a.release_date ? a.release_date.substring(0, 4) : '';
const img = (a.image_url || '').replace(/'/g, "\\'");
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escAttr(a.name)}', '${_escAttr(ar)}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '💿'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${yr ? ` · ${yr}` : ''}</div></div></div>`;
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escAttr(a.name)}', '${_escAttr(ar)}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='💿'">` : '💿'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${yr ? ` · ${yr}` : ''}</div></div></div>`;
}).join('');
h += '</div>';
}
@ -5359,7 +5359,7 @@ function _gsRenderFromState(state) {
h += singles.map(a => {
const ar = a.artist || (a.artists ? a.artists.join(', ') : '');
const img = (a.image_url || '').replace(/'/g, "\\'");
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escAttr(a.name)}', '${_escAttr(ar)}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎶'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}</div></div></div>`;
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escAttr(a.name)}', '${_escAttr(ar)}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎶'">` : '🎶'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}</div></div></div>`;
}).join('');
h += '</div>';
}
@ -5369,7 +5369,7 @@ function _gsRenderFromState(state) {
h += tracks.map(t => {
const ar = t.artist || (t.artists ? t.artists.join(', ') : '');
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
return `<div class="gsearch-track" onclick="_gsClickTrack('${_escAttr(ar)}', '${_escAttr(t.name)}', '${_escAttr(t.album || '')}', '${_escAttr(t.id || '')}', '${_escAttr(t.image_url || '')}', ${t.duration_ms || 0})"><div class="gsearch-item-art" style="width:32px;height:32px;border-radius:6px">${t.image_url ? `<img src="${t.image_url}" loading="lazy">` : '🎵'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(t.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}</div></div><div class="gsearch-track-dur">${dur}</div><button class="gsearch-play-btn" onclick="event.stopPropagation(); _gsPlayTrack('${_escAttr(t.name)}', '${_escAttr(ar)}', '${_escAttr(t.album || '')}')" title="Stream">▶</button></div>`;
return `<div class="gsearch-track" onclick="_gsClickTrack('${_escAttr(ar)}', '${_escAttr(t.name)}', '${_escAttr(t.album || '')}', '${_escAttr(t.id || '')}', '${_escAttr(t.image_url || '')}', ${t.duration_ms || 0})"><div class="gsearch-item-art" style="width:32px;height:32px;border-radius:6px">${t.image_url ? `<img src="${t.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎵'">` : '🎵'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(t.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}</div></div><div class="gsearch-track-dur">${dur}</div><button class="gsearch-play-btn" onclick="event.stopPropagation(); _gsPlayTrack('${_escAttr(t.name)}', '${_escAttr(ar)}', '${_escAttr(t.album || '')}')" title="Stream">▶</button></div>`;
}).join('');
h += '</div>';
}
@ -5398,11 +5398,15 @@ async function _gsLazyLoadArtistImages() {
const artistId = card.dataset.artistId;
if (!artistId) continue;
try {
const res = await fetch(`/api/artist/${artistId}/image?source=${activeSrc}`);
// Pass the artist name so MusicBrainz lookups (which have no
// artist art) can resolve the image by name on a fallback source.
const params = new URLSearchParams({ source: activeSrc });
if (card.dataset.artistName) params.set('name', card.dataset.artistName);
const res = await fetch(`/api/artist/${artistId}/image?${params}`);
const data = await res.json();
if (data.success && data.image_url) {
const artDiv = card.querySelector('.gsearch-item-art');
if (artDiv) artDiv.innerHTML = `<img src="${data.image_url}" loading="lazy">`;
if (artDiv) artDiv.innerHTML = `<img src="${data.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎤'">`;
card.removeAttribute('data-needs-image');
}
} catch (e) { /* ignore */ }

View file

@ -3462,6 +3462,8 @@ const WHATS_NEW = {
{ title: 'Stale Search Requests No Longer Flash Empty Results on Fast Retype', desc: 'Cin flagged a race in createSearchController: when you typed a query then quickly re-typed before the first fetch returned, the first fetch\'s catch block (firing on AbortError after the second submitQuery aborted it) cleared loadingSources and notified the UI, causing a brief flash of empty/error state while the new query\'s fetch was still mid-flight. Added a monotonic _requestSeq token — each fetch captures the next value, and stale completions bail before mutating shared state. The controller still aborts in-flight fetches on supersession; this just keeps the abort-cleanup of the old request from clobbering the new one\'s spinner', page: 'search', unreleased: true },
{ title: 'Source Picker Dims Soulseek When slskd Isn\'t Configured', desc: 'Cin pointed out that the Soulseek icon was always rendered as configured, so users without slskd set up could click it and fire searches that would never succeed. Soulseek is now in the backend config-status registry as `required: [slskd_url]` and removed from the frontend\'s always-configured set. Without slskd, the icon dims and clicking it routes to Settings → Downloads tab (where the slskd URL field lives, gated behind the download-source dropdown) instead of Settings → Connections', page: 'search', unreleased: true },
{ title: 'Fix Discover Hero "View Discography" 404ing on Source Artists', desc: 'Clicking "View Discography" on the Discover page hero slideshow was calling navigateToArtistDetail without a source, so /api/artist-detail defaulted to a library lookup and returned 404 for artists that don\'t exist in your library (which is nearly every hero artist — they come from discover similar-artists, not the library). Regression from the unification PR that rewrote the click handler to route to /artist-detail but forgot to pass the source. Backend already sends artist.source on each hero entry; we now stash it as data-source on the discography button and thread it through to navigateToArtistDetail so the API call includes source=itunes/deezer/etc. and returns the synthesized discography', page: 'discover', unreleased: true },
{ title: 'MusicBrainz Search Actually Works Now', desc: 'kettui flagged during PR #371 review that the MusicBrainz source tab never returned artists and served garbage tracks/albums. Three things were wrong. First, the artist search was hardcoded to return an empty list — re-enabled with a proper fuzzy query (bare Lucene string against alias/artist/sortname indexes) and score-filtered at 80+ to drop tribute bands. Second, track and album searches used text-search on recording/release TITLES — so typing "metallica" matched random tracks literally named "Metallica" (all scoring 100 because they\'re exact title hits). Now a bare name query resolves to the top-scoring artist, then BROWSES that artist\'s release-groups and recordings directly — the same pattern Plex uses. Structured "Artist - Title" queries still take the text-search path since the user gave an explicit title to match. Third, the adapter was firing synchronous Cover Art Archive HEAD requests (up to 30s of blocking probes per search) — replaced with deterministic URL construction so the browser loads images lazily with <img onerror> fallback. Search completes in ~3 seconds instead of 30+ on cold cache. Also shipped: project URL in User-Agent per MB\'s rate-limit policy recommendations', page: 'search', unreleased: true },
{ title: 'MusicBrainz Search Follow-Ups (Images, Counts, Title Hints)', desc: 'Three fixes from kettui\'s follow-up pass on the MusicBrainz search PR. (1) Artist images were missing because MB doesn\'t store artist art — the lazy-load endpoint now accepts an optional `name` query param and resolves images by searching iTunes/Deezer for that artist name. (2) Track total_tracks was off by one because the counter initialized at 1 before summing release media track-counts — an 11-track album reported 12. Initialized to 0 now, with a special case for standalone recordings that have no release (report 1). (3) Queries like "The Beatles Abbey Road" used to browse The Beatles\' whole discography because the artist-first path resolved the artist and ignored the trailing title. Now extracts the title hint from queries shaped like "Artist Title", filters browse results to match, and falls back to text-search when no browse result matches (so "The Beatles Totally Fake Album" still finds something rather than nothing). 10 new tests covering title-hint extraction, browse-filter behavior, total_tracks edge cases', page: 'search', unreleased: true },
],
'2.39': [
// --- April 22, 2026 ---

View file

@ -575,9 +575,16 @@ function initializeSearchModeToggle() {
try {
const activeSource = searchController.state.activeSource;
const imgUrl = activeSource && activeSource !== 'spotify'
? `/api/artist/${artistId}/image?source=${activeSource}`
: `/api/artist/${artistId}/image`;
// Pass the artist name so the backend can look up images
// for sources that don't store them (e.g. MusicBrainz —
// it only has MBIDs, not artist art, so the resolver
// falls back to iTunes/Deezer keyed by name).
const artistName = card.dataset.artistName || '';
const params = new URLSearchParams();
if (activeSource && activeSource !== 'spotify') params.set('source', activeSource);
if (artistName) params.set('name', artistName);
const qs = params.toString();
const imgUrl = `/api/artist/${artistId}/image${qs ? '?' + qs : ''}`;
const response = await fetch(imgUrl);
const data = await response.json();

View file

@ -537,6 +537,11 @@ function renderCompactSection(sectionId, listId, countId, items, mapItem) {
if (item.id) {
elem.dataset.artistId = item.id;
elem.dataset.needsImage = config.image ? 'false' : 'true';
// Stash the artist name so the lazy-loader can pass it to
// the backend. Needed for sources that don't store artist
// images directly (MusicBrainz) — backend resolves the
// image by looking up the name on a fallback source.
if (config.name) elem.dataset.artistName = config.name;
}
} else if (isAlbum) {
elem.className = 'enh-compact-item album-card';
@ -559,9 +564,15 @@ function renderCompactSection(sectionId, listId, countId, items, mapItem) {
placeholderClass += ' track-placeholder';
}
// Fallback placeholder used when the image 404s (common for MB
// Cover Art Archive URLs — we construct them deterministically
// without probing first, so some will miss). Without onerror the
// browser shows its broken-image icon.
const placeholderHtml = `<div class="${placeholderClass}" data-lazy-image="true">${config.placeholder}</div>`;
const escapedFallback = placeholderHtml.replace(/"/g, '&quot;');
const imageHtml = config.image
? `<img src="${escapeHtml(config.image)}" class="${imageClass}" alt="${escapeHtml(config.name)}">`
: `<div class="${placeholderClass}" data-lazy-image="true">${config.placeholder}</div>`;
? `<img src="${escapeHtml(config.image)}" class="${imageClass}" alt="${escapeHtml(config.name)}" onerror="this.outerHTML='${escapedFallback}'">`
: placeholderHtml;
const badgeHtml = config.badge
? `<div class="enh-item-badge ${config.badge.class}">${config.badge.text}</div>`

View file

@ -5466,6 +5466,22 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
.gsearch-results.visible { display: flex; animation: gsearchSlideUp 0.2s ease; }
/* Stable results-panel structure built by downloads.js _doInit: the source
row and fallback banner keep their natural height; #gsearch-body grows
to fill what's left of the 60vh cap and scrolls when content overflows.
Without the flex:1 + overflow on #gsearch-body, content past the cap
was clipped with no scrollbar the source-picker refactor introduced
the structure but not the accompanying CSS. */
#gsearch-source-row { flex-shrink: 0; }
#gsearch-fallback-banner { flex-shrink: 0; }
#gsearch-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
#gsearch-body::-webkit-scrollbar { width: 4px; }
#gsearch-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; }
@keyframes gsearchSlideUp {
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }