Files
SpotiFLAC-Mobile/go_backend/cover.go
T

312 lines
8.4 KiB
Go

package gobackend
import (
"bytes"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
"image/png"
"io"
"net/http"
"sync"
"time"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
// downloadCoverToMemory downloads exactly the URL supplied by the metadata
// provider. Cover-resolution selection belongs to the provider extension;
// the app must not infer a provider from its CDN URL or rewrite that URL.
func downloadCoverToMemory(coverURL string) ([]byte, error) {
if coverURL == "" {
return nil, fmt.Errorf("no cover URL provided")
}
GoLog("[Cover] Provider URL: %s", coverURL)
data, err := fetchCoverCached(coverURL)
if err != nil {
return nil, err
}
// Cached bytes are shared across goroutines and must never be mutated;
// hand callers their own copy.
return append([]byte(nil), data...), nil
}
const (
embeddedCoverJPEGQuality = 88
// Decoding arbitrary provider artwork allocates roughly four bytes per
// pixel. Refuse pathological images before Decode so a malicious extension
// cannot force an unbounded mobile allocation. Normal artwork through
// 4000x4000 is still accepted and downscaled.
maxCoverDecodePixels int64 = 16_000_000
)
// downloadCoverToMemorySized returns provider artwork with its aspect ratio
// preserved and its longest side capped at maxDimension. A non-positive limit
// keeps the original bytes. Images already within the limit are also returned
// byte-for-byte so this option never introduces needless generation loss.
func downloadCoverToMemorySized(coverURL string, maxDimension int) ([]byte, error) {
if maxDimension <= 0 {
return downloadCoverToMemory(coverURL)
}
variantKey := fmt.Sprintf("%s\x00max-dimension=%d", coverURL, maxDimension)
data, err := fetchCoverCachedWithKey(variantKey, func() ([]byte, error) {
original, fetchErr := fetchCoverCached(coverURL)
if fetchErr != nil {
return nil, fetchErr
}
resized, changed, resizeErr := resizeCoverForEmbedding(
original,
maxDimension,
)
if resizeErr != nil {
// A requested limit is a hard ceiling. Omitting an unsupported cover is
// preferable to silently embedding the oversized original. The default
// (maxDimension == 0) never enters this path and remains compatible.
return nil, fmt.Errorf("resize artwork: %w", resizeErr)
}
if changed {
width, height := coverDimensions(resized)
GoLog(
"[Cover] Downscaled artwork to %dx%d (%d KB -> %d KB)",
width,
height,
len(original)/1024,
len(resized)/1024,
)
}
return resized, nil
})
if err != nil {
return nil, err
}
return append([]byte(nil), data...), nil
}
func resizeCoverForEmbedding(data []byte, maxDimension int) ([]byte, bool, error) {
if len(data) == 0 || maxDimension <= 0 {
return data, false, nil
}
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, false, fmt.Errorf("decode artwork dimensions: %w", err)
}
if config.Width <= 0 || config.Height <= 0 {
return nil, false, fmt.Errorf("invalid artwork dimensions %dx%d", config.Width, config.Height)
}
if config.Width <= maxDimension && config.Height <= maxDimension {
return data, false, nil
}
if int64(config.Width)*int64(config.Height) > maxCoverDecodePixels {
return nil, false, fmt.Errorf(
"artwork dimensions %dx%d exceed safe decode limit",
config.Width,
config.Height,
)
}
source, decodedFormat, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, false, fmt.Errorf("decode artwork: %w", err)
}
if decodedFormat != "" {
format = decodedFormat
}
destinationWidth, destinationHeight := scaledCoverDimensions(
config.Width,
config.Height,
maxDimension,
)
destination := image.NewRGBA(
image.Rect(0, 0, destinationWidth, destinationHeight),
)
xdraw.ApproxBiLinear.Scale(
destination,
destination.Bounds(),
source,
source.Bounds(),
xdraw.Over,
nil,
)
var encoded bytes.Buffer
if format == "png" {
if err := png.Encode(&encoded, destination); err != nil {
return nil, false, fmt.Errorf("encode resized PNG artwork: %w", err)
}
} else if err := jpeg.Encode(
&encoded,
destination,
&jpeg.Options{Quality: embeddedCoverJPEGQuality},
); err != nil {
return nil, false, fmt.Errorf("encode resized JPEG artwork: %w", err)
}
return encoded.Bytes(), true, nil
}
func scaledCoverDimensions(width, height, maxDimension int) (int, int) {
if width >= height {
scaledHeight := max(1, (height*maxDimension+width/2)/width)
return maxDimension, scaledHeight
}
scaledWidth := max(1, (width*maxDimension+height/2)/height)
return scaledWidth, maxDimension
}
func coverDimensions(data []byte) (int, int) {
if len(data) == 0 {
return 0, 0
}
config, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || config.Width <= 0 || config.Height <= 0 {
return 0, 0
}
return config.Width, config.Height
}
const (
coverCacheMaxBytes = 24 * 1024 * 1024
coverCacheTTL = 15 * time.Minute
)
type coverCacheEntry struct {
data []byte
expiresAt time.Time
}
type coverInflightCall struct {
wg sync.WaitGroup
data []byte
err error
}
var (
coverMu sync.Mutex
coverCache = map[string]*coverCacheEntry{}
coverCacheBytes int
coverInflight = map[string]*coverInflightCall{}
coverFetch = fetchCoverBytes
)
func clearCoverMemoryCache() {
coverMu.Lock()
coverCache = map[string]*coverCacheEntry{}
coverCacheBytes = 0
coverMu.Unlock()
}
// fetchCoverCached returns cover bytes for a final URL, collapsing concurrent
// requests for the same URL into a single fetch (singleflight) and caching
// results in memory for the duration of an album batch. The returned slice is
// shared; callers must copy before mutating.
func fetchCoverCached(downloadURL string) ([]byte, error) {
return fetchCoverCachedWithKey(downloadURL, func() ([]byte, error) {
return coverFetch(downloadURL)
})
}
// fetchCoverCachedWithKey collapses both original cover downloads and derived
// size variants. This keeps native-worker album batches from decoding and
// resizing the same artwork once per track.
func fetchCoverCachedWithKey(
cacheKey string,
fetch func() ([]byte, error),
) ([]byte, error) {
coverMu.Lock()
if e, ok := coverCache[cacheKey]; ok {
if time.Now().Before(e.expiresAt) {
data := e.data
coverMu.Unlock()
return data, nil
}
delete(coverCache, cacheKey)
coverCacheBytes -= len(e.data)
}
if call, ok := coverInflight[cacheKey]; ok {
coverMu.Unlock()
call.wg.Wait()
return call.data, call.err
}
call := &coverInflightCall{}
// Default error so a panicking fetch never strands waiters with a
// (nil, nil) "success"; overwritten on normal completion.
call.err = fmt.Errorf("cover fetch aborted")
call.wg.Add(1)
coverInflight[cacheKey] = call
coverMu.Unlock()
defer func() {
call.wg.Done()
coverMu.Lock()
delete(coverInflight, cacheKey)
coverMu.Unlock()
}()
data, err := fetch()
call.data, call.err = data, err
if err == nil {
coverCachePut(cacheKey, data)
}
return data, err
}
func coverCachePut(downloadURL string, data []byte) {
if len(data) == 0 || len(data) > coverCacheMaxBytes {
return
}
coverMu.Lock()
defer coverMu.Unlock()
if e, ok := coverCache[downloadURL]; ok {
coverCacheBytes -= len(e.data)
}
coverCache[downloadURL] = &coverCacheEntry{data: data, expiresAt: time.Now().Add(coverCacheTTL)}
coverCacheBytes += len(data)
for coverCacheBytes > coverCacheMaxBytes && len(coverCache) > 1 {
var oldestKey string
var oldest time.Time
first := true
for k, e := range coverCache {
if first || e.expiresAt.Before(oldest) {
oldest, oldestKey, first = e.expiresAt, k, false
}
}
coverCacheBytes -= len(coverCache[oldestKey].data)
delete(coverCache, oldestKey)
}
}
func fetchCoverBytes(downloadURL string) ([]byte, error) {
client := NewHTTPClientWithTimeout(DefaultTimeout)
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(client, req)
if err != nil {
return nil, fmt.Errorf("failed to download cover: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("cover download failed: HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read cover data: %w", err)
}
width, height := coverDimensions(data)
GoLog("[Cover] Downloaded %d KB (%dx%d)", len(data)/1024, width, height)
return data, nil
}