From 49a26c6163e89868e9e40de6f22ba5dd2100621d Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Wed, 25 Mar 2026 14:09:30 -0400 Subject: [PATCH] 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. --- backend/explore/coverartproxy.go | 158 ++++++++++++++++++ backend/explore/explore.go | 35 ++-- backend/explore/searchindex.go | 19 ++- .../components/explore-view/explore-view.ts | 31 +++- frontend/wailsjs/go/explore/Service.d.ts | 109 +++--------- frontend/wailsjs/go/explore/Service.js | 44 +++-- 6 files changed, 275 insertions(+), 121 deletions(-) create mode 100644 backend/explore/coverartproxy.go mode change 100644 => 100755 frontend/wailsjs/go/explore/Service.d.ts mode change 100644 => 100755 frontend/wailsjs/go/explore/Service.js diff --git a/backend/explore/coverartproxy.go b/backend/explore/coverartproxy.go new file mode 100644 index 0000000..67cf651 --- /dev/null +++ b/backend/explore/coverartproxy.go @@ -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 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) +} diff --git a/backend/explore/explore.go b/backend/explore/explore.go index 143488a..55a09bf 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -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 diff --git a/backend/explore/searchindex.go b/backend/explore/searchindex.go index a17448b..d4369be 100644 --- a/backend/explore/searchindex.go +++ b/backend/explore/searchindex.go @@ -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", diff --git a/frontend/src/components/explore-view/explore-view.ts b/frontend/src/components/explore-view/explore-view.ts index 0b6a2d0..bfec7c4 100644 --- a/frontend/src/components/explore-view/explore-view.ts +++ b/frontend/src/components/explore-view/explore-view.ts @@ -1,7 +1,7 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, state, query as litQuery } from 'lit/decorators.js'; import { designTokens } from '../../styles/tokens.css'; -import { Search } from '@go/explore/Service'; +import { Search, GetThumbnail } from '@go/explore/Service'; import type { MBSearchResult, MBArtist, @@ -74,6 +74,7 @@ export class ExploreView extends LitElement { /** Monotonic counter to discard stale responses. */ private searchVersion = 0; private debounceTimer: ReturnType | null = null; + private thumbnailCache = new Map(); @litQuery('input') private inputEl!: HTMLInputElement; @@ -584,6 +585,25 @@ export class ExploreView extends LitElement { } } + /* ── Thumbnail Loading ── */ + + private loadThumbnail(mbid: 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) => { + if (dataUrl) { + this.thumbnailCache.set(mbid, dataUrl); + this.requestUpdate(); + } + }).catch(() => { + // Leave empty string in cache — fallback will show. + }); + } + /* ── Top Results ── */ private getTopResults(): ScoredItem[] { @@ -868,8 +888,15 @@ export class ExploreView extends LitElement {

Albums

