mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-20 08:04:43 -05:00
Compare commits
21 Commits
custom-col
...
transcodin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b33d831a1d | ||
|
|
3b1bd2c265 | ||
|
|
0c55c7ce89 | ||
|
|
fab2acfe36 | ||
|
|
22dba77509 | ||
|
|
5107492059 | ||
|
|
01b1fc90a9 | ||
|
|
4a50142dd6 | ||
|
|
e843b918b2 | ||
|
|
39e341e863 | ||
|
|
7ca0eade80 | ||
|
|
2e02e92cc4 | ||
|
|
216d0c6c6c | ||
|
|
c26cc0f5b9 | ||
|
|
4bb6802922 | ||
|
|
2e00479a8b | ||
|
|
07e2f699da | ||
|
|
ff57efa170 | ||
|
|
0658e1f824 | ||
|
|
a88ab9f16c | ||
|
|
b5621b9784 |
@@ -65,6 +65,7 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
|||||||
Channels: int(props.Channels),
|
Channels: int(props.Channels),
|
||||||
SampleRate: int(props.SampleRate),
|
SampleRate: int(props.SampleRate),
|
||||||
BitDepth: int(props.BitsPerSample),
|
BitDepth: int(props.BitsPerSample),
|
||||||
|
Codec: props.Codec,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
|
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
|
"github.com/navidrome/navidrome/core/transcode"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
@@ -102,7 +103,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||||
playbackServer := playback.GetInstance(dataStore)
|
playbackServer := playback.GetInstance(dataStore)
|
||||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
|
decider := transcode.NewDecider(dataStore)
|
||||||
|
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics, decider)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,13 @@ var (
|
|||||||
Name: "aac audio",
|
Name: "aac audio",
|
||||||
TargetFormat: "aac",
|
TargetFormat: "aac",
|
||||||
DefaultBitRate: 256,
|
DefaultBitRate: 256,
|
||||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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
|
var r io.ReadCloser
|
||||||
if format != "raw" && format != "" {
|
if format != "raw" && format != "" {
|
||||||
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
|
r, err = a.ms.DoStream(ctx, &mf, StreamRequest{Format: format, BitRate: bitrate})
|
||||||
} else {
|
} else {
|
||||||
r, err = os.Open(path)
|
r, err = os.Open(path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ var _ = Describe("Archiver", func() {
|
|||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
|
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
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, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||||
@@ -73,7 +73,7 @@ var _ = Describe("Archiver", func() {
|
|||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
|
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
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, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
|
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)
|
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, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
err := arch.ZipShare(context.Background(), "1", out)
|
err := arch.ZipShare(context.Background(), "1", out)
|
||||||
@@ -136,7 +136,7 @@ var _ = Describe("Archiver", func() {
|
|||||||
plRepo := &mockPlaylistRepository{}
|
plRepo := &mockPlaylistRepository{}
|
||||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
||||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
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, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
|
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
|
||||||
@@ -217,8 +217,8 @@ type mockMediaStreamer struct {
|
|||||||
core.MediaStreamer
|
core.MediaStreamer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
|
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req core.StreamRequest) (*core.Stream, error) {
|
||||||
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
args := m.Called(ctx, mf, req)
|
||||||
if args.Error(1) != nil {
|
if args.Error(1) != nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,24 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/log"
|
"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
|
||||||
|
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
|
||||||
|
Offset int // seconds
|
||||||
|
}
|
||||||
|
|
||||||
type FFmpeg interface {
|
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)
|
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
||||||
Probe(ctx context.Context, files []string) (string, error)
|
Probe(ctx context.Context, files []string) (string, error)
|
||||||
CmdPath() (string, error)
|
CmdPath() (string, error)
|
||||||
@@ -35,15 +48,19 @@ const (
|
|||||||
|
|
||||||
type ffmpeg struct{}
|
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 {
|
if _, err := ffmpegCmd(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// First make sure the file exists
|
if err := fileExists(opts.FilePath); err != nil {
|
||||||
if err := fileExists(path); err != nil {
|
|
||||||
return nil, err
|
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)
|
return e.start(ctx, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +68,6 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser,
|
|||||||
if _, err := ffmpegCmd(); err != nil {
|
if _, err := ffmpegCmd(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// First make sure the file exists
|
|
||||||
if err := fileExists(path); err != nil {
|
if err := fileExists(path); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -156,6 +172,139 @@ func (j *ffCmd) wait() {
|
|||||||
_ = j.out.Close()
|
_ = j.out.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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": "ipod",
|
||||||
|
"flac": "flac",
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultCommands is used to detect whether a user has customized their transcoding command.
|
||||||
|
var defaultCommands = func() map[string]string {
|
||||||
|
m := make(map[string]string, len(consts.DefaultTranscodings))
|
||||||
|
for _, t := range consts.DefaultTranscodings {
|
||||||
|
m[t.TargetFormat] = t.Command
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
// isDefaultCommand returns true if the command matches the known default for this format.
|
||||||
|
func isDefaultCommand(format, command string) bool {
|
||||||
|
return defaultCommands[format] == command
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
// Only pass -sample_fmt for lossless output formats where bit depth matters.
|
||||||
|
// Lossy codecs (mp3, aac, opus) handle sample format conversion internally,
|
||||||
|
// and passing interleaved formats like "s16" causes silent failures.
|
||||||
|
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
|
||||||
|
args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "-v", "0")
|
||||||
|
|
||||||
|
if outputFmt, ok := formatOutputMap[opts.Format]; ok {
|
||||||
|
args = append(args, "-f", outputFmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For AAC in MP4 container, enable fragmented MP4 for pipe-safe streaming
|
||||||
|
if opts.Format == "aac" {
|
||||||
|
args = append(args, "-movflags", "frag_keyframe+empty_moov")
|
||||||
|
}
|
||||||
|
|
||||||
|
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, -ac, and -sample_fmt for custom templates that don't include them
|
||||||
|
if opts.SampleRate > 0 {
|
||||||
|
args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
|
||||||
|
}
|
||||||
|
if opts.Channels > 0 {
|
||||||
|
args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels))
|
||||||
|
}
|
||||||
|
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
|
||||||
|
args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLosslessOutputFormat returns true if the format is a lossless audio format
|
||||||
|
// where preserving bit depth via -sample_fmt is meaningful.
|
||||||
|
// Note: this covers only formats ffmpeg can produce as output. For the full set of
|
||||||
|
// lossless formats used in transcoding decisions, see core/transcode/codec.go:isLosslessFormat.
|
||||||
|
func isLosslessOutputFormat(format string) bool {
|
||||||
|
switch strings.ToLower(format) {
|
||||||
|
case "flac", "alac", "wav", "aiff":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string.
|
||||||
|
// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format
|
||||||
|
// (ffmpeg packs 24-bit samples into 32-bit containers).
|
||||||
|
func bitDepthToSampleFmt(bitDepth int) string {
|
||||||
|
switch bitDepth {
|
||||||
|
case 16:
|
||||||
|
return "s16"
|
||||||
|
case 32:
|
||||||
|
return "s32"
|
||||||
|
default:
|
||||||
|
// 24-bit and other depths: use s32 (the next valid container size)
|
||||||
|
return "s32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Path will always be an absolute path
|
// Path will always be an absolute path
|
||||||
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||||
var args []string
|
var args []string
|
||||||
|
|||||||
@@ -2,19 +2,27 @@ package ffmpeg
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
sync "sync"
|
sync "sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFFmpeg(t *testing.T) {
|
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)
|
log.SetLevel(log.LevelFatal)
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "FFmpeg Suite")
|
RunSpecs(t, "FFmpeg Suite")
|
||||||
@@ -70,6 +78,286 @@ 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 ipod -movflags frag_keyframe+empty_moov -")).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 with fragmented MP4 container", 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", "ipod",
|
||||||
|
"-movflags", "frag_keyframe+empty_moov",
|
||||||
|
"-",
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("builds flac args with bit depth", func() {
|
||||||
|
args := buildDynamicArgs(TranscodeOptions{
|
||||||
|
Format: "flac",
|
||||||
|
FilePath: "/music/file.dsf",
|
||||||
|
BitDepth: 24,
|
||||||
|
})
|
||||||
|
Expect(args).To(Equal([]string{
|
||||||
|
"ffmpeg", "-i", "/music/file.dsf",
|
||||||
|
"-map", "0:a:0",
|
||||||
|
"-c:a", "flac",
|
||||||
|
"-sample_fmt", "s32",
|
||||||
|
"-v", "0",
|
||||||
|
"-f", "flac",
|
||||||
|
"-",
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("omits -sample_fmt when bit depth is 0", func() {
|
||||||
|
args := buildDynamicArgs(TranscodeOptions{
|
||||||
|
Format: "flac",
|
||||||
|
FilePath: "/music/file.flac",
|
||||||
|
BitDepth: 0,
|
||||||
|
})
|
||||||
|
Expect(args).ToNot(ContainElement("-sample_fmt"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("omits -sample_fmt when bit depth is too low (DSD)", func() {
|
||||||
|
args := buildDynamicArgs(TranscodeOptions{
|
||||||
|
Format: "flac",
|
||||||
|
FilePath: "/music/file.dsf",
|
||||||
|
BitDepth: 1,
|
||||||
|
})
|
||||||
|
Expect(args).ToNot(ContainElement("-sample_fmt"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("omits -sample_fmt for mp3 even when bit depth >= 16", func() {
|
||||||
|
args := buildDynamicArgs(TranscodeOptions{
|
||||||
|
Format: "mp3",
|
||||||
|
FilePath: "/music/file.flac",
|
||||||
|
BitRate: 256,
|
||||||
|
BitDepth: 16,
|
||||||
|
})
|
||||||
|
Expect(args).ToNot(ContainElement("-sample_fmt"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("omits -sample_fmt for aac even when bit depth >= 16", func() {
|
||||||
|
args := buildDynamicArgs(TranscodeOptions{
|
||||||
|
Format: "aac",
|
||||||
|
FilePath: "/music/file.flac",
|
||||||
|
BitRate: 256,
|
||||||
|
BitDepth: 16,
|
||||||
|
})
|
||||||
|
Expect(args).ToNot(ContainElement("-sample_fmt"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("omits -sample_fmt for opus even when bit depth >= 16", func() {
|
||||||
|
args := buildDynamicArgs(TranscodeOptions{
|
||||||
|
Format: "opus",
|
||||||
|
FilePath: "/music/file.flac",
|
||||||
|
BitRate: 128,
|
||||||
|
BitDepth: 16,
|
||||||
|
})
|
||||||
|
Expect(args).ToNot(ContainElement("-sample_fmt"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("bitDepthToSampleFmt", func() {
|
||||||
|
It("converts 16-bit", func() {
|
||||||
|
Expect(bitDepthToSampleFmt(16)).To(Equal("s16"))
|
||||||
|
})
|
||||||
|
It("converts 24-bit to s32 (FLAC only supports s16/s32)", func() {
|
||||||
|
Expect(bitDepthToSampleFmt(24)).To(Equal("s32"))
|
||||||
|
})
|
||||||
|
It("converts 32-bit", func() {
|
||||||
|
Expect(bitDepthToSampleFmt(32)).To(Equal("s32"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("buildTemplateArgs", func() {
|
||||||
|
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",
|
||||||
|
"-",
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("injects -sample_fmt for lossless output format with bit depth", func() {
|
||||||
|
args := buildTemplateArgs(TranscodeOptions{
|
||||||
|
Command: "ffmpeg -i %s -v 0 -c:a flac -f flac -",
|
||||||
|
Format: "flac",
|
||||||
|
FilePath: "/music/file.dsf",
|
||||||
|
BitDepth: 24,
|
||||||
|
})
|
||||||
|
Expect(args).To(Equal([]string{
|
||||||
|
"ffmpeg", "-i", "/music/file.dsf",
|
||||||
|
"-v", "0", "-c:a", "flac", "-f", "flac",
|
||||||
|
"-sample_fmt", "s32",
|
||||||
|
"-",
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not inject -sample_fmt for lossy output format even with bit depth", func() {
|
||||||
|
args := buildTemplateArgs(TranscodeOptions{
|
||||||
|
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
|
||||||
|
Format: "mp3",
|
||||||
|
FilePath: "/music/file.flac",
|
||||||
|
BitRate: 192,
|
||||||
|
BitDepth: 16,
|
||||||
|
})
|
||||||
|
Expect(args).To(Equal([]string{
|
||||||
|
"ffmpeg", "-i", "/music/file.flac",
|
||||||
|
"-b:a", "192k", "-v", "0", "-f", "mp3",
|
||||||
|
"-",
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("injectBeforeOutput", func() {
|
||||||
|
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() {
|
Describe("FFmpeg", func() {
|
||||||
Context("when FFmpeg is available", func() {
|
Context("when FFmpeg is available", func() {
|
||||||
var ff FFmpeg
|
var ff FFmpeg
|
||||||
@@ -93,7 +381,12 @@ var _ = Describe("ffmpeg", func() {
|
|||||||
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
|
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
|
// 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())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
defer stream.Close()
|
defer stream.Close()
|
||||||
|
|
||||||
@@ -115,7 +408,12 @@ var _ = Describe("ffmpeg", func() {
|
|||||||
cancel() // Cancel immediately
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
// This should fail 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))
|
Expect(err).To(MatchError(context.Canceled))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -142,7 +440,10 @@ var _ = Describe("ffmpeg", func() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Start a process that will run for a while
|
// 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())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
defer stream.Close()
|
defer stream.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,20 @@ import (
|
|||||||
"github.com/navidrome/navidrome/utils/cache"
|
"github.com/navidrome/navidrome/utils/cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StreamRequest contains all parameters for creating a media stream.
|
||||||
|
type StreamRequest struct {
|
||||||
|
ID string
|
||||||
|
Format string
|
||||||
|
BitRate int // kbps
|
||||||
|
SampleRate int
|
||||||
|
BitDepth int
|
||||||
|
Channels int
|
||||||
|
Offset int // seconds
|
||||||
|
}
|
||||||
|
|
||||||
type MediaStreamer interface {
|
type MediaStreamer interface {
|
||||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
|
NewStream(ctx context.Context, req StreamRequest) (*Stream, error)
|
||||||
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
|
DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TranscodingCache cache.FileCache
|
type TranscodingCache cache.FileCache
|
||||||
@@ -41,39 +52,43 @@ type streamJob struct {
|
|||||||
filePath string
|
filePath string
|
||||||
format string
|
format string
|
||||||
bitRate int
|
bitRate int
|
||||||
|
sampleRate int
|
||||||
|
bitDepth int
|
||||||
|
channels int
|
||||||
offset int
|
offset int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *streamJob) Key() string {
|
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.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, 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, req StreamRequest) (*Stream, error) {
|
||||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
mf, err := ms.ds.MediaFile(ctx).Get(req.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
return ms.DoStream(ctx, mf, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
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, req StreamRequest) (*Stream, error) {
|
||||||
var format string
|
var format string
|
||||||
var bitRate int
|
var bitRate int
|
||||||
var cached bool
|
var cached bool
|
||||||
defer func() {
|
defer func() {
|
||||||
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
|
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
|
||||||
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw",
|
"bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels,
|
||||||
|
"user", userName(ctx), "transcoding", format != "raw",
|
||||||
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
|
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
|
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, req.Format, req.BitRate, req.SampleRate)
|
||||||
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
|
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
|
||||||
filePath := mf.AbsolutePath()
|
filePath := mf.AbsolutePath()
|
||||||
|
|
||||||
if format == "raw" {
|
if format == "raw" {
|
||||||
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
|
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
|
||||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
|
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
|
||||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||||
"selectedBitrate", bitRate, "selectedFormat", format)
|
"selectedBitrate", bitRate, "selectedFormat", format)
|
||||||
f, err := os.Open(filePath)
|
f, err := os.Open(filePath)
|
||||||
@@ -92,7 +107,10 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
|
|||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
format: format,
|
format: format,
|
||||||
bitRate: bitRate,
|
bitRate: bitRate,
|
||||||
offset: reqOffset,
|
sampleRate: req.SampleRate,
|
||||||
|
bitDepth: req.BitDepth,
|
||||||
|
channels: req.Channels,
|
||||||
|
offset: req.Offset,
|
||||||
}
|
}
|
||||||
r, err := ms.cache.Get(ctx, job)
|
r, err := ms.cache.Get(ctx, job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,7 +123,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
|
|||||||
s.Seeker = r.Seeker
|
s.Seeker = r.Seeker
|
||||||
|
|
||||||
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
|
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
|
||||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
|
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
|
||||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||||
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
|
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
|
||||||
|
|
||||||
@@ -131,12 +149,13 @@ func (s *Stream) EstimatedContentLength() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO This function deserves some love (refactoring)
|
// TODO This function deserves some love (refactoring)
|
||||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
|
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int) (format string, bitRate int) {
|
||||||
format = "raw"
|
format = "raw"
|
||||||
if reqFormat == "raw" {
|
if reqFormat == "raw" {
|
||||||
return format, 0
|
return format, 0
|
||||||
}
|
}
|
||||||
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
needsResample := reqSampleRate > 0 && reqSampleRate < mf.SampleRate
|
||||||
|
if reqFormat == mf.Suffix && reqBitRate == 0 && !needsResample {
|
||||||
bitRate = mf.BitRate
|
bitRate = mf.BitRate
|
||||||
return format, bitRate
|
return format, bitRate
|
||||||
}
|
}
|
||||||
@@ -175,7 +194,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
|
|||||||
bitRate = t.DefaultBitRate
|
bitRate = t.DefaultBitRate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if format == mf.Suffix && bitRate >= mf.BitRate {
|
if format == mf.Suffix && bitRate >= mf.BitRate && !needsResample {
|
||||||
format = "raw"
|
format = "raw"
|
||||||
bitRate = 0
|
bitRate = 0
|
||||||
}
|
}
|
||||||
@@ -217,7 +236,16 @@ func NewTranscodingCache() TranscodingCache {
|
|||||||
transcodingCtx = request.AddValues(context.Background(), ctx)
|
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,
|
||||||
|
BitDepth: job.bitDepth,
|
||||||
|
Channels: job.channels,
|
||||||
|
Offset: job.offset,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||||
return nil, os.ErrInvalid
|
return nil, os.ErrInvalid
|
||||||
|
|||||||
@@ -26,42 +26,64 @@ var _ = Describe("MediaStreamer", func() {
|
|||||||
It("returns raw if raw is requested", func() {
|
It("returns raw if raw is requested", func() {
|
||||||
mf.Suffix = "flac"
|
mf.Suffix = "flac"
|
||||||
mf.BitRate = 1000
|
mf.BitRate = 1000
|
||||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
|
||||||
Expect(format).To(Equal("raw"))
|
Expect(format).To(Equal("raw"))
|
||||||
})
|
})
|
||||||
It("returns raw if a transcoder does not exists", func() {
|
It("returns raw if a transcoder does not exists", func() {
|
||||||
mf.Suffix = "flac"
|
mf.Suffix = "flac"
|
||||||
mf.BitRate = 1000
|
mf.BitRate = 1000
|
||||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
|
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0, 0)
|
||||||
Expect(format).To(Equal("raw"))
|
Expect(format).To(Equal("raw"))
|
||||||
})
|
})
|
||||||
It("returns the requested format if a transcoder exists", func() {
|
It("returns the requested format if a transcoder exists", func() {
|
||||||
mf.Suffix = "flac"
|
mf.Suffix = "flac"
|
||||||
mf.BitRate = 1000
|
mf.BitRate = 1000
|
||||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
|
||||||
Expect(format).To(Equal("mp3"))
|
Expect(format).To(Equal("mp3"))
|
||||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||||
})
|
})
|
||||||
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
|
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
|
||||||
mf.Suffix = "mp3"
|
mf.Suffix = "mp3"
|
||||||
mf.BitRate = 112
|
mf.BitRate = 112
|
||||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
|
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128, 0)
|
||||||
Expect(format).To(Equal("raw"))
|
Expect(format).To(Equal("raw"))
|
||||||
})
|
})
|
||||||
It("returns the requested format if requested BitRate is lower than original", func() {
|
It("returns the requested format if requested BitRate is lower than original", func() {
|
||||||
mf.Suffix = "mp3"
|
mf.Suffix = "mp3"
|
||||||
mf.BitRate = 320
|
mf.BitRate = 320
|
||||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 0)
|
||||||
Expect(format).To(Equal("mp3"))
|
Expect(format).To(Equal("mp3"))
|
||||||
Expect(bitRate).To(Equal(192))
|
Expect(bitRate).To(Equal(192))
|
||||||
})
|
})
|
||||||
It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
|
It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
|
||||||
mf.Suffix = "mp3"
|
mf.Suffix = "mp3"
|
||||||
mf.BitRate = 320
|
mf.BitRate = 320
|
||||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
|
||||||
Expect(format).To(Equal("raw"))
|
Expect(format).To(Equal("raw"))
|
||||||
Expect(bitRate).To(Equal(320))
|
Expect(bitRate).To(Equal(320))
|
||||||
})
|
})
|
||||||
|
It("returns the format when same format is requested but with a lower sample rate", func() {
|
||||||
|
mf.Suffix = "flac"
|
||||||
|
mf.BitRate = 2118
|
||||||
|
mf.SampleRate = 96000
|
||||||
|
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 48000)
|
||||||
|
Expect(format).To(Equal("flac"))
|
||||||
|
Expect(bitRate).To(Equal(0))
|
||||||
|
})
|
||||||
|
It("returns raw when same format is requested with same sample rate", func() {
|
||||||
|
mf.Suffix = "flac"
|
||||||
|
mf.BitRate = 1000
|
||||||
|
mf.SampleRate = 48000
|
||||||
|
format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 48000)
|
||||||
|
Expect(format).To(Equal("raw"))
|
||||||
|
})
|
||||||
|
It("returns raw when same format is requested with no sample rate constraint", func() {
|
||||||
|
mf.Suffix = "flac"
|
||||||
|
mf.BitRate = 1000
|
||||||
|
mf.SampleRate = 96000
|
||||||
|
format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 0)
|
||||||
|
Expect(format).To(Equal("raw"))
|
||||||
|
})
|
||||||
Context("Downsampling", func() {
|
Context("Downsampling", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||||
@@ -69,13 +91,13 @@ var _ = Describe("MediaStreamer", func() {
|
|||||||
mf.BitRate = 960
|
mf.BitRate = 960
|
||||||
})
|
})
|
||||||
It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() {
|
It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() {
|
||||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128)
|
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128, 0)
|
||||||
Expect(format).To(Equal("opus"))
|
Expect(format).To(Equal("opus"))
|
||||||
Expect(bitRate).To(Equal(128))
|
Expect(bitRate).To(Equal(128))
|
||||||
})
|
})
|
||||||
It("returns raw if maxBitrate is equal or greater than original", func() {
|
It("returns raw if maxBitrate is equal or greater than original", func() {
|
||||||
// This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066
|
// This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066
|
||||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960)
|
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960, 0)
|
||||||
Expect(format).To(Equal("raw"))
|
Expect(format).To(Equal("raw"))
|
||||||
Expect(bitRate).To(Equal(0))
|
Expect(bitRate).To(Equal(0))
|
||||||
})
|
})
|
||||||
@@ -90,34 +112,34 @@ var _ = Describe("MediaStreamer", func() {
|
|||||||
It("returns raw if raw is requested", func() {
|
It("returns raw if raw is requested", func() {
|
||||||
mf.Suffix = "flac"
|
mf.Suffix = "flac"
|
||||||
mf.BitRate = 1000
|
mf.BitRate = 1000
|
||||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
|
||||||
Expect(format).To(Equal("raw"))
|
Expect(format).To(Equal("raw"))
|
||||||
})
|
})
|
||||||
It("returns configured format/bitrate as default", func() {
|
It("returns configured format/bitrate as default", func() {
|
||||||
mf.Suffix = "flac"
|
mf.Suffix = "flac"
|
||||||
mf.BitRate = 1000
|
mf.BitRate = 1000
|
||||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0, 0)
|
||||||
Expect(format).To(Equal("oga"))
|
Expect(format).To(Equal("oga"))
|
||||||
Expect(bitRate).To(Equal(96))
|
Expect(bitRate).To(Equal(96))
|
||||||
})
|
})
|
||||||
It("returns requested format", func() {
|
It("returns requested format", func() {
|
||||||
mf.Suffix = "flac"
|
mf.Suffix = "flac"
|
||||||
mf.BitRate = 1000
|
mf.BitRate = 1000
|
||||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
|
||||||
Expect(format).To(Equal("mp3"))
|
Expect(format).To(Equal("mp3"))
|
||||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||||
})
|
})
|
||||||
It("returns requested bitrate", func() {
|
It("returns requested bitrate", func() {
|
||||||
mf.Suffix = "flac"
|
mf.Suffix = "flac"
|
||||||
mf.BitRate = 1000
|
mf.BitRate = 1000
|
||||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80, 0)
|
||||||
Expect(format).To(Equal("oga"))
|
Expect(format).To(Equal("oga"))
|
||||||
Expect(bitRate).To(Equal(80))
|
Expect(bitRate).To(Equal(80))
|
||||||
})
|
})
|
||||||
It("returns raw if selected bitrate and format is the same as original", func() {
|
It("returns raw if selected bitrate and format is the same as original", func() {
|
||||||
mf.Suffix = "mp3"
|
mf.Suffix = "mp3"
|
||||||
mf.BitRate = 192
|
mf.BitRate = 192
|
||||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 0)
|
||||||
Expect(format).To(Equal("raw"))
|
Expect(format).To(Equal("raw"))
|
||||||
Expect(bitRate).To(Equal(0))
|
Expect(bitRate).To(Equal(0))
|
||||||
})
|
})
|
||||||
@@ -133,27 +155,27 @@ var _ = Describe("MediaStreamer", func() {
|
|||||||
It("returns raw if raw is requested", func() {
|
It("returns raw if raw is requested", func() {
|
||||||
mf.Suffix = "flac"
|
mf.Suffix = "flac"
|
||||||
mf.BitRate = 1000
|
mf.BitRate = 1000
|
||||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
|
||||||
Expect(format).To(Equal("raw"))
|
Expect(format).To(Equal("raw"))
|
||||||
})
|
})
|
||||||
It("returns configured format/bitrate as default", func() {
|
It("returns configured format/bitrate as default", func() {
|
||||||
mf.Suffix = "flac"
|
mf.Suffix = "flac"
|
||||||
mf.BitRate = 1000
|
mf.BitRate = 1000
|
||||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0, 0)
|
||||||
Expect(format).To(Equal("oga"))
|
Expect(format).To(Equal("oga"))
|
||||||
Expect(bitRate).To(Equal(192))
|
Expect(bitRate).To(Equal(192))
|
||||||
})
|
})
|
||||||
It("returns requested format", func() {
|
It("returns requested format", func() {
|
||||||
mf.Suffix = "flac"
|
mf.Suffix = "flac"
|
||||||
mf.BitRate = 1000
|
mf.BitRate = 1000
|
||||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
|
||||||
Expect(format).To(Equal("mp3"))
|
Expect(format).To(Equal("mp3"))
|
||||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||||
})
|
})
|
||||||
It("returns requested bitrate", func() {
|
It("returns requested bitrate", func() {
|
||||||
mf.Suffix = "flac"
|
mf.Suffix = "flac"
|
||||||
mf.BitRate = 1000
|
mf.BitRate = 1000
|
||||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160)
|
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160, 0)
|
||||||
Expect(format).To(Equal("oga"))
|
Expect(format).To(Equal("oga"))
|
||||||
Expect(bitRate).To(Equal(160))
|
Expect(bitRate).To(Equal(160))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,34 +39,34 @@ var _ = Describe("MediaStreamer", func() {
|
|||||||
|
|
||||||
Context("NewStream", func() {
|
Context("NewStream", func() {
|
||||||
It("returns a seekable stream if format is 'raw'", 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, core.StreamRequest{ID: "123", Format: "raw"})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(s.Seekable()).To(BeTrue())
|
Expect(s.Seekable()).To(BeTrue())
|
||||||
})
|
})
|
||||||
It("returns a seekable stream if maxBitRate is 0", func() {
|
It("returns a seekable stream if maxBitRate is 0", func() {
|
||||||
s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0)
|
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3"})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(s.Seekable()).To(BeTrue())
|
Expect(s.Seekable()).To(BeTrue())
|
||||||
})
|
})
|
||||||
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
|
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, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 320})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(s.Seekable()).To(BeTrue())
|
Expect(s.Seekable()).To(BeTrue())
|
||||||
})
|
})
|
||||||
It("returns a NON seekable stream if transcode is required", func() {
|
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, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 64})
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(s.Seekable()).To(BeFalse())
|
Expect(s.Seekable()).To(BeFalse())
|
||||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||||
})
|
})
|
||||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
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, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
_, _ = io.ReadAll(s)
|
_, _ = io.ReadAll(s)
|
||||||
_ = s.Close()
|
_ = s.Close()
|
||||||
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
|
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
|
||||||
|
|
||||||
s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
|
s, err = streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(s.Seekable()).To(BeTrue())
|
Expect(s.Seekable()).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|||||||
87
core/transcode/aliases.go
Normal file
87
core/transcode/aliases.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package transcode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// containerAliasGroups maps each container alias to a canonical group name.
|
||||||
|
var containerAliasGroups = func() map[string]string {
|
||||||
|
groups := [][]string{
|
||||||
|
{"aac", "adts", "m4a", "mp4", "m4b", "m4p"},
|
||||||
|
{"mpeg", "mp3", "mp2"},
|
||||||
|
{"ogg", "oga"},
|
||||||
|
{"aif", "aiff"},
|
||||||
|
{"asf", "wma"},
|
||||||
|
{"mpc", "mpp"},
|
||||||
|
{"wv"},
|
||||||
|
}
|
||||||
|
m := make(map[string]string)
|
||||||
|
for _, g := range groups {
|
||||||
|
canonical := g[0]
|
||||||
|
for _, name := range g {
|
||||||
|
m[name] = canonical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
// codecAliasGroups maps each codec alias to a canonical group name.
|
||||||
|
// Codecs within the same group are considered equivalent.
|
||||||
|
var codecAliasGroups = func() map[string]string {
|
||||||
|
groups := [][]string{
|
||||||
|
{"aac", "adts"},
|
||||||
|
{"ac3", "ac-3"},
|
||||||
|
{"eac3", "e-ac3", "e-ac-3", "eac-3"},
|
||||||
|
{"mpc7", "musepack7"},
|
||||||
|
{"mpc8", "musepack8"},
|
||||||
|
{"wma1", "wmav1"},
|
||||||
|
{"wma2", "wmav2"},
|
||||||
|
{"wmalossless", "wma9lossless"},
|
||||||
|
{"wmapro", "wma9pro"},
|
||||||
|
{"shn", "shorten"},
|
||||||
|
{"mp4als", "als"},
|
||||||
|
}
|
||||||
|
m := make(map[string]string)
|
||||||
|
for _, g := range groups {
|
||||||
|
for _, name := range g {
|
||||||
|
m[name] = g[0] // canonical = first entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
// matchesWithAliases checks if a value matches any entry in candidates,
|
||||||
|
// consulting the alias map for equivalent names.
|
||||||
|
func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool {
|
||||||
|
value = strings.ToLower(value)
|
||||||
|
canonical := aliases[value]
|
||||||
|
for _, c := range candidates {
|
||||||
|
c = strings.ToLower(c)
|
||||||
|
if c == value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if canonical != "" && aliases[c] == canonical {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesContainer checks if a file suffix matches any of the container names,
|
||||||
|
// including common aliases.
|
||||||
|
func matchesContainer(suffix string, containers []string) bool {
|
||||||
|
return matchesWithAliases(suffix, containers, containerAliasGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesCodec checks if a codec matches any of the codec names,
|
||||||
|
// including common aliases.
|
||||||
|
func matchesCodec(codec string, codecs []string) bool {
|
||||||
|
return matchesWithAliases(codec, codecs, codecAliasGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsIgnoreCase(slice []string, s string) bool {
|
||||||
|
return slices.ContainsFunc(slice, func(item string) bool {
|
||||||
|
return strings.EqualFold(item, s)
|
||||||
|
})
|
||||||
|
}
|
||||||
59
core/transcode/codec.go
Normal file
59
core/transcode/codec.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package transcode
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// isLosslessFormat returns true if the format is a lossless audio codec/format.
|
||||||
|
// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats
|
||||||
|
// ffmpeg can produce as output (a smaller set). This function covers all known lossless formats
|
||||||
|
// for transcoding decision purposes.
|
||||||
|
func isLosslessFormat(format string) bool {
|
||||||
|
switch strings.ToLower(format) {
|
||||||
|
case "flac", "alac", "wav", "aiff", "ape", "wv", "tta", "tak", "shn", "dsd", "pcm":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeSourceSampleRate adjusts the source sample rate for codecs that store
|
||||||
|
// it differently than PCM. Currently handles DSD (÷8):
|
||||||
|
// DSD64=2822400→352800, DSD128=5644800→705600, etc.
|
||||||
|
// For other codecs, returns the rate unchanged.
|
||||||
|
func normalizeSourceSampleRate(sampleRate int, codec string) int {
|
||||||
|
if strings.EqualFold(codec, "dsd") && sampleRate > 0 {
|
||||||
|
return sampleRate / 8
|
||||||
|
}
|
||||||
|
return sampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeSourceBitDepth adjusts the source bit depth for codecs that use
|
||||||
|
// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is
|
||||||
|
// what ffmpeg produces). For other codecs, returns the depth unchanged.
|
||||||
|
func normalizeSourceBitDepth(bitDepth int, codec string) int {
|
||||||
|
if strings.EqualFold(codec, "dsd") && bitDepth == 1 {
|
||||||
|
return 24
|
||||||
|
}
|
||||||
|
return bitDepth
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
206
core/transcode/limitations.go
Normal file
206
core/transcode/limitations.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package transcode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adjustResult represents the outcome of applying a limitation to a transcoded stream value
|
||||||
|
type adjustResult int
|
||||||
|
|
||||||
|
const (
|
||||||
|
adjustNone adjustResult = iota // Value already satisfies the limitation
|
||||||
|
adjustAdjusted // Value was changed to fit the limitation
|
||||||
|
adjustCannotFit // Cannot satisfy the limitation (reject this profile)
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkLimitations checks codec profile limitations against source media.
|
||||||
|
// Returns "" if all limitations pass, or a typed reason string for the first failure.
|
||||||
|
func checkLimitations(mf *model.MediaFile, sourceBitrate int, limitations []Limitation) string {
|
||||||
|
for _, lim := range limitations {
|
||||||
|
var ok bool
|
||||||
|
var reason string
|
||||||
|
|
||||||
|
switch lim.Name {
|
||||||
|
case LimitationAudioChannels:
|
||||||
|
ok = checkIntLimitation(mf.Channels, lim.Comparison, lim.Values)
|
||||||
|
reason = "audio channels not supported"
|
||||||
|
case LimitationAudioSamplerate:
|
||||||
|
ok = checkIntLimitation(mf.SampleRate, lim.Comparison, lim.Values)
|
||||||
|
reason = "audio samplerate not supported"
|
||||||
|
case LimitationAudioBitrate:
|
||||||
|
ok = checkIntLimitation(sourceBitrate, lim.Comparison, lim.Values)
|
||||||
|
reason = "audio bitrate not supported"
|
||||||
|
case LimitationAudioBitdepth:
|
||||||
|
ok = checkIntLimitation(mf.BitDepth, lim.Comparison, lim.Values)
|
||||||
|
reason = "audio bitdepth not supported"
|
||||||
|
case LimitationAudioProfile:
|
||||||
|
// TODO: populate source profile when MediaFile has audio profile info
|
||||||
|
ok = checkStringLimitation("", lim.Comparison, lim.Values)
|
||||||
|
reason = "audio profile not supported"
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok && lim.Required {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation.
|
||||||
|
// Returns the adjustment result.
|
||||||
|
func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult {
|
||||||
|
switch lim.Name {
|
||||||
|
case LimitationAudioChannels:
|
||||||
|
return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v })
|
||||||
|
case LimitationAudioBitrate:
|
||||||
|
current := ts.Bitrate
|
||||||
|
if current == 0 {
|
||||||
|
current = sourceBitrate
|
||||||
|
}
|
||||||
|
return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v })
|
||||||
|
case LimitationAudioSamplerate:
|
||||||
|
return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v })
|
||||||
|
case LimitationAudioBitdepth:
|
||||||
|
if ts.BitDepth > 0 {
|
||||||
|
return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v })
|
||||||
|
}
|
||||||
|
case LimitationAudioProfile:
|
||||||
|
// TODO: implement when audio profile data is available
|
||||||
|
}
|
||||||
|
return adjustNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyIntLimitation applies a limitation comparison to a value.
|
||||||
|
// If the value needs adjusting, calls the setter and returns the result.
|
||||||
|
func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return adjustNone
|
||||||
|
}
|
||||||
|
|
||||||
|
switch comparison {
|
||||||
|
case ComparisonLessThanEqual:
|
||||||
|
limit, ok := parseInt(values[0])
|
||||||
|
if !ok {
|
||||||
|
return adjustNone
|
||||||
|
}
|
||||||
|
if current <= limit {
|
||||||
|
return adjustNone
|
||||||
|
}
|
||||||
|
setter(limit)
|
||||||
|
return adjustAdjusted
|
||||||
|
case ComparisonGreaterThanEqual:
|
||||||
|
limit, ok := parseInt(values[0])
|
||||||
|
if !ok {
|
||||||
|
return adjustNone
|
||||||
|
}
|
||||||
|
if current >= limit {
|
||||||
|
return adjustNone
|
||||||
|
}
|
||||||
|
// Cannot upscale
|
||||||
|
return adjustCannotFit
|
||||||
|
case ComparisonEquals:
|
||||||
|
// Check if current value matches any allowed value
|
||||||
|
for _, v := range values {
|
||||||
|
if limit, ok := parseInt(v); ok && current == limit {
|
||||||
|
return adjustNone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Find the closest allowed value below current (don't upscale)
|
||||||
|
var closest int
|
||||||
|
found := false
|
||||||
|
for _, v := range values {
|
||||||
|
if limit, ok := parseInt(v); ok && limit < current {
|
||||||
|
if !found || limit > closest {
|
||||||
|
closest = limit
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
setter(closest)
|
||||||
|
return adjustAdjusted
|
||||||
|
}
|
||||||
|
return adjustCannotFit
|
||||||
|
case ComparisonNotEquals:
|
||||||
|
for _, v := range values {
|
||||||
|
if limit, ok := parseInt(v); ok && current == limit {
|
||||||
|
return adjustCannotFit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return adjustNone
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjustNone
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkIntLimitation(value int, comparison string, values []string) bool {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch comparison {
|
||||||
|
case ComparisonLessThanEqual:
|
||||||
|
limit, ok := parseInt(values[0])
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return value <= limit
|
||||||
|
case ComparisonGreaterThanEqual:
|
||||||
|
limit, ok := parseInt(values[0])
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return value >= limit
|
||||||
|
case ComparisonEquals:
|
||||||
|
for _, v := range values {
|
||||||
|
if limit, ok := parseInt(v); ok && value == limit {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case ComparisonNotEquals:
|
||||||
|
for _, v := range values {
|
||||||
|
if limit, ok := parseInt(v); ok && value == limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkStringLimitation checks a string value against a limitation.
|
||||||
|
// Only Equals and NotEquals comparisons are meaningful for strings.
|
||||||
|
// LessThanEqual/GreaterThanEqual are not applicable and always pass.
|
||||||
|
func checkStringLimitation(value string, comparison string, values []string) bool {
|
||||||
|
switch comparison {
|
||||||
|
case ComparisonEquals:
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.EqualFold(value, v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case ComparisonNotEquals:
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.EqualFold(value, v) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(s string) (int, bool) {
|
||||||
|
v, err := strconv.Atoi(s)
|
||||||
|
if err != nil || v < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
400
core/transcode/transcode.go
Normal file
400
core/transcode/transcode.go
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
package transcode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tokenTTL = 12 * time.Hour
|
||||||
|
defaultBitrate = 256 // kbps
|
||||||
|
|
||||||
|
// JWT claim keys for transcode params tokens
|
||||||
|
claimMediaID = "mid" // Media file ID
|
||||||
|
claimDirectPlay = "dp" // Direct play flag (bool)
|
||||||
|
claimUpdatedAt = "ua" // Source file updated-at (Unix seconds)
|
||||||
|
claimFormat = "fmt" // Target transcoding format
|
||||||
|
claimBitrate = "br" // Target bitrate (kbps)
|
||||||
|
claimChannels = "ch" // Target channels
|
||||||
|
claimSampleRate = "sr" // Target sample rate (Hz)
|
||||||
|
claimBitDepth = "bd" // Target bit depth
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewDecider(ds model.DataStore) Decider {
|
||||||
|
return &deciderService{
|
||||||
|
ds: ds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type deciderService struct {
|
||||||
|
ds model.DataStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) {
|
||||||
|
decision := &Decision{
|
||||||
|
MediaID: mf.ID,
|
||||||
|
SourceUpdatedAt: mf.UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceBitrate := mf.BitRate // kbps
|
||||||
|
|
||||||
|
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", mf.Suffix,
|
||||||
|
"codec", mf.AudioCodec(), "bitrate", sourceBitrate, "channels", mf.Channels,
|
||||||
|
"sampleRate", mf.SampleRate, "lossless", mf.IsLossless(), "client", clientInfo.Name)
|
||||||
|
|
||||||
|
// Build source stream details
|
||||||
|
decision.SourceStream = buildSourceStream(mf)
|
||||||
|
|
||||||
|
// Check global bitrate constraint first.
|
||||||
|
if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate {
|
||||||
|
log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
|
||||||
|
"sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
|
||||||
|
decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported")
|
||||||
|
// Skip direct play profiles entirely — global constraint fails
|
||||||
|
} else {
|
||||||
|
// Try direct play profiles, collecting reasons for each failure
|
||||||
|
for _, profile := range clientInfo.DirectPlayProfiles {
|
||||||
|
if reason := s.checkDirectPlayProfile(mf, sourceBitrate, &profile, clientInfo); reason == "" {
|
||||||
|
decision.CanDirectPlay = true
|
||||||
|
decision.TranscodeReasons = nil // Clear any previously collected reasons
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
decision.TranscodeReasons = append(decision.TranscodeReasons, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If direct play is possible, we're done
|
||||||
|
if decision.CanDirectPlay {
|
||||||
|
log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", mf.Suffix, "codec", mf.AudioCodec())
|
||||||
|
return decision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try transcoding profiles (in order of preference)
|
||||||
|
for _, profile := range clientInfo.TranscodingProfiles {
|
||||||
|
if ts, transcodeFormat := s.computeTranscodedStream(ctx, mf, sourceBitrate, &profile, clientInfo); ts != nil {
|
||||||
|
decision.CanTranscode = true
|
||||||
|
decision.TargetFormat = transcodeFormat
|
||||||
|
decision.TargetBitrate = ts.Bitrate
|
||||||
|
decision.TargetChannels = ts.Channels
|
||||||
|
decision.TargetSampleRate = ts.SampleRate
|
||||||
|
decision.TargetBitDepth = ts.BitDepth
|
||||||
|
decision.TranscodeStream = ts
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if decision.CanTranscode {
|
||||||
|
log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID,
|
||||||
|
"targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate,
|
||||||
|
"targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither direct play nor transcode is possible
|
||||||
|
if !decision.CanDirectPlay && !decision.CanTranscode {
|
||||||
|
decision.ErrorReason = "no compatible playback profile found"
|
||||||
|
log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID,
|
||||||
|
"container", mf.Suffix, "codec", mf.AudioCodec(), "reasons", decision.TranscodeReasons)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSourceStream(mf *model.MediaFile) StreamDetails {
|
||||||
|
return StreamDetails{
|
||||||
|
Container: mf.Suffix,
|
||||||
|
Codec: mf.AudioCodec(),
|
||||||
|
Bitrate: mf.BitRate,
|
||||||
|
SampleRate: mf.SampleRate,
|
||||||
|
BitDepth: mf.BitDepth,
|
||||||
|
Channels: mf.Channels,
|
||||||
|
Duration: mf.Duration,
|
||||||
|
Size: mf.Size,
|
||||||
|
IsLossless: mf.IsLossless(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
|
||||||
|
// or a typed reason string if it doesn't match.
|
||||||
|
func (s *deciderService) checkDirectPlayProfile(mf *model.MediaFile, sourceBitrate int, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
|
||||||
|
// Check protocol (only http for now)
|
||||||
|
if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) {
|
||||||
|
return "protocol not supported"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check container
|
||||||
|
if len(profile.Containers) > 0 && !matchesContainer(mf.Suffix, profile.Containers) {
|
||||||
|
return "container not supported"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check codec
|
||||||
|
if len(profile.AudioCodecs) > 0 && !matchesCodec(mf.AudioCodec(), profile.AudioCodecs) {
|
||||||
|
return "audio codec not supported"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check channels
|
||||||
|
if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels {
|
||||||
|
return "audio channels not supported"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check codec-specific limitations
|
||||||
|
for _, codecProfile := range clientInfo.CodecProfiles {
|
||||||
|
if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(mf.AudioCodec(), []string{codecProfile.Name}) {
|
||||||
|
if reason := checkLimitations(mf, sourceBitrate, codecProfile.Limitations); reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeTranscodedStream attempts to build a valid transcoded stream for the given profile.
|
||||||
|
// 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, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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: normalizeSourceSampleRate(mf.SampleRate, mf.AudioCodec()),
|
||||||
|
Channels: mf.Channels,
|
||||||
|
BitDepth: normalizeSourceBitDepth(mf.BitDepth, mf.AudioCodec()),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try the container first, then fall back to the audioCodec (e.g. "ogg" → "opus", "mp4" → "aac").
|
||||||
|
_, err := s.ds.Transcoding(ctx).FindByFormat(targetFormat)
|
||||||
|
if errors.Is(err, model.ErrNotFound) && profile.AudioCodec != "" && !strings.EqualFold(targetFormat, profile.AudioCodec) {
|
||||||
|
codec := strings.ToLower(profile.AudioCodec)
|
||||||
|
log.Trace(ctx, "No transcoding config for container, trying audioCodec", "container", targetFormat, "audioCodec", codec)
|
||||||
|
_, err = s.ds.Transcoding(ctx).FindByFormat(codec)
|
||||||
|
if err == nil {
|
||||||
|
targetFormat = codec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, model.ErrNotFound) {
|
||||||
|
log.Error(ctx, "Error looking up transcoding config", "format", targetFormat, err)
|
||||||
|
} else {
|
||||||
|
log.Trace(ctx, "Skipping transcoding profile: no transcoding config", "targetFormat", targetFormat)
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return responseContainer, targetFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if clientInfo.MaxTranscodingAudioBitrate > 0 {
|
||||||
|
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
|
||||||
|
} else {
|
||||||
|
ts.Bitrate = defaultBitrate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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 false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ts.Bitrate = sourceBitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply maxAudioBitrate as final cap
|
||||||
|
if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate {
|
||||||
|
ts.Bitrate = clientInfo.MaxAudioBitrate
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchesCodec(targetCodec, []string{codecProfile.Name}) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, lim := range codecProfile.Limitations {
|
||||||
|
result := applyLimitation(sourceBitrate, &lim, ts)
|
||||||
|
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 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 false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) {
|
||||||
|
exp := time.Now().Add(tokenTTL)
|
||||||
|
claims := map[string]any{
|
||||||
|
claimMediaID: decision.MediaID,
|
||||||
|
claimDirectPlay: decision.CanDirectPlay,
|
||||||
|
claimUpdatedAt: decision.SourceUpdatedAt.Truncate(time.Second).Unix(),
|
||||||
|
}
|
||||||
|
if decision.CanTranscode && decision.TargetFormat != "" {
|
||||||
|
claims[claimFormat] = decision.TargetFormat
|
||||||
|
claims[claimBitrate] = decision.TargetBitrate
|
||||||
|
if decision.TargetChannels > 0 {
|
||||||
|
claims[claimChannels] = decision.TargetChannels
|
||||||
|
}
|
||||||
|
if decision.TargetSampleRate > 0 {
|
||||||
|
claims[claimSampleRate] = decision.TargetSampleRate
|
||||||
|
}
|
||||||
|
if decision.TargetBitDepth > 0 {
|
||||||
|
claims[claimBitDepth] = decision.TargetBitDepth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return auth.CreateExpiringPublicToken(exp, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *deciderService) ParseTranscodeParams(token string) (*Params, error) {
|
||||||
|
claims, err := auth.Validate(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &Params{}
|
||||||
|
|
||||||
|
// Required claims
|
||||||
|
mid, ok := claims[claimMediaID].(string)
|
||||||
|
if !ok || mid == "" {
|
||||||
|
return nil, fmt.Errorf("%w: invalid transcode token: missing media ID", ErrTokenInvalid)
|
||||||
|
}
|
||||||
|
params.MediaID = mid
|
||||||
|
|
||||||
|
dp, ok := claims[claimDirectPlay].(bool)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: invalid transcode token: missing direct play flag", ErrTokenInvalid)
|
||||||
|
}
|
||||||
|
params.DirectPlay = dp
|
||||||
|
|
||||||
|
// Optional claims (legitimately absent for direct-play tokens)
|
||||||
|
if f, ok := claims[claimFormat].(string); ok {
|
||||||
|
params.TargetFormat = f
|
||||||
|
}
|
||||||
|
if br, ok := claims[claimBitrate].(float64); ok {
|
||||||
|
params.TargetBitrate = int(br)
|
||||||
|
}
|
||||||
|
if ch, ok := claims[claimChannels].(float64); ok {
|
||||||
|
params.TargetChannels = int(ch)
|
||||||
|
}
|
||||||
|
if sr, ok := claims[claimSampleRate].(float64); ok {
|
||||||
|
params.TargetSampleRate = int(sr)
|
||||||
|
}
|
||||||
|
if bd, ok := claims[claimBitDepth].(float64); ok {
|
||||||
|
params.TargetBitDepth = int(bd)
|
||||||
|
}
|
||||||
|
|
||||||
|
ua, ok := claims[claimUpdatedAt].(float64)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: invalid transcode token: missing source timestamp", ErrTokenInvalid)
|
||||||
|
}
|
||||||
|
params.SourceUpdatedAt = time.Unix(int64(ua), 0)
|
||||||
|
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *deciderService) ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error) {
|
||||||
|
params, err := s.ParseTranscodeParams(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Join(ErrTokenInvalid, err)
|
||||||
|
}
|
||||||
|
if params.MediaID != mediaID {
|
||||||
|
return nil, nil, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, params.MediaID, mediaID)
|
||||||
|
}
|
||||||
|
mf, err := s.ds.MediaFile(ctx).Get(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return nil, nil, ErrMediaNotFound
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if !mf.UpdatedAt.Truncate(time.Second).Equal(params.SourceUpdatedAt) {
|
||||||
|
log.Info(ctx, "Transcode token is stale", "mediaID", mediaID,
|
||||||
|
"tokenUpdatedAt", params.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt)
|
||||||
|
return nil, nil, ErrTokenStale
|
||||||
|
}
|
||||||
|
return params, mf, nil
|
||||||
|
}
|
||||||
17
core/transcode/transcode_suite_test.go
Normal file
17
core/transcode/transcode_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package transcode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTranscode(t *testing.T) {
|
||||||
|
tests.Init(t, false)
|
||||||
|
log.SetLevel(log.LevelFatal)
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "Transcode Suite")
|
||||||
|
}
|
||||||
1073
core/transcode/transcode_test.go
Normal file
1073
core/transcode/transcode_test.go
Normal file
File diff suppressed because it is too large
Load Diff
140
core/transcode/types.go
Normal file
140
core/transcode/types.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package transcode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTokenInvalid = errors.New("invalid or expired transcode token")
|
||||||
|
ErrMediaNotFound = errors.New("media file not found")
|
||||||
|
ErrTokenStale = errors.New("transcode token is stale: media file has changed")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decider is the core service interface for making transcoding decisions
|
||||||
|
type Decider interface {
|
||||||
|
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error)
|
||||||
|
CreateTranscodeParams(decision *Decision) (string, error)
|
||||||
|
ParseTranscodeParams(token string) (*Params, error)
|
||||||
|
ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientInfo represents client playback capabilities.
|
||||||
|
// All bitrate values are in kilobits per second (kbps)
|
||||||
|
type ClientInfo struct {
|
||||||
|
Name string
|
||||||
|
Platform string
|
||||||
|
MaxAudioBitrate int
|
||||||
|
MaxTranscodingAudioBitrate int
|
||||||
|
DirectPlayProfiles []DirectPlayProfile
|
||||||
|
TranscodingProfiles []Profile
|
||||||
|
CodecProfiles []CodecProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectPlayProfile describes a format the client can play directly
|
||||||
|
type DirectPlayProfile struct {
|
||||||
|
Containers []string
|
||||||
|
AudioCodecs []string
|
||||||
|
Protocols []string
|
||||||
|
MaxAudioChannels int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile describes a transcoding target the client supports
|
||||||
|
type Profile struct {
|
||||||
|
Container string
|
||||||
|
AudioCodec string
|
||||||
|
Protocol string
|
||||||
|
MaxAudioChannels int
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodecProfile describes codec-specific limitations
|
||||||
|
type CodecProfile struct {
|
||||||
|
Type string
|
||||||
|
Name string
|
||||||
|
Limitations []Limitation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limitation describes a specific codec limitation
|
||||||
|
type Limitation struct {
|
||||||
|
Name string
|
||||||
|
Comparison string
|
||||||
|
Values []string
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protocol values (OpenSubsonic spec enum)
|
||||||
|
const (
|
||||||
|
ProtocolHTTP = "http"
|
||||||
|
ProtocolHLS = "hls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Comparison operators (OpenSubsonic spec enum)
|
||||||
|
const (
|
||||||
|
ComparisonEquals = "Equals"
|
||||||
|
ComparisonNotEquals = "NotEquals"
|
||||||
|
ComparisonLessThanEqual = "LessThanEqual"
|
||||||
|
ComparisonGreaterThanEqual = "GreaterThanEqual"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Limitation names (OpenSubsonic spec enum)
|
||||||
|
const (
|
||||||
|
LimitationAudioChannels = "audioChannels"
|
||||||
|
LimitationAudioBitrate = "audioBitrate"
|
||||||
|
LimitationAudioProfile = "audioProfile"
|
||||||
|
LimitationAudioSamplerate = "audioSamplerate"
|
||||||
|
LimitationAudioBitdepth = "audioBitdepth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Codec profile types (OpenSubsonic spec enum)
|
||||||
|
const (
|
||||||
|
CodecProfileTypeAudio = "AudioCodec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decision represents the internal decision result.
|
||||||
|
// All bitrate values are in kilobits per second (kbps).
|
||||||
|
type Decision struct {
|
||||||
|
MediaID string
|
||||||
|
CanDirectPlay bool
|
||||||
|
CanTranscode bool
|
||||||
|
TranscodeReasons []string
|
||||||
|
ErrorReason string
|
||||||
|
TargetFormat string
|
||||||
|
TargetBitrate int
|
||||||
|
TargetChannels int
|
||||||
|
TargetSampleRate int
|
||||||
|
TargetBitDepth int
|
||||||
|
SourceStream StreamDetails
|
||||||
|
SourceUpdatedAt time.Time
|
||||||
|
TranscodeStream *StreamDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamDetails describes audio stream properties.
|
||||||
|
// Bitrate is in kilobits per second (kbps).
|
||||||
|
type StreamDetails struct {
|
||||||
|
Container string
|
||||||
|
Codec string
|
||||||
|
Profile string // Audio profile (e.g., "LC", "HE-AAC"). Empty until scanner support is added.
|
||||||
|
Bitrate int
|
||||||
|
SampleRate int
|
||||||
|
BitDepth int
|
||||||
|
Channels int
|
||||||
|
Duration float32
|
||||||
|
Size int64
|
||||||
|
IsLossless bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
TargetSampleRate int
|
||||||
|
TargetBitDepth int
|
||||||
|
SourceUpdatedAt time.Time
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
|
"github.com/navidrome/navidrome/core/transcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Set = wire.NewSet(
|
var Set = wire.NewSet(
|
||||||
@@ -20,6 +21,7 @@ var Set = wire.NewSet(
|
|||||||
NewLibrary,
|
NewLibrary,
|
||||||
NewUser,
|
NewUser,
|
||||||
NewMaintenance,
|
NewMaintenance,
|
||||||
|
transcode.NewDecider,
|
||||||
agents.GetAgents,
|
agents.GetAgents,
|
||||||
external.NewProvider,
|
external.NewProvider,
|
||||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/model/id"
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||||
|
// Add codec column to media_file.
|
||||||
|
_, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update old AAC default (adts) to new default (ipod with fragmented MP4).
|
||||||
|
// Only affects users who still have the unmodified old default command.
|
||||||
|
_, err = tx.Exec(
|
||||||
|
`UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`,
|
||||||
|
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
|
||||||
|
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add FLAC transcoding for existing installations that were seeded before FLAC was added.
|
||||||
|
var count int
|
||||||
|
err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
"INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
id.NewRandom(), "flac audio", "flac", 0,
|
||||||
|
"ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||||
|
_, err := tx.Exec(`DROP INDEX IF EXISTS media_file_codec`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`ALTER TABLE media_file DROP COLUMN codec`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gohugoio/hashstructure"
|
"github.com/gohugoio/hashstructure"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
confmime "github.com/navidrome/navidrome/conf/mime"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
@@ -56,6 +57,7 @@ type MediaFile struct {
|
|||||||
SampleRate int `structs:"sample_rate" json:"sampleRate"`
|
SampleRate int `structs:"sample_rate" json:"sampleRate"`
|
||||||
BitDepth int `structs:"bit_depth" json:"bitDepth"`
|
BitDepth int `structs:"bit_depth" json:"bitDepth"`
|
||||||
Channels int `structs:"channels" json:"channels"`
|
Channels int `structs:"channels" json:"channels"`
|
||||||
|
Codec string `structs:"codec" json:"codec"`
|
||||||
Genre string `structs:"genre" json:"genre"`
|
Genre string `structs:"genre" json:"genre"`
|
||||||
Genres Genres `structs:"-" json:"genres,omitempty"`
|
Genres Genres `structs:"-" json:"genres,omitempty"`
|
||||||
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
|
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
|
||||||
@@ -161,6 +163,81 @@ func (mf MediaFile) AbsolutePath() string {
|
|||||||
return filepath.Join(mf.LibraryPath, mf.Path)
|
return filepath.Join(mf.LibraryPath, mf.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AudioCodec returns the audio codec for this file.
|
||||||
|
// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties.
|
||||||
|
func (mf MediaFile) AudioCodec() string {
|
||||||
|
// If we have a stored codec from scanning, normalize and return it
|
||||||
|
if mf.Codec != "" {
|
||||||
|
return strings.ToLower(mf.Codec)
|
||||||
|
}
|
||||||
|
// Fallback: infer from Suffix + BitDepth
|
||||||
|
return mf.inferCodecFromSuffix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty.
|
||||||
|
func (mf MediaFile) inferCodecFromSuffix() string {
|
||||||
|
switch strings.ToLower(mf.Suffix) {
|
||||||
|
case "mp3", "mpga":
|
||||||
|
return "mp3"
|
||||||
|
case "mp2":
|
||||||
|
return "mp2"
|
||||||
|
case "ogg", "oga":
|
||||||
|
return "vorbis"
|
||||||
|
case "opus":
|
||||||
|
return "opus"
|
||||||
|
case "mpc":
|
||||||
|
return "mpc"
|
||||||
|
case "wma":
|
||||||
|
return "wma"
|
||||||
|
case "flac":
|
||||||
|
return "flac"
|
||||||
|
case "wav":
|
||||||
|
return "pcm"
|
||||||
|
case "aif", "aiff", "aifc":
|
||||||
|
return "pcm"
|
||||||
|
case "ape":
|
||||||
|
return "ape"
|
||||||
|
case "wv", "wvp":
|
||||||
|
return "wv"
|
||||||
|
case "tta":
|
||||||
|
return "tta"
|
||||||
|
case "tak":
|
||||||
|
return "tak"
|
||||||
|
case "shn":
|
||||||
|
return "shn"
|
||||||
|
case "dsf", "dff":
|
||||||
|
return "dsd"
|
||||||
|
case "m4a":
|
||||||
|
// AAC if BitDepth==0, ALAC if BitDepth>0
|
||||||
|
if mf.BitDepth > 0 {
|
||||||
|
return "alac"
|
||||||
|
}
|
||||||
|
return "aac"
|
||||||
|
case "m4b", "m4p", "m4r":
|
||||||
|
return "aac"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLossless returns true if this file uses a lossless codec.
|
||||||
|
func (mf MediaFile) IsLossless() bool {
|
||||||
|
codec := mf.AudioCodec()
|
||||||
|
// Primary: codec-based check (most accurate for containers like M4A)
|
||||||
|
switch codec {
|
||||||
|
case "flac", "alac", "pcm", "ape", "wv", "tta", "tak", "shn", "dsd":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Secondary: suffix-based check using configurable list from YAML
|
||||||
|
if slices.Contains(confmime.LosslessFormats, mf.Suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Fallback heuristic: if BitDepth is set, it's likely lossless.
|
||||||
|
// This may produce false positives for lossy formats that report bit depth,
|
||||||
|
// but it becomes irrelevant once the Codec column is populated after a full rescan.
|
||||||
|
return mf.BitDepth > 0
|
||||||
|
}
|
||||||
|
|
||||||
type MediaFiles []MediaFile
|
type MediaFiles []MediaFile
|
||||||
|
|
||||||
// ToAlbum creates an Album object based on the attributes of this MediaFiles collection.
|
// ToAlbum creates an Album object based on the attributes of this MediaFiles collection.
|
||||||
|
|||||||
@@ -475,7 +475,7 @@ var _ = Describe("MediaFile", func() {
|
|||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
conf.Server.EnableMediaFileCoverArt = true
|
conf.Server.EnableMediaFileCoverArt = true
|
||||||
})
|
})
|
||||||
Describe(".CoverArtId()", func() {
|
Describe("CoverArtId", func() {
|
||||||
It("returns its own id if it HasCoverArt", func() {
|
It("returns its own id if it HasCoverArt", func() {
|
||||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||||
id := mf.CoverArtID()
|
id := mf.CoverArtID()
|
||||||
@@ -496,6 +496,94 @@ var _ = Describe("MediaFile", func() {
|
|||||||
Expect(id.ID).To(Equal(mf.AlbumID))
|
Expect(id.ID).To(Equal(mf.AlbumID))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("AudioCodec", func() {
|
||||||
|
It("returns normalized stored codec when available", func() {
|
||||||
|
mf := MediaFile{Codec: "AAC", Suffix: "m4a"}
|
||||||
|
Expect(mf.AudioCodec()).To(Equal("aac"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns stored codec lowercased", func() {
|
||||||
|
mf := MediaFile{Codec: "ALAC", Suffix: "m4a"}
|
||||||
|
Expect(mf.AudioCodec()).To(Equal("alac"))
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("infers codec from suffix when Codec field is empty",
|
||||||
|
func(suffix string, bitDepth int, expected string) {
|
||||||
|
mf := MediaFile{Suffix: suffix, BitDepth: bitDepth}
|
||||||
|
Expect(mf.AudioCodec()).To(Equal(expected))
|
||||||
|
},
|
||||||
|
Entry("mp3", "mp3", 0, "mp3"),
|
||||||
|
Entry("mpga", "mpga", 0, "mp3"),
|
||||||
|
Entry("mp2", "mp2", 0, "mp2"),
|
||||||
|
Entry("ogg", "ogg", 0, "vorbis"),
|
||||||
|
Entry("oga", "oga", 0, "vorbis"),
|
||||||
|
Entry("opus", "opus", 0, "opus"),
|
||||||
|
Entry("mpc", "mpc", 0, "mpc"),
|
||||||
|
Entry("wma", "wma", 0, "wma"),
|
||||||
|
Entry("flac", "flac", 0, "flac"),
|
||||||
|
Entry("wav", "wav", 0, "pcm"),
|
||||||
|
Entry("aif", "aif", 0, "pcm"),
|
||||||
|
Entry("aiff", "aiff", 0, "pcm"),
|
||||||
|
Entry("aifc", "aifc", 0, "pcm"),
|
||||||
|
Entry("ape", "ape", 0, "ape"),
|
||||||
|
Entry("wv", "wv", 0, "wv"),
|
||||||
|
Entry("wvp", "wvp", 0, "wv"),
|
||||||
|
Entry("tta", "tta", 0, "tta"),
|
||||||
|
Entry("tak", "tak", 0, "tak"),
|
||||||
|
Entry("shn", "shn", 0, "shn"),
|
||||||
|
Entry("dsf", "dsf", 0, "dsd"),
|
||||||
|
Entry("dff", "dff", 0, "dsd"),
|
||||||
|
Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"),
|
||||||
|
Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"),
|
||||||
|
Entry("m4b", "m4b", 0, "aac"),
|
||||||
|
Entry("m4p", "m4p", 0, "aac"),
|
||||||
|
Entry("m4r", "m4r", 0, "aac"),
|
||||||
|
Entry("unknown suffix", "xyz", 0, ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("prefers stored codec over suffix inference", func() {
|
||||||
|
mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0}
|
||||||
|
Expect(mf.AudioCodec()).To(Equal("alac"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("IsLossless", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("detects lossless codecs",
|
||||||
|
func(codec string, suffix string, bitDepth int, expected bool) {
|
||||||
|
mf := MediaFile{Codec: codec, Suffix: suffix, BitDepth: bitDepth}
|
||||||
|
Expect(mf.IsLossless()).To(Equal(expected))
|
||||||
|
},
|
||||||
|
Entry("flac", "FLAC", "flac", 16, true),
|
||||||
|
Entry("alac", "ALAC", "m4a", 24, true),
|
||||||
|
Entry("pcm via wav", "", "wav", 16, true),
|
||||||
|
Entry("pcm via aiff", "", "aiff", 24, true),
|
||||||
|
Entry("ape", "", "ape", 16, true),
|
||||||
|
Entry("wv", "", "wv", 0, true),
|
||||||
|
Entry("tta", "", "tta", 0, true),
|
||||||
|
Entry("tak", "", "tak", 0, true),
|
||||||
|
Entry("shn", "", "shn", 0, true),
|
||||||
|
Entry("dsd", "", "dsf", 0, true),
|
||||||
|
Entry("mp3 is lossy", "MP3", "mp3", 0, false),
|
||||||
|
Entry("aac is lossy", "AAC", "m4a", 0, false),
|
||||||
|
Entry("vorbis is lossy", "", "ogg", 0, false),
|
||||||
|
Entry("opus is lossy", "", "opus", 0, false),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("detects lossless via BitDepth fallback when codec is unknown", func() {
|
||||||
|
mf := MediaFile{Suffix: "xyz", BitDepth: 24}
|
||||||
|
Expect(mf.IsLossless()).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns false for unknown with no BitDepth", func() {
|
||||||
|
mf := MediaFile{Suffix: "xyz", BitDepth: 0}
|
||||||
|
Expect(mf.IsLossless()).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
func t(v string) time.Time {
|
func t(v string) time.Time {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
|||||||
mf.SampleRate = md.AudioProperties().SampleRate
|
mf.SampleRate = md.AudioProperties().SampleRate
|
||||||
mf.BitDepth = md.AudioProperties().BitDepth
|
mf.BitDepth = md.AudioProperties().BitDepth
|
||||||
mf.Channels = md.AudioProperties().Channels
|
mf.Channels = md.AudioProperties().Channels
|
||||||
|
mf.Codec = md.AudioProperties().Codec
|
||||||
mf.Path = md.FilePath()
|
mf.Path = md.FilePath()
|
||||||
mf.Suffix = md.Suffix()
|
mf.Suffix = md.Suffix()
|
||||||
mf.Size = md.Size()
|
mf.Size = md.Size()
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type AudioProperties struct {
|
|||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
Channels int
|
Channels int
|
||||||
|
Codec string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Date string
|
type Date string
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||||
|
"github.com/navidrome/navidrome/core/transcode"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@@ -189,14 +190,33 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool
|
|||||||
// noopStreamer implements core.MediaStreamer
|
// noopStreamer implements core.MediaStreamer
|
||||||
type noopStreamer struct{}
|
type noopStreamer struct{}
|
||||||
|
|
||||||
func (n noopStreamer) NewStream(context.Context, string, string, int, int) (*core.Stream, error) {
|
func (n noopStreamer) NewStream(context.Context, core.StreamRequest) (*core.Stream, error) {
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n noopStreamer) DoStream(context.Context, *model.MediaFile, string, int, int) (*core.Stream, error) {
|
func (n noopStreamer) DoStream(context.Context, *model.MediaFile, core.StreamRequest) (*core.Stream, error) {
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noopDecider implements transcode.Decider
|
||||||
|
type noopDecider struct{}
|
||||||
|
|
||||||
|
func (n noopDecider) MakeDecision(context.Context, *model.MediaFile, *transcode.ClientInfo) (*transcode.Decision, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopDecider) CreateTranscodeParams(*transcode.Decision) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopDecider) ParseTranscodeParams(string) (*transcode.Params, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopDecider) ValidateTranscodeParams(context.Context, string, string) (*transcode.Params, *model.MediaFile, error) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// noopArchiver implements core.Archiver
|
// noopArchiver implements core.Archiver
|
||||||
type noopArchiver struct{}
|
type noopArchiver struct{}
|
||||||
|
|
||||||
@@ -265,6 +285,7 @@ var (
|
|||||||
_ core.Archiver = noopArchiver{}
|
_ core.Archiver = noopArchiver{}
|
||||||
_ external.Provider = noopProvider{}
|
_ external.Provider = noopProvider{}
|
||||||
_ scrobbler.PlayTracker = noopPlayTracker{}
|
_ scrobbler.PlayTracker = noopPlayTracker{}
|
||||||
|
_ transcode.Decider = noopDecider{}
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = BeforeSuite(func() {
|
var _ = BeforeSuite(func() {
|
||||||
@@ -344,6 +365,7 @@ func setupTestDB() {
|
|||||||
core.NewShare(ds),
|
core.NewShare(ds),
|
||||||
playback.PlaybackServer(nil),
|
playback.PlaybackServer(nil),
|
||||||
metrics.NewNoopInstance(),
|
metrics.NewNoopInstance(),
|
||||||
|
noopDecider{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/auth"
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/utils/req"
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
@@ -24,10 +25,13 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stream, err := pub.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 0)
|
stream, err := pub.streamer.NewStream(ctx, core.StreamRequest{
|
||||||
|
ID: info.id, Format: info.format, BitRate: info.bitrate,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error starting shared stream", err)
|
log.Error(ctx, "Error starting shared stream", err)
|
||||||
http.Error(w, "invalid request", http.StatusInternalServerError)
|
http.Error(w, "invalid request", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure the stream will be closed at the end, to avoid leakage
|
// Make sure the stream will be closed at the end, to avoid leakage
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() {
|
|||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
|
"github.com/navidrome/navidrome/core/transcode"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
@@ -47,12 +48,13 @@ type Router struct {
|
|||||||
share core.Share
|
share core.Share
|
||||||
playback playback.PlaybackServer
|
playback playback.PlaybackServer
|
||||||
metrics metrics.Metrics
|
metrics metrics.Metrics
|
||||||
|
transcodeDecision transcode.Decider
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
||||||
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
|
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
|
||||||
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
||||||
metrics metrics.Metrics,
|
metrics metrics.Metrics, transcodeDecision transcode.Decider,
|
||||||
) *Router {
|
) *Router {
|
||||||
r := &Router{
|
r := &Router{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
@@ -68,6 +70,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreame
|
|||||||
share: share,
|
share: share,
|
||||||
playback: playback,
|
playback: playback,
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
|
transcodeDecision: transcodeDecision,
|
||||||
}
|
}
|
||||||
r.Handler = r.routes()
|
r.Handler = r.routes()
|
||||||
return r
|
return r
|
||||||
@@ -172,6 +175,8 @@ func (api *Router) routes() http.Handler {
|
|||||||
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
|
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
|
||||||
hr(r, "stream", api.Stream)
|
hr(r, "stream", api.Stream)
|
||||||
hr(r, "download", api.Download)
|
hr(r, "download", api.Download)
|
||||||
|
hr(r, "getTranscodeDecision", api.GetTranscodeDecision)
|
||||||
|
hr(r, "getTranscodeStream", api.GetTranscodeStream)
|
||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
// configure request throttling
|
// configure request throttling
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() {
|
|||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
playTracker = &fakePlayTracker{}
|
playTracker = &fakePlayTracker{}
|
||||||
eventBroker = &fakeEventBroker{}
|
eventBroker = &fakeEventBroker{}
|
||||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil)
|
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil)
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Scrobble", func() {
|
Describe("Scrobble", func() {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||||||
MockedMediaFile: mockRepo,
|
MockedMediaFile: mockRepo,
|
||||||
}
|
}
|
||||||
artwork = &fakeArtwork{data: "image data"}
|
artwork = &fakeArtwork{data: "image data"}
|
||||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
conf.Server.LyricsPriority = "embedded,.lrc"
|
conf.Server.LyricsPriority = "embedded,.lrc"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
|
|||||||
{Name: "formPost", Versions: []int32{1}},
|
{Name: "formPost", Versions: []int32{1}},
|
||||||
{Name: "songLyrics", Versions: []int32{1}},
|
{Name: "songLyrics", Versions: []int32{1}},
|
||||||
{Name: "indexBasedQueue", Versions: []int32{1}},
|
{Name: "indexBasedQueue", Versions: []int32{1}},
|
||||||
|
{Name: "transcoding", Versions: []int32{1}},
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
|
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
|
||||||
})
|
})
|
||||||
@@ -35,11 +35,12 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
|||||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||||
HaveLen(4),
|
HaveLen(5),
|
||||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
||||||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||||
|
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() {
|
|||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ var _ = Describe("UpdatePlaylist", func() {
|
|||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
playlists = &fakePlaylists{}
|
playlists = &fakePlaylists{}
|
||||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil)
|
||||||
})
|
})
|
||||||
|
|
||||||
It("clears the comment when parameter is empty", func() {
|
It("clears the comment when parameter is empty", func() {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type Subsonic struct {
|
|||||||
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
||||||
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||||
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
|
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
|
||||||
|
TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -617,3 +618,26 @@ func marshalJSONArray[T any](v []T) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return json.Marshal(v)
|
return json.Marshal(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TranscodeDecision represents the response for getTranscodeDecision (OpenSubsonic transcoding extension)
|
||||||
|
type TranscodeDecision struct {
|
||||||
|
CanDirectPlay bool `xml:"canDirectPlay,attr" json:"canDirectPlay"`
|
||||||
|
CanTranscode bool `xml:"canTranscode,attr" json:"canTranscode"`
|
||||||
|
TranscodeReasons []string `xml:"transcodeReason,omitempty" json:"transcodeReason,omitempty"`
|
||||||
|
ErrorReason string `xml:"errorReason,attr,omitempty" json:"errorReason,omitempty"`
|
||||||
|
TranscodeParams string `xml:"transcodeParams,attr,omitempty" json:"transcodeParams,omitempty"`
|
||||||
|
SourceStream *StreamDetails `xml:"sourceStream,omitempty" json:"sourceStream,omitempty"`
|
||||||
|
TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamDetails describes audio stream properties for transcoding decisions
|
||||||
|
type StreamDetails struct {
|
||||||
|
Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"`
|
||||||
|
Container string `xml:"container,attr,omitempty" json:"container,omitempty"`
|
||||||
|
Codec string `xml:"codec,attr,omitempty" json:"codec,omitempty"`
|
||||||
|
AudioChannels int32 `xml:"audioChannels,attr,omitempty" json:"audioChannels,omitempty"`
|
||||||
|
AudioBitrate int32 `xml:"audioBitrate,attr,omitempty" json:"audioBitrate,omitempty"`
|
||||||
|
AudioProfile string `xml:"audioProfile,attr,omitempty" json:"audioProfile,omitempty"`
|
||||||
|
AudioSamplerate int32 `xml:"audioSamplerate,attr,omitempty" json:"audioSamplerate,omitempty"`
|
||||||
|
AudioBitdepth int32 `xml:"audioBitdepth,attr,omitempty" json:"audioBitdepth,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ var _ = Describe("Search", func() {
|
|||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
|
|
||||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
// Get references to the mock repositories so we can inspect their Options
|
// Get references to the mock repositories so we can inspect their Options
|
||||||
mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)
|
mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su
|
|||||||
format, _ := p.String("format")
|
format, _ := p.String("format")
|
||||||
timeOffset := p.IntOr("timeOffset", 0)
|
timeOffset := p.IntOr("timeOffset", 0)
|
||||||
|
|
||||||
stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, timeOffset)
|
stream, err := api.streamer.NewStream(ctx, core.StreamRequest{
|
||||||
|
ID: id, Format: format, BitRate: maxBitRate, Offset: timeOffset,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -129,7 +131,9 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.
|
|||||||
|
|
||||||
switch v := entity.(type) {
|
switch v := entity.(type) {
|
||||||
case *model.MediaFile:
|
case *model.MediaFile:
|
||||||
stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, 0)
|
stream, err := api.streamer.NewStream(ctx, core.StreamRequest{
|
||||||
|
ID: id, Format: format, BitRate: maxBitRate,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
383
server/subsonic/transcode.go
Normal file
383
server/subsonic/transcode.go
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/core/transcode"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||||
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
|
)
|
||||||
|
|
||||||
|
// API-layer request structs for JSON unmarshaling (decoupled from core structs)
|
||||||
|
|
||||||
|
// clientInfoRequest represents client playback capabilities from the request body
|
||||||
|
type clientInfoRequest struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
MaxAudioBitrate int `json:"maxAudioBitrate,omitempty"`
|
||||||
|
MaxTranscodingAudioBitrate int `json:"maxTranscodingAudioBitrate,omitempty"`
|
||||||
|
DirectPlayProfiles []directPlayProfileRequest `json:"directPlayProfiles,omitempty"`
|
||||||
|
TranscodingProfiles []transcodingProfileRequest `json:"transcodingProfiles,omitempty"`
|
||||||
|
CodecProfiles []codecProfileRequest `json:"codecProfiles,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// directPlayProfileRequest describes a format the client can play directly
|
||||||
|
type directPlayProfileRequest struct {
|
||||||
|
Containers []string `json:"containers,omitempty"`
|
||||||
|
AudioCodecs []string `json:"audioCodecs,omitempty"`
|
||||||
|
Protocols []string `json:"protocols,omitempty"`
|
||||||
|
MaxAudioChannels int `json:"maxAudioChannels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// transcodingProfileRequest describes a transcoding target the client supports
|
||||||
|
type transcodingProfileRequest struct {
|
||||||
|
Container string `json:"container,omitempty"`
|
||||||
|
AudioCodec string `json:"audioCodec,omitempty"`
|
||||||
|
Protocol string `json:"protocol,omitempty"`
|
||||||
|
MaxAudioChannels int `json:"maxAudioChannels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// codecProfileRequest describes codec-specific limitations
|
||||||
|
type codecProfileRequest struct {
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Limitations []limitationRequest `json:"limitations,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitationRequest describes a specific codec limitation
|
||||||
|
type limitationRequest struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Comparison string `json:"comparison,omitempty"`
|
||||||
|
Values []string `json:"values,omitempty"`
|
||||||
|
Required bool `json:"required,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toCoreClientInfo converts the API request struct to the transcode.ClientInfo struct.
|
||||||
|
// The OpenSubsonic spec uses bps for bitrate values; core uses kbps.
|
||||||
|
func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo {
|
||||||
|
ci := &transcode.ClientInfo{
|
||||||
|
Name: r.Name,
|
||||||
|
Platform: r.Platform,
|
||||||
|
MaxAudioBitrate: bpsToKbps(r.MaxAudioBitrate),
|
||||||
|
MaxTranscodingAudioBitrate: bpsToKbps(r.MaxTranscodingAudioBitrate),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dp := range r.DirectPlayProfiles {
|
||||||
|
ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, transcode.DirectPlayProfile{
|
||||||
|
Containers: dp.Containers,
|
||||||
|
AudioCodecs: dp.AudioCodecs,
|
||||||
|
Protocols: dp.Protocols,
|
||||||
|
MaxAudioChannels: dp.MaxAudioChannels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tp := range r.TranscodingProfiles {
|
||||||
|
ci.TranscodingProfiles = append(ci.TranscodingProfiles, transcode.Profile{
|
||||||
|
Container: tp.Container,
|
||||||
|
AudioCodec: tp.AudioCodec,
|
||||||
|
Protocol: tp.Protocol,
|
||||||
|
MaxAudioChannels: tp.MaxAudioChannels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cp := range r.CodecProfiles {
|
||||||
|
coreCP := transcode.CodecProfile{
|
||||||
|
Type: cp.Type,
|
||||||
|
Name: cp.Name,
|
||||||
|
}
|
||||||
|
for _, lim := range cp.Limitations {
|
||||||
|
coreLim := transcode.Limitation{
|
||||||
|
Name: lim.Name,
|
||||||
|
Comparison: lim.Comparison,
|
||||||
|
Values: lim.Values,
|
||||||
|
Required: lim.Required,
|
||||||
|
}
|
||||||
|
// Convert audioBitrate limitation values from bps to kbps
|
||||||
|
if lim.Name == transcode.LimitationAudioBitrate {
|
||||||
|
coreLim.Values = convertBitrateValues(lim.Values)
|
||||||
|
}
|
||||||
|
coreCP.Limitations = append(coreCP.Limitations, coreLim)
|
||||||
|
}
|
||||||
|
ci.CodecProfiles = append(ci.CodecProfiles, coreCP)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ci
|
||||||
|
}
|
||||||
|
|
||||||
|
// bpsToKbps converts bits per second to kilobits per second (rounded).
|
||||||
|
func bpsToKbps(bps int) int {
|
||||||
|
return (bps + 500) / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// kbpsToBps converts kilobits per second to bits per second.
|
||||||
|
func kbpsToBps(kbps int) int {
|
||||||
|
return kbps * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertBitrateValues converts a slice of bps string values to kbps string values.
|
||||||
|
func convertBitrateValues(bpsValues []string) []string {
|
||||||
|
result := make([]string, len(bpsValues))
|
||||||
|
for i, v := range bpsValues {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err == nil {
|
||||||
|
result[i] = strconv.Itoa(bpsToKbps(n))
|
||||||
|
} else {
|
||||||
|
result[i] = v // preserve unparseable values as-is
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate checks that all enum fields in the request contain valid values per the OpenSubsonic spec.
|
||||||
|
func (r *clientInfoRequest) validate() error {
|
||||||
|
for _, dp := range r.DirectPlayProfiles {
|
||||||
|
for _, p := range dp.Protocols {
|
||||||
|
if !isValidProtocol(p) {
|
||||||
|
return fmt.Errorf("invalid protocol: %s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tp := range r.TranscodingProfiles {
|
||||||
|
if tp.Protocol != "" && !isValidProtocol(tp.Protocol) {
|
||||||
|
return fmt.Errorf("invalid protocol: %s", tp.Protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, cp := range r.CodecProfiles {
|
||||||
|
if !isValidCodecProfileType(cp.Type) {
|
||||||
|
return fmt.Errorf("invalid codec profile type: %s", cp.Type)
|
||||||
|
}
|
||||||
|
for _, lim := range cp.Limitations {
|
||||||
|
if !isValidLimitationName(lim.Name) {
|
||||||
|
return fmt.Errorf("invalid limitation name: %s", lim.Name)
|
||||||
|
}
|
||||||
|
if !isValidComparison(lim.Comparison) {
|
||||||
|
return fmt.Errorf("invalid comparison: %s", lim.Comparison)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var validProtocols = []string{
|
||||||
|
transcode.ProtocolHTTP,
|
||||||
|
transcode.ProtocolHLS,
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidProtocol(p string) bool {
|
||||||
|
return slices.Contains(validProtocols, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validCodecProfileTypes = []string{
|
||||||
|
transcode.CodecProfileTypeAudio,
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidCodecProfileType(t string) bool {
|
||||||
|
return slices.Contains(validCodecProfileTypes, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validLimitationNames = []string{
|
||||||
|
transcode.LimitationAudioChannels,
|
||||||
|
transcode.LimitationAudioBitrate,
|
||||||
|
transcode.LimitationAudioProfile,
|
||||||
|
transcode.LimitationAudioSamplerate,
|
||||||
|
transcode.LimitationAudioBitdepth,
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidLimitationName(n string) bool {
|
||||||
|
return slices.Contains(validLimitationNames, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validComparisons = []string{
|
||||||
|
transcode.ComparisonEquals,
|
||||||
|
transcode.ComparisonNotEquals,
|
||||||
|
transcode.ComparisonLessThanEqual,
|
||||||
|
transcode.ComparisonGreaterThanEqual,
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidComparison(c string) bool {
|
||||||
|
return slices.Contains(validComparisons, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTranscodeDecision handles the OpenSubsonic getTranscodeDecision endpoint.
|
||||||
|
// It receives client capabilities and returns a decision on whether to direct play or transcode.
|
||||||
|
func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", "POST")
|
||||||
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
p := req.Params(r)
|
||||||
|
|
||||||
|
mediaID, err := p.String("mediaId")
|
||||||
|
if err != nil {
|
||||||
|
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaId")
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaType, err := p.String("mediaType")
|
||||||
|
if err != nil {
|
||||||
|
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaType")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only support songs for now
|
||||||
|
if mediaType != "song" {
|
||||||
|
return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate ClientInfo from request body (required per OpenSubsonic spec)
|
||||||
|
var clientInfoReq clientInfoRequest
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB limit
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&clientInfoReq); err != nil {
|
||||||
|
return nil, newError(responses.ErrorGeneric, "invalid JSON request body")
|
||||||
|
}
|
||||||
|
if err := clientInfoReq.validate(); err != nil {
|
||||||
|
return nil, newError(responses.ErrorGeneric, "%v", err)
|
||||||
|
}
|
||||||
|
clientInfo := clientInfoReq.toCoreClientInfo()
|
||||||
|
|
||||||
|
// Get media file
|
||||||
|
mf, err := api.ds.MediaFile(ctx).Get(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return nil, newError(responses.ErrorDataNotFound, "media file not found: %s", mediaID)
|
||||||
|
}
|
||||||
|
return nil, newError(responses.ErrorGeneric, "error retrieving media file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the decision
|
||||||
|
decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, newError(responses.ErrorGeneric, "failed to make transcode decision: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only create a token when there is a valid playback path
|
||||||
|
var transcodeParams string
|
||||||
|
if decision.CanDirectPlay || decision.CanTranscode {
|
||||||
|
transcodeParams, err = api.transcodeDecision.CreateTranscodeParams(decision)
|
||||||
|
if err != nil {
|
||||||
|
return nil, newError(responses.ErrorGeneric, "failed to create transcode token: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response (convert kbps from core to bps for the API)
|
||||||
|
response := newResponse()
|
||||||
|
response.TranscodeDecision = &responses.TranscodeDecision{
|
||||||
|
CanDirectPlay: decision.CanDirectPlay,
|
||||||
|
CanTranscode: decision.CanTranscode,
|
||||||
|
TranscodeReasons: decision.TranscodeReasons,
|
||||||
|
ErrorReason: decision.ErrorReason,
|
||||||
|
TranscodeParams: transcodeParams,
|
||||||
|
SourceStream: &responses.StreamDetails{
|
||||||
|
Protocol: "http",
|
||||||
|
Container: decision.SourceStream.Container,
|
||||||
|
Codec: decision.SourceStream.Codec,
|
||||||
|
AudioBitrate: int32(kbpsToBps(decision.SourceStream.Bitrate)),
|
||||||
|
AudioProfile: decision.SourceStream.Profile,
|
||||||
|
AudioSamplerate: int32(decision.SourceStream.SampleRate),
|
||||||
|
AudioBitdepth: int32(decision.SourceStream.BitDepth),
|
||||||
|
AudioChannels: int32(decision.SourceStream.Channels),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if decision.TranscodeStream != nil {
|
||||||
|
response.TranscodeDecision.TranscodeStream = &responses.StreamDetails{
|
||||||
|
Protocol: "http",
|
||||||
|
Container: decision.TranscodeStream.Container,
|
||||||
|
Codec: decision.TranscodeStream.Codec,
|
||||||
|
AudioBitrate: int32(kbpsToBps(decision.TranscodeStream.Bitrate)),
|
||||||
|
AudioProfile: decision.TranscodeStream.Profile,
|
||||||
|
AudioSamplerate: int32(decision.TranscodeStream.SampleRate),
|
||||||
|
AudioBitdepth: int32(decision.TranscodeStream.BitDepth),
|
||||||
|
AudioChannels: int32(decision.TranscodeStream.Channels),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint.
|
||||||
|
// It streams media using the decision encoded in the transcodeParams JWT token.
|
||||||
|
// All errors are returned as proper HTTP status codes (not Subsonic error responses).
|
||||||
|
func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
p := req.Params(r)
|
||||||
|
|
||||||
|
mediaID, err := p.String("mediaId")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaType, err := p.String("mediaType")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
transcodeParamsToken, err := p.String("transcodeParams")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only support songs for now
|
||||||
|
if mediaType != "song" {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the token, mediaID match, file existence, and freshness
|
||||||
|
params, mf, err := api.transcodeDecision.ValidateTranscodeParams(ctx, transcodeParamsToken, mediaID)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, transcode.ErrMediaNotFound):
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
case errors.Is(err, transcode.ErrTokenInvalid), errors.Is(err, transcode.ErrTokenStale):
|
||||||
|
http.Error(w, "Gone", http.StatusGone)
|
||||||
|
default:
|
||||||
|
log.Error(ctx, "Error validating transcode params", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build streaming parameters from the token
|
||||||
|
streamReq := core.StreamRequest{ID: mediaID, Offset: p.IntOr("offset", 0)}
|
||||||
|
if !params.DirectPlay && params.TargetFormat != "" {
|
||||||
|
streamReq.Format = params.TargetFormat
|
||||||
|
streamReq.BitRate = params.TargetBitrate // Already in kbps, matching the streamer
|
||||||
|
streamReq.SampleRate = params.TargetSampleRate
|
||||||
|
streamReq.BitDepth = params.TargetBitDepth
|
||||||
|
streamReq.Channels = params.TargetChannels
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stream (use DoStream to avoid duplicate DB fetch)
|
||||||
|
stream, err := api.streamer.DoStream(ctx, mf, streamReq)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Error creating stream", "mediaID", mediaID, err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the stream will be closed at the end
|
||||||
|
defer func() {
|
||||||
|
if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||||
|
log.Error("Error closing stream", "id", mediaID, "file", stream.Name(), err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|
||||||
|
api.serveStream(ctx, w, r, stream, mediaID)
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
406
server/subsonic/transcode_test.go
Normal file
406
server/subsonic/transcode_test.go
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/core/transcode"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Transcode endpoints", func() {
|
||||||
|
var (
|
||||||
|
router *Router
|
||||||
|
ds *tests.MockDataStore
|
||||||
|
mockTD *mockTranscodeDecision
|
||||||
|
w *httptest.ResponseRecorder
|
||||||
|
mockMFRepo *tests.MockMediaFileRepo
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
mockMFRepo = &tests.MockMediaFileRepo{}
|
||||||
|
ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo}
|
||||||
|
mockTD = &mockTranscodeDecision{}
|
||||||
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GetTranscodeDecision", func() {
|
||||||
|
It("returns 405 for non-POST requests", func() {
|
||||||
|
r := newGetRequest("mediaId=123", "mediaType=song")
|
||||||
|
resp, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(resp).To(BeNil())
|
||||||
|
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
|
||||||
|
Expect(w.Header().Get("Allow")).To(Equal("POST"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error when mediaId is missing", func() {
|
||||||
|
r := newJSONPostRequest("mediaType=song", "{}")
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error when mediaType is missing", func() {
|
||||||
|
r := newJSONPostRequest("mediaId=123", "{}")
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for unsupported mediaType", func() {
|
||||||
|
r := newJSONPostRequest("mediaId=123&mediaType=podcast", "{}")
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("not yet supported"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error when media file not found", func() {
|
||||||
|
mockMFRepo.SetError(true)
|
||||||
|
r := newJSONPostRequest("mediaId=notfound&mediaType=song", "{}")
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error when body is empty", func() {
|
||||||
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "")
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error when body contains invalid JSON", func() {
|
||||||
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "not-json{{{")
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for invalid protocol in direct play profile", func() {
|
||||||
|
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["ftp"]}]}`
|
||||||
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for invalid comparison operator", func() {
|
||||||
|
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"InvalidOp","values":["320"]}]}]}`
|
||||||
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for invalid limitation name", func() {
|
||||||
|
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"unknownField","comparison":"Equals","values":["320"]}]}]}`
|
||||||
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for invalid codec profile type", func() {
|
||||||
|
body := `{"codecProfiles":[{"type":"VideoCodec","name":"mp3"}]}`
|
||||||
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects wrong-case protocol", func() {
|
||||||
|
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["HTTP"]}]}`
|
||||||
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects wrong-case codec profile type", func() {
|
||||||
|
body := `{"codecProfiles":[{"type":"audiocodec","name":"mp3"}]}`
|
||||||
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects wrong-case comparison operator", func() {
|
||||||
|
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"lessthanequal","values":["320"]}]}]}`
|
||||||
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects wrong-case limitation name", func() {
|
||||||
|
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"AudioBitrate","comparison":"Equals","values":["320"]}]}]}`
|
||||||
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns a valid decision response", func() {
|
||||||
|
mockMFRepo.SetData(model.MediaFiles{
|
||||||
|
{ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100},
|
||||||
|
})
|
||||||
|
mockTD.decision = &transcode.Decision{
|
||||||
|
MediaID: "song-1",
|
||||||
|
CanDirectPlay: true,
|
||||||
|
SourceStream: transcode.StreamDetails{
|
||||||
|
Container: "mp3", Codec: "mp3", Bitrate: 320,
|
||||||
|
SampleRate: 44100, Channels: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockTD.token = "test-jwt-token"
|
||||||
|
|
||||||
|
body := `{"directPlayProfiles":[{"containers":["mp3"],"protocols":["http"]}]}`
|
||||||
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||||
|
resp, err := router.GetTranscodeDecision(w, r)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeParams).To(Equal("test-jwt-token"))
|
||||||
|
Expect(resp.TranscodeDecision.SourceStream).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.SourceStream.Protocol).To(Equal("http"))
|
||||||
|
Expect(resp.TranscodeDecision.SourceStream.Container).To(Equal("mp3"))
|
||||||
|
Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320_000)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("includes transcode stream when transcoding", func() {
|
||||||
|
mockMFRepo.SetData(model.MediaFiles{
|
||||||
|
{ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24},
|
||||||
|
})
|
||||||
|
mockTD.decision = &transcode.Decision{
|
||||||
|
MediaID: "song-2",
|
||||||
|
CanDirectPlay: false,
|
||||||
|
CanTranscode: true,
|
||||||
|
TargetFormat: "mp3",
|
||||||
|
TargetBitrate: 256,
|
||||||
|
TranscodeReasons: []string{"container not supported"},
|
||||||
|
SourceStream: transcode.StreamDetails{
|
||||||
|
Container: "flac", Codec: "flac", Bitrate: 1000,
|
||||||
|
SampleRate: 96000, BitDepth: 24, Channels: 2,
|
||||||
|
},
|
||||||
|
TranscodeStream: &transcode.StreamDetails{
|
||||||
|
Container: "mp3", Codec: "mp3", Bitrate: 256,
|
||||||
|
SampleRate: 96000, Channels: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockTD.token = "transcode-token"
|
||||||
|
|
||||||
|
r := newJSONPostRequest("mediaId=song-2&mediaType=song", "{}")
|
||||||
|
resp, err := router.GetTranscodeDecision(w, r)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeReasons).To(ConsistOf("container not supported"))
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
||||||
|
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GetTranscodeStream", func() {
|
||||||
|
It("returns 400 when mediaId is missing", func() {
|
||||||
|
r := newGetRequest("mediaType=song", "transcodeParams=abc")
|
||||||
|
resp, err := router.GetTranscodeStream(w, r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(resp).To(BeNil())
|
||||||
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 400 when transcodeParams is missing", func() {
|
||||||
|
r := newGetRequest("mediaId=123", "mediaType=song")
|
||||||
|
resp, err := router.GetTranscodeStream(w, r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(resp).To(BeNil())
|
||||||
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 410 for invalid token", func() {
|
||||||
|
mockTD.validateErr = transcode.ErrTokenInvalid
|
||||||
|
r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token")
|
||||||
|
resp, err := router.GetTranscodeStream(w, r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(resp).To(BeNil())
|
||||||
|
Expect(w.Code).To(Equal(http.StatusGone))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 410 when mediaId doesn't match token", func() {
|
||||||
|
mockTD.validateErr = transcode.ErrTokenInvalid
|
||||||
|
r := newGetRequest("mediaId=wrong-id", "mediaType=song", "transcodeParams=valid-token")
|
||||||
|
resp, err := router.GetTranscodeStream(w, r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(resp).To(BeNil())
|
||||||
|
Expect(w.Code).To(Equal(http.StatusGone))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 404 when media file not found", func() {
|
||||||
|
mockTD.validateErr = transcode.ErrMediaNotFound
|
||||||
|
r := newGetRequest("mediaId=gone-id", "mediaType=song", "transcodeParams=valid-token")
|
||||||
|
resp, err := router.GetTranscodeStream(w, r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(resp).To(BeNil())
|
||||||
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 410 when media file has changed (stale token)", func() {
|
||||||
|
mockTD.validateErr = transcode.ErrTokenStale
|
||||||
|
r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=stale-token")
|
||||||
|
resp, err := router.GetTranscodeStream(w, r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(resp).To(BeNil())
|
||||||
|
Expect(w.Code).To(Equal(http.StatusGone))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("builds correct StreamRequest for direct play", func() {
|
||||||
|
fakeStreamer := &fakeMediaStreamer{}
|
||||||
|
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD)
|
||||||
|
mockTD.validateParams = &transcode.Params{MediaID: "song-1", DirectPlay: true}
|
||||||
|
mockTD.validateMF = &model.MediaFile{ID: "song-1"}
|
||||||
|
|
||||||
|
r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=valid-token")
|
||||||
|
_, _ = router.GetTranscodeStream(w, r)
|
||||||
|
|
||||||
|
Expect(fakeStreamer.captured).ToNot(BeNil())
|
||||||
|
Expect(fakeStreamer.captured.ID).To(Equal("song-1"))
|
||||||
|
Expect(fakeStreamer.captured.Format).To(BeEmpty())
|
||||||
|
Expect(fakeStreamer.captured.BitRate).To(BeZero())
|
||||||
|
Expect(fakeStreamer.captured.SampleRate).To(BeZero())
|
||||||
|
Expect(fakeStreamer.captured.BitDepth).To(BeZero())
|
||||||
|
Expect(fakeStreamer.captured.Channels).To(BeZero())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("builds correct StreamRequest for transcoding", func() {
|
||||||
|
fakeStreamer := &fakeMediaStreamer{}
|
||||||
|
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD)
|
||||||
|
mockTD.validateParams = &transcode.Params{
|
||||||
|
MediaID: "song-2",
|
||||||
|
DirectPlay: false,
|
||||||
|
TargetFormat: "mp3",
|
||||||
|
TargetBitrate: 256,
|
||||||
|
TargetSampleRate: 44100,
|
||||||
|
TargetBitDepth: 16,
|
||||||
|
TargetChannels: 2,
|
||||||
|
}
|
||||||
|
mockTD.validateMF = &model.MediaFile{ID: "song-2"}
|
||||||
|
|
||||||
|
r := newGetRequest("mediaId=song-2", "mediaType=song", "transcodeParams=valid-token", "offset=10")
|
||||||
|
_, _ = router.GetTranscodeStream(w, r)
|
||||||
|
|
||||||
|
Expect(fakeStreamer.captured).ToNot(BeNil())
|
||||||
|
Expect(fakeStreamer.captured.ID).To(Equal("song-2"))
|
||||||
|
Expect(fakeStreamer.captured.Format).To(Equal("mp3"))
|
||||||
|
Expect(fakeStreamer.captured.BitRate).To(Equal(256))
|
||||||
|
Expect(fakeStreamer.captured.SampleRate).To(Equal(44100))
|
||||||
|
Expect(fakeStreamer.captured.BitDepth).To(Equal(16))
|
||||||
|
Expect(fakeStreamer.captured.Channels).To(Equal(2))
|
||||||
|
Expect(fakeStreamer.captured.Offset).To(Equal(10))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("bpsToKbps", func() {
|
||||||
|
It("converts standard bitrates", func() {
|
||||||
|
Expect(bpsToKbps(128000)).To(Equal(128))
|
||||||
|
Expect(bpsToKbps(320000)).To(Equal(320))
|
||||||
|
Expect(bpsToKbps(256000)).To(Equal(256))
|
||||||
|
})
|
||||||
|
It("returns 0 for 0", func() {
|
||||||
|
Expect(bpsToKbps(0)).To(Equal(0))
|
||||||
|
})
|
||||||
|
It("rounds instead of truncating", func() {
|
||||||
|
Expect(bpsToKbps(999)).To(Equal(1))
|
||||||
|
Expect(bpsToKbps(500)).To(Equal(1))
|
||||||
|
Expect(bpsToKbps(499)).To(Equal(0))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("kbpsToBps", func() {
|
||||||
|
It("converts standard bitrates", func() {
|
||||||
|
Expect(kbpsToBps(128)).To(Equal(128000))
|
||||||
|
Expect(kbpsToBps(320)).To(Equal(320000))
|
||||||
|
})
|
||||||
|
It("returns 0 for 0", func() {
|
||||||
|
Expect(kbpsToBps(0)).To(Equal(0))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("convertBitrateValues", func() {
|
||||||
|
It("converts valid bps strings to kbps", func() {
|
||||||
|
Expect(convertBitrateValues([]string{"128000", "320000"})).To(Equal([]string{"128", "320"}))
|
||||||
|
})
|
||||||
|
It("preserves unparseable values", func() {
|
||||||
|
Expect(convertBitrateValues([]string{"128000", "bad", "320000"})).To(Equal([]string{"128", "bad", "320"}))
|
||||||
|
})
|
||||||
|
It("handles empty slice", func() {
|
||||||
|
Expect(convertBitrateValues([]string{})).To(Equal([]string{}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// newJSONPostRequest creates an HTTP POST request with JSON body and query params
|
||||||
|
func newJSONPostRequest(queryParams string, jsonBody string) *http.Request {
|
||||||
|
r := httptest.NewRequest("POST", "/getTranscodeDecision?"+queryParams, bytes.NewBufferString(jsonBody))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockTranscodeDecision is a test double for transcode.Decider
|
||||||
|
type mockTranscodeDecision struct {
|
||||||
|
decision *transcode.Decision
|
||||||
|
token string
|
||||||
|
tokenErr error
|
||||||
|
params *transcode.Params
|
||||||
|
parseErr error
|
||||||
|
validateParams *transcode.Params
|
||||||
|
validateMF *model.MediaFile
|
||||||
|
validateErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *transcode.ClientInfo) (*transcode.Decision, error) {
|
||||||
|
if m.decision != nil {
|
||||||
|
return m.decision, nil
|
||||||
|
}
|
||||||
|
return &transcode.Decision{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTranscodeDecision) CreateTranscodeParams(_ *transcode.Decision) (string, error) {
|
||||||
|
return m.token, m.tokenErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTranscodeDecision) ParseTranscodeParams(_ string) (*transcode.Params, error) {
|
||||||
|
if m.parseErr != nil {
|
||||||
|
return nil, m.parseErr
|
||||||
|
}
|
||||||
|
return m.params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTranscodeDecision) ValidateTranscodeParams(_ context.Context, _ string, _ string) (*transcode.Params, *model.MediaFile, error) {
|
||||||
|
if m.validateErr != nil {
|
||||||
|
return nil, nil, m.validateErr
|
||||||
|
}
|
||||||
|
return m.validateParams, m.validateMF, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeMediaStreamer captures the StreamRequest and returns a sentinel error,
|
||||||
|
// allowing tests to verify parameter passing without constructing a real Stream.
|
||||||
|
var errStreamCaptured = errors.New("stream request captured")
|
||||||
|
|
||||||
|
type fakeMediaStreamer struct {
|
||||||
|
captured *core.StreamRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMediaStreamer) NewStream(_ context.Context, req core.StreamRequest) (*core.Stream, error) {
|
||||||
|
f.captured = &req
|
||||||
|
return nil, errStreamCaptured
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMediaStreamer) DoStream(_ context.Context, _ *model.MediaFile, req core.StreamRequest) (*core.Stream, error) {
|
||||||
|
f.captured = &req
|
||||||
|
return nil, errStreamCaptured
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewMockFFmpeg(data string) *MockFFmpeg {
|
func NewMockFFmpeg(data string) *MockFFmpeg {
|
||||||
@@ -23,7 +25,7 @@ func (ff *MockFFmpeg) IsAvailable() bool {
|
|||||||
return true
|
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 {
|
if ff.Error != nil {
|
||||||
return nil, ff.Error
|
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
|
return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil
|
||||||
case "opus":
|
case "opus":
|
||||||
return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil
|
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 ipod -movflags frag_keyframe+empty_moov -"}, nil
|
||||||
default:
|
default:
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user