mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
fix(subsonic): update codec limitation structure and decision logic for improved clarity
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
662e3cf723
commit
0c9e3221e9
@ -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 {
|
||||
|
||||
360
core/transcode_decision_test.go
Normal file
360
core/transcode_decision_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
183
server/subsonic/transcode_test.go
Normal file
183
server/subsonic/transcode_test.go
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user