* 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.
192 lines
7 KiB
Go
192 lines
7 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)
|
|
flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch)
|
|
)
|
|
|
|
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())
|
|
flacMultichTrackID = byTitle["TC FLAC Multichannel"]
|
|
Expect(flacMultichTrackID).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))
|
|
})
|
|
|
|
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))
|
|
})
|
|
})
|
|
|
|
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))
|
|
})
|
|
})
|
|
})
|