Files
yellowjacket/backend/explore/artistimage.go
T
yonlu 9b88b88523 fix: rate-limit MB url-rels fetches, serialize frontend image loads
Artist image resolution was hitting musicbrainz.org with 10
concurrent unthrottled requests per search — enough to trigger
MB's rate limit rejection. Two fixes:

Backend: add dedicated 1 req/s RateLimiter for MB url-rels fetches
in ArtistImageProvider. Each fetch waits on the limiter before
the HTTP call. Results are cached 30 days so repeat lookups are
instant.

Frontend: switch loadArtistImages from concurrent fire-all to
sequential await loop. Each artist image loads one at a time,
images appear progressively as they resolve instead of all
failing from rate limit rejection.
2026-03-25 22:19:18 -04:00

296 lines
7.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
// artistImageCacheTTL is how long resolved image URLs are cached.
artistImageCacheTTL = 30 * 24 * time.Hour
)
// ArtistImageProvider resolves artist MBIDs to image URLs. It
// fetches the artist's MB url-rels (once, cached 30 days), extracts
// image sources from them, and returns a Wikimedia Commons thumbnail
// URL. Designed to be extended with additional sources (fanart.tv,
// etc.) by adding to the resolve chain.
type ArtistImageProvider struct {
cache *Cache
mbLimiter *RateLimiter
client *http.Client
logger *slog.Logger
}
// NewArtistImageProvider creates a provider that resolves artist
// images via MusicBrainz relationships and Wikidata.
func NewArtistImageProvider(
cache *Cache,
mbLimiter *RateLimiter,
logger *slog.Logger,
) *ArtistImageProvider {
return &ArtistImageProvider{
cache: cache,
mbLimiter: mbLimiter,
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 for 30 days.
func (p *ArtistImageProvider) GetArtistImageURL(artistMBID string) string {
if artistMBID == "" {
return ""
}
// Check resolved URL cache first.
cacheKey := "artist-image:" + artistMBID
if data, ok := p.cache.Get(cacheKey); ok {
return string(data)
}
// Fetch MB url-rels (cached separately, shared with other uses).
rels := p.fetchMBRels(artistMBID)
if rels == nil {
p.cache.Set(cacheKey, []byte(""), artistImageCacheTTL, artistMBID, "artist")
return ""
}
// Try each source in priority order.
imageURL := p.fromDirectImageRel(rels)
if imageURL == "" {
imageURL = p.fromWikidataRel(rels)
}
// Cache the result (even "" = no image).
p.cache.Set(cacheKey, []byte(imageURL), artistImageCacheTTL, artistMBID, "artist")
if imageURL != "" {
p.logger.Debug("artist image resolved",
"mbid", artistMBID,
"url", imageURL,
)
}
return imageURL
}
// ---------------------------------------------------------------------------
// MB url-rels fetching (shared by all sources)
// ---------------------------------------------------------------------------
type mbRelation struct {
Type string `json:"type"`
URL struct {
Resource string `json:"resource"`
} `json:"url"`
}
func (p *ArtistImageProvider) fetchMBRels(artistMBID string) []mbRelation {
cacheKey := "mb:artist-rels:" + artistMBID
if data, ok := p.cache.Get(cacheKey); ok {
var envelope struct {
Relations []mbRelation `json:"relations"`
}
if err := json.Unmarshal(data, &envelope); err == nil {
return envelope.Relations
}
}
url := fmt.Sprintf(
"https://musicbrainz.org/ws/2/artist/%s?fmt=json&inc=url-rels",
artistMBID,
)
// Rate-limit the MB API call.
if err := p.mbLimiter.Wait(context.Background()); err != nil {
return nil
}
body, err := p.fetchURL(url)
if err != nil {
p.logger.Debug("artist image: MB rels fetch failed",
"mbid", artistMBID,
"error", err,
)
return nil
}
p.cache.Set(cacheKey, body, artistImageCacheTTL, artistMBID, "artist")
var envelope struct {
Relations []mbRelation `json:"relations"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil
}
return envelope.Relations
}
// ---------------------------------------------------------------------------
// Source 1: direct image relation (Commons wiki page link)
// ---------------------------------------------------------------------------
func (p *ArtistImageProvider) fromDirectImageRel(rels []mbRelation) string {
for _, rel := range rels {
if rel.Type != "image" {
continue
}
resource := rel.URL.Resource
// "https://commons.wikimedia.org/wiki/File:Name.jpg"
if idx := strings.LastIndex(resource, "File:"); idx >= 0 {
filename := resource[idx+5:]
return wikimediaThumbURL(filename)
}
}
return ""
}
// ---------------------------------------------------------------------------
// Source 2: Wikidata P18 property
// ---------------------------------------------------------------------------
func (p *ArtistImageProvider) fromWikidataRel(rels []mbRelation) string {
// Find the wikidata Q-ID.
qid := ""
for _, rel := range rels {
if rel.Type == "wikidata" {
parts := strings.Split(rel.URL.Resource, "/")
qid = parts[len(parts)-1]
break
}
}
if qid == "" {
return ""
}
// Check cache for this Wikidata entity.
cacheKey := "wikidata-p18:" + qid
if data, ok := p.cache.Get(cacheKey); ok {
return string(data)
}
// Fetch P18 from Wikidata.
url := fmt.Sprintf(
"%s?action=wbgetclaims&entity=%s&property=P18&format=json",
wikidataAPIBase, qid,
)
body, err := p.fetchURL(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"`
}
thumbURL := ""
if err := json.Unmarshal(body, &wd); err == nil && len(wd.Claims.P18) > 0 {
filename := strings.ReplaceAll(wd.Claims.P18[0].Mainsnak.Datavalue.Value, " ", "_")
thumbURL = wikimediaThumbURL(filename)
}
p.cache.Set(cacheKey, []byte(thumbURL), artistImageCacheTTL, "", "")
return thumbURL
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// wikimediaThumbURL constructs a Wikimedia Commons thumbnail URL
// from a filename using the MD5 directory bucketing scheme.
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(url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), artistImageTimeout)
defer cancel()
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)
}