${releaseGroups.map((rg) => { - const artURL = CoverArtGroupURL(rg.mbid); + const cachedArt = this.thumbnailCache.get(rg.mbid); + const artURL = cachedArt || CoverArtGroupURL(rg.mbid); const year = extractYear(rg.firstReleaseDate); + + // Kick off async thumbnail fetch if not cached. + if (!cachedArt) { + this.loadThumbnail(rg.mbid); + } + return html`
>; -export interface MBArtist { - mbid: string; - name: string; - sortName: string; - type: string; - country: string; - disambiguation: string; - score: number; -} +export function BrowseReleases(arg1:string):Promise>; -export interface MBReleaseGroup { - mbid: string; - title: string; - primaryType: string; - secondaryTypes?: string[]; - firstReleaseDate: string; - artistCredit: string; -} - -export interface MBRecording { - mbid: string; - title: string; - length: number; - artistCredit: string; - score: number; -} - -export interface MBRelease { - mbid: string; - title: string; - date: string; - country: string; - status: string; - tracks?: MBTrack[]; -} - -export interface MBTrack { - position: number; - discNumber: number; - title: string; - length: number; - mbid: string; -} - -export interface MBSearchResult { - artists?: MBArtist[]; - releaseGroups?: MBReleaseGroup[]; - recordings?: MBRecording[]; -} - -export interface LBTopRecording { - recordingMbid: string; - artistName: string; - trackName: string; - totalListenCount: number; -} - -export interface LBSimilarArtist { - artistMbid: string; - name: string; - score: number; -} - -// -- Service methods ------------------------------------------------ - -export function Search(arg1:string):Promise; - -export function SearchArtists(arg1:string):Promise>; - -export function SearchReleaseGroups(arg1:string):Promise>; - -export function SearchRecordings(arg1:string):Promise>; - -export function LookupArtist(arg1:string):Promise; - -export function LookupReleaseGroup(arg1:string):Promise; - -export function BrowseReleaseGroups(arg1:string):Promise>; - -export function BrowseReleases(arg1:string):Promise>; - -export function TopRecordingsForArtist(arg1:string):Promise>; - -export function SimilarArtists(arg1:string):Promise>; +export function CoverArtGroupURL(arg1:string):Promise; export function CoverArtURL(arg1:string):Promise; -export function CoverArtGroupURL(arg1:string):Promise; +export function LookupArtist(arg1:string):Promise; + +export function LookupReleaseGroup(arg1:string):Promise; + +export function Search(arg1:string):Promise; + +export function SearchArtists(arg1:string):Promise>; + +export function SearchRecordings(arg1:string):Promise>; + +export function SearchReleaseGroups(arg1:string):Promise>; + +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 old mode 100644 new mode 100755 index 7cb06de..d711217 --- a/frontend/wailsjs/go/explore/Service.js +++ b/frontend/wailsjs/go/explore/Service.js @@ -2,20 +2,20 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT -export function Search(arg1) { - return window['go']['explore']['Service']['Search'](arg1); +export function BrowseReleaseGroups(arg1) { + return window['go']['explore']['Service']['BrowseReleaseGroups'](arg1); } -export function SearchArtists(arg1) { - return window['go']['explore']['Service']['SearchArtists'](arg1); +export function BrowseReleases(arg1) { + return window['go']['explore']['Service']['BrowseReleases'](arg1); } -export function SearchReleaseGroups(arg1) { - return window['go']['explore']['Service']['SearchReleaseGroups'](arg1); +export function CoverArtGroupURL(arg1) { + return window['go']['explore']['Service']['CoverArtGroupURL'](arg1); } -export function SearchRecordings(arg1) { - return window['go']['explore']['Service']['SearchRecordings'](arg1); +export function CoverArtURL(arg1) { + return window['go']['explore']['Service']['CoverArtURL'](arg1); } export function LookupArtist(arg1) { @@ -26,26 +26,34 @@ export function LookupReleaseGroup(arg1) { return window['go']['explore']['Service']['LookupReleaseGroup'](arg1); } -export function BrowseReleaseGroups(arg1) { - return window['go']['explore']['Service']['BrowseReleaseGroups'](arg1); +export function Search(arg1) { + return window['go']['explore']['Service']['Search'](arg1); } -export function BrowseReleases(arg1) { - return window['go']['explore']['Service']['BrowseReleases'](arg1); +export function SearchArtists(arg1) { + return window['go']['explore']['Service']['SearchArtists'](arg1); } -export function TopRecordingsForArtist(arg1) { - return window['go']['explore']['Service']['TopRecordingsForArtist'](arg1); +export function SearchRecordings(arg1) { + return window['go']['explore']['Service']['SearchRecordings'](arg1); +} + +export function SearchReleaseGroups(arg1) { + return window['go']['explore']['Service']['SearchReleaseGroups'](arg1); +} + +export function SetContext(arg1) { + return window['go']['explore']['Service']['SetContext'](arg1); } export function SimilarArtists(arg1) { return window['go']['explore']['Service']['SimilarArtists'](arg1); } -export function CoverArtURL(arg1) { - return window['go']['explore']['Service']['CoverArtURL'](arg1); +export function TopRecordingsForArtist(arg1) { + return window['go']['explore']['Service']['TopRecordingsForArtist'](arg1); } -export function CoverArtGroupURL(arg1) { - return window['go']['explore']['Service']['CoverArtGroupURL'](arg1); +export function GetThumbnail(arg1) { + return window['go']['explore']['Service']['GetThumbnail'](arg1); }