diff --git a/backend/explore/coverartproxy.go b/backend/explore/coverartproxy.go
index 9a46459..9b0c7dc 100644
--- a/backend/explore/coverartproxy.go
+++ b/backend/explore/coverartproxy.go
@@ -9,9 +9,11 @@ import (
"net/http"
"os"
"path/filepath"
+ "strings"
"sync"
"time"
+ "yellowjacket/backend/database"
"yellowjacket/backend/system"
)
@@ -32,19 +34,24 @@ const (
)
// CoverArtProxy fetches and caches cover art thumbnails locally.
-// Wails-bound methods return base64-encoded image data for display
-// in
tags, eliminating browser HTTP requests
-// to the slow Cover Art Archive.
+// It checks three sources in order:
+// 1. Local library cover art (instant, matched by album+artist name)
+// 2. Disk cache from a previous CAA fetch (instant)
+// 3. Cover Art Archive network fetch (slow, cached to disk)
type CoverArtProxy struct {
+ db *database.DB
cacheDir string
client *http.Client
limiter *RateLimiter
+
mu sync.Mutex // serializes disk writes
+ libOnce sync.Once
+ libIndex map[string]string // "album\x00artist" → cover art file path
}
-// NewCoverArtProxy creates a proxy that caches thumbnails under
-// the user data directory.
-func NewCoverArtProxy(limiter *RateLimiter) *CoverArtProxy {
+// NewCoverArtProxy creates a proxy that checks the local library
+// first and caches CAA thumbnails under the user data directory.
+func NewCoverArtProxy(db *database.DB, limiter *RateLimiter) *CoverArtProxy {
dir := ""
dataDir, err := system.GetUserDataDirPath()
@@ -54,6 +61,7 @@ func NewCoverArtProxy(limiter *RateLimiter) *CoverArtProxy {
}
return &CoverArtProxy{
+ db: db,
cacheDir: dir,
client: &http.Client{Timeout: thumbnailTimeout},
limiter: limiter,
@@ -61,26 +69,32 @@ func NewCoverArtProxy(limiter *RateLimiter) *CoverArtProxy {
}
// 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 {
+// release group. Checks local library art first (by name match),
+// then disk cache, then fetches from CAA. Returns "" on failure.
+func (p *CoverArtProxy) GetThumbnail(
+ releaseGroupMBID, albumName, artistName string,
+) string {
+ // Source 1: local library cover art (instant).
+ if albumName != "" {
+ if dataURL := p.libraryArt(albumName, artistName); dataURL != "" {
+ return dataURL
+ }
+ }
+
if p.cacheDir == "" || releaseGroupMBID == "" {
return ""
}
- // Check disk cache.
- cached := p.readCache(releaseGroupMBID)
- if cached != "" {
+ // Source 2: disk cache from previous CAA fetch (instant).
+ if cached := p.readCache(releaseGroupMBID); cached != "" {
return cached
}
- // Fetch from CAA.
+ // Source 3: fetch from Cover Art Archive (slow, cached to disk).
url := CoverArtGroupURL(releaseGroupMBID)
data, cacheable, err := p.fetch(url)
if err != nil || len(data) == 0 {
- // Only cache permanent misses (404), not transient errors.
if cacheable {
p.writeCache(releaseGroupMBID, nil)
}
@@ -93,8 +107,76 @@ func (p *CoverArtProxy) GetThumbnail(releaseGroupMBID string) string {
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data)
}
+// ---------------------------------------------------------------------------
+// Source 1: local library art
+// ---------------------------------------------------------------------------
+
+// libraryArt returns a base64 data URL for the album if it exists
+// in the local music library. Matched by lowercased album name +
+// artist name.
+func (p *CoverArtProxy) libraryArt(albumName, artistName string) string {
+ p.libOnce.Do(p.buildLibraryIndex)
+
+ key := libraryArtKey(albumName, artistName)
+
+ path, ok := p.libIndex[key]
+ if !ok || path == "" {
+ return ""
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil || len(data) == 0 {
+ return ""
+ }
+
+ mime := "image/jpeg"
+ if strings.HasSuffix(strings.ToLower(path), ".png") {
+ mime = "image/png"
+ }
+
+ return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
+}
+
+func (p *CoverArtProxy) buildLibraryIndex() {
+ p.libIndex = make(map[string]string)
+
+ if p.db == nil {
+ return
+ }
+
+ rows, err := p.db.QueryContext(`
+ SELECT rg.name, a.name, ca.file_path
+ FROM release_groups rg
+ JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
+ JOIN artist_credit_artist aca ON aca.credit_id = ac.id
+ JOIN artists a ON a.id = aca.artist_id
+ LEFT JOIN cover_art ca ON ca.id = rg.cover_art_id
+ WHERE ca.file_path IS NOT NULL AND ca.file_path != ''
+ `)
+ if err != nil {
+ return
+ }
+
+ defer func() { _ = rows.Close() }()
+
+ for rows.Next() {
+ var album, artist, path string
+ if err := rows.Scan(&album, &artist, &path); err == nil {
+ key := libraryArtKey(album, artist)
+ p.libIndex[key] = path
+ }
+ }
+}
+
+func libraryArtKey(album, artist string) string {
+ return strings.ToLower(album) + "\x00" + strings.ToLower(artist)
+}
+
+// ---------------------------------------------------------------------------
+// Source 2+3: CAA disk cache and network fetch
+// ---------------------------------------------------------------------------
+
func (p *CoverArtProxy) fetch(url string) ([]byte, bool, error) {
- // Rate-limit CAA requests.
ctx := context.Background()
if err := p.limiter.Wait(ctx); err != nil {
return nil, false, err
@@ -114,12 +196,10 @@ func (p *CoverArtProxy) fetch(url string) ([]byte, bool, error) {
defer func() { _ = resp.Body.Close() }()
- // 404 = no cover art exists — permanent, safe to cache as miss.
if resp.StatusCode == http.StatusNotFound {
return nil, true, nil
}
- // Other non-200 = transient error — don't cache.
if resp.StatusCode != http.StatusOK {
return nil, false, fmt.Errorf("%w: %d", ErrCoverArt, resp.StatusCode)
}
@@ -144,7 +224,6 @@ func (p *CoverArtProxy) readCache(mbid string) string {
return ""
}
- // Empty file = cached miss.
if len(data) == 0 {
return ""
}
@@ -159,7 +238,7 @@ func (p *CoverArtProxy) writeCache(mbid string, data []byte) {
path := p.cachePath(mbid)
if data == nil {
- data = []byte{} // empty file = miss marker
+ data = []byte{}
}
_ = os.WriteFile(path, data, 0o644)
diff --git a/backend/explore/explore.go b/backend/explore/explore.go
index 55a09bf..46d6789 100644
--- a/backend/explore/explore.go
+++ b/backend/explore/explore.go
@@ -36,7 +36,7 @@ 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)
+ artProxy := NewCoverArtProxy(db, limiter)
logger.Info("explore service created")
@@ -156,11 +156,11 @@ func (e *Service) CoverArtGroupURL(releaseGroupMBID string) string {
}
// 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.
+// cover art. Checks local library art first (by album+artist
+// name), then disk cache, then Cover Art Archive.
// Returns "" if no cover art is available.
-func (e *Service) GetThumbnail(releaseGroupMBID string) string {
- return e.artProxy.GetThumbnail(releaseGroupMBID)
+func (e *Service) GetThumbnail(releaseGroupMBID, albumName, artistName string) string {
+ return e.artProxy.GetThumbnail(releaseGroupMBID, albumName, artistName)
}
// 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 bfec7c4..1fe7486 100644
--- a/frontend/src/components/explore-view/explore-view.ts
+++ b/frontend/src/components/explore-view/explore-view.ts
@@ -587,14 +587,14 @@ export class ExploreView extends LitElement {
/* ── Thumbnail Loading ── */
- private loadThumbnail(mbid: string) {
+ private loadThumbnail(mbid: string, albumName: string, artistName: string) {
// Don't re-fetch if already loading or cached.
if (this.thumbnailCache.has(mbid)) return;
// Mark as loading to prevent duplicate requests.
this.thumbnailCache.set(mbid, '');
- GetThumbnail(mbid).then((dataUrl) => {
+ GetThumbnail(mbid, albumName || '', artistName || '').then((dataUrl) => {
if (dataUrl) {
this.thumbnailCache.set(mbid, dataUrl);
this.requestUpdate();
@@ -894,7 +894,7 @@ export class ExploreView extends LitElement {
// Kick off async thumbnail fetch if not cached.
if (!cachedArt) {
- this.loadThumbnail(rg.mbid);
+ this.loadThumbnail(rg.mbid, rg.title, rg.artistCredit);
}
return html`
diff --git a/frontend/wailsjs/go/explore/Service.d.ts b/frontend/wailsjs/go/explore/Service.d.ts
index d5a9456..49800f8 100755
--- a/frontend/wailsjs/go/explore/Service.d.ts
+++ b/frontend/wailsjs/go/explore/Service.d.ts
@@ -11,6 +11,8 @@ export function CoverArtGroupURL(arg1:string):Promise;
export function CoverArtURL(arg1:string):Promise;
+export function GetThumbnail(arg1:string, arg2:string, arg3:string):Promise;
+
export function LookupArtist(arg1:string):Promise;
export function LookupReleaseGroup(arg1:string):Promise;
@@ -28,5 +30,3 @@ export function SetContext(arg1:context.Context):Promise;
export function SimilarArtists(arg1:string):Promise>;
export function TopRecordingsForArtist(arg1:string):Promise>;
-
-export function GetThumbnail(arg1:string):Promise;
diff --git a/frontend/wailsjs/go/explore/Service.js b/frontend/wailsjs/go/explore/Service.js
index d711217..6ac459f 100755
--- a/frontend/wailsjs/go/explore/Service.js
+++ b/frontend/wailsjs/go/explore/Service.js
@@ -18,6 +18,10 @@ export function CoverArtURL(arg1) {
return window['go']['explore']['Service']['CoverArtURL'](arg1);
}
+export function GetThumbnail(arg1, arg2, arg3) {
+ return window['go']['explore']['Service']['GetThumbnail'](arg1, arg2, arg3);
+}
+
export function LookupArtist(arg1) {
return window['go']['explore']['Service']['LookupArtist'](arg1);
}
@@ -53,7 +57,3 @@ export function SimilarArtists(arg1) {
export function TopRecordingsForArtist(arg1) {
return window['go']['explore']['Service']['TopRecordingsForArtist'](arg1);
}
-
-export function GetThumbnail(arg1) {
- return window['go']['explore']['Service']['GetThumbnail'](arg1);
-}