fix(subsonic): update codec limitation structure and decision logic for improved clarity

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-02-05 15:27:19 -05:00
parent 662e3cf723
commit 0c9e3221e9
6 changed files with 716 additions and 67 deletions

View File

@ -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 {

View File

@ -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())
})
})
})

View File

@ -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 {

View File

@ -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"`
}

View File

@ -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),
}
}

View File

@ -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
}