diff --git a/core/transcode_decision.go b/core/transcode_decision.go index 140cf0357..baba4d360 100644 --- a/core/transcode_decision.go +++ b/core/transcode_decision.go @@ -57,9 +57,10 @@ type CodecProfile struct { // Limitation describes a specific codec limitation type Limitation struct { - Property string - Condition string - Value string + Name string + Comparison string + Values []string + Required bool } // Decision represents the internal decision result @@ -144,6 +145,11 @@ func (s *transcodeDecisionService) MakeDecision(ctx context.Context, mf *model.M return decision, nil } + // If direct play matched but there are global constraints violated, revoke direct play + if decision.CanDirectPlay && len(decision.TranscodeReasons) > 0 { + decision.CanDirectPlay = false + } + // Try transcoding profiles (in order of preference) for _, profile := range clientInfo.TranscodingProfiles { if targetFormat, targetBitrate, ok := s.matchesTranscodingProfile(ctx, mf, &profile, clientInfo); ok { @@ -273,36 +279,31 @@ func (s *transcodeDecisionService) matchesContainer(suffix string, containers [] func (s *transcodeDecisionService) matchesCodec(codec string, codecs []string) bool { codec = strings.ToLower(codec) for _, c := range codecs { - c = strings.ToLower(c) - if c == codec { + if strings.EqualFold(c, codec) { return true } - // Handle common aliases - if c == "aac" && codec == "alac" { - continue // ALAC is not AAC - } } return false } func (s *transcodeDecisionService) meetsLimitations(mf *model.MediaFile, limitations []Limitation) bool { for _, lim := range limitations { - switch strings.ToLower(lim.Property) { + switch strings.ToLower(lim.Name) { case "audiochannels": - if !checkIntLimitation(mf.Channels, lim.Condition, lim.Value) { - return false + if !checkIntLimitation(mf.Channels, lim.Comparison, lim.Values) { + return !lim.Required } case "audiosamplerate": - if !checkIntLimitation(mf.SampleRate, lim.Condition, lim.Value) { - return false + if !checkIntLimitation(mf.SampleRate, lim.Comparison, lim.Values) { + return !lim.Required } case "audiobitrate": - if !checkIntLimitation(mf.BitRate, lim.Condition, lim.Value) { - return false + if !checkIntLimitation(mf.BitRate, lim.Comparison, lim.Values) { + return !lim.Required } case "audiobitdepth": - if !checkIntLimitation(mf.BitDepth, lim.Condition, lim.Value) { - return false + if !checkIntLimitation(mf.BitDepth, lim.Comparison, lim.Values) { + return !lim.Required } } } @@ -360,36 +361,55 @@ func containsIgnoreCase(slice []string, s string) bool { return false } -func checkIntLimitation(value int, condition, limitValue string) bool { - var limit int - if _, err := parseIntFromString(limitValue, &limit); err != nil { - return true // If we can't parse the limit, assume it passes +func checkIntLimitation(value int, comparison string, values []string) bool { + if len(values) == 0 { + return true } - switch strings.ToLower(condition) { - case "lessthanequal", "lte": + switch strings.ToLower(comparison) { + case "lessthanequal": + limit, ok := parseInt(values[0]) + if !ok { + return true + } return value <= limit - case "greaterthanequal", "gte": + case "greaterthanequal": + limit, ok := parseInt(values[0]) + if !ok { + return true + } return value >= limit - case "equals", "eq": - return value == limit - case "notequals", "ne": - return value != limit + case "equals": + for _, v := range values { + if limit, ok := parseInt(v); ok && value == limit { + return true + } + } + return false + case "notequals": + for _, v := range values { + if limit, ok := parseInt(v); ok && value == limit { + return false + } + } + return true default: return true } } -func parseIntFromString(s string, out *int) (bool, error) { +func parseInt(s string) (int, bool) { + if s == "" { + return 0, false + } var v int for _, c := range s { if c < '0' || c > '9' { - return false, nil + return 0, false } v = v*10 + int(c-'0') } - *out = v - return true, nil + return v, true } func isLosslessFormat(format string) bool { diff --git a/core/transcode_decision_test.go b/core/transcode_decision_test.go new file mode 100644 index 000000000..06e246960 --- /dev/null +++ b/core/transcode_decision_test.go @@ -0,0 +1,360 @@ +package core + +import ( + "context" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TranscodeDecision", func() { + var ( + ds *tests.MockDataStore + svc TranscodeDecision + ctx context.Context + ) + + BeforeEach(func() { + ctx = context.Background() + ds = &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedTranscoding: &tests.MockTranscodingRepo{}, + } + auth.Init(ds) + svc = NewTranscodeDecision(ds) + }) + + Describe("MakeDecision", func() { + Context("Direct Play", func() { + It("allows direct play when profile matches", func() { + mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320000, Channels: 2, SampleRate: 44100} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}, MaxAudioChannels: 2}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + Expect(decision.CanTranscode).To(BeFalse()) + Expect(decision.TranscodeReasons).To(BeEmpty()) + }) + + It("rejects direct play when container doesn't match", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, Protocols: []string{"http"}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + }) + + It("rejects direct play when codec doesn't match", func() { + mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "ALAC", BitRate: 1000000, Channels: 2} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + }) + + It("rejects direct play when channels exceed limit", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 6} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{"http"}, MaxAudioChannels: 2}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + }) + + It("handles container aliases (aac -> m4a)", func() { + mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256000, Channels: 2} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"aac"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("allows when protocol list is empty (any protocol)", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + }) + + Context("MaxAudioBitrate constraint", func() { + It("revokes direct play when bitrate exceeds maxAudioBitrate", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500000, Channels: 2} + ci := &ClientInfo{ + MaxAudioBitrate: 500000, + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{"http"}}, + }, + TranscodingProfiles: []TranscodingProfile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeReasons).To(ContainElement("bitrate exceeds maxAudioBitrate")) + }) + }) + + Context("Transcoding", func() { + It("selects transcoding when direct play isn't possible", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2, SampleRate: 44100, BitDepth: 16} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 256000, + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, Protocols: []string{"http"}}, + }, + TranscodingProfiles: []TranscodingProfile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http", MaxAudioChannels: 2}, + }, + } + decision, err := svc.MakeDecision(ctx, 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(256)) + }) + + It("rejects lossy to lossless transcoding", func() { + mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320000, Channels: 2} + ci := &ClientInfo{ + TranscodingProfiles: []TranscodingProfile{ + {Container: "flac", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeFalse()) + }) + + It("uses default bitrate when client doesn't specify", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2, BitDepth: 16} + ci := &ClientInfo{ + TranscodingProfiles: []TranscodingProfile{ + {Container: "mp3", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetBitrate).To(Equal(defaultTranscodeBitrate)) + }) + + It("preserves lossy bitrate when under max", func() { + mf := &model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192000, Channels: 2} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 256000, + TranscodingProfiles: []TranscodingProfile{ + {Container: "mp3", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetBitrate).To(Equal(192)) + }) + + It("rejects unsupported transcoding format", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2} + ci := &ClientInfo{ + TranscodingProfiles: []TranscodingProfile{ + {Container: "aac", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeFalse()) + }) + }) + + Context("No compatible profile", func() { + It("returns error when nothing matches", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 6} + ci := &ClientInfo{} + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeFalse()) + Expect(decision.ErrorReason).To(Equal("no compatible playback profile found")) + }) + }) + + Context("Codec limitations", func() { + It("rejects direct play when codec limitation fails (required)", func() { + mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512000, Channels: 2, SampleRate: 44100} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: "AudioCodec", + Name: "mp3", + Limitations: []Limitation{ + {Name: "audioBitrate", Comparison: "LessThanEqual", Values: []string{"320000"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + }) + + It("allows direct play when optional limitation fails", func() { + mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512000, Channels: 2, SampleRate: 44100} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: "AudioCodec", + Name: "mp3", + Limitations: []Limitation{ + {Name: "audioBitrate", Comparison: "LessThanEqual", Values: []string{"320000"}, Required: false}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("handles Equals comparison with multiple values", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2, SampleRate: 44100} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{"http"}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: "AudioCodec", + Name: "flac", + Limitations: []Limitation{ + {Name: "audioChannels", Comparison: "Equals", Values: []string{"1", "2"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("rejects when Equals comparison doesn't match any value", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 6, SampleRate: 44100} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{"http"}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: "AudioCodec", + Name: "flac", + Limitations: []Limitation{ + {Name: "audioChannels", Comparison: "Equals", Values: []string{"1", "2"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + }) + }) + + Context("Source stream details", func() { + It("populates source stream correctly", func() { + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{"http"}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.SourceStream.Container).To(Equal("flac")) + Expect(decision.SourceStream.Codec).To(Equal("flac")) + Expect(decision.SourceStream.Bitrate).To(Equal(1000000)) + Expect(decision.SourceStream.SampleRate).To(Equal(96000)) + Expect(decision.SourceStream.BitDepth).To(Equal(24)) + Expect(decision.SourceStream.Channels).To(Equal(2)) + }) + }) + }) + + Describe("Token round-trip", func() { + It("creates and parses a direct play token", func() { + decision := &Decision{ + MediaID: "media-123", + CanDirectPlay: true, + } + token, err := svc.CreateToken(decision) + Expect(err).ToNot(HaveOccurred()) + Expect(token).ToNot(BeEmpty()) + + params, err := svc.ParseToken(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-123")) + Expect(params.DirectPlay).To(BeTrue()) + Expect(params.TargetFormat).To(BeEmpty()) + }) + + It("creates and parses a transcode token", func() { + decision := &Decision{ + MediaID: "media-456", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, + TargetChannels: 2, + } + token, err := svc.CreateToken(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := svc.ParseToken(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-456")) + Expect(params.DirectPlay).To(BeFalse()) + Expect(params.TargetFormat).To(Equal("mp3")) + Expect(params.TargetBitrate).To(Equal(256)) + Expect(params.TargetChannels).To(Equal(2)) + }) + + It("rejects an invalid token", func() { + _, err := svc.ParseToken("invalid-token") + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 0b9191fe5..31f5c53be 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -497,7 +497,7 @@ var _ = Describe("MediaFile", func() { Entry("returns just album name when tag is absent", true, Tags{}, "Album"), Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), ) - 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() @@ -518,6 +518,94 @@ 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 { diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 7bb7b36b5..be59e5851 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -623,7 +623,7 @@ func marshalJSONArray[T any](v []T) ([]byte, error) { type TranscodeDecision struct { CanDirectPlay bool `xml:"canDirectPlay,attr" json:"canDirectPlay"` CanTranscode bool `xml:"canTranscode,attr" json:"canTranscode"` - TranscodeReasons []string `xml:"transcodeReason,omitempty" json:"transcodeReasons,omitempty"` + 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"` @@ -632,14 +632,12 @@ type TranscodeDecision struct { // StreamDetails describes audio stream properties for transcoding decisions type StreamDetails struct { - Container string `xml:"container,attr,omitempty" json:"container,omitempty"` - Codec string `xml:"codec,attr,omitempty" json:"codec,omitempty"` - Bitrate int32 `xml:"bitrate,attr,omitempty" json:"bitrate,omitempty"` - SampleRate int32 `xml:"sampleRate,attr,omitempty" json:"sampleRate,omitempty"` - BitDepth int32 `xml:"bitDepth,attr,omitempty" json:"bitDepth,omitempty"` - Channels int32 `xml:"channels,attr,omitempty" json:"channels,omitempty"` - Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"` - Size int64 `xml:"size,attr,omitempty" json:"size,omitempty"` - IsLossless bool `xml:"isLossless,attr,omitempty" json:"isLossless,omitempty"` - IsDirectPlay bool `xml:"isDirectPlay,attr,omitempty" json:"isDirectPlay,omitempty"` + 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"` } diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 399e37510..d02e7a12e 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -48,9 +48,10 @@ type codecProfileReq struct { // limitationReq describes a specific codec limitation type limitationReq struct { - Property string `json:"property,omitempty"` - Condition string `json:"condition,omitempty"` - Value string `json:"value,omitempty"` + Name string `json:"name,omitempty"` + Comparison string `json:"comparison,omitempty"` + Values []string `json:"values,omitempty"` + Required bool `json:"required,omitempty"` } // toCore converts the API request struct to the core ClientInfo struct @@ -87,9 +88,10 @@ func (r *clientInfoRequest) toCore() *core.ClientInfo { } for _, lim := range cp.Limitations { coreCP.Limitations = append(coreCP.Limitations, core.Limitation{ - Property: lim.Property, - Condition: lim.Condition, - Value: lim.Value, + Name: lim.Name, + Comparison: lim.Comparison, + Values: lim.Values, + Required: lim.Required, }) } ci.CodecProfiles = append(ci.CodecProfiles, coreCP) @@ -156,27 +158,25 @@ func (api *Router) GetTranscodeDecision(_ http.ResponseWriter, r *http.Request) ErrorReason: decision.ErrorReason, TranscodeParams: transcodeParams, SourceStream: &responses.StreamDetails{ - Container: decision.SourceStream.Container, - Codec: decision.SourceStream.Codec, - Bitrate: int32(decision.SourceStream.Bitrate), - SampleRate: int32(decision.SourceStream.SampleRate), - BitDepth: int32(decision.SourceStream.BitDepth), - Channels: int32(decision.SourceStream.Channels), - Duration: int32(decision.SourceStream.Duration), - Size: decision.SourceStream.Size, - IsLossless: decision.SourceStream.IsLossless, + Protocol: "http", + Container: decision.SourceStream.Container, + Codec: decision.SourceStream.Codec, + AudioBitrate: int32(decision.SourceStream.Bitrate), + AudioSamplerate: int32(decision.SourceStream.SampleRate), + AudioBitdepth: int32(decision.SourceStream.BitDepth), + AudioChannels: int32(decision.SourceStream.Channels), }, } if decision.TranscodeStream != nil { response.TranscodeDecision.TranscodeStream = &responses.StreamDetails{ - Container: decision.TranscodeStream.Container, - Codec: decision.TranscodeStream.Codec, - Bitrate: int32(decision.TranscodeStream.Bitrate), - SampleRate: int32(decision.TranscodeStream.SampleRate), - BitDepth: int32(decision.TranscodeStream.BitDepth), - Channels: int32(decision.TranscodeStream.Channels), - IsLossless: decision.TranscodeStream.IsLossless, + Protocol: "http", + Container: decision.TranscodeStream.Container, + Codec: decision.TranscodeStream.Codec, + AudioBitrate: int32(decision.TranscodeStream.Bitrate), + AudioSamplerate: int32(decision.TranscodeStream.SampleRate), + AudioBitdepth: int32(decision.TranscodeStream.BitDepth), + AudioChannels: int32(decision.TranscodeStream.Channels), } } diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go new file mode 100644 index 000000000..9b695a28c --- /dev/null +++ b/server/subsonic/transcode_test.go @@ -0,0 +1,183 @@ +package subsonic + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/core" + "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 error when mediaId is missing", func() { + r := newGetRequest("mediaType=song") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when mediaType is missing", func() { + r := newGetRequest("mediaId=123") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for unsupported mediaType", func() { + r := newGetRequest("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 := newGetRequest("mediaId=notfound", "mediaType=song") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns a valid decision response", func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320000, Channels: 2, SampleRate: 44100}, + }) + mockTD.decision = &core.Decision{ + MediaID: "song-1", + CanDirectPlay: true, + SourceStream: core.StreamDetails{ + Container: "mp3", Codec: "mp3", Bitrate: 320000, + 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(320000))) + }) + + It("includes transcode stream when transcoding", func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2, SampleRate: 96000, BitDepth: 24}, + }) + mockTD.decision = &core.Decision{ + MediaID: "song-2", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, + TranscodeReasons: []string{"AudioCodecNotSupported"}, + SourceStream: core.StreamDetails{ + Container: "flac", Codec: "flac", Bitrate: 1000000, + SampleRate: 96000, BitDepth: 24, Channels: 2, + }, + TranscodeStream: &core.StreamDetails{ + Container: "mp3", Codec: "mp3", Bitrate: 256, + SampleRate: 96000, Channels: 2, + }, + } + mockTD.token = "transcode-token" + + r := newGetRequest("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("AudioCodecNotSupported")) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) + }) + }) + + Describe("GetTranscodeStream", func() { + It("returns error when mediaId is missing", func() { + r := newGetRequest("mediaType=song", "transcodeParams=abc") + _, err := router.GetTranscodeStream(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when transcodeParams is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song") + _, err := router.GetTranscodeStream(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for invalid token", func() { + mockTD.parseErr = model.ErrNotFound + r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token") + _, err := router.GetTranscodeStream(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when mediaId doesn't match token", func() { + mockTD.params = &core.TranscodeParams{MediaID: "other-id", DirectPlay: true} + r := newGetRequest("mediaId=wrong-id", "mediaType=song", "transcodeParams=valid-token") + _, err := router.GetTranscodeStream(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not match")) + }) + }) +}) + +// 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 core.TranscodeDecision +type mockTranscodeDecision struct { + decision *core.Decision + token string + tokenErr error + params *core.TranscodeParams + parseErr error +} + +func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *core.ClientInfo) (*core.Decision, error) { + if m.decision != nil { + return m.decision, nil + } + return &core.Decision{}, nil +} + +func (m *mockTranscodeDecision) CreateToken(_ *core.Decision) (string, error) { + return m.token, m.tokenErr +} + +func (m *mockTranscodeDecision) ParseToken(_ string) (*core.TranscodeParams, error) { + if m.parseErr != nil { + return nil, m.parseErr + } + return m.params, nil +}