mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
190 lines
4.8 KiB
Go
190 lines
4.8 KiB
Go
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 = conf.NewDir(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 range n {
|
|
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()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|