Replace per-artist sequential GetArtistMBID + GetArtistImageURL calls (2 Wails round-trips × N artists) with a single batch GetArtistImages(names[]) call that: 1. Resolves all names → MBIDs via AllArtistMBIDs() (one DB query) 2. Checks disk cache for each MBID via GetCachedImage (no network) 3. Returns map[name]→dataURL in one Wails bridge round-trip Only returns already-cached images from the disk cache populated by the index build. No network fetches triggered — artists whose images haven't been cached yet keep the initial letter fallback until the index build resolves them in the background. Result: all cached artist images appear simultaneously on first render instead of loading one-by-one over several seconds.
446 lines
10 KiB
Go
446 lines
10 KiB
Go
package explore
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5" //nolint:gosec // MD5 used for Wikimedia URL hashing, not security
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"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"
|
|
artistImageSize = 250
|
|
artistImageTimeout = 10 * time.Second
|
|
artistImageCacheTTL = 30 * 24 * time.Hour
|
|
artistImageDir = "artist-image-cache"
|
|
artistImageMaxBytes = 2 * 1024 * 1024 // 2 MB max per image
|
|
)
|
|
|
|
// ArtistImageProvider resolves artist MBIDs to images. It checks
|
|
// three sources in order:
|
|
// 1. Local disk cache (instant, from previous fetch)
|
|
// 2. MB url-rels → Wikimedia Commons thumb URL → fetch + cache
|
|
// 3. Wikidata P18 → Wikimedia Commons thumb URL → fetch + cache
|
|
//
|
|
// Returns base64 data URLs for display in <img src="data:...">.
|
|
type ArtistImageProvider struct {
|
|
db *database.DB
|
|
cache *Cache
|
|
mbLimiter *RateLimiter
|
|
client *http.Client
|
|
logger *slog.Logger
|
|
imageDir string
|
|
mu sync.Mutex // serializes disk writes
|
|
}
|
|
|
|
// NewArtistImageProvider creates a provider that resolves and caches
|
|
// artist images.
|
|
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, artistImageDir)
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
}
|
|
|
|
return &ArtistImageProvider{
|
|
db: db,
|
|
cache: cache,
|
|
mbLimiter: mbLimiter,
|
|
client: &http.Client{Timeout: artistImageTimeout},
|
|
logger: logger,
|
|
imageDir: dir,
|
|
}
|
|
}
|
|
|
|
// GetCachedImage returns a base64 data URL from the disk cache
|
|
// only — no network fetches. Returns "" if not cached.
|
|
func (p *ArtistImageProvider) GetCachedImage(artistMBID string) string {
|
|
if artistMBID == "" || p.imageDir == "" {
|
|
return ""
|
|
}
|
|
|
|
return p.readDiskCache(artistMBID)
|
|
}
|
|
|
|
// GetArtistImage returns a base64 data URL for the artist's photo.
|
|
// Checks disk cache first, then resolves via MB/Wikidata and fetches
|
|
// the image from Wikimedia Commons. Returns "" if no image.
|
|
func (p *ArtistImageProvider) GetArtistImage(artistMBID string) string {
|
|
if artistMBID == "" || p.imageDir == "" {
|
|
return ""
|
|
}
|
|
|
|
// Source 1: disk cache (instant).
|
|
if dataURL := p.readDiskCache(artistMBID); dataURL != "" {
|
|
return dataURL
|
|
}
|
|
|
|
// Check if we already know there's no image (cached miss marker).
|
|
if p.isDiskCacheMiss(artistMBID) {
|
|
return ""
|
|
}
|
|
|
|
// Source 2+3: resolve URL then fetch image.
|
|
imageURL := p.resolveURL(artistMBID)
|
|
if imageURL == "" {
|
|
p.writeDiskCache(artistMBID, nil) // miss marker
|
|
|
|
return ""
|
|
}
|
|
|
|
// Fetch the actual image bytes.
|
|
data, err := p.fetchImageBytes(imageURL)
|
|
if err != nil || len(data) == 0 {
|
|
p.writeDiskCache(artistMBID, nil)
|
|
|
|
return ""
|
|
}
|
|
|
|
p.writeDiskCache(artistMBID, data)
|
|
|
|
return toDataURL(data, artistMBID)
|
|
}
|
|
|
|
// resolveURL finds the Wikimedia Commons thumbnail URL for an
|
|
// artist via MB url-rels and Wikidata. The URL itself (not image
|
|
// bytes) is cached in explore_cache for 30 days.
|
|
func (p *ArtistImageProvider) resolveURL(artistMBID string) string {
|
|
cacheKey := "artist-image-url:" + artistMBID
|
|
|
|
if data, ok := p.cache.Get(cacheKey); ok {
|
|
return string(data)
|
|
}
|
|
|
|
rels := p.fetchMBRels(artistMBID)
|
|
if rels == nil {
|
|
p.cache.Set(cacheKey, []byte(""), artistImageCacheTTL, artistMBID, "artist")
|
|
|
|
return ""
|
|
}
|
|
|
|
imageURL := p.fromDirectImageRel(rels)
|
|
|
|
if imageURL == "" {
|
|
imageURL = p.fromWikidataRel(rels)
|
|
}
|
|
|
|
p.cache.Set(cacheKey, []byte(imageURL), artistImageCacheTTL, artistMBID, "artist")
|
|
|
|
return imageURL
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MB url-rels
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type mbRelation struct {
|
|
Type string `json:"type"`
|
|
URL struct {
|
|
Resource string `json:"resource"`
|
|
} `json:"url"`
|
|
}
|
|
|
|
func (p *ArtistImageProvider) fetchMBRels(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(context.Background()); err != nil {
|
|
return nil
|
|
}
|
|
|
|
body, err := p.fetchURL(url)
|
|
if err != nil {
|
|
p.logger.Debug("artist image: MB rels fetch failed",
|
|
"mbid", artistMBID,
|
|
"error", err,
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Source 1: direct image relation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (p *ArtistImageProvider) fromDirectImageRel(rels []mbRelation) string {
|
|
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:]
|
|
|
|
return wikimediaThumbURL(filename)
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Source 2: Wikidata P18
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (p *ArtistImageProvider) fromWikidataRel(rels []mbRelation) string {
|
|
qid := ""
|
|
|
|
for _, rel := range rels {
|
|
if rel.Type == "wikidata" {
|
|
parts := strings.Split(rel.URL.Resource, "/")
|
|
qid = parts[len(parts)-1]
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if qid == "" {
|
|
return ""
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Disk cache
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (p *ArtistImageProvider) diskCachePath(mbid string) string {
|
|
return filepath.Join(p.imageDir, mbid+".jpg")
|
|
}
|
|
|
|
func (p *ArtistImageProvider) readDiskCache(mbid string) string {
|
|
data, err := os.ReadFile(p.diskCachePath(mbid))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
return "" // miss marker
|
|
}
|
|
|
|
return toDataURL(data, mbid)
|
|
}
|
|
|
|
func (p *ArtistImageProvider) isDiskCacheMiss(mbid string) bool {
|
|
info, err := os.Stat(p.diskCachePath(mbid))
|
|
|
|
return err == nil && info.Size() == 0
|
|
}
|
|
|
|
func (p *ArtistImageProvider) writeDiskCache(mbid string, data []byte) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
if data == nil {
|
|
data = []byte{} // miss marker
|
|
}
|
|
|
|
_ = os.WriteFile(p.diskCachePath(mbid), data, 0o644)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Image fetching
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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, artistImageSize, filename,
|
|
)
|
|
}
|
|
|
|
// GetAliases returns the artist's aliases as a space-separated
|
|
// string, extracted from the cached MB rels response. Returns ""
|
|
// if no aliases are cached.
|
|
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, " ")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func toDataURL(data []byte, _ string) string {
|
|
mime := "image/jpeg"
|
|
if len(data) > 1 && data[0] == 0x89 && data[1] == 0x50 {
|
|
mime = "image/png"
|
|
}
|
|
|
|
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
|
|
}
|