From 963269b753cb251d3614fa0593d31d353d9b6202 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Wed, 25 Mar 2026 22:28:52 -0400 Subject: [PATCH] feat: artist image disk cache + fix top results artist photos Two changes: 1. Artist image disk cache: ArtistImageProvider now fetches the actual image bytes from Wikimedia Commons and caches them on disk (~/.local/share/yellowjacket/artist-image-cache/{mbid}.jpg). Returns base64 data URLs, same pattern as CoverArtProxy. First lookup: resolve URL via MB/Wikidata + fetch image (~2s). Subsequent: instant from disk cache. 404s cached as empty files to avoid re-fetching. 2. Top results artist photos: the Top Results section now shows artist images from the artistImageCache, same as the Artists section. Also shows englishName in the top card display name. --- backend/explore/artistimage.go | 204 +++++++++++++----- backend/explore/explore.go | 14 +- .../components/explore-view/explore-view.ts | 7 +- 3 files changed, 169 insertions(+), 56 deletions(-) diff --git a/backend/explore/artistimage.go b/backend/explore/artistimage.go index d440205..a9d4cc6 100644 --- a/backend/explore/artistimage.go +++ b/backend/explore/artistimage.go @@ -3,80 +3,128 @@ 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 is the base URL for Wikimedia Commons - // thumbnail generation. - wikimediaThumbBase = "https://upload.wikimedia.org/wikipedia/commons/thumb" - - // wikidataAPIBase is the base URL for the Wikidata API. - wikidataAPIBase = "https://www.wikidata.org/w/api.php" - - // artistImageSize is the default thumbnail width in pixels. - artistImageSize = 250 - - // artistImageTimeout is the HTTP timeout for image URL lookups. - artistImageTimeout = 10 * time.Second - - // artistImageCacheTTL is how long resolved image URLs are cached. + 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 image URLs. It -// fetches the artist's MB url-rels (once, cached 30 days), extracts -// image sources from them, and returns a Wikimedia Commons thumbnail -// URL. Designed to be extended with additional sources (fanart.tv, -// etc.) by adding to the resolve chain. +// 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 . 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 artist -// images via MusicBrainz relationships and Wikidata. +// 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, } } -// GetArtistImageURL returns a Wikimedia Commons thumbnail URL for -// the given artist MBID, or "" if no image is available. Results -// are cached for 30 days. -func (p *ArtistImageProvider) GetArtistImageURL(artistMBID string) string { - if 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 "" } - // Check resolved URL cache first. - cacheKey := "artist-image:" + artistMBID + // 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) } - // Fetch MB url-rels (cached separately, shared with other uses). rels := p.fetchMBRels(artistMBID) if rels == nil { p.cache.Set(cacheKey, []byte(""), artistImageCacheTTL, artistMBID, "artist") @@ -84,28 +132,19 @@ func (p *ArtistImageProvider) GetArtistImageURL(artistMBID string) string { return "" } - // Try each source in priority order. imageURL := p.fromDirectImageRel(rels) if imageURL == "" { imageURL = p.fromWikidataRel(rels) } - // Cache the result (even "" = no image). p.cache.Set(cacheKey, []byte(imageURL), artistImageCacheTTL, artistMBID, "artist") - if imageURL != "" { - p.logger.Debug("artist image resolved", - "mbid", artistMBID, - "url", imageURL, - ) - } - return imageURL } // --------------------------------------------------------------------------- -// MB url-rels fetching (shared by all sources) +// MB url-rels // --------------------------------------------------------------------------- type mbRelation struct { @@ -133,7 +172,6 @@ func (p *ArtistImageProvider) fetchMBRels(artistMBID string) []mbRelation { artistMBID, ) - // Rate-limit the MB API call. if err := p.mbLimiter.Wait(context.Background()); err != nil { return nil } @@ -162,7 +200,7 @@ func (p *ArtistImageProvider) fetchMBRels(artistMBID string) []mbRelation { } // --------------------------------------------------------------------------- -// Source 1: direct image relation (Commons wiki page link) +// Source 1: direct image relation // --------------------------------------------------------------------------- func (p *ArtistImageProvider) fromDirectImageRel(rels []mbRelation) string { @@ -173,7 +211,6 @@ func (p *ArtistImageProvider) fromDirectImageRel(rels []mbRelation) string { resource := rel.URL.Resource - // "https://commons.wikimedia.org/wiki/File:Name.jpg" if idx := strings.LastIndex(resource, "File:"); idx >= 0 { filename := resource[idx+5:] @@ -185,11 +222,10 @@ func (p *ArtistImageProvider) fromDirectImageRel(rels []mbRelation) string { } // --------------------------------------------------------------------------- -// Source 2: Wikidata P18 property +// Source 2: Wikidata P18 // --------------------------------------------------------------------------- func (p *ArtistImageProvider) fromWikidataRel(rels []mbRelation) string { - // Find the wikidata Q-ID. qid := "" for _, rel := range rels { @@ -205,14 +241,12 @@ func (p *ArtistImageProvider) fromWikidataRel(rels []mbRelation) string { return "" } - // Check cache for this Wikidata entity. cacheKey := "wikidata-p18:" + qid if data, ok := p.cache.Get(cacheKey); ok { return string(data) } - // Fetch P18 from Wikidata. url := fmt.Sprintf( "%s?action=wbgetclaims&entity=%s&property=P18&format=json", wikidataAPIBase, qid, @@ -247,12 +281,77 @@ func (p *ArtistImageProvider) fromWikidataRel(rels []mbRelation) string { 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 // --------------------------------------------------------------------------- -// wikimediaThumbURL constructs a Wikimedia Commons thumbnail URL -// from a filename using the MD5 directory bucketing scheme. func wikimediaThumbURL(filename string) string { if filename == "" { return "" @@ -293,3 +392,12 @@ func (p *ArtistImageProvider) fetchURL(url string) ([]byte, error) { 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) +} diff --git a/backend/explore/explore.go b/backend/explore/explore.go index 4924b3c..5008a29 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -38,7 +38,9 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service { lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz")) index := NewSearchIndex(db, lb, logger.WithGroup("search-index")) artProxy := NewCoverArtProxy(db, limiter) - artistImg := NewArtistImageProvider(cache, NewRateLimiter(), logger.WithGroup("artist-image")) + artistImg := NewArtistImageProvider( + db, cache, NewRateLimiter(), logger.WithGroup("artist-image"), + ) logger.Info("explore service created") @@ -188,12 +190,12 @@ func (e *Service) GetThumbnails(requests []ThumbnailRequest) map[string]string { return result } -// GetArtistImageURL returns a Wikimedia Commons thumbnail URL for -// the given artist MBID. Resolved via MB url-rels → Wikidata P18 -// → Commons thumb URL. Cached for 30 days. Returns "" if no -// image is available. +// GetArtistImageURL returns a base64 data URL for the artist's +// photo. Cached on disk — first call resolves via MB/Wikidata and +// fetches from Wikimedia Commons, subsequent calls are instant. +// Returns "" if no image is available. func (e *Service) GetArtistImageURL(artistMBID string) string { - return e.artistImg.GetArtistImageURL(artistMBID) + return e.artistImg.GetArtistImage(artistMBID) } // Search concurrently queries MusicBrainz for artists, release diff --git a/frontend/src/components/explore-view/explore-view.ts b/frontend/src/components/explore-view/explore-view.ts index 349fa42..f00b05e 100644 --- a/frontend/src/components/explore-view/explore-view.ts +++ b/frontend/src/components/explore-view/explore-view.ts @@ -866,6 +866,7 @@ export class ExploreView extends LitElement { if (item.type === 'artist' && item.artist) { const a = item.artist; const hue = nameToHue(a.name); + const imgURL = this.artistImageCache.get(a.mbid); return html`
- ${(a.englishName || a.name).charAt(0).toUpperCase()} + ${imgURL + ? html`${a.englishName || a.name}` + : (a.englishName || a.name).charAt(0).toUpperCase()}
-
${a.name}
+
${a.englishName || a.name}
Artist${a.country ? ` · ${a.country}` : ''}