From 4ddb0774ecb2c7d4dd714daf841994bc9a11ac1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 13 Mar 2026 09:35:59 -0400 Subject: [PATCH] perf(artwork): improve image serving performance with WebP encoding and optimized pipeline (#5181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(artwork): add benchmark helpers for generating test images * test(artwork): add image decode benchmarks for JPEG/PNG at various sizes * test(artwork): add image resize benchmarks for Lanczos at various sizes * test(artwork): add image encode benchmarks for JPEG quality levels and PNG * test(artwork): add full resize pipeline benchmark (decode+resize+encode) * test(artwork): add tag extraction benchmark for embedded art * test(cache): add file cache benchmarks for read, write, and concurrent access * test(artwork): add E2E benchmarks for artwork.Get with cache on/off and concurrency * fix(test): use absolute path for tag extraction benchmark fixture * test(artwork): add resize alternatives benchmark comparing resamplers * perf(artwork): switch to CatmullRom resampler and JPEG for square images Replace imaging.Lanczos with imaging.CatmullRom for image resizing (30% faster, indistinguishable quality at thumbnail sizes). Stop forcing PNG encoding for square images when the source is JPEG — JPEG is smaller and faster to encode. Square images from JPEG sources went from 52ms to 10ms (80% improvement). Add sync.Pool for encode buffers to reduce GC pressure under concurrent load. * perf(artwork): increase cache warmer concurrency from 2 to 4 workers Resize is CPU-bound, so more workers improve throughput on multi-core systems. Doubled worker count to better utilize available cores during background cache warming. * perf(artwork): switch to xdraw.ApproxBiLinear and always encode as JPEG Replace disintegration/imaging with golang.org/x/image/draw for image resizing. This eliminates ~92K allocations per resize (from imaging's internal goroutine parallelism) down to ~20, reducing GC pressure under concurrent load. Always encode resized artwork as JPEG regardless of source format, since cover art doesn't need transparency. This is ~5x faster than PNG encode and produces much smaller output (e.g. 18KB JPEG vs 124KB PNG). * perf(artwork): skip external API call when artist image URL is cached ArtistImage() was always calling the external agent (Spotify/Last.fm) to get the image URL, even when the artist already had URLs stored in the database. This caused every artist image request to block on an external API call, creating severe serialization when loading artist grids (5-20 seconds for the first page). Now use the stored URL directly when available. Artists with no stored URL still fetch synchronously. Background refresh via UpdateArtistInfo handles TTL-based URL updates. * perf(artwork): increase getCoverArt throttle from NumCPU/3 to NumCPU The previous default of max(2, NumCPU/3) was too aggressive for artist images which are I/O-bound (downloading from external CDNs), not CPU-bound. On an 8-core machine this meant only 2 concurrent requests, causing a staircase pattern where 12 images took ~2.4s wall-clock. Bumping to max(4, NumCPU) cuts wall-clock time by ~50% for artist image grids while still preventing unbounded concurrency for CPU-bound resizes. * perf(artwork): encode resized images as WebP instead of JPEG Switch from JPEG to WebP encoding for resized artwork using gen2brain/webp (libwebp via WASM, no CGo). WebP produces ~74% smaller output at the same quality with only ~25% slower full-pipeline encode time (cached, so only paid once per artwork+size). Use NRGBA image type to preserve alpha channel in WebP output, and transparent padding for square canvas instead of black. Also removes the disintegration/imaging dependency entirely by replacing imaging.Fill in playlist tile generation with a custom fillCenter function using xdraw.ApproxBiLinear. * perf(artwork): switch from ApproxBiLinear to BiLinear scaling for improved image processing Signed-off-by: Deluan * refactor(configuration): rename CoverJpegQuality to CoverArtQuality and update references Signed-off-by: Deluan * feat(artwork): add DevJpegCoverArt option to control JPEG encoding for cover art Signed-off-by: Deluan * fix(artwork): remove redundant transparent fill and handle encode errors in resizeImage Removed a no-op draw.Draw call that filled the NRGBA canvas with transparent pixels — NewNRGBA already zero-initializes to fully transparent. Also added an early return on encode failure to avoid allocating and copying potentially corrupt buffer data before returning the error. * fix(configuration): reorder default agents (deezer is faster) Signed-off-by: Deluan * fix(test): resolve dogsled lint warning in tag extraction benchmark Use all return values from runtime.Caller instead of discarding three with blank identifiers, which triggered the dogsled linter. * fix(artwork): revert cache key format Signed-off-by: Deluan * fix(configuration): remove deprecated CoverJpegQuality field and update references to CoverArtQuality Signed-off-by: Deluan --------- Signed-off-by: Deluan --- conf/configuration.go | 12 +- core/artwork/artwork_internal_test.go | 81 ++++++++-- core/artwork/benchmark_decode_test.go | 37 +++++ core/artwork/benchmark_e2e_test.go | 189 ++++++++++++++++++++++++ core/artwork/benchmark_encode_test.go | 40 +++++ core/artwork/benchmark_helpers_test.go | 47 ++++++ core/artwork/benchmark_pipeline_test.go | 51 +++++++ core/artwork/benchmark_tag_test.go | 38 +++++ core/artwork/cache_warmer.go | 2 +- core/artwork/reader_playlist.go | 33 ++++- core/artwork/reader_resized.go | 72 ++++++--- core/external/provider.go | 16 +- go.mod | 3 +- go.sum | 7 +- utils/cache/benchmark_test.go | 171 +++++++++++++++++++++ 15 files changed, 749 insertions(+), 50 deletions(-) create mode 100644 core/artwork/benchmark_decode_test.go create mode 100644 core/artwork/benchmark_e2e_test.go create mode 100644 core/artwork/benchmark_encode_test.go create mode 100644 core/artwork/benchmark_helpers_test.go create mode 100644 core/artwork/benchmark_pipeline_test.go create mode 100644 core/artwork/benchmark_tag_test.go create mode 100644 utils/cache/benchmark_test.go diff --git a/conf/configuration.go b/conf/configuration.go index da549ce26..b867e0d56 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -69,7 +69,7 @@ type configOptions struct { MPVPath string MPVCmdTemplate string CoverArtPriority string - CoverJpegQuality int + CoverArtQuality int ArtistArtPriority string LyricsPriority string EnableGravatar bool @@ -140,6 +140,7 @@ type configOptions struct { DevOptimizeDB bool DevPreserveUnicodeInExternalCalls bool DevEnableMediaFileProbe bool + DevJpegCoverArt bool } type scannerOptions struct { @@ -283,6 +284,7 @@ func Load(noConfigDump bool) { mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources") mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader") mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") + mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") err := viper.Unmarshal(&Server) if err != nil { @@ -415,6 +417,7 @@ func Load(noConfigDump bool) { logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources") logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader") logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") + logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality") // Call init hooks for _, hook := range hooks { @@ -655,7 +658,7 @@ func setViperDefaults() { viper.SetDefault("ffmpegpath", "") viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s") viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external") - viper.SetDefault("coverjpegquality", 75) + viper.SetDefault("coverartquality", 75) viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") viper.SetDefault("enablegravatar", false) @@ -707,7 +710,7 @@ func setViperDefaults() { viper.SetDefault("subsonic.enableaveragerating", true) viper.SetDefault("subsonic.legacyclients", "DSub") viper.SetDefault("subsonic.minimalclients", "SubMusic") - viper.SetDefault("agents", "lastfm,spotify,deezer") + viper.SetDefault("agents", "deezer,lastfm,spotify") viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) viper.SetDefault("lastfm.apikey", "") @@ -749,7 +752,7 @@ func setViperDefaults() { viper.SetDefault("devuishowconfig", true) viper.SetDefault("devneweventstream", true) viper.SetDefault("devoffsetoptimize", 50000) - viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3)) + viper.SetDefault("devartworkmaxrequests", max(4, runtime.NumCPU())) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) @@ -765,6 +768,7 @@ func setViperDefaults() { viper.SetDefault("devoptimizedb", true) viper.SetDefault("devpreserveunicodeinexternalcalls", false) viper.SetDefault("devenablemediafileprobe", true) + viper.SetDefault("devjpegcoverart", false) } func init() { diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index e2ea7adb0..5ca32f401 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -7,9 +7,12 @@ import ( "image/jpeg" "image/png" "io" + "os" "path/filepath" + _ "github.com/gen2brain/webp" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" @@ -353,24 +356,24 @@ var _ = Describe("Artwork", func() { }) }) When("Square is false", func() { - It("returns a PNG if original image is a PNG", func() { + It("returns WebP even if original image is a PNG", func() { conf.Server.CoverArtPriority = "front.png" r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) Expect(err).ToNot(HaveOccurred()) img, format, err := image.Decode(r) Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) + Expect(format).To(Equal("webp")) Expect(img.Bounds().Size().X).To(Equal(15)) Expect(img.Bounds().Size().Y).To(Equal(15)) }) - It("returns a JPEG if original image is not a PNG", func() { + It("returns WebP if original image is not a PNG", func() { conf.Server.CoverArtPriority = "cover.jpg" r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) Expect(err).ToNot(HaveOccurred()) img, format, err := image.Decode(r) - Expect(format).To(Equal("jpeg")) + Expect(format).To(Equal("webp")) Expect(err).ToNot(HaveOccurred()) Expect(img.Bounds().Size().X).To(Equal(200)) Expect(img.Bounds().Size().Y).To(Equal(200)) @@ -380,9 +383,9 @@ var _ = Describe("Artwork", func() { var alCover model.Album DescribeTable("resize", - func(format string, landscape bool, size int) { - coverFileName := "cover." + format - dirName := createImage(format, landscape, size) + func(srcFormat string, expectedFormat string, landscape bool, size int) { + coverFileName := "cover." + srcFormat + dirName := createImage(srcFormat, landscape, size) alCover = model.Album{ ID: "444", Name: "Only external", @@ -399,16 +402,70 @@ var _ = Describe("Artwork", func() { img, format, err := image.Decode(r) Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) + Expect(format).To(Equal(expectedFormat)) Expect(img.Bounds().Size().X).To(Equal(size)) Expect(img.Bounds().Size().Y).To(Equal(size)) }, - Entry("portrait png image", "png", false, 200), - Entry("landscape png image", "png", true, 200), - Entry("portrait jpg image", "jpg", false, 200), - Entry("landscape jpg image", "jpg", true, 200), + Entry("portrait png image", "png", "webp", false, 200), + Entry("landscape png image", "png", "webp", true, 200), + Entry("portrait jpg image", "jpg", "webp", false, 200), + Entry("landscape jpg image", "jpg", "webp", true, 200), ) }) + When("DevJpegCoverArt is true and square is false", func() { + BeforeEach(func() { + conf.Server.DevJpegCoverArt = true + }) + It("returns JPEG even if original image is a PNG", func() { + conf.Server.CoverArtPriority = "front.png" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("jpeg")) + Expect(img.Bounds().Size().X).To(Equal(15)) + Expect(img.Bounds().Size().Y).To(Equal(15)) + }) + It("returns JPEG if original image is a JPG", func() { + conf.Server.CoverArtPriority = "cover.jpg" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("jpeg")) + Expect(img.Bounds().Size().X).To(Equal(200)) + Expect(img.Bounds().Size().Y).To(Equal(200)) + }) + }) + When("DevJpegCoverArt is true and square is true", func() { + var alCover model.Album + + BeforeEach(func() { + conf.Server.DevJpegCoverArt = true + }) + It("returns PNG for square mode", func() { + dirName := createImage("png", false, 200) + alCover = model.Album{ + ID: "444", + Name: "Only external", + FolderIDs: []string{"tmp"}, + } + folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{"cover.png"}}} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover}) + + conf.Server.CoverArtPriority = "cover.png" + r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), 200, true) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("png")) + Expect(img.Bounds().Size().X).To(Equal(200)) + Expect(img.Bounds().Size().Y).To(Equal(200)) + }) + }) When("Requested size is larger than original", func() { It("clamps size to original dimensions", func() { conf.Server.CoverArtPriority = "front.png" diff --git a/core/artwork/benchmark_decode_test.go b/core/artwork/benchmark_decode_test.go new file mode 100644 index 000000000..cfbfe5605 --- /dev/null +++ b/core/artwork/benchmark_decode_test.go @@ -0,0 +1,37 @@ +package artwork + +import ( + "bytes" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "testing" +) + +func BenchmarkImageDecode(b *testing.B) { + sizes := []int{300, 1000, 3000} + formats := []struct { + name string + gen func(tb testing.TB, w, h int) []byte + }{ + {"jpeg", func(tb testing.TB, w, h int) []byte { return generateJPEG(tb, w, h, 75) }}, + {"png", func(tb testing.TB, w, h int) []byte { return generatePNG(tb, w, h) }}, + } + + for _, format := range formats { + for _, size := range sizes { + data := format.gen(b, size, size) + b.Run(fmt.Sprintf("%s/%dx%d", format.name, size, size), func(b *testing.B) { + b.SetBytes(int64(len(data))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + b.Fatal(err) + } + } + }) + } + } +} diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go new file mode 100644 index 000000000..c27964018 --- /dev/null +++ b/core/artwork/benchmark_e2e_test.go @@ -0,0 +1,189 @@ +package artwork + +import ( + "context" + "fmt" + "image/jpeg" + "io" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/cache" +) + +// setupE2EBenchmark creates an artwork instance with a real album cover image on disk, +// backed by either a real file cache or disabled cache depending on cacheSize. +// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers +// the critical path (source selection, decode, resize, encode, cache). This is a deliberate +// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure +// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant. +// +// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together). +func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) { + b.Helper() + cleanup := configtest.SetupConfig() + b.Cleanup(cleanup) + + tmpDir, err := os.MkdirTemp("", "artwork-bench-*") + if err != nil { + b.Fatal(err) + } + + // Create a realistic cover image on disk + coverPath := filepath.Join(tmpDir, "cover.jpg") + coverImg := generateGradientImage(1000, 1000) + f, err := os.Create(coverPath) + if err != nil { + b.Fatal(err) + } + if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil { + f.Close() + b.Fatal(err) + } + f.Close() + + // Configure cache + conf.Server.ImageCacheSize = cacheSize + conf.Server.CacheFolder = tmpDir + conf.Server.CoverArtQuality = 75 + conf.Server.CoverArtPriority = "cover.*" + + // Set up mock data store with album pointing to our cover. + // Set UpdatedAt so CoverArtID().LastUpdate is consistent across calls. + album := model.Album{ + ID: "bench-album-1", + Name: "Benchmark Album", + FolderIDs: []string{"f1"}, + UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + } + folderRepo := &fakeFolderRepo{ + result: []model.Folder{{ + Path: tmpDir, + ImageFiles: []string{"cover.jpg"}, + }}, + } + ds := &tests.MockDataStore{ + MockedTranscoding: &tests.MockTranscodingRepo{}, + MockedFolder: folderRepo, + } + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{album}) + + artID := album.CoverArtID() + + imgCache := cache.NewFileCache("BenchImage", cacheSize, "bench-images", 0, + func(ctx context.Context, arg cache.Item) (io.Reader, error) { + r, _, err := arg.(artworkReader).Reader(ctx) + return r, err + }) + + // Wait for cache init if enabled + if cacheSize != "0" { + for !imgCache.Available(context.Background()) && !imgCache.Disabled(context.Background()) { + runtime.Gosched() // Yield to allow background init goroutine to run + } + } + + ffmpeg := tests.NewMockFFmpeg("fallback content") + aw := NewArtwork(ds, imgCache, ffmpeg, nil) + + cleanupAll := func() { + os.RemoveAll(tmpDir) + } + return aw, artID, cleanupAll +} + +func BenchmarkArtworkGetE2E(b *testing.B) { + cacheConfigs := []struct { + name string + cacheSize string + }{ + {"no_cache", "0"}, + {"with_cache", "100MB"}, + } + sizes := []int{0, 300} + + for _, cc := range cacheConfigs { + for _, size := range sizes { + b.Run(fmt.Sprintf("%s/size_%d", cc.name, size), func(b *testing.B) { + aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize) + defer cleanup() + + // Warm the cache on first call if cache is enabled + if cc.cacheSize != "0" { + r, _, err := aw.Get(context.Background(), artID, size, size > 0) + if err != nil { + b.Fatal(err) + } + _, _ = io.ReadAll(r) + r.Close() + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + r, _, err := aw.Get(context.Background(), artID, size, size > 0) + if err != nil { + b.Fatal(err) + } + _, _ = io.ReadAll(r) + r.Close() + } + }) + } + } +} + +func BenchmarkArtworkGetE2EConcurrent(b *testing.B) { + cacheConfigs := []struct { + name string + cacheSize string + }{ + {"no_cache", "0"}, + {"with_cache", "100MB"}, + } + concurrencyLevels := []int{10, 50} + + for _, cc := range cacheConfigs { + for _, n := range concurrencyLevels { + b.Run(fmt.Sprintf("%s/goroutines_%d", cc.name, n), func(b *testing.B) { + aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize) + defer cleanup() + + // Warm cache + if cc.cacheSize != "0" { + r, _, _ := aw.Get(context.Background(), artID, 300, true) + if r != nil { + _, _ = io.ReadAll(r) + r.Close() + } + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + var wg sync.WaitGroup + wg.Add(n) + for g := 0; g < n; g++ { + go func() { + defer wg.Done() + r, _, err := aw.Get(context.Background(), artID, 300, true) + if err != nil { + b.Error(err) + return + } + _, _ = io.ReadAll(r) + r.Close() + }() + } + wg.Wait() + } + }) + } + } +} diff --git a/core/artwork/benchmark_encode_test.go b/core/artwork/benchmark_encode_test.go new file mode 100644 index 000000000..d8ab858f5 --- /dev/null +++ b/core/artwork/benchmark_encode_test.go @@ -0,0 +1,40 @@ +package artwork + +import ( + "bytes" + "fmt" + "image/jpeg" + "image/png" + "testing" +) + +func BenchmarkImageEncode(b *testing.B) { + img := generateGradientImage(300, 300) + + jpegQualities := []int{60, 75, 90} + for _, q := range jpegQualities { + b.Run(fmt.Sprintf("jpeg/q%d/300x300", q), func(b *testing.B) { + var buf bytes.Buffer + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf.Reset() + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(buf.Len()), "bytes") + }) + } + + b.Run("png/300x300", func(b *testing.B) { + var buf bytes.Buffer + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf.Reset() + if err := png.Encode(&buf, img); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(buf.Len()), "bytes") + }) +} diff --git a/core/artwork/benchmark_helpers_test.go b/core/artwork/benchmark_helpers_test.go new file mode 100644 index 000000000..60990bb8b --- /dev/null +++ b/core/artwork/benchmark_helpers_test.go @@ -0,0 +1,47 @@ +package artwork + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" + "testing" +) + +// generateJPEG creates a JPEG image of the given dimensions with a gradient pattern. +// The gradient ensures the image has realistic entropy (not trivially compressible). +func generateJPEG(t testing.TB, width, height, quality int) []byte { + t.Helper() + img := generateGradientImage(width, height) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// generatePNG creates a PNG image of the given dimensions with a gradient pattern. +func generatePNG(t testing.TB, width, height int) []byte { + t.Helper() + img := generateGradientImage(width, height) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// generateGradientImage creates an RGBA image with a diagonal gradient pattern. +func generateGradientImage(width, height int) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + r := uint8((x * 255) / width) + g := uint8((y * 255) / height) + b := uint8(((x + y) * 255) / (width + height)) + img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: 255}) + } + } + return img +} diff --git a/core/artwork/benchmark_pipeline_test.go b/core/artwork/benchmark_pipeline_test.go new file mode 100644 index 000000000..9dc4f6c95 --- /dev/null +++ b/core/artwork/benchmark_pipeline_test.go @@ -0,0 +1,51 @@ +package artwork + +import ( + "bytes" + "fmt" + "testing" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" +) + +func BenchmarkResizeFullPipeline(b *testing.B) { + cleanup := configtest.SetupConfig() + b.Cleanup(cleanup) + conf.Server.CoverArtQuality = 75 + + sourceSizes := []int{1000, 3000} + targetSize := 300 + + for _, srcSize := range sourceSizes { + jpegData := generateJPEG(b, srcSize, srcSize, 90) + + b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d", srcSize, srcSize, targetSize), func(b *testing.B) { + b.SetBytes(int64(len(jpegData))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + result, _, err := resizeImage(bytes.NewReader(jpegData), targetSize, false) + if err != nil { + b.Fatal(err) + } + if result == nil { + b.Fatal("expected non-nil resized image") + } + } + }) + + b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d_square", srcSize, srcSize, targetSize), func(b *testing.B) { + b.SetBytes(int64(len(jpegData))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + result, _, err := resizeImage(bytes.NewReader(jpegData), targetSize, true) + if err != nil { + b.Fatal(err) + } + if result == nil { + b.Fatal("expected non-nil resized image") + } + } + }) + } +} diff --git a/core/artwork/benchmark_tag_test.go b/core/artwork/benchmark_tag_test.go new file mode 100644 index 000000000..fd649beab --- /dev/null +++ b/core/artwork/benchmark_tag_test.go @@ -0,0 +1,38 @@ +package artwork + +import ( + "path/filepath" + "runtime" + "testing" + + "go.senan.xyz/taglib" +) + +func BenchmarkTagExtraction(b *testing.B) { + // Ensure working directory is the project root (tests.Init not called with -run='^$') + _, file, _, ok := runtime.Caller(0) + if !ok { + b.Fatal("runtime.Caller failed") + } + appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", "..")) + + // Use existing test fixture with embedded artwork + testFile := filepath.Join(appPath, "tests/fixtures/artist/an-album/test.mp3") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + f, err := taglib.OpenReadOnly(testFile, taglib.WithReadStyle(taglib.ReadStyleFast)) + if err != nil { + b.Fatal(err) + } + images := f.Properties().Images + if len(images) == 0 { + b.Fatal("no images found in test file") + } + data, err := f.Image(0) + if err != nil || len(data) == 0 { + b.Fatal("failed to extract image data") + } + f.Close() + } +} diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go index 909d299d8..f13820d00 100644 --- a/core/artwork/cache_warmer.go +++ b/core/artwork/cache_warmer.go @@ -132,7 +132,7 @@ func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) { func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) { log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch)) input := pl.FromSlice(ctx, batch) - errs := pl.Sink(ctx, 2, input, a.doCacheImage) + errs := pl.Sink(ctx, 4, input, a.doCacheImage) for err := range errs { log.Debug(ctx, "Error warming cache", err) } diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go index 91d47b0b0..7919eead6 100644 --- a/core/artwork/reader_playlist.go +++ b/core/artwork/reader_playlist.go @@ -14,11 +14,11 @@ import ( "strings" "time" - "github.com/disintegration/imaging" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" + xdraw "golang.org/x/image/draw" ) type playlistArtworkReader struct { @@ -200,7 +200,7 @@ func (a *playlistArtworkReader) createTile(_ context.Context, r io.ReadCloser) ( if err != nil { return nil, err } - return imaging.Fill(img, tileSize/2, tileSize/2, imaging.Center, imaging.Lanczos), nil + return fillCenter(img, tileSize/2, tileSize/2), nil } func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) { @@ -238,3 +238,32 @@ func rect(pos int) image.Rectangle { r.Max.Y = r.Min.Y + tileSize/2 return r } + +// fillCenter crops the source image from the center and scales it to fill dstW x dstH exactly, +// equivalent to imaging.Fill with Center anchor. +func fillCenter(src image.Image, dstW, dstH int) image.Image { + srcBounds := src.Bounds() + srcW := srcBounds.Dx() + srcH := srcBounds.Dy() + + // Calculate crop rectangle (center crop to match destination aspect ratio) + srcAspect := float64(srcW) / float64(srcH) + dstAspect := float64(dstW) / float64(dstH) + + var cropRect image.Rectangle + if srcAspect > dstAspect { + // Source is wider — crop horizontally + cropW := int(float64(srcH) * dstAspect) + cropX := (srcW - cropW) / 2 + cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y) + } else { + // Source is taller — crop vertically + cropH := int(float64(srcW) / dstAspect) + cropY := (srcH - cropH) / 2 + cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH) + } + + dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) + xdraw.BiLinear.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) + return dst +} diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 6de983baf..dffcba8e3 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -5,17 +5,26 @@ import ( "context" "fmt" "image" + "image/draw" "image/jpeg" "image/png" "io" + "sync" "time" - "github.com/disintegration/imaging" + "github.com/gen2brain/webp" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + xdraw "golang.org/x/image/draw" ) +var bufPool = sync.Pool{ + New: func() any { + return new(bytes.Buffer) + }, +} + type resizedArtworkReader struct { artID model.ArtworkID cacheKey string @@ -46,7 +55,7 @@ func (a *resizedArtworkReader) Key() string { if a.square { return baseKey + ".square" } - return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverJpegQuality) + return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverArtQuality) } func (a *resizedArtworkReader) LastUpdated() time.Time { @@ -79,7 +88,7 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin } func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) { - original, format, err := image.Decode(reader) + original, _, err := image.Decode(reader) if err != nil { return nil, 0, err } @@ -96,26 +105,45 @@ func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error return nil, originalSize, nil } - var resized image.Image - if originalSize >= size { - resized = imaging.Fit(original, size, size, imaging.Lanczos) - } else { - if bounds.Max.Y < bounds.Max.X { - resized = imaging.Resize(original, size, 0, imaging.Lanczos) - } else { - resized = imaging.Resize(original, 0, size, imaging.Lanczos) - } - } - if square { - bg := image.NewRGBA(image.Rect(0, 0, size, size)) - resized = imaging.OverlayCenter(bg, resized, 1) - } + // Calculate aspect-fit dimensions + srcW, srcH := bounds.Dx(), bounds.Dy() + scale := float64(size) / float64(max(srcW, srcH)) + dstW := int(float64(srcW) * scale) + dstH := int(float64(srcH) * scale) - buf := new(bytes.Buffer) - if format == "png" || square { - err = png.Encode(buf, resized) + var dst *image.NRGBA + var dstRect image.Rectangle + if square { + // Square canvas with image centered (transparent padding via zero-initialized NRGBA) + dst = image.NewNRGBA(image.Rect(0, 0, size, size)) + offsetX := (size - dstW) / 2 + offsetY := (size - dstH) / 2 + dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH) } else { - err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality}) + // Tight-fit canvas + dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) + dstRect = dst.Bounds() } - return buf, originalSize, err + xdraw.BiLinear.Scale(dst, dstRect, original, bounds, draw.Src, nil) + + buf := bufPool.Get().(*bytes.Buffer) + buf.Reset() + if conf.Server.DevJpegCoverArt { + if square { + err = png.Encode(buf, dst) + } else { + err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) + } + } else { + err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) + } + if err != nil { + bufPool.Put(buf) + return nil, originalSize, err + } + // Copy bytes before returning buffer to pool (pool may reuse the buffer) + encoded := make([]byte, buf.Len()) + copy(encoded, buf.Bytes()) + bufPool.Put(buf) + return bytes.NewReader(encoded), originalSize, nil } diff --git a/core/external/provider.go b/core/external/provider.go index 6a4f4f5e0..a30afed15 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -374,13 +374,19 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) return nil, err } - e.callGetImage(ctx, e.ag, &artist) - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "ArtistImage call canceled", ctx.Err()) - return nil, ctx.Err() + // Use already-stored image URL if available, avoiding expensive external API calls. + // If the info is expired, the background refresh (via UpdateArtistInfo/artistQueue) will update it. + imageUrl := artist.ArtistImageUrl() + if imageUrl == "" { + // No cached URL — must fetch from external source synchronously + e.callGetImage(ctx, e.ag, &artist) + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "ArtistImage call canceled", ctx.Err()) + return nil, ctx.Err() + } + imageUrl = artist.ArtistImageUrl() } - imageUrl := artist.ArtistImageUrl() if imageUrl == "" { return nil, model.ErrNotFound } diff --git a/go.mod b/go.mod index d50f738b7..424c52421 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ require ( github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 - github.com/disintegration/imaging v1.6.2 github.com/djherbis/atime v1.1.0 github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 github.com/djherbis/stream v1.4.0 @@ -22,6 +21,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 + github.com/gen2brain/webp v0.5.5 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 @@ -84,6 +84,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect + github.com/ebitengine/purego v0.8.3 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect diff --git a/go.sum b/go.sum index d92075234..a28900a41 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,6 @@ github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6n github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E= github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg= github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= -github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= -github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc= @@ -60,6 +58,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= +github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc= +github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= @@ -69,6 +69,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= +github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -325,7 +327,6 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/utils/cache/benchmark_test.go b/utils/cache/benchmark_test.go new file mode 100644 index 000000000..1fe448f84 --- /dev/null +++ b/utils/cache/benchmark_test.go @@ -0,0 +1,171 @@ +package cache + +import ( + "context" + "fmt" + "io" + "os" + "runtime" + "strings" + "sync" + "testing" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" +) + +type benchItem struct { + key string +} + +func (b *benchItem) Key() string { return b.key } + +// setupBenchCache creates a file cache in a temp directory. Returns the cache and cleanup function. +func setupBenchCache(b *testing.B, cacheSize string, getReader ReadFunc) (*fileCache, func()) { + b.Helper() + tmpDir, err := os.MkdirTemp("", "bench-cache-*") + if err != nil { + b.Fatal(err) + } + b.Cleanup(configtest.SetupConfig()) + conf.Server.CacheFolder = tmpDir + + fc := NewFileCache("bench", cacheSize, "bench", 0, getReader).(*fileCache) + + // Wait for cache to be ready + for !fc.ready.Load() { + runtime.Gosched() // Yield to allow background init goroutine to run + } + + teardown := func() { + os.RemoveAll(tmpDir) + } + return fc, teardown +} + +func BenchmarkCacheWrite(b *testing.B) { + // Simulate writing 50KB images (typical 300px JPEG) + imageData := strings.Repeat("x", 50*1024) + + fc, cleanup := setupBenchCache(b, "100MB", func(ctx context.Context, item Item) (io.Reader, error) { + return strings.NewReader(imageData), nil + }) + defer cleanup() + + b.SetBytes(int64(len(imageData))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + key := fmt.Sprintf("write-bench-%d", i) + s, err := fc.Get(context.Background(), &benchItem{key: key}) + if err != nil { + b.Fatal(err) + } + _, _ = io.ReadAll(s) + s.Close() + } +} + +func BenchmarkCacheRead(b *testing.B) { + imageData := strings.Repeat("x", 50*1024) + + fc, cleanup := setupBenchCache(b, "100MB", func(ctx context.Context, item Item) (io.Reader, error) { + return strings.NewReader(imageData), nil + }) + defer cleanup() + + // Pre-populate cache + item := &benchItem{key: "read-bench"} + s, err := fc.Get(context.Background(), item) + if err != nil { + b.Fatal(err) + } + _, _ = io.ReadAll(s) + s.Close() + + b.SetBytes(int64(len(imageData))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + s, err := fc.Get(context.Background(), item) + if err != nil { + b.Fatal(err) + } + _, _ = io.ReadAll(s) + s.Close() + } +} + +func BenchmarkConcurrentCacheRead(b *testing.B) { + imageData := strings.Repeat("x", 50*1024) + + fc, cleanup := setupBenchCache(b, "100MB", func(ctx context.Context, item Item) (io.Reader, error) { + return strings.NewReader(imageData), nil + }) + defer cleanup() + + // Pre-populate cache + item := &benchItem{key: "concurrent-read"} + s, _ := fc.Get(context.Background(), item) + _, _ = io.ReadAll(s) + s.Close() + + concurrencyLevels := []int{1, 10, 50} + for _, n := range concurrencyLevels { + b.Run(fmt.Sprintf("goroutines_%d", n), func(b *testing.B) { + b.SetBytes(int64(len(imageData))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + var wg sync.WaitGroup + wg.Add(n) + for g := 0; g < n; g++ { + go func() { + defer wg.Done() + s, err := fc.Get(context.Background(), item) + if err != nil { + b.Error(err) + return + } + _, _ = io.ReadAll(s) + s.Close() + }() + } + wg.Wait() + } + }) + } +} + +func BenchmarkConcurrentCacheMiss(b *testing.B) { + imageData := strings.Repeat("x", 50*1024) + + concurrencyLevels := []int{1, 10, 50} + for _, n := range concurrencyLevels { + b.Run(fmt.Sprintf("goroutines_%d", n), func(b *testing.B) { + fc, cleanup := setupBenchCache(b, "100MB", func(ctx context.Context, item Item) (io.Reader, error) { + return strings.NewReader(imageData), nil + }) + defer cleanup() + + b.SetBytes(int64(len(imageData))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + var wg sync.WaitGroup + wg.Add(n) + // All goroutines request the SAME key (not yet cached) + item := &benchItem{key: fmt.Sprintf("miss-%d", i)} + for g := 0; g < n; g++ { + go func() { + defer wg.Done() + s, err := fc.Get(context.Background(), item) + if err != nil { + b.Error(err) + return + } + _, _ = io.ReadAll(s) + s.Close() + }() + } + wg.Wait() + } + }) + } +}