diff --git a/core/media_streamer.go b/core/media_streamer.go index 6dbad4bcd..50f0ae262 100644 --- a/core/media_streamer.go +++ b/core/media_streamer.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/core/transcode" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -218,9 +219,9 @@ func NewTranscodingCache() TranscodingCache { consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems, func(ctx context.Context, arg cache.Item) (io.Reader, error) { job := arg.(*streamJob) - t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format) - if err != nil { - log.Error(ctx, "Error loading transcoding command", "format", job.format, err) + command := transcode.LookupTranscodeCommand(ctx, job.ms.ds, job.format) + if command == "" { + log.Error(ctx, "No transcoding command available", "format", job.format) return nil, os.ErrInvalid } @@ -237,7 +238,7 @@ func NewTranscodingCache() TranscodingCache { } out, err := job.ms.transcoder.Transcode(transcodingCtx, ffmpeg.TranscodeOptions{ - Command: t.Command, + Command: command, Format: job.format, FilePath: job.filePath, BitRate: job.bitRate, diff --git a/core/transcode/transcode.go b/core/transcode/transcode.go index 8ac7436fa..629f53c4b 100644 --- a/core/transcode/transcode.go +++ b/core/transcode/transcode.go @@ -10,6 +10,7 @@ import ( "encoding/json" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" @@ -196,11 +197,17 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Strea return nil, "" } - responseContainer, targetFormat := s.resolveTargetFormat(ctx, profile) + responseContainer, targetFormat := resolveTargetFormat(profile) if targetFormat == "" { return nil, "" } + // Verify we have a transcoding command available (DB custom or built-in default) + if LookupTranscodeCommand(ctx, s.ds, targetFormat) == "" { + log.Trace(ctx, "Skipping transcoding profile: no transcoding command available", "targetFormat", targetFormat) + return nil, "" + } + targetIsLossless := isLosslessFormat(targetFormat) // Reject lossy to lossless conversion @@ -247,34 +254,46 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Strea return ts, targetFormat } +// LookupTranscodeCommand returns the ffmpeg command for the given format. +// It checks the DB first (for user-customized commands), then falls back to +// the built-in default command. Returns "" if the format is unknown. +func LookupTranscodeCommand(ctx context.Context, ds model.DataStore, format string) string { + t, err := ds.Transcoding(ctx).FindByFormat(format) + if err == nil && t.Command != "" { + return t.Command + } + // Fall back to built-in defaults + for _, dt := range consts.DefaultTranscodings { + if dt.TargetFormat == format { + return dt.Command + } + } + return "" +} + // 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) { +// from the profile's Container and AudioCodec fields. When an AudioCodec is specified +// it is preferred as targetFormat (e.g. container "mp4" with audioCodec "aac" → targetFormat "aac"). +func resolveTargetFormat(profile *Profile) (responseContainer, targetFormat string) { responseContainer = strings.ToLower(profile.Container) targetFormat = responseContainer - if targetFormat == "" { + + // Prefer the audioCodec as targetFormat when provided (handles container-to-codec + // mapping like "mp4" → "aac", "ogg" → "opus"). + if profile.AudioCodec != "" { targetFormat = strings.ToLower(profile.AudioCodec) + } + + // If neither container nor audioCodec is set, we can't resolve a format. + if targetFormat == "" { + return "", "" + } + + // When no container was specified, use the targetFormat as container too. + if responseContainer == "" { 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 } diff --git a/core/transcode/transcode_test.go b/core/transcode/transcode_test.go index 63016984a..307b40344 100644 --- a/core/transcode/transcode_test.go +++ b/core/transcode/transcode_test.go @@ -247,7 +247,7 @@ var _ = Describe("Decider", func() { Expect(decision.TargetBitrate).To(Equal(192)) // source bitrate in kbps }) - It("rejects unsupported transcoding format", func() { + It("rejects format with no transcoding command available", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ @@ -294,8 +294,6 @@ var _ = Describe("Decider", func() { Context("Lossless to lossless transcoding", func() { It("allows lossless to lossless when samplerate needs downsampling", func() { - // MockTranscodingRepo doesn't support "flac" format, so this would fail to find a config. - // This test documents the behavior: lossless→lossless requires server transcoding config. mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1}) ci := &ClientInfo{ MaxAudioBitrate: 1000, @@ -313,13 +311,7 @@ var _ = Describe("Decider", func() { }) It("sets IsLossless=true on transcoded stream when target is lossless", func() { - // Simulate DSD→FLAC transcoding by using a mock that supports "flac" - mockTranscoding := &tests.MockTranscodingRepo{} - ds.MockedTranscoding = mockTranscoding - svc = NewDecider(ds, ff) - // Transcoding to mp3 (lossy) should result in IsLossless=false. - // Use mp3 profile to test that lossy output is correctly identified. mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, @@ -811,7 +803,7 @@ var _ = Describe("Decider", func() { decision, err := svc.MakeDecision(ctx, mf, ci) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) - // TargetFormat is the internal format used for DB lookup ("aac") + // TargetFormat is the internal format used for transcoding ("aac") Expect(decision.TargetFormat).To(Equal("aac")) // Container in the response preserves what the client asked ("mp4") Expect(decision.TranscodeStream.Container).To(Equal("mp4"))