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:
Deluan Quintão
2026-03-09 16:47:34 -04:00
committed by GitHub
parent d4b2499e1e
commit d7c3a50f86
6 changed files with 413 additions and 22 deletions
+26 -2
View File
@@ -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 {
+94 -3
View File
@@ -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
}
+188
View File
@@ -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() {
+27 -15
View File
@@ -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: [],
} }
} }
+37 -2
View File
@@ -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')