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
+327
View File
@@ -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)
}
+25 -14
View File
@@ -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
@@ -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}">
@@ -1,7 +1,7 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
import { designTokens } from '../../styles/tokens.css';
import { Search, GetThumbnails } from '@go/explore/Service';
import { Search, GetThumbnails, GetArtistImageURL } from '@go/explore/Service';
import type { ThumbnailRequest } from '@go/explore/Service';
import type {
MBSearchResult,
@@ -76,6 +76,7 @@ export class ExploreView extends LitElement {
private searchVersion = 0;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private thumbnailCache = new Map<string, string>();
private artistImageCache = new Map<string, string>();
@litQuery('input') private inputEl!: HTMLInputElement;
@@ -293,6 +294,13 @@ export class ExploreView extends LitElement {
text-transform: uppercase;
user-select: none;
flex-shrink: 0;
overflow: hidden;
}
.artist-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-name {
@@ -568,6 +576,7 @@ export class ExploreView extends LitElement {
this.results = result;
this.loadThumbnails();
this.loadArtistImages();
const elapsed = (performance.now() - startTime).toFixed(0);
console.log(
@@ -653,6 +662,32 @@ export class ExploreView extends LitElement {
});
}
/**
* Load artist images for all visible artist cards. Each call
* is async and updates the cache + re-renders on success.
*/
private loadArtistImages() {
if (!this.results?.artists?.length) return;
for (const a of this.results.artists) {
if (this.artistImageCache.has(a.mbid)) continue;
// Mark as loading.
this.artistImageCache.set(a.mbid, '');
GetArtistImageURL(a.mbid)
.then((url) => {
if (url) {
this.artistImageCache.set(a.mbid, url);
this.requestUpdate();
}
})
.catch(() => {
// No image — leave empty string.
});
}
}
/* ── Top Results ── */
private getTopResults(): ScoredItem[] {
@@ -905,7 +940,12 @@ export class ExploreView extends LitElement {
class="artist-avatar"
style="background: hsl(${hue}, 45%, 35%)"
>
${(a.englishName || a.name).charAt(0).toUpperCase()}
${this.artistImageCache.get(a.mbid)
? html`<img
src="${this.artistImageCache.get(a.mbid)}"
alt="${a.englishName || a.name}"
/>`
: (a.englishName || a.name).charAt(0).toUpperCase()}
</div>
<div class="artist-name" title="${a.englishName || a.name}">
${a.englishName || a.name}
Vendored Executable → Regular
+4 -7
View File
@@ -11,15 +11,11 @@ export function CoverArtGroupURL(arg1:string):Promise<string>;
export function CoverArtURL(arg1:string):Promise<string>;
export function GetThumbnail(arg1:string, arg2:string, arg3:string):Promise<string>;
export function GetArtistImageURL(arg1:string):Promise<string>;
export interface ThumbnailRequest {
mbid: string;
albumName: string;
artistName: string;
}
export function GetThumbnail(arg1:string,arg2:string,arg3:string):Promise<string>;
export function GetThumbnails(arg1:ThumbnailRequest[]):Promise<Record<string, string>>;
export function GetThumbnails(arg1:Array<explore.ThumbnailRequest>):Promise<Record<string, string>>;
export function LookupArtist(arg1:string):Promise<explore.MBArtist>;
@@ -38,3 +34,4 @@ export function SetContext(arg1:context.Context):Promise<void>;
export function SimilarArtists(arg1:string):Promise<Array<explore.LBSimilarArtist>>;
export function TopRecordingsForArtist(arg1:string):Promise<Array<explore.LBTopRecording>>;
+4
View File
@@ -18,6 +18,10 @@ export function CoverArtURL(arg1) {
return window['go']['explore']['Service']['CoverArtURL'](arg1);
}
export function GetArtistImageURL(arg1) {
return window['go']['explore']['Service']['GetArtistImageURL'](arg1);
}
export function GetThumbnail(arg1, arg2, arg3) {
return window['go']['explore']['Service']['GetThumbnail'](arg1, arg2, arg3);
}