feat: add fanart.tv as artist image source (highest priority)

Fanart.tv artistthumb images are now the primary source for artist
photos. Up to 5 thumbnails fetched per artist (sorted by community
likes). Falls through to Wikimedia/Wikidata/Wikipedia if fanart.tv
has no images for the artist.

API key handling per fanart.tv project key terms:
- Project key loaded from FANART_TV_API_KEY env var (or build-time
  ldflags via fanartTVProjectKey variable)
- Users can provide their own personal key via FANART_TV_PERSONAL_KEY
  env var for higher rate limits (sent as client_key parameter)
- Results cached 30 days in explore_cache
- No bulk downloading — only fetched per-artist during index build

Source priority order:
1. fanart.tv artistthumb (best quality, community-curated)
2. MusicBrainz direct image rels (Wikimedia Commons)
3. Wikidata P18 (Wikimedia Commons)
4. Wikipedia lead image

Attribution: fanart.tv images are CC-BY-SA, contributed by the
fanart.tv community (https://fanart.tv).
This commit is contained in:
2026-03-29 10:18:09 -04:00
parent 19a803d1fc
commit 37392e0a46
+117 -27
View File
@@ -31,6 +31,7 @@ const (
wikimediaThumbBase = "https://upload.wikimedia.org/wikipedia/commons/thumb"
wikidataAPIBase = "https://www.wikidata.org/w/api.php"
wikipediaAPIBase = "https://en.wikipedia.org/w/api.php"
fanartTVAPIBase = "https://webservice.fanart.tv/v3/music"
artistImageTimeout = 10 * time.Second
artistImageCacheTTL = 30 * 24 * time.Hour
artistImageBaseDir = "artist-images"
@@ -39,6 +40,14 @@ const (
maxImagesPerArtist = 10
)
// fanartTVProjectKey is the project API key for fanart.tv.
// Set via -ldflags at build time, or FANART_TV_API_KEY env var.
// Users can provide their own personal key via FANART_TV_PERSONAL_KEY.
// Per fanart.tv terms: images are CC-BY-SA, attribution required.
//
//nolint:gochecknoglobals
var fanartTVProjectKey = ""
// artistImageTier defines a thumbnail size variant.
type artistImageTier struct {
Suffix string
@@ -56,12 +65,13 @@ var artistImageTiers = []artistImageTier{
// from multiple sources. Stores up to 10 images per artist with
// sm/md/lg thumbnails for the primary image.
type ArtistImageProvider struct {
db *database.DB
cache *Cache
mbLimiter *RateLimiter
client *http.Client
logger *slog.Logger
baseDir string
db *database.DB
cache *Cache
mbLimiter *RateLimiter
client *http.Client
logger *slog.Logger
baseDir string
fanartAPIKey string // resolved project key + optional personal key
}
// NewArtistImageProvider creates a multi-source artist image provider.
@@ -79,13 +89,24 @@ func NewArtistImageProvider(
_ = os.MkdirAll(dir, 0o755)
}
// Resolve fanart.tv API key: env var > build-time ldflags.
fanartKey := os.Getenv("FANART_TV_API_KEY")
if fanartKey == "" {
fanartKey = fanartTVProjectKey
}
if fanartKey != "" {
logger.Info("fanart.tv API key configured")
}
return &ArtistImageProvider{
db: db,
cache: cache,
mbLimiter: mbLimiter,
client: &http.Client{Timeout: artistImageTimeout},
logger: logger,
baseDir: dir,
db: db,
cache: cache,
mbLimiter: mbLimiter,
client: &http.Client{Timeout: artistImageTimeout},
logger: logger,
baseDir: dir,
fanartAPIKey: fanartKey,
}
}
@@ -214,13 +235,24 @@ type mbRelation struct {
}
func (p *ArtistImageProvider) resolveAllSources(artistMBID string) {
rels := p.fetchMBRels(artistMBID)
var urls []struct {
type imageSource struct {
source string
url string
}
var urls []imageSource
// Source 0 (highest priority): fanart.tv artist thumbnails.
if p.fanartAPIKey != "" {
fanartURLs := p.fetchFanartTV(artistMBID)
for _, u := range fanartURLs {
urls = append(urls, imageSource{source: "fanart", url: u})
}
}
rels := p.fetchMBRels(artistMBID)
// Source 1: MB direct image relations (Wikimedia Commons).
for _, rel := range rels {
if rel.Type != "image" {
@@ -234,10 +266,7 @@ func (p *ArtistImageProvider) resolveAllSources(artistMBID string) {
thumbURL := wikimediaThumbURL(filename)
if thumbURL != "" {
urls = append(urls, struct {
source string
url string
}{"wikimedia", thumbURL})
urls = append(urls, imageSource{source: "wikimedia", url: thumbURL})
}
}
}
@@ -258,10 +287,7 @@ func (p *ArtistImageProvider) resolveAllSources(artistMBID string) {
}
if !dup {
urls = append(urls, struct {
source string
url string
}{"wikidata", thumbURL})
urls = append(urls, imageSource{"wikidata", thumbURL})
}
}
@@ -278,10 +304,7 @@ func (p *ArtistImageProvider) resolveAllSources(artistMBID string) {
}
if !dup {
urls = append(urls, struct {
source string
url string
}{"wikipedia", leadURL})
urls = append(urls, imageSource{"wikipedia", leadURL})
}
}
}
@@ -402,6 +425,73 @@ func (p *ArtistImageProvider) generateThumbnail(
// MB rels + Wikidata + Wikipedia
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Source 0: fanart.tv
// ---------------------------------------------------------------------------
// fetchFanartTV returns artist thumbnail URLs from fanart.tv.
// Uses the project API key + optional user personal key.
// Returns up to 5 URLs (artistthumb images, sorted by likes).
func (p *ArtistImageProvider) fetchFanartTV(artistMBID string) []string {
cacheKey := "fanart:" + artistMBID
if data, ok := p.cache.Get(cacheKey); ok {
var cached []string
if err := json.Unmarshal(data, &cached); err == nil {
return cached
}
}
url := fmt.Sprintf("%s/%s?api_key=%s", fanartTVAPIBase, artistMBID, p.fanartAPIKey)
// Add personal key if the user configured one.
if personalKey := os.Getenv("FANART_TV_PERSONAL_KEY"); personalKey != "" {
url += "&client_key=" + personalKey
}
body, err := p.fetchURL(url)
if err != nil {
// Cache empty result to avoid re-fetching.
p.cache.Set(cacheKey, []byte("[]"), artistImageCacheTTL, artistMBID, "artist")
return nil
}
var response struct {
ArtistThumb []struct {
URL string `json:"url"`
Likes string `json:"likes"`
} `json:"artistthumb"`
}
if err := json.Unmarshal(body, &response); err != nil || len(response.ArtistThumb) == 0 {
p.cache.Set(cacheKey, []byte("[]"), artistImageCacheTTL, artistMBID, "artist")
return nil
}
// Take up to 5 thumbs (they're already sorted by likes on the API side).
limit := 5
if limit > len(response.ArtistThumb) {
limit = len(response.ArtistThumb)
}
urls := make([]string, limit)
for i := range limit {
urls[i] = response.ArtistThumb[i].URL
}
// Cache the resolved URLs.
data, _ := json.Marshal(urls)
p.cache.Set(cacheKey, data, artistImageCacheTTL, artistMBID, "artist")
return urls
}
// ---------------------------------------------------------------------------
// Source 1-3: MB rels + Wikidata + Wikipedia
// ---------------------------------------------------------------------------
func (p *ArtistImageProvider) fetchMBRels(artistMBID string) []mbRelation {
cacheKey := "mb:artist-rels:" + artistMBID