Compare commits

..

4 Commits

Author SHA1 Message Date
Kendall Garner
34c6f12aee feat(server): add explicit status support in smart playlists (#5031)
* feat(smart playlist): add explicit status support

* retrigger checks

* rename field (remove snake_case)

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2026-02-10 18:22:34 -05:00
Denisa Rissa
eb9ebc3fba fix(ui): add missing keys in Danish translation (#5011)
update Danish translation with 59 missing keys for the `resources.plugin` section as well as `message.startingInstantMix`, `resources.song.actions.instantMix`, `resources.song.fields.composer`, and `resources.plugin.name`.
2026-02-10 14:05:14 -05:00
Deluan
e05a7e230f fix: prevent data race on conf.Server during cleanup in e2e tests
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-10 11:25:17 -05:00
Rob Emery
62f9c3a458 fix: linux service should restart when upgrading (#5001)
* When upgrading packages this should restart the service

* We need to specify configfile otherwise this command doesn't work
2026-02-09 17:11:45 -05:00
42 changed files with 200 additions and 3694 deletions

View File

@@ -65,7 +65,6 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
Channels: int(props.Channels),
SampleRate: int(props.SampleRate),
BitDepth: int(props.BitsPerSample),
Codec: props.Codec,
}
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)

View File

@@ -19,7 +19,6 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@@ -103,8 +102,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
decider := transcode.NewDecider(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics, decider)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
return router
}

View File

@@ -151,13 +151,7 @@ var (
Name: "aac audio",
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 -",
},
{
Name: "flac audio",
TargetFormat: "flac",
DefaultBitRate: 0,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
}
)

View File

@@ -176,7 +176,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
var r io.ReadCloser
if format != "raw" && format != "" {
r, err = a.ms.DoStream(ctx, &mf, StreamRequest{Format: format, BitRate: bitrate})
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
} else {
r, err = os.Open(path)
}

View File

