From 887a8ede12da1d30f09ee6f66941f35303637273 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 28 Aug 2026 13:21:05 +0700 Subject: [PATCH] feat(metadata): limit embedded cover resolution --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 10 +- .../zarz/spotiflac/NativeFinalizerMedia.kt | 17 +- go_backend/cover.go | 159 +++++++++++++++++- go_backend/cover_test.go | 121 +++++++++++++ go_backend/exports_download.go | 1 + go_backend/exports_metadata.go | 9 +- go_backend/exports_reenrich.go | 52 +++--- go_backend/extension_fallback_output.go | 2 +- go_backend/go.mod | 1 + go_backend/go.sum | 2 + ios/Runner/AppDelegate.swift | 3 +- lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_de.dart | 10 ++ lib/l10n/app_localizations_en.dart | 10 ++ lib/l10n/app_localizations_es.dart | 10 ++ lib/l10n/app_localizations_fr.dart | 10 ++ lib/l10n/app_localizations_id.dart | 10 ++ lib/l10n/app_localizations_ja.dart | 10 ++ lib/l10n/app_localizations_ko.dart | 10 ++ lib/l10n/app_localizations_pt.dart | 10 ++ lib/l10n/app_localizations_ru.dart | 10 ++ lib/l10n/app_localizations_tr.dart | 10 ++ lib/l10n/app_localizations_uk.dart | 10 ++ lib/l10n/arb/app_en.arb | 12 ++ lib/l10n/arb/app_id.arb | 5 +- lib/models/settings.dart | 8 + lib/models/settings.g.dart | 3 + lib/providers/download_queue_provider.dart | 1 + .../download_queue_provider_embedding.dart | 21 ++- lib/providers/settings_provider.dart | 26 +++ .../settings/metadata_settings_page.dart | 84 +++++++++ .../settings/settings_search_catalog.dart | 6 + lib/screens/track_metadata_lyrics.dart | 1 + lib/services/batch_metadata_re_enrich.dart | 1 + lib/services/download_request_payload.dart | 4 + lib/services/platform_bridge.dart | 6 +- test/batch_metadata_re_enrich_test.dart | 3 +- test/models_and_utils_test.dart | 7 + 38 files changed, 639 insertions(+), 54 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 95c79f2b..16d197f6 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1544,9 +1544,17 @@ class MainActivity: FlutterFragmentActivity() { "downloadCoverToFile" -> { val coverUrl = call.argument("cover_url") ?: "" val outputPath = call.argument("output_path") ?: "" + val maxDimension = call.argument("max_dimension") + ?.toLong() + ?.coerceAtLeast(0L) + ?: 0L val response = withContext(Dispatchers.IO) { try { - Gobackend.downloadCoverToFile(coverUrl, outputPath, false) + Gobackend.downloadCoverToFileSized( + coverUrl, + outputPath, + maxDimension + ) """{"success":true}""" } catch (e: Exception) { """{"success":false,"error":"${e.message?.replace("\"", "'")}"}""" diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt index 12e7fdc5..7a8710a8 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt @@ -483,9 +483,6 @@ internal fun NativeDownloadFinalizer.createMetadataBlockPicture(coverFile: File) } internal fun NativeDownloadFinalizer.detectCoverMimeType(coverFile: File, imageData: ByteArray): String { - val ext = coverFile.extension.lowercase(Locale.ROOT) - if (ext == "png") return "image/png" - if (ext == "jpg" || ext == "jpeg") return "image/jpeg" if (imageData.size >= 8 && imageData[0] == 0x89.toByte() && imageData[1] == 0x50.toByte() && @@ -494,6 +491,15 @@ internal fun NativeDownloadFinalizer.detectCoverMimeType(coverFile: File, imageD ) { return "image/png" } + if (imageData.size >= 3 && + imageData[0] == 0xFF.toByte() && + imageData[1] == 0xD8.toByte() && + imageData[2] == 0xFF.toByte() + ) { + return "image/jpeg" + } + val ext = coverFile.extension.lowercase(Locale.ROOT) + if (ext == "png") return "image/png" return "image/jpeg" } @@ -502,12 +508,13 @@ internal fun NativeDownloadFinalizer.downloadCoverForMetadata(context: Context, if (coverUrl.isBlank()) return null val safeItemId = input.itemId.ifBlank { "item" }.replace(Regex("[^A-Za-z0-9._-]"), "_") + val maxDimension = input.request.optLong("cover_max_dimension", 0L).coerceAtLeast(0L) val output = File.createTempFile("native_cover_${safeItemId}_", ".jpg", context.cacheDir) return try { - Gobackend.downloadCoverToFile( + Gobackend.downloadCoverToFileSized( coverUrl, output.absolutePath, - false + maxDimension ) if (output.exists() && output.length() > 0L) { output diff --git a/go_backend/cover.go b/go_backend/cover.go index 52eb3848..24f7888e 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -4,12 +4,16 @@ import ( "bytes" "fmt" "image" - _ "image/jpeg" - _ "image/png" + _ "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 @@ -30,6 +34,131 @@ func downloadCoverToMemory(coverURL string) ([]byte, error) { 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 @@ -77,17 +206,29 @@ func clearCoverMemoryCache() { // 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[downloadURL]; ok { + if e, ok := coverCache[cacheKey]; ok { if time.Now().Before(e.expiresAt) { data := e.data coverMu.Unlock() return data, nil } - delete(coverCache, downloadURL) + delete(coverCache, cacheKey) coverCacheBytes -= len(e.data) } - if call, ok := coverInflight[downloadURL]; ok { + if call, ok := coverInflight[cacheKey]; ok { coverMu.Unlock() call.wg.Wait() return call.data, call.err @@ -97,20 +238,20 @@ func fetchCoverCached(downloadURL string) ([]byte, error) { // (nil, nil) "success"; overwritten on normal completion. call.err = fmt.Errorf("cover fetch aborted") call.wg.Add(1) - coverInflight[downloadURL] = call + coverInflight[cacheKey] = call coverMu.Unlock() defer func() { call.wg.Done() coverMu.Lock() - delete(coverInflight, downloadURL) + delete(coverInflight, cacheKey) coverMu.Unlock() }() - data, err := coverFetch(downloadURL) + data, err := fetch() call.data, call.err = data, err if err == nil { - coverCachePut(downloadURL, data) + coverCachePut(cacheKey, data) } return data, err } diff --git a/go_backend/cover_test.go b/go_backend/cover_test.go index 2dc2ae6a..4fcc8ec4 100644 --- a/go_backend/cover_test.go +++ b/go_backend/cover_test.go @@ -1,12 +1,44 @@ package gobackend import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" "sync" "sync/atomic" "testing" "time" ) +func encodedTestCover(t *testing.T, width, height int, format string) []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.SetRGBA(x, y, color.RGBA{ + R: uint8(x % 256), + G: uint8(y % 256), + B: uint8((x + y) % 256), + A: uint8(128 + (x+y)%128), + }) + } + } + + var encoded bytes.Buffer + var err error + if format == "png" { + err = png.Encode(&encoded, img) + } else { + err = jpeg.Encode(&encoded, img, &jpeg.Options{Quality: 95}) + } + if err != nil { + t.Fatalf("encode test cover: %v", err) + } + return encoded.Bytes() +} + func resetCoverCache() { coverMu.Lock() coverCache = map[string]*coverCacheEntry{} @@ -118,3 +150,92 @@ func TestDownloadCoverUsesProviderURLUnchanged(t *testing.T) { t.Fatalf("downloaded cover = %q", got) } } + +func TestResizeCoverForEmbeddingPreservesAspectRatio(t *testing.T) { + original := encodedTestCover(t, 1200, 600, "jpeg") + + resized, changed, err := resizeCoverForEmbedding(original, 500) + if err != nil { + t.Fatalf("resize cover: %v", err) + } + if !changed { + t.Fatal("expected oversized artwork to be resized") + } + config, format, err := image.DecodeConfig(bytes.NewReader(resized)) + if err != nil { + t.Fatalf("decode resized cover config: %v", err) + } + if config.Width != 500 || config.Height != 250 { + t.Fatalf("resized dimensions = %dx%d, want 500x250", config.Width, config.Height) + } + if format != "jpeg" { + t.Fatalf("resized format = %q, want jpeg", format) + } +} + +func TestResizeCoverForEmbeddingKeepsSmallArtworkByteForByte(t *testing.T) { + original := encodedTestCover(t, 320, 320, "jpeg") + + resized, changed, err := resizeCoverForEmbedding(original, 500) + if err != nil { + t.Fatalf("resize cover: %v", err) + } + if changed { + t.Fatal("artwork within the limit should not be re-encoded") + } + if !bytes.Equal(resized, original) { + t.Fatal("artwork within the limit was modified") + } +} + +func TestResizeCoverForEmbeddingPreservesPNG(t *testing.T) { + original := encodedTestCover(t, 600, 1200, "png") + + resized, changed, err := resizeCoverForEmbedding(original, 500) + if err != nil { + t.Fatalf("resize PNG cover: %v", err) + } + if !changed { + t.Fatal("expected oversized PNG artwork to be resized") + } + config, format, err := image.DecodeConfig(bytes.NewReader(resized)) + if err != nil { + t.Fatalf("decode resized PNG config: %v", err) + } + if config.Width != 250 || config.Height != 500 { + t.Fatalf("resized dimensions = %dx%d, want 250x500", config.Width, config.Height) + } + if format != "png" { + t.Fatalf("resized format = %q, want png", format) + } +} + +func TestDownloadCoverToMemorySizedCachesDerivedVariant(t *testing.T) { + originalFetch := coverFetch + defer func() { coverFetch = originalFetch }() + resetCoverCache() + + original := encodedTestCover(t, 1200, 600, "jpeg") + var calls int32 + coverFetch = func(string) ([]byte, error) { + atomic.AddInt32(&calls, 1) + return original, nil + } + + for range 2 { + resized, err := downloadCoverToMemorySized( + "https://cdn.example/album.jpg", + 500, + ) + if err != nil { + t.Fatalf("download sized cover: %v", err) + } + width, height := coverDimensions(resized) + if width != 500 || height != 250 { + t.Fatalf("sized cover = %dx%d, want 500x250", width, height) + } + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("provider cover fetched %d times, want once", got) + } +} diff --git a/go_backend/exports_download.go b/go_backend/exports_download.go index cea7a5c5..34068161 100644 --- a/go_backend/exports_download.go +++ b/go_backend/exports_download.go @@ -19,6 +19,7 @@ type DownloadRequest struct { AlbumName string `json:"album_name"` AlbumArtist string `json:"album_artist"` CoverURL string `json:"cover_url"` + CoverMaxDimension int `json:"cover_max_dimension,omitempty"` OutputDir string `json:"output_dir"` OutputPath string `json:"output_path,omitempty"` OutputFD int `json:"output_fd,omitempty"` diff --git a/go_backend/exports_metadata.go b/go_backend/exports_metadata.go index 2eb7e16d..82fe26db 100644 --- a/go_backend/exports_metadata.go +++ b/go_backend/exports_metadata.go @@ -531,11 +531,18 @@ func RewriteSplitArtistTagsExport(filePath, artist, albumArtist string) (string, // native shells. Resolution selection is extension-owned and the value is // intentionally ignored. func DownloadCoverToFile(coverURL string, outputPath string, _ bool) error { + return DownloadCoverToFileSized(coverURL, outputPath, 0) +} + +// DownloadCoverToFileSized downloads provider artwork and optionally caps its +// longest side before writing it. It is a separate export so the legacy +// gomobile ABI remains available to older native shells. +func DownloadCoverToFileSized(coverURL string, outputPath string, maxDimension int) error { if coverURL == "" { return fmt.Errorf("no cover URL provided") } - data, err := downloadCoverToMemory(coverURL) + data, err := downloadCoverToMemorySized(coverURL, maxDimension) if err != nil { return fmt.Errorf("failed to download cover: %w", err) } diff --git a/go_backend/exports_reenrich.go b/go_backend/exports_reenrich.go index 6941b75b..e929eb51 100644 --- a/go_backend/exports_reenrich.go +++ b/go_backend/exports_reenrich.go @@ -19,29 +19,30 @@ var fetchMusicBrainzGenreByISRC = FetchMusicBrainzGenreByISRC var fetchMusicBrainzAlbumArtistByISRC = FetchMusicBrainzAlbumArtistByISRC type reEnrichRequest struct { - FilePath string `json:"file_path"` - CoverURL string `json:"cover_url"` - EmbedLyrics bool `json:"embed_lyrics"` - LyricsMode string `json:"lyrics_mode,omitempty"` - ArtistTagMode string `json:"artist_tag_mode,omitempty"` - SpotifyID string `json:"spotify_id"` - TrackName string `json:"track_name"` - ArtistName string `json:"artist_name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist"` - TrackNumber int `json:"track_number"` - DiscNumber int `json:"disc_number"` - TotalTracks int `json:"total_tracks,omitempty"` - TotalDiscs int `json:"total_discs,omitempty"` - ReleaseDate string `json:"release_date"` - ISRC string `json:"isrc"` - Genre string `json:"genre"` - Label string `json:"label"` - Copyright string `json:"copyright"` - Composer string `json:"composer"` - DurationMs int64 `json:"duration_ms"` - SearchOnline bool `json:"search_online"` - UpdateFields []string `json:"update_fields,omitempty"` + FilePath string `json:"file_path"` + CoverURL string `json:"cover_url"` + CoverMaxDimension int `json:"cover_max_dimension,omitempty"` + EmbedLyrics bool `json:"embed_lyrics"` + LyricsMode string `json:"lyrics_mode,omitempty"` + ArtistTagMode string `json:"artist_tag_mode,omitempty"` + SpotifyID string `json:"spotify_id"` + TrackName string `json:"track_name"` + ArtistName string `json:"artist_name"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist"` + TrackNumber int `json:"track_number"` + DiscNumber int `json:"disc_number"` + TotalTracks int `json:"total_tracks,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` + ReleaseDate string `json:"release_date"` + ISRC string `json:"isrc"` + Genre string `json:"genre"` + Label string `json:"label"` + Copyright string `json:"copyright"` + Composer string `json:"composer"` + DurationMs int64 `json:"duration_ms"` + SearchOnline bool `json:"search_online"` + UpdateFields []string `json:"update_fields,omitempty"` // PreviewOnly resolves the metadata candidate and returns the proposed // values without downloading artwork, fetching lyrics, or touching the // audio file. Batch callers use this to review changes before embedding. @@ -726,7 +727,10 @@ func ReEnrichFile(requestJSON string) (string, error) { var coverTempPath string var coverDataBytes []byte if req.CoverURL != "" && req.shouldUpdateTag("cover", "cover") { - coverData, err := downloadCoverToMemory(req.CoverURL) + coverData, err := downloadCoverToMemorySized( + req.CoverURL, + req.CoverMaxDimension, + ) if err != nil { GoLog("[ReEnrich] Failed to download cover: %v\n", err) } else { diff --git a/go_backend/extension_fallback_output.go b/go_backend/extension_fallback_output.go index 7569c601..bc6b0635 100644 --- a/go_backend/extension_fallback_output.go +++ b/go_backend/extension_fallback_output.go @@ -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) + data, err := downloadCoverToMemorySized(coverURL, req.CoverMaxDimension) if err != nil { GoLog("[DownloadWithExtensionFallback] Warning: failed to download cover for metadata embed: %v\n", err) } else if len(data) > 0 { diff --git a/go_backend/go.mod b/go_backend/go.mod index 545aa88c..1d57a262 100644 --- a/go_backend/go.mod +++ b/go_backend/go.mod @@ -12,6 +12,7 @@ require ( github.com/go-flac/go-flac/v2 v2.0.4 github.com/refraction-networking/utls v1.8.2 golang.org/x/crypto v0.55.0 + golang.org/x/image v0.45.0 golang.org/x/mobile v0.0.0-20260821190718-4776eadac327 golang.org/x/net v0.58.0 golang.org/x/sync v0.22.0 diff --git a/go_backend/go.sum b/go_backend/go.sum index 5cf72b98..c9992e46 100644 --- a/go_backend/go.sum +++ b/go_backend/go.sum @@ -34,6 +34,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/mobile v0.0.0-20260821190718-4776eadac327 h1:D/wiQ6AoTYjDtSD0HMPhU8O40NUP8EF0UmDhIYCnG4I= golang.org/x/mobile v0.0.0-20260821190718-4776eadac327/go.mod h1:D9q8rgXu13Q3uuM+Vuy6F/DG1WF/giTPLtqQ9on5B1M= golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index bad78c30..3bc8e7c9 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -579,7 +579,8 @@ import Gobackend let args = call.arguments as! [String: Any] let coverURL = args["cover_url"] as! String let outputPath = args["output_path"] as! String - GobackendDownloadCoverToFile(coverURL, outputPath, false, &error) + let maxDimension = max(0, (args["max_dimension"] as? NSNumber)?.intValue ?? 0) + GobackendDownloadCoverToFileSized(coverURL, outputPath, maxDimension, &error) if let error = error { throw error } return "{\"success\":true}" diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bb8cb4b8..0a9ea7a5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -8223,6 +8223,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Copy all metadata'** String get metadataCopyAll; + + /// Metadata setting that limits downloaded artwork resolution before it is embedded + /// + /// In en, this message translates to: + /// **'Embedded Cover Size'** + String get optionsEmbeddedCoverSize; + + /// Description shown in the embedded cover size picker + /// + /// In en, this message translates to: + /// **'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.'** + String get optionsEmbeddedCoverSizeDescription; + + /// Option that preserves the provider artwork at its original resolution + /// + /// In en, this message translates to: + /// **'Original resolution'** + String get optionsEmbeddedCoverSizeOriginal; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index c6b33ff1..f2378f8d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -5047,4 +5047,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String get metadataCopyAll => 'Copy all metadata'; + + @override + String get optionsEmbeddedCoverSize => 'Embedded Cover Size'; + + @override + String get optionsEmbeddedCoverSizeDescription => + 'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.'; + + @override + String get optionsEmbeddedCoverSizeOriginal => 'Original resolution'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9cd8e5b3..1c8b0e27 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -5002,4 +5002,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get metadataCopyAll => 'Copy all metadata'; + + @override + String get optionsEmbeddedCoverSize => 'Embedded Cover Size'; + + @override + String get optionsEmbeddedCoverSizeDescription => + 'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.'; + + @override + String get optionsEmbeddedCoverSizeOriginal => 'Original resolution'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 66f0d963..ee2fcfc7 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4997,6 +4997,16 @@ class AppLocalizationsEs extends AppLocalizations { @override String get metadataCopyAll => 'Copy all metadata'; + + @override + String get optionsEmbeddedCoverSize => 'Embedded Cover Size'; + + @override + String get optionsEmbeddedCoverSizeDescription => + 'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.'; + + @override + String get optionsEmbeddedCoverSizeOriginal => 'Original resolution'; } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f724f7f6..1ae15d86 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -5117,4 +5117,14 @@ class AppLocalizationsFr extends AppLocalizations { @override String get metadataCopyAll => 'Copy all metadata'; + + @override + String get optionsEmbeddedCoverSize => 'Embedded Cover Size'; + + @override + String get optionsEmbeddedCoverSizeDescription => + 'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.'; + + @override + String get optionsEmbeddedCoverSizeOriginal => 'Original resolution'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 4009965e..1e153c21 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -5000,4 +5000,14 @@ class AppLocalizationsId extends AppLocalizations { @override String get metadataCopyAll => 'Salin semua metadata'; + + @override + String get optionsEmbeddedCoverSize => 'Ukuran Cover Tertanam'; + + @override + String get optionsEmbeddedCoverSizeDescription => + 'Perkecil cover yang diunduh dari internet sebelum ditanamkan. Gambar yang sudah berada dalam batas tidak akan diubah.'; + + @override + String get optionsEmbeddedCoverSizeOriginal => 'Resolusi asli'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 75053dea..6aad2285 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -4990,4 +4990,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String get metadataCopyAll => 'Copy all metadata'; + + @override + String get optionsEmbeddedCoverSize => 'Embedded Cover Size'; + + @override + String get optionsEmbeddedCoverSizeDescription => + 'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.'; + + @override + String get optionsEmbeddedCoverSizeOriginal => 'Original resolution'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 874f9177..403a6977 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -4871,4 +4871,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String get metadataCopyAll => 'Copy all metadata'; + + @override + String get optionsEmbeddedCoverSize => 'Embedded Cover Size'; + + @override + String get optionsEmbeddedCoverSizeDescription => + 'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.'; + + @override + String get optionsEmbeddedCoverSizeOriginal => 'Original resolution'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 6aa3bef6..ff64540c 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4996,6 +4996,16 @@ class AppLocalizationsPt extends AppLocalizations { @override String get metadataCopyAll => 'Copy all metadata'; + + @override + String get optionsEmbeddedCoverSize => 'Embedded Cover Size'; + + @override + String get optionsEmbeddedCoverSizeDescription => + 'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.'; + + @override + String get optionsEmbeddedCoverSizeOriginal => 'Original resolution'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index a93ca62f..e9f4a3a0 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -5033,4 +5033,14 @@ class AppLocalizationsRu extends AppLocalizations { @override String get metadataCopyAll => 'Copy all metadata'; + + @override + String get optionsEmbeddedCoverSize => 'Embedded Cover Size'; + + @override + String get optionsEmbeddedCoverSizeDescription => + 'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.'; + + @override + String get optionsEmbeddedCoverSizeOriginal => 'Original resolution'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 8d3962c2..dd879b9b 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -5032,4 +5032,14 @@ class AppLocalizationsTr extends AppLocalizations { @override String get metadataCopyAll => 'Copy all metadata'; + + @override + String get optionsEmbeddedCoverSize => 'Embedded Cover Size'; + + @override + String get optionsEmbeddedCoverSizeDescription => + 'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.'; + + @override + String get optionsEmbeddedCoverSizeOriginal => 'Original resolution'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 5a24f8a7..087ec0b0 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -5050,4 +5050,14 @@ class AppLocalizationsUk extends AppLocalizations { @override String get metadataCopyAll => 'Copy all metadata'; + + @override + String get optionsEmbeddedCoverSize => 'Embedded Cover Size'; + + @override + String get optionsEmbeddedCoverSizeDescription => + 'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.'; + + @override + String get optionsEmbeddedCoverSizeOriginal => 'Original resolution'; } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 2539f40a..c87a9805 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -6475,5 +6475,17 @@ "metadataCopyAll": "Copy all metadata", "@metadataCopyAll": { "description": "Metadata menu action that copies every visible key and value" + }, + "optionsEmbeddedCoverSize": "Embedded Cover Size", + "@optionsEmbeddedCoverSize": { + "description": "Metadata setting that limits downloaded artwork resolution before it is embedded" + }, + "optionsEmbeddedCoverSizeDescription": "Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.", + "@optionsEmbeddedCoverSizeDescription": { + "description": "Description shown in the embedded cover size picker" + }, + "optionsEmbeddedCoverSizeOriginal": "Original resolution", + "@optionsEmbeddedCoverSizeOriginal": { + "description": "Option that preserves the provider artwork at its original resolution" } } diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 28ba0847..c00183de 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -6153,5 +6153,8 @@ "trackOptionCopyTrackAndArtist": "Salin judul dan artis", "metadataCopyValue": "Salin nilai", "metadataCopyField": "Salin field dan nilai", - "metadataCopyAll": "Salin semua metadata" + "metadataCopyAll": "Salin semua metadata", + "optionsEmbeddedCoverSize": "Ukuran Cover Tertanam", + "optionsEmbeddedCoverSizeDescription": "Perkecil cover yang diunduh dari internet sebelum ditanamkan. Gambar yang sudah berada dalam batas tidak akan diubah.", + "optionsEmbeddedCoverSizeOriginal": "Resolusi asli" } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 3860628f..0459e8a5 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -21,6 +21,10 @@ class AppSettings { final String downloadTreeUri; // SAF persistable tree URI final bool autoFallback; final bool embedMetadata; + + /// Maximum width or height of remotely fetched artwork before embedding. + /// Zero preserves the provider's original image. + final int embeddedCoverMaxDimension; final String artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats final bool embedLyrics; @@ -131,6 +135,7 @@ class AppSettings { this.downloadTreeUri = '', this.autoFallback = true, this.embedMetadata = true, + this.embeddedCoverMaxDimension = 0, this.artistTagMode = artistTagModeJoined, this.embedLyrics = true, this.embedReplayGain = false, @@ -205,6 +210,7 @@ class AppSettings { String? downloadTreeUri, bool? autoFallback, bool? embedMetadata, + int? embeddedCoverMaxDimension, String? artistTagMode, bool? embedLyrics, bool? embedReplayGain, @@ -282,6 +288,8 @@ class AppSettings { downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, autoFallback: autoFallback ?? this.autoFallback, embedMetadata: embedMetadata ?? this.embedMetadata, + embeddedCoverMaxDimension: + embeddedCoverMaxDimension ?? this.embeddedCoverMaxDimension, artistTagMode: artistTagMode ?? this.artistTagMode, embedLyrics: embedLyrics ?? this.embedLyrics, embedReplayGain: embedReplayGain ?? this.embedReplayGain, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 4b72643c..16872407 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -16,6 +16,8 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( downloadTreeUri: json['downloadTreeUri'] as String? ?? '', autoFallback: json['autoFallback'] as bool? ?? true, embedMetadata: json['embedMetadata'] as bool? ?? true, + embeddedCoverMaxDimension: + (json['embeddedCoverMaxDimension'] as num?)?.toInt() ?? 0, artistTagMode: json['artistTagMode'] as String? ?? artistTagModeJoined, embedLyrics: json['embedLyrics'] as bool? ?? true, embedReplayGain: json['embedReplayGain'] as bool? ?? false, @@ -111,6 +113,7 @@ Map _$AppSettingsToJson( 'downloadTreeUri': instance.downloadTreeUri, 'autoFallback': instance.autoFallback, 'embedMetadata': instance.embedMetadata, + 'embeddedCoverMaxDimension': instance.embeddedCoverMaxDimension, 'artistTagMode': instance.artistTagMode, 'embedLyrics': instance.embedLyrics, 'embedReplayGain': instance.embedReplayGain, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index d6ff4a63..f7266a23 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -849,6 +849,7 @@ class DownloadQueueNotifier extends Notifier { albumName: track.albumName, albumArtist: resolvedAlbumArtist ?? '', coverUrl: settings.embedMetadata ? (track.coverUrl ?? '') : '', + coverMaxDimension: settings.embeddedCoverMaxDimension, outputDir: outputDir, filenameFormat: filenameFormat, quality: quality, diff --git a/lib/providers/download_queue_provider_embedding.dart b/lib/providers/download_queue_provider_embedding.dart index 3cd67ed7..83c15322 100644 --- a/lib/providers/download_queue_provider_embedding.dart +++ b/lib/providers/download_queue_provider_embedding.dart @@ -657,7 +657,10 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { // Started here, awaited only after the lyrics fetch below so the two // network round trips overlap. Errors are handled inside the fetch // (it resolves to null), never as an unhandled rejection. - coverFuture = _sharedEmbedCover(coverUrl); + coverFuture = _sharedEmbedCover( + coverUrl, + settings.embeddedCoverMaxDimension, + ); } String? lrcContent; @@ -1019,24 +1022,25 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { static const _embedCoverCacheMax = 8; /// One cover fetch per URL, shared by every track in the batch. - Future _sharedEmbedCover(String coverUrl) { - final existing = _embedCoverCache.remove(coverUrl); + Future _sharedEmbedCover(String coverUrl, int maxDimension) { + final cacheKey = '$maxDimension\u0000$coverUrl'; + final existing = _embedCoverCache.remove(cacheKey); if (existing != null) { - _embedCoverCache[coverUrl] = existing; // LRU touch + _embedCoverCache[cacheKey] = existing; // LRU touch return existing; } - final fetch = _downloadEmbedCover(coverUrl).then((path) { - if (path == null) _embedCoverCache.remove(coverUrl); // allow retry + final fetch = _downloadEmbedCover(coverUrl, maxDimension).then((path) { + if (path == null) _embedCoverCache.remove(cacheKey); // allow retry return path; }); - _embedCoverCache[coverUrl] = fetch; + _embedCoverCache[cacheKey] = fetch; while (_embedCoverCache.length > _embedCoverCacheMax) { _evictEmbedCover(_embedCoverCache.keys.first); } return fetch; } - Future _downloadEmbedCover(String coverUrl) async { + Future _downloadEmbedCover(String coverUrl, int maxDimension) async { try { final tempDir = await getTemporaryDirectory(); final uniqueId = @@ -1047,6 +1051,7 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { final result = await PlatformBridge.downloadCoverToFile( coverUrl, coverPath, + maxDimension: maxDimension, ); if (result['error'] != null) { _log.w('Failed to download cover: ${result['error']}'); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 5d209105..c967064b 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -108,6 +108,13 @@ class SettingsNotifier extends Notifier { 'external_first', 'in_app_first', }; + static const Set _embeddedCoverMaxDimensionValues = { + 0, + 500, + 1000, + 1500, + 2000, + }; final Future _prefs = SharedPreferences.getInstance(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); @@ -169,6 +176,9 @@ class SettingsNotifier extends Notifier { autoConvertBitrate: normalizeAutoConvertBitrate( loaded.autoConvertBitrate, ), + embeddedCoverMaxDimension: _normalizeEmbeddedCoverMaxDimension( + loaded.embeddedCoverMaxDimension, + ), defaultService: loaded.defaultService, searchProvider: loaded.searchProvider, extensionVerificationBrowserMode: @@ -327,6 +337,9 @@ class SettingsNotifier extends Notifier { downloadDirectoryBookmark: current.downloadDirectoryBookmark, storageMode: current.storageMode, downloadTreeUri: current.downloadTreeUri, + embeddedCoverMaxDimension: _normalizeEmbeddedCoverMaxDimension( + restored.embeddedCoverMaxDimension, + ), ); await _saveSettings(); @@ -401,6 +414,10 @@ class SettingsNotifier extends Notifier { return 'in_app_first'; } + int _normalizeEmbeddedCoverMaxDimension(int value) { + return _embeddedCoverMaxDimensionValues.contains(value) ? value : 0; + } + String? _sanitizeRetiredBuiltInProviderId(String? providerId) { final normalized = providerId?.trim().toLowerCase(); if (normalized == null || normalized.isEmpty) return providerId; @@ -528,6 +545,15 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setEmbeddedCoverMaxDimension(int maxDimension) { + state = state.copyWith( + embeddedCoverMaxDimension: _normalizeEmbeddedCoverMaxDimension( + maxDimension, + ), + ); + _saveSettings(); + } + void setArtistTagMode(String mode) { if (mode == artistTagModeJoined || mode == artistTagModeSplitVorbis) { state = state.copyWith(artistTagMode: mode); diff --git a/lib/screens/settings/metadata_settings_page.dart b/lib/screens/settings/metadata_settings_page.dart index a55eeb78..04bab912 100644 --- a/lib/screens/settings/metadata_settings_page.dart +++ b/lib/screens/settings/metadata_settings_page.dart @@ -52,6 +52,19 @@ class MetadataSettingsPage extends ConsumerWidget { settings.artistTagMode, ), ), + SettingsItem( + icon: Icons.photo_size_select_large_outlined, + title: context.l10n.optionsEmbeddedCoverSize, + subtitle: _getEmbeddedCoverSizeLabel( + context, + settings.embeddedCoverMaxDimension, + ), + onTap: () => _showEmbeddedCoverSizePicker( + context, + ref, + settings.embeddedCoverMaxDimension, + ), + ), SettingsSwitchItem( icon: Icons.graphic_eq, title: context.l10n.optionsReplayGain, @@ -146,6 +159,77 @@ class MetadataSettingsPage extends ConsumerWidget { } } + String _getEmbeddedCoverSizeLabel(BuildContext context, int maxDimension) { + if (maxDimension <= 0) { + return context.l10n.optionsEmbeddedCoverSizeOriginal; + } + return '$maxDimension × $maxDimension px'; + } + + void _showEmbeddedCoverSizePicker( + BuildContext context, + WidgetRef ref, + int currentMaxDimension, + ) { + const options = [0, 500, 1000, 1500, 2000]; + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + backgroundColor: colorScheme.surfaceContainerHigh, + builder: (context) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + context.l10n.optionsEmbeddedCoverSize, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: Text( + context.l10n.optionsEmbeddedCoverSizeDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + for (final maxDimension in options) + ListTile( + leading: Icon( + maxDimension == 0 + ? Icons.image_outlined + : Icons.compress_outlined, + ), + title: Text( + _getEmbeddedCoverSizeLabel(context, maxDimension), + ), + trailing: currentMaxDimension == maxDimension + ? const Icon(Icons.check) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setEmbeddedCoverMaxDimension(maxDimension); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + } + void _showArtistTagModePicker( BuildContext context, WidgetRef ref, diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/screens/settings/settings_search_catalog.dart index d0c7f797..4348505e 100644 --- a/lib/screens/settings/settings_search_catalog.dart +++ b/lib/screens/settings/settings_search_catalog.dart @@ -242,6 +242,12 @@ class SettingsSearchCatalog { title: l10n.optionsArtistTagMode, keywords: const ['artist separator', 'multiple artists'], ), + SettingsSearchEntry( + icon: Icons.photo_size_select_large_outlined, + title: l10n.optionsEmbeddedCoverSize, + subtitle: l10n.optionsEmbeddedCoverSizeDescription, + keywords: const ['cover size', 'artwork resolution', 'resize image'], + ), SettingsSearchEntry( icon: Icons.graphic_eq, title: l10n.optionsReplayGain, diff --git a/lib/screens/track_metadata_lyrics.dart b/lib/screens/track_metadata_lyrics.dart index b9429c24..a6a79b9c 100644 --- a/lib/screens/track_metadata_lyrics.dart +++ b/lib/screens/track_metadata_lyrics.dart @@ -992,6 +992,7 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { final request = { 'file_path': cleanFilePath, 'cover_url': _coverUrl ?? '', + 'cover_max_dimension': settings.embeddedCoverMaxDimension, 'embed_lyrics': settings.embedLyrics, 'lyrics_mode': settings.lyricsMode, 'artist_tag_mode': artistTagMode, diff --git a/lib/services/batch_metadata_re_enrich.dart b/lib/services/batch_metadata_re_enrich.dart index 75c6419e..6046109c 100644 --- a/lib/services/batch_metadata_re_enrich.dart +++ b/lib/services/batch_metadata_re_enrich.dart @@ -81,6 +81,7 @@ Map buildBatchReEnrichRequest({ final request = { 'file_path': item.filePath, 'cover_url': '', + 'cover_max_dimension': settings.embeddedCoverMaxDimension, 'embed_lyrics': settings.embedLyrics, 'lyrics_mode': settings.lyricsMode, 'artist_tag_mode': settings.artistTagMode, diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index c5ce5fc2..aa7c2d62 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -12,6 +12,7 @@ class DownloadRequestPayload { final String albumName; final String albumArtist; final String coverUrl; + final int coverMaxDimension; final String outputDir; final String filenameFormat; final String quality; @@ -74,6 +75,7 @@ class DownloadRequestPayload { required this.albumName, this.albumArtist = '', this.coverUrl = '', + this.coverMaxDimension = 0, required this.outputDir, required this.filenameFormat, this.quality = 'LOSSLESS', @@ -138,6 +140,7 @@ class DownloadRequestPayload { 'album_name': albumName, 'album_artist': albumArtist, 'cover_url': coverUrl, + 'cover_max_dimension': coverMaxDimension, 'output_dir': outputDir, 'filename_format': filenameFormat, 'quality': quality, @@ -206,6 +209,7 @@ class DownloadRequestPayload { albumName: albumName, albumArtist: albumArtist, coverUrl: coverUrl, + coverMaxDimension: coverMaxDimension, outputDir: outputDir, filenameFormat: filenameFormat, quality: quality, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index b450e11d..f1286430 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -956,11 +956,13 @@ class PlatformBridge { static Future> downloadCoverToFile( String coverUrl, - String outputPath, - ) { + String outputPath, { + int maxDimension = 0, + }) { return _invokeMap('downloadCoverToFile', { 'cover_url': coverUrl, 'output_path': outputPath, + 'max_dimension': maxDimension, }); } diff --git a/test/batch_metadata_re_enrich_test.dart b/test/batch_metadata_re_enrich_test.dart index e0a91b4c..3fdbf40c 100644 --- a/test/batch_metadata_re_enrich_test.dart +++ b/test/batch_metadata_re_enrich_test.dart @@ -54,7 +54,7 @@ void main() { test('resolved preview metadata is reused without another online search', () { final request = buildBatchReEnrichRequest( item: _item(), - settings: const AppSettings(), + settings: const AppSettings(embeddedCoverMaxDimension: 1000), updateFields: const ['isrc'], resolvedMetadata: const { 'isrc': 'USRC17607839', @@ -66,6 +66,7 @@ void main() { expect(request['update_fields'], const ['isrc']); expect(request['isrc'], 'USRC17607839'); expect(request['spotify_id'], 'resolved-id'); + expect(request['cover_max_dimension'], 1000); }); test('review only includes values that would actually change', () { diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index aeaa48c6..c29f071a 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -882,6 +882,7 @@ void main() { expect(settings.audioQuality, 'LOSSLESS'); expect(settings.filenameFormat, '{title} - {artist}'); expect(settings.artistTagMode, artistTagModeJoined); + expect(settings.embeddedCoverMaxDimension, 0); expect(settings.autoFallback, isTrue); expect(settings.lyricsProviders, ['lrclib', 'apple_music']); expect(settings.lyricsAppleElrcWordSync, isFalse); @@ -907,6 +908,7 @@ void main() { final updated = settings.copyWith( defaultService: 'tidal', embedReplayGain: true, + embeddedCoverMaxDimension: 1000, lyricsProviders: ['apple_music'], lyricsAppleElrcWordSync: true, deduplicateDownloads: false, @@ -919,6 +921,7 @@ void main() { expect(updated.defaultService, 'tidal'); expect(updated.embedReplayGain, isTrue); + expect(updated.embeddedCoverMaxDimension, 1000); expect(updated.lyricsProviders, ['apple_music']); expect(updated.lyricsAppleElrcWordSync, isTrue); expect(updated.deduplicateDownloads, isFalse); @@ -957,6 +960,7 @@ void main() { autoConvertFormat: 'opus', autoConvertBitrate: '192k', libraryQualityLabelMode: AppSettings.libraryQualityLabelBitDepthOnly, + embeddedCoverMaxDimension: 1500, ); final decoded = AppSettings.fromJson(settings.toJson()); @@ -986,6 +990,7 @@ void main() { expect(decoded.autoConvertDownloads, isTrue); expect(decoded.autoConvertFormat, 'opus'); expect(decoded.autoConvertBitrate, '192k'); + expect(decoded.embeddedCoverMaxDimension, 1500); }); }); @@ -1026,6 +1031,7 @@ void main() { albumName: 'Album', albumArtist: 'Album Artist', coverUrl: 'https://example.test/cover.jpg', + coverMaxDimension: 1000, outputDir: '/downloads', filenameFormat: '{artist} - {title}', quality: 'HI_RES', @@ -1086,6 +1092,7 @@ void main() { 'album_name': 'Album', 'album_artist': 'Album Artist', 'cover_url': 'https://example.test/cover.jpg', + 'cover_max_dimension': 1000, 'output_dir': '/downloads', 'filename_format': '{artist} - {title}', 'quality': 'HI_RES',