mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
refactor(transcoding): enhance transcoding options with sample rate support and improve command handling
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
e67884c3b6
commit
e04f64e59e
@ -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 -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user