@@ -44,7 +44,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
@@ -73,7 +73,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
@@ -104,7 +104,7 @@ var _ = Describe("Archiver", func() {
}
sh.On("Load", mock.Anything, "1").Return(share, nil)
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipShare(context.Background(), "1", out)
@@ -136,7 +136,7 @@ var _ = Describe("Archiver", func() {
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
ds.On("Playlist", mock.Anything).Return(plRepo)
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
@@ -217,8 +217,8 @@ type mockMediaStreamer struct {
core.MediaStreamer
}
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req core.StreamRequest) (*core.Stream, error) {
args := m.Called(ctx, mf, req)
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
if args.Error(1) != nil {
return nil, args.Error(1)
}

View File

@@ -12,24 +12,11 @@ import (
"sync"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"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 {
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
Probe(ctx context.Context, files []string) (string, error)
CmdPath() (string, error)
@@ -48,19 +35,15 @@ const (
type ffmpeg struct{}
func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) {
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
if err := fileExists(opts.FilePath); err != nil {
// First make sure the file exists
if err := fileExists(path); err != nil {
return nil, err
}
var args []string
if isDefaultCommand(opts.Format, opts.Command) {
args = buildDynamicArgs(opts)
} else {
args = buildTemplateArgs(opts)
}
args := createFFmpegCommand(command, path, maxBitRate, offset)
return e.start(ctx, args)
}
@@ -68,6 +51,7 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser,
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
// First make sure the file exists
if err := fileExists(path); err != nil {
return nil, err
}
@@ -172,139 +156,6 @@ func (j *ffCmd) wait() {
_ = 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
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
var args []string

View File

@@ -2,27 +2,19 @@ package ffmpeg
import (
"context"
"os"
"path/filepath"
"runtime"
sync "sync"
"testing"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestFFmpeg(t *testing.T) {
// 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)
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "FFmpeg Suite")
@@ -78,286 +70,6 @@ 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() {
Context("when FFmpeg is available", func() {
var ff FFmpeg
@@ -381,12 +93,7 @@ var _ = Describe("ffmpeg", func() {
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
// The input file is not used here, but we need to provide a valid path to the Transcode function
stream, err := ff.Transcode(ctx, TranscodeOptions{
Command: command,
Format: "mp3",
FilePath: "tests/fixtures/test.mp3",
BitRate: 128,
})
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
@@ -408,12 +115,7 @@ var _ = Describe("ffmpeg", func() {
cancel() // Cancel immediately
// This should fail immediately
_, err := ff.Transcode(ctx, TranscodeOptions{
Command: "ffmpeg -i %s -f mp3 -",
Format: "mp3",
FilePath: "tests/fixtures/test.mp3",
BitRate: 128,
})
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
Expect(err).To(MatchError(context.Canceled))
})
})
@@ -440,10 +142,7 @@ var _ = Describe("ffmpeg", func() {
defer cancel()
// Start a process that will run for a while
stream, err := ff.Transcode(ctx, TranscodeOptions{
Command: longRunningCmd,
FilePath: "tests/fixtures/test.mp3",
})
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()

View File

@@ -18,20 +18,9 @@ import (
"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 {
NewStream(ctx context.Context, req StreamRequest) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error)
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
}
type TranscodingCache cache.FileCache
@@ -47,48 +36,44 @@ type mediaStreamer struct {
}
type streamJob struct {
ms *mediaStreamer
mf *model.MediaFile
filePath string
format string
bitRate int
sampleRate int
bitDepth int
channels int
offset int
ms *mediaStreamer
mf *model.MediaFile
filePath string
format string
bitRate int
offset int
}
func (j *streamJob) Key() string {
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)
return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
}
func (ms *mediaStreamer) NewStream(ctx context.Context, req StreamRequest) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(req.ID)
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, err
}
return ms.DoStream(ctx, mf, req)
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
}
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) {
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
var format string
var bitRate int
var cached bool
defer func() {
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
"bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels,
"user", userName(ctx), "transcoding", format != "raw",
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw",
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
}()
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, req.Format, req.BitRate, req.SampleRate)
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
filePath := mf.AbsolutePath()
if format == "raw" {
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format)
f, err := os.Open(filePath)
@@ -102,15 +87,12 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req
}
job := &streamJob{
ms: ms,
mf: mf,
filePath: filePath,
format: format,
bitRate: bitRate,
sampleRate: req.SampleRate,
bitDepth: req.BitDepth,
channels: req.Channels,
offset: req.Offset,
ms: ms,
mf: mf,
filePath: filePath,
format: format,
bitRate: bitRate,
offset: reqOffset,
}
r, err := ms.cache.Get(ctx, job)
if err != nil {
@@ -123,7 +105,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req
s.Seeker = r.Seeker
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
@@ -149,13 +131,12 @@ func (s *Stream) EstimatedContentLength() int {
}
// TODO This function deserves some love (refactoring)
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int) (format string, bitRate int) {
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
format = "raw"
if reqFormat == "raw" {
return format, 0
}
needsResample := reqSampleRate > 0 && reqSampleRate < mf.SampleRate
if reqFormat == mf.Suffix && reqBitRate == 0 && !needsResample {
if reqFormat == mf.Suffix && reqBitRate == 0 {
bitRate = mf.BitRate
return format, bitRate
}
@@ -194,7 +175,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
bitRate = t.DefaultBitRate
}
}
if format == mf.Suffix && bitRate >= mf.BitRate && !needsResample {
if format == mf.Suffix && bitRate >= mf.BitRate {
format = "raw"
bitRate = 0
}
@@ -236,16 +217,7 @@ func NewTranscodingCache() TranscodingCache {
transcodingCtx = request.AddValues(context.Background(), ctx)
}
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,
})
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid

View File

@@ -26,64 +26,42 @@ var _ = Describe("MediaStreamer", func() {
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
Expect(format).To(Equal("raw"))
})
It("returns raw if a transcoder does not exists", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0, 0)
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
Expect(format).To(Equal("raw"))
})
It("returns the requested format if a transcoder exists", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
mf.Suffix = "mp3"
mf.BitRate = 112
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128, 0)
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
Expect(format).To(Equal("raw"))
})
It("returns the requested format if requested BitRate is lower than original", func() {
mf.Suffix = "mp3"
mf.BitRate = 320
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(192))
})
It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
mf.Suffix = "mp3"
mf.BitRate = 320
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("raw"))
Expect(bitRate).To(Equal(320))
})
It("returns the format when same format is requested but with a lower sample rate", func() {
mf.Suffix = "flac"
mf.BitRate = 2118
mf.SampleRate = 96000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 48000)
Expect(format).To(Equal("flac"))
Expect(bitRate).To(Equal(0))
})
It("returns raw when same format is requested with same sample rate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
mf.SampleRate = 48000
format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 48000)
Expect(format).To(Equal("raw"))
})
It("returns raw when same format is requested with no sample rate constraint", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
mf.SampleRate = 96000
format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 0)
Expect(format).To(Equal("raw"))
})
Context("Downsampling", func() {
BeforeEach(func() {
conf.Server.DefaultDownsamplingFormat = "opus"
@@ -91,13 +69,13 @@ var _ = Describe("MediaStreamer", func() {
mf.BitRate = 960
})
It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() {
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128, 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128)
Expect(format).To(Equal("opus"))
Expect(bitRate).To(Equal(128))
})
It("returns raw if maxBitrate is equal or greater than original", func() {
// This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960, 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960)
Expect(format).To(Equal("raw"))
Expect(bitRate).To(Equal(0))
})
@@ -112,34 +90,34 @@ var _ = Describe("MediaStreamer", func() {
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
Expect(format).To(Equal("raw"))
})
It("returns configured format/bitrate as default", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0, 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(96))
})
It("returns requested format", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns requested bitrate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80, 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(80))
})
It("returns raw if selected bitrate and format is the same as original", func() {
mf.Suffix = "mp3"
mf.BitRate = 192
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
Expect(format).To(Equal("raw"))
Expect(bitRate).To(Equal(0))
})
@@ -155,27 +133,27 @@ var _ = Describe("MediaStreamer", func() {
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
Expect(format).To(Equal("raw"))
})
It("returns configured format/bitrate as default", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0, 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(192))
})
It("returns requested format", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns requested bitrate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160, 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(160))
})

