refactor(metadata): move cover resolution to extensions

This commit is contained in:
zarzet
2026-08-19 13:12:29 +07:00
parent bdb10a2ae6
commit ea63291db8
49 changed files with 42 additions and 579 deletions
+7 -202
View File
@@ -8,158 +8,28 @@ import (
_ "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) {
// 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] 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)
GoLog("[Cover] Provider URL: %s", coverURL)
data, err := fetchCoverCached(coverURL)
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)
return nil, err
}
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 = &copy
}
}
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
@@ -171,15 +41,6 @@ func coverDimensions(data []byte) (int, int) {
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
@@ -307,59 +168,3 @@ func fetchCoverBytes(downloadURL string) ([]byte, error) {
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
}
+11 -78
View File
@@ -1,33 +1,12 @@
package gobackend
import (
"bytes"
"errors"
"image"
"image/color"
"image/png"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func testPNG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 100, A: 255})
}
}
var buffer bytes.Buffer
if err := png.Encode(&buffer, img); err != nil {
t.Fatalf("encode test cover: %v", err)
}
return buffer.Bytes()
}
func resetCoverCache() {
coverMu.Lock()
coverCache = map[string]*coverCacheEntry{}
@@ -116,72 +95,26 @@ func TestFetchCoverCachedTTLExpiry(t *testing.T) {
}
}
func TestMaxQualityCoverCandidatesProbe1900And1500(t *testing.T) {
url := "https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg"
candidates := maxQualityCoverCandidateURLs(url)
if len(candidates) != 2 {
t.Fatalf("expected two candidates, got %#v", candidates)
}
if !strings.Contains(candidates[0], "1900x1900") {
t.Fatalf("first candidate = %q", candidates[0])
}
if !strings.Contains(candidates[1], "1500x1500") {
t.Fatalf("second candidate = %q", candidates[1])
}
}
func TestDownloadCoverSelectsDecodedDimensionsBeforeByteSize(t *testing.T) {
func TestDownloadCoverUsesProviderURLUnchanged(t *testing.T) {
orig := coverFetch
defer func() { coverFetch = orig }()
resetCoverCache()
advertised1900ButSmaller := append(testPNG(t, 12, 12), bytes.Repeat([]byte{0}, 4096)...)
actualLargerDimensions := testPNG(t, 15, 15)
const providerURL = "https://i.scdn.co/image/ab67616d00001e02example"
var requestedURL string
coverFetch = func(url string) ([]byte, error) {
switch {
case strings.Contains(url, "1900x1900"):
return advertised1900ButSmaller, nil
case strings.Contains(url, "1500x1500"):
return actualLargerDimensions, nil
default:
return nil, errors.New("unexpected URL")
}
requestedURL = url
return []byte("provider-cover"), nil
}
url := "https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg"
got, err := downloadCoverToMemory(url, true)
got, err := downloadCoverToMemory(providerURL)
if err != nil {
t.Fatalf("download max cover: %v", err)
t.Fatalf("download provider cover: %v", err)
}
if !bytes.Equal(got, actualLargerDimensions) {
t.Fatal("expected decoded 15x15 candidate instead of larger-byte 12x12 candidate")
if requestedURL != providerURL {
t.Fatalf("requested URL = %q, want provider URL %q", requestedURL, providerURL)
}
}
func TestBestCoverCandidateUsesByteSizeAsDimensionTiebreaker(t *testing.T) {
orig := coverFetch
defer func() { coverFetch = orig }()
resetCoverCache()
base := testPNG(t, 10, 10)
largerFile := append(append([]byte(nil), base...), bytes.Repeat([]byte{0}, 512)...)
coverFetch = func(url string) ([]byte, error) {
if strings.Contains(url, "large") {
return largerFile, nil
}
return base, nil
}
got, selected, width, height, err := fetchBestCoverCandidate(
[]string{"https://covers.test/small", "https://covers.test/large"},
)
if err != nil {
t.Fatalf("select cover: %v", err)
}
if selected != "https://covers.test/large" || width != 10 || height != 10 {
t.Fatalf("selected=%q dimensions=%dx%d", selected, width, height)
}
if !bytes.Equal(got, largerFile) {
t.Fatal("expected larger file when decoded dimensions are equal")
if string(got) != "provider-cover" {
t.Fatalf("downloaded cover = %q", got)
}
}
-1
View File
@@ -28,7 +28,6 @@ type DownloadRequest struct {
EmbedMetadata bool `json:"embed_metadata"`
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
EmbedLyrics bool `json:"embed_lyrics"`
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
EmbedReplayGain bool `json:"embed_replaygain,omitempty"`
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
TidalHighFormat string `json:"tidal_high_format,omitempty"`
+5 -2
View File
@@ -527,12 +527,15 @@ func RewriteSplitArtistTagsExport(filePath, artist, albumArtist string) (string,
return s, nil
}
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
// The final bool is retained for gomobile ABI compatibility with existing
// native shells. Resolution selection is extension-owned and the value is
// intentionally ignored.
func DownloadCoverToFile(coverURL string, outputPath string, _ bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
}
data, err := downloadCoverToMemory(coverURL, maxQuality)
data, err := downloadCoverToMemory(coverURL)
if err != nil {
return fmt.Errorf("failed to download cover: %w", err)
}
+1 -2
View File
@@ -21,7 +21,6 @@ var fetchMusicBrainzAlbumArtistByISRC = FetchMusicBrainzAlbumArtistByISRC
type reEnrichRequest struct {
FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"`
MaxQuality bool `json:"max_quality"`
EmbedLyrics bool `json:"embed_lyrics"`
LyricsMode string `json:"lyrics_mode,omitempty"`
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
@@ -727,7 +726,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
var coverTempPath string
var coverDataBytes []byte
if req.CoverURL != "" && req.shouldUpdateTag("cover", "cover") {
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
coverData, err := downloadCoverToMemory(req.CoverURL)
if err != nil {
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
} else {
+1 -1
View File
@@ -146,7 +146,7 @@ func embedExtensionDownloadMetadata(resp DownloadResponse, req DownloadRequest,
coverURL := firstNonEmptyTrimmed(resp.CoverURL, req.CoverURL)
var coverData []byte
if coverURL != "" {
data, err := downloadCoverToMemory(coverURL, req.EmbedMaxQualityCover)
data, err := downloadCoverToMemory(coverURL)
if err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to download cover for metadata embed: %v\n", err)
} else if len(data) > 0 {
@@ -72,16 +72,7 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
}
func TestCoverAndIDHSHelpers(t *testing.T) {
if got := upgradeToMaxQuality("https://cdn-images.dzcdn.net/images/cover/abc/500x500-000000-80-0-0.jpg"); !strings.Contains(got, "1900x1900") {
t.Fatalf("deezer cover = %q", got)
}
if got := upgradeToMaxQuality("https://resources.tidal.com/images/id/320x320.jpg"); !strings.Contains(got, "origin.jpg") {
t.Fatalf("tidal cover = %q", got)
}
if got := upgradeToMaxQuality("https://static.qobuz.com/images/covers/ab/cd/foo_600.jpg"); !strings.Contains(got, "_max.jpg") {
t.Fatalf("qobuz cover = %q", got)
}
if data, err := downloadCoverToMemory("", false); err == nil || data != nil {
if data, err := downloadCoverToMemory(""); err == nil || data != nil {
t.Fatalf("expected empty cover error")
}