feat: use local library cover art for search results

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.
This commit is contained in:
2026-03-25 19:00:29 -04:00
parent d5f34f242f
commit 0761cff408
5 changed files with 113 additions and 34 deletions
+99 -20
View File
@@ -9,9 +9,11 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
"yellowjacket/backend/database"
"yellowjacket/backend/system" "yellowjacket/backend/system"
) )
@@ -32,19 +34,24 @@ const (
) )
// CoverArtProxy fetches and caches cover art thumbnails locally. // CoverArtProxy fetches and caches cover art thumbnails locally.
// Wails-bound methods return base64-encoded image data for display // It checks three sources in order:
// in <img src="data:..."> tags, eliminating browser HTTP requests // 1. Local library cover art (instant, matched by album+artist name)
// to the slow Cover Art Archive. // 2. Disk cache from a previous CAA fetch (instant)
// 3. Cover Art Archive network fetch (slow, cached to disk)
type CoverArtProxy struct { type CoverArtProxy struct {
db *database.DB
cacheDir string cacheDir string
client *http.Client client *http.Client
limiter *RateLimiter limiter *RateLimiter
mu sync.Mutex // serializes disk writes 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 // NewCoverArtProxy creates a proxy that checks the local library
// the user data directory. // first and caches CAA thumbnails under the user data directory.
func NewCoverArtProxy(limiter *RateLimiter) *CoverArtProxy { func NewCoverArtProxy(db *database.DB, limiter *RateLimiter) *CoverArtProxy {
dir := "" dir := ""
dataDir, err := system.GetUserDataDirPath() dataDir, err := system.GetUserDataDirPath()
@@ -54,6 +61,7 @@ func NewCoverArtProxy(limiter *RateLimiter) *CoverArtProxy {
} }
return &CoverArtProxy{ return &CoverArtProxy{
db: db,
cacheDir: dir, cacheDir: dir,
client: &http.Client{Timeout: thumbnailTimeout}, client: &http.Client{Timeout: thumbnailTimeout},
limiter: limiter, limiter: limiter,
@@ -61,26 +69,32 @@ func NewCoverArtProxy(limiter *RateLimiter) *CoverArtProxy {
} }
// GetThumbnail returns a base64-encoded JPEG data URL for the given // GetThumbnail returns a base64-encoded JPEG data URL for the given
// release group MBID. Returns from local cache if available, // release group. Checks local library art first (by name match),
// otherwise fetches from the Cover Art Archive. Returns "" on // then disk cache, then fetches from CAA. Returns "" on failure.
// failure (no cover art, network error, etc.). func (p *CoverArtProxy) GetThumbnail(
func (p *CoverArtProxy) GetThumbnail(releaseGroupMBID string) string { 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 == "" { if p.cacheDir == "" || releaseGroupMBID == "" {
return "" return ""
} }
// Check disk cache. // Source 2: disk cache from previous CAA fetch (instant).
cached := p.readCache(releaseGroupMBID) if cached := p.readCache(releaseGroupMBID); cached != "" {
if cached != "" {
return cached return cached
} }
// Fetch from CAA. // Source 3: fetch from Cover Art Archive (slow, cached to disk).
url := CoverArtGroupURL(releaseGroupMBID) url := CoverArtGroupURL(releaseGroupMBID)
data, cacheable, err := p.fetch(url) data, cacheable, err := p.fetch(url)
if err != nil || len(data) == 0 { if err != nil || len(data) == 0 {
// Only cache permanent misses (404), not transient errors.
if cacheable { if cacheable {
p.writeCache(releaseGroupMBID, nil) p.writeCache(releaseGroupMBID, nil)
} }
@@ -93,8 +107,76 @@ func (p *CoverArtProxy) GetThumbnail(releaseGroupMBID string) string {
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data) 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) { func (p *CoverArtProxy) fetch(url string) ([]byte, bool, error) {
// Rate-limit CAA requests.
ctx := context.Background() ctx := context.Background()
if err := p.limiter.Wait(ctx); err != nil { if err := p.limiter.Wait(ctx); err != nil {
return nil, false, err return nil, false, err
@@ -114,12 +196,10 @@ func (p *CoverArtProxy) fetch(url string) ([]byte, bool, error) {
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
// 404 = no cover art exists — permanent, safe to cache as miss.
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return nil, true, nil return nil, true, nil
} }
// Other non-200 = transient error — don't cache.
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, false, fmt.Errorf("%w: %d", ErrCoverArt, resp.StatusCode) return nil, false, fmt.Errorf("%w: %d", ErrCoverArt, resp.StatusCode)
} }
@@ -144,7 +224,6 @@ func (p *CoverArtProxy) readCache(mbid string) string {
return "" return ""
} }
// Empty file = cached miss.
if len(data) == 0 { if len(data) == 0 {
return "" return ""
} }
@@ -159,7 +238,7 @@ func (p *CoverArtProxy) writeCache(mbid string, data []byte) {
path := p.cachePath(mbid) path := p.cachePath(mbid)
if data == nil { if data == nil {
data = []byte{} // empty file = miss marker data = []byte{}
} }
_ = os.WriteFile(path, data, 0o644) _ = os.WriteFile(path, data, 0o644)
+5 -5
View File
@@ -36,7 +36,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
mb := NewMusicBrainzClient(cache, logger.WithGroup("musicbrainz")) mb := NewMusicBrainzClient(cache, logger.WithGroup("musicbrainz"))
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(limiter) artProxy := NewCoverArtProxy(db, limiter)
logger.Info("explore service created") 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 // GetThumbnail returns a base64 data URL for the release group's
// cover art. Cached locally on disk — first call fetches from // cover art. Checks local library art first (by album+artist
// the Cover Art Archive, subsequent calls are instant. // name), then disk cache, then Cover Art Archive.
// Returns "" if no cover art is available. // Returns "" if no cover art is available.
func (e *Service) GetThumbnail(releaseGroupMBID string) string { func (e *Service) GetThumbnail(releaseGroupMBID, albumName, artistName string) string {
return e.artProxy.GetThumbnail(releaseGroupMBID) return e.artProxy.GetThumbnail(releaseGroupMBID, albumName, artistName)
} }
// Search concurrently queries MusicBrainz for artists, release // Search concurrently queries MusicBrainz for artists, release
@@ -587,14 +587,14 @@ export class ExploreView extends LitElement {
/* ── Thumbnail Loading ── */ /* ── Thumbnail Loading ── */
private loadThumbnail(mbid: string) { private loadThumbnail(mbid: string, albumName: string, artistName: string) {
// Don't re-fetch if already loading or cached. // Don't re-fetch if already loading or cached.
if (this.thumbnailCache.has(mbid)) return; if (this.thumbnailCache.has(mbid)) return;
// Mark as loading to prevent duplicate requests. // Mark as loading to prevent duplicate requests.
this.thumbnailCache.set(mbid, ''); this.thumbnailCache.set(mbid, '');
GetThumbnail(mbid).then((dataUrl) => { GetThumbnail(mbid, albumName || '', artistName || '').then((dataUrl) => {
if (dataUrl) { if (dataUrl) {
this.thumbnailCache.set(mbid, dataUrl); this.thumbnailCache.set(mbid, dataUrl);
this.requestUpdate(); this.requestUpdate();
@@ -894,7 +894,7 @@ export class ExploreView extends LitElement {
// Kick off async thumbnail fetch if not cached. // Kick off async thumbnail fetch if not cached.
if (!cachedArt) { if (!cachedArt) {
this.loadThumbnail(rg.mbid); this.loadThumbnail(rg.mbid, rg.title, rg.artistCredit);
} }
return html` return html`
+2 -2
View File
@@ -11,6 +11,8 @@ export function CoverArtGroupURL(arg1:string):Promise<string>;
export function CoverArtURL(arg1:string):Promise<string>; export function CoverArtURL(arg1:string):Promise<string>;
export function GetThumbnail(arg1:string, arg2:string, arg3:string):Promise<string>;
export function LookupArtist(arg1:string):Promise<explore.MBArtist>; export function LookupArtist(arg1:string):Promise<explore.MBArtist>;
export function LookupReleaseGroup(arg1:string):Promise<explore.MBReleaseGroup>; export function LookupReleaseGroup(arg1:string):Promise<explore.MBReleaseGroup>;
@@ -28,5 +30,3 @@ export function SetContext(arg1:context.Context):Promise<void>;
export function SimilarArtists(arg1:string):Promise<Array<explore.LBSimilarArtist>>; export function SimilarArtists(arg1:string):Promise<Array<explore.LBSimilarArtist>>;
export function TopRecordingsForArtist(arg1:string):Promise<Array<explore.LBTopRecording>>; export function TopRecordingsForArtist(arg1:string):Promise<Array<explore.LBTopRecording>>;
export function GetThumbnail(arg1:string):Promise<string>;
+4 -4
View File
@@ -18,6 +18,10 @@ export function CoverArtURL(arg1) {
return window['go']['explore']['Service']['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) { export function LookupArtist(arg1) {
return window['go']['explore']['Service']['LookupArtist'](arg1); return window['go']['explore']['Service']['LookupArtist'](arg1);
} }
@@ -53,7 +57,3 @@ export function SimilarArtists(arg1) {
export function TopRecordingsForArtist(arg1) { export function TopRecordingsForArtist(arg1) {
return window['go']['explore']['Service']['TopRecordingsForArtist'](arg1); return window['go']['explore']['Service']['TopRecordingsForArtist'](arg1);
} }
export function GetThumbnail(arg1) {
return window['go']['explore']['Service']['GetThumbnail'](arg1);
}