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
+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