2026-03-09 09:43:55 -04:00
|
|
|
package e2e
|
|
|
|
|
|
|
|
|
|
import (
|
fix(server): improve transcoding failure diagnostics and error responses (#5227)
* fix(server): capture ffmpeg stderr and warn on empty transcoded output
When ffmpeg fails during transcoding (e.g., missing codec like libopus),
the error was silently discarded because stderr was sent to io.Discard
and the HTTP response returned 200 OK with a 0-byte body.
- Capture ffmpeg stderr in a bounded buffer (4KB) and include it in the
error message when the process exits with a non-zero status code
- Log a warning when transcoded output is 0 bytes, guiding users to
check codec support and enable Trace logging for details
- Remove log level guard so transcoding errors are always logged, not
just at Debug level
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(server): return proper error responses for empty transcoded output
Instead of returning HTTP 200 with 0-byte body when transcoding fails,
return a Subsonic error response (for stream/download/getTranscodeStream)
or HTTP 500 (for public shared streams). This gives clients a clear
signal that the request failed rather than a misleading empty success.
Signed-off-by: Deluan <deluan@navidrome.org>
* test(e2e): add tests for empty transcoded stream error responses
Add E2E tests verifying that stream and download endpoints return
Subsonic error responses when transcoding produces empty output.
Extend spyStreamer with SimulateEmptyStream and SimulateError fields
to support failure injection in tests.
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(server): extract stream serving logic into Stream.Serve method
Extract the duplicated non-seekable stream serving logic (header setup,
estimateContentLength, HEAD draining, io.Copy with error/empty detection)
from server/subsonic/stream.go and server/public/handle_streams.go into a
single Stream.Serve method on core/stream. Both callers now delegate to it,
eliminating ~30 lines of near-identical code.
* fix(server): return 200 with empty body for stream/download on empty transcoded output
Don't return a Subsonic error response when transcoding produces empty
output on stream/download endpoints — just log the error and return 200
with an empty body. The getTranscodeStream and public share endpoints
still return HTTP 500 for empty output. Stream.Serve now returns
(int64, error) so callers can check the byte count.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-18 12:39:03 -04:00
|
|
|
"errors"
|
2026-03-09 09:43:55 -04:00
|
|
|
"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 (
|
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
|
|
|
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)
|
2026-03-09 09:43:55 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
BeforeAll(func() {
|
|
|
|
|
setupTestDB()
|
|
|
|
|
|
2026-09-25 18:06:10 -04:00
|
|
|
songs, err := ds.MediaFile().GetAll(ctx)
|
2026-03-09 09:43:55 -04:00
|
|
|
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")
|
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
|
|
|
flacMultichTrackID = ensureGetTrackID("TC FLAC Multichannel")
|
2026-03-09 09:43:55 -04:00
|
|
|
alacTrackID = ensureGetTrackID("TC ALAC Track")
|
|
|
|
|
dsdTrackID = ensureGetTrackID("TC DSD Track")
|
|
|
|
|
opusTrackID = ensureGetTrackID("TC Opus Track")
|
|
|
|
|
mkaOpusTrackID = ensureGetTrackID("TC MKA Opus")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("getTranscodeDecision", func() {
|
2026-03-09 16:47:34 -04:00
|
|
|
// 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")
|
2026-09-25 18:06:10 -04:00
|
|
|
player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "")
|
2026-03-09 16:47:34 -04:00
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
player.MaxBitRate = maxBitRate
|
2026-09-25 18:06:10 -04:00
|
|
|
Expect(ds.Player().Put(ctx, player)).To(Succeed())
|
2026-03-09 16:47:34 -04:00
|
|
|
}
|
|
|
|
|
|
2026-06-14 20:52:19 -04:00
|
|
|
setPlayerForcedFormat := func(format string) {
|
|
|
|
|
doReq("ping")
|
2026-09-25 18:06:10 -04:00
|
|
|
player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "")
|
2026-06-14 20:52:19 -04:00
|
|
|
Expect(err).ToNot(HaveOccurred())
|
2026-09-25 18:06:10 -04:00
|
|
|
trc, err := ds.Transcoding().FindByFormat(ctx, format)
|
2026-06-14 20:52:19 -04:00
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
player.TranscodingId = trc.ID
|
2026-09-25 18:06:10 -04:00
|
|
|
Expect(ds.Player().Put(ctx, player)).To(Succeed())
|
2026-06-14 20:52:19 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-09 16:47:34 -04:00
|
|
|
AfterEach(func() {
|
|
|
|
|
// Reset player MaxBitRate to 0 after each test
|
2026-09-25 18:06:10 -04:00
|
|
|
player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "")
|
2026-03-09 16:47:34 -04:00
|
|
|
if err == nil {
|
|
|
|
|
player.MaxBitRate = 0
|
2026-06-14 20:52:19 -04:00
|
|
|
player.TranscodingId = ""
|
2026-09-25 18:06:10 -04:00
|
|
|
_ = ds.Player().Put(ctx, player)
|
2026-03-09 16:47:34 -04:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-09 09:43:55 -04:00
|
|
|
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)))
|
|
|
|
|
})
|
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
|
|
|
|
|
|
|
|
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)))
|
|
|
|
|
})
|
2026-03-09 09:43:55 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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)))
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-03-09 16:47:34 -04:00
|
|
|
|
fix(transcoding): enforce server-side player MaxBitRate on /rest/stream (#5611)
* fix(transcoding): enforce player MaxBitRate on getTranscodeDecision
The Web UI streams via getTranscodeDecision, which (since #5473) ignored
the server-side player config. Apply the player's MaxBitRate as a bitrate
ceiling on the client's declared limits before MakeDecision, restoring
per-player bitrate enforcement without reintroducing the forced-format
override. Fixes #5583.
* test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision
Invert the assertions added in #5473 that expected the player cap to be
ignored; getTranscodeDecision now enforces it (issue #5583).
* feat(ui): clarify web player ignores forced transcoding format
Add helper text to the Transcoding field on the player edit form when the
player is the NavidromeUI web client, since it enforces only the Max. Bit
Rate, not the forced format. Part of issue #5583.
* refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths
Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate
method in core/stream, used by both getTranscodeDecision and the legacy
ResolveRequest path. Removes handler-layer duplication and corrects a
misleading comment that wrongly implied the legacy single-field cap was buggy.
* fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set
A bare /stream or /download request from a player configured with a
server-side MaxBitRate (but no forced format) was served raw, ignoring the
cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the
player MaxBitRate alone is below the source bitrate, matching the
already-correct forced-format and request-bitrate paths. Part of #5583.
* fix(ui): add Brazilian Portuguese translation for player transcoding helper text
Translates the new resources.player.helperTexts.transcodingId key added for
the web player transcoding-format clarification. Part of #5583.
* fix(ui): restore Transcoding field styling and render helper text
The TranscodingInput wrapper swallowed the variant SimpleForm injects into
its direct children (field lost its outlined box) and put helperText on the
ReferenceInput, which does not forward it to the input. Spread the form props
onto ReferenceInput and move helperText to the SelectInput child so both the
outlined styling and the helper text render. Part of #5583.
* fix(i18n): update Brazilian Portuguese translation for album artist field
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): clean up comments in PlayerEdit component
Signed-off-by: Deluan <deluan@navidrome.org>
* test(ui): mock useTranslate in PlayerEdit test for determinism
Avoid depending on ra-core's out-of-provider translation behavior, which can
vary by version. Part of #5583.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 16:52:01 -04:00
|
|
|
Describe("player MaxBitRate cap is enforced", func() {
|
|
|
|
|
It("forces transcode when source bitrate exceeds player MaxBitRate", func() {
|
2026-03-09 16:47:34 -04:00
|
|
|
setPlayerMaxBitRate(320) // 320 kbps cap
|
|
|
|
|
|
fix(transcoding): enforce server-side player MaxBitRate on /rest/stream (#5611)
* fix(transcoding): enforce player MaxBitRate on getTranscodeDecision
The Web UI streams via getTranscodeDecision, which (since #5473) ignored
the server-side player config. Apply the player's MaxBitRate as a bitrate
ceiling on the client's declared limits before MakeDecision, restoring
per-player bitrate enforcement without reintroducing the forced-format
override. Fixes #5583.
* test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision
Invert the assertions added in #5473 that expected the player cap to be
ignored; getTranscodeDecision now enforces it (issue #5583).
* feat(ui): clarify web player ignores forced transcoding format
Add helper text to the Transcoding field on the player edit form when the
player is the NavidromeUI web client, since it enforces only the Max. Bit
Rate, not the forced format. Part of issue #5583.
* refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths
Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate
method in core/stream, used by both getTranscodeDecision and the legacy
ResolveRequest path. Removes handler-layer duplication and corrects a
misleading comment that wrongly implied the legacy single-field cap was buggy.
* fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set
A bare /stream or /download request from a player configured with a
server-side MaxBitRate (but no forced format) was served raw, ignoring the
cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the
player MaxBitRate alone is below the source bitrate, matching the
already-correct forced-format and request-bitrate paths. Part of #5583.
* fix(ui): add Brazilian Portuguese translation for player transcoding helper text
Translates the new resources.player.helperTexts.transcodingId key added for
the web player transcoding-format clarification. Part of #5583.
* fix(ui): restore Transcoding field styling and render helper text
The TranscodingInput wrapper swallowed the variant SimpleForm injects into
its direct children (field lost its outlined box) and put helperText on the
ReferenceInput, which does not forward it to the input. Spread the form props
onto ReferenceInput and move helperText to the SelectInput child so both the
outlined styling and the helper text render. Part of #5583.
* fix(i18n): update Brazilian Portuguese translation for album artist field
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): clean up comments in PlayerEdit component
Signed-off-by: Deluan <deluan@navidrome.org>
* test(ui): mock useTranslate in PlayerEdit test for determinism
Avoid depending on ra-core's out-of-provider translation behavior, which can
vary by version. Part of #5583.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 16:52:01 -04:00
|
|
|
// FLAC is 900kbps. Player cap (320) < source → direct play is
|
|
|
|
|
// rejected and the file is transcoded down.
|
2026-03-09 16:47:34 -04:00
|
|
|
resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song")
|
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
fix(transcoding): enforce server-side player MaxBitRate on /rest/stream (#5611)
* fix(transcoding): enforce player MaxBitRate on getTranscodeDecision
The Web UI streams via getTranscodeDecision, which (since #5473) ignored
the server-side player config. Apply the player's MaxBitRate as a bitrate
ceiling on the client's declared limits before MakeDecision, restoring
per-player bitrate enforcement without reintroducing the forced-format
override. Fixes #5583.
* test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision
Invert the assertions added in #5473 that expected the player cap to be
ignored; getTranscodeDecision now enforces it (issue #5583).
* feat(ui): clarify web player ignores forced transcoding format
Add helper text to the Transcoding field on the player edit form when the
player is the NavidromeUI web client, since it enforces only the Max. Bit
Rate, not the forced format. Part of issue #5583.
* refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths
Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate
method in core/stream, used by both getTranscodeDecision and the legacy
ResolveRequest path. Removes handler-layer duplication and corrects a
misleading comment that wrongly implied the legacy single-field cap was buggy.
* fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set
A bare /stream or /download request from a player configured with a
server-side MaxBitRate (but no forced format) was served raw, ignoring the
cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the
player MaxBitRate alone is below the source bitrate, matching the
already-correct forced-format and request-bitrate paths. Part of #5583.
* fix(ui): add Brazilian Portuguese translation for player transcoding helper text
Translates the new resources.player.helperTexts.transcodingId key added for
the web player transcoding-format clarification. Part of #5583.
* fix(ui): restore Transcoding field styling and render helper text
The TranscodingInput wrapper swallowed the variant SimpleForm injects into
its direct children (field lost its outlined box) and put helperText on the
ReferenceInput, which does not forward it to the input. Spread the form props
onto ReferenceInput and move helperText to the SelectInput child so both the
outlined styling and the helper text render. Part of #5583.
* fix(i18n): update Brazilian Portuguese translation for album artist field
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): clean up comments in PlayerEdit component
Signed-off-by: Deluan <deluan@navidrome.org>
* test(ui): mock useTranslate in PlayerEdit test for determinism
Avoid depending on ra-core's out-of-provider translation behavior, which can
vary by version. Part of #5583.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 16:52:01 -04:00
|
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
|
|
|
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
|
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
|
|
|
// Target bitrate is capped at the player MaxBitRate (320kbps).
|
|
|
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
|
2026-03-09 16:47:34 -04:00
|
|
|
})
|
|
|
|
|
|
fix(transcoding): enforce server-side player MaxBitRate on /rest/stream (#5611)
* fix(transcoding): enforce player MaxBitRate on getTranscodeDecision
The Web UI streams via getTranscodeDecision, which (since #5473) ignored
the server-side player config. Apply the player's MaxBitRate as a bitrate
ceiling on the client's declared limits before MakeDecision, restoring
per-player bitrate enforcement without reintroducing the forced-format
override. Fixes #5583.
* test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision
Invert the assertions added in #5473 that expected the player cap to be
ignored; getTranscodeDecision now enforces it (issue #5583).
* feat(ui): clarify web player ignores forced transcoding format
Add helper text to the Transcoding field on the player edit form when the
player is the NavidromeUI web client, since it enforces only the Max. Bit
Rate, not the forced format. Part of issue #5583.
* refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths
Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate
method in core/stream, used by both getTranscodeDecision and the legacy
ResolveRequest path. Removes handler-layer duplication and corrects a
misleading comment that wrongly implied the legacy single-field cap was buggy.
* fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set
A bare /stream or /download request from a player configured with a
server-side MaxBitRate (but no forced format) was served raw, ignoring the
cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the
player MaxBitRate alone is below the source bitrate, matching the
already-correct forced-format and request-bitrate paths. Part of #5583.
* fix(ui): add Brazilian Portuguese translation for player transcoding helper text
Translates the new resources.player.helperTexts.transcodingId key added for
the web player transcoding-format clarification. Part of #5583.
* fix(ui): restore Transcoding field styling and render helper text
The TranscodingInput wrapper swallowed the variant SimpleForm injects into
its direct children (field lost its outlined box) and put helperText on the
ReferenceInput, which does not forward it to the input. Spread the form props
onto ReferenceInput and move helperText to the SelectInput child so both the
outlined styling and the helper text render. Part of #5583.
* fix(i18n): update Brazilian Portuguese translation for album artist field
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): clean up comments in PlayerEdit component
Signed-off-by: Deluan <deluan@navidrome.org>
* test(ui): mock useTranslate in PlayerEdit test for determinism
Avoid depending on ra-core's out-of-provider translation behavior, which can
vary by version. Part of #5583.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 16:52:01 -04:00
|
|
|
It("uses the player cap when it is more restrictive than the client limit", func() {
|
2026-03-09 16:47:34 -04:00
|
|
|
setPlayerMaxBitRate(192) // 192 kbps player cap
|
|
|
|
|
|
fix(transcoding): enforce server-side player MaxBitRate on /rest/stream (#5611)
* fix(transcoding): enforce player MaxBitRate on getTranscodeDecision
The Web UI streams via getTranscodeDecision, which (since #5473) ignored
the server-side player config. Apply the player's MaxBitRate as a bitrate
ceiling on the client's declared limits before MakeDecision, restoring
per-player bitrate enforcement without reintroducing the forced-format
override. Fixes #5583.
* test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision
Invert the assertions added in #5473 that expected the player cap to be
ignored; getTranscodeDecision now enforces it (issue #5583).
* feat(ui): clarify web player ignores forced transcoding format
Add helper text to the Transcoding field on the player edit form when the
player is the NavidromeUI web client, since it enforces only the Max. Bit
Rate, not the forced format. Part of issue #5583.
* refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths
Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate
method in core/stream, used by both getTranscodeDecision and the legacy
ResolveRequest path. Removes handler-layer duplication and corrects a
misleading comment that wrongly implied the legacy single-field cap was buggy.
* fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set
A bare /stream or /download request from a player configured with a
server-side MaxBitRate (but no forced format) was served raw, ignoring the
cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the
player MaxBitRate alone is below the source bitrate, matching the
already-correct forced-format and request-bitrate paths. Part of #5583.
* fix(ui): add Brazilian Portuguese translation for player transcoding helper text
Translates the new resources.player.helperTexts.transcodingId key added for
the web player transcoding-format clarification. Part of #5583.
* fix(ui): restore Transcoding field styling and render helper text
The TranscodingInput wrapper swallowed the variant SimpleForm injects into
its direct children (field lost its outlined box) and put helperText on the
ReferenceInput, which does not forward it to the input. Spread the form props
onto ReferenceInput and move helperText to the SelectInput child so both the
outlined styling and the helper text render. Part of #5583.
* fix(i18n): update Brazilian Portuguese translation for album artist field
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): clean up comments in PlayerEdit component
Signed-off-by: Deluan <deluan@navidrome.org>
* test(ui): mock useTranslate in PlayerEdit test for determinism
Avoid depending on ra-core's out-of-provider translation behavior, which can
vary by version. Part of #5583.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 16:52:01 -04:00
|
|
|
// Client caps at 320kbps (bitrateCapClient); player is more
|
|
|
|
|
// restrictive at 192 → player cap wins.
|
2026-03-09 16:47:34 -04:00
|
|
|
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())
|
fix(transcoding): enforce server-side player MaxBitRate on /rest/stream (#5611)
* fix(transcoding): enforce player MaxBitRate on getTranscodeDecision
The Web UI streams via getTranscodeDecision, which (since #5473) ignored
the server-side player config. Apply the player's MaxBitRate as a bitrate
ceiling on the client's declared limits before MakeDecision, restoring
per-player bitrate enforcement without reintroducing the forced-format
override. Fixes #5583.
* test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision
Invert the assertions added in #5473 that expected the player cap to be
ignored; getTranscodeDecision now enforces it (issue #5583).
* feat(ui): clarify web player ignores forced transcoding format
Add helper text to the Transcoding field on the player edit form when the
player is the NavidromeUI web client, since it enforces only the Max. Bit
Rate, not the forced format. Part of issue #5583.
* refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths
Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate
method in core/stream, used by both getTranscodeDecision and the legacy
ResolveRequest path. Removes handler-layer duplication and corrects a
misleading comment that wrongly implied the legacy single-field cap was buggy.
* fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set
A bare /stream or /download request from a player configured with a
server-side MaxBitRate (but no forced format) was served raw, ignoring the
cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the
player MaxBitRate alone is below the source bitrate, matching the
already-correct forced-format and request-bitrate paths. Part of #5583.
* fix(ui): add Brazilian Portuguese translation for player transcoding helper text
Translates the new resources.player.helperTexts.transcodingId key added for
the web player transcoding-format clarification. Part of #5583.
* fix(ui): restore Transcoding field styling and render helper text
The TranscodingInput wrapper swallowed the variant SimpleForm injects into
its direct children (field lost its outlined box) and put helperText on the
ReferenceInput, which does not forward it to the input. Spread the form props
onto ReferenceInput and move helperText to the SelectInput child so both the
outlined styling and the helper text render. Part of #5583.
* fix(i18n): update Brazilian Portuguese translation for album artist field
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): clean up comments in PlayerEdit component
Signed-off-by: Deluan <deluan@navidrome.org>
* test(ui): mock useTranslate in PlayerEdit test for determinism
Avoid depending on ra-core's out-of-provider translation behavior, which can
vary by version. Part of #5583.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 16:52:01 -04:00
|
|
|
// Player cap (192kbps) applies → 192000 bps.
|
|
|
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
|
2026-03-09 16:47:34 -04:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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)))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
fix(transcoding): enforce server-side player MaxBitRate on /rest/stream (#5611)
* fix(transcoding): enforce player MaxBitRate on getTranscodeDecision
The Web UI streams via getTranscodeDecision, which (since #5473) ignored
the server-side player config. Apply the player's MaxBitRate as a bitrate
ceiling on the client's declared limits before MakeDecision, restoring
per-player bitrate enforcement without reintroducing the forced-format
override. Fixes #5583.
* test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision
Invert the assertions added in #5473 that expected the player cap to be
ignored; getTranscodeDecision now enforces it (issue #5583).
* feat(ui): clarify web player ignores forced transcoding format
Add helper text to the Transcoding field on the player edit form when the
player is the NavidromeUI web client, since it enforces only the Max. Bit
Rate, not the forced format. Part of issue #5583.
* refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths
Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate
method in core/stream, used by both getTranscodeDecision and the legacy
ResolveRequest path. Removes handler-layer duplication and corrects a
misleading comment that wrongly implied the legacy single-field cap was buggy.
* fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set
A bare /stream or /download request from a player configured with a
server-side MaxBitRate (but no forced format) was served raw, ignoring the
cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the
player MaxBitRate alone is below the source bitrate, matching the
already-correct forced-format and request-bitrate paths. Part of #5583.
* fix(ui): add Brazilian Portuguese translation for player transcoding helper text
Translates the new resources.player.helperTexts.transcodingId key added for
the web player transcoding-format clarification. Part of #5583.
* fix(ui): restore Transcoding field styling and render helper text
The TranscodingInput wrapper swallowed the variant SimpleForm injects into
its direct children (field lost its outlined box) and put helperText on the
ReferenceInput, which does not forward it to the input. Spread the form props
onto ReferenceInput and move helperText to the SelectInput child so both the
outlined styling and the helper text render. Part of #5583.
* fix(i18n): update Brazilian Portuguese translation for album artist field
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): clean up comments in PlayerEdit component
Signed-off-by: Deluan <deluan@navidrome.org>
* test(ui): mock useTranslate in PlayerEdit test for determinism
Avoid depending on ra-core's out-of-provider translation behavior, which can
vary by version. Part of #5583.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 16:52:01 -04:00
|
|
|
Describe("player MaxBitRate injected by getTranscodeDecision", func() {
|
|
|
|
|
It("injects the player cap as the transcode target when the client declares none", func() {
|
2026-03-09 16:47:34 -04:00
|
|
|
setPlayerMaxBitRate(320)
|
|
|
|
|
|
fix(transcoding): enforce server-side player MaxBitRate on /rest/stream (#5611)
* fix(transcoding): enforce player MaxBitRate on getTranscodeDecision
The Web UI streams via getTranscodeDecision, which (since #5473) ignored
the server-side player config. Apply the player's MaxBitRate as a bitrate
ceiling on the client's declared limits before MakeDecision, restoring
per-player bitrate enforcement without reintroducing the forced-format
override. Fixes #5583.
* test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision
Invert the assertions added in #5473 that expected the player cap to be
ignored; getTranscodeDecision now enforces it (issue #5583).
* feat(ui): clarify web player ignores forced transcoding format
Add helper text to the Transcoding field on the player edit form when the
player is the NavidromeUI web client, since it enforces only the Max. Bit
Rate, not the forced format. Part of issue #5583.
* refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths
Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate
method in core/stream, used by both getTranscodeDecision and the legacy
ResolveRequest path. Removes handler-layer duplication and corrects a
misleading comment that wrongly implied the legacy single-field cap was buggy.
* fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set
A bare /stream or /download request from a player configured with a
server-side MaxBitRate (but no forced format) was served raw, ignoring the
cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the
player MaxBitRate alone is below the source bitrate, matching the
already-correct forced-format and request-bitrate paths. Part of #5583.
* fix(ui): add Brazilian Portuguese translation for player transcoding helper text
Translates the new resources.player.helperTexts.transcodingId key added for
the web player transcoding-format clarification. Part of #5583.
* fix(ui): restore Transcoding field styling and render helper text
The TranscodingInput wrapper swallowed the variant SimpleForm injects into
its direct children (field lost its outlined box) and put helperText on the
ReferenceInput, which does not forward it to the input. Spread the form props
onto ReferenceInput and move helperText to the SelectInput child so both the
outlined styling and the helper text render. Part of #5583.
* fix(i18n): update Brazilian Portuguese translation for album artist field
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): clean up comments in PlayerEdit component
Signed-off-by: Deluan <deluan@navidrome.org>
* test(ui): mock useTranslate in PlayerEdit test for determinism
Avoid depending on ra-core's out-of-provider translation behavior, which can
vary by version. Part of #5583.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 16:52:01 -04:00
|
|
|
// opusTranscodeClient has no client bitrate limits. The player
|
|
|
|
|
// cap (320) is injected, so FLAC (900kbps) → opus is capped at 320.
|
2026-03-09 16:47:34 -04:00
|
|
|
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"))
|
fix(transcoding): enforce server-side player MaxBitRate on /rest/stream (#5611)
* fix(transcoding): enforce player MaxBitRate on getTranscodeDecision
The Web UI streams via getTranscodeDecision, which (since #5473) ignored
the server-side player config. Apply the player's MaxBitRate as a bitrate
ceiling on the client's declared limits before MakeDecision, restoring
per-player bitrate enforcement without reintroducing the forced-format
override. Fixes #5583.
* test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision
Invert the assertions added in #5473 that expected the player cap to be
ignored; getTranscodeDecision now enforces it (issue #5583).
* feat(ui): clarify web player ignores forced transcoding format
Add helper text to the Transcoding field on the player edit form when the
player is the NavidromeUI web client, since it enforces only the Max. Bit
Rate, not the forced format. Part of issue #5583.
* refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths
Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate
method in core/stream, used by both getTranscodeDecision and the legacy
ResolveRequest path. Removes handler-layer duplication and corrects a
misleading comment that wrongly implied the legacy single-field cap was buggy.
* fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set
A bare /stream or /download request from a player configured with a
server-side MaxBitRate (but no forced format) was served raw, ignoring the
cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the
player MaxBitRate alone is below the source bitrate, matching the
already-correct forced-format and request-bitrate paths. Part of #5583.
* fix(ui): add Brazilian Portuguese translation for player transcoding helper text
Translates the new resources.player.helperTexts.transcodingId key added for
the web player transcoding-format clarification. Part of #5583.
* fix(ui): restore Transcoding field styling and render helper text
The TranscodingInput wrapper swallowed the variant SimpleForm injects into
its direct children (field lost its outlined box) and put helperText on the
ReferenceInput, which does not forward it to the input. Spread the form props
onto ReferenceInput and move helperText to the SelectInput child so both the
outlined styling and the helper text render. Part of #5583.
* fix(i18n): update Brazilian Portuguese translation for album artist field
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): clean up comments in PlayerEdit component
Signed-off-by: Deluan <deluan@navidrome.org>
* test(ui): mock useTranslate in PlayerEdit test for determinism
Avoid depending on ra-core's out-of-provider translation behavior, which can
vary by version. Part of #5583.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 16:52:01 -04:00
|
|
|
// Bitrate is the player cap (320kbps), not the opus format default.
|
|
|
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
|
2026-03-09 16:47:34 -04:00
|
|
|
})
|
|
|
|
|
|
fix(transcoding): enforce server-side player MaxBitRate on /rest/stream (#5611)
* fix(transcoding): enforce player MaxBitRate on getTranscodeDecision
The Web UI streams via getTranscodeDecision, which (since #5473) ignored
the server-side player config. Apply the player's MaxBitRate as a bitrate
ceiling on the client's declared limits before MakeDecision, restoring
per-player bitrate enforcement without reintroducing the forced-format
override. Fixes #5583.
* test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision
Invert the assertions added in #5473 that expected the player cap to be
ignored; getTranscodeDecision now enforces it (issue #5583).
* feat(ui): clarify web player ignores forced transcoding format
Add helper text to the Transcoding field on the player edit form when the
player is the NavidromeUI web client, since it enforces only the Max. Bit
Rate, not the forced format. Part of issue #5583.
* refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths
Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate
method in core/stream, used by both getTranscodeDecision and the legacy
ResolveRequest path. Removes handler-layer duplication and corrects a
misleading comment that wrongly implied the legacy single-field cap was buggy.
* fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set
A bare /stream or /download request from a player configured with a
server-side MaxBitRate (but no forced format) was served raw, ignoring the
cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the
player MaxBitRate alone is below the source bitrate, matching the
already-correct forced-format and request-bitrate paths. Part of #5583.
* fix(ui): add Brazilian Portuguese translation for player transcoding helper text
Translates the new resources.player.helperTexts.transcodingId key added for
the web player transcoding-format clarification. Part of #5583.
* fix(ui): restore Transcoding field styling and render helper text
The TranscodingInput wrapper swallowed the variant SimpleForm injects into
its direct children (field lost its outlined box) and put helperText on the
ReferenceInput, which does not forward it to the input. Spread the form props
onto ReferenceInput and move helperText to the SelectInput child so both the
outlined styling and the helper text render. Part of #5583.
* fix(i18n): update Brazilian Portuguese translation for album artist field
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): clean up comments in PlayerEdit component
Signed-off-by: Deluan <deluan@navidrome.org>
* test(ui): mock useTranslate in PlayerEdit test for determinism
Avoid depending on ra-core's out-of-provider translation behavior, which can
vary by version. Part of #5583.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 16:52:01 -04:00
|
|
|
It("keeps the lower client maxTranscodingAudioBitrate over a higher player cap", func() {
|
2026-03-09 16:47:34 -04:00
|
|
|
setPlayerMaxBitRate(320)
|
|
|
|
|
|
fix(transcoding): enforce server-side player MaxBitRate on /rest/stream (#5611)
* fix(transcoding): enforce player MaxBitRate on getTranscodeDecision
The Web UI streams via getTranscodeDecision, which (since #5473) ignored
the server-side player config. Apply the player's MaxBitRate as a bitrate
ceiling on the client's declared limits before MakeDecision, restoring
per-player bitrate enforcement without reintroducing the forced-format
override. Fixes #5583.
* test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision
Invert the assertions added in #5473 that expected the player cap to be
ignored; getTranscodeDecision now enforces it (issue #5583).
* feat(ui): clarify web player ignores forced transcoding format
Add helper text to the Transcoding field on the player edit form when the
player is the NavidromeUI web client, since it enforces only the Max. Bit
Rate, not the forced format. Part of issue #5583.
* refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths
Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate
method in core/stream, used by both getTranscodeDecision and the legacy
ResolveRequest path. Removes handler-layer duplication and corrects a
misleading comment that wrongly implied the legacy single-field cap was buggy.
* fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set
A bare /stream or /download request from a player configured with a
server-side MaxBitRate (but no forced format) was served raw, ignoring the
cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the
player MaxBitRate alone is below the source bitrate, matching the
already-correct forced-format and request-bitrate paths. Part of #5583.
* fix(ui): add Brazilian Portuguese translation for player transcoding helper text
Translates the new resources.player.helperTexts.transcodingId key added for
the web player transcoding-format clarification. Part of #5583.
* fix(ui): restore Transcoding field styling and render helper text
The TranscodingInput wrapper swallowed the variant SimpleForm injects into
its direct children (field lost its outlined box) and put helperText on the
ReferenceInput, which does not forward it to the input. Spread the form props
onto ReferenceInput and move helperText to the SelectInput child so both the
outlined styling and the helper text render. Part of #5583.
* fix(i18n): update Brazilian Portuguese translation for album artist field
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): clean up comments in PlayerEdit component
Signed-off-by: Deluan <deluan@navidrome.org>
* test(ui): mock useTranslate in PlayerEdit test for determinism
Avoid depending on ra-core's out-of-provider translation behavior, which can
vary by version. Part of #5583.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 16:52:01 -04:00
|
|
|
// maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps).
|
|
|
|
|
// Player cap (320) is higher → the lower client limit wins.
|
2026-03-09 16:47:34 -04:00
|
|
|
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())
|
fix(transcoding): enforce server-side player MaxBitRate on /rest/stream (#5611)
* fix(transcoding): enforce player MaxBitRate on getTranscodeDecision
The Web UI streams via getTranscodeDecision, which (since #5473) ignored
the server-side player config. Apply the player's MaxBitRate as a bitrate
ceiling on the client's declared limits before MakeDecision, restoring
per-player bitrate enforcement without reintroducing the forced-format
override. Fixes #5583.
* test(e2e): assert player MaxBitRate is enforced on getTranscodeDecision
Invert the assertions added in #5473 that expected the player cap to be
ignored; getTranscodeDecision now enforces it (issue #5583).
* feat(ui): clarify web player ignores forced transcoding format
Add helper text to the Transcoding field on the player edit form when the
player is the NavidromeUI web client, since it enforces only the Max. Bit
Rate, not the forced format. Part of issue #5583.
* refactor(stream): extract ClientInfo.CapBitrate, share across transcode paths
Move the player MaxBitRate ceiling logic into a canonical ClientInfo.CapBitrate
method in core/stream, used by both getTranscodeDecision and the legacy
ResolveRequest path. Removes handler-layer duplication and corrects a
misleading comment that wrongly implied the legacy single-field cap was buggy.
* fix(transcoding): downsample on legacy /stream when only player MaxBitRate is set
A bare /stream or /download request from a player configured with a
server-side MaxBitRate (but no forced format) was served raw, ignoring the
cap. buildLegacyClientInfo now triggers DefaultDownsamplingFormat when the
player MaxBitRate alone is below the source bitrate, matching the
already-correct forced-format and request-bitrate paths. Part of #5583.
* fix(ui): add Brazilian Portuguese translation for player transcoding helper text
Translates the new resources.player.helperTexts.transcodingId key added for
the web player transcoding-format clarification. Part of #5583.
* fix(ui): restore Transcoding field styling and render helper text
The TranscodingInput wrapper swallowed the variant SimpleForm injects into
its direct children (field lost its outlined box) and put helperText on the
ReferenceInput, which does not forward it to the input. Spread the form props
onto ReferenceInput and move helperText to the SelectInput child so both the
outlined styling and the helper text render. Part of #5583.
* fix(i18n): update Brazilian Portuguese translation for album artist field
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): clean up comments in PlayerEdit component
Signed-off-by: Deluan <deluan@navidrome.org>
* test(ui): mock useTranslate in PlayerEdit test for determinism
Avoid depending on ra-core's out-of-provider translation behavior, which can
vary by version. Part of #5583.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-14 16:52:01 -04:00
|
|
|
// Client limit (192kbps) wins → 192000 bps.
|
2026-03-09 16:47:34 -04:00
|
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-06-14 20:52:19 -04:00
|
|
|
|
|
|
|
|
Describe("player forced format", func() {
|
|
|
|
|
It("transcodes a FLAC to the forced opus format when the client supports it", func() {
|
|
|
|
|
setPlayerForcedFormat("opus")
|
|
|
|
|
|
|
|
|
|
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"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("falls back to negotiation when the client does not support the forced format", func() {
|
|
|
|
|
setPlayerForcedFormat("opus")
|
|
|
|
|
|
|
|
|
|
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"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("applies maxBitRate on top of the forced format", func() {
|
|
|
|
|
setPlayerForcedFormat("opus")
|
|
|
|
|
setPlayerMaxBitRate(96)
|
|
|
|
|
|
|
|
|
|
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"))
|
|
|
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(96000)))
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-03-09 09:43:55 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-25 18:06:10 -04:00
|
|
|
mf, err := ds.MediaFile().Get(ctx, mp3TrackID)
|
2026-03-09 09:43:55 -04:00
|
|
|
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)
|
2026-09-25 18:06:10 -04:00
|
|
|
Expect(ds.MediaFile().Put(ctx, mf)).To(Succeed())
|
2026-03-09 09:43:55 -04:00
|
|
|
|
|
|
|
|
// 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
|
2026-09-25 18:06:10 -04:00
|
|
|
Expect(ds.MediaFile().Put(ctx, mf)).To(Succeed())
|
2026-03-09 09:43:55 -04:00
|
|
|
})
|
fix(server): improve transcoding failure diagnostics and error responses (#5227)
* fix(server): capture ffmpeg stderr and warn on empty transcoded output
When ffmpeg fails during transcoding (e.g., missing codec like libopus),
the error was silently discarded because stderr was sent to io.Discard
and the HTTP response returned 200 OK with a 0-byte body.
- Capture ffmpeg stderr in a bounded buffer (4KB) and include it in the
error message when the process exits with a non-zero status code
- Log a warning when transcoded output is 0 bytes, guiding users to
check codec support and enable Trace logging for details
- Remove log level guard so transcoding errors are always logged, not
just at Debug level
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(server): return proper error responses for empty transcoded output
Instead of returning HTTP 200 with 0-byte body when transcoding fails,
return a Subsonic error response (for stream/download/getTranscodeStream)
or HTTP 500 (for public shared streams). This gives clients a clear
signal that the request failed rather than a misleading empty success.
Signed-off-by: Deluan <deluan@navidrome.org>
* test(e2e): add tests for empty transcoded stream error responses
Add E2E tests verifying that stream and download endpoints return
Subsonic error responses when transcoding produces empty output.
Extend spyStreamer with SimulateEmptyStream and SimulateError fields
to support failure injection in tests.
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(server): extract stream serving logic into Stream.Serve method
Extract the duplicated non-seekable stream serving logic (header setup,
estimateContentLength, HEAD draining, io.Copy with error/empty detection)
from server/subsonic/stream.go and server/public/handle_streams.go into a
single Stream.Serve method on core/stream. Both callers now delegate to it,
eliminating ~30 lines of near-identical code.
* fix(server): return 200 with empty body for stream/download on empty transcoded output
Don't return a Subsonic error response when transcoding produces empty
output on stream/download endpoints — just log the error and return 200
with an empty body. The getTranscodeStream and public share endpoints
still return HTTP 500 for empty output. Stream.Serve now returns
(int64, error) so callers can check the byte count.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-18 12:39:03 -04:00
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
})
|
2026-03-09 09:43:55 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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
|
2026-03-10 17:19:25 -04:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", ""))
|
2026-03-09 09:43:55 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
2026-03-10 17:19:25 -04:00
|
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
|
2026-03-09 09:43:55 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
2026-03-10 17:19:25 -04:00
|
|
|
Expect(streamerSpy.LastRequest.Offset).To(Equal(30))
|
2026-03-09 09:43:55 -04:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|