From 0761cff408380940a8a2b92e28d753b9795b9e80 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Wed, 25 Mar 2026 19:00:29 -0400 Subject: [PATCH] feat: use local library cover art for search results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoverArtProxy now checks three sources in order: 1. Local library (instant) — matches by album+artist name against the release_groups/cover_art tables. Albums the user already owns show their local cover art immediately. 2. Disk cache (instant) — previously fetched CAA thumbnails. 3. Cover Art Archive (network) — fetches and caches to disk. Library index is built once on first access (sync.Once) from a single SQL query joining release_groups → cover_art → artists. Keyed by lowercased 'album\x00artist' for exact name matching. GetThumbnail now takes (mbid, albumName, artistName) so the proxy can check the library before falling back to CAA. Frontend passes the album title and artist credit from the search result. --- backend/explore/coverartproxy.go | 119 +++++++++++++++--- backend/explore/explore.go | 10 +- .../components/explore-view/explore-view.ts | 6 +- frontend/wailsjs/go/explore/Service.d.ts | 4 +- frontend/wailsjs/go/explore/Service.js | 8 +- 5 files changed, 113 insertions(+), 34 deletions(-) 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); -}