mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 15:28:45 +02:00
feat(metadata): limit embedded cover resolution
This commit is contained in:
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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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']}');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user