feat(metadata): limit embedded cover resolution

This commit is contained in:
zarzet committed 2026-08-28 13:21:05 +07:00
1 parent 45d9bbd7b2
commit 887a8ede12
38 files changed
+639 -54

No files matched your search

@@ -1544,9 +1544,17 @@ class MainActivity: FlutterFragmentActivity() {
"downloadCoverToFile" -> {
val coverUrl = call.argument<String>("cover_url") ?: ""
val outputPath = call.argument<String>("output_path") ?: ""
val maxDimension = call.argument<Number>("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("\"", "'")}"}"""
@@ -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
+150 -9
View File
@@ -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
}
+121
View File
@@ -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)
}
}
+1
View File
@@ -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"`
+8 -1
View File
@@ -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)
}
+28 -24
View File
@@ -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 {
+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)
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 {
+1
View File
@@ -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
+2
View File
@@ -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=
+2 -1
View File
@@ -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}"
+18
View File
@@ -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
+10
View File
@@ -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';
}
+10
View File
@@ -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';
}
+10
View File
@@ -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`).
+10
View File
@@ -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';
}
+10
View File
@@ -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';
}
+10
View File
@@ -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';
}
+10
View File
@@ -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';
}
+10
View File
@@ -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`).
+10
View File
@@ -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';
}
+10
View File
@@ -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';
}
+10
View File
@@ -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';
}
+12
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
+8
View File
@@ -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,
+3
View File
@@ -16,6 +16,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(
'downloadTreeUri': instance.downloadTreeUri,
'autoFallback': instance.autoFallback,
'embedMetadata': instance.embedMetadata,
'embeddedCoverMaxDimension': instance.embeddedCoverMaxDimension,
'artistTagMode': instance.artistTagMode,
'embedLyrics': instance.embedLyrics,
'embedReplayGain': instance.embedReplayGain,
@@ -849,6 +849,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumName: track.albumName,
albumArtist: resolvedAlbumArtist ?? '',
coverUrl: settings.embedMetadata ? (track.coverUrl ?? '') : '',
coverMaxDimension: settings.embeddedCoverMaxDimension,
outputDir: outputDir,
filenameFormat: filenameFormat,
quality: quality,
@@ -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<String?> _sharedEmbedCover(String coverUrl) {
final existing = _embedCoverCache.remove(coverUrl);
Future<String?> _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<String?> _downloadEmbedCover(String coverUrl) async {
Future<String?> _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']}');
+26
View File
@@ -108,6 +108,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
'external_first',
'in_app_first',
};
static const Set<int> _embeddedCoverMaxDimensionValues = {
0,
500,
1000,
1500,
2000,
};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -169,6 +176,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
autoConvertBitrate: normalizeAutoConvertBitrate(
loaded.autoConvertBitrate,
),
embeddedCoverMaxDimension: _normalizeEmbeddedCoverMaxDimension(
loaded.embeddedCoverMaxDimension,
),
defaultService: loaded.defaultService,
searchProvider: loaded.searchProvider,
extensionVerificationBrowserMode:
@@ -327,6 +337,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
downloadDirectoryBookmark: current.downloadDirectoryBookmark,
storageMode: current.storageMode,
downloadTreeUri: current.downloadTreeUri,
embeddedCoverMaxDimension: _normalizeEmbeddedCoverMaxDimension(
restored.embeddedCoverMaxDimension,
),
);
await _saveSettings();
@@ -401,6 +414,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
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<AppSettings> {
_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);
@@ -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<void>(
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,
@@ -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,
+1
View File
@@ -992,6 +992,7 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
final request = <String, dynamic>{
'file_path': cleanFilePath,
'cover_url': _coverUrl ?? '',
'cover_max_dimension': settings.embeddedCoverMaxDimension,
'embed_lyrics': settings.embedLyrics,
'lyrics_mode': settings.lyricsMode,
'artist_tag_mode': artistTagMode,
@@ -81,6 +81,7 @@ Map<String, dynamic> buildBatchReEnrichRequest({
final request = <String, dynamic>{
'file_path': item.filePath,
'cover_url': '',
'cover_max_dimension': settings.embeddedCoverMaxDimension,
'embed_lyrics': settings.embedLyrics,
'lyrics_mode': settings.lyricsMode,
'artist_tag_mode': settings.artistTagMode,
@@ -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,
+4 -2
View File
@@ -956,11 +956,13 @@ class PlatformBridge {
static Future<Map<String, dynamic>> downloadCoverToFile(
String coverUrl,
String outputPath,
) {
String outputPath, {
int maxDimension = 0,
}) {
return _invokeMap('downloadCoverToFile', {
'cover_url': coverUrl,
'output_path': outputPath,
'max_dimension': maxDimension,
});
}
+2 -1
View File
@@ -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', () {
+7
View File
@@ -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',