feat: cover art proxy with disk cache for instant thumbnail loading

Add CoverArtProxy that fetches cover art from CAA, caches the image
bytes on disk (~/.local/share/yellowjacket/cover-art-cache/), and
returns base64 data URLs via the GetThumbnail Wails binding.

First load: fetches from CAA (rate-limited), caches to disk.
Subsequent loads: instant from disk cache, no network.
404s: cached as empty files to avoid re-fetching.

Frontend explore-view loads thumbnails async via GetThumbnail()
calls that fire during render. Cached thumbnails appear as data
URLs directly in img src, bypassing the browser's HTTP stack.
Uncached thumbnails fall back to the CAA URL while the proxy
fetches in the background, then re-render with the cached version.

Also stores caa_id and caa_release_mbid in the search index's
extra_json for future direct Internet Archive URL construction.
This commit is contained in:
2026-03-25 14:09:30 -04:00
parent 1141febf29
commit 49a26c6163
6 changed files with 275 additions and 121 deletions
+158
View File
@@ -0,0 +1,158 @@
package explore
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"yellowjacket/backend/system"
)
// ErrCoverArt is returned when the Cover Art Archive responds
// with a non-200 status code.
var ErrCoverArt = errors.New("cover art fetch failed")
const (
// thumbnailDir is the subdirectory under the user data dir
// where cached cover art thumbnails are stored.
thumbnailDir = "cover-art-cache"
// thumbnailTimeout is the HTTP timeout for fetching a thumbnail.
thumbnailTimeout = 10 * time.Second
// thumbnailMaxSize is the maximum image size to cache (2 MB).
thumbnailMaxSize = 2 * 1024 * 1024
)
// CoverArtProxy fetches and caches cover art thumbnails locally.
// Wails-bound methods return base64-encoded image data for display
// in <img src="data:..."> tags, eliminating browser HTTP requests
// to the slow Cover Art Archive.
type CoverArtProxy struct {
cacheDir string
client *http.Client
limiter *RateLimiter
mu sync.Mutex // serializes disk writes
}
// NewCoverArtProxy creates a proxy that caches thumbnails under
// the user data directory.
func NewCoverArtProxy(limiter *RateLimiter) *CoverArtProxy {
dir := ""
dataDir, err := system.GetUserDataDirPath()
if err == nil {
dir = filepath.Join(dataDir, thumbnailDir)
_ = os.MkdirAll(dir, 0o755)
}
return &CoverArtProxy{
cacheDir: dir,
client: &http.Client{Timeout: thumbnailTimeout},
limiter: limiter,
}
}
// GetThumbnail returns a base64-encoded JPEG data URL for the given
// release group MBID. Returns from local cache if available,
// otherwise fetches from the Cover Art Archive. Returns "" on
// failure (no cover art, network error, etc.).
func (p *CoverArtProxy) GetThumbnail(releaseGroupMBID string) string {
if p.cacheDir == "" || releaseGroupMBID == "" {
return ""
}
// Check disk cache.
cached := p.readCache(releaseGroupMBID)
if cached != "" {
return cached
}
// Fetch from CAA.
url := CoverArtGroupURL(releaseGroupMBID)
data, err := p.fetch(url)
if err != nil || len(data) == 0 {
// Cache the miss as an empty file so we don't retry.
p.writeCache(releaseGroupMBID, nil)
return ""
}
p.writeCache(releaseGroupMBID, data)
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data)
}
func (p *CoverArtProxy) fetch(url string) ([]byte, error) {
// Rate-limit CAA requests.
ctx := context.Background()
if err := p.limiter.Wait(ctx); err != nil {
return nil, err
}
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: %d", ErrCoverArt, resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, thumbnailMaxSize))
if err != nil {
return nil, err
}
return data, nil
}
func (p *CoverArtProxy) cachePath(mbid string) string {
return filepath.Join(p.cacheDir, mbid+".jpg")
}
func (p *CoverArtProxy) readCache(mbid string) string {
path := p.cachePath(mbid)
data, err := os.ReadFile(path)
if err != nil {
return ""
}
// Empty file = cached miss.
if len(data) == 0 {
return ""
}
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data)
}
func (p *CoverArtProxy) writeCache(mbid string, data []byte) {
p.mu.Lock()
defer p.mu.Unlock()
path := p.cachePath(mbid)
if data == nil {
data = []byte{} // empty file = miss marker
}
_ = os.WriteFile(path, data, 0o644)
}
+23 -12
View File
@@ -18,12 +18,13 @@ import (
// response cache. Its exported methods form the binding surface
// that the frontend calls via generated TypeScript stubs.
type Service struct {
mb *MusicBrainzClient
lb *ListenBrainzClient
cache *Cache
index *SearchIndex
logger *slog.Logger
ctx context.Context
mb *MusicBrainzClient
lb *ListenBrainzClient
cache *Cache
index *SearchIndex
artProxy *CoverArtProxy
logger *slog.Logger
ctx context.Context
}
// NewExploreService creates a Service backed by the given
@@ -35,16 +36,18 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
mb := NewMusicBrainzClient(cache, logger.WithGroup("musicbrainz"))
lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz"))
index := NewSearchIndex(db, lb, logger.WithGroup("search-index"))
artProxy := NewCoverArtProxy(limiter)
logger.Info("explore service created")
return &Service{
mb: mb,
lb: lb,
cache: cache,
index: index,
logger: logger,
ctx: context.Background(),
mb: mb,
lb: lb,
cache: cache,
index: index,
artProxy: artProxy,
logger: logger,
ctx: context.Background(),
}
}
@@ -152,6 +155,14 @@ func (e *Service) CoverArtGroupURL(releaseGroupMBID string) string {
return CoverArtGroupURL(releaseGroupMBID)
}
// GetThumbnail returns a base64 data URL for the release group's
// cover art. Cached locally on disk — first call fetches from
// the Cover Art Archive, subsequent calls are instant.
// Returns "" if no cover art is available.
func (e *Service) GetThumbnail(releaseGroupMBID string) string {
return e.artProxy.GetThumbnail(releaseGroupMBID)
}
// Search concurrently queries MusicBrainz for artists, release
// groups, and recordings matching the query, then boosts results
// using ListenBrainz popularity data. The final score blends
+15 -4
View File
@@ -971,9 +971,11 @@ func (si *SearchIndex) fetchTopReleaseGroups(
}
var raw []struct {
ReleaseGroupMBID string `json:"release_group_mbid"`
TotalListenCount int `json:"total_listen_count"`
ReleaseGroup struct {
ReleaseGroupMBID string `json:"release_group_mbid"`
TotalListenCount int `json:"total_listen_count"`
CAAId *int64 `json:"caa_id"`
CAAReleaseGroupMBID string `json:"caa_release_mbid"`
ReleaseGroup struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"release_group"`
@@ -1009,7 +1011,16 @@ func (si *SearchIndex) fetchTopReleaseGroups(
artistMBID = r.Artist.Artists[0].ArtistMBID
}
extra, _ := json.Marshal(map[string]string{"type": r.ReleaseGroup.Type})
extraMap := map[string]any{"type": r.ReleaseGroup.Type}
if r.CAAId != nil {
extraMap["caaId"] = *r.CAAId
}
if r.CAAReleaseGroupMBID != "" {
extraMap["caaReleaseMbid"] = r.CAAReleaseGroupMBID
}
extra, _ := json.Marshal(extraMap)
results = append(results, SearchIndexResult{
EntityType: "release_group",