mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
312 lines
8.4 KiB
Go
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
|
|
}
|