navidrome/server/e2e/subsonic_transcode_test.go
Deluan Quintão b18dfb474a
fix(transcoding): don't apply server-side override on getTranscodeDecision (#5473)
* fix(transcoding): don't apply server-side transcoding override on getTranscodeDecision

The getTranscodeDecision endpoint was incorrectly applying server-side
player transcoding overrides (forced format and MaxBitRate cap), which
replaced the client's declared capabilities with synthetic profiles.
This caused the endpoint to ignore what the client can actually play and
return decisions for formats the client never requested (e.g. AAC when
the client only supports FLAC/opus/mp3). The override is now gated
behind an ApplyServerOverride flag in TranscodeOptions, which is only
set by the legacy stream endpoint where this behavior is expected.

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: move server-side transcoding override to ResolveRequest

Moved the server-side player transcoding override logic (forced format
and MaxBitRate cap) from MakeDecision into ResolveRequest, where the
legacy stream context is handled. This makes MakeDecision a pure
function that only operates on the ClientInfo it receives, removing the
ApplyServerOverride flag and all context-sniffing from the decision
engine. Tests moved accordingly to legacy_client_test.go.

* test(e2e): update transcode decision tests for server override removal

Updated e2e tests to reflect that getTranscodeDecision no longer applies
server-side player overrides (MaxBitRate cap and forced transcoding
profile). The player MaxBitRate tests now verify the endpoint ignores
the player cap and relies solely on client-declared capabilities.

* test(e2e): assert opus default bitrate when player cap is ignored

Added bitrate assertion to verify the player MaxBitRate cap is truly
ignored: the target bitrate should be the opus format default (128kbps),
not the player cap (320kbps).

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-06 10:03:24 -04:00

642 lines
28 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 is ignored", func() {
It("allows direct play even when source bitrate exceeds player MaxBitRate", func() {
setPlayerMaxBitRate(320) // 320 kbps cap
// FLAC is 900kbps, player cap is 320, but getTranscodeDecision
// ignores server-side overrides — client profiles are used as-is
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("uses only client limit, not player MaxBitRate", func() {
setPlayerMaxBitRate(192) // 192 kbps player cap
// Client caps at 320kbps (bitrateCapClient), player is more restrictive at 192
// but getTranscodeDecision ignores player cap → client limit (320kbps) applies
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())
// Only client limit (320kbps) applies → 320000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
})
})
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 is ignored by getTranscodeDecision", func() {
It("does not inject maxAudioBitrate from player cap", func() {
setPlayerMaxBitRate(320)
// opusTranscodeClient has no client bitrate limits
// Player cap is 320, but getTranscodeDecision ignores it
// FLAC (900kbps) → can't direct play → transcode to opus using format default
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"))
// Bitrate should be opus format default (128kbps), not player cap (320kbps)
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000)))
})
It("uses only client maxTranscodingAudioBitrate, ignoring player cap", func() {
setPlayerMaxBitRate(320)
// maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps)
// Player cap is 320, but getTranscodeDecision ignores it
// Only client maxTranscodingAudioBitrate=192 applies
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 → 192000 bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
})
})
})
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))
})
})
})
})