MusicBrainz: Fix artist images, total_tracks off-by-one, and Artist+Title queries

Three bugs from kettui's follow-up review pass on the MusicBrainz
search PR, all fixed in one commit because they share UI context.

1. Missing artist images on MB artist results

MusicBrainz doesn't store artist images directly. My earlier commit
returned `image_url=None` on every artist result and trusted the
frontend's lazy-loader — but the lazy-loader's `/api/artist/<id>/image?
source=musicbrainz` endpoint had no handler for MusicBrainz, so it
silently returned None and the emoji placeholder stayed.

Fix plumbs the artist name through:
- `renderCompactSection` stashes `data-artist-name` on artist cards.
- `search.js` and `downloads.js` lazy-loaders pass `name=<artist>` as a
  query param.
- `/api/artist/<id>/image` accepts an optional `name` param.
- `metadata_service.get_artist_image_url` has a new `musicbrainz`
  branch: since MB has no artist art, it searches fallback sources
  (iTunes/Deezer by configured priority) for the artist name and
  returns the first image found.

Verified live — Metallica/Kendrick Lamar/Daft Punk all resolve to
Deezer artist images via the name lookup.

2. total_tracks off-by-one on tracks with a release

`_recording_to_track` initialized `total_tracks = 1` and then summed
media track-counts on top. For an 11-track album, it reported 12. An
adapter-level regression introduced when the recording-projection
helper was extracted during the main MB refactor.

Fix: initialize at 0, sum normally. Standalone recordings with no
release (can happen for uncredited remixes etc.) still report 1 via
an explicit fallback — so the existing "single track" case isn't
broken.

3. "Artist Album Title" queries buried specific albums in the
   discography list

Bare-name queries like "The Beatles Abbey Road" used to resolve "The
Beatles" as the artist and then browse their full discography — Abbey
Road was buried alphabetically among 200+ releases instead of being
the top result.

Fix adds a title-hint extractor. When the query starts with the
resolved artist name followed by more words, the trailing portion is
treated as a title hint. Browse results are filtered to those whose
release-group title contains the hint. If the filter matches nothing,
falls back to text-search with the hint as the title (the "keep the
old split-by-whitespace fallback" path kettui called for). If text-
search also misses, shows the full discography rather than nothing.

10 new tests in tests/test_musicbrainz_search.py (46 total):
- Title-hint extractor: basic match, case-insensitive, whitespace
  tolerance, bare-artist-no-hint, artist-not-prefix-no-hint, word-
  boundary required (no false splits on "Metallicasomething").
- Browse filtering by title hint.
- Text-search fallback when the title hint matches nothing in browse.
- Bare-artist queries return the full discography unfiltered.
- total_tracks for single-release, multi-disc, and no-release cases.
This commit is contained in:
Broque Thomas 2026-04-24 10:17:59 -07:00
parent 7dfe1ae88d
commit b3722449fc
8 changed files with 295 additions and 8 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

@ -92,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()
@ -303,6 +325,13 @@ class MusicBrainzSearchClient:
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
@ -332,6 +361,25 @@ class MusicBrainzSearchClient:
# 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").
@ -440,7 +488,10 @@ class MusicBrainzSearchClient:
release_date = ''
image_url = None
album_type = 'single'
total_tracks = 1
# 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:
@ -460,6 +511,12 @@ class MusicBrainzSearchClient:
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,

View file

@ -15,6 +15,7 @@ import pytest
from core.musicbrainz_search import (
MusicBrainzSearchClient,
_cover_art_url,
_extract_title_hint,
)
@ -520,6 +521,164 @@ def test_get_album_falls_back_to_release_lookup_on_rg_miss():
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."""

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

@ -5339,7 +5339,7 @@ function _gsRenderFromState(state) {
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" onerror="this.parentElement.textContent='🎤'">` : '🎤'}</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>';
}
@ -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

@ -3463,6 +3463,7 @@ const WHATS_NEW = {
{ 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';