mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
5686c352c changed the estimate from bitRate / 8 * 1024 to * 1000, reading the 1024 as a unit mistake. It was headroom: the body also carries the ID3 tag, the Xing frame and frame padding, so a nominal-size estimate is short, and net/http aborts a response that writes past its declared Content-Length. Measured on a live server, a 429s track at 64kbps was estimated at 3,439,840 bytes against a 3,441,569-byte body, and the stream was cut with 'http: wrote more than the declared Content-Length'. Restores the 1024, says why next to it, and turns the test into one that asserts the headroom rather than a formula.
210 lines
7.6 KiB
Go
210 lines
7.6 KiB
Go
package stream_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strconv"
|
|
"testing/iotest"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/core/stream"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("MediaStreamer", func() {
|
|
var streamer stream.MediaStreamer
|
|
var ds model.DataStore
|
|
ffmpeg := tests.NewMockFFmpeg("fake data")
|
|
ctx := log.NewContext(context.TODO())
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
cacheDir, _ := os.MkdirTemp("", "file_caches")
|
|
conf.Server.CacheFolder = conf.NewDir(cacheDir)
|
|
conf.Server.TranscodingCacheSize = "100MB"
|
|
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
|
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
|
|
})
|
|
testCache := stream.NewTranscodingCache()
|
|
Eventually(func() bool { return testCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue())
|
|
streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache)
|
|
})
|
|
AfterEach(func() {
|
|
_ = os.RemoveAll(conf.Server.CacheFolder.String())
|
|
})
|
|
|
|
Context("NewStream", func() {
|
|
var mf *model.MediaFile
|
|
BeforeEach(func() {
|
|
var err error
|
|
mf, err = ds.MediaFile(ctx).Get("123")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
It("returns a seekable stream if format is 'raw'", func() {
|
|
s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "raw"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(s.Seekable()).To(BeTrue())
|
|
})
|
|
It("returns a seekable stream if no format is specified (direct play)", func() {
|
|
s, err := streamer.NewStream(ctx, mf, stream.Request{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(s.Seekable()).To(BeTrue())
|
|
})
|
|
It("returns a NON seekable stream if transcode is required", func() {
|
|
s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 64})
|
|
Expect(err).To(BeNil())
|
|
Expect(s.Seekable()).To(BeFalse())
|
|
Expect(s.Duration()).To(Equal(float32(257.0)))
|
|
})
|
|
It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
|
|
// Use an ffmpeg whose Read blocks indefinitely so the cache's
|
|
// background copy can't drain the source and release the slot —
|
|
// keeping the single transcode slot pinned for this test.
|
|
pr, pw := io.Pipe()
|
|
DeferCleanup(func() { _ = pw.Close() })
|
|
blockingFFmpeg := tests.NewMockFFmpeg("")
|
|
blockingFFmpeg.Reader = pr
|
|
|
|
conf.Server.Transcoding.MaxConcurrent = 1
|
|
conf.Server.Transcoding.MaxConcurrentPerUser = 0
|
|
tightCache := stream.NewTranscodingCache()
|
|
Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue())
|
|
tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache)
|
|
|
|
userCtx := request.WithUsername(ctx, "alice")
|
|
s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer s1.Close()
|
|
|
|
// Different cache key so it doesn't dedupe with the first request.
|
|
_, err = tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96})
|
|
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
|
})
|
|
|
|
It("releases the slot once the stream is closed", func() {
|
|
conf.Server.Transcoding.MaxConcurrent = 1
|
|
conf.Server.Transcoding.MaxConcurrentPerUser = 0
|
|
tightCache := stream.NewTranscodingCache()
|
|
Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue())
|
|
tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache)
|
|
|
|
userCtx := request.WithUsername(ctx, "alice")
|
|
s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
_, _ = io.ReadAll(s1)
|
|
_ = s1.Close()
|
|
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
|
|
|
|
// Slot should now be free for a different transcode.
|
|
s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer s2.Close()
|
|
})
|
|
|
|
It("does not consume a slot for raw streams", func() {
|
|
conf.Server.Transcoding.MaxConcurrent = 1
|
|
conf.Server.Transcoding.MaxConcurrentPerUser = 0
|
|
tightCache := stream.NewTranscodingCache()
|
|
Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue())
|
|
tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache)
|
|
|
|
userCtx := request.WithUsername(ctx, "alice")
|
|
// First, saturate the single transcode slot.
|
|
s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer s1.Close()
|
|
|
|
// Raw stream must still succeed.
|
|
s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "raw"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer s2.Close()
|
|
})
|
|
|
|
It("returns a seekable stream if the file is complete in the cache", func() {
|
|
s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32})
|
|
Expect(err).To(BeNil())
|
|
_, _ = io.ReadAll(s)
|
|
_ = s.Close()
|
|
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
|
|
|
|
s, err = streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32})
|
|
Expect(err).To(BeNil())
|
|
Expect(s.Seekable()).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Context("Serve", func() {
|
|
var mf *model.MediaFile
|
|
BeforeEach(func() {
|
|
var err error
|
|
mf, err = ds.MediaFile(ctx).Get("123")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("keeps empty output a non-error, so callers still reply 200 with an empty body", func() {
|
|
s := stream.NewStream(mf, "mp3", 128, io.NopCloser(bytes.NewReader(nil)))
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
|
|
n, err := s.Serve(ctx, w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(n).To(BeZero())
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
})
|
|
|
|
It("aborts the response when the source fails after sending data", func() {
|
|
src := io.NopCloser(io.MultiReader(
|
|
bytes.NewReader(bytes.Repeat([]byte("a"), 64*1024)),
|
|
iotest.ErrReader(errors.New("transcoder died")),
|
|
))
|
|
server := httptest.NewServer(serveHandler(stream.NewStream(mf, "mp3", 128, src)))
|
|
DeferCleanup(server.Close)
|
|
|
|
resp, err := http.Get(server.URL)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer resp.Body.Close()
|
|
|
|
// A client-side read failure is the only observable proof the response was aborted.
|
|
_, err = io.ReadAll(resp.Body)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("estimates a content length above the nominal size, so the body never outruns it", func() {
|
|
hundredSeconds := *mf
|
|
hundredSeconds.Duration = 100
|
|
s := stream.NewStream(&hundredSeconds, "mp3", 128, io.NopCloser(bytes.NewReader(nil)))
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/?estimateContentLength=true", nil)
|
|
|
|
_, _ = s.Serve(ctx, w, r)
|
|
|
|
length, err := strconv.Atoi(w.Header().Get("Content-Length"))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(length).To(BeNumerically(">", 100*128*1000/8))
|
|
})
|
|
})
|
|
})
|
|
|
|
// Serve runs behind the real server's Recoverer, which must let ErrAbortHandler through.
|
|
func serveHandler(s *stream.Stream) http.Handler {
|
|
return middleware.Recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = s.Serve(r.Context(), w, r)
|
|
}))
|
|
}
|