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); -}