mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-28 11:29:38 +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.
91 lines
3 KiB
Go
91 lines
3 KiB
Go
package stream
|
|
|
|
import (
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Codec", func() {
|
|
Describe("isLosslessFormat", func() {
|
|
It("returns true for known lossless codecs", func() {
|
|
Expect(isLosslessFormat("flac")).To(BeTrue())
|
|
Expect(isLosslessFormat("alac")).To(BeTrue())
|
|
Expect(isLosslessFormat("pcm")).To(BeTrue())
|
|
Expect(isLosslessFormat("wav")).To(BeTrue())
|
|
Expect(isLosslessFormat("dsd")).To(BeTrue())
|
|
Expect(isLosslessFormat("ape")).To(BeTrue())
|
|
Expect(isLosslessFormat("wv")).To(BeTrue())
|
|
Expect(isLosslessFormat("wavpack")).To(BeTrue()) // ffprobe codec_name for WavPack
|
|
})
|
|
|
|
It("returns false for lossy codecs", func() {
|
|
Expect(isLosslessFormat("mp3")).To(BeFalse())
|
|
Expect(isLosslessFormat("aac")).To(BeFalse())
|
|
Expect(isLosslessFormat("opus")).To(BeFalse())
|
|
Expect(isLosslessFormat("vorbis")).To(BeFalse())
|
|
})
|
|
|
|
It("returns false for unknown codecs", func() {
|
|
Expect(isLosslessFormat("unknown_codec")).To(BeFalse())
|
|
})
|
|
|
|
It("is case-insensitive", func() {
|
|
Expect(isLosslessFormat("FLAC")).To(BeTrue())
|
|
Expect(isLosslessFormat("Alac")).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Describe("normalizeProbeCodec", func() {
|
|
It("passes through common codec names unchanged", func() {
|
|
Expect(normalizeProbeCodec("mp3")).To(Equal("mp3"))
|
|
Expect(normalizeProbeCodec("aac")).To(Equal("aac"))
|
|
Expect(normalizeProbeCodec("flac")).To(Equal("flac"))
|
|
Expect(normalizeProbeCodec("opus")).To(Equal("opus"))
|
|
Expect(normalizeProbeCodec("vorbis")).To(Equal("vorbis"))
|
|
Expect(normalizeProbeCodec("alac")).To(Equal("alac"))
|
|
Expect(normalizeProbeCodec("wmav2")).To(Equal("wmav2"))
|
|
})
|
|
|
|
It("normalizes DSD variants to dsd", func() {
|
|
Expect(normalizeProbeCodec("dsd_lsbf_planar")).To(Equal("dsd"))
|
|
Expect(normalizeProbeCodec("dsd_msbf_planar")).To(Equal("dsd"))
|
|
Expect(normalizeProbeCodec("dsd_lsbf")).To(Equal("dsd"))
|
|
Expect(normalizeProbeCodec("dsd_msbf")).To(Equal("dsd"))
|
|
})
|
|
|
|
It("normalizes PCM variants to pcm", func() {
|
|
Expect(normalizeProbeCodec("pcm_s16le")).To(Equal("pcm"))
|
|
Expect(normalizeProbeCodec("pcm_s24le")).To(Equal("pcm"))
|
|
Expect(normalizeProbeCodec("pcm_s32be")).To(Equal("pcm"))
|
|
Expect(normalizeProbeCodec("pcm_f32le")).To(Equal("pcm"))
|
|
})
|
|
|
|
It("lowercases input", func() {
|
|
Expect(normalizeProbeCodec("MP3")).To(Equal("mp3"))
|
|
Expect(normalizeProbeCodec("AAC")).To(Equal("aac"))
|
|
Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd"))
|
|
})
|
|
})
|
|
|
|
Describe("codecMaxChannels", func() {
|
|
It("returns 2 for mp3", func() {
|
|
Expect(codecMaxChannels("mp3")).To(Equal(2))
|
|
})
|
|
|
|
It("returns 8 for opus", func() {
|
|
Expect(codecMaxChannels("opus")).To(Equal(8))
|
|
})
|
|
|
|
It("is case-insensitive", func() {
|
|
Expect(codecMaxChannels("MP3")).To(Equal(2))
|
|
Expect(codecMaxChannels("Opus")).To(Equal(8))
|
|
})
|
|
|
|
It("returns 0 for codecs with no hard limit", func() {
|
|
Expect(codecMaxChannels("aac")).To(Equal(0))
|
|
Expect(codecMaxChannels("flac")).To(Equal(0))
|
|
Expect(codecMaxChannels("vorbis")).To(Equal(0))
|
|
Expect(codecMaxChannels("")).To(Equal(0))
|
|
})
|
|
})
|
|
})
|