From e04f64e59eeb657f8a893ee6072157baf70b2200 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 8 Feb 2026 14:26:26 -0500 Subject: [PATCH] refactor(transcoding): enhance transcoding options with sample rate support and improve command handling Signed-off-by: Deluan --- consts/consts.go | 6 + core/archiver.go | 2 +- core/archiver_test.go | 12 +- core/ffmpeg/ffmpeg.go | 119 ++++++++++++++++- core/ffmpeg/ffmpeg_test.go | 203 ++++++++++++++++++++++++++++- core/media_streamer.go | 47 ++++--- core/media_streamer_test.go | 12 +- core/transcode/transcode.go | 176 ++++++++++++++++++------- core/transcode/transcode_test.go | 215 ++++++++++++++++++++++++++++++- server/public/handle_streams.go | 2 +- server/subsonic/stream.go | 4 +- server/subsonic/transcode.go | 4 +- tests/mock_ffmpeg.go | 4 +- tests/mock_transcoding_repo.go | 4 + 14 files changed, 711 insertions(+), 99 deletions(-) diff --git a/consts/consts.go b/consts/consts.go index 295abe8a9..061aebd7b 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -155,6 +155,12 @@ var ( DefaultBitRate: 256, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", }, + { + Name: "flac audio", + TargetFormat: "flac", + DefaultBitRate: 0, + Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", + }, } ) diff --git a/core/archiver.go b/core/archiver.go index 63459816e..e48e692e4 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) + r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0, 0) } else { r, err = os.Open(path) } diff --git a/core/archiver_test.go b/core/archiver_test.go index 37c4ef9ab..09f127cd3 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).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 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).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 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).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 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).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 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, reqOffset int) (*core.Stream, error) { - args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset) +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) if args.Error(1) != nil { return nil, args.Error(1) } diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index d134077ce..e301c143a 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -15,8 +15,19 @@ import ( "github.com/navidrome/navidrome/log" ) +// TranscodeOptions contains all parameters for a transcoding operation. +type TranscodeOptions struct { + Command string // DB command template (used to detect custom vs default) + Format string // Target format (mp3, opus, aac, flac) + FilePath string + BitRate int // kbps, 0 = codec default + SampleRate int // 0 = no constraint + Channels int // 0 = no constraint + Offset int // seconds +} + type FFmpeg interface { - Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) + Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) Probe(ctx context.Context, files []string) (string, error) CmdPath() (string, error) @@ -35,15 +46,19 @@ const ( type ffmpeg struct{} -func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) { +func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) { if _, err := ffmpegCmd(); err != nil { return nil, err } - // First make sure the file exists - if err := fileExists(path); err != nil { + if err := fileExists(opts.FilePath); err != nil { return nil, err } - args := createFFmpegCommand(command, path, maxBitRate, offset) + var args []string + if isDefaultCommand(opts.Format, opts.Command) { + args = buildDynamicArgs(opts) + } else { + args = buildTemplateArgs(opts) + } return e.start(ctx, args) } @@ -51,7 +66,6 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, if _, err := ffmpegCmd(); err != nil { return nil, err } - // First make sure the file exists if err := fileExists(path); err != nil { return nil, err } @@ -156,6 +170,99 @@ func (j *ffCmd) wait() { _ = j.out.Close() } +// defaultCommands maps format to the known default command template. +// Used to detect whether a user has customized their transcoding command. +var defaultCommands = map[string]string{ + "mp3": "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + "opus": "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + "aac": "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + "flac": "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", +} + +// formatCodecMap maps target format to ffmpeg codec flag. +var formatCodecMap = map[string]string{ + "mp3": "libmp3lame", + "opus": "libopus", + "aac": "aac", + "flac": "flac", +} + +// formatOutputMap maps target format to ffmpeg output format flag (-f). +var formatOutputMap = map[string]string{ + "mp3": "mp3", + "opus": "opus", + "aac": "adts", + "flac": "flac", +} + +// isDefaultCommand returns true if the command matches the known default for this format. +func isDefaultCommand(format, command string) bool { + defaultCmd, ok := defaultCommands[format] + return ok && command == defaultCmd +} + +// buildDynamicArgs programmatically constructs ffmpeg arguments for known formats, +// including all transcoding parameters (bitrate, sample rate, channels). +func buildDynamicArgs(opts TranscodeOptions) []string { + cmdPath, _ := ffmpegCmd() + args := []string{cmdPath, "-i", opts.FilePath} + + if opts.Offset > 0 { + args = append(args, "-ss", strconv.Itoa(opts.Offset)) + } + + args = append(args, "-map", "0:a:0") + + if codec, ok := formatCodecMap[opts.Format]; ok { + args = append(args, "-c:a", codec) + } + + if opts.BitRate > 0 { + args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k") + } + if opts.SampleRate > 0 { + args = append(args, "-ar", strconv.Itoa(opts.SampleRate)) + } + if opts.Channels > 0 { + args = append(args, "-ac", strconv.Itoa(opts.Channels)) + } + + args = append(args, "-v", "0") + + if outputFmt, ok := formatOutputMap[opts.Format]; ok { + args = append(args, "-f", outputFmt) + } + + args = append(args, "-") + return args +} + +// buildTemplateArgs handles user-customized command templates, with dynamic injection +// of sample rate and channels when the template doesn't already include them. +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 + if opts.SampleRate > 0 { + args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate)) + } + if opts.Channels > 0 { + args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels)) + } + return args +} + +// injectBeforeOutput inserts a flag and value before the trailing "-" (stdout output). +func injectBeforeOutput(args []string, flag, value string) []string { + if len(args) > 0 && args[len(args)-1] == "-" { + result := make([]string, 0, len(args)+2) + result = append(result, args[:len(args)-1]...) + result = append(result, flag, value, "-") + return result + } + return append(args, flag, value) +} + // 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 debe0b51e..28e64d2fa 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -2,19 +2,27 @@ package ffmpeg import ( "context" + "os" + "path/filepath" "runtime" sync "sync" "testing" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) func TestFFmpeg(t *testing.T) { - tests.Init(t, false) + // Inline test init to avoid import cycle with tests package + //nolint:dogsled + _, file, _, _ := runtime.Caller(0) + appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", "..")) + confPath := filepath.Join(appPath, "tests", "navidrome-test.toml") + _ = os.Chdir(appPath) + conf.LoadFromFile(confPath) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "FFmpeg Suite") @@ -70,6 +78,178 @@ var _ = Describe("ffmpeg", func() { }) }) + Describe("isDefaultCommand", func() { + It("returns true for known default mp3 command", func() { + Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) + }) + It("returns true for known default opus command", func() { + Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) + }) + It("returns true for known default aac command", func() { + Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) + }) + It("returns true for known default flac command", func() { + Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) + }) + It("returns false for a custom command", func() { + Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse()) + }) + It("returns false for unknown format", func() { + Expect(isDefaultCommand("wav", "ffmpeg -i %s -f wav -")).To(BeFalse()) + }) + }) + + Describe("buildDynamicArgs", func() { + It("builds mp3 args with bitrate, samplerate, and channels", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "mp3", + FilePath: "/music/file.flac", + BitRate: 256, + SampleRate: 48000, + Channels: 2, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-map", "0:a:0", + "-c:a", "libmp3lame", + "-b:a", "256k", + "-ar", "48000", + "-ac", "2", + "-v", "0", + "-f", "mp3", + "-", + })) + }) + + It("builds flac args without bitrate", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "flac", + FilePath: "/music/file.dsf", + SampleRate: 48000, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.dsf", + "-map", "0:a:0", + "-c:a", "flac", + "-ar", "48000", + "-v", "0", + "-f", "flac", + "-", + })) + }) + + It("builds opus args with bitrate only", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "opus", + FilePath: "/music/file.flac", + BitRate: 128, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-map", "0:a:0", + "-c:a", "libopus", + "-b:a", "128k", + "-v", "0", + "-f", "opus", + "-", + })) + }) + + It("includes offset when specified", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "mp3", + FilePath: "/music/file.mp3", + BitRate: 192, + Offset: 30, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.mp3", + "-ss", "30", + "-map", "0:a:0", + "-c:a", "libmp3lame", + "-b:a", "192k", + "-v", "0", + "-f", "mp3", + "-", + })) + }) + + It("builds aac args correctly", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "aac", + FilePath: "/music/file.flac", + BitRate: 256, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-map", "0:a:0", + "-c:a", "aac", + "-b:a", "256k", + "-v", "0", + "-f", "adts", + "-", + })) + }) + }) + + Describe("buildTemplateArgs", func() { + It("injects -ar and -ac into custom template", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", + FilePath: "/music/file.flac", + BitRate: 192, + SampleRate: 44100, + Channels: 2, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-b:a", "192k", "-v", "0", "-f", "mp3", + "-ar", "44100", "-ac", "2", + "-", + })) + }) + + It("injects only -ar when channels is 0", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", + FilePath: "/music/file.flac", + BitRate: 192, + SampleRate: 48000, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-b:a", "192k", "-v", "0", "-f", "mp3", + "-ar", "48000", + "-", + })) + }) + + It("does not inject anything when sample rate and channels are 0", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", + FilePath: "/music/file.flac", + BitRate: 192, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-b:a", "192k", "-v", "0", "-f", "mp3", + "-", + })) + }) + }) + + Describe("injectBeforeOutput", func() { + It("inserts flag before trailing dash", func() { + args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-"}, "-ar", "48000") + Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-ar", "48000", "-"})) + }) + + It("appends when no trailing dash", func() { + args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3"}, "-ar", "48000") + Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-ar", "48000"})) + }) + }) + Describe("FFmpeg", func() { Context("when FFmpeg is available", func() { var ff FFmpeg @@ -93,7 +273,12 @@ var _ = Describe("ffmpeg", func() { command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -" // The input file is not used here, but we need to provide a valid path to the Transcode function - stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0) + stream, err := ff.Transcode(ctx, TranscodeOptions{ + Command: command, + Format: "mp3", + FilePath: "tests/fixtures/test.mp3", + BitRate: 128, + }) Expect(err).ToNot(HaveOccurred()) defer stream.Close() @@ -115,7 +300,12 @@ var _ = Describe("ffmpeg", func() { cancel() // Cancel immediately // This should fail immediately - _, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0) + _, err := ff.Transcode(ctx, TranscodeOptions{ + Command: "ffmpeg -i %s -f mp3 -", + Format: "mp3", + FilePath: "tests/fixtures/test.mp3", + BitRate: 128, + }) Expect(err).To(MatchError(context.Canceled)) }) }) @@ -142,7 +332,10 @@ var _ = Describe("ffmpeg", func() { defer cancel() // Start a process that will run for a while - stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0) + stream, err := ff.Transcode(ctx, TranscodeOptions{ + Command: longRunningCmd, + FilePath: "tests/fixtures/test.mp3", + }) Expect(err).ToNot(HaveOccurred()) defer stream.Close() diff --git a/core/media_streamer.go b/core/media_streamer.go index c741ed476..ced1af805 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, offset int) (*Stream, error) - DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) + 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) } type TranscodingCache cache.FileCache @@ -36,28 +36,29 @@ type mediaStreamer struct { } type streamJob struct { - ms *mediaStreamer - mf *model.MediaFile - filePath string - format string - bitRate int - offset int + ms *mediaStreamer + mf *model.MediaFile + filePath string + format string + bitRate int + sampleRate int + offset int } func (j *streamJob) Key() string { - return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset) + 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) } -func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) { +func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqSampleRate 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, reqOffset) + return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqSampleRate, reqOffset) } -func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) { +func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int, reqOffset int) (*Stream, error) { var format string var bitRate int var cached bool @@ -87,12 +88,13 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF } job := &streamJob{ - ms: ms, - mf: mf, - filePath: filePath, - format: format, - bitRate: bitRate, - offset: reqOffset, + ms: ms, + mf: mf, + filePath: filePath, + format: format, + bitRate: bitRate, + sampleRate: reqSampleRate, + offset: reqOffset, } r, err := ms.cache.Get(ctx, job) if err != nil { @@ -217,7 +219,14 @@ func NewTranscodingCache() TranscodingCache { transcodingCtx = request.AddValues(context.Background(), ctx) } - out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset) + out, err := job.ms.transcoder.Transcode(transcodingCtx, ffmpeg.TranscodeOptions{ + Command: t.Command, + Format: job.format, + FilePath: job.filePath, + BitRate: job.bitRate, + SampleRate: job.sampleRate, + Offset: job.offset, + }) if err != nil { log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err) return nil, os.ErrInvalid diff --git a/core/media_streamer_test.go b/core/media_streamer_test.go index f5175495b..8cd6d7ca1 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) + s, err := streamer.NewStream(ctx, "123", "raw", 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) + s, err := streamer.NewStream(ctx, "123", "mp3", 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) + s, err := streamer.NewStream(ctx, "123", "mp3", 320, 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) + s, err := streamer.NewStream(ctx, "123", "mp3", 64, 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) + s, err := streamer.NewStream(ctx, "123", "mp3", 32, 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) + s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0, 0) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeTrue()) }) diff --git a/core/transcode/transcode.go b/core/transcode/transcode.go index 4b5f348a4..464aa8467 100644 --- a/core/transcode/transcode.go +++ b/core/transcode/transcode.go @@ -106,6 +106,7 @@ type Decision struct { TargetFormat string TargetBitrate int TargetChannels int + TargetSampleRate int SourceStream StreamDetails TranscodeStream *StreamDetails } @@ -128,11 +129,12 @@ type StreamDetails struct { // Params contains the parameters extracted from a transcode token. // TargetBitrate is in kilobits per second (kbps). type Params struct { - MediaID string - DirectPlay bool - TargetFormat string - TargetBitrate int - TargetChannels int + MediaID string + DirectPlay bool + TargetFormat string + TargetBitrate int + TargetChannels int + TargetSampleRate int } func NewDecider(ds model.DataStore) Decider { @@ -196,11 +198,12 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, // Try transcoding profiles (in order of preference) for _, profile := range clientInfo.TranscodingProfiles { - if ts := s.computeTranscodedStream(ctx, mf, sourceBitrate, &profile, clientInfo); ts != nil { + if ts, transcodeFormat := s.computeTranscodedStream(ctx, mf, sourceBitrate, &profile, clientInfo); ts != nil { decision.CanTranscode = true - decision.TargetFormat = ts.Container + decision.TargetFormat = transcodeFormat decision.TargetBitrate = ts.Bitrate decision.TargetChannels = ts.Channels + decision.TargetSampleRate = ts.SampleRate decision.TranscodeStream = ts break } @@ -302,20 +305,76 @@ const ( ) // computeTranscodedStream attempts to build a valid transcoded stream for the given profile. -// Returns nil if the profile cannot produce a valid output. -func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model.MediaFile, sourceBitrate int, profile *Profile, clientInfo *ClientInfo) *StreamDetails { +// Returns the stream details and the internal transcoding format (which may differ from the +// response container when a codec fallback occurs, e.g., "mp4"→"aac"). +// Returns nil, "" if the profile cannot produce a valid output. +func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model.MediaFile, sourceBitrate int, profile *Profile, clientInfo *ClientInfo) (*StreamDetails, string) { // Check protocol (only http for now) if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) { log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol) - return nil + return nil, "" } - targetFormat := strings.ToLower(profile.Container) + responseContainer, targetFormat := s.resolveTargetFormat(ctx, profile) + if targetFormat == "" { + return nil, "" + } + + targetIsLossless := isLosslessFormat(targetFormat) + + // Reject lossy to lossless conversion + if !mf.IsLossless() && targetIsLossless { + log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat) + return nil, "" + } + + ts := &StreamDetails{ + Container: responseContainer, + Codec: strings.ToLower(profile.AudioCodec), + SampleRate: dsdToPCMSampleRate(mf.SampleRate, mf.AudioCodec()), + Channels: mf.Channels, + IsLossless: targetIsLossless, + } + if ts.Codec == "" { + ts.Codec = targetFormat + } + + // Apply codec-intrinsic sample rate adjustments before codec profile limitations + if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 { + ts.SampleRate = fixedRate + } + if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate { + ts.SampleRate = maxRate + } + + // Determine target bitrate (all in kbps) + if ok := s.computeBitrate(ctx, mf, sourceBitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok { + return nil, "" + } + + // Apply MaxAudioChannels from the transcoding profile + if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels { + ts.Channels = profile.MaxAudioChannels + } + + // Apply codec profile limitations to the TARGET codec + if ok := s.applyCodecLimitations(ctx, sourceBitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok { + return nil, "" + } + + return ts, targetFormat +} + +// resolveTargetFormat determines the response container and internal target format +// by looking up transcoding configs. Returns ("", "") if no config found. +func (s *deciderService) resolveTargetFormat(ctx context.Context, profile *Profile) (responseContainer, targetFormat string) { + responseContainer = strings.ToLower(profile.Container) + targetFormat = responseContainer if targetFormat == "" { targetFormat = strings.ToLower(profile.AudioCodec) + responseContainer = targetFormat } - // Verify we have a transcoding config for this format. // Try the container first, then fall back to the audioCodec (e.g. "ogg" → "opus", "mp4" → "aac"). tc, err := s.ds.Transcoding(ctx).FindByFormat(targetFormat) if (err != nil || tc == nil) && profile.AudioCodec != "" && !strings.EqualFold(targetFormat, profile.AudioCodec) { @@ -328,62 +387,42 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model. } if err != nil || tc == nil { log.Trace(ctx, "Skipping transcoding profile: no transcoding config", "targetFormat", targetFormat) - return nil + return "", "" } + return responseContainer, targetFormat +} - targetIsLossless := isLosslessFormat(targetFormat) - - // Reject lossy to lossless conversion - if !mf.IsLossless() && targetIsLossless { - log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat) - return nil - } - - ts := &StreamDetails{ - Container: targetFormat, - Codec: strings.ToLower(profile.AudioCodec), - SampleRate: mf.SampleRate, - Channels: mf.Channels, - IsLossless: targetIsLossless, - } - if ts.Codec == "" { - ts.Codec = targetFormat - } - - // Determine target bitrate (all in kbps) +// computeBitrate determines the target bitrate for the transcoded stream. +// Returns false if the profile should be rejected. +func (s *deciderService) computeBitrate(ctx context.Context, mf *model.MediaFile, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool { if mf.IsLossless() { if !targetIsLossless { - // Lossless to lossy: use client's max transcoding bitrate or default if clientInfo.MaxTranscodingAudioBitrate > 0 { ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate } else { ts.Bitrate = defaultBitrate } } else { - // Lossless to lossless: check if bitrate is under the global max if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate { log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit", "targetFormat", targetFormat, "sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate) - return nil + return false } - // No explicit bitrate for lossless target (leave 0) } } else { - // Lossy to lossy: preserve source bitrate ts.Bitrate = sourceBitrate } - // Apply maxAudioBitrate as final cap on transcoded stream (#5) + // Apply maxAudioBitrate as final cap if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate { ts.Bitrate = clientInfo.MaxAudioBitrate } + return true +} - // Apply MaxAudioChannels from the transcoding profile - if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels { - ts.Channels = profile.MaxAudioChannels - } - - // Apply codec profile limitations to the TARGET codec (#4) +// applyCodecLimitations applies codec profile limitations to the transcoded stream. +// Returns false if the profile should be rejected. +func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool { targetCodec := ts.Codec for _, codecProfile := range clientInfo.CodecProfiles { if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) { @@ -394,22 +433,20 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model. } for _, lim := range codecProfile.Limitations { result := applyLimitation(sourceBitrate, &lim, ts) - // For lossless codecs, adjusting bitrate is not valid if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted { log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target", "targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name) - return nil + return false } if result == adjustCannotFit { log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied", "targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name, "comparison", lim.Comparison, "values", lim.Values) - return nil + return false } } } - - return ts + return true } // applyLimitation adjusts a transcoded stream parameter to satisfy the limitation. @@ -511,6 +548,9 @@ func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, erro if decision.TargetChannels > 0 { claims["ch"] = decision.TargetChannels } + if decision.TargetSampleRate > 0 { + claims["sr"] = decision.TargetSampleRate + } } return auth.CreateExpiringPublicToken(exp, claims) } @@ -537,6 +577,9 @@ func (s *deciderService) ParseTranscodeParams(token string) (*Params, error) { if ch, ok := claims["ch"].(float64); ok { params.TargetChannels = int(ch) } + if sr, ok := claims["sr"].(float64); ok { + params.TargetSampleRate = int(sr) + } return params, nil } @@ -696,3 +739,36 @@ func isLosslessFormat(format string) bool { } return false } + +// dsdToPCMSampleRate converts a DSD sample rate to its PCM-equivalent rate (÷8). +// DSD64=2822400→352800, DSD128=5644800→705600, etc. +// For non-DSD codecs, returns the rate unchanged. +func dsdToPCMSampleRate(sampleRate int, codec string) int { + if strings.EqualFold(codec, "dsd") && sampleRate > 0 { + return sampleRate / 8 + } + return sampleRate +} + +// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs +// that always resample regardless of input (e.g., Opus always outputs 48000Hz). +// Returns 0 if the codec has no fixed output rate. +func codecFixedOutputSampleRate(codec string) int { + switch strings.ToLower(codec) { + case "opus": + return 48000 + } + return 0 +} + +// codecMaxSampleRate returns the hard maximum output sample rate for a codec. +// Returns 0 if the codec has no hard limit. +func codecMaxSampleRate(codec string) int { + switch strings.ToLower(codec) { + case "mp3": + return 48000 + case "aac": + return 96000 + } + return 0 +} diff --git a/core/transcode/transcode_test.go b/core/transcode/transcode_test.go index 49c29049a..f138427ef 100644 --- a/core/transcode/transcode_test.go +++ b/core/transcode/transcode_test.go @@ -227,7 +227,7 @@ var _ = Describe("Decider", func() { mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2} ci := &ClientInfo{ TranscodingProfiles: []Profile{ - {Container: "aac", Protocol: "http"}, + {Container: "wav", Protocol: "http"}, }, } decision, err := svc.MakeDecision(ctx, mf, ci) @@ -568,6 +568,180 @@ var _ = Describe("Decider", func() { }) }) + Context("DSD sample rate conversion", func() { + It("converts DSD sample rate to PCM-equivalent in decision", func() { + mf := &model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + // DSD64 2822400 / 8 = 352800, capped by MP3 max of 48000 + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + Expect(decision.TargetSampleRate).To(Equal(48000)) + }) + + It("converts DSD sample rate for FLAC target without codec limit", func() { + mf := &model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1} + 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.TargetFormat).To(Equal("flac")) + // DSD64 2822400 / 8 = 352800, FLAC has no hard max + Expect(decision.TranscodeStream.SampleRate).To(Equal(352800)) + Expect(decision.TargetSampleRate).To(Equal(352800)) + }) + + It("applies codec profile limit to DSD-converted FLAC sample rate", func() { + mf := &model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1} + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: "http"}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + // DSD64 2822400 / 8 = 352800, capped by codec profile limit of 48000 + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + Expect(decision.TargetSampleRate).To(Equal(48000)) + }) + }) + + Context("Opus fixed sample rate", func() { + It("sets Opus output to 48000Hz regardless of input", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 128, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("opus")) + // Opus always outputs 48000Hz + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + Expect(decision.TargetSampleRate).To(Equal(48000)) + }) + + It("sets Opus output to 48000Hz even for 96kHz input", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 128, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + }) + }) + + Context("Container vs format separation", func() { + It("preserves mp4 container when falling back to aac format", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 256, + TranscodingProfiles: []Profile{ + {Container: "mp4", AudioCodec: "aac", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + // TargetFormat is the internal format used for DB lookup ("aac") + Expect(decision.TargetFormat).To(Equal("aac")) + // Container in the response preserves what the client asked ("mp4") + Expect(decision.TranscodeStream.Container).To(Equal("mp4")) + Expect(decision.TranscodeStream.Codec).To(Equal("aac")) + }) + + It("uses container as format when container matches transcoding config", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 256, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TranscodeStream.Container).To(Equal("mp3")) + }) + }) + + Context("MP3 max sample rate", func() { + It("caps sample rate at 48000 for MP3", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + }) + + It("preserves sample rate at 44100 for MP3", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.SampleRate).To(Equal(44100)) + }) + }) + + Context("AAC max sample rate", func() { + It("caps sample rate at 96000 for AAC", func() { + mf := &model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "aac", AudioCodec: "aac", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + // DSD64 2822400 / 8 = 352800, capped by AAC max of 96000 + Expect(decision.TranscodeStream.SampleRate).To(Equal(96000)) + }) + }) + Context("Typed transcode reasons from multiple profiles", func() { It("collects reasons from each failed direct play profile", func() { mf := &model.MediaFile{ID: "1", Suffix: "ogg", Codec: "Vorbis", BitRate: 128, Channels: 2, SampleRate: 48000} @@ -649,6 +823,45 @@ var _ = Describe("Decider", func() { Expect(params.TargetChannels).To(Equal(2)) }) + It("creates and parses a transcode token with sample rate", func() { + decision := &Decision{ + MediaID: "media-789", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "flac", + TargetBitrate: 0, + TargetChannels: 2, + TargetSampleRate: 48000, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := svc.ParseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-789")) + Expect(params.DirectPlay).To(BeFalse()) + Expect(params.TargetFormat).To(Equal("flac")) + Expect(params.TargetSampleRate).To(Equal(48000)) + Expect(params.TargetChannels).To(Equal(2)) + }) + + It("omits sample rate from token when 0", func() { + decision := &Decision{ + MediaID: "media-100", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, + TargetSampleRate: 0, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := svc.ParseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.TargetSampleRate).To(Equal(0)) + }) + It("rejects an invalid token", func() { _, err := svc.ParseTranscodeParams("invalid-token") Expect(err).To(HaveOccurred()) diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index d6819974b..5e831933b 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) + stream, err := pub.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 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 d0cbe2086..1ab6ff63f 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, timeOffset) + stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, 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) + stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, 0, 0) if err != nil { return nil, err } diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 70a3443e4..003202f43 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -320,16 +320,18 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* // Determine streaming parameters format := "" maxBitRate := 0 + sampleRate := 0 if !params.DirectPlay && params.TargetFormat != "" { format = params.TargetFormat maxBitRate = params.TargetBitrate // Already in kbps, matching the streamer + sampleRate = params.TargetSampleRate } // Get offset parameter offset := p.IntOr("offset", 0) // Create stream - stream, err := api.streamer.NewStream(ctx, mediaID, format, maxBitRate, offset) + stream, err := api.streamer.NewStream(ctx, mediaID, format, maxBitRate, sampleRate, offset) if err != nil { return nil, err } diff --git a/tests/mock_ffmpeg.go b/tests/mock_ffmpeg.go index a792ae9d3..9b66134ec 100644 --- a/tests/mock_ffmpeg.go +++ b/tests/mock_ffmpeg.go @@ -6,6 +6,8 @@ import ( "strings" "sync" "sync/atomic" + + "github.com/navidrome/navidrome/core/ffmpeg" ) func NewMockFFmpeg(data string) *MockFFmpeg { @@ -23,7 +25,7 @@ func (ff *MockFFmpeg) IsAvailable() bool { return true } -func (ff *MockFFmpeg) Transcode(context.Context, string, string, int, int) (io.ReadCloser, error) { +func (ff *MockFFmpeg) Transcode(_ context.Context, _ ffmpeg.TranscodeOptions) (io.ReadCloser, error) { if ff.Error != nil { return nil, ff.Error } diff --git a/tests/mock_transcoding_repo.go b/tests/mock_transcoding_repo.go index 12db0d7be..ff0aba86b 100644 --- a/tests/mock_transcoding_repo.go +++ b/tests/mock_transcoding_repo.go @@ -18,6 +18,10 @@ func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, e return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil case "opus": return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil + case "flac": + return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil + case "aac": + return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -"}, nil default: return nil, model.ErrNotFound }