fix(stream): honor explicit lossless transcoding of lossy sources - #6247

Signed-off-by: Michał Furgała <83299832+00200200@users.noreply.github.com>
This commit is contained in:
Michał Furgała 2026-09-29 14:03:32 +02:00
commit eccbb39f29
3 changed files with 63 additions and 4 deletions

View file

@ -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.

View file

@ -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() {

View file

@ -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})