View File

@@ -39,34 +39,34 @@ var _ = Describe("MediaStreamer", func() {
Context("NewStream", func() {
It("returns a seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "raw"})
s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if maxBitRate is 0", func() {
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3"})
s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 320})
s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a NON seekable stream if transcode is required", func() {
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 64})
s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeFalse())
Expect(s.Duration()).To(Equal(float32(257.0)))
})
It("returns a seekable stream if the file is complete in the cache", func() {
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0)
Expect(err).To(BeNil())
_, _ = io.ReadAll(s)
_ = s.Close()
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
s, err = streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue())
})

View File

@@ -1,87 +0,0 @@
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)
})
}

View File

@@ -1,59 +0,0 @@
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
}

View File

@@ -1,206 +0,0 @@
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
}

View File

@@ -1,400 +0,0 @@
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
}

View File

@@ -1,17 +0,0 @@
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")
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,140 +0,0 @@
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
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/transcode"
)
var Set = wire.NewSet(
@@ -21,7 +20,6 @@ var Set = wire.NewSet(
NewLibrary,
NewUser,
NewMaintenance,
transcode.NewDecider,
agents.GetAgents,
external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)),

View File

@@ -1,63 +0,0 @@
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
}

View File

@@ -23,6 +23,7 @@ var fieldMap = map[string]*mappedField{
"releasedate": {field: "media_file.release_date"},
"size": {field: "media_file.size"},
"compilation": {field: "media_file.compilation"},
"explicitstatus": {field: "media_file.explicit_status"},
"dateadded": {field: "media_file.created_at"},
"datemodified": {field: "media_file.updated_at"},
"discsubtitle": {field: "media_file.disc_subtitle"},

View File

@@ -14,7 +14,6 @@ import (
"github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/conf"
confmime "github.com/navidrome/navidrome/conf/mime"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
@@ -57,7 +56,6 @@ type MediaFile struct {
SampleRate int `structs:"sample_rate" json:"sampleRate"`
BitDepth int `structs:"bit_depth" json:"bitDepth"`
Channels int `structs:"channels" json:"channels"`
Codec string `structs:"codec" json:"codec"`
Genre string `structs:"genre" json:"genre"`
Genres Genres `structs:"-" json:"genres,omitempty"`
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
@@ -163,81 +161,6 @@ func (mf MediaFile) AbsolutePath() string {
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
// ToAlbum creates an Album object based on the attributes of this MediaFiles collection.

View File

@@ -475,7 +475,7 @@ var _ = Describe("MediaFile", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableMediaFileCoverArt = true
})
Describe("CoverArtId", func() {
Describe(".CoverArtId()", func() {
It("returns its own id if it HasCoverArt", func() {
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
id := mf.CoverArtID()
@@ -496,94 +496,6 @@ var _ = Describe("MediaFile", func() {
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 {

View File

@@ -65,7 +65,6 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf.SampleRate = md.AudioProperties().SampleRate
mf.BitDepth = md.AudioProperties().BitDepth
mf.Channels = md.AudioProperties().Channels
mf.Codec = md.AudioProperties().Codec
mf.Path = md.FilePath()
mf.Suffix = md.Suffix()
mf.Size = md.Size()

View File

@@ -35,7 +35,6 @@ type AudioProperties struct {
BitDepth int
SampleRate int
Channels int
Codec string
}
type Date string

View File

@@ -23,6 +23,8 @@ if [ ! -f "$postinstall_flag" ]; then
# and not by root
chown navidrome:navidrome /var/lib/navidrome/cache
touch "$postinstall_flag"
else
navidrome service stop --configfile /etc/navidrome/navidrome.toml && navidrome service start --configfile /etc/navidrome/navidrome.toml
fi

View File

@@ -36,7 +36,8 @@
"bitDepth": "Bitdybde",
"sampleRate": "Samplingfrekvens",
"missing": "Manglende",
"libraryName": "Bibliotek"
"libraryName": "Bibliotek",
"composer": "Komponist"
},
"actions": {
"addToQueue": "Afspil senere",
@@ -46,7 +47,8 @@
"download": "Download",
"playNext": "Afspil næste",
"info": "Hent info",
"showInPlaylist": "Vis i afspilningsliste"
"showInPlaylist": "Vis i afspilningsliste",
"instantMix": "Instant Mix"
}
},
"album": {
@@ -328,6 +330,80 @@
"scanInProgress": "Scanning i gang...",
"noLibrariesAssigned": "Ingen biblioteker tildelt denne bruger"
}
},
"plugin": {
"name": "Plugin |||| Plugins",
"actions": {
"addConfig": "Tilføj konfiguration",
"disable": "Deaktivér",
"disabledDueToError": "Ret fejlen før aktivering",
"disabledLibrariesRequired": "Vælg biblioteker før aktivering",
"disabledUsersRequired": "Vælg brugere før aktivering",
"enable": "Aktivér",
"rescan": "Genskan"
},
"fields": {
"allLibraries": "Tillad alle biblioteker",
"allUsers": "Tillad alle brugere",
"author": "Forfatter",
"configKey": "Nøgle",
"configValue": "Værdi",
"createdAt": "Installeret",
"description": "Beskrivelse",
"enabled": "Aktiveret",
"hasError": "Fejl",
"id": "ID",
"lastError": "Fejl",
"name": "Navn",
"path": "Sti",
"permissions": "Tilladelser",
"selectedLibraries": "Valgte biblioteker",
"selectedUsers": "Valgte brugere",
"status": "Status",
"updatedAt": "Opdateret",
"version": "Version",
"website": "Hjemmeside"
},
"messages": {
"allLibrariesHelp": "Når aktiveret, vil pluginet have adgang til alle biblioteker, inklusiv dem der oprettes i fremtiden.",
"allUsersHelp": "Når aktiveret, vil pluginet have adgang til alle brugere, inklusiv dem der oprettes i fremtiden.",
"clickPermissions": "Klik på en tilladelse for detaljer",
"configHelp": "Konfigurér pluginet med nøgle-værdi-par. Lad stå tomt, hvis pluginet ikke kræver konfiguration.",
"configValidationError": "Konfigurationsvalidering mislykkedes:",
"librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.",
"noConfig": "Ingen konfiguration angivet",
"noLibraries": "Ingen biblioteker valgt",
"noUsers": "Ingen brugere valgt",
"permissionReason": "Årsag",
"requiredHosts": "Påkrævede værter",
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.",
"usersRequired": "Dette plugin kræver adgang til brugeroplysninger. Vælg hvilke brugere pluginet kan tilgå, eller aktivér 'Tillad alle brugere'."
},
"notifications": {
"disabled": "Plugin deaktiveret",
"enabled": "Plugin aktiveret",
"error": "Fejl ved opdatering af plugin",
"updated": "Plugin opdateret"
},
"placeholders": {
"configKey": "nøgle",
"configValue": "værdi"
},
"sections": {
"configuration": "Konfiguration",
"info": "Pluginoplysninger",
"libraryPermission": "Bibliotekstilladelse",
"manifest": "Manifest",
"status": "Status",
"usersPermission": "Brugertilladelse"
},
"status": {
"disabled": "Deaktiveret",
"enabled": "Aktiveret"
},
"validation": {
"invalidJson": "Konfigurationen skal være gyldig JSON"
}
}
},
"ra": {
@@ -511,7 +587,8 @@
"remove_all_missing_title": "Fjern alle manglende filer",
"remove_all_missing_content": "Er du sikker på, at du vil fjerne alle manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.",
"noSimilarSongsFound": "Ingen lignende sange fundet",
"noTopSongsFound": "Ingen topsange fundet"
"noTopSongsFound": "Ingen topsange fundet",
"startingInstantMix": "Indlæser Instant Mix..."
},
"menu": {
"library": "Bibliotek",

View File

@@ -23,7 +23,6 @@ import (
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -190,33 +189,14 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool
// noopStreamer implements core.MediaStreamer
type noopStreamer struct{}
func (n noopStreamer) NewStream(context.Context, core.StreamRequest) (*core.Stream, error) {
func (n noopStreamer) NewStream(context.Context, string, string, int, int) (*core.Stream, error) {
return nil, model.ErrNotFound
}
func (n noopStreamer) DoStream(context.Context, *model.MediaFile, core.StreamRequest) (*core.Stream, error) {
func (n noopStreamer) DoStream(context.Context, *model.MediaFile, string, int, int) (*core.Stream, error) {
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
type noopArchiver struct{}
@@ -285,7 +265,6 @@ var (
_ core.Archiver = noopArchiver{}
_ external.Provider = noopProvider{}
_ scrobbler.PlayTracker = noopPlayTracker{}
_ transcode.Decider = noopDecider{}
)
var _ = BeforeSuite(func() {
@@ -339,6 +318,11 @@ func setupTestDB() {
ctx = request.WithUser(GinkgoT().Context(), adminUser)
DeferCleanup(configtest.SetupConfig())
DeferCleanup(func() {
// Wait for any background scan (e.g. from startScan endpoint) to finish
// before config cleanup runs, to avoid a data race on conf.Server.
Eventually(scanner.IsScanning).Should(BeFalse())
})
conf.Server.MusicFolder = "fake:///music"
conf.Server.DevExternalScanner = false
@@ -365,7 +349,6 @@ func setupTestDB() {
core.NewShare(ds),
playback.PlaybackServer(nil),
metrics.NewNoopInstance(),
noopDecider{},
)
}

View File

@@ -8,7 +8,6 @@ import (
"strconv"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/req"
@@ -25,13 +24,10 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
return
}
stream, err := pub.streamer.NewStream(ctx, core.StreamRequest{
ID: info.id, Format: info.format, BitRate: info.bitrate,
})
stream, err := pub.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 0)
if err != nil {
log.Error(ctx, "Error starting shared stream", err)
http.Error(w, "invalid request", http.StatusInternalServerError)
return
}
// Make sure the stream will be closed at the end, to avoid leakage

View File

@@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
router = New(ds, nil, 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)
w = httptest.NewRecorder()
})

View File

@@ -17,7 +17,6 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
@@ -35,42 +34,40 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic,
type Router struct {
http.Handler
ds model.DataStore
artwork artwork.Artwork
streamer core.MediaStreamer
archiver core.Archiver
players core.Players
provider external.Provider
playlists core.Playlists
scanner model.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
playback playback.PlaybackServer
metrics metrics.Metrics
transcodeDecision transcode.Decider
ds model.DataStore
artwork artwork.Artwork
streamer core.MediaStreamer
archiver core.Archiver
players core.Players
provider external.Provider
playlists core.Playlists
scanner model.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
playback playback.PlaybackServer
metrics metrics.Metrics
}
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,
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
metrics metrics.Metrics, transcodeDecision transcode.Decider,
metrics metrics.Metrics,
) *Router {
r := &Router{
ds: ds,
artwork: artwork,
streamer: streamer,
archiver: archiver,
players: players,
provider: provider,
playlists: playlists,
scanner: scanner,
broker: broker,
scrobbler: scrobbler,
share: share,
playback: playback,
metrics: metrics,
transcodeDecision: transcodeDecision,
ds: ds,
artwork: artwork,
streamer: streamer,
archiver: archiver,
players: players,
provider: provider,
playlists: playlists,
scanner: scanner,
broker: broker,
scrobbler: scrobbler,
share: share,
playback: playback,
metrics: metrics,
}
r.Handler = r.routes()
return r
@@ -175,8 +172,6 @@ func (api *Router) routes() http.Handler {
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
hr(r, "stream", api.Stream)
hr(r, "download", api.Download)
hr(r, "getTranscodeDecision", api.GetTranscodeDecision)
hr(r, "getTranscodeStream", api.GetTranscodeStream)
})
r.Group(func(r chi.Router) {
// configure request throttling

View File

@@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() {
ds = &tests.MockDataStore{}
playTracker = &fakePlayTracker{}
eventBroker = &fakeEventBroker{}
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil)
})
Describe("Scrobble", func() {

View File

@@ -33,7 +33,7 @@ var _ = Describe("MediaRetrievalController", func() {
MockedMediaFile: mockRepo,
}
artwork = &fakeArtwork{data: "image data"}
router = New(ds, artwork, nil, 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)
w = httptest.NewRecorder()
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded,.lrc"

View File

@@ -13,7 +13,6 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
{Name: "formPost", Versions: []int32{1}},
{Name: "songLyrics", Versions: []int32{1}},
{Name: "indexBasedQueue", Versions: []int32{1}},
{Name: "transcoding", Versions: []int32{1}},
}
return response, nil
}

View File

@@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
)
BeforeEach(func() {
router = subsonic.New(nil, 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)
w = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
})
@@ -35,12 +35,11 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
HaveLen(5),
HaveLen(4),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
))
})
})

View File

@@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
router = New(ds, nil, 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)
ctx = context.Background()
})
@@ -224,7 +224,7 @@ var _ = Describe("UpdatePlaylist", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
playlists = &fakePlaylists{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
})
It("clears the comment when parameter is empty", func() {

View File

@@ -61,7 +61,6 @@ type Subsonic struct {
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"`
}
const (
@@ -618,26 +617,3 @@ func marshalJSONArray[T any](v []T) ([]byte, error) {
}
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"`
}

View File

@@ -21,7 +21,7 @@ var _ = Describe("Search", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
router = New(ds, nil, 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)
// Get references to the mock repositories so we can inspect their Options
mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)

View File

@@ -60,9 +60,7 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su
format, _ := p.String("format")
timeOffset := p.IntOr("timeOffset", 0)
stream, err := api.streamer.NewStream(ctx, core.StreamRequest{
ID: id, Format: format, BitRate: maxBitRate, Offset: timeOffset,
})
stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, timeOffset)
if err != nil {
return nil, err
}
@@ -131,9 +129,7 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.
switch v := entity.(type) {
case *model.MediaFile:
stream, err := api.streamer.NewStream(ctx, core.StreamRequest{
ID: id, Format: format, BitRate: maxBitRate,
})
stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, 0)
if err != nil {
return nil, err
}

View File

@@ -1,383 +0,0 @@
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
}

View File

@@ -1,406 +0,0 @@
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
}

View File

@@ -6,8 +6,6 @@ import (
"strings"
"sync"
"sync/atomic"
"github.com/navidrome/navidrome/core/ffmpeg"
)
func NewMockFFmpeg(data string) *MockFFmpeg {
@@ -25,7 +23,7 @@ func (ff *MockFFmpeg) IsAvailable() bool {
return true
}
func (ff *MockFFmpeg) Transcode(_ context.Context, _ ffmpeg.TranscodeOptions) (io.ReadCloser, error) {
func (ff *MockFFmpeg) Transcode(context.Context, string, string, int, int) (io.ReadCloser, error) {
if ff.Error != nil {
return nil, ff.Error
}

View File

@@ -18,10 +18,6 @@ func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, e
return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil
case "opus":
return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil
case "flac":
return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil
case "aac":
return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil
default:
return nil, model.ErrNotFound
}