From 8f508d08f734a96d376a3ba309c1354232061d24 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 8 Mar 2026 11:18:10 -0400 Subject: [PATCH] feat(transcode): implement server-side transcoding override for player formats Signed-off-by: Deluan --- core/transcode/transcode.go | 32 +++++++++ core/transcode/transcode_test.go | 114 +++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/core/transcode/transcode.go b/core/transcode/transcode.go index 629f53c4b..110da0f1a 100644 --- a/core/transcode/transcode.go +++ b/core/transcode/transcode.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" ) const ( @@ -49,6 +50,11 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, decision.SourceStream = buildSourceStream(mf, probe) src := &decision.SourceStream + // Check for server-side player transcoding override + if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { + clientInfo = applyServerOverride(clientInfo, &trc, ctx) + } + log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels, "sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name) @@ -140,6 +146,32 @@ func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Stre return sd } +// applyServerOverride replaces the client-provided profiles with synthetic ones +// matching the server-forced transcoding format and bitrate. +func applyServerOverride(original *ClientInfo, trc *model.Transcoding, ctx context.Context) *ClientInfo { + maxBitRate := trc.DefaultBitRate + if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { + maxBitRate = player.MaxBitRate + } + + log.Debug(ctx, "Applying server-side transcoding override", + "targetFormat", trc.TargetFormat, "maxBitRate", maxBitRate, + "client", original.Name) + + return &ClientInfo{ + Name: original.Name, + Platform: original.Platform, + MaxAudioBitrate: maxBitRate, + MaxTranscodingAudioBitrate: maxBitRate, + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{trc.TargetFormat}, AudioCodecs: []string{trc.TargetFormat}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: trc.TargetFormat, AudioCodec: trc.TargetFormat, Protocol: ProtocolHTTP}, + }, + } +} + func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) { if data == "" { return nil, nil diff --git a/core/transcode/transcode_test.go b/core/transcode/transcode_test.go index 307b40344..ef70857a8 100644 --- a/core/transcode/transcode_test.go +++ b/core/transcode/transcode_test.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -914,6 +915,119 @@ var _ = Describe("Decider", func() { Expect(decision.SourceStream.Channels).To(Equal(2)) }) }) + + Context("Server-side player transcoding override", func() { + It("forces transcoding when override targets a different format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{"http"}}, + }, + } + // Set server override in context + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) + + decision, err := svc.MakeDecision(overrideCtx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TargetBitrate).To(Equal(192)) + }) + + It("allows direct play when source matches forced format and bitrate is within cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{"http"}}, + }, + } + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256}) + + decision, err := svc.MakeDecision(overrideCtx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + Expect(decision.CanTranscode).To(BeFalse()) + }) + + It("transcodes when source bitrate exceeds the forced cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + } + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + + decision, err := svc.MakeDecision(overrideCtx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TargetBitrate).To(Equal(192)) + }) + + It("uses player MaxBitRate over transcoding DefaultBitRate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + } + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320}) + + decision, err := svc.MakeDecision(overrideCtx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TargetBitrate).To(Equal(320)) + }) + + It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + } + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) + + decision, err := svc.MakeDecision(overrideCtx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + // With no cap, lossless→lossy uses defaultBitrate (256) + Expect(decision.TargetBitrate).To(Equal(defaultBitrate)) + }) + + It("does not apply override when no transcoding is in context", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{"http"}}, + }, + } + // No override in context — client profiles used as-is + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("preserves client Name and Platform in overridden ClientInfo", func() { + ci := &ClientInfo{ + Name: "MyApp", + Platform: "iOS", + } + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + + // Verify via applyServerOverride directly (package-level function) + trc := model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192} + overridden := applyServerOverride(ci, &trc, overrideCtx) + Expect(overridden.Name).To(Equal("MyApp")) + Expect(overridden.Platform).To(Equal("iOS")) + Expect(overridden.CodecProfiles).To(BeEmpty()) + }) + }) }) Describe("ensureProbed", func() {