mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-17 13:10:27 -04: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.
92 lines
3.0 KiB
Go
92 lines
3.0 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))
|
|
})
|
|
})
|
|
})
|