From 6d19a23c9ab359f39bf7a1dc35a58e7882209c8e Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 8 Feb 2026 19:34:04 -0500 Subject: [PATCH] refactor(transcoding): add bit depth support for audio transcoding and enhance related logic Signed-off-by: Deluan --- core/archiver.go | 2 +- core/archiver_test.go | 12 +-- core/ffmpeg/ffmpeg.go | 37 ++++++++- core/ffmpeg/ffmpeg_test.go | 107 +++++++++++++++++++++++++++ core/media_streamer.go | 24 +++--- core/media_streamer_Internal_test.go | 56 +++++++++----- core/media_streamer_test.go | 12 +-- core/transcode/transcode.go | 10 +++ core/transcode/transcode_test.go | 73 ++++++++++++++++++ server/public/handle_streams.go | 2 +- server/subsonic/stream.go | 4 +- server/subsonic/transcode.go | 15 ++-- 12 files changed, 305 insertions(+), 49 deletions(-) diff --git a/core/archiver.go b/core/archiver.go index e48e692e4..d6bdaaf00 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -176,7 +176,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med var r io.ReadCloser if format != "raw" && format != "" { - r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0, 0) + r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0, 0, 0) } else { r, err = os.Open(path) } diff --git a/core/archiver_test.go b/core/archiver_test.go index 09f127cd3..bf0ee3672 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -44,7 +44,7 @@ var _ = Describe("Archiver", func() { }}).Return(mfs, nil) ds.On("MediaFile", mock.Anything).Return(mfRepo) - ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0, 0, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) out := new(bytes.Buffer) err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out) @@ -73,7 +73,7 @@ var _ = Describe("Archiver", func() { }}).Return(mfs, nil) ds.On("MediaFile", mock.Anything).Return(mfRepo) - ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0, 0, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out) @@ -104,7 +104,7 @@ var _ = Describe("Archiver", func() { } sh.On("Load", mock.Anything, "1").Return(share, nil) - ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0, 0, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipShare(context.Background(), "1", out) @@ -136,7 +136,7 @@ var _ = Describe("Archiver", func() { plRepo := &mockPlaylistRepository{} plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) ds.On("Playlist", mock.Anything).Return(plRepo) - ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0, 0, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out) @@ -217,8 +217,8 @@ type mockMediaStreamer struct { core.MediaStreamer } -func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int, reqOffset int) (*core.Stream, error) { - args := m.Called(ctx, mf, reqFormat, reqBitRate, reqSampleRate, reqOffset) +func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int, reqBitDepth int, reqOffset int) (*core.Stream, error) { + args := m.Called(ctx, mf, reqFormat, reqBitRate, reqSampleRate, reqBitDepth, reqOffset) if args.Error(1) != nil { return nil, args.Error(1) } diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index e301c143a..19d2480f8 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -23,6 +23,7 @@ type TranscodeOptions struct { BitRate int // kbps, 0 = codec default SampleRate int // 0 = no constraint Channels int // 0 = no constraint + BitDepth int // 0 = no constraint; valid values: 16, 24, 32 Offset int // seconds } @@ -226,6 +227,12 @@ func buildDynamicArgs(opts TranscodeOptions) []string { if opts.Channels > 0 { args = append(args, "-ac", strconv.Itoa(opts.Channels)) } + // Only pass -sample_fmt for lossless output formats where bit depth matters. + // Lossy codecs (mp3, aac, opus) handle sample format conversion internally, + // and passing interleaved formats like "s16" causes silent failures. + if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) { + args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth)) + } args = append(args, "-v", "0") @@ -242,13 +249,16 @@ func buildDynamicArgs(opts TranscodeOptions) []string { func buildTemplateArgs(opts TranscodeOptions) []string { args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset) - // Dynamically inject -ar and -ac for custom templates that don't include them + // Dynamically inject -ar, -ac, and -sample_fmt for custom templates that don't include them if opts.SampleRate > 0 { args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate)) } if opts.Channels > 0 { args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels)) } + if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) { + args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth)) + } return args } @@ -263,6 +273,31 @@ func injectBeforeOutput(args []string, flag, value string) []string { return append(args, flag, value) } +// isLosslessOutputFormat returns true if the format is a lossless audio format +// where preserving bit depth via -sample_fmt is meaningful. +func isLosslessOutputFormat(format string) bool { + switch strings.ToLower(format) { + case "flac", "alac", "wav", "aiff": + return true + } + return false +} + +// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string. +// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format +// (ffmpeg packs 24-bit samples into 32-bit containers). +func bitDepthToSampleFmt(bitDepth int) string { + switch bitDepth { + case 16: + return "s16" + case 32: + return "s32" + default: + // 24-bit and other depths: use s32 (the next valid container size) + return "s32" + } +} + // Path will always be an absolute path func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { var args []string diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 28e64d2fa..88abaf4cc 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -190,6 +190,83 @@ var _ = Describe("ffmpeg", func() { "-", })) }) + + It("builds flac args with bit depth", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "flac", + FilePath: "/music/file.dsf", + BitDepth: 24, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.dsf", + "-map", "0:a:0", + "-c:a", "flac", + "-sample_fmt", "s32", + "-v", "0", + "-f", "flac", + "-", + })) + }) + + It("omits -sample_fmt when bit depth is 0", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "flac", + FilePath: "/music/file.flac", + BitDepth: 0, + }) + Expect(args).ToNot(ContainElement("-sample_fmt")) + }) + + It("omits -sample_fmt when bit depth is too low (DSD)", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "flac", + FilePath: "/music/file.dsf", + BitDepth: 1, + }) + Expect(args).ToNot(ContainElement("-sample_fmt")) + }) + + It("omits -sample_fmt for mp3 even when bit depth >= 16", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "mp3", + FilePath: "/music/file.flac", + BitRate: 256, + BitDepth: 16, + }) + Expect(args).ToNot(ContainElement("-sample_fmt")) + }) + + It("omits -sample_fmt for aac even when bit depth >= 16", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "aac", + FilePath: "/music/file.flac", + BitRate: 256, + BitDepth: 16, + }) + Expect(args).ToNot(ContainElement("-sample_fmt")) + }) + + It("omits -sample_fmt for opus even when bit depth >= 16", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "opus", + FilePath: "/music/file.flac", + BitRate: 128, + BitDepth: 16, + }) + Expect(args).ToNot(ContainElement("-sample_fmt")) + }) + }) + + Describe("bitDepthToSampleFmt", func() { + It("converts 16-bit", func() { + Expect(bitDepthToSampleFmt(16)).To(Equal("s16")) + }) + It("converts 24-bit to s32 (FLAC only supports s16/s32)", func() { + Expect(bitDepthToSampleFmt(24)).To(Equal("s32")) + }) + It("converts 32-bit", func() { + Expect(bitDepthToSampleFmt(32)).To(Equal("s32")) + }) }) Describe("buildTemplateArgs", func() { @@ -236,6 +313,36 @@ var _ = Describe("ffmpeg", func() { "-", })) }) + + It("injects -sample_fmt for lossless output format with bit depth", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -v 0 -c:a flac -f flac -", + Format: "flac", + FilePath: "/music/file.dsf", + BitDepth: 24, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.dsf", + "-v", "0", "-c:a", "flac", "-f", "flac", + "-sample_fmt", "s32", + "-", + })) + }) + + It("does not inject -sample_fmt for lossy output format even with bit depth", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", + Format: "mp3", + FilePath: "/music/file.flac", + BitRate: 192, + BitDepth: 16, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-b:a", "192k", "-v", "0", "-f", "mp3", + "-", + })) + }) }) Describe("injectBeforeOutput", func() { diff --git a/core/media_streamer.go b/core/media_streamer.go index ced1af805..8f620a46d 100644 --- a/core/media_streamer.go +++ b/core/media_streamer.go @@ -19,8 +19,8 @@ import ( ) type MediaStreamer interface { - NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqSampleRate int, offset int) (*Stream, error) - DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int, reqOffset int) (*Stream, error) + NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqSampleRate int, reqBitDepth int, offset int) (*Stream, error) + DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int, reqBitDepth int, reqOffset int) (*Stream, error) } type TranscodingCache cache.FileCache @@ -42,23 +42,24 @@ type streamJob struct { format string bitRate int sampleRate int + bitDepth int offset int } func (j *streamJob) Key() string { - return fmt.Sprintf("%s.%s.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.format, j.offset) + return fmt.Sprintf("%s.%s.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.format, j.offset) } -func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqSampleRate int, reqOffset int) (*Stream, error) { +func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqSampleRate int, reqBitDepth int, reqOffset int) (*Stream, error) { mf, err := ms.ds.MediaFile(ctx).Get(id) if err != nil { return nil, err } - return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqSampleRate, reqOffset) + return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqSampleRate, reqBitDepth, reqOffset) } -func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int, reqOffset int) (*Stream, error) { +func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int, reqBitDepth int, reqOffset int) (*Stream, error) { var format string var bitRate int var cached bool @@ -68,7 +69,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF "originalFormat", mf.Suffix, "originalBitRate", mf.BitRate) }() - format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate) + format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate, reqSampleRate) s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate} filePath := mf.AbsolutePath() @@ -94,6 +95,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF format: format, bitRate: bitRate, sampleRate: reqSampleRate, + bitDepth: reqBitDepth, offset: reqOffset, } r, err := ms.cache.Get(ctx, job) @@ -133,12 +135,13 @@ func (s *Stream) EstimatedContentLength() int { } // TODO This function deserves some love (refactoring) -func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) { +func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int) (format string, bitRate int) { format = "raw" if reqFormat == "raw" { return format, 0 } - if reqFormat == mf.Suffix && reqBitRate == 0 { + needsResample := reqSampleRate > 0 && reqSampleRate < mf.SampleRate + if reqFormat == mf.Suffix && reqBitRate == 0 && !needsResample { bitRate = mf.BitRate return format, bitRate } @@ -177,7 +180,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model bitRate = t.DefaultBitRate } } - if format == mf.Suffix && bitRate >= mf.BitRate { + if format == mf.Suffix && bitRate >= mf.BitRate && !needsResample { format = "raw" bitRate = 0 } @@ -225,6 +228,7 @@ func NewTranscodingCache() TranscodingCache { FilePath: job.filePath, BitRate: job.bitRate, SampleRate: job.sampleRate, + BitDepth: job.bitDepth, Offset: job.offset, }) if err != nil { diff --git a/core/media_streamer_Internal_test.go b/core/media_streamer_Internal_test.go index 44fbf701c..930ffe0ba 100644 --- a/core/media_streamer_Internal_test.go +++ b/core/media_streamer_Internal_test.go @@ -26,42 +26,64 @@ var _ = Describe("MediaStreamer", func() { It("returns raw if raw is requested", func() { mf.Suffix = "flac" mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) + format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 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) + format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 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) + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 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) + format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128, 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) + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 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) + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 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) + 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) + 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) + Expect(format).To(Equal("raw")) + }) Context("Downsampling", func() { BeforeEach(func() { conf.Server.DefaultDownsamplingFormat = "opus" @@ -69,13 +91,13 @@ var _ = Describe("MediaStreamer", func() { mf.BitRate = 960 }) It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() { - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128) + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128, 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) + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960, 0) Expect(format).To(Equal("raw")) Expect(bitRate).To(Equal(0)) }) @@ -90,34 +112,34 @@ var _ = Describe("MediaStreamer", func() { It("returns raw if raw is requested", func() { mf.Suffix = "flac" mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) + format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 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) + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 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) + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 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) + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80, 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) + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 0) Expect(format).To(Equal("raw")) Expect(bitRate).To(Equal(0)) }) @@ -133,27 +155,27 @@ var _ = Describe("MediaStreamer", func() { It("returns raw if raw is requested", func() { mf.Suffix = "flac" mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) + format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 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) + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 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) + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 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) + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160, 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 8cd6d7ca1..204f90ac0 100644 --- a/core/media_streamer_test.go +++ b/core/media_streamer_test.go @@ -39,34 +39,34 @@ var _ = Describe("MediaStreamer", func() { Context("NewStream", func() { It("returns a seekable stream if format is 'raw'", func() { - s, err := streamer.NewStream(ctx, "123", "raw", 0, 0, 0) + s, err := streamer.NewStream(ctx, "123", "raw", 0, 0, 0, 0) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) It("returns a seekable stream if maxBitRate is 0", func() { - s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0, 0) + s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0, 0, 0) 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, "123", "mp3", 320, 0, 0) + s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0, 0, 0) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) It("returns a NON seekable stream if transcode is required", func() { - s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0, 0) + s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0, 0, 0) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeFalse()) Expect(s.Duration()).To(Equal(float32(257.0))) }) It("returns a seekable stream if the file is complete in the cache", func() { - s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0, 0) + s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0, 0, 0) Expect(err).To(BeNil()) _, _ = io.ReadAll(s) _ = s.Close() Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue()) - s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0, 0) + s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0, 0, 0) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeTrue()) }) diff --git a/core/transcode/transcode.go b/core/transcode/transcode.go index 464aa8467..01098eeb3 100644 --- a/core/transcode/transcode.go +++ b/core/transcode/transcode.go @@ -107,6 +107,7 @@ type Decision struct { TargetBitrate int TargetChannels int TargetSampleRate int + TargetBitDepth int SourceStream StreamDetails TranscodeStream *StreamDetails } @@ -135,6 +136,7 @@ type Params struct { TargetBitrate int TargetChannels int TargetSampleRate int + TargetBitDepth int } func NewDecider(ds model.DataStore) Decider { @@ -204,6 +206,7 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, decision.TargetBitrate = ts.Bitrate decision.TargetChannels = ts.Channels decision.TargetSampleRate = ts.SampleRate + decision.TargetBitDepth = ts.BitDepth decision.TranscodeStream = ts break } @@ -333,6 +336,7 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model. Codec: strings.ToLower(profile.AudioCodec), SampleRate: dsdToPCMSampleRate(mf.SampleRate, mf.AudioCodec()), Channels: mf.Channels, + BitDepth: mf.BitDepth, IsLossless: targetIsLossless, } if ts.Codec == "" { @@ -551,6 +555,9 @@ func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, erro if decision.TargetSampleRate > 0 { claims["sr"] = decision.TargetSampleRate } + if decision.TargetBitDepth > 0 { + claims["bd"] = decision.TargetBitDepth + } } return auth.CreateExpiringPublicToken(exp, claims) } @@ -580,6 +587,9 @@ func (s *deciderService) ParseTranscodeParams(token string) (*Params, error) { if sr, ok := claims["sr"].(float64); ok { params.TargetSampleRate = int(sr) } + if bd, ok := claims["bd"].(float64); ok { + params.TargetBitDepth = int(bd) + } return params, nil } diff --git a/core/transcode/transcode_test.go b/core/transcode/transcode_test.go index f138427ef..a2ccba2e6 100644 --- a/core/transcode/transcode_test.go +++ b/core/transcode/transcode_test.go @@ -545,6 +545,43 @@ var _ = Describe("Decider", func() { Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) }) + It("applies bitdepth limitation to transcoded stream", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24} + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: "http"}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) + Expect(decision.TargetBitDepth).To(Equal(16)) + }) + + It("preserves source bit depth when no limitation applies", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 24} + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) + Expect(decision.TargetBitDepth).To(Equal(24)) + }) + It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() { mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} ci := &ClientInfo{ @@ -845,6 +882,42 @@ var _ = Describe("Decider", func() { Expect(params.TargetChannels).To(Equal(2)) }) + It("creates and parses a transcode token with bit depth", func() { + decision := &Decision{ + MediaID: "media-bd", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "flac", + TargetBitrate: 0, + TargetChannels: 2, + TargetBitDepth: 24, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := svc.ParseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-bd")) + Expect(params.TargetBitDepth).To(Equal(24)) + }) + + It("omits bit depth from token when 0", func() { + decision := &Decision{ + MediaID: "media-nobd", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, + TargetBitDepth: 0, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := svc.ParseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.TargetBitDepth).To(Equal(0)) + }) + It("omits sample rate from token when 0", func() { decision := &Decision{ MediaID: "media-100", diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 5e831933b..53b084a7b 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -22,7 +22,7 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := pub.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 0, 0) + stream, err := pub.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 0, 0, 0) if err != nil { log.Error(ctx, "Error starting shared stream", err) http.Error(w, "invalid request", http.StatusInternalServerError) diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index 1ab6ff63f..64a61e57a 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -60,7 +60,7 @@ 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, id, format, maxBitRate, 0, timeOffset) + stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, 0, 0, timeOffset) if err != nil { return nil, err } @@ -129,7 +129,7 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. switch v := entity.(type) { case *model.MediaFile: - stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, 0, 0) + stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, 0, 0, 0) if err != nil { return nil, err } diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 003202f43..64a097794 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -237,10 +237,13 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return nil, newError(responses.ErrorGeneric, "failed to make transcode decision: %v", err) } - // Create transcode params token - transcodeParams, err := api.transcodeDecision.CreateTranscodeParams(decision) - if err != nil { - return nil, newError(responses.ErrorGeneric, "failed to create transcode token: %v", err) + // Only create a token when there is a valid playback path + var transcodeParams string + if decision.CanDirectPlay || decision.CanTranscode { + transcodeParams, err = api.transcodeDecision.CreateTranscodeParams(decision) + if err != nil { + return nil, newError(responses.ErrorGeneric, "failed to create transcode token: %v", err) + } } // Build response (convert kbps from core to bps for the API) @@ -321,17 +324,19 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* format := "" maxBitRate := 0 sampleRate := 0 + bitDepth := 0 if !params.DirectPlay && params.TargetFormat != "" { format = params.TargetFormat maxBitRate = params.TargetBitrate // Already in kbps, matching the streamer sampleRate = params.TargetSampleRate + bitDepth = params.TargetBitDepth } // Get offset parameter offset := p.IntOr("offset", 0) // Create stream - stream, err := api.streamer.NewStream(ctx, mediaID, format, maxBitRate, sampleRate, offset) + stream, err := api.streamer.NewStream(ctx, mediaID, format, maxBitRate, sampleRate, bitDepth, offset) if err != nil { return nil, err }