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.
This commit is contained in:
+156
-48
@@ -3,80 +3,128 @@ package explore
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/md5" //nolint:gosec // MD5 used for Wikimedia URL hashing, not security
|
"crypto/md5" //nolint:gosec // MD5 used for Wikimedia URL hashing, not security
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"yellowjacket/backend/database"
|
||||||
|
"yellowjacket/backend/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrArtistImage is returned when an artist image HTTP fetch fails.
|
// ErrArtistImage is returned when an artist image HTTP fetch fails.
|
||||||
var ErrArtistImage = errors.New("artist image fetch failed")
|
var ErrArtistImage = errors.New("artist image fetch failed")
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// wikimediaThumbBase is the base URL for Wikimedia Commons
|
wikimediaThumbBase = "https://upload.wikimedia.org/wikipedia/commons/thumb"
|
||||||
// thumbnail generation.
|
wikidataAPIBase = "https://www.wikidata.org/w/api.php"
|
||||||
wikimediaThumbBase = "https://upload.wikimedia.org/wikipedia/commons/thumb"
|
artistImageSize = 250
|
||||||
|
artistImageTimeout = 10 * time.Second
|
||||||
// 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.
|
|
||||||
artistImageCacheTTL = 30 * 24 * time.Hour
|
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
|
// ArtistImageProvider resolves artist MBIDs to images. It checks
|
||||||
// fetches the artist's MB url-rels (once, cached 30 days), extracts
|
// three sources in order:
|
||||||
// image sources from them, and returns a Wikimedia Commons thumbnail
|
// 1. Local disk cache (instant, from previous fetch)
|
||||||
// URL. Designed to be extended with additional sources (fanart.tv,
|
// 2. MB url-rels → Wikimedia Commons thumb URL → fetch + cache
|
||||||
// etc.) by adding to the resolve chain.
|
// 3. Wikidata P18 → Wikimedia Commons thumb URL → fetch + cache
|
||||||
|
//
|
||||||
|
// Returns base64 data URLs for display in <img src="data:...">.
|
||||||
type ArtistImageProvider struct {
|
type ArtistImageProvider struct {
|
||||||
|
db *database.DB
|
||||||
cache *Cache
|
cache *Cache
|
||||||
mbLimiter *RateLimiter
|
mbLimiter *RateLimiter
|
||||||
client *http.Client
|
client *http.Client
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
imageDir string
|
||||||
|
mu sync.Mutex // serializes disk writes
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewArtistImageProvider creates a provider that resolves artist
|
// NewArtistImageProvider creates a provider that resolves and caches
|
||||||
// images via MusicBrainz relationships and Wikidata.
|
// artist images.
|
||||||
func NewArtistImageProvider(
|
func NewArtistImageProvider(
|
||||||
|
db *database.DB,
|
||||||
cache *Cache,
|
cache *Cache,
|
||||||
mbLimiter *RateLimiter,
|
mbLimiter *RateLimiter,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
) *ArtistImageProvider {
|
) *ArtistImageProvider {
|
||||||
|
dir := ""
|
||||||
|
|
||||||
|
dataDir, err := system.GetUserDataDirPath()
|
||||||
|
if err == nil {
|
||||||
|
dir = filepath.Join(dataDir, artistImageDir)
|
||||||
|
_ = os.MkdirAll(dir, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
return &ArtistImageProvider{
|
return &ArtistImageProvider{
|
||||||
|
db: db,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
mbLimiter: mbLimiter,
|
mbLimiter: mbLimiter,
|
||||||
client: &http.Client{Timeout: artistImageTimeout},
|
client: &http.Client{Timeout: artistImageTimeout},
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
imageDir: dir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetArtistImageURL returns a Wikimedia Commons thumbnail URL for
|
// GetArtistImage returns a base64 data URL for the artist's photo.
|
||||||
// the given artist MBID, or "" if no image is available. Results
|
// Checks disk cache first, then resolves via MB/Wikidata and fetches
|
||||||
// are cached for 30 days.
|
// the image from Wikimedia Commons. Returns "" if no image.
|
||||||
func (p *ArtistImageProvider) GetArtistImageURL(artistMBID string) string {
|
func (p *ArtistImageProvider) GetArtistImage(artistMBID string) string {
|
||||||
if artistMBID == "" {
|
if artistMBID == "" || p.imageDir == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check resolved URL cache first.
|
// Source 1: disk cache (instant).
|
||||||
cacheKey := "artist-image:" + artistMBID
|
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 {
|
if data, ok := p.cache.Get(cacheKey); ok {
|
||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch MB url-rels (cached separately, shared with other uses).
|
|
||||||
rels := p.fetchMBRels(artistMBID)
|
rels := p.fetchMBRels(artistMBID)
|
||||||
if rels == nil {
|
if rels == nil {
|
||||||
p.cache.Set(cacheKey, []byte(""), artistImageCacheTTL, artistMBID, "artist")
|
p.cache.Set(cacheKey, []byte(""), artistImageCacheTTL, artistMBID, "artist")
|
||||||
@@ -84,28 +132,19 @@ func (p *ArtistImageProvider) GetArtistImageURL(artistMBID string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try each source in priority order.
|
|
||||||
imageURL := p.fromDirectImageRel(rels)
|
imageURL := p.fromDirectImageRel(rels)
|
||||||
|
|
||||||
if imageURL == "" {
|
if imageURL == "" {
|
||||||
imageURL = p.fromWikidataRel(rels)
|
imageURL = p.fromWikidataRel(rels)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result (even "" = no image).
|
|
||||||
p.cache.Set(cacheKey, []byte(imageURL), artistImageCacheTTL, artistMBID, "artist")
|
p.cache.Set(cacheKey, []byte(imageURL), artistImageCacheTTL, artistMBID, "artist")
|
||||||
|
|
||||||
if imageURL != "" {
|
|
||||||
p.logger.Debug("artist image resolved",
|
|
||||||
"mbid", artistMBID,
|
|
||||||
"url", imageURL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return imageURL
|
return imageURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// MB url-rels fetching (shared by all sources)
|
// MB url-rels
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type mbRelation struct {
|
type mbRelation struct {
|
||||||
@@ -133,7 +172,6 @@ func (p *ArtistImageProvider) fetchMBRels(artistMBID string) []mbRelation {
|
|||||||
artistMBID,
|
artistMBID,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rate-limit the MB API call.
|
|
||||||
if err := p.mbLimiter.Wait(context.Background()); err != nil {
|
if err := p.mbLimiter.Wait(context.Background()); err != nil {
|
||||||
return 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 {
|
func (p *ArtistImageProvider) fromDirectImageRel(rels []mbRelation) string {
|
||||||
@@ -173,7 +211,6 @@ func (p *ArtistImageProvider) fromDirectImageRel(rels []mbRelation) string {
|
|||||||
|
|
||||||
resource := rel.URL.Resource
|
resource := rel.URL.Resource
|
||||||
|
|
||||||
// "https://commons.wikimedia.org/wiki/File:Name.jpg"
|
|
||||||
if idx := strings.LastIndex(resource, "File:"); idx >= 0 {
|
if idx := strings.LastIndex(resource, "File:"); idx >= 0 {
|
||||||
filename := resource[idx+5:]
|
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 {
|
func (p *ArtistImageProvider) fromWikidataRel(rels []mbRelation) string {
|
||||||
// Find the wikidata Q-ID.
|
|
||||||
qid := ""
|
qid := ""
|
||||||
|
|
||||||
for _, rel := range rels {
|
for _, rel := range rels {
|
||||||
@@ -205,14 +241,12 @@ func (p *ArtistImageProvider) fromWikidataRel(rels []mbRelation) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache for this Wikidata entity.
|
|
||||||
cacheKey := "wikidata-p18:" + qid
|
cacheKey := "wikidata-p18:" + qid
|
||||||
|
|
||||||
if data, ok := p.cache.Get(cacheKey); ok {
|
if data, ok := p.cache.Get(cacheKey); ok {
|
||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch P18 from Wikidata.
|
|
||||||
url := fmt.Sprintf(
|
url := fmt.Sprintf(
|
||||||
"%s?action=wbgetclaims&entity=%s&property=P18&format=json",
|
"%s?action=wbgetclaims&entity=%s&property=P18&format=json",
|
||||||
wikidataAPIBase, qid,
|
wikidataAPIBase, qid,
|
||||||
@@ -247,12 +281,77 @@ func (p *ArtistImageProvider) fromWikidataRel(rels []mbRelation) string {
|
|||||||
return thumbURL
|
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
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// wikimediaThumbURL constructs a Wikimedia Commons thumbnail URL
|
|
||||||
// from a filename using the MD5 directory bucketing scheme.
|
|
||||||
func wikimediaThumbURL(filename string) string {
|
func wikimediaThumbURL(filename string) string {
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -293,3 +392,12 @@ func (p *ArtistImageProvider) fetchURL(url string) ([]byte, error) {
|
|||||||
|
|
||||||
return io.ReadAll(resp.Body)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
|
|||||||
lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz"))
|
lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz"))
|
||||||
index := NewSearchIndex(db, lb, logger.WithGroup("search-index"))
|
index := NewSearchIndex(db, lb, logger.WithGroup("search-index"))
|
||||||
artProxy := NewCoverArtProxy(db, limiter)
|
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")
|
logger.Info("explore service created")
|
||||||
|
|
||||||
@@ -188,12 +190,12 @@ func (e *Service) GetThumbnails(requests []ThumbnailRequest) map[string]string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetArtistImageURL returns a Wikimedia Commons thumbnail URL for
|
// GetArtistImageURL returns a base64 data URL for the artist's
|
||||||
// the given artist MBID. Resolved via MB url-rels → Wikidata P18
|
// photo. Cached on disk — first call resolves via MB/Wikidata and
|
||||||
// → Commons thumb URL. Cached for 30 days. Returns "" if no
|
// fetches from Wikimedia Commons, subsequent calls are instant.
|
||||||
// image is available.
|
// Returns "" if no image is available.
|
||||||
func (e *Service) GetArtistImageURL(artistMBID string) string {
|
func (e *Service) GetArtistImageURL(artistMBID string) string {
|
||||||
return e.artistImg.GetArtistImageURL(artistMBID)
|
return e.artistImg.GetArtistImage(artistMBID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search concurrently queries MusicBrainz for artists, release
|
// Search concurrently queries MusicBrainz for artists, release
|
||||||
|
|||||||
@@ -866,6 +866,7 @@ export class ExploreView extends LitElement {
|
|||||||
if (item.type === 'artist' && item.artist) {
|
if (item.type === 'artist' && item.artist) {
|
||||||
const a = item.artist;
|
const a = item.artist;
|
||||||
const hue = nameToHue(a.name);
|
const hue = nameToHue(a.name);
|
||||||
|
const imgURL = this.artistImageCache.get(a.mbid);
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="top-card"
|
class="top-card"
|
||||||
@@ -883,10 +884,12 @@ export class ExploreView extends LitElement {
|
|||||||
class="artist-avatar"
|
class="artist-avatar"
|
||||||
style="background: hsl(${hue}, 45%, 35%)"
|
style="background: hsl(${hue}, 45%, 35%)"
|
||||||
>
|
>
|
||||||
${(a.englishName || a.name).charAt(0).toUpperCase()}
|
${imgURL
|
||||||
|
? html`<img src="${imgURL}" alt="${a.englishName || a.name}" />`
|
||||||
|
: (a.englishName || a.name).charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div class="top-card-info">
|
<div class="top-card-info">
|
||||||
<div class="top-card-name">${a.name}</div>
|
<div class="top-card-name">${a.englishName || a.name}</div>
|
||||||
<div class="top-card-meta">Artist${a.country ? ` · ${a.country}` : ''}</div>
|
<div class="top-card-meta">Artist${a.country ? ` · ${a.country}` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user