diff --git a/core/transcode/decider.go b/core/transcode/decider.go index 55b451fd..e870e9af 100644 --- a/core/transcode/decider.go +++ b/core/transcode/decider.go @@ -14,7 +14,7 @@ import ( "github.com/navidrome/navidrome/model/request" ) -const defaultBitrate = 256 // kbps +const fallbackBitrate = 256 // kbps // Decider is the core service interface for making transcoding decisions type Decider interface { @@ -58,6 +58,13 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, // Check for server-side player transcoding override if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { 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, @@ -291,6 +298,21 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Strea 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. // It checks the DB first (for user-customized commands), then falls back to // 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 clientInfo.MaxTranscodingAudioBitrate > 0 { ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate + } else if clientInfo.MaxAudioBitrate > 0 { + ts.Bitrate = clientInfo.MaxAudioBitrate } else { - ts.Bitrate = defaultBitrate + ts.Bitrate = lookupDefaultBitrate(ctx, s.ds, targetFormat) } } else { if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate { diff --git a/core/transcode/decider_test.go b/core/transcode/decider_test.go index e5ad2f62..280060fb 100644 --- a/core/transcode/decider_test.go +++ b/core/transcode/decider_test.go @@ -229,7 +229,7 @@ var _ = Describe("Decider", func() { decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) 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() { @@ -993,8 +993,8 @@ var _ = Describe("Decider", func() { Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) - // With no cap, lossless→lossy uses defaultBitrate (256) - Expect(decision.TargetBitrate).To(Equal(defaultBitrate)) + // With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock) + Expect(decision.TargetBitrate).To(Equal(160)) }) 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() { diff --git a/db/migrations/20260309203355_ensure_default_transcodings.go b/db/migrations/20260309203355_ensure_default_transcodings.go new file mode 100644 index 00000000..ff383822 --- /dev/null +++ b/db/migrations/20260309203355_ensure_default_transcodings.go @@ -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 +} diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index 16a884bf..64d788b3 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -146,6 +146,25 @@ var _ = Describe("Transcode Endpoints", Ordered, 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() { It("returns 405 for GET request", func() { w := doRawReq("getTranscodeDecision", "mediaId", mp3TrackID, "mediaType", "song") @@ -360,6 +379,175 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { 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() { diff --git a/ui/src/transcode/browserProfile.js b/ui/src/transcode/browserProfile.js index 4ee114e4..5a4cde20 100644 --- a/ui/src/transcode/browserProfile.js +++ b/ui/src/transcode/browserProfile.js @@ -9,32 +9,44 @@ export const CODEC_PROBES = [ { codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' }, ] -// Default transcoding targets — ordered by preference. -// These are attempted if direct play is not possible. -const DEFAULT_TRANSCODING_PROFILES = [ - { container: 'ogg', audioCodec: 'opus', protocol: 'http' }, - { container: 'mp3', audioCodec: 'mp3', protocol: 'http' }, -] +// Transcoding targets in preference order (lossless first, then lossy). +// Derived from CODEC_PROBES to avoid duplicating MIME strings. +// MP3 is always included as a universal fallback. +const TRANSCODE_CODECS = ['flac', 'opus', 'mp3'] + +function probeSupported(audio, probes) { + return probes.filter(({ mime }) => audio.canPlayType(mime) === 'probably') +} export function detectBrowserProfile() { const audio = new Audio() - const directPlayProfiles = [] - for (const { codec, container, mime } of CODEC_PROBES) { - if (audio.canPlayType(mime) === 'probably') { - directPlayProfiles.push({ - containers: [container], - audioCodecs: [codec], - protocols: ['http'], + const directPlayProfiles = probeSupported(audio, CODEC_PROBES).map( + ({ codec, container }) => ({ + containers: [container], + audioCodecs: [codec], + 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 { name: 'NavidromeUI', platform: navigator.userAgent, directPlayProfiles, - transcodingProfiles: DEFAULT_TRANSCODING_PROFILES, + transcodingProfiles, codecProfiles: [], } } diff --git a/ui/src/transcode/browserProfile.test.js b/ui/src/transcode/browserProfile.test.js index 9764a004..360ae788 100644 --- a/ui/src/transcode/browserProfile.test.js +++ b/ui/src/transcode/browserProfile.test.js @@ -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('') 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') }) + 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', () => { mockCanPlayType.mockReturnValue('probably')