3f7226d253
* fix(server): capture ffmpeg stderr and warn on empty transcoded output When ffmpeg fails during transcoding (e.g., missing codec like libopus), the error was silently discarded because stderr was sent to io.Discard and the HTTP response returned 200 OK with a 0-byte body. - Capture ffmpeg stderr in a bounded buffer (4KB) and include it in the error message when the process exits with a non-zero status code - Log a warning when transcoded output is 0 bytes, guiding users to check codec support and enable Trace logging for details - Remove log level guard so transcoding errors are always logged, not just at Debug level Signed-off-by: Deluan <deluan@navidrome.org> * fix(server): return proper error responses for empty transcoded output Instead of returning HTTP 200 with 0-byte body when transcoding fails, return a Subsonic error response (for stream/download/getTranscodeStream) or HTTP 500 (for public shared streams). This gives clients a clear signal that the request failed rather than a misleading empty success. Signed-off-by: Deluan <deluan@navidrome.org> * test(e2e): add tests for empty transcoded stream error responses Add E2E tests verifying that stream and download endpoints return Subsonic error responses when transcoding produces empty output. Extend spyStreamer with SimulateEmptyStream and SimulateError fields to support failure injection in tests. Signed-off-by: Deluan <deluan@navidrome.org> * refactor(server): extract stream serving logic into Stream.Serve method Extract the duplicated non-seekable stream serving logic (header setup, estimateContentLength, HEAD draining, io.Copy with error/empty detection) from server/subsonic/stream.go and server/public/handle_streams.go into a single Stream.Serve method on core/stream. Both callers now delegate to it, eliminating ~30 lines of near-identical code. * fix(server): return 200 with empty body for stream/download on empty transcoded output Don't return a Subsonic error response when transcoding produces empty output on stream/download endpoints — just log the error and return 200 with an empty body. The getTranscodeStream and public share endpoints still return HTTP 500 for empty output. Stream.Serve now returns (int64, error) so callers can check the byte count. --------- Signed-off-by: Deluan <deluan@navidrome.org>
183 lines
6.5 KiB
Go
183 lines
6.5 KiB
Go
package e2e
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
|
|
var (
|
|
mp3TrackID string // Come Together (mp3, 320kbps)
|
|
flacTrackID string // TC FLAC Standard (flac, 900kbps)
|
|
)
|
|
|
|
BeforeAll(func() {
|
|
setupTestDB()
|
|
|
|
songs, err := ds.MediaFile(ctx).GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
byTitle := map[string]string{}
|
|
for _, s := range songs {
|
|
byTitle[s.Title] = s.ID
|
|
}
|
|
mp3TrackID = byTitle["Come Together"]
|
|
Expect(mp3TrackID).ToNot(BeEmpty())
|
|
flacTrackID = byTitle["TC FLAC Standard"]
|
|
Expect(flacTrackID).ToNot(BeEmpty())
|
|
})
|
|
|
|
Describe("raw / direct play", func() {
|
|
It("streams raw when no format or maxBitRate is specified", func() {
|
|
w := doRawReq("stream", "id", flacTrackID)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", ""))
|
|
})
|
|
|
|
It("streams raw when format=raw is explicitly requested", func() {
|
|
w := doRawReq("stream", "id", flacTrackID, "format", "raw")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", ""))
|
|
})
|
|
|
|
It("streams raw when maxBitRate is >= source bitrate", func() {
|
|
w := doRawReq("stream", "id", flacTrackID, "maxBitRate", "1000")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", ""))
|
|
})
|
|
|
|
It("streams raw when format matches source and bitrate is not lower", func() {
|
|
w := doRawReq("stream", "id", mp3TrackID, "format", "mp3", "maxBitRate", "320")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("raw"))
|
|
})
|
|
})
|
|
|
|
Describe("transcoding with explicit format", func() {
|
|
It("transcodes to mp3 when format=mp3 is requested", func() {
|
|
w := doRawReq("stream", "id", flacTrackID, "format", "mp3")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
|
|
// Should use the mp3 default bitrate (192kbps)
|
|
Expect(streamerSpy.LastRequest.BitRate).To(Equal(192))
|
|
})
|
|
|
|
It("transcodes to opus when format=opus is requested (no maxBitRate)", func() {
|
|
w := doRawReq("stream", "id", flacTrackID, "format", "opus")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("opus"))
|
|
// Should use the opus default bitrate (128kbps)
|
|
Expect(streamerSpy.LastRequest.BitRate).To(Equal(128))
|
|
})
|
|
|
|
It("transcodes to opus with specified maxBitRate", func() {
|
|
w := doRawReq("stream", "id", flacTrackID, "format", "opus", "maxBitRate", "192")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("opus"))
|
|
Expect(streamerSpy.LastRequest.BitRate).To(Equal(192))
|
|
})
|
|
|
|
It("transcodes to mp3 with specified maxBitRate", func() {
|
|
w := doRawReq("stream", "id", flacTrackID, "format", "mp3", "maxBitRate", "128")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
|
|
Expect(streamerSpy.LastRequest.BitRate).To(Equal(128))
|
|
})
|
|
|
|
It("transcodes MP3 to opus when format=opus is requested", func() {
|
|
w := doRawReq("stream", "id", mp3TrackID, "format", "opus")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("opus"))
|
|
})
|
|
|
|
It("transcodes same format when maxBitRate is lower than source", func() {
|
|
w := doRawReq("stream", "id", mp3TrackID, "format", "mp3", "maxBitRate", "128")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
|
|
Expect(streamerSpy.LastRequest.BitRate).To(Equal(128))
|
|
})
|
|
})
|
|
|
|
Describe("downsampling with maxBitRate only", func() {
|
|
It("transcodes using default downsampling format when maxBitRate < source bitrate", func() {
|
|
conf.Server.DefaultDownsamplingFormat = "opus"
|
|
w := doRawReq("stream", "id", flacTrackID, "maxBitRate", "192")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("opus"))
|
|
Expect(streamerSpy.LastRequest.BitRate).To(Equal(192))
|
|
})
|
|
|
|
It("streams raw when maxBitRate >= source bitrate (no downsampling needed)", func() {
|
|
conf.Server.DefaultDownsamplingFormat = "opus"
|
|
w := doRawReq("stream", "id", mp3TrackID, "maxBitRate", "320")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", ""))
|
|
})
|
|
})
|
|
|
|
Describe("timeOffset", func() {
|
|
It("passes timeOffset to the stream request", func() {
|
|
w := doRawReq("stream", "id", flacTrackID, "format", "mp3", "timeOffset", "30")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Offset).To(Equal(30))
|
|
})
|
|
})
|
|
|
|
Describe("stream creation failure", func() {
|
|
BeforeEach(func() {
|
|
streamerSpy.SimulateError = errors.New("ffmpeg exited with non-zero status code: 1: Unknown encoder 'libopus'")
|
|
})
|
|
AfterEach(func() {
|
|
streamerSpy.SimulateError = nil
|
|
})
|
|
|
|
It("returns a Subsonic error for stream endpoint", func() {
|
|
w := doRawReq("stream", "id", flacTrackID, "format", "opus")
|
|
Expect(w.Code).To(Equal(http.StatusOK)) // Subsonic errors are returned as 200
|
|
|
|
var wrapper responses.JsonWrapper
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed())
|
|
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
|
|
Expect(wrapper.Subsonic.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("returns a Subsonic error for download endpoint", func() {
|
|
conf.Server.EnableDownloads = true
|
|
w := doRawReq("download", "id", flacTrackID, "format", "opus")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
|
|
var wrapper responses.JsonWrapper
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed())
|
|
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
|
|
Expect(wrapper.Subsonic.Error).ToNot(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("empty transcoded output", func() {
|
|
BeforeEach(func() {
|
|
streamerSpy.SimulateEmptyStream = true
|
|
})
|
|
AfterEach(func() {
|
|
streamerSpy.SimulateEmptyStream = false
|
|
})
|
|
|
|
It("returns 200 with empty body for stream endpoint", func() {
|
|
w := doRawReq("stream", "id", flacTrackID, "format", "opus")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Body.Len()).To(Equal(0))
|
|
})
|
|
|
|
It("returns 200 with empty body for download endpoint", func() {
|
|
conf.Server.EnableDownloads = true
|
|
w := doRawReq("download", "id", flacTrackID, "format", "opus")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Body.Len()).To(Equal(0))
|
|
})
|
|
})
|
|
})
|