package stream_test
import (
"context"
"errors"
"sync"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("TranscodeLimiter", func() {
ctx := log.NewContext(context.TODO())
Describe("Disabled (both caps <= 0)", func() {
It("never blocks and never returns ErrTooManyTranscodes", func() {
lim := stream.NewTranscodeLimiter(0, 0)
for range 100 {
rel, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
Expect(rel).ToNot(BeNil())
}
})
})
Describe("Per-user cap only (no global cap)", func() {
It("still enforces the per-user limit when MaxConcurrent is disabled", func() {
lim := stream.NewTranscodeLimiter(0, 2)
rel1, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
rel2, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
_, err = lim.Acquire(ctx, "alice")
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
rel3, err := lim.Acquire(ctx, "bob")
Expect(err).ToNot(HaveOccurred())
rel1()
_, err = lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
rel2()
rel3()
})
})
Describe("Global cap", func() {
It("rejects requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
lim := stream.NewTranscodeLimiter(2, 0)
rel1, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
rel2, err := lim.Acquire(ctx, "bob")
Expect(err).ToNot(HaveOccurred())
_, err = lim.Acquire(ctx, "carol")
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
rel1()
_, err = lim.Acquire(ctx, "carol")
Expect(err).ToNot(HaveOccurred())
rel2()
})
It("releases a slot only once even if release is called multiple times", func() {
lim := stream.NewTranscodeLimiter(1, 0)
rel, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
rel()
rel()
rel()
_, err = lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
_, err = lim.Acquire(ctx, "alice")
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
})
})
Describe("Per-user cap", func() {
It("rejects a user beyond MaxConcurrentPerUser even if global slots remain", func() {
lim := stream.NewTranscodeLimiter(10, 2)
rel1, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
rel2, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
_, err = lim.Acquire(ctx, "alice")
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
rel3, err := lim.Acquire(ctx, "bob")
Expect(err).ToNot(HaveOccurred())
rel1()
_, err = lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
rel2()
rel3()
})
It("skips the per-user cap for anonymous users (empty key)", func() {
lim := stream.NewTranscodeLimiter(10, 1)
rels := make([]func(), 0, 5)
for range 5 {
rel, err := lim.Acquire(ctx, "")
Expect(err).ToNot(HaveOccurred())
rels = append(rels, rel)
}
for _, rel := range rels {
rel()
}
})
It("still applies the global cap to anonymous users", func() {
lim := stream.NewTranscodeLimiter(2, 1)
rel1, err := lim.Acquire(ctx, "")
Expect(err).ToNot(HaveOccurred())
rel2, err := lim.Acquire(ctx, "")
Expect(err).ToNot(HaveOccurred())
_, err = lim.Acquire(ctx, "")
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
rel1()
rel2()
})
})
Describe("Concurrent safety", func() {
It("survives parallel Acquire/release with consistent counts", func() {
lim := stream.NewTranscodeLimiter(5, 0)
var wg sync.WaitGroup
var acquired int64
var rejected int64
var mu sync.Mutex
for i := range 50 {
wg.Add(1)
go func(i int) {
defer wg.Done()
rel, err := lim.Acquire(ctx, "alice")
mu.Lock()
if err == nil {
acquired++
mu.Unlock()
rel()
} else {
rejected++
mu.Unlock()
}
_ = i
}(i)
}
wg.Wait()
Expect(acquired + rejected).To(Equal(int64(50)))
for range 5 {
_, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
}
_, err := lim.Acquire(ctx, "alice")
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
})
})
})