The post-scan backfills share MusicBrainz's rate limiters with every page the user can open, and both were FIFO — so a thousand-artist enrichment put an album page behind an hour of queued work. WithBackgroundLane/WithBackgroundPriority add a slower second lane: a marked wait takes no token while any interactive wait is outstanding. It is a context marker rather than a parameter because a backfill calls the same client methods a detail page does. A long backfill also has to be visible and stoppable, so jobs.KindCatalogEnrich registers both with progress and cancel — after the work is counted, since these passes are a no-op on every launch once the library is covered. What it does not fetch is the point. It ran for hours against a 900-artist library and marked nothing, because three of the four things it did per artist were work nobody asked for: similar artists, which the artist page already resolves on view, and a full GetArtistImage (fanart.tv, TheAudioDB, Wikidata, Wikipedia, ten portraits) reached only to warm the MB artist lookup EnsureArtistRels does alone. It was also serial across artists while every limiter is per-host and idle. The marks are a table rather than more explore_index columns, because artifactimport merges by column list and a flag added there is a second place to remember. BrowseReleaseGroupsAll pages to exhaustion, where the old call silently cut a prolific artist at 100 release groups. One portrait is downloaded now; the rest are remembered as URLs. resolveAllSources downloaded every candidate, up to ten, full size, while nothing reads anything but primary.jpg — 5.3 GB measured on a real cache, 4.1 GB of it unreachable. OrphanedArtistImagesJob is why that survived: it joined the bare MBID onto the images directory, but artist directories are sharded under a two-character prefix, so it named a path that never existed and deleted the rows that were the only record of the files it left behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
1054 lines
27 KiB
Go
1054 lines
27 KiB
Go
package explore
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5" //nolint:gosec // MD5 for Wikimedia URL hashing
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/jpeg"
|
|
_ "image/png" // register PNG decoder
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/image/draw"
|
|
|
|
"yellowjacket/backend/database"
|
|
"yellowjacket/backend/system"
|
|
)
|
|
|
|
// ErrArtistImage is returned when an artist image HTTP fetch fails.
|
|
var ErrArtistImage = errors.New("artist image fetch failed")
|
|
|
|
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"
|
|
audioDBAPIBase = "https://www.theaudiodb.com/api/v1/json/2"
|
|
artistImageTimeout = 10 * time.Second
|
|
artistImageCacheTTL = 365 * 24 * time.Hour // positive results: ~permanent
|
|
artistImageMissCacheTTL = 30 * 24 * time.Hour // negative results: retry monthly
|
|
artistImageBaseDir = ArtistImageDirName
|
|
artistImageMaxBytes = 2 * 1024 * 1024
|
|
artistImageMaxSize = 500 // max dimension for stored full-res images
|
|
maxImagesPerArtist = 10
|
|
|
|
// artistMissFile marks an artist directory as having been resolved
|
|
// with no artwork found, so the sources are not asked again until
|
|
// the marker ages out.
|
|
artistMissFile = ".miss"
|
|
)
|
|
|
|
// 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
|
|
MaxSize int
|
|
Quality int
|
|
}
|
|
|
|
var artistImageTiers = []artistImageTier{
|
|
{Suffix: "_sm", MaxSize: 100, Quality: 75},
|
|
{Suffix: "_md", MaxSize: 200, Quality: 80},
|
|
{Suffix: "_lg", MaxSize: 400, Quality: 85},
|
|
}
|
|
|
|
// ArtistImageProvider resolves, fetches, and caches artist images
|
|
// 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
|
|
fanartAPIKey string // resolved project key + optional personal key
|
|
}
|
|
|
|
// NewArtistImageProvider creates a multi-source artist image provider.
|
|
func NewArtistImageProvider(
|
|
db *database.DB,
|
|
cache *Cache,
|
|
mbLimiter *RateLimiter,
|
|
logger *slog.Logger,
|
|
) *ArtistImageProvider {
|
|
dir := ""
|
|
|
|
dataDir, err := system.GetUserDataDirPath()
|
|
if err == nil {
|
|
dir = filepath.Join(dataDir, artistImageBaseDir)
|
|
_ = 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,
|
|
fanartAPIKey: fanartKey,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GetArtistImage returns the primary image as a base64 data URL.
|
|
// Resolves from all sources if not yet cached.
|
|
//
|
|
// The context is carried purely for its priority marking: the MB
|
|
// relations fetch below shares a limiter with the rest of the app, and
|
|
// a backfill resolving a thousand artists' photos has to yield to the
|
|
// page a user is looking at (see WithBackgroundPriority).
|
|
func (p *ArtistImageProvider) GetArtistImage(
|
|
ctx context.Context, artistMBID string,
|
|
) string {
|
|
if artistMBID == "" || p.baseDir == "" {
|
|
return ""
|
|
}
|
|
|
|
// Check for existing primary image on disk.
|
|
primaryPath := p.primaryPath(artistMBID)
|
|
if data := readFileData(primaryPath); data != "" {
|
|
return data
|
|
}
|
|
|
|
// Check if we already know there's no image.
|
|
if p.isMiss(artistMBID) {
|
|
return ""
|
|
}
|
|
|
|
// Resolve from all sources and select primary.
|
|
p.resolveAllSources(ctx, artistMBID)
|
|
|
|
// Try again after resolution.
|
|
if data := readFileData(primaryPath); data != "" {
|
|
return data
|
|
}
|
|
|
|
// Mark as miss.
|
|
p.writeMiss(artistMBID)
|
|
|
|
return ""
|
|
}
|
|
|
|
// EnsureArtistRels caches the MusicBrainz artist lookup — type,
|
|
// country, sort name, aliases and the URL relations — without
|
|
// resolving or downloading a single image.
|
|
//
|
|
// It exists because that lookup is all the discography backfill ever
|
|
// wanted from the image provider: it calls GetArtistDetails, which
|
|
// reads the cache this fills. Going through GetArtistImage to get it
|
|
// meant every owned artist also paid five upstream lookups and an
|
|
// image download, which is the bulk of what made that pass take hours.
|
|
// Portraits resolve when a page that shows one asks for them.
|
|
func (p *ArtistImageProvider) EnsureArtistRels(
|
|
ctx context.Context, artistMBID string,
|
|
) {
|
|
if artistMBID == "" {
|
|
return
|
|
}
|
|
|
|
p.fetchMBRels(ctx, artistMBID)
|
|
}
|
|
|
|
// GetCachedImage returns the primary image from disk cache only.
|
|
// No network fetches.
|
|
func (p *ArtistImageProvider) GetCachedImage(artistMBID string) string {
|
|
if artistMBID == "" || p.baseDir == "" {
|
|
return ""
|
|
}
|
|
|
|
return readFileData(p.primaryPath(artistMBID))
|
|
}
|
|
|
|
// GetImageURLs returns the asset-handler URLs for the primary image
|
|
// at all size tiers. Returns empty strings if no image.
|
|
func (p *ArtistImageProvider) GetImageURLs(artistMBID string) (string, string, string, string) {
|
|
if artistMBID == "" || p.baseDir == "" {
|
|
return "", "", "", ""
|
|
}
|
|
|
|
dir := p.artistDir(artistMBID)
|
|
prefix := "/artist-images/" + artistMBID[:2] + "/" + artistMBID + "/"
|
|
|
|
if _, err := os.Stat(filepath.Join(dir, "primary.jpg")); err != nil {
|
|
return "", "", "", ""
|
|
}
|
|
|
|
var small, medium, large string
|
|
|
|
full := prefix + "primary.jpg"
|
|
|
|
for _, tier := range artistImageTiers {
|
|
path := filepath.Join(dir, "primary"+tier.Suffix+".jpg")
|
|
if _, err := os.Stat(path); err == nil {
|
|
url := prefix + "primary" + tier.Suffix + ".jpg"
|
|
|
|
switch tier.Suffix {
|
|
case "_sm":
|
|
small = url
|
|
case "_md":
|
|
medium = url
|
|
case "_lg":
|
|
large = url
|
|
}
|
|
}
|
|
}
|
|
|
|
return small, medium, large, full
|
|
}
|
|
|
|
// GetAliases returns artist aliases from cached MB rels.
|
|
func (p *ArtistImageProvider) GetAliases(artistMBID string) string {
|
|
cacheKey := "mb:artist-rels:" + artistMBID
|
|
|
|
data, ok := p.cache.Get(cacheKey)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
var envelope struct {
|
|
Aliases []struct {
|
|
Name string `json:"name"`
|
|
} `json:"aliases"`
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &envelope); err != nil || len(envelope.Aliases) == 0 {
|
|
return ""
|
|
}
|
|
|
|
names := make([]string, 0, len(envelope.Aliases))
|
|
|
|
for _, a := range envelope.Aliases {
|
|
if a.Name != "" {
|
|
names = append(names, a.Name)
|
|
}
|
|
}
|
|
|
|
return strings.Join(names, " ")
|
|
}
|
|
|
|
// ArtistDetails holds the structured metadata extracted from MB's
|
|
// artist lookup response. Returned by GetArtistDetails.
|
|
type ArtistDetails struct {
|
|
Type string
|
|
Country string
|
|
Disambiguation string
|
|
SortName string
|
|
Aliases string
|
|
}
|
|
|
|
// GetArtistDetails returns structured metadata for an artist from
|
|
// the cached MB artist-rels response (which we fetch anyway during
|
|
// image resolution). Returns nil if not cached.
|
|
func (p *ArtistImageProvider) GetArtistDetails(artistMBID string) *ArtistDetails {
|
|
cacheKey := "mb:artist-rels:" + artistMBID
|
|
|
|
data, ok := p.cache.Get(cacheKey)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
var envelope struct {
|
|
Type string `json:"type"`
|
|
Country string `json:"country"`
|
|
Disambiguation string `json:"disambiguation"`
|
|
SortName string `json:"sort-name"`
|
|
Aliases []struct {
|
|
Name string `json:"name"`
|
|
} `json:"aliases"`
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &envelope); err != nil {
|
|
return nil
|
|
}
|
|
|
|
names := make([]string, 0, len(envelope.Aliases))
|
|
for _, a := range envelope.Aliases {
|
|
if a.Name != "" {
|
|
names = append(names, a.Name)
|
|
}
|
|
}
|
|
|
|
return &ArtistDetails{
|
|
Type: envelope.Type,
|
|
Country: envelope.Country,
|
|
Disambiguation: envelope.Disambiguation,
|
|
SortName: envelope.SortName,
|
|
Aliases: strings.Join(names, " "),
|
|
}
|
|
}
|
|
|
|
// PreloadArtistRels writes a synthesized mb:artist-rels cache entry
|
|
// derived from LB batch metadata. This lets fetchMBRels skip the
|
|
// per-artist MB network call — we already have type, country, name,
|
|
// and wikidata QID from LB. Aliases and disambiguation are left
|
|
// empty (those only come from a real MB call).
|
|
//
|
|
// The envelope shape matches what fetchMBRels reads, so the cache
|
|
// hit is transparent to the image resolution pipeline.
|
|
func (p *ArtistImageProvider) PreloadArtistRels(mbid string, meta ArtistMetadata) {
|
|
cacheKey := "mb:artist-rels:" + mbid
|
|
|
|
// Don't overwrite a real MB response if we already have one.
|
|
if data, ok := p.cache.Get(cacheKey); ok && len(data) > 0 {
|
|
return
|
|
}
|
|
|
|
// Construct an envelope compatible with both fetchMBRels
|
|
// (which reads `relations`) and GetArtistDetails (which reads
|
|
// `type`, `country`, `disambiguation`, `sort-name`, `aliases`).
|
|
envelope := struct {
|
|
Type string `json:"type"`
|
|
Country string `json:"country"`
|
|
SortName string `json:"sort-name"`
|
|
Disambiguation string `json:"disambiguation"`
|
|
Name string `json:"name"`
|
|
Relations []mbRelation `json:"relations"`
|
|
Aliases []struct {
|
|
Name string `json:"name"`
|
|
} `json:"aliases"`
|
|
}{
|
|
Type: meta.Type,
|
|
Country: meta.Country,
|
|
Name: meta.Name,
|
|
}
|
|
|
|
// Add a wikidata relation so getWikidataQID finds the QID.
|
|
if meta.WikidataQID != "" {
|
|
envelope.Relations = append(envelope.Relations, mbRelation{
|
|
Type: "wikidata",
|
|
URL: struct {
|
|
Resource string `json:"resource"`
|
|
}{
|
|
Resource: "https://www.wikidata.org/wiki/" + meta.WikidataQID,
|
|
},
|
|
})
|
|
}
|
|
|
|
data, err := json.Marshal(envelope)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
p.cache.Set(cacheKey, data, artistImageCacheTTL, mbid, "artist")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Source resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type mbRelation struct {
|
|
Type string `json:"type"`
|
|
URL struct {
|
|
Resource string `json:"resource"`
|
|
} `json:"url"`
|
|
}
|
|
|
|
// artistImageCandidate is one image an upstream offered for an artist,
|
|
// before anything has been downloaded.
|
|
type artistImageCandidate struct {
|
|
source string
|
|
url string
|
|
}
|
|
|
|
// resolveAllSources resolves an artist's image candidates and downloads
|
|
// exactly one of them.
|
|
//
|
|
// It used to download every candidate — up to maxImagesPerArtist, full
|
|
// size, serially — and keep them all, while nothing in the app has ever
|
|
// read anything but the primary. Measured on a real cache that was
|
|
// 4.1 GB of the 5.3 GB on disk, and several seconds of the per-artist
|
|
// cost the discography backfill pays for every owned artist.
|
|
//
|
|
// The candidates that are not downloaded are still *recorded*, with an
|
|
// empty file_path, so replacing the primary later is one download
|
|
// rather than a re-resolution of five upstreams.
|
|
func (p *ArtistImageProvider) resolveAllSources(
|
|
ctx context.Context, artistMBID string,
|
|
) {
|
|
candidates := p.resolveCandidates(ctx, artistMBID)
|
|
if len(candidates) == 0 {
|
|
return
|
|
}
|
|
|
|
if len(candidates) > maxImagesPerArtist {
|
|
candidates = candidates[:maxImagesPerArtist]
|
|
}
|
|
|
|
p.fetchPrimary(artistMBID, candidates)
|
|
}
|
|
|
|
// resolveCandidates asks every upstream what images it has for an
|
|
// artist, in priority order. It downloads no image bytes — only the
|
|
// metadata lookups happen here.
|
|
func (p *ArtistImageProvider) resolveCandidates(
|
|
ctx context.Context, artistMBID string,
|
|
) []artistImageCandidate {
|
|
type imageSource = artistImageCandidate
|
|
|
|
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})
|
|
}
|
|
}
|
|
|
|
// Source 1: TheAudioDB artist thumb.
|
|
if audioDBURLs := p.fetchAudioDB(artistMBID); len(audioDBURLs) > 0 {
|
|
for _, u := range audioDBURLs {
|
|
urls = append(urls, imageSource{source: "audiodb", url: u})
|
|
}
|
|
}
|
|
|
|
rels := p.fetchMBRels(ctx, artistMBID)
|
|
|
|
// Source 2: MB direct image relations (Wikimedia Commons).
|
|
for _, rel := range rels {
|
|
if rel.Type != "image" {
|
|
continue
|
|
}
|
|
|
|
resource := rel.URL.Resource
|
|
|
|
if idx := strings.LastIndex(resource, "File:"); idx >= 0 {
|
|
filename := resource[idx+5:]
|
|
thumbURL := wikimediaThumbURL(filename)
|
|
|
|
if thumbURL != "" {
|
|
urls = append(urls, imageSource{source: "wikimedia", url: thumbURL})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Source 2: Wikidata P18.
|
|
qid := p.getWikidataQID(rels)
|
|
if qid != "" {
|
|
if thumbURL := p.fetchWikidataP18(qid); thumbURL != "" {
|
|
// Avoid duplicates with source 1.
|
|
dup := false
|
|
|
|
for _, u := range urls {
|
|
if u.url == thumbURL {
|
|
dup = true
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if !dup {
|
|
urls = append(urls, imageSource{"wikidata", thumbURL})
|
|
}
|
|
}
|
|
|
|
// Source 3: Wikipedia lead image.
|
|
if leadURL := p.fetchWikipediaLeadImage(qid); leadURL != "" {
|
|
dup := false
|
|
|
|
for _, u := range urls {
|
|
if u.url == leadURL {
|
|
dup = true
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if !dup {
|
|
urls = append(urls, imageSource{"wikipedia", leadURL})
|
|
}
|
|
}
|
|
}
|
|
|
|
return urls
|
|
}
|
|
|
|
// fetchPrimary downloads candidates in priority order until one
|
|
// succeeds, makes that one the primary, and records the rest as
|
|
// known-but-unfetched.
|
|
//
|
|
// Taking the *first that succeeds* rather than the first outright is
|
|
// also a fix: the old loop keyed is_primary on the index, so a failed
|
|
// download of candidate 0 left the artist with a stored image, no
|
|
// primary.jpg, and — because GetArtistImage looks for primary.jpg —
|
|
// a `.miss` marker claiming the artist has no art at all.
|
|
func (p *ArtistImageProvider) fetchPrimary(
|
|
artistMBID string, candidates []artistImageCandidate,
|
|
) {
|
|
dir := p.artistDir(artistMBID)
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
|
|
primaryPath := filepath.Join(dir, "primary.jpg")
|
|
found := false
|
|
|
|
for i, c := range candidates {
|
|
if found {
|
|
p.recordCandidate(artistMBID, c, "", false, 0, i)
|
|
|
|
continue
|
|
}
|
|
|
|
imgData, err := p.fetchImageBytes(c.url)
|
|
if err != nil || len(imgData) == 0 {
|
|
p.recordCandidate(artistMBID, c, "", false, 0, i)
|
|
|
|
continue
|
|
}
|
|
|
|
// setPrimary writes primary.jpg itself, so the candidate is not
|
|
// also written under its own name — that second copy was a
|
|
// straight duplicate of the largest file in the directory.
|
|
p.setPrimary(artistMBID, dir, imgData)
|
|
p.recordCandidate(artistMBID, c, primaryPath, true, len(imgData), i)
|
|
|
|
found = true
|
|
}
|
|
}
|
|
|
|
// recordCandidate stores one candidate. An empty filePath means the
|
|
// image was offered but never downloaded, which is the ordinary state
|
|
// for everything but the primary.
|
|
func (p *ArtistImageProvider) recordCandidate(
|
|
artistMBID string,
|
|
c artistImageCandidate,
|
|
filePath string,
|
|
isPrimary bool,
|
|
size, order int,
|
|
) {
|
|
primary := 0
|
|
if isPrimary {
|
|
primary = 1
|
|
}
|
|
|
|
_, _ = p.db.ExecContext(`
|
|
INSERT OR REPLACE INTO artist_images
|
|
(artist_mbid, source, source_url, file_path, is_primary, sort_order, file_size)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, artistMBID, c.source, c.url, filePath, primary, order, size)
|
|
}
|
|
|
|
// setPrimary copies image data to primary.jpg and generates thumbnails.
|
|
func (p *ArtistImageProvider) setPrimary(artistMBID, dir string, imgData []byte) {
|
|
primaryPath := filepath.Join(dir, "primary.jpg")
|
|
_ = os.WriteFile(primaryPath, imgData, 0o644)
|
|
|
|
// Decode and generate thumbnails.
|
|
img, _, err := image.Decode(strings.NewReader(string(imgData)))
|
|
if err != nil {
|
|
// Try as bytes reader.
|
|
reader := strings.NewReader(string(imgData))
|
|
|
|
img, _, err = image.Decode(reader)
|
|
if err != nil {
|
|
p.logger.Debug("artist image: could not decode for thumbnails",
|
|
"mbid", artistMBID, "error", err)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
for _, tier := range artistImageTiers {
|
|
thumbPath := filepath.Join(dir, "primary"+tier.Suffix+".jpg")
|
|
p.generateThumbnail(img, thumbPath, tier.MaxSize, tier.Quality)
|
|
}
|
|
}
|
|
|
|
func (p *ArtistImageProvider) generateThumbnail(
|
|
src image.Image, path string, maxSize, quality int,
|
|
) {
|
|
bounds := src.Bounds()
|
|
w := bounds.Dx()
|
|
h := bounds.Dy()
|
|
|
|
if w <= maxSize && h <= maxSize {
|
|
// Image already small enough — just encode as JPEG.
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() { _ = f.Close() }()
|
|
|
|
_ = jpeg.Encode(f, src, &jpeg.Options{Quality: quality})
|
|
|
|
return
|
|
}
|
|
|
|
// Scale down maintaining aspect ratio.
|
|
var newW, newH int
|
|
if w > h {
|
|
newW = maxSize
|
|
newH = maxSize * h / w
|
|
} else {
|
|
newH = maxSize
|
|
newW = maxSize * w / h
|
|
}
|
|
|
|
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
|
|
draw.BiLinear.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil)
|
|
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() { _ = f.Close() }()
|
|
|
|
_ = jpeg.Encode(f, dst, &jpeg.Options{Quality: quality})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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("[]"), artistImageMissCacheTTL, 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("[]"), artistImageMissCacheTTL, 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: TheAudioDB
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// fetchAudioDB returns artist thumb URLs from TheAudioDB.
|
|
// Uses the free API key (2) for MBID-based lookups.
|
|
func (p *ArtistImageProvider) fetchAudioDB(artistMBID string) []string {
|
|
cacheKey := "audiodb:" + 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/artist-mb.php?i=%s", audioDBAPIBase, artistMBID)
|
|
|
|
body, err := p.fetchURL(url)
|
|
if err != nil {
|
|
p.cache.Set(cacheKey, []byte("[]"), artistImageMissCacheTTL, artistMBID, "artist")
|
|
|
|
return nil
|
|
}
|
|
|
|
var response struct {
|
|
Artists []struct {
|
|
Thumb *string `json:"strArtistThumb"`
|
|
Fanart *string `json:"strArtistFanart"`
|
|
Fanart2 *string `json:"strArtistFanart2"`
|
|
Fanart3 *string `json:"strArtistFanart3"`
|
|
} `json:"artists"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &response); err != nil || len(response.Artists) == 0 {
|
|
p.cache.Set(cacheKey, []byte("[]"), artistImageMissCacheTTL, artistMBID, "artist")
|
|
|
|
return nil
|
|
}
|
|
|
|
artist := response.Artists[0]
|
|
|
|
var urls []string
|
|
|
|
// Thumb is the primary portrait photo; fanart images are wider/background shots.
|
|
for _, u := range []*string{artist.Thumb, artist.Fanart, artist.Fanart2, artist.Fanart3} {
|
|
if u != nil && *u != "" {
|
|
urls = append(urls, *u)
|
|
}
|
|
}
|
|
|
|
data, _ := json.Marshal(urls)
|
|
p.cache.Set(cacheKey, data, artistImageCacheTTL, artistMBID, "artist")
|
|
|
|
return urls
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Source 2-4: MB rels + Wikidata + Wikipedia
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (p *ArtistImageProvider) fetchMBRels(
|
|
ctx context.Context, 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+aliases",
|
|
artistMBID,
|
|
)
|
|
|
|
if err := p.mbLimiter.Wait(ctx); err != nil {
|
|
return nil
|
|
}
|
|
|
|
body, err := p.fetchURL(url)
|
|
if err != nil {
|
|
// Cache the miss so we don't re-request on every build.
|
|
p.cache.Set(cacheKey, []byte("{}"), artistImageMissCacheTTL, artistMBID, "artist")
|
|
|
|
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
|
|
}
|
|
|
|
func (p *ArtistImageProvider) getWikidataQID(rels []mbRelation) string {
|
|
for _, rel := range rels {
|
|
if rel.Type == "wikidata" {
|
|
parts := strings.Split(rel.URL.Resource, "/")
|
|
|
|
return parts[len(parts)-1]
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (p *ArtistImageProvider) fetchWikidataP18(qid string) string {
|
|
cacheKey := "wikidata-p18:" + qid
|
|
|
|
if data, ok := p.cache.Get(cacheKey); ok {
|
|
return string(data)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (p *ArtistImageProvider) fetchWikipediaLeadImage(qid string) string {
|
|
cacheKey := "wikipedia-lead:" + qid
|
|
|
|
if data, ok := p.cache.Get(cacheKey); ok {
|
|
return string(data)
|
|
}
|
|
|
|
// Get the English Wikipedia article title from Wikidata sitelinks.
|
|
titleURL := fmt.Sprintf(
|
|
"%s?action=wbgetentities&ids=%s&props=sitelinks&sitefilter=enwiki&format=json",
|
|
wikidataAPIBase, qid,
|
|
)
|
|
|
|
titleBody, err := p.fetchURL(titleURL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
var sitelinks struct {
|
|
Entities map[string]struct {
|
|
Sitelinks map[string]struct {
|
|
Title string `json:"title"`
|
|
} `json:"sitelinks"`
|
|
} `json:"entities"`
|
|
}
|
|
|
|
if err := json.Unmarshal(titleBody, &sitelinks); err != nil {
|
|
return ""
|
|
}
|
|
|
|
entity, ok := sitelinks.Entities[qid]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
enwiki, ok := entity.Sitelinks["enwiki"]
|
|
if !ok || enwiki.Title == "" {
|
|
p.cache.Set(cacheKey, []byte(""), artistImageMissCacheTTL, "", "")
|
|
|
|
return ""
|
|
}
|
|
|
|
// Fetch the lead image from Wikipedia.
|
|
imgURL := fmt.Sprintf(
|
|
"%s?action=query&titles=%s&prop=pageimages&format=json&pithumbsize=%d",
|
|
wikipediaAPIBase,
|
|
strings.ReplaceAll(enwiki.Title, " ", "_"),
|
|
artistImageMaxSize,
|
|
)
|
|
|
|
imgBody, err := p.fetchURL(imgURL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
var wp struct {
|
|
Query struct {
|
|
Pages map[string]struct {
|
|
Thumbnail struct {
|
|
Source string `json:"source"`
|
|
} `json:"thumbnail"`
|
|
} `json:"pages"`
|
|
} `json:"query"`
|
|
}
|
|
|
|
if err := json.Unmarshal(imgBody, &wp); err != nil {
|
|
return ""
|
|
}
|
|
|
|
leadURL := ""
|
|
|
|
for _, page := range wp.Query.Pages {
|
|
if page.Thumbnail.Source != "" {
|
|
leadURL = page.Thumbnail.Source
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
p.cache.Set(cacheKey, []byte(leadURL), artistImageCacheTTL, "", "")
|
|
|
|
return leadURL
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Disk paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (p *ArtistImageProvider) artistDir(mbid string) string {
|
|
return ArtistImageDir(p.baseDir, mbid)
|
|
}
|
|
|
|
func (p *ArtistImageProvider) primaryPath(mbid string) string {
|
|
return filepath.Join(p.artistDir(mbid), "primary.jpg")
|
|
}
|
|
|
|
func (p *ArtistImageProvider) isMiss(mbid string) bool {
|
|
missPath := filepath.Join(p.artistDir(mbid), artistMissFile)
|
|
_, err := os.Stat(missPath)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
func (p *ArtistImageProvider) writeMiss(mbid string) {
|
|
dir := p.artistDir(mbid)
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
_ = os.WriteFile(filepath.Join(dir, artistMissFile), []byte{}, 0o644)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (p *ArtistImageProvider) fetchImageBytes(imageURL string) ([]byte, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), artistImageTimeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, 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(io.LimitReader(resp.Body, artistImageMaxBytes))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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, artistImageMaxSize, filename,
|
|
)
|
|
}
|
|
|
|
func readFileData(path string) string {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil || len(data) == 0 {
|
|
return ""
|
|
}
|
|
|
|
mime := "image/jpeg"
|
|
if len(data) > 1 && data[0] == 0x89 && data[1] == 0x50 {
|
|
mime = "image/png"
|
|
}
|
|
|
|
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
|
|
}
|