mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
* 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.
193 lines
7.0 KiB
Go
193 lines
7.0 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))
|
|
})
|
|
})
|
|
})
|