2026-03-10 13:08:35 -08:00
|
|
|
package e2e
|
|
|
|
|
|
|
|
|
|
import (
|
fix(server): improve transcoding failure diagnostics and error responses (#5227)
* 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>
2026-03-18 08:39:03 -08:00
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
2026-03-10 13:08:35 -08:00
|
|
|
"net/http"
|
|
|
|
|
|
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
fix(server): improve transcoding failure diagnostics and error responses (#5227)
* 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>
2026-03-18 08:39:03 -08:00
|
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
2026-03-10 13:08:35 -08:00
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
|
|
|
. "github.com/onsi/gomega"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
|
|
|
|
|
var (
|
fix(transcoding): clamp target channels to codec limit (#5336) (#5345)
* fix(transcoding): clamp target channels to codec limit (#5336)
When transcoding a multi-channel source (e.g. 6-channel FLAC) to MP3, the
decider passed the source channel count through to ffmpeg unchanged. The
default MP3 command path then emitted `-ac 6`, and the template path injected
`-ac 6` after the template's own `-ac 2`, causing ffmpeg to honor the last
occurrence and fail with exit code 234 since libmp3lame only supports up to
2 channels.
Introduce `codecMaxChannels()` in core/stream/codec.go (mp3→2, opus→8),
mirroring the existing `codecMaxSampleRate` pattern, and apply the clamp in
`computeTranscodedStream` right after the sample-rate clamps. Also fix a
pre-existing ordering bug where the profile's MaxAudioChannels check compared
against src.Channels rather than ts.Channels, which would have let a looser
profile setting raise the codec-clamped value back up. Comparing against the
already-clamped ts.Channels makes profile limits strictly narrowing, which
matches how the sample-rate block already behaves.
The ffmpeg buildTemplateArgs comment is refreshed to point at the new upstream
clamp, since the flags it injects are now always codec-safe.
Adds unit tests for codecMaxChannels and four decider scenarios covering the
literal issue repro (6-ch FLAC→MP3 clamps to 2), a stricter profile limit
winning over the codec clamp, a looser profile limit leaving the codec clamp
intact, and a codec with no hard limit (AAC) passing 6 channels through.
* test(e2e): pin codec channel clamp at the Subsonic API surface (#5336)
Add a 6-channel FLAC fixture to the e2e test suite and use it to assert the
codec channel clamp end-to-end on both Subsonic streaming endpoints:
- getTranscodeDecision (mp3OnlyClient, no MaxAudioChannels in profile):
expects TranscodeStream.AudioChannels == 2 for the 6-channel source. This
exercises the new codecMaxChannels() helper through the OpenSubsonic
decision endpoint, with no profile-level channel limit masking the bug.
- /rest/stream (legacy): requests format=mp3 against the multichannel
fixture and asserts streamerSpy.LastRequest.Channels == 2, confirming
the clamp propagates through ResolveRequest into the stream.Request that
the streamer receives.
The fixture is metadata-only (channels: 6 plumbed via the existing
storagetest.File helper) — no real audio bytes required, since the e2e
suite uses a spy streamer rather than invoking ffmpeg. Bumps the empty-query
search3 song count expectation from 13 to 14 to account for the new fixture.
* test(decider): clarify codec-clamp comment terminology
Distinguish "transcoding profile MaxAudioChannels" (Profile.MaxAudioChannels
field) from "LimitationAudioChannels" (CodecProfile rule constant). The
regression test bypasses the former, not the latter.
2026-04-11 19:15:07 -08:00
|
|
|
mp3TrackID string // Come Together (mp3, 320kbps)
|
|
|
|
|
flacTrackID string // TC FLAC Standard (flac, 900kbps)
|
|
|
|
|
flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch)
|
2026-03-10 13:08:35 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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())
|
fix(transcoding): clamp target channels to codec limit (#5336) (#5345)
* fix(transcoding): clamp target channels to codec limit (#5336)
When transcoding a multi-channel source (e.g. 6-channel FLAC) to MP3, the
decider passed the source channel count through to ffmpeg unchanged. The
default MP3 command path then emitted `-ac 6`, and the template path injected
`-ac 6` after the template's own `-ac 2`, causing ffmpeg to honor the last
occurrence and fail with exit code 234 since libmp3lame only supports up to
2 channels.
Introduce `codecMaxChannels()` in core/stream/codec.go (mp3→2, opus→8),
mirroring the existing `codecMaxSampleRate` pattern, and apply the clamp in
`computeTranscodedStream` right after the sample-rate clamps. Also fix a
pre-existing ordering bug where the profile's MaxAudioChannels check compared
against src.Channels rather than ts.Channels, which would have let a looser
profile setting raise the codec-clamped value back up. Comparing against the
already-clamped ts.Channels makes profile limits strictly narrowing, which
matches how the sample-rate block already behaves.
The ffmpeg buildTemplateArgs comment is refreshed to point at the new upstream
clamp, since the flags it injects are now always codec-safe.
Adds unit tests for codecMaxChannels and four decider scenarios covering the
literal issue repro (6-ch FLAC→MP3 clamps to 2), a stricter profile limit
winning over the codec clamp, a looser profile limit leaving the codec clamp
intact, and a codec with no hard limit (AAC) passing 6 channels through.
* test(e2e): pin codec channel clamp at the Subsonic API surface (#5336)
Add a 6-channel FLAC fixture to the e2e test suite and use it to assert the
codec channel clamp end-to-end on both Subsonic streaming endpoints:
- getTranscodeDecision (mp3OnlyClient, no MaxAudioChannels in profile):
expects TranscodeStream.AudioChannels == 2 for the 6-channel source. This
exercises the new codecMaxChannels() helper through the OpenSubsonic
decision endpoint, with no profile-level channel limit masking the bug.
- /rest/stream (legacy): requests format=mp3 against the multichannel
fixture and asserts streamerSpy.LastRequest.Channels == 2, confirming
the clamp propagates through ResolveRequest into the stream.Request that
the streamer receives.
The fixture is metadata-only (channels: 6 plumbed via the existing
storagetest.File helper) — no real audio bytes required, since the e2e
suite uses a spy streamer rather than invoking ffmpeg. Bumps the empty-query
search3 song count expectation from 13 to 14 to account for the new fixture.
* test(decider): clarify codec-clamp comment terminology
Distinguish "transcoding profile MaxAudioChannels" (Profile.MaxAudioChannels
field) from "LimitationAudioChannels" (CodecProfile rule constant). The
regression test bypasses the former, not the latter.
2026-04-11 19:15:07 -08:00
|
|
|
flacMultichTrackID = byTitle["TC FLAC Multichannel"]
|
|
|
|
|
Expect(flacMultichTrackID).ToNot(BeEmpty())
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", ""))
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("streams raw when format=raw is explicitly requested", func() {
|
|
|
|
|
w := doRawReq("stream", "id", flacTrackID, "format", "raw")
|
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", ""))
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("streams raw when maxBitRate is >= source bitrate", func() {
|
|
|
|
|
w := doRawReq("stream", "id", flacTrackID, "maxBitRate", "1000")
|
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", ""))
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("raw"))
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
|
2026-03-10 13:08:35 -08:00
|
|
|
// Should use the mp3 default bitrate (192kbps)
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.BitRate).To(Equal(192))
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("opus"))
|
2026-03-10 13:08:35 -08:00
|
|
|
// Should use the opus default bitrate (128kbps)
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.BitRate).To(Equal(128))
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("transcodes to opus with specified maxBitRate", func() {
|
|
|
|
|
w := doRawReq("stream", "id", flacTrackID, "format", "opus", "maxBitRate", "192")
|
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("opus"))
|
|
|
|
|
Expect(streamerSpy.LastRequest.BitRate).To(Equal(192))
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("transcodes to mp3 with specified maxBitRate", func() {
|
|
|
|
|
w := doRawReq("stream", "id", flacTrackID, "format", "mp3", "maxBitRate", "128")
|
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
|
|
|
|
|
Expect(streamerSpy.LastRequest.BitRate).To(Equal(128))
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("opus"))
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
|
|
|
|
|
Expect(streamerSpy.LastRequest.BitRate).To(Equal(128))
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
fix(transcoding): clamp target channels to codec limit (#5336) (#5345)
* fix(transcoding): clamp target channels to codec limit (#5336)
When transcoding a multi-channel source (e.g. 6-channel FLAC) to MP3, the
decider passed the source channel count through to ffmpeg unchanged. The
default MP3 command path then emitted `-ac 6`, and the template path injected
`-ac 6` after the template's own `-ac 2`, causing ffmpeg to honor the last
occurrence and fail with exit code 234 since libmp3lame only supports up to
2 channels.
Introduce `codecMaxChannels()` in core/stream/codec.go (mp3→2, opus→8),
mirroring the existing `codecMaxSampleRate` pattern, and apply the clamp in
`computeTranscodedStream` right after the sample-rate clamps. Also fix a
pre-existing ordering bug where the profile's MaxAudioChannels check compared
against src.Channels rather than ts.Channels, which would have let a looser
profile setting raise the codec-clamped value back up. Comparing against the
already-clamped ts.Channels makes profile limits strictly narrowing, which
matches how the sample-rate block already behaves.
The ffmpeg buildTemplateArgs comment is refreshed to point at the new upstream
clamp, since the flags it injects are now always codec-safe.
Adds unit tests for codecMaxChannels and four decider scenarios covering the
literal issue repro (6-ch FLAC→MP3 clamps to 2), a stricter profile limit
winning over the codec clamp, a looser profile limit leaving the codec clamp
intact, and a codec with no hard limit (AAC) passing 6 channels through.
* test(e2e): pin codec channel clamp at the Subsonic API surface (#5336)
Add a 6-channel FLAC fixture to the e2e test suite and use it to assert the
codec channel clamp end-to-end on both Subsonic streaming endpoints:
- getTranscodeDecision (mp3OnlyClient, no MaxAudioChannels in profile):
expects TranscodeStream.AudioChannels == 2 for the 6-channel source. This
exercises the new codecMaxChannels() helper through the OpenSubsonic
decision endpoint, with no profile-level channel limit masking the bug.
- /rest/stream (legacy): requests format=mp3 against the multichannel
fixture and asserts streamerSpy.LastRequest.Channels == 2, confirming
the clamp propagates through ResolveRequest into the stream.Request that
the streamer receives.
The fixture is metadata-only (channels: 6 plumbed via the existing
storagetest.File helper) — no real audio bytes required, since the e2e
suite uses a spy streamer rather than invoking ffmpeg. Bumps the empty-query
search3 song count expectation from 13 to 14 to account for the new fixture.
* test(decider): clarify codec-clamp comment terminology
Distinguish "transcoding profile MaxAudioChannels" (Profile.MaxAudioChannels
field) from "LimitationAudioChannels" (CodecProfile rule constant). The
regression test bypasses the former, not the latter.
2026-04-11 19:15:07 -08:00
|
|
|
|
|
|
|
|
It("clamps multichannel FLAC to 2 channels when transcoding to mp3 (#5336)", func() {
|
|
|
|
|
w := doRawReq("stream", "id", flacMultichTrackID, "format", "mp3", "maxBitRate", "256")
|
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
|
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
|
|
|
|
|
Expect(streamerSpy.LastRequest.Channels).To(Equal(2))
|
|
|
|
|
})
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("opus"))
|
|
|
|
|
Expect(streamerSpy.LastRequest.BitRate).To(Equal(192))
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", ""))
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
2026-03-10 13:19:25 -08:00
|
|
|
Expect(streamerSpy.LastRequest.Offset).To(Equal(30))
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|
|
|
|
|
})
|
fix(server): improve transcoding failure diagnostics and error responses (#5227)
* 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>
2026-03-18 08:39:03 -08:00
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-03-10 13:08:35 -08:00
|
|
|
})
|