navidrome/core/stream/legacy_client_test.go
Deluan Quintão c4c70519b5
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

417 lines
16 KiB
Go

package stream
import (
"context"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("buildLegacyClientInfo", func() {
var mf *model.MediaFile
BeforeEach(func() {
mf = &model.MediaFile{Suffix: "flac", BitRate: 960}
})
It("sets transcoding profile for explicit format without bitrate", func() {
ci := buildLegacyClientInfo(mf, "mp3", 0, 0)
Expect(ci.Name).To(Equal("legacy"))
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP))
Expect(ci.MaxAudioBitrate).To(BeZero())
Expect(ci.MaxTranscodingAudioBitrate).To(BeZero())
Expect(ci.DirectPlayProfiles).To(BeEmpty())
})
It("does not add direct play profile when explicit format differs from source (no bitrate)", func() {
ci := buildLegacyClientInfo(mf, "opus", 0, 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
Expect(ci.DirectPlayProfiles).To(BeEmpty())
})
It("adds direct play profile when explicit format matches source format", func() {
ci := buildLegacyClientInfo(mf, "flac", 0, 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac"))
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
})
It("sets transcoding profile and bitrate for explicit format with bitrate", func() {
ci := buildLegacyClientInfo(mf, "mp3", 192, 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
Expect(ci.MaxAudioBitrate).To(Equal(192))
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192))
Expect(ci.DirectPlayProfiles).To(BeEmpty())
})
It("returns direct play profile when no format and no bitrate", func() {
ci := buildLegacyClientInfo(mf, "", 0, 0)
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
Expect(ci.TranscodingProfiles).To(BeEmpty())
Expect(ci.MaxAudioBitrate).To(BeZero())
})
It("uses default downsampling format for bitrate-only downsampling", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
ci := buildLegacyClientInfo(mf, "", 128, 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP))
Expect(ci.MaxAudioBitrate).To(Equal(128))
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128))
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
})
It("returns direct play when bitrate >= source bitrate", func() {
ci := buildLegacyClientInfo(mf, "", 960, 0)
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
Expect(ci.TranscodingProfiles).To(BeEmpty())
Expect(ci.MaxAudioBitrate).To(BeZero())
})
It("uses default downsampling format when player MaxBitRate is below source and no format/bitrate", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
ci := buildLegacyClientInfo(mf, "", 0, 256)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
})
It("does not downsample when player MaxBitRate is >= source bitrate", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
ci := buildLegacyClientInfo(mf, "", 0, 960)
Expect(ci.TranscodingProfiles).To(BeEmpty())
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
})
It("does not downsample when DefaultDownsamplingFormat is empty even with player cap", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = ""
ci := buildLegacyClientInfo(mf, "", 0, 256)
Expect(ci.TranscodingProfiles).To(BeEmpty())
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
})
It("prefers explicit request format over player cap", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
ci := buildLegacyClientInfo(mf, "mp3", 0, 256)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
})
})
var _ = Describe("ResolveRequest", func() {
var (
svc TranscodeDecider
ctx context.Context
)
BeforeEach(func() {
ctx = GinkgoT().Context()
ds := &tests.MockDataStore{
MockedProperty: &tests.MockedPropertyRepo{},
MockedTranscoding: &tests.MockTranscodingRepo{},
}
ff := tests.NewMockFFmpeg("")
auth.Init(ds)
svc = NewTranscodeDecider(ds, ff)
})
It("returns raw when format is 'raw'", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "raw", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
It("returns raw (direct play) when no format or bitrate specified", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
It("transcodes to requested format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "opus", 0, 0)
Expect(req.Format).To(Equal("opus"))
})
It("transcodes to requested format with bitrate limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(128))
})
It("returns raw when requested format matches source and no bitrate reduction", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "mp3", 320, 0)
Expect(req.Format).To(Equal("raw"))
})
It("downsamples when only bitrate is specified below source", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "", 128, 0)
Expect(req.Format).To(Equal("opus"))
Expect(req.BitRate).To(Equal(128))
})
It("passes offset through", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "opus", 128, 30)
Expect(req.Format).To(Equal("opus"))
Expect(req.Offset).To(Equal(30))
})
Context("Server-side player transcoding override", func() {
It("forces transcoding when override targets a different format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(192))
})
It("allows direct play when source matches forced format and bitrate is within cap", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
It("transcodes when source bitrate exceeds the forced cap", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(192))
})
It("uses player MaxBitRate over transcoding DefaultBitRate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(320))
})
It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0})
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
decider := svc.(*deciderService)
req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("mp3"))
// With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock)
Expect(req.BitRate).To(Equal(160))
})
It("does not apply override when no transcoding is in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
})
Context("Player MaxBitRate cap", func() {
It("applies player MaxBitRate cap when client has no limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "mp3", 0, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(320))
})
It("uses client limit when it is more restrictive than player MaxBitRate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "mp3", 256, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(256))
})
It("does not cap when player MaxBitRate is 0", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
It("downsamples using DefaultDownsamplingFormat when only player MaxBitRate is set (no format/bitrate)", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("opus"))
Expect(req.BitRate).To(Equal(256))
})
It("serves raw when only player MaxBitRate is set but no DefaultDownsamplingFormat", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = ""
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
})
Context("fallback for unknown format", func() {
It("falls back to DefaultDownsamplingFormat", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0)
Expect(req.Format).To(Equal("opus"))
})
It("falls back to raw when DefaultDownsamplingFormat is empty", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = ""
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
It("falls back to raw when DefaultDownsamplingFormat is also invalid", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "xyz"
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
It("preserves bitrate when falling back to DefaultDownsamplingFormat", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0)
Expect(req.Format).To(Equal("opus"))
Expect(req.BitRate).To(Equal(128))
})
})
})