🏑 index : ironmagma/quintodrome.git

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