Add <img onerror> fallbacks for search result images

Self-audit catch: my earlier cover-art commit claimed 'the frontend's
<img onerror> fallback handles 404s' — that was wrong. The enhanced
search result images in shared-helpers.js renderCompactSection and all
five gsearch-item/track templates in downloads.js render bare
`<img src="...">` with no fallback. With the MusicBrainz adapter now
emitting Cover Art Archive URLs deterministically (no HEAD probe),
albums that don't have cover art would show the browser's broken-image
icon instead of the emoji placeholder.

Two fallback shapes:

- shared-helpers.js renderCompactSection: the `<img>` sits inside a
  card with a sibling placeholder pattern. On error, replace the img's
  outerHTML with the placeholder div, matching the shape used when
  config.image is missing entirely.

- downloads.js gsearch items: the `<img>` sits inside a
  `.gsearch-item-art` div whose default text content is the emoji fallback
  (🎤 / 💿 / 🎶 / 🎵). On error, set parentElement.textContent to the
  emoji, which wipes the img and shows the glyph. Same shape as the
  "no image_url" branch.

Applies to every card type that renders a user-provided image URL so
the fix covers all sources that might return 404s — MB is the most
common offender but iTunes/Deezer/Discogs can all miss too.

Tested against the live MB API: Metallica albums without CAA cover art
now show the 💿 emoji instead of a broken-image icon.
This commit is contained in:
Broque Thomas 2026-04-24 08:41:07 -07:00
parent 2b7d6c8c7c
commit a6359a2690
2 changed files with 13 additions and 7 deletions

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"` : ''}><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>';
}

View file

@ -559,9 +559,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>`