From 5ecbe31a06b4035fc2768219455e804c26660647 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 11 Mar 2026 09:46:06 -0400 Subject: [PATCH] fix: implement fallback to DefaultDownsamplingFormat for unknown formats Signed-off-by: Deluan --- core/stream/legacy_client.go | 11 +- core/stream/legacy_client_test.go | 142 ++++++++++++++++++++ server/e2e/subsonic_media_retrieval_test.go | 4 +- 3 files changed, 154 insertions(+), 3 deletions(-) diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index 82d01f5de..d6e929ec8 100644 --- a/core/stream/legacy_client.go +++ b/core/stream/legacy_client.go @@ -86,7 +86,16 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile return req } - // No compatible profile — fallback to raw + // No compatible profile for the requested format — retry with DefaultDownsamplingFormat + // TODO: validate DefaultDownsamplingFormat at startup to warn about unsupported values + fallbackFormat := conf.Server.DefaultDownsamplingFormat + if reqFormat != "" && fallbackFormat != "" && !strings.EqualFold(reqFormat, fallbackFormat) { + log.Warn(ctx, "Requested format not available, falling back to default downsampling format", + "requestedFormat", reqFormat, "fallbackFormat", fallbackFormat, "id", mf.ID) + return s.ResolveRequest(ctx, mf, fallbackFormat, reqBitRate, offset) + } + + // Ultimate fallback — raw req.Format = "raw" return req } diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index 3557a9314..de1eb1339 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -1,9 +1,13 @@ package stream import ( + "context" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -96,3 +100,141 @@ var _ = Describe("buildLegacyClientInfo", func() { Expect(ci.MaxAudioBitrate).To(BeZero()) }) }) + +var _ = Describe("ResolveRequest", func() { + var ( + svc TranscodeDecider + ctx context.Context + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds := &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedTranscoding: &tests.MockTranscodingRepo{}, + } + ff := tests.NewMockFFmpeg("") + auth.Init(ds) + svc = NewTranscodeDecider(ds, ff) + }) + + It("returns raw when format is 'raw'", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "raw", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + + It("returns raw (direct play) when no format or bitrate specified", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + + It("transcodes to requested format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "opus", 0, 0) + + Expect(req.Format).To(Equal("opus")) + }) + + It("transcodes to requested format with bitrate limit", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(128)) + }) + + It("returns raw when requested format matches source and no bitrate reduction", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "mp3", 320, 0) + + Expect(req.Format).To(Equal("raw")) + }) + + It("downsamples when only bitrate is specified below source", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "", 128, 0) + + Expect(req.Format).To(Equal("opus")) + Expect(req.BitRate).To(Equal(128)) + }) + + It("passes offset through", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "opus", 128, 30) + + Expect(req.Format).To(Equal("opus")) + Expect(req.Offset).To(Equal(30)) + }) + + Context("fallback for unknown format", func() { + It("falls back to DefaultDownsamplingFormat", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0) + + Expect(req.Format).To(Equal("opus")) + }) + + It("falls back to raw when DefaultDownsamplingFormat is empty", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + + It("falls back to raw when DefaultDownsamplingFormat is also invalid", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "xyz" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + + It("preserves bitrate when falling back to DefaultDownsamplingFormat", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0) + + Expect(req.Format).To(Equal("opus")) + Expect(req.BitRate).To(Equal(128)) + }) + }) +}) diff --git a/server/e2e/subsonic_media_retrieval_test.go b/server/e2e/subsonic_media_retrieval_test.go index f51386672..079b131ff 100644 --- a/server/e2e/subsonic_media_retrieval_test.go +++ b/server/e2e/subsonic_media_retrieval_test.go @@ -89,11 +89,11 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) }) - It("falls back to raw for unknown format", func() { + It("falls back to default downsampling format for unknown format", func() { w := doRawReq("stream", "id", trackID, "format", "xyz") Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) + Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) }) It("passes timeOffset through", func() {