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:
+117
-27
@@ -31,6 +31,7 @@ const (
|
|||||||
wikimediaThumbBase = "https://upload.wikimedia.org/wikipedia/commons/thumb"
|
wikimediaThumbBase = "https://upload.wikimedia.org/wikipedia/commons/thumb"
|
||||||
wikidataAPIBase = "https://www.wikidata.org/w/api.php"
|
wikidataAPIBase = "https://www.wikidata.org/w/api.php"
|
||||||
wikipediaAPIBase = "https://en.wikipedia.org/w/api.php"
|
wikipediaAPIBase = "https://en.wikipedia.org/w/api.php"
|
||||||
|
fanartTVAPIBase = "https://webservice.fanart.tv/v3/music"
|
||||||
artistImageTimeout = 10 * time.Second
|
artistImageTimeout = 10 * time.Second
|
||||||
artistImageCacheTTL = 30 * 24 * time.Hour
|
artistImageCacheTTL = 30 * 24 * time.Hour
|
||||||
artistImageBaseDir = "artist-images"
|
artistImageBaseDir = "artist-images"
|
||||||
@@ -39,6 +40,14 @@ const (
|
|||||||
maxImagesPerArtist = 10
|
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.
|
// artistImageTier defines a thumbnail size variant.
|
||||||
type artistImageTier struct {
|
type artistImageTier struct {
|
||||||
Suffix string
|
Suffix string
|
||||||
@@ -56,12 +65,13 @@ var artistImageTiers = []artistImageTier{
|
|||||||
// from multiple sources. Stores up to 10 images per artist with
|
// from multiple sources. Stores up to 10 images per artist with
|
||||||
// sm/md/lg thumbnails for the primary image.
|
// sm/md/lg thumbnails for the primary image.
|
||||||
type ArtistImageProvider struct {
|
type ArtistImageProvider struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
cache *Cache
|
cache *Cache
|
||||||
mbLimiter *RateLimiter
|
mbLimiter *RateLimiter
|
||||||
client *http.Client
|
client *http.Client
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
baseDir string
|
baseDir string
|
||||||
|
fanartAPIKey string // resolved project key + optional personal key
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewArtistImageProvider creates a multi-source artist image provider.
|
// NewArtistImageProvider creates a multi-source artist image provider.
|
||||||
@@ -79,13 +89,24 @@ func NewArtistImageProvider(
|
|||||||
_ = os.MkdirAll(dir, 0o755)
|
_ = 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{
|
return &ArtistImageProvider{
|
||||||
db: db,
|
db: db,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
mbLimiter: mbLimiter,
|
mbLimiter: mbLimiter,
|
||||||
client: &http.Client{Timeout: artistImageTimeout},
|
client: &http.Client{Timeout: artistImageTimeout},
|
||||||
logger: logger,
|
logger: logger,
|
||||||
baseDir: dir,
|
baseDir: dir,
|
||||||
|
fanartAPIKey: fanartKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,13 +235,24 @@ type mbRelation struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *ArtistImageProvider) resolveAllSources(artistMBID string) {
|
func (p *ArtistImageProvider) resolveAllSources(artistMBID string) {
|
||||||
rels := p.fetchMBRels(artistMBID)
|
type imageSource struct {
|
||||||
|
|
||||||
var urls []struct {
|
|
||||||
source string
|
source string
|
||||||
url 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).
|
// Source 1: MB direct image relations (Wikimedia Commons).
|
||||||
for _, rel := range rels {
|
for _, rel := range rels {
|
||||||
if rel.Type != "image" {
|
if rel.Type != "image" {
|
||||||
@@ -234,10 +266,7 @@ func (p *ArtistImageProvider) resolveAllSources(artistMBID string) {
|
|||||||
thumbURL := wikimediaThumbURL(filename)
|
thumbURL := wikimediaThumbURL(filename)
|
||||||
|
|
||||||
if thumbURL != "" {
|
if thumbURL != "" {
|
||||||
urls = append(urls, struct {
|
urls = append(urls, imageSource{source: "wikimedia", url: thumbURL})
|
||||||
source string
|
|
||||||
url string
|
|
||||||
}{"wikimedia", thumbURL})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,10 +287,7 @@ func (p *ArtistImageProvider) resolveAllSources(artistMBID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !dup {
|
if !dup {
|
||||||
urls = append(urls, struct {
|
urls = append(urls, imageSource{"wikidata", thumbURL})
|
||||||
source string
|
|
||||||
url string
|
|
||||||
}{"wikidata", thumbURL})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,10 +304,7 @@ func (p *ArtistImageProvider) resolveAllSources(artistMBID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !dup {
|
if !dup {
|
||||||
urls = append(urls, struct {
|
urls = append(urls, imageSource{"wikipedia", leadURL})
|
||||||
source string
|
|
||||||
url string
|
|
||||||
}{"wikipedia", leadURL})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,6 +425,73 @@ func (p *ArtistImageProvider) generateThumbnail(
|
|||||||
// MB rels + Wikidata + Wikipedia
|
// 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 {
|
func (p *ArtistImageProvider) fetchMBRels(artistMBID string) []mbRelation {
|
||||||
cacheKey := "mb:artist-rels:" + artistMBID
|
cacheKey := "mb:artist-rels:" + artistMBID
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user