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