diff --git a/backend/explore/artistimage.go b/backend/explore/artistimage.go
new file mode 100644
index 0000000..bf85c76
--- /dev/null
+++ b/backend/explore/artistimage.go
@@ -0,0 +1,327 @@
+package explore
+
+import (
+ "context"
+ "crypto/md5" //nolint:gosec // MD5 used for Wikimedia URL hashing, not security
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// ErrArtistImage is returned when an artist image HTTP fetch fails.
+var ErrArtistImage = errors.New("artist image fetch failed")
+
+const (
+ // wikimediaThumbBase is the base URL for Wikimedia Commons
+ // thumbnail generation.
+ wikimediaThumbBase = "https://upload.wikimedia.org/wikipedia/commons/thumb"
+
+ // wikidataAPIBase is the base URL for the Wikidata API.
+ wikidataAPIBase = "https://www.wikidata.org/w/api.php"
+
+ // artistImageSize is the default thumbnail width in pixels.
+ artistImageSize = 250
+
+ // artistImageTimeout is the HTTP timeout for image URL lookups.
+ artistImageTimeout = 10 * time.Second
+)
+
+// ArtistImageProvider resolves artist MBIDs to image URLs. It
+// checks multiple sources in priority order and caches results in
+// the explore_cache. Designed to be extended with additional
+// sources (fanart.tv, etc.) by adding to the providers slice.
+type ArtistImageProvider struct {
+ mb *MusicBrainzClient
+ cache *Cache
+ client *http.Client
+ logger *slog.Logger
+}
+
+// NewArtistImageProvider creates a provider that resolves artist
+// images via MusicBrainz relationships and Wikidata.
+func NewArtistImageProvider(
+ mb *MusicBrainzClient,
+ cache *Cache,
+ logger *slog.Logger,
+) *ArtistImageProvider {
+ return &ArtistImageProvider{
+ mb: mb,
+ cache: cache,
+ client: &http.Client{Timeout: artistImageTimeout},
+ logger: logger,
+ }
+}
+
+// GetArtistImageURL returns a Wikimedia Commons thumbnail URL for
+// the given artist MBID, or "" if no image is available. Results
+// are cached in explore_cache with a 30-day TTL.
+func (p *ArtistImageProvider) GetArtistImageURL(artistMBID string) string {
+ if artistMBID == "" {
+ return ""
+ }
+
+ cacheKey := "artist-image:" + artistMBID
+
+ // Check cache.
+ if data, ok := p.cache.Get(cacheKey); ok {
+ return string(data)
+ }
+
+ // Resolve image URL.
+ imageURL := p.resolve(artistMBID)
+
+ // Cache the result (even empty string = no image found).
+ cacheTTL := 30 * 24 * time.Hour
+ p.cache.Set(cacheKey, []byte(imageURL), cacheTTL, artistMBID, "artist")
+
+ return imageURL
+}
+
+// resolve tries each source in order and returns the first image
+// URL found.
+func (p *ArtistImageProvider) resolve(artistMBID string) string {
+ // Source 1: MB direct image relation (Commons wiki page link).
+ if url := p.fromMBImageRelation(artistMBID); url != "" {
+ return url
+ }
+
+ // Source 2: MB wikidata relation → Wikidata P18 → Commons thumb.
+ if url := p.fromWikidata(artistMBID); url != "" {
+ return url
+ }
+
+ // No image found from any source.
+ return ""
+}
+
+// ---------------------------------------------------------------------------
+// Source 1: MB direct image relation
+// ---------------------------------------------------------------------------
+
+// fromMBImageRelation checks the artist's MB url-rels for a direct
+// "image" type pointing to Wikimedia Commons.
+func (p *ArtistImageProvider) fromMBImageRelation(artistMBID string) string {
+ ctx := context.Background()
+
+ artist, err := p.mb.LookupArtist(ctx, artistMBID)
+ if err != nil || artist == nil {
+ return ""
+ }
+
+ // The LookupArtist doesn't include rels in our current wrapper.
+ // We need the raw MB data with url-rels. Check if there's a
+ // cached response that includes relations.
+ cacheKey := "mb:artist-rels:" + artistMBID
+
+ if data, ok := p.cache.Get(cacheKey); ok {
+ return p.parseImageFromRels(data)
+ }
+
+ // Fetch with url-rels included.
+ url := fmt.Sprintf(
+ "https://musicbrainz.org/ws/2/artist/%s?fmt=json&inc=url-rels",
+ artistMBID,
+ )
+
+ body, err := p.fetchURL(ctx, url)
+ if err != nil {
+ return ""
+ }
+
+ // Cache the response.
+ cacheTTL := 30 * 24 * time.Hour
+ p.cache.Set(cacheKey, body, cacheTTL, artistMBID, "artist")
+
+ return p.parseImageFromRels(body)
+}
+
+func (p *ArtistImageProvider) parseImageFromRels(data []byte) string {
+ var mb struct {
+ Relations []struct {
+ Type string `json:"type"`
+ URL struct {
+ Resource string `json:"resource"`
+ } `json:"url"`
+ } `json:"relations"`
+ }
+
+ if err := json.Unmarshal(data, &mb); err != nil {
+ return ""
+ }
+
+ for _, rel := range mb.Relations {
+ if rel.Type != "image" {
+ continue
+ }
+
+ resource := rel.URL.Resource
+
+ // Direct Commons file link: "https://commons.wikimedia.org/wiki/File:Name.jpg"
+ if strings.Contains(resource, "commons.wikimedia.org/wiki/File:") {
+ filename := resource[strings.LastIndex(resource, "File:")+5:]
+
+ return wikimediaThumbURL(filename)
+ }
+ }
+
+ return ""
+}
+
+// ---------------------------------------------------------------------------
+// Source 2: Wikidata P18
+// ---------------------------------------------------------------------------
+
+// fromWikidata looks up the artist's Wikidata Q-ID from MB rels,
+// then fetches the P18 (image) property from Wikidata.
+func (p *ArtistImageProvider) fromWikidata(artistMBID string) string {
+ // Get the wikidata Q-ID from cached MB rels.
+ qid := p.getWikidataQID(artistMBID)
+ if qid == "" {
+ return ""
+ }
+
+ // Check cache for Wikidata image.
+ cacheKey := "wikidata-image:" + qid
+
+ if data, ok := p.cache.Get(cacheKey); ok {
+ return string(data)
+ }
+
+ // Fetch P18 from Wikidata API.
+ ctx := context.Background()
+ url := fmt.Sprintf(
+ "%s?action=wbgetclaims&entity=%s&property=P18&format=json",
+ wikidataAPIBase, qid,
+ )
+
+ body, err := p.fetchURL(ctx, url)
+ if err != nil {
+ return ""
+ }
+
+ var wd struct {
+ Claims struct {
+ P18 []struct {
+ Mainsnak struct {
+ Datavalue struct {
+ Value string `json:"value"`
+ } `json:"datavalue"`
+ } `json:"mainsnak"`
+ } `json:"P18"`
+ } `json:"claims"`
+ }
+
+ if err := json.Unmarshal(body, &wd); err != nil || len(wd.Claims.P18) == 0 {
+ // Cache empty result.
+ cacheTTL := 30 * 24 * time.Hour
+ p.cache.Set(cacheKey, []byte(""), cacheTTL, "", "")
+
+ return ""
+ }
+
+ filename := strings.ReplaceAll(wd.Claims.P18[0].Mainsnak.Datavalue.Value, " ", "_")
+ thumbURL := wikimediaThumbURL(filename)
+
+ cacheTTL := 30 * 24 * time.Hour
+ p.cache.Set(cacheKey, []byte(thumbURL), cacheTTL, "", "")
+
+ return thumbURL
+}
+
+func (p *ArtistImageProvider) getWikidataQID(artistMBID string) string {
+ cacheKey := "mb:artist-rels:" + artistMBID
+
+ data, ok := p.cache.Get(cacheKey)
+ if !ok {
+ // Need to fetch rels — fromMBImageRelation should have
+ // populated this, but if not, fetch now.
+ ctx := context.Background()
+ url := fmt.Sprintf(
+ "https://musicbrainz.org/ws/2/artist/%s?fmt=json&inc=url-rels",
+ artistMBID,
+ )
+
+ var err error
+
+ data, err = p.fetchURL(ctx, url)
+ if err != nil {
+ return ""
+ }
+
+ cacheTTL := 30 * 24 * time.Hour
+ p.cache.Set(cacheKey, data, cacheTTL, artistMBID, "artist")
+ }
+
+ var mb struct {
+ Relations []struct {
+ Type string `json:"type"`
+ URL struct {
+ Resource string `json:"resource"`
+ } `json:"url"`
+ } `json:"relations"`
+ }
+
+ if err := json.Unmarshal(data, &mb); err != nil {
+ return ""
+ }
+
+ for _, rel := range mb.Relations {
+ if rel.Type == "wikidata" {
+ parts := strings.Split(rel.URL.Resource, "/")
+
+ return parts[len(parts)-1]
+ }
+ }
+
+ return ""
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+// wikimediaThumbURL constructs a Wikimedia Commons thumbnail URL
+// from a filename. The URL scheme uses MD5 hashing of the filename
+// for directory bucketing.
+func wikimediaThumbURL(filename string) string {
+ if filename == "" {
+ return ""
+ }
+
+ filename = strings.ReplaceAll(filename, " ", "_")
+
+ hash := fmt.Sprintf("%x", md5.Sum([]byte(filename))) //nolint:gosec
+ h1 := string(hash[0])
+ h2 := hash[:2]
+
+ return fmt.Sprintf("%s/%s/%s/%s/%dpx-%s",
+ wikimediaThumbBase, h1, h2, filename, artistImageSize, filename,
+ )
+}
+
+func (p *ArtistImageProvider) fetchURL(ctx context.Context, url string) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("User-Agent", lbUserAgent)
+
+ resp, err := p.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("%w: HTTP %d", ErrArtistImage, resp.StatusCode)
+ }
+
+ return io.ReadAll(resp.Body)
+}
diff --git a/backend/explore/explore.go b/backend/explore/explore.go
index 2a52a0c..133f70d 100644
--- a/backend/explore/explore.go
+++ b/backend/explore/explore.go
@@ -18,13 +18,14 @@ import (
// response cache. Its exported methods form the binding surface
// that the frontend calls via generated TypeScript stubs.
type Service struct {
- mb *MusicBrainzClient
- lb *ListenBrainzClient
- cache *Cache
- index *SearchIndex
- artProxy *CoverArtProxy
- logger *slog.Logger
- ctx context.Context
+ mb *MusicBrainzClient
+ lb *ListenBrainzClient
+ cache *Cache
+ index *SearchIndex
+ artProxy *CoverArtProxy
+ artistImg *ArtistImageProvider
+ logger *slog.Logger
+ ctx context.Context
}
// NewExploreService creates a Service backed by the given
@@ -37,17 +38,19 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz"))
index := NewSearchIndex(db, lb, logger.WithGroup("search-index"))
artProxy := NewCoverArtProxy(db, limiter)
+ artistImg := NewArtistImageProvider(mb, cache, logger.WithGroup("artist-image"))
logger.Info("explore service created")
return &Service{
- mb: mb,
- lb: lb,
- cache: cache,
- index: index,
- artProxy: artProxy,
- logger: logger,
- ctx: context.Background(),
+ mb: mb,
+ lb: lb,
+ cache: cache,
+ index: index,
+ artProxy: artProxy,
+ artistImg: artistImg,
+ logger: logger,
+ ctx: context.Background(),
}
}
@@ -185,6 +188,14 @@ func (e *Service) GetThumbnails(requests []ThumbnailRequest) map[string]string {
return result
}
+// GetArtistImageURL returns a Wikimedia Commons thumbnail URL for
+// the given artist MBID. Resolved via MB url-rels → Wikidata P18
+// → Commons thumb URL. Cached for 30 days. Returns "" if no
+// image is available.
+func (e *Service) GetArtistImageURL(artistMBID string) string {
+ return e.artistImg.GetArtistImageURL(artistMBID)
+}
+
// Search concurrently queries MusicBrainz for artists, release
// groups, and recordings matching the query, then boosts results
// using ListenBrainz popularity data. The final score blends
diff --git a/frontend/src/components/explore-artist-details/explore-artist-details.ts b/frontend/src/components/explore-artist-details/explore-artist-details.ts
index e02dcf8..5999dd4 100644
--- a/frontend/src/components/explore-artist-details/explore-artist-details.ts
+++ b/frontend/src/components/explore-artist-details/explore-artist-details.ts
@@ -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``
+ : this.getInitial(this.displayName)}