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.
246 lines
5.9 KiB
Go
246 lines
5.9 KiB
Go
package explore
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"yellowjacket/backend/database"
|
|
"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.
|
|
// 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 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()
|
|
if err == nil {
|
|
dir = filepath.Join(dataDir, thumbnailDir)
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
}
|
|
|
|
return &CoverArtProxy{
|
|
db: db,
|
|
cacheDir: dir,
|
|
client: &http.Client{Timeout: thumbnailTimeout},
|
|
limiter: limiter,
|
|
}
|
|
}
|
|
|
|
// GetThumbnail returns a base64-encoded JPEG data URL for the given
|
|
// 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 ""
|
|
}
|
|
|
|
// Source 2: disk cache from previous CAA fetch (instant).
|
|
if cached := p.readCache(releaseGroupMBID); cached != "" {
|
|
return cached
|
|
}
|
|
|
|
// 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 {
|
|
if cacheable {
|
|
p.writeCache(releaseGroupMBID, nil)
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
p.writeCache(releaseGroupMBID, 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) {
|
|
ctx := context.Background()
|
|
if err := p.limiter.Wait(ctx); err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
req.Header.Set("User-Agent", lbUserAgent)
|
|
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return nil, true, nil
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, false, fmt.Errorf("%w: %d", ErrCoverArt, resp.StatusCode)
|
|
}
|
|
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, thumbnailMaxSize))
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
return data, true, 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 ""
|
|
}
|
|
|
|
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{}
|
|
}
|
|
|
|
_ = os.WriteFile(path, data, 0o644)
|
|
}
|