diff --git a/core/stream/decider.go b/core/stream/decider.go index 38839cdf0..be12516ff 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -259,8 +259,12 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai targetIsLossless := isLosslessFormat(targetFormat) - // Reject lossy to lossless conversion - if !src.IsLossless && targetIsLossless { + // Skip lossy→lossless when the client can transcode to a lossy format + // instead. An explicit lossless-only request (Subsonic format=flac, a + // player-forced FLAC transcoder, or a client that only accepts lossless) + // has no such alternative and must be honored — AV receivers often need + // that path. See #6247. + if !src.IsLossless && targetIsLossless && hasLossyTranscodingAlternative(clientInfo) { log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat) return nil, "" } @@ -308,6 +312,21 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai return ts, targetFormat } +// hasLossyTranscodingAlternative reports whether clientInfo lists any +// transcoding profile whose target is lossy. +func hasLossyTranscodingAlternative(clientInfo *ClientInfo) bool { + if clientInfo == nil { + return false + } + for i := range clientInfo.TranscodingProfiles { + _, format := resolveTargetFormat(&clientInfo.TranscodingProfiles[i]) + if format != "" && !isLosslessFormat(format) { + return true + } + } + return false +} + // 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. diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 01fef1249..9e5cdba22 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -266,8 +266,25 @@ var _ = Describe("Decider", func() { ))) }) - It("rejects lossy to lossless transcoding", func() { + It("skips lossy to lossless when a lossy transcoding profile is also available", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", Protocol: ProtocolHTTP}, + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("opus")) + }) + + It("allows lossy to lossless when that is the only requested format", func() { + // Subsonic format=flac and a player-forced FLAC transcoder both + // produce a lossless-only profile list. AV receivers that cannot + // play the source (e.g. ogg) need this path. See #6247. + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", Codec: "Vorbis", BitRate: 192, Channels: 2, SampleRate: 44100}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", Protocol: ProtocolHTTP}, @@ -275,7 +292,8 @@ var _ = Describe("Decider", func() { } decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("flac")) }) It("uses default bitrate when client doesn't specify", func() { diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index ad3417faf..b89ac965d 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -191,6 +191,18 @@ var _ = Describe("ResolveRequest", func() { Expect(req.Format).To(Equal("opus")) }) + It("transcodes a lossy source to an explicitly requested lossless format", func() { + // stream?id=…&format=flac on an ogg file. Before the fix this fell + // through to raw / DefaultDownsamplingFormat because lossy→lossless + // was unconditionally rejected. See #6247. + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", Codec: "Vorbis", BitRate: 192, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "flac", 0, 0) + + Expect(req.Format).To(Equal("flac")) + }) + 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: new(16)}) @@ -234,6 +246,16 @@ var _ = Describe("ResolveRequest", func() { }) Context("Server-side player transcoding override", func() { + It("forces a lossless transcoder even when the source is lossy", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", Codec: "Vorbis", BitRate: 192, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "flac", DefaultBitRate: 0}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("flac")) + }) + It("forces transcoding when override targets a different format", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})