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() + } + }) + } +}