diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index e813a352..82d01f5d 100644 --- a/core/stream/legacy_client.go +++ b/core/stream/legacy_client.go @@ -2,6 +2,7 @@ package stream import ( "context" + "strings" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" @@ -25,8 +26,15 @@ func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int } if targetFormat != "" { - ci.DirectPlayProfiles = []DirectPlayProfile{ - {Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}}, + // Add a direct play profile for the source format when no explicit + // format was requested (bitrate-only downsampling) or when the + // requested format matches the source. When the client explicitly + // requests a different format, direct play must not match the + // source — otherwise the source is returned untranscoded. + if reqFormat == "" || strings.EqualFold(reqFormat, mf.Suffix) { + ci.DirectPlayProfiles = []DirectPlayProfile{ + {Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}}, + } } ci.TranscodingProfiles = []Profile{ {Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP}, diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index d163464a..3557a931 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -25,10 +25,25 @@ var _ = Describe("buildLegacyClientInfo", func() { Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP)) Expect(ci.MaxAudioBitrate).To(BeZero()) Expect(ci.MaxTranscodingAudioBitrate).To(BeZero()) + Expect(ci.DirectPlayProfiles).To(BeEmpty()) + }) + + It("does not add direct play profile when explicit format differs from source (no bitrate)", func() { + ci := buildLegacyClientInfo(mf, "opus", 0) + + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) + Expect(ci.DirectPlayProfiles).To(BeEmpty()) + }) + + It("adds direct play profile when explicit format matches source format", func() { + ci := buildLegacyClientInfo(mf, "flac", 0) + + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac")) 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{ProtocolHTTP})) }) It("sets transcoding profile and bitrate for explicit format with bitrate", func() { @@ -39,8 +54,7 @@ var _ = Describe("buildLegacyClientInfo", func() { 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"})) + Expect(ci.DirectPlayProfiles).To(BeEmpty()) }) It("returns direct play profile when no format and no bitrate", func() { diff --git a/server/e2e/subsonic_stream_test.go b/server/e2e/subsonic_stream_test.go new file mode 100644 index 00000000..1ca99510 --- /dev/null +++ b/server/e2e/subsonic_stream_test.go @@ -0,0 +1,127 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/conf" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("stream.view (legacy streaming)", Ordered, func() { + var ( + mp3TrackID string // Come Together (mp3, 320kbps) + flacTrackID string // TC FLAC Standard (flac, 900kbps) + ) + + BeforeAll(func() { + setupTestDB() + + songs, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + byTitle := map[string]string{} + for _, s := range songs { + byTitle[s.Title] = s.ID + } + mp3TrackID = byTitle["Come Together"] + Expect(mp3TrackID).ToNot(BeEmpty()) + flacTrackID = byTitle["TC FLAC Standard"] + Expect(flacTrackID).ToNot(BeEmpty()) + }) + + Describe("raw / direct play", func() { + It("streams raw when no format or maxBitRate is specified", func() { + w := doRawReq("stream", "id", flacTrackID) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + }) + + It("streams raw when format=raw is explicitly requested", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "raw") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + }) + + It("streams raw when maxBitRate is >= source bitrate", func() { + w := doRawReq("stream", "id", flacTrackID, "maxBitRate", "1000") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + }) + + It("streams raw when format matches source and bitrate is not lower", func() { + w := doRawReq("stream", "id", mp3TrackID, "format", "mp3", "maxBitRate", "320") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + }) + + Describe("transcoding with explicit format", func() { + It("transcodes to mp3 when format=mp3 is requested", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "mp3") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("mp3")) + // Should use the mp3 default bitrate (192kbps) + Expect(spy.LastRequest.BitRate).To(Equal(192)) + }) + + It("transcodes to opus when format=opus is requested (no maxBitRate)", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "opus") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + // Should use the opus default bitrate (128kbps) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + + It("transcodes to opus with specified maxBitRate", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "opus", "maxBitRate", "192") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(spy.LastRequest.BitRate).To(Equal(192)) + }) + + It("transcodes to mp3 with specified maxBitRate", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "mp3", "maxBitRate", "128") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("mp3")) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + + It("transcodes MP3 to opus when format=opus is requested", func() { + w := doRawReq("stream", "id", mp3TrackID, "format", "opus") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + }) + + It("transcodes same format when maxBitRate is lower than source", func() { + w := doRawReq("stream", "id", mp3TrackID, "format", "mp3", "maxBitRate", "128") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("mp3")) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + }) + + Describe("downsampling with maxBitRate only", func() { + It("transcodes using default downsampling format when maxBitRate < source bitrate", func() { + conf.Server.DefaultDownsamplingFormat = "opus" + w := doRawReq("stream", "id", flacTrackID, "maxBitRate", "192") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(spy.LastRequest.BitRate).To(Equal(192)) + }) + + It("streams raw when maxBitRate >= source bitrate (no downsampling needed)", func() { + conf.Server.DefaultDownsamplingFormat = "opus" + w := doRawReq("stream", "id", mp3TrackID, "maxBitRate", "320") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(BeElementOf("raw", "")) + }) + }) + + Describe("timeOffset", func() { + It("passes timeOffset to the stream request", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "mp3", "timeOffset", "30") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Offset).To(Equal(30)) + }) + }) +})