Files
yellowjacket/backend/explore/artistimage.go
T
yonlu 55473dd52a 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.
2026-03-25 19:55:10 -04:00

328 lines
8.2 KiB
Go

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)
}