From 1ce16d8315431ea8c89bd8f39e1f93300a458263 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 8 Mar 2026 19:09:39 -0400 Subject: [PATCH] refactor(transcode): unify streaming decision engine via MakeDecision Move transcoding decision-making out of mediaStreamer and into the subsonic Stream/Download handlers, using transcode.Decider.MakeDecision as the single decision engine. This eliminates selectTranscodingOptions and the mismatch between decision and streaming code paths (decision used LookupTranscodeCommand with built-in fallbacks, while streaming used FindByFormat which only checked the DB). - Add DecisionOptions with SkipProbe to MakeDecision so the legacy streaming path never calls ffprobe - Add buildLegacyClientInfo to translate legacy stream params (format, maxBitRate, DefaultDownsamplingFormat) into a synthetic ClientInfo - Add resolveStreamRequest on the subsonic Router to resolve legacy params into a fully specified StreamRequest via MakeDecision - Simplify DoStream to a dumb executor that receives pre-resolved params - Remove selectTranscodingOptions entirely Signed-off-by: Deluan --- core/media_streamer.go | 62 +------ core/media_streamer_Internal_test.go | 214 ------------------------ core/media_streamer_test.go | 9 +- core/transcode/transcode.go | 12 +- core/transcode/transcode_test.go | 108 ++++++------ core/transcode/types.go | 9 +- server/e2e/e2e_suite_test.go | 2 +- server/subsonic/stream.go | 90 +++++++++- server/subsonic/stream_internal_test.go | 85 ++++++++++ server/subsonic/transcode.go | 2 +- server/subsonic/transcode_test.go | 2 +- 11 files changed, 250 insertions(+), 345 deletions(-) delete mode 100644 core/media_streamer_Internal_test.go create mode 100644 server/subsonic/stream_internal_test.go diff --git a/core/media_streamer.go b/core/media_streamer.go index 8b7e89490..59f91821e 100644 --- a/core/media_streamer.go +++ b/core/media_streamer.go @@ -83,7 +83,12 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req "originalFormat", mf.Suffix, "originalBitRate", mf.BitRate) }() - format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, req.Format, req.BitRate, req.SampleRate, req.BitDepth, req.Channels) + format = req.Format + bitRate = req.BitRate + if format == "" || format == "raw" { + format = "raw" + bitRate = 0 + } s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate} filePath := mf.AbsolutePath() @@ -149,61 +154,6 @@ func (s *Stream) EstimatedContentLength() int { return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024) } -// TODO This function deserves some love (refactoring) -func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int, reqBitDepth int, reqChannels int) (format string, bitRate int) { - format = "raw" - if reqFormat == "raw" { - return format, 0 - } - needsTranscode := (reqSampleRate > 0 && reqSampleRate < mf.SampleRate) || - (reqBitDepth > 0 && reqBitDepth < mf.BitDepth) || - (reqChannels > 0 && reqChannels < mf.Channels) - if reqFormat == mf.Suffix && reqBitRate == 0 && !needsTranscode { - bitRate = mf.BitRate - return format, bitRate - } - trc, hasDefault := request.TranscodingFrom(ctx) - var cFormat string - var cBitRate int - if reqFormat != "" { - cFormat = reqFormat - } else { - if hasDefault { - cFormat = trc.TargetFormat - cBitRate = trc.DefaultBitRate - if p, ok := request.PlayerFrom(ctx); ok { - cBitRate = p.MaxBitRate - } - } else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" { - // If no format is specified and no transcoding associated to the player, but a bitrate is specified, - // and there is no transcoding set for the player, we use the default downsampling format. - // But only if the requested bitRate is lower than the original bitRate. - log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat) - cFormat = conf.Server.DefaultDownsamplingFormat - } - } - if reqBitRate > 0 { - cBitRate = reqBitRate - } - if cBitRate == 0 && cFormat == "" { - return format, bitRate - } - t, err := ds.Transcoding(ctx).FindByFormat(cFormat) - if err == nil { - format = t.TargetFormat - if cBitRate != 0 { - bitRate = cBitRate - } else { - bitRate = t.DefaultBitRate - } - } - if format == mf.Suffix && bitRate >= mf.BitRate && !needsTranscode { - format = "raw" - bitRate = 0 - } - return format, bitRate -} - var ( onceTranscodingCache sync.Once instanceTranscodingCache TranscodingCache diff --git a/core/media_streamer_Internal_test.go b/core/media_streamer_Internal_test.go deleted file mode 100644 index cda5274f4..000000000 --- a/core/media_streamer_Internal_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package core - -import ( - "context" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "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("MediaStreamer", func() { - var ds model.DataStore - ctx := log.NewContext(context.Background()) - - BeforeEach(func() { - ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} - }) - - Context("selectTranscodingOptions", func() { - mf := &model.MediaFile{} - Context("player is not configured", func() { - It("returns raw if raw is requested", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0, 0, 0) - Expect(format).To(Equal("raw")) - }) - It("returns raw if a transcoder does not exists", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0, 0, 0, 0) - Expect(format).To(Equal("raw")) - }) - It("returns the requested format if a transcoder exists", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0, 0, 0) - Expect(format).To(Equal("mp3")) - Expect(bitRate).To(Equal(160)) // Default Bit Rate - }) - It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() { - mf.Suffix = "mp3" - mf.BitRate = 112 - format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128, 0, 0, 0) - Expect(format).To(Equal("raw")) - }) - It("returns the requested format if requested BitRate is lower than original", func() { - mf.Suffix = "mp3" - mf.BitRate = 320 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 0, 0, 0) - Expect(format).To(Equal("mp3")) - Expect(bitRate).To(Equal(192)) - }) - It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() { - mf.Suffix = "mp3" - mf.BitRate = 320 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0, 0, 0) - Expect(format).To(Equal("raw")) - Expect(bitRate).To(Equal(320)) - }) - It("returns the format when same format is requested but with a lower sample rate", func() { - mf.Suffix = "flac" - mf.BitRate = 2118 - mf.SampleRate = 96000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 48000, 0, 0) - Expect(format).To(Equal("flac")) - Expect(bitRate).To(Equal(0)) - }) - It("returns raw when same format is requested with same sample rate", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - mf.SampleRate = 48000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 48000, 0, 0) - Expect(format).To(Equal("raw")) - }) - It("returns raw when same format is requested with no sample rate constraint", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - mf.SampleRate = 96000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 0, 0, 0) - Expect(format).To(Equal("raw")) - }) - It("returns the format when same format is requested but with lower bit depth", func() { - mf.Suffix = "flac" - mf.BitRate = 2118 - mf.BitDepth = 24 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 0, 16, 0) - Expect(format).To(Equal("flac")) - Expect(bitRate).To(Equal(0)) - }) - It("returns raw when same format is requested with same bit depth", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - mf.BitDepth = 16 - format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 0, 16, 0) - Expect(format).To(Equal("raw")) - }) - It("returns the format when same format is requested but with fewer channels", func() { - mf.Suffix = "flac" - mf.BitRate = 2118 - mf.Channels = 6 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 0, 0, 2) - Expect(format).To(Equal("flac")) - Expect(bitRate).To(Equal(0)) - }) - It("returns raw when same format is requested with same channels", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - mf.Channels = 2 - format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 0, 0, 2) - Expect(format).To(Equal("raw")) - }) - Context("Downsampling", func() { - BeforeEach(func() { - conf.Server.DefaultDownsamplingFormat = "opus" - mf.Suffix = "FLAC" - mf.BitRate = 960 - }) - It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() { - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128, 0, 0, 0) - Expect(format).To(Equal("opus")) - Expect(bitRate).To(Equal(128)) - }) - It("returns raw if maxBitrate is equal or greater than original", func() { - // This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960, 0, 0, 0) - Expect(format).To(Equal("raw")) - Expect(bitRate).To(Equal(0)) - }) - }) - }) - - Context("player has format configured", func() { - BeforeEach(func() { - t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96} - ctx = request.WithTranscoding(ctx, t) - }) - It("returns raw if raw is requested", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0, 0, 0) - Expect(format).To(Equal("raw")) - }) - It("returns configured format/bitrate as default", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0, 0, 0, 0) - Expect(format).To(Equal("oga")) - Expect(bitRate).To(Equal(96)) - }) - It("returns requested format", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0, 0, 0) - Expect(format).To(Equal("mp3")) - Expect(bitRate).To(Equal(160)) // Default Bit Rate - }) - It("returns requested bitrate", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80, 0, 0, 0) - Expect(format).To(Equal("oga")) - Expect(bitRate).To(Equal(80)) - }) - It("returns raw if selected bitrate and format is the same as original", func() { - mf.Suffix = "mp3" - mf.BitRate = 192 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 0, 0, 0) - Expect(format).To(Equal("raw")) - Expect(bitRate).To(Equal(0)) - }) - }) - - Context("player has maxBitRate configured", func() { - BeforeEach(func() { - t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96} - p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 192} - ctx = request.WithTranscoding(ctx, t) - ctx = request.WithPlayer(ctx, p) - }) - It("returns raw if raw is requested", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0, 0, 0) - Expect(format).To(Equal("raw")) - }) - It("returns configured format/bitrate as default", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0, 0, 0, 0) - Expect(format).To(Equal("oga")) - Expect(bitRate).To(Equal(192)) - }) - It("returns requested format", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0, 0, 0) - Expect(format).To(Equal("mp3")) - Expect(bitRate).To(Equal(160)) // Default Bit Rate - }) - It("returns requested bitrate", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160, 0, 0, 0) - Expect(format).To(Equal("oga")) - Expect(bitRate).To(Equal(160)) - }) - }) - }) -}) diff --git a/core/media_streamer_test.go b/core/media_streamer_test.go index 0652cf008..e47beb66d 100644 --- a/core/media_streamer_test.go +++ b/core/media_streamer_test.go @@ -43,13 +43,8 @@ var _ = Describe("MediaStreamer", func() { Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) - It("returns a seekable stream if maxBitRate is 0", func() { - s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Seekable()).To(BeTrue()) - }) - It("returns a seekable stream if maxBitRate is higher than file bitRate", func() { - s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 320}) + It("returns a seekable stream if no format is specified (direct play)", func() { + s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123"}) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) diff --git a/core/transcode/transcode.go b/core/transcode/transcode.go index 110da0f1a..1fd48f8ca 100644 --- a/core/transcode/transcode.go +++ b/core/transcode/transcode.go @@ -35,15 +35,19 @@ type deciderService struct { ff ffmpeg.FFmpeg } -func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) { +func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts DecisionOptions) (*Decision, error) { decision := &Decision{ MediaID: mf.ID, SourceUpdatedAt: mf.UpdatedAt, } - probe, err := s.ensureProbed(ctx, mf) - if err != nil { - return nil, err + var probe *ffmpeg.AudioProbeResult + if !opts.SkipProbe { + var err error + probe, err = s.ensureProbed(ctx, mf) + if err != nil { + return nil, err + } } // Build source stream details (uses probe data if available) diff --git a/core/transcode/transcode_test.go b/core/transcode/transcode_test.go index ef70857a8..35e8f18bf 100644 --- a/core/transcode/transcode_test.go +++ b/core/transcode/transcode_test.go @@ -61,7 +61,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}, MaxAudioChannels: 2}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) Expect(decision.CanTranscode).To(BeFalse()) @@ -75,7 +75,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"mp3"}, Protocols: []string{"http"}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("container not supported")) @@ -88,7 +88,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio codec not supported")) @@ -101,7 +101,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"flac"}, Protocols: []string{"http"}, MaxAudioChannels: 2}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio channels not supported")) @@ -114,7 +114,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"aac"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -126,7 +126,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -138,7 +138,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"m4a"}, AudioCodecs: []string{"adts"}, Protocols: []string{"http"}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -150,7 +150,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -162,7 +162,7 @@ var _ = Describe("Decider", func() { {Containers: []string{}, AudioCodecs: []string{}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -180,7 +180,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) @@ -200,7 +200,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: "http", MaxAudioChannels: 2}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) @@ -216,7 +216,7 @@ var _ = Describe("Decider", func() { {Container: "flac", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeFalse()) }) @@ -228,7 +228,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + 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 @@ -242,7 +242,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetBitrate).To(Equal(192)) // source bitrate in kbps @@ -255,7 +255,7 @@ var _ = Describe("Decider", func() { {Container: "wav", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeFalse()) }) @@ -268,7 +268,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetBitrate).To(Equal(96)) // capped by maxAudioBitrate @@ -286,7 +286,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: "http", MaxAudioChannels: 2}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("opus")) @@ -305,7 +305,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -320,7 +320,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.IsLossless).To(BeFalse()) // mp3 is lossy @@ -331,7 +331,7 @@ var _ = Describe("Decider", func() { It("returns error when nothing matches", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}) ci := &ClientInfo{} - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeFalse()) @@ -356,7 +356,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported")) @@ -378,7 +378,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -399,7 +399,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -420,7 +420,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) }) @@ -442,7 +442,7 @@ var _ = Describe("Decider", func() { }, } // Source profile is empty (not yet populated from scanner), so Equals("LC") fails - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio profile not supported")) @@ -464,7 +464,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -485,7 +485,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio samplerate not supported")) @@ -510,7 +510,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.Bitrate).To(Equal(96)) @@ -533,7 +533,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.Channels).To(Equal(2)) @@ -556,7 +556,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) @@ -578,7 +578,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) @@ -592,7 +592,7 @@ var _ = Describe("Decider", func() { {Container: "flac", AudioCodec: "flac", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) @@ -616,7 +616,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeFalse()) }) @@ -631,7 +631,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -650,7 +650,7 @@ var _ = Describe("Decider", func() { {Container: "flac", AudioCodec: "flac", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("flac")) @@ -678,7 +678,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // DSD64 2822400 / 8 = 352800, capped by codec profile limit of 48000 @@ -705,7 +705,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // DSD 1-bit → 24-bit PCM, then capped by codec profile limit to 16-bit @@ -730,7 +730,7 @@ var _ = Describe("Decider", func() { }, MaxTranscodingAudioBitrate: 256, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.SourceStream.IsLossless).To(BeTrue()) Expect(decision.SourceStream.Codec).To(Equal("wavpack")) @@ -752,7 +752,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"ogg"}, AudioCodecs: []string{"vorbis"}, Protocols: []string{"http"}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.SourceStream.IsLossless).To(BeFalse()) Expect(decision.CanDirectPlay).To(BeTrue()) @@ -768,7 +768,7 @@ var _ = Describe("Decider", func() { {Container: "opus", AudioCodec: "opus", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("opus")) @@ -785,7 +785,7 @@ var _ = Describe("Decider", func() { {Container: "opus", AudioCodec: "opus", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) @@ -801,7 +801,7 @@ var _ = Describe("Decider", func() { {Container: "mp4", AudioCodec: "aac", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // TargetFormat is the internal format used for transcoding ("aac") @@ -819,7 +819,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -836,7 +836,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) @@ -850,7 +850,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.SampleRate).To(Equal(44100)) @@ -866,7 +866,7 @@ var _ = Describe("Decider", func() { {Container: "aac", AudioCodec: "aac", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // DSD64 2822400 / 8 = 352800, capped by AAC max of 96000 @@ -887,7 +887,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(HaveLen(3)) @@ -905,7 +905,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"flac"}, Protocols: []string{"http"}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.SourceStream.Container).To(Equal("flac")) Expect(decision.SourceStream.Codec).To(Equal("flac")) @@ -929,7 +929,7 @@ var _ = Describe("Decider", func() { overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) @@ -947,7 +947,7 @@ var _ = Describe("Decider", func() { } overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) Expect(decision.CanTranscode).To(BeFalse()) @@ -960,7 +960,7 @@ var _ = Describe("Decider", func() { } overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) @@ -976,7 +976,7 @@ var _ = Describe("Decider", func() { overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -991,7 +991,7 @@ var _ = Describe("Decider", func() { overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0}) overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -1008,7 +1008,7 @@ var _ = Describe("Decider", func() { }, } // No override in context — client profiles used as-is - decision, err := svc.MakeDecision(ctx, mf, ci) + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) diff --git a/core/transcode/types.go b/core/transcode/types.go index 341df26ac..ffc79349e 100644 --- a/core/transcode/types.go +++ b/core/transcode/types.go @@ -16,9 +16,16 @@ var ( ErrTokenStale = errors.New("transcode token is stale: media file has changed") ) +// DecisionOptions controls optional behavior of MakeDecision. +type DecisionOptions struct { + // SkipProbe prevents MakeDecision from running ffprobe on the media file. + // When true, source stream details are derived from tag metadata only. + SkipProbe bool +} + // Decider is the core service interface for making transcoding decisions type Decider interface { - MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) + MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts DecisionOptions) (*Decision, error) CreateTranscodeParams(decision *Decision) (string, error) ParseTranscodeParams(token string) (*Params, error) ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error) diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index cd92d31c4..041db0935 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -239,7 +239,7 @@ func (n noopStreamer) DoStream(context.Context, *model.MediaFile, core.StreamReq // noopDecider implements transcode.Decider type noopDecider struct{} -func (n noopDecider) MakeDecision(context.Context, *model.MediaFile, *transcode.ClientInfo) (*transcode.Decision, error) { +func (n noopDecider) MakeDecision(context.Context, *model.MediaFile, *transcode.ClientInfo, transcode.DecisionOptions) (*transcode.Decision, error) { return nil, nil } diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index 9540f32c8..38824ee69 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/transcode" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -60,9 +61,13 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su format, _ := p.String("format") timeOffset := p.IntOr("timeOffset", 0) - stream, err := api.streamer.NewStream(ctx, core.StreamRequest{ - ID: id, Format: format, BitRate: maxBitRate, Offset: timeOffset, - }) + mf, err := api.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, err + } + + streamReq := api.resolveStreamRequest(ctx, mf, format, maxBitRate, timeOffset) + stream, err := api.streamer.DoStream(ctx, mf, streamReq) if err != nil { return nil, err } @@ -131,9 +136,8 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. switch v := entity.(type) { case *model.MediaFile: - stream, err := api.streamer.NewStream(ctx, core.StreamRequest{ - ID: id, Format: format, BitRate: maxBitRate, - }) + streamReq := api.resolveStreamRequest(ctx, v, format, maxBitRate, 0) + stream, err := api.streamer.DoStream(ctx, v, streamReq) if err != nil { return nil, err } @@ -165,3 +169,77 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. return nil, err } + +// buildLegacyClientInfo translates legacy Subsonic stream/download parameters +// into a transcode.ClientInfo for use with MakeDecision. +// It does NOT read request.TranscodingFrom(ctx) — that is handled by +// MakeDecision's applyServerOverride. +func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *transcode.ClientInfo { + ci := &transcode.ClientInfo{Name: "legacy"} + + // Determine target format for transcoding + var targetFormat string + switch { + case reqFormat != "": + targetFormat = reqFormat + case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "": + targetFormat = conf.Server.DefaultDownsamplingFormat + } + + if targetFormat != "" { + ci.DirectPlayProfiles = []transcode.DirectPlayProfile{ + {Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{transcode.ProtocolHTTP}}, + } + ci.TranscodingProfiles = []transcode.Profile{ + {Container: targetFormat, AudioCodec: targetFormat, Protocol: transcode.ProtocolHTTP}, + } + if reqBitRate > 0 { + ci.MaxAudioBitrate = reqBitRate + ci.MaxTranscodingAudioBitrate = reqBitRate + } + } else { + // No transcoding requested — direct play everything + ci.DirectPlayProfiles = []transcode.DirectPlayProfile{ + {Protocols: []string{transcode.ProtocolHTTP}}, + } + } + + return ci +} + +// resolveStreamRequest uses MakeDecision to resolve legacy stream parameters +// into a fully specified StreamRequest. +func (api *Router) resolveStreamRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) core.StreamRequest { + req := core.StreamRequest{ID: mf.ID, Offset: offset} + + if reqFormat == "raw" { + req.Format = "raw" + return req + } + + clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) + decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, transcode.DecisionOptions{SkipProbe: true}) + if err != nil { + log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) + req.Format = "raw" + return req + } + + if decision.CanDirectPlay { + req.Format = "raw" + return req + } + + if decision.CanTranscode { + req.Format = decision.TargetFormat + req.BitRate = decision.TargetBitrate + req.SampleRate = decision.TargetSampleRate + req.BitDepth = decision.TargetBitDepth + req.Channels = decision.TargetChannels + return req + } + + // No compatible profile — fallback to raw + req.Format = "raw" + return req +} diff --git a/server/subsonic/stream_internal_test.go b/server/subsonic/stream_internal_test.go new file mode 100644 index 000000000..aeb8c90e7 --- /dev/null +++ b/server/subsonic/stream_internal_test.go @@ -0,0 +1,85 @@ +package subsonic + +import ( + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/model" + . "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) + + 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(transcode.ProtocolHTTP)) + Expect(ci.MaxAudioBitrate).To(BeZero()) + Expect(ci.MaxTranscodingAudioBitrate).To(BeZero()) + 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()})) + Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{transcode.ProtocolHTTP})) + }) + + It("sets transcoding profile and bitrate for explicit format with bitrate", func() { + ci := buildLegacyClientInfo(mf, "mp3", 192) + + 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(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"})) + }) + + It("returns direct play profile when no format and no bitrate", func() { + ci := buildLegacyClientInfo(mf, "", 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{transcode.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) + + 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(transcode.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) + + 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{transcode.ProtocolHTTP})) + Expect(ci.TranscodingProfiles).To(BeEmpty()) + Expect(ci.MaxAudioBitrate).To(BeZero()) + }) +}) diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 2aaec3e9a..1c8d52aea 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -280,7 +280,7 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) } // Make the decision - decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo) + decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, transcode.DecisionOptions{}) if err != nil { log.Error(ctx, "Failed to make transcode decision", "mediaID", mediaID, err) return nil, newError(responses.ErrorGeneric, "failed to make transcode decision") diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index fbc4a6f43..ca86f0a78 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -376,7 +376,7 @@ type mockTranscodeDecision struct { validateErr error } -func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *transcode.ClientInfo) (*transcode.Decision, error) { +func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *transcode.ClientInfo, _ transcode.DecisionOptions) (*transcode.Decision, error) { if m.decision != nil { return m.decision, nil }