mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
366 lines
9.5 KiB
Go
366 lines
9.5 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
spotifySize300 = "ab67616d00001e02"
|
|
spotifySize640 = "ab67616d0000b273"
|
|
spotifySizeMax = "ab67616d000082c1"
|
|
)
|
|
|
|
// Square CDN covers using this path shape may return an image whose decoded
|
|
// dimensions differ from the dimensions advertised in the URL. Max-quality
|
|
// selection therefore probes both useful high-resolution variants and checks
|
|
// the image headers instead of trusting the filename.
|
|
var squareCoverSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
|
|
|
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
|
|
|
var qobuzSizeRegex = regexp.MustCompile(`_\d+\.jpg$`)
|
|
|
|
func convertSmallToMedium(imageURL string) string {
|
|
if strings.Contains(imageURL, spotifySize300) {
|
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
|
}
|
|
return imageURL
|
|
}
|
|
|
|
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|
if coverURL == "" {
|
|
return nil, fmt.Errorf("no cover URL provided")
|
|
}
|
|
|
|
GoLog("[Cover] Original URL: %s", coverURL)
|
|
|
|
downloadURL := convertSmallToMedium(coverURL)
|
|
if downloadURL != coverURL {
|
|
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
|
}
|
|
|
|
if !maxQuality {
|
|
GoLog("[Cover] Final URL: %s", downloadURL)
|
|
data, err := fetchCoverCached(downloadURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append([]byte(nil), data...), nil
|
|
}
|
|
|
|
candidates := maxQualityCoverCandidateURLs(downloadURL)
|
|
data, selectedURL, width, height, err := fetchBestCoverCandidate(candidates)
|
|
if err != nil {
|
|
// A CDN can reject an upgraded size while the provider-supplied URL is
|
|
// still valid. Preserve that URL as the final fallback.
|
|
if len(candidates) == 1 && candidates[0] == downloadURL {
|
|
return nil, err
|
|
}
|
|
data, err = fetchCoverCached(downloadURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
selectedURL = downloadURL
|
|
width, height = coverDimensions(data)
|
|
}
|
|
GoLog("[Cover] Selected URL: %s (%dx%d, %d KB)", selectedURL, width, height, len(data)/1024)
|
|
// Cached bytes are shared across goroutines and must never be mutated;
|
|
// hand callers their own copy.
|
|
return append([]byte(nil), data...), nil
|
|
}
|
|
|
|
type fetchedCoverCandidate struct {
|
|
url string
|
|
data []byte
|
|
width, height int
|
|
err error
|
|
}
|
|
|
|
func maxQualityCoverCandidateURLs(coverURL string) []string {
|
|
upgraded := upgradeToMaxQuality(coverURL)
|
|
candidates := []string{upgraded}
|
|
// This is deliberately based on the URL capability rather than a provider
|
|
// or extension ID. Any metadata source returning the same square-cover URL
|
|
// shape receives the same verified candidate selection.
|
|
if squareCoverSizeRegex.MatchString(coverURL) {
|
|
candidate1500 := squareCoverSizeRegex.ReplaceAllString(
|
|
coverURL,
|
|
"/1500x1500-000000-80-0-0.jpg",
|
|
)
|
|
if candidate1500 != upgraded {
|
|
candidates = append(candidates, candidate1500)
|
|
}
|
|
}
|
|
return uniqueNonEmptyStrings(candidates)
|
|
}
|
|
|
|
func uniqueNonEmptyStrings(values []string) []string {
|
|
seen := make(map[string]struct{}, len(values))
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[value]; exists {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
result = append(result, value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func fetchBestCoverCandidate(urls []string) ([]byte, string, int, int, error) {
|
|
if len(urls) == 0 {
|
|
return nil, "", 0, 0, fmt.Errorf("no cover candidates available")
|
|
}
|
|
results := make(chan fetchedCoverCandidate, len(urls))
|
|
for _, candidateURL := range urls {
|
|
go func(url string) {
|
|
data, err := fetchCoverCached(url)
|
|
width, height := coverDimensions(data)
|
|
results <- fetchedCoverCandidate{
|
|
url: url, data: data, width: width, height: height, err: err,
|
|
}
|
|
}(candidateURL)
|
|
}
|
|
|
|
var best *fetchedCoverCandidate
|
|
var firstErr error
|
|
for range urls {
|
|
candidate := <-results
|
|
if candidate.err != nil || len(candidate.data) == 0 {
|
|
if firstErr == nil {
|
|
firstErr = candidate.err
|
|
}
|
|
continue
|
|
}
|
|
if best == nil || coverCandidateBetter(candidate, *best) {
|
|
copy := candidate
|
|
best = ©
|
|
}
|
|
}
|
|
if best == nil {
|
|
if firstErr == nil {
|
|
firstErr = fmt.Errorf("cover candidates returned no image data")
|
|
}
|
|
return nil, "", 0, 0, firstErr
|
|
}
|
|
return best.data, best.url, best.width, best.height, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func coverCandidateBetter(candidate, current fetchedCoverCandidate) bool {
|
|
candidatePixels := int64(candidate.width) * int64(candidate.height)
|
|
currentPixels := int64(current.width) * int64(current.height)
|
|
if candidatePixels != currentPixels {
|
|
return candidatePixels > currentPixels
|
|
}
|
|
return len(candidate.data) > len(current.data)
|
|
}
|
|
|
|
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) {
|
|
coverMu.Lock()
|
|
if e, ok := coverCache[downloadURL]; ok {
|
|
if time.Now().Before(e.expiresAt) {
|
|
data := e.data
|
|
coverMu.Unlock()
|
|
return data, nil
|
|
}
|
|
delete(coverCache, downloadURL)
|
|
coverCacheBytes -= len(e.data)
|
|
}
|
|
if call, ok := coverInflight[downloadURL]; 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[downloadURL] = call
|
|
coverMu.Unlock()
|
|
|
|
defer func() {
|
|
call.wg.Done()
|
|
coverMu.Lock()
|
|
delete(coverInflight, downloadURL)
|
|
coverMu.Unlock()
|
|
}()
|
|
|
|
data, err := coverFetch(downloadURL)
|
|
call.data, call.err = data, err
|
|
if err == nil {
|
|
coverCachePut(downloadURL, 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
|
|
}
|
|
|
|
func upgradeToMaxQuality(coverURL string) string {
|
|
if strings.Contains(coverURL, spotifySize640) {
|
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
|
}
|
|
|
|
if squareCoverSizeRegex.MatchString(coverURL) {
|
|
return upgradeSquareCover(coverURL)
|
|
}
|
|
|
|
if strings.Contains(coverURL, "resources.tidal.com") {
|
|
return upgradeTidalCover(coverURL)
|
|
}
|
|
|
|
if strings.Contains(coverURL, "static.qobuz.com") {
|
|
return upgradeQobuzCover(coverURL)
|
|
}
|
|
|
|
return coverURL
|
|
}
|
|
|
|
func upgradeSquareCover(coverURL string) string {
|
|
if !squareCoverSizeRegex.MatchString(coverURL) {
|
|
return coverURL
|
|
}
|
|
|
|
upgraded := squareCoverSizeRegex.ReplaceAllString(coverURL, "/1900x1900-000000-80-0-0.jpg")
|
|
if upgraded != coverURL {
|
|
GoLog("[Cover] Square CDN: probing 1900x1900 and 1500x1500")
|
|
}
|
|
return upgraded
|
|
}
|
|
|
|
func upgradeTidalCover(coverURL string) string {
|
|
if !strings.Contains(coverURL, "resources.tidal.com") {
|
|
return coverURL
|
|
}
|
|
|
|
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
|
|
if upgraded != coverURL {
|
|
GoLog("[Cover] Tidal: upgraded to origin resolution")
|
|
}
|
|
return upgraded
|
|
}
|
|
|
|
func upgradeQobuzCover(coverURL string) string {
|
|
if !strings.Contains(coverURL, "static.qobuz.com") {
|
|
return coverURL
|
|
}
|
|
|
|
upgraded := qobuzSizeRegex.ReplaceAllString(coverURL, "_max.jpg")
|
|
if upgraded != coverURL {
|
|
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
|
}
|
|
return upgraded
|
|
}
|