Files
navidrome/server/e2e/subsonic_transcode_test.go
Deluan Quintão 27209ed26a 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.
2026-04-11 23:15:07 -04:00

699 lines
31 KiB
Go

package e2e
import (
"errors"
"net/http"
"time"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Client profile JSON bodies for getTranscodeDecision requests.
// All bitrate values are in bps (per OpenSubsonic spec).
const (
// mp3OnlyClient can direct-play mp3 and transcode to mp3
mp3OnlyClient = `{
"name": "test-mp3-only",
"directPlayProfiles": [
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}
],
"transcodingProfiles": [
{"container": "mp3", "audioCodec": "mp3", "protocol": "http"}
]
}`
// flacAndMp3Client can direct-play flac and mp3, transcode to mp3
flacAndMp3Client = `{
"name": "test-flac-mp3",
"directPlayProfiles": [
{"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]},
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}
],
"transcodingProfiles": [
{"container": "mp3", "audioCodec": "mp3", "protocol": "http"}
]
}`
// universalClient can direct-play most formats
universalClient = `{
"name": "test-universal",
"directPlayProfiles": [
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]},
{"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]},
{"containers": ["m4a"], "audioCodecs": ["alac", "aac"], "protocols": ["http"]},
{"containers": ["opus", "ogg"], "audioCodecs": ["opus"], "protocols": ["http"]},
{"containers": ["wav"], "audioCodecs": ["pcm"], "protocols": ["http"]},
{"containers": ["dsf"], "audioCodecs": ["dsd"], "protocols": ["http"]}
],
"transcodingProfiles": [
{"container": "mp3", "audioCodec": "mp3", "protocol": "http"}
]
}`
// bitrateCapClient has maxAudioBitrate set to 320000 bps (320 kbps)
bitrateCapClient = `{
"name": "test-bitrate-cap",
"maxAudioBitrate": 320000,
"directPlayProfiles": [
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]},
{"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]}
],
"transcodingProfiles": [
{"container": "mp3", "audioCodec": "mp3", "protocol": "http"}
]
}`
// opusTranscodeClient can direct-play mp3, transcode to opus
opusTranscodeClient = `{
"name": "test-opus-transcode",
"directPlayProfiles": [
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}
],
"transcodingProfiles": [
{"container": "opus", "audioCodec": "opus", "protocol": "http"}
]
}`
// flacOnlyClient can direct-play flac, transcode to flac (no mp3 support at all)
flacOnlyClient = `{
"name": "test-flac-only",
"directPlayProfiles": [
{"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]}
],
"transcodingProfiles": [
{"container": "flac", "audioCodec": "flac", "protocol": "http"}
]
}`
// maxTranscodeBitrateClient has maxTranscodingAudioBitrate set
maxTranscodeBitrateClient = `{
"name": "test-max-transcode-bitrate",
"maxTranscodingAudioBitrate": 192000,
"directPlayProfiles": [
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}
],
"transcodingProfiles": [
{"container": "mp3", "audioCodec": "mp3", "protocol": "http"}
]
}`
// dsdToFlacClient can direct-play mp3, transcode to flac
dsdToFlacClient = `{
"name": "test-dsd-to-flac",
"directPlayProfiles": [
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}
],
"transcodingProfiles": [
{"container": "flac", "audioCodec": "flac", "protocol": "http"}
]
}`
)
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)
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() {
setupTestDB()
songs, err := ds.MediaFile(ctx).GetAll()
Expect(err).ToNot(HaveOccurred())
byTitle := map[string]string{}
for _, s := range songs {
byTitle[s.Title] = s.ID
}
ensureGetTrackID := func(title string) string {
id := byTitle[title]
Expect(id).ToNot(BeEmpty())
return id
}
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")
mkaOpusTrackID = ensureGetTrackID("TC MKA Opus")
})
Describe("getTranscodeDecision", func() {
// setPlayerMaxBitRate ensures a player exists for the test-client and sets its MaxBitRate.
// It makes a dummy request to register the player, then updates it via the repository.
setPlayerMaxBitRate := func(maxBitRate int) {
doReq("ping")
player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "")
Expect(err).ToNot(HaveOccurred())
player.MaxBitRate = maxBitRate
Expect(ds.Player(ctx).Put(player)).To(Succeed())
}
AfterEach(func() {
// Reset player MaxBitRate to 0 after each test
player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "")
if err == nil {
player.MaxBitRate = 0
_ = ds.Player(ctx).Put(player)
}
})
Describe("error cases", func() {
It("returns 405 for GET request", func() {
w := doRawReq("getTranscodeDecision", "mediaId", mp3TrackID, "mediaType", "song")
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
})
It("returns error when mediaId is missing", func() {
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter))
})
It("returns error when mediaType is missing", func() {
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter))
})
It("returns error for unsupported mediaType", func() {
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "video")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(responses.ErrorGeneric))
})
It("returns error for invalid JSON body", func() {
resp := doPostReq("getTranscodeDecision", "{invalid-json", "mediaId", mp3TrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
It("returns error for empty JSON body", func() {
w := doRawPostReq("getTranscodeDecision", "", "mediaId", mp3TrackID, "mediaType", "song")
Expect(w.Code).To(Equal(http.StatusOK)) // Subsonic errors are returned as 200 with error status
resp := parseJSONResponse(w)
Expect(resp.Status).To(Equal(responses.StatusFailed))
})
It("returns error for non-existent media ID", func() {
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", "non-existent-id", "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(responses.ErrorDataNotFound))
})
It("returns error for invalid protocol in body", func() {
invalidBody := `{
"directPlayProfiles": [
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["invalid-protocol"]}
]
}`
resp := doPostReq("getTranscodeDecision", invalidBody, "mediaId", mp3TrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
It("returns error for invalid comparison operator in body", func() {
invalidBody := `{
"directPlayProfiles": [
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}
],
"codecProfiles": [{
"type": "AudioCodec", "name": "mp3",
"limitations": [{"name": "audioBitrate", "comparison": "InvalidOp", "values": ["320000"]}]
}]
}`
resp := doPostReq("getTranscodeDecision", invalidBody, "mediaId", mp3TrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
})
Describe("direct play decisions", func() {
It("allows MP3 direct play when client supports mp3", func() {
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).To(BeNil())
Expect(resp.TranscodeDecision.TranscodeParams).ToNot(BeEmpty())
})
It("allows FLAC direct play when client supports flac", func() {
resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
})
It("allows ALAC direct play via m4a container + alac codec matching", func() {
resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", alacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
})
It("allows Opus direct play when client supports opus", func() {
resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", opusTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
})
It("denies direct play when container mismatches", func() {
// mp3OnlyClient cannot play FLAC container
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
})
It("denies direct play when codec mismatches", func() {
// MKA container with opus codec — client only supports mp3
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mkaOpusTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
})
It("denies direct play when maxAudioBitrate exceeded", func() {
// bitrateCapClient caps at 320kbps, FLAC is 900kbps
resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
})
})
Describe("transcode decisions", func() {
It("transcodes FLAC to MP3 when client only supports MP3", func() {
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("mp3"))
Expect(resp.TranscodeDecision.TranscodeParams).ToNot(BeEmpty())
})
It("transcodes FLAC hi-res to Opus with correct sample rate", func() {
resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacHiResTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus"))
// Opus always outputs 48000 Hz
Expect(resp.TranscodeDecision.TranscodeStream.AudioSamplerate).To(Equal(int32(48000)))
})
It("transcodes DSD to FLAC with normalized sample rate and bit depth", func() {
resp := doPostReq("getTranscodeDecision", dsdToFlacClient, "mediaId", dsdTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("flac"))
// DSD sample rate normalized: 2822400 / 8 = 352800
Expect(resp.TranscodeDecision.TranscodeStream.AudioSamplerate).To(Equal(int32(352800)))
// DSD 1-bit → 24-bit PCM
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitdepth).To(Equal(int32(24)))
})
It("refuses lossy to lossless transcoding: MP3 to FLAC", func() {
// flacOnlyClient can't direct-play mp3, and lossy→lossless transcode is rejected
resp := doPostReq("getTranscodeDecision", flacOnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
// MP3 is lossy, FLAC is lossless — should not allow transcoding
Expect(resp.TranscodeDecision.CanTranscode).To(BeFalse())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
Expect(resp.TranscodeDecision.TranscodeParams).To(BeEmpty())
})
It("caps transcode bitrate via maxTranscodingAudioBitrate", func() {
resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
// 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() {
It("has correct sourceStream details", func() {
resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
src := resp.TranscodeDecision.SourceStream
Expect(src).ToNot(BeNil())
Expect(src.Container).To(Equal("flac"))
Expect(src.Codec).To(Equal("flac"))
// AudioBitrate is in bps: 900 kbps * 1000 = 900000 bps
Expect(src.AudioBitrate).To(Equal(int32(900000)))
Expect(src.AudioSamplerate).To(Equal(int32(44100)))
Expect(src.AudioChannels).To(Equal(int32(2)))
Expect(src.Protocol).To(Equal("http"))
})
It("reports audioBitrate in bps (kbps * 1000)", func() {
resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", mp3TrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
src := resp.TranscodeDecision.SourceStream
Expect(src).ToNot(BeNil())
// MP3 is 320 kbps → 320000 bps
Expect(src.AudioBitrate).To(Equal(int32(320000)))
})
})
Describe("player MaxBitRate cap", func() {
It("forces transcode when source bitrate exceeds player MaxBitRate", func() {
setPlayerMaxBitRate(320) // 320 kbps cap
// FLAC is 900kbps, client has no bitrate limit but player cap is 320
resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
// Target bitrate should be capped at player's 320kbps = 320000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
})
It("does not affect direct play when source bitrate is under player MaxBitRate", func() {
setPlayerMaxBitRate(500) // 500 kbps cap
// MP3 is 320kbps, under the 500kbps player cap → direct play
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
})
It("uses client limit when more restrictive than player MaxBitRate", func() {
setPlayerMaxBitRate(500) // 500 kbps player cap
// Client caps at 320kbps (bitrateCapClient), which is more restrictive than 500
// FLAC is 900kbps → exceeds both limits → transcode
resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
// Client limit (320kbps) is more restrictive → 320000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
})
It("uses player MaxBitRate when more restrictive than client limit", func() {
setPlayerMaxBitRate(192) // 192 kbps player cap
// Client caps at 320kbps (bitrateCapClient), player is more restrictive at 192
// FLAC is 900kbps → transcode at 192kbps
resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
// Player limit (192kbps) is more restrictive → 192000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
})
It("has no effect when player MaxBitRate is 0", func() {
setPlayerMaxBitRate(0) // No player cap
// FLAC with flac+mp3 client → direct play (no bitrate constraint)
resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
})
})
Describe("format-aware default bitrate", func() {
It("uses mp3 format default (192kbps) for lossless-to-mp3 with no bitrate limits", func() {
// mp3OnlyClient has no maxAudioBitrate or maxTranscodingAudioBitrate
// FLAC → MP3 should use the mp3 default bitrate (192kbps), not hardcoded 256
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
// mp3 default is 192kbps = 192000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
})
It("uses opus format default (128kbps) for lossless-to-opus with no bitrate limits", func() {
// opusTranscodeClient has no maxAudioBitrate or maxTranscodingAudioBitrate
// FLAC → Opus should use the opus default bitrate (128kbps)
resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus"))
// opus default is 128kbps = 128000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000)))
})
It("uses maxAudioBitrate as fallback for lossless-to-lossy when no maxTranscodingAudioBitrate", func() {
// bitrateCapClient has maxAudioBitrate=320000 but no maxTranscodingAudioBitrate
// FLAC → MP3: maxAudioBitrate (320kbps) should be used as the target
resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
// maxAudioBitrate is 320kbps = 320000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
})
It("prefers maxTranscodingAudioBitrate over maxAudioBitrate for lossless-to-lossy", func() {
// maxTranscodeBitrateClient has maxTranscodingAudioBitrate=192000
// FLAC → MP3: should use 192kbps, not format default or maxAudioBitrate
resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
// maxTranscodingAudioBitrate is 192kbps = 192000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
})
})
Describe("player MaxBitRate + client limits combined", func() {
It("player MaxBitRate injects maxAudioBitrate, format default used for transcode target", func() {
setPlayerMaxBitRate(320)
// opusTranscodeClient has no client bitrate limits
// Player cap injects maxAudioBitrate=320
// FLAC (900kbps) → exceeds 320 → transcode to opus
// Lossless→lossy: maxTranscodingAudioBitrate=0, so falls back to maxAudioBitrate=320
resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus"))
// maxAudioBitrate=320 used as fallback → 320000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
})
It("player MaxBitRate + client maxTranscodingAudioBitrate work together", func() {
setPlayerMaxBitRate(320)
// maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps), no maxAudioBitrate
// Player cap injects maxAudioBitrate=320
// FLAC (900kbps) → exceeds 320 → transcode to mp3
// Lossless→lossy: maxTranscodingAudioBitrate=192 takes priority
resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
// maxTranscodingAudioBitrate=192 is preferred → 192000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
})
It("streams with correct bitrate after player MaxBitRate-triggered transcode", func() {
setPlayerMaxBitRate(128)
// Get decision: FLAC (900kbps) with player cap 128 → transcode
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
token := resp.TranscodeDecision.TranscodeParams
Expect(token).ToNot(BeEmpty())
// Stream using the token
w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
Expect(streamerSpy.LastRequest.BitRate).To(Equal(128))
})
})
})
Describe("getTranscodeStream", func() {
Describe("error cases", func() {
It("returns 400 when mediaId is missing", func() {
w := doRawReq("getTranscodeStream", "mediaType", "song", "transcodeParams", "some-token")
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 400 when mediaType is missing", func() {
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "transcodeParams", "some-token")
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 400 when transcodeParams is missing", func() {
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song")
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 400 for unsupported mediaType", func() {
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "video", "transcodeParams", "some-token")
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 410 for malformed token", func() {
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", "invalid-token")
Expect(w.Code).To(Equal(http.StatusGone))
})
It("returns 410 for stale token (media file updated after token issued)", func() {
// Get a valid decision token
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
token := resp.TranscodeDecision.TranscodeParams
Expect(token).ToNot(BeEmpty())
// Save original UpdatedAt and restore after test
mf, err := ds.MediaFile(ctx).Get(mp3TrackID)
Expect(err).ToNot(HaveOccurred())
originalUpdatedAt := mf.UpdatedAt
// Update the media file's UpdatedAt to simulate a change after token issuance
mf.UpdatedAt = time.Now().Add(time.Hour)
Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed())
// Attempt to stream with the now-stale token
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", token)
Expect(w.Code).To(Equal(http.StatusGone))
// Restore original UpdatedAt
mf.UpdatedAt = originalUpdatedAt
Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed())
})
It("returns 500 when stream creation fails", func() {
// Get a valid decision token
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
token := resp.TranscodeDecision.TranscodeParams
Expect(token).ToNot(BeEmpty())
// Simulate streamer failure (e.g., ffmpeg missing codec)
streamerSpy.SimulateError = errors.New("ffmpeg exited with non-zero status code: 1: Unknown encoder 'libopus'")
defer func() { streamerSpy.SimulateError = nil }()
w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
It("returns 500 when transcoded stream is empty", func() {
// Get a valid decision token
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
token := resp.TranscodeDecision.TranscodeParams
Expect(token).ToNot(BeEmpty())
// Simulate ffmpeg producing 0 bytes
streamerSpy.SimulateEmptyStream = true
defer func() { streamerSpy.SimulateEmptyStream = false }()
w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Describe("round-trip: decision then stream", func() {
It("streams direct play for MP3", func() {
// Get decision
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
token := resp.TranscodeDecision.TranscodeParams
Expect(token).ToNot(BeEmpty())
// Stream using the token
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", token)
Expect(w.Code).To(Equal(http.StatusOK))
// Direct play: format should be "raw" or empty
Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", ""))
})
It("streams transcoded FLAC to MP3", func() {
// Get decision
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
token := resp.TranscodeDecision.TranscodeParams
Expect(token).ToNot(BeEmpty())
// Stream using the token
w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
})
It("passes offset through to stream request", func() {
// Get decision
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
token := resp.TranscodeDecision.TranscodeParams
Expect(token).ToNot(BeEmpty())
// Stream with offset
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song",
"transcodeParams", token, "offset", "30")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(streamerSpy.LastRequest.Offset).To(Equal(30))
})
})
})
})