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) }