From 452d9a76cb5d588dab3dd1b4c637e051e8692ba9 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 29 Jul 2026 02:42:46 +0700 Subject: [PATCH] fix(metadata): verify maximum cover candidates --- go_backend/cover.go | 167 +++++++++++++++--- go_backend/cover_test.go | 91 ++++++++++ .../extension_health_misc_supplement_test.go | 2 +- 3 files changed, 230 insertions(+), 30 deletions(-) diff --git a/go_backend/cover.go b/go_backend/cover.go index 16258c46..702e3ff0 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -1,7 +1,11 @@ package gobackend import ( + "bytes" "fmt" + "image" + _ "image/jpeg" + _ "image/png" "io" "net/http" "regexp" @@ -16,8 +20,11 @@ const ( spotifySizeMax = "ab67616d000082c1" ) -// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800 -var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`) +// 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$`) @@ -42,27 +49,137 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { GoLog("[Cover] Upgraded 300x300 → 640x640") } - if maxQuality { - maxURL := upgradeToMaxQuality(downloadURL) - if maxURL != downloadURL { - downloadURL = maxURL - if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") { - GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)") - } + if !maxQuality { + GoLog("[Cover] Final URL: %s", downloadURL) + data, err := fetchCoverCached(downloadURL) + if err != nil { + return nil, err } + return append([]byte(nil), data...), nil } - GoLog("[Cover] Final URL: %s", downloadURL) - - data, err := fetchCoverCached(downloadURL) + candidates := maxQualityCoverCandidateURLs(downloadURL) + data, selectedURL, width, height, err := fetchBestCoverCandidate(candidates) if err != nil { - return nil, err + // 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 @@ -185,16 +302,8 @@ func fetchCoverBytes(downloadURL string) ([]byte, error) { return nil, fmt.Errorf("failed to read cover data: %w", err) } - sizeKB := len(data) / 1024 - var resolution string - if sizeKB > 200 { - resolution = "~2000x2000 (hi-res)" - } else if sizeKB > 50 { - resolution = "~640x640" - } else { - resolution = "~300x300" - } - GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution) + width, height := coverDimensions(data) + GoLog("[Cover] Downloaded %d KB (%dx%d)", len(data)/1024, width, height) return data, nil } @@ -204,8 +313,8 @@ func upgradeToMaxQuality(coverURL string) string { return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1) } - if strings.Contains(coverURL, "cdn-images.dzcdn.net") { - return upgradeDeezerCover(coverURL) + if squareCoverSizeRegex.MatchString(coverURL) { + return upgradeSquareCover(coverURL) } if strings.Contains(coverURL, "resources.tidal.com") { @@ -219,14 +328,14 @@ func upgradeToMaxQuality(coverURL string) string { return coverURL } -func upgradeDeezerCover(coverURL string) string { - if !strings.Contains(coverURL, "cdn-images.dzcdn.net") { +func upgradeSquareCover(coverURL string) string { + if !squareCoverSizeRegex.MatchString(coverURL) { return coverURL } - upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg") + upgraded := squareCoverSizeRegex.ReplaceAllString(coverURL, "/1900x1900-000000-80-0-0.jpg") if upgraded != coverURL { - GoLog("[Cover] Deezer: upgraded to 1800x1800") + GoLog("[Cover] Square CDN: probing 1900x1900 and 1500x1500") } return upgraded } diff --git a/go_backend/cover_test.go b/go_backend/cover_test.go index 3c915e73..89154033 100644 --- a/go_backend/cover_test.go +++ b/go_backend/cover_test.go @@ -1,12 +1,33 @@ 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{} @@ -94,3 +115,73 @@ func TestFetchCoverCachedTTLExpiry(t *testing.T) { t.Fatalf("expected refetch after TTL expiry, got %d fetches", got) } } + +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) { + orig := coverFetch + defer func() { coverFetch = orig }() + resetCoverCache() + + advertised1900ButSmaller := append(testPNG(t, 12, 12), bytes.Repeat([]byte{0}, 4096)...) + actualLargerDimensions := testPNG(t, 15, 15) + 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") + } + } + + url := "https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg" + got, err := downloadCoverToMemory(url, true) + if err != nil { + t.Fatalf("download max cover: %v", err) + } + if !bytes.Equal(got, actualLargerDimensions) { + t.Fatal("expected decoded 15x15 candidate instead of larger-byte 12x12 candidate") + } +} + +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") + } +} diff --git a/go_backend/extension_health_misc_supplement_test.go b/go_backend/extension_health_misc_supplement_test.go index 94bcf6d8..4fe15fdb 100644 --- a/go_backend/extension_health_misc_supplement_test.go +++ b/go_backend/extension_health_misc_supplement_test.go @@ -72,7 +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, "1800x1800") { + 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") {