fix: player MaxBitRate cap, format-aware defaults, browser profile filtering (#5165)
* feat(transcode): apply player MaxBitRate cap and use format-aware default bitrates Add player MaxBitRate cap to the transcode decider so server-side player bitrate limits are respected when making OpenSubsonic transcode decisions. The player cap is applied only when it is more restrictive than the client's maxAudioBitrate (or when the client has no limit). Also replace the hardcoded 256 kbps default with a format-aware lookup that checks the DB first (for user-customized values), then built-in defaults, and finally falls back to 256 kbps. For lossless→lossy transcoding, prefer maxTranscodingAudioBitrate over maxAudioBitrate when available. * test(e2e): add tests for player MaxBitRate cap and format-aware default bitrates Add e2e tests covering: - Player MaxBitRate forcing transcode when source exceeds cap - Player MaxBitRate having no effect when source is under cap - Client limit winning when more restrictive than player MaxBitRate - Player MaxBitRate winning when more restrictive than client limit - Player MaxBitRate=0 having no effect - Format-aware defaults: mp3 (192kbps), opus (128kbps) instead of hardcoded 256 - maxAudioBitrate fallback for lossless→lossy when no maxTranscodingAudioBitrate - maxTranscodingAudioBitrate taking priority over maxAudioBitrate - Combined player + client limits flowing correctly through decision→stream * feat(transcode): update transcoding profiles to add flac, filter by supported codecs, and ensure mp3 fallback Signed-off-by: Deluan <deluan@navidrome.org> * fix(db): ensure all default transcodings exist on upgrade Older installations that were seeded before aac/flac were added to DefaultTranscodings may be missing these entries. The previous migration only added flac; this one ensures all default transcodings are present without touching user-customized entries. * test: remove duplication Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -14,7 +14,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultBitrate = 256 // kbps
|
const fallbackBitrate = 256 // kbps
|
||||||
|
|
||||||
// Decider is the core service interface for making transcoding decisions
|
// Decider is the core service interface for making transcoding decisions
|
||||||
type Decider interface {
|
type Decider interface {
|
||||||
@@ -58,6 +58,13 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile,
|
|||||||
// Check for server-side player transcoding override
|
// Check for server-side player transcoding override
|
||||||
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
|
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
|
||||||
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
|
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
|
||||||
|
} else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
|
||||||
|
if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
|
||||||
|
modified := *clientInfo
|
||||||
|
modified.MaxAudioBitrate = player.MaxBitRate
|
||||||
|
clientInfo = &modified
|
||||||
|
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container,
|
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container,
|
||||||
@@ -291,6 +298,21 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Strea
|
|||||||
return ts, targetFormat
|
return ts, targetFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lookupDefaultBitrate returns the default bitrate for the given format.
|
||||||
|
// It checks the DB first (for user-customized values), then falls back to
|
||||||
|
// the built-in defaults, and finally to fallbackBitrate.
|
||||||
|
func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string) int {
|
||||||
|
if t, err := ds.Transcoding(ctx).FindByFormat(format); err == nil && t.DefaultBitRate > 0 {
|
||||||
|
return t.DefaultBitRate
|
||||||
|
}
|
||||||
|
for _, dt := range consts.DefaultTranscodings {
|
||||||
|
if dt.TargetFormat == format && dt.DefaultBitRate > 0 {
|
||||||
|
return dt.DefaultBitRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackBitrate
|
||||||
|
}
|
||||||
|
|
||||||
// LookupTranscodeCommand returns the ffmpeg command for the given format.
|
// LookupTranscodeCommand returns the ffmpeg command for the given format.
|
||||||
// It checks the DB first (for user-customized commands), then falls back to
|
// It checks the DB first (for user-customized commands), then falls back to
|
||||||
// the built-in default command. Returns "" if the format is unknown.
|
// the built-in default command. Returns "" if the format is unknown.
|
||||||
@@ -341,8 +363,10 @@ func (s *deciderService) computeBitrate(ctx context.Context, src *StreamDetails,
|
|||||||
if !targetIsLossless {
|
if !targetIsLossless {
|
||||||
if clientInfo.MaxTranscodingAudioBitrate > 0 {
|
if clientInfo.MaxTranscodingAudioBitrate > 0 {
|
||||||
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
|
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
|
||||||
|
} else if clientInfo.MaxAudioBitrate > 0 {
|
||||||
|
ts.Bitrate = clientInfo.MaxAudioBitrate
|
||||||
} else {
|
} else {
|
||||||
ts.Bitrate = defaultBitrate
|
ts.Bitrate = lookupDefaultBitrate(ctx, s.ds, targetFormat)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
|
if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ var _ = Describe("Decider", func() {
|
|||||||
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(decision.CanTranscode).To(BeTrue())
|
Expect(decision.CanTranscode).To(BeTrue())
|
||||||
Expect(decision.TargetBitrate).To(Equal(defaultBitrate)) // 256 kbps
|
Expect(decision.TargetBitrate).To(Equal(160)) // mp3 default from mock transcoding repo
|
||||||
})
|
})
|
||||||
|
|
||||||
It("preserves lossy bitrate when under max", func() {
|
It("preserves lossy bitrate when under max", func() {
|
||||||
@@ -993,8 +993,8 @@ var _ = Describe("Decider", func() {
|
|||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(decision.CanTranscode).To(BeTrue())
|
Expect(decision.CanTranscode).To(BeTrue())
|
||||||
Expect(decision.TargetFormat).To(Equal("mp3"))
|
Expect(decision.TargetFormat).To(Equal("mp3"))
|
||||||
// With no cap, lossless→lossy uses defaultBitrate (256)
|
// With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock)
|
||||||
Expect(decision.TargetBitrate).To(Equal(defaultBitrate))
|
Expect(decision.TargetBitrate).To(Equal(160))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("does not apply override when no transcoding is in context", func() {
|
It("does not apply override when no transcoding is in context", func() {
|
||||||
@@ -1012,6 +1012,97 @@ var _ = Describe("Decider", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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: 16})
|
||||||
|
ci := &ClientInfo{
|
||||||
|
Name: "TestClient",
|
||||||
|
DirectPlayProfiles: []DirectPlayProfile{
|
||||||
|
{Containers: []string{"flac", "mp3"}, AudioCodecs: []string{"flac", "mp3"}, Protocols: []string{ProtocolHTTP}},
|
||||||
|
},
|
||||||
|
TranscodingProfiles: []Profile{
|
||||||
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
|
||||||
|
|
||||||
|
decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
// Source bitrate 1000 > player cap 320, so direct play is not possible
|
||||||
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||||
|
Expect(decision.CanTranscode).To(BeTrue())
|
||||||
|
// Lossless→lossy should use MaxAudioBitrate (320) as target, not format default
|
||||||
|
Expect(decision.TargetBitrate).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: 16})
|
||||||
|
ci := &ClientInfo{
|
||||||
|
Name: "TestClient",
|
||||||
|
MaxAudioBitrate: 256,
|
||||||
|
MaxTranscodingAudioBitrate: 256,
|
||||||
|
TranscodingProfiles: []Profile{
|
||||||
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
|
||||||
|
|
||||||
|
decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(decision.CanTranscode).To(BeTrue())
|
||||||
|
// Client limit 256 < player cap 500, so player cap doesn't apply; client limit wins
|
||||||
|
Expect(decision.TargetBitrate).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})
|
||||||
|
ci := &ClientInfo{
|
||||||
|
Name: "TestClient",
|
||||||
|
DirectPlayProfiles: []DirectPlayProfile{
|
||||||
|
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0})
|
||||||
|
|
||||||
|
decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("Format-aware default bitrate", func() {
|
||||||
|
It("uses opus default bitrate from DB", func() {
|
||||||
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16})
|
||||||
|
ci := &ClientInfo{
|
||||||
|
TranscodingProfiles: []Profile{
|
||||||
|
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(decision.CanTranscode).To(BeTrue())
|
||||||
|
Expect(decision.TargetBitrate).To(Equal(96)) // opus default from mock
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses aac default bitrate from DB", func() {
|
||||||
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||||
|
ci := &ClientInfo{
|
||||||
|
TranscodingProfiles: []Profile{
|
||||||
|
{Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(decision.CanTranscode).To(BeTrue())
|
||||||
|
Expect(decision.TargetBitrate).To(Equal(256)) // aac default from mock
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to 256 for unknown format", func() {
|
||||||
|
bitrate := lookupDefaultBitrate(ctx, ds, "xyz")
|
||||||
|
Expect(bitrate).To(Equal(fallbackBitrate))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("ensureProbed", func() {
|
Describe("ensureProbed", func() {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/model/id"
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
goose.AddMigrationContext(upEnsureDefaultTranscodings, downEnsureDefaultTranscodings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||||
|
// Older installations may be missing default transcodings that were added
|
||||||
|
// after the initial seeding (e.g., aac was added later than mp3/opus).
|
||||||
|
// Insert any missing defaults without touching user-customized entries.
|
||||||
|
for _, t := range consts.DefaultTranscodings {
|
||||||
|
var count int
|
||||||
|
err := tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = ?", t.TargetFormat).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
"INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -146,6 +146,25 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
Describe("getTranscodeDecision", func() {
|
Describe("getTranscodeDecision", func() {
|
||||||
|
// setPlayerMaxBitRate ensures a player exists for the test-client and sets its MaxBitRate.
|
||||||
|
// It makes a dummy request to register the player, then updates it via the repository.
|
||||||
|
setPlayerMaxBitRate := func(maxBitRate int) {
|
||||||
|
doReq("ping")
|
||||||
|
player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
player.MaxBitRate = maxBitRate
|
||||||
|
Expect(ds.Player(ctx).Put(player)).To(Succeed())
|
||||||
|
}
|
||||||
|
|
||||||
|
AfterEach(func() {
|
||||||
|
// Reset player MaxBitRate to 0 after each test
|
||||||
|
player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "")
|
||||||
|
if err == nil {
|
||||||
|
player.MaxBitRate = 0
|
||||||
|
_ = ds.Player(ctx).Put(player)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
Describe("error cases", func() {
|
Describe("error cases", func() {
|
||||||
It("returns 405 for GET request", func() {
|
It("returns 405 for GET request", func() {
|
||||||
w := doRawReq("getTranscodeDecision", "mediaId", mp3TrackID, "mediaType", "song")
|
w := doRawReq("getTranscodeDecision", "mediaId", mp3TrackID, "mediaType", "song")
|
||||||
@@ -360,6 +379,175 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
|
|||||||
Expect(src.AudioBitrate).To(Equal(int32(320000)))
|
Expect(src.AudioBitrate).To(Equal(int32(320000)))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("player MaxBitRate cap", func() {
|
||||||
|
It("forces transcode when source bitrate exceeds player MaxBitRate", func() {
|
||||||
|
setPlayerMaxBitRate(320) // 320 kbps cap
|
||||||
|
|
||||||
|
// FLAC is 900kbps, client has no bitrate limit but player cap is 320
|
||||||
|
resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
|
||||||
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
|
||||||
|
// Target bitrate should be capped at player's 320kbps = 320000 bps
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not affect direct play when source bitrate is under player MaxBitRate", func() {
|
||||||
|
setPlayerMaxBitRate(500) // 500 kbps cap
|
||||||
|
|
||||||
|
// MP3 is 320kbps, under the 500kbps player cap → direct play
|
||||||
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses client limit when more restrictive than player MaxBitRate", func() {
|
||||||
|
setPlayerMaxBitRate(500) // 500 kbps player cap
|
||||||
|
|
||||||
|
// Client caps at 320kbps (bitrateCapClient), which is more restrictive than 500
|
||||||
|
// FLAC is 900kbps → exceeds both limits → transcode
|
||||||
|
resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
||||||
|
// Client limit (320kbps) is more restrictive → 320000 bps
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses player MaxBitRate when more restrictive than client limit", func() {
|
||||||
|
setPlayerMaxBitRate(192) // 192 kbps player cap
|
||||||
|
|
||||||
|
// Client caps at 320kbps (bitrateCapClient), player is more restrictive at 192
|
||||||
|
// FLAC is 900kbps → transcode at 192kbps
|
||||||
|
resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
||||||
|
// Player limit (192kbps) is more restrictive → 192000 bps
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("has no effect when player MaxBitRate is 0", func() {
|
||||||
|
setPlayerMaxBitRate(0) // No player cap
|
||||||
|
|
||||||
|
// FLAC with flac+mp3 client → direct play (no bitrate constraint)
|
||||||
|
resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("format-aware default bitrate", func() {
|
||||||
|
It("uses mp3 format default (192kbps) for lossless-to-mp3 with no bitrate limits", func() {
|
||||||
|
// mp3OnlyClient has no maxAudioBitrate or maxTranscodingAudioBitrate
|
||||||
|
// FLAC → MP3 should use the mp3 default bitrate (192kbps), not hardcoded 256
|
||||||
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
|
||||||
|
// mp3 default is 192kbps = 192000 bps
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses opus format default (128kbps) for lossless-to-opus with no bitrate limits", func() {
|
||||||
|
// opusTranscodeClient has no maxAudioBitrate or maxTranscodingAudioBitrate
|
||||||
|
// FLAC → Opus should use the opus default bitrate (128kbps)
|
||||||
|
resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus"))
|
||||||
|
// opus default is 128kbps = 128000 bps
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses maxAudioBitrate as fallback for lossless-to-lossy when no maxTranscodingAudioBitrate", func() {
|
||||||
|
// bitrateCapClient has maxAudioBitrate=320000 but no maxTranscodingAudioBitrate
|
||||||
|
// FLAC → MP3: maxAudioBitrate (320kbps) should be used as the target
|
||||||
|
resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
||||||
|
// maxAudioBitrate is 320kbps = 320000 bps
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("prefers maxTranscodingAudioBitrate over maxAudioBitrate for lossless-to-lossy", func() {
|
||||||
|
// maxTranscodeBitrateClient has maxTranscodingAudioBitrate=192000
|
||||||
|
// FLAC → MP3: should use 192kbps, not format default or maxAudioBitrate
|
||||||
|
resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
||||||
|
// maxTranscodingAudioBitrate is 192kbps = 192000 bps
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("player MaxBitRate + client limits combined", func() {
|
||||||
|
It("player MaxBitRate injects maxAudioBitrate, format default used for transcode target", func() {
|
||||||
|
setPlayerMaxBitRate(320)
|
||||||
|
|
||||||
|
// opusTranscodeClient has no client bitrate limits
|
||||||
|
// Player cap injects maxAudioBitrate=320
|
||||||
|
// FLAC (900kbps) → exceeds 320 → transcode to opus
|
||||||
|
// Lossless→lossy: maxTranscodingAudioBitrate=0, so falls back to maxAudioBitrate=320
|
||||||
|
resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus"))
|
||||||
|
// maxAudioBitrate=320 used as fallback → 320000 bps
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("player MaxBitRate + client maxTranscodingAudioBitrate work together", func() {
|
||||||
|
setPlayerMaxBitRate(320)
|
||||||
|
|
||||||
|
// maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps), no maxAudioBitrate
|
||||||
|
// Player cap injects maxAudioBitrate=320
|
||||||
|
// FLAC (900kbps) → exceeds 320 → transcode to mp3
|
||||||
|
// Lossless→lossy: maxTranscodingAudioBitrate=192 takes priority
|
||||||
|
resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
||||||
|
// maxTranscodingAudioBitrate=192 is preferred → 192000 bps
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("streams with correct bitrate after player MaxBitRate-triggered transcode", func() {
|
||||||
|
setPlayerMaxBitRate(128)
|
||||||
|
|
||||||
|
// Get decision: FLAC (900kbps) with player cap 128 → transcode
|
||||||
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||||
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||||
|
token := resp.TranscodeDecision.TranscodeParams
|
||||||
|
Expect(token).ToNot(BeEmpty())
|
||||||
|
|
||||||
|
// Stream using the token
|
||||||
|
w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(spy.LastRequest.Format).To(Equal("mp3"))
|
||||||
|
Expect(spy.LastRequest.BitRate).To(Equal(128))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("getTranscodeStream", func() {
|
Describe("getTranscodeStream", func() {
|
||||||
|
|||||||
@@ -9,32 +9,44 @@ export const CODEC_PROBES = [
|
|||||||
{ codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' },
|
{ codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Default transcoding targets — ordered by preference.
|
// Transcoding targets in preference order (lossless first, then lossy).
|
||||||
// These are attempted if direct play is not possible.
|
// Derived from CODEC_PROBES to avoid duplicating MIME strings.
|
||||||
const DEFAULT_TRANSCODING_PROFILES = [
|
// MP3 is always included as a universal fallback.
|
||||||
{ container: 'ogg', audioCodec: 'opus', protocol: 'http' },
|
const TRANSCODE_CODECS = ['flac', 'opus', 'mp3']
|
||||||
{ container: 'mp3', audioCodec: 'mp3', protocol: 'http' },
|
|
||||||
]
|
function probeSupported(audio, probes) {
|
||||||
|
return probes.filter(({ mime }) => audio.canPlayType(mime) === 'probably')
|
||||||
|
}
|
||||||
|
|
||||||
export function detectBrowserProfile() {
|
export function detectBrowserProfile() {
|
||||||
const audio = new Audio()
|
const audio = new Audio()
|
||||||
const directPlayProfiles = []
|
|
||||||
|
|
||||||
for (const { codec, container, mime } of CODEC_PROBES) {
|
const directPlayProfiles = probeSupported(audio, CODEC_PROBES).map(
|
||||||
if (audio.canPlayType(mime) === 'probably') {
|
({ codec, container }) => ({
|
||||||
directPlayProfiles.push({
|
|
||||||
containers: [container],
|
containers: [container],
|
||||||
audioCodecs: [codec],
|
audioCodecs: [codec],
|
||||||
protocols: ['http'],
|
protocols: ['http'],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build transcoding profiles from supported codecs, always keeping mp3 as fallback
|
||||||
|
const transcodingProfiles = TRANSCODE_CODECS.reduce((profiles, codec) => {
|
||||||
|
const probe = CODEC_PROBES.find((p) => p.codec === codec)
|
||||||
|
if (audio.canPlayType(probe.mime) === 'probably' || codec === 'mp3') {
|
||||||
|
profiles.push({
|
||||||
|
container: probe.container,
|
||||||
|
audioCodec: codec,
|
||||||
|
protocol: 'http',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
return profiles
|
||||||
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'NavidromeUI',
|
name: 'NavidromeUI',
|
||||||
platform: navigator.userAgent,
|
platform: navigator.userAgent,
|
||||||
directPlayProfiles,
|
directPlayProfiles,
|
||||||
transcodingProfiles: DEFAULT_TRANSCODING_PROFILES,
|
transcodingProfiles,
|
||||||
codecProfiles: [],
|
codecProfiles: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,14 +54,49 @@ describe('detectBrowserProfile', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('includes transcoding profiles for common formats', () => {
|
it('filters transcoding profiles by canPlayType', () => {
|
||||||
|
mockCanPlayType.mockImplementation((mime) => {
|
||||||
|
if (mime === 'audio/mpeg') return 'probably'
|
||||||
|
if (mime === 'audio/ogg; codecs="opus"') return 'probably'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const profile = detectBrowserProfile()
|
||||||
|
const codecs = profile.transcodingProfiles.map((p) => p.audioCodec)
|
||||||
|
expect(codecs).toEqual(['opus', 'mp3'])
|
||||||
|
expect(codecs).not.toContain('flac')
|
||||||
|
profile.transcodingProfiles.forEach((p) => {
|
||||||
|
expect(p.protocol).toBe('http')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('always includes mp3 fallback in transcoding profiles', () => {
|
||||||
mockCanPlayType.mockReturnValue('')
|
mockCanPlayType.mockReturnValue('')
|
||||||
|
|
||||||
const profile = detectBrowserProfile()
|
const profile = detectBrowserProfile()
|
||||||
expect(profile.transcodingProfiles.length).toBeGreaterThan(0)
|
expect(profile.transcodingProfiles.length).toBe(1)
|
||||||
|
expect(profile.transcodingProfiles[0].audioCodec).toBe('mp3')
|
||||||
expect(profile.transcodingProfiles[0].protocol).toBe('http')
|
expect(profile.transcodingProfiles[0].protocol).toBe('http')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not duplicate mp3 when canPlayType supports it', () => {
|
||||||
|
mockCanPlayType.mockReturnValue('probably')
|
||||||
|
|
||||||
|
const profile = detectBrowserProfile()
|
||||||
|
const mp3Count = profile.transcodingProfiles.filter(
|
||||||
|
(p) => p.audioCodec === 'mp3',
|
||||||
|
).length
|
||||||
|
expect(mp3Count).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves transcoding profile preference order', () => {
|
||||||
|
mockCanPlayType.mockReturnValue('probably')
|
||||||
|
|
||||||
|
const profile = detectBrowserProfile()
|
||||||
|
const codecs = profile.transcodingProfiles.map((p) => p.audioCodec)
|
||||||
|
expect(codecs).toEqual(['flac', 'opus', 'mp3'])
|
||||||
|
})
|
||||||
|
|
||||||
it('sets codecProfiles to empty array', () => {
|
it('sets codecProfiles to empty array', () => {
|
||||||
mockCanPlayType.mockReturnValue('probably')
|
mockCanPlayType.mockReturnValue('probably')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user