feat: artist images from MusicBrainz/Wikidata/Wikimedia Commons

Add ArtistImageProvider that resolves artist MBIDs to photo URLs:
1. MB url-rels 'image' type → extract Commons filename → thumb URL
2. MB url-rels 'wikidata' type → Wikidata P18 property → thumb URL
3. No image → falls back to initial-letter avatar

Wikimedia Commons thumb URLs constructed via MD5 hash bucketing
(standard Commons URL scheme). Results cached in explore_cache
with 30-day TTL — subsequent lookups are instant.

Frontend: search results and artist detail page show artist photos
in the circular avatar when available. Images load async and
replace the initial-letter fallback on arrival. Artist detail page
fires the image fetch alongside the other 4 parallel data loads.

Architecture supports adding more sources (fanart.tv, etc.) by
extending the resolve() method's source chain.
This commit is contained in:
2026-03-25 19:55:10 -04:00
parent 0e376abfbb
commit 55473dd52a
6 changed files with 432 additions and 25 deletions
@@ -6,6 +6,7 @@ import {
BrowseReleaseGroups,
TopRecordingsForArtist,
SimilarArtists,
GetArtistImageURL,
} from '@go/explore/Service';
import type {
MBArtist,
@@ -88,6 +89,7 @@ export class ExploreArtistDetails extends LitElement {
@state() private errorReleases = '';
@state() private similarArtists: LBSimilarArtist[] = [];
@state() private loadingSimilar = true;
@state() private artistImageURL = '';
/* ── Styles ── */
@@ -149,6 +151,13 @@ export class ExploreArtistDetails extends LitElement {
user-select: none;
flex-shrink: 0;
line-height: 1;
overflow: hidden;
}
.artist-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-info {
@@ -484,7 +493,7 @@ export class ExploreArtistDetails extends LitElement {
`[explore-artist] loading: "${this.artistName}" (${mbid})`,
);
// Fire all four requests in parallel — each section is independent.
// Fire all five requests in parallel — each section is independent.
const [artistResult, tracksResult, releasesResult, similarResult] =
await Promise.allSettled([
this.fetchArtist(mbid),
@@ -493,6 +502,9 @@ export class ExploreArtistDetails extends LitElement {
this.fetchSimilarArtists(mbid),
]);
// Artist image is fire-and-forget — doesn't block the page.
this.fetchArtistImage(mbid);
const summary = [
`artist=${artistResult.status}`,
`tracks=${tracksResult.status}`,
@@ -562,6 +574,17 @@ export class ExploreArtistDetails extends LitElement {
}
}
private async fetchArtistImage(mbid: string) {
try {
const url = await GetArtistImageURL(mbid);
if (url) {
this.artistImageURL = url;
}
} catch {
// No image available — avatar stays as initial letter.
}
}
/* ── Navigation ── */
private navigateBack() {
@@ -709,7 +732,12 @@ export class ExploreArtistDetails extends LitElement {
class="artist-avatar"
style="background: hsl(${hue}, 45%, 35%)"
>
${this.getInitial(this.displayName)}
${this.artistImageURL
? html`<img
src="${this.artistImageURL}"
alt="${this.displayName}"
/>`
: this.getInitial(this.displayName)}
</div>
<div class="artist-info">
<h1 class="artist-title" title="${this.displayName}">