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.
This commit is contained in:
Deluan Quintão 2026-04-11 23:15:07 -04:00 • committed by GitHub
commit 27209ed26a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 151 additions and 14 deletions

View file

@ -172,6 +172,10 @@ func buildTestFS() storagetest.FakeFS {
"title": "TC MKA Opus", "track": 6, "suffix": "mka", "codec": "opus",
"bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(220),
}),
"Test/Transcode Formats/07 - TC FLAC Multichannel.flac": file(tcBase, _t{
"title": "TC FLAC Multichannel", "track": 7, "suffix": "flac",
"bitrate": 4500, "samplerate": 48000, "bitdepth": 24, "channels": 6, "duration": int64(180),
}),
// _empty folder (directory with no audio)
"_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()},

View file

@ -117,7 +117,7 @@ var _ = Describe("Search Endpoints", func() {
Expect(resp.SearchResult3).ToNot(BeNil())
Expect(resp.SearchResult3.Artist).To(HaveLen(6))
Expect(resp.SearchResult3.Album).To(HaveLen(7))
Expect(resp.SearchResult3.Song).To(HaveLen(13))
Expect(resp.SearchResult3.Song).To(HaveLen(14))
})
It("finds across all entity types simultaneously", func() {

View file

@ -13,8 +13,9 @@ import (
var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
var (
mp3TrackID string // Come Together (mp3, 320kbps)
flacTrackID string // TC FLAC Standard (flac, 900kbps)
mp3TrackID string // Come Together (mp3, 320kbps)
flacTrackID string // TC FLAC Standard (flac, 900kbps)
flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch)
)
BeforeAll(func() {
@ -30,6 +31,8 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
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() {
@ -101,6 +104,13 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
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() {

View file

@ -114,13 +114,14 @@ const (
var _ = Describe("Transcode Endpoints", Ordered, func() {
// Track IDs resolved in BeforeAll
var (
mp3TrackID string // Come Together (mp3, 320kbps)
flacTrackID string // TC FLAC Standard (flac, 900kbps)
flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps)
alacTrackID string // TC ALAC Track (m4a, alac)
dsdTrackID string // TC DSD Track (dsf, dsd)
opusTrackID string // TC Opus Track (opus, 128kbps)
mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag)
mp3TrackID string // Come Together (mp3, 320kbps)
flacTrackID string // TC FLAC Standard (flac, 900kbps)
flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps)
flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch)
alacTrackID string // TC ALAC Track (m4a, alac)
dsdTrackID string // TC DSD Track (dsf, dsd)
opusTrackID string // TC Opus Track (opus, 128kbps)
mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag)
)
BeforeAll(func() {
@ -140,6 +141,7 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
mp3TrackID = ensureGetTrackID("Come Together")
flacTrackID = ensureGetTrackID("TC FLAC Standard")
flacHiResTrackID = ensureGetTrackID("TC FLAC HiRes")
flacMultichTrackID = ensureGetTrackID("TC FLAC Multichannel")
alacTrackID = ensureGetTrackID("TC ALAC Track")
dsdTrackID = ensureGetTrackID("TC DSD Track")
opusTrackID = ensureGetTrackID("TC Opus Track")
@ -353,6 +355,19 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
// maxTranscodingAudioBitrate is 192000 bps = 192 kbps → response in bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
})
It("clamps multichannel FLAC to 2 channels when transcoding to MP3 (#5336)", func() {
// mp3OnlyClient has no MaxAudioChannels set, so this exercises the
// codec-intrinsic clamp in core/stream/codec.go (codecMaxChannels).
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacMultichTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.SourceStream.AudioChannels).To(Equal(int32(6)))
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("mp3"))
Expect(resp.TranscodeDecision.TranscodeStream.AudioChannels).To(Equal(int32(2)))
})
})
Describe("response structure", func() {