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:
parent
7dfe1ae88d
commit
b3722449fc
8 changed files with 295 additions and 8 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue