844dffa2f1
Signed-off-by: Deluan <deluan@navidrome.org>
1191 lines
48 KiB
Go
1191 lines
48 KiB
Go
package transcode
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// withProbe pre-populates ProbeData on a MediaFile from its own fields,
|
|
// so ensureProbed short-circuits and tests don't need mock ffprobe results.
|
|
func withProbe(mf *model.MediaFile) *model.MediaFile {
|
|
probe := ffmpeg.AudioProbeResult{
|
|
Codec: mf.AudioCodec(),
|
|
BitRate: mf.BitRate,
|
|
SampleRate: mf.SampleRate,
|
|
BitDepth: mf.BitDepth,
|
|
Channels: mf.Channels,
|
|
}
|
|
data, _ := json.Marshal(probe)
|
|
mf.ProbeData = string(data)
|
|
return mf
|
|
}
|
|
|
|
var _ = Describe("Decider", func() {
|
|
var (
|
|
ds *tests.MockDataStore
|
|
ff *tests.MockFFmpeg
|
|
svc Decider
|
|
ctx context.Context
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctx = GinkgoT().Context()
|
|
ds = &tests.MockDataStore{
|
|
MockedProperty: &tests.MockedPropertyRepo{},
|
|
MockedTranscoding: &tests.MockTranscodingRepo{},
|
|
}
|
|
ff = tests.NewMockFFmpeg("")
|
|
auth.Init(ds)
|
|
svc = NewDecider(ds, ff)
|
|
})
|
|
|
|
Describe("MakeDecision", func() {
|
|
Context("Direct Play", func() {
|
|
It("allows direct play when profile matches", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
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 := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.TranscodeReasons).To(ContainElement("container not supported"))
|
|
})
|
|
|
|
It("rejects direct play when codec doesn't match", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "ALAC", BitRate: 1000, Channels: 2})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.TranscodeReasons).To(ContainElement("audio codec not supported"))
|
|
})
|
|
|
|
It("rejects direct play when channels exceed limit", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.TranscodeReasons).To(ContainElement("audio channels not supported"))
|
|
})
|
|
|
|
It("handles container aliases (aac -> m4a)", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"aac"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
|
|
It("handles container aliases (mp4 -> m4a)", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
|
|
It("handles container aliases (opus -> ogg)", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "opus", Codec: "Opus", BitRate: 165, Channels: 2, SampleRate: 48000})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
|
|
It("handles codec aliases (adts -> aac)", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"m4a"}, AudioCodecs: []string{"adts"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
|
|
It("allows when protocol list is empty (any protocol)", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
|
|
It("allows when both container and codec lists are empty (wildcard)", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{}, AudioCodecs: []string{}},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Context("MaxAudioBitrate constraint", func() {
|
|
It("revokes direct play when bitrate exceeds maxAudioBitrate", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2})
|
|
ci := &ClientInfo{
|
|
MaxAudioBitrate: 500, // kbps
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported"))
|
|
})
|
|
})
|
|
|
|
Context("Transcoding", func() {
|
|
It("selects transcoding when direct play isn't possible", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 256, // kbps
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
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)) // kbps
|
|
Expect(decision.TranscodeReasons).To(ContainElement("container not supported"))
|
|
})
|
|
|
|
It("rejects lossy to lossless transcoding", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2})
|
|
ci := &ClientInfo{
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "flac", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeFalse())
|
|
})
|
|
|
|
It("uses default bitrate when client doesn't specify", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16})
|
|
ci := &ClientInfo{
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetBitrate).To(Equal(160)) // mp3 default from mock transcoding repo
|
|
})
|
|
|
|
It("preserves lossy bitrate when under max", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 256, // kbps
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetBitrate).To(Equal(192)) // source bitrate in kbps
|
|
})
|
|
|
|
It("rejects format with no transcoding command available", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2})
|
|
ci := &ClientInfo{
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "wav", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeFalse())
|
|
})
|
|
|
|
It("applies maxAudioBitrate as final cap on transcoded stream", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2})
|
|
ci := &ClientInfo{
|
|
MaxAudioBitrate: 96, // kbps
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetBitrate).To(Equal(96)) // capped by maxAudioBitrate
|
|
})
|
|
|
|
It("selects first valid transcoding profile in order", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 320,
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetFormat).To(Equal("opus"))
|
|
})
|
|
})
|
|
|
|
Context("Lossless to lossless transcoding", func() {
|
|
It("allows lossless to lossless when samplerate needs downsampling", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1})
|
|
ci := &ClientInfo{
|
|
MaxAudioBitrate: 1000,
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetFormat).To(Equal("mp3"))
|
|
})
|
|
|
|
It("sets IsLossless=true on transcoded stream when target is lossless", func() {
|
|
// Transcoding to mp3 (lossy) should result in IsLossless=false.
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 320,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TranscodeStream.IsLossless).To(BeFalse()) // mp3 is lossy
|
|
})
|
|
})
|
|
|
|
Context("No compatible profile", func() {
|
|
It("returns error when nothing matches", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6})
|
|
ci := &ClientInfo{}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
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 on direct play", func() {
|
|
It("rejects direct play when codec limitation fails (required)", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "mp3",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported"))
|
|
})
|
|
|
|
It("allows direct play when optional limitation fails", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "mp3",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: false},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
|
|
It("handles Equals comparison with multiple values", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "flac",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
|
|
It("rejects when Equals comparison doesn't match any value", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "flac",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
})
|
|
|
|
It("rejects direct play when audioProfile limitation fails (required)", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "aac",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
// Source profile is empty (not yet populated from scanner), so Equals("LC") fails
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.TranscodeReasons).To(ContainElement("audio profile not supported"))
|
|
})
|
|
|
|
It("allows direct play when audioProfile limitation is optional", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "aac",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: false},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
|
|
It("rejects direct play due to samplerate limitation", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "flac",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.TranscodeReasons).To(ContainElement("audio samplerate not supported"))
|
|
})
|
|
})
|
|
|
|
Context("Codec limitations on transcoded output", func() {
|
|
It("applies bitrate limitation to transcoded stream", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 192, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
MaxAudioBitrate: 96, // force transcode
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "mp3",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"96"}, Required: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TranscodeStream.Bitrate).To(Equal(96))
|
|
})
|
|
|
|
It("applies channel limitation to transcoded stream", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 320,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "mp3",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioChannels, Comparison: ComparisonLessThanEqual, Values: []string{"2"}, Required: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TranscodeStream.Channels).To(Equal(2))
|
|
})
|
|
|
|
It("applies samplerate limitation to transcoded stream", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 320,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "mp3",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
|
|
})
|
|
|
|
It("applies bitdepth limitation to transcoded stream", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
|
|
ci := &ClientInfo{
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "flac",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TranscodeStream.BitDepth).To(Equal(16))
|
|
Expect(decision.TargetBitDepth).To(Equal(16))
|
|
})
|
|
|
|
It("preserves source bit depth when no limitation applies", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 24})
|
|
ci := &ClientInfo{
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
|
|
Expect(decision.TargetBitDepth).To(Equal(24))
|
|
})
|
|
|
|
It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 320,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "mp3",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioSamplerate, Comparison: ComparisonGreaterThanEqual, Values: []string{"96000"}, Required: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Context("DSD sample rate conversion", func() {
|
|
It("converts DSD sample rate to PCM-equivalent in decision", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 320,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetFormat).To(Equal("mp3"))
|
|
// DSD64 2822400 / 8 = 352800, capped by MP3 max of 48000
|
|
Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
|
|
Expect(decision.TargetSampleRate).To(Equal(48000))
|
|
// DSD 1-bit → 24-bit PCM
|
|
Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
|
|
Expect(decision.TargetBitDepth).To(Equal(24))
|
|
})
|
|
|
|
It("converts DSD sample rate for FLAC target without codec limit", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
|
|
ci := &ClientInfo{
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetFormat).To(Equal("flac"))
|
|
// DSD64 2822400 / 8 = 352800, FLAC has no hard max
|
|
Expect(decision.TranscodeStream.SampleRate).To(Equal(352800))
|
|
Expect(decision.TargetSampleRate).To(Equal(352800))
|
|
// DSD 1-bit → 24-bit PCM
|
|
Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
|
|
Expect(decision.TargetBitDepth).To(Equal(24))
|
|
})
|
|
|
|
It("applies codec profile limit to DSD-converted FLAC sample rate", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
|
|
ci := &ClientInfo{
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "flac",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
// DSD64 2822400 / 8 = 352800, capped by codec profile limit of 48000
|
|
Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
|
|
Expect(decision.TargetSampleRate).To(Equal(48000))
|
|
// DSD 1-bit → 24-bit PCM
|
|
Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
|
|
Expect(decision.TargetBitDepth).To(Equal(24))
|
|
})
|
|
|
|
It("applies audioBitdepth limitation to DSD-converted bit depth", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
|
|
ci := &ClientInfo{
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
|
},
|
|
CodecProfiles: []CodecProfile{
|
|
{
|
|
Type: CodecProfileTypeAudio,
|
|
Name: "flac",
|
|
Limitations: []Limitation{
|
|
{Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
// DSD 1-bit → 24-bit PCM, then capped by codec profile limit to 16-bit
|
|
Expect(decision.TranscodeStream.BitDepth).To(Equal(16))
|
|
Expect(decision.TargetBitDepth).To(Equal(16))
|
|
})
|
|
})
|
|
|
|
Context("Probe-based lossless detection", func() {
|
|
It("uses probe codec name for lossless detection", func() {
|
|
// WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv"
|
|
mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}
|
|
probe := ffmpeg.AudioProbeResult{
|
|
Codec: "wavpack", BitRate: 1000, SampleRate: 44100, BitDepth: 16, Channels: 2,
|
|
}
|
|
data, _ := json.Marshal(probe)
|
|
mf.ProbeData = string(data)
|
|
|
|
ci := &ClientInfo{
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
MaxTranscodingAudioBitrate: 256,
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.SourceStream.IsLossless).To(BeTrue())
|
|
Expect(decision.SourceStream.Codec).To(Equal("wavpack"))
|
|
// Lossless source transcoding to MP3 should use MaxTranscodingAudioBitrate
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TranscodeStream.Bitrate).To(Equal(256))
|
|
})
|
|
|
|
It("detects lossy from probe codec name", func() {
|
|
mf := &model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2, SampleRate: 48000}
|
|
probe := ffmpeg.AudioProbeResult{
|
|
Codec: "vorbis", BitRate: 192, SampleRate: 48000, BitDepth: 0, Channels: 2,
|
|
}
|
|
data, _ := json.Marshal(probe)
|
|
mf.ProbeData = string(data)
|
|
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"ogg"}, AudioCodecs: []string{"vorbis"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.SourceStream.IsLossless).To(BeFalse())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Context("Opus fixed sample rate", func() {
|
|
It("sets Opus output to 48000Hz regardless of input", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 128,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetFormat).To(Equal("opus"))
|
|
// Opus always outputs 48000Hz
|
|
Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
|
|
Expect(decision.TargetSampleRate).To(Equal(48000))
|
|
})
|
|
|
|
It("sets Opus output to 48000Hz even for 96kHz input", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 128,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
|
|
})
|
|
})
|
|
|
|
Context("Container vs format separation", func() {
|
|
It("preserves mp4 container when falling back to aac format", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 256,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp4", AudioCodec: "aac", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
// TargetFormat is the internal format used for transcoding ("aac")
|
|
Expect(decision.TargetFormat).To(Equal("aac"))
|
|
// Container in the response preserves what the client asked ("mp4")
|
|
Expect(decision.TranscodeStream.Container).To(Equal("mp4"))
|
|
Expect(decision.TranscodeStream.Codec).To(Equal("aac"))
|
|
})
|
|
|
|
It("uses container as format when container matches transcoding config", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 256,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetFormat).To(Equal("mp3"))
|
|
Expect(decision.TranscodeStream.Container).To(Equal("mp3"))
|
|
})
|
|
})
|
|
|
|
Context("MP3 max sample rate", func() {
|
|
It("caps sample rate at 48000 for MP3", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 320,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
|
|
})
|
|
|
|
It("preserves sample rate at 44100 for MP3", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 320,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TranscodeStream.SampleRate).To(Equal(44100))
|
|
})
|
|
})
|
|
|
|
Context("AAC max sample rate", func() {
|
|
It("caps sample rate at 96000 for AAC", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 320,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
// DSD64 2822400 / 8 = 352800, capped by AAC max of 96000
|
|
Expect(decision.TranscodeStream.SampleRate).To(Equal(96000))
|
|
})
|
|
})
|
|
|
|
Context("Typed transcode reasons from multiple profiles", func() {
|
|
It("collects reasons from each failed direct play profile", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", Codec: "Vorbis", BitRate: 128, Channels: 2, SampleRate: 48000})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
|
|
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
|
|
{Containers: []string{"m4a", "mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.TranscodeReasons).To(HaveLen(3))
|
|
Expect(decision.TranscodeReasons[0]).To(Equal("container not supported"))
|
|
Expect(decision.TranscodeReasons[1]).To(Equal("container not supported"))
|
|
Expect(decision.TranscodeReasons[2]).To(Equal("container not supported"))
|
|
})
|
|
})
|
|
|
|
Context("Source stream details", func() {
|
|
It("populates source stream correctly with kbps bitrate", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.SourceStream.Container).To(Equal("flac"))
|
|
Expect(decision.SourceStream.Codec).To(Equal("flac"))
|
|
Expect(decision.SourceStream.Bitrate).To(Equal(1000)) // kbps
|
|
Expect(decision.SourceStream.SampleRate).To(Equal(96000))
|
|
Expect(decision.SourceStream.BitDepth).To(Equal(24))
|
|
Expect(decision.SourceStream.Channels).To(Equal(2))
|
|
})
|
|
})
|
|
|
|
Context("Server-side player transcoding override", func() {
|
|
It("forces transcoding when override targets a different format", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
Name: "TestClient",
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
// Set server override in context
|
|
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
|
|
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
|
|
|
|
decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetFormat).To(Equal("mp3"))
|
|
Expect(decision.TargetBitrate).To(Equal(192))
|
|
})
|
|
|
|
It("allows direct play when source matches forced format and bitrate is within cap", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
Name: "TestClient",
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256})
|
|
|
|
decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
Expect(decision.CanTranscode).To(BeFalse())
|
|
})
|
|
|
|
It("transcodes when source bitrate exceeds the forced cap", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
Name: "TestClient",
|
|
}
|
|
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
|
|
|
|
decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetFormat).To(Equal("mp3"))
|
|
Expect(decision.TargetBitrate).To(Equal(192))
|
|
})
|
|
|
|
It("uses player MaxBitRate over transcoding DefaultBitRate", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
Name: "TestClient",
|
|
}
|
|
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
|
|
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320})
|
|
|
|
decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetFormat).To(Equal("mp3"))
|
|
Expect(decision.TargetBitrate).To(Equal(320))
|
|
})
|
|
|
|
It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
Name: "TestClient",
|
|
}
|
|
overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0})
|
|
overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
|
|
|
|
decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetFormat).To(Equal("mp3"))
|
|
// With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock)
|
|
Expect(decision.TargetBitrate).To(Equal(160))
|
|
})
|
|
|
|
It("does not apply override when no transcoding is in context", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
Name: "TestClient",
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
// No override in context — client profiles used as-is
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
|
|
})
|
|
|
|
Context("Player MaxBitRate cap", func() {
|
|
It("applies player MaxBitRate cap when client has no limit", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
|
ci := &ClientInfo{
|
|
Name: "TestClient",
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac", "mp3"}, AudioCodecs: []string{"flac", "mp3"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
|
|
|
|
decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// Source bitrate 1000 > player cap 320, so direct play is not possible
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
// Lossless→lossy should use MaxAudioBitrate (320) as target, not format default
|
|
Expect(decision.TargetBitrate).To(Equal(320))
|
|
})
|
|
|
|
It("uses client limit when it is more restrictive than player MaxBitRate", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
|
ci := &ClientInfo{
|
|
Name: "TestClient",
|
|
MaxAudioBitrate: 256,
|
|
MaxTranscodingAudioBitrate: 256,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
|
|
|
|
decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
// Client limit 256 < player cap 500, so player cap doesn't apply; client limit wins
|
|
Expect(decision.TargetBitrate).To(Equal(256))
|
|
})
|
|
|
|
It("does not cap when player MaxBitRate is 0", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
|
|
ci := &ClientInfo{
|
|
Name: "TestClient",
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0})
|
|
|
|
decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Context("Format-aware default bitrate", func() {
|
|
It("uses opus default bitrate from DB", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16})
|
|
ci := &ClientInfo{
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetBitrate).To(Equal(96)) // opus default from mock
|
|
})
|
|
|
|
It("uses aac default bitrate from DB", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
|
ci := &ClientInfo{
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetBitrate).To(Equal(256)) // aac default from mock
|
|
})
|
|
|
|
It("falls back to 256 for unknown format", func() {
|
|
bitrate := lookupDefaultBitrate(ctx, ds, "xyz")
|
|
Expect(bitrate).To(Equal(fallbackBitrate))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("ensureProbed", func() {
|
|
var mockMFRepo *tests.MockMediaFileRepo
|
|
|
|
BeforeEach(func() {
|
|
mockMFRepo = tests.CreateMockMediaFileRepo()
|
|
ds.MockedMediaFile = mockMFRepo
|
|
})
|
|
|
|
It("calls ffprobe and populates ProbeData when empty", func() {
|
|
mf := &model.MediaFile{ID: "probe-1", Suffix: "mp3", BitRate: 320, Channels: 2}
|
|
mockMFRepo.SetData(model.MediaFiles{*mf})
|
|
|
|
ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{
|
|
Codec: "mp3", BitRate: 320, SampleRate: 44100, Channels: 2,
|
|
}
|
|
|
|
svc := NewDecider(ds, ff).(*deciderService)
|
|
probe, err := svc.ensureProbed(ctx, mf)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mf.ProbeData).ToNot(BeEmpty())
|
|
Expect(probe).ToNot(BeNil())
|
|
Expect(probe.Codec).To(Equal("mp3"))
|
|
Expect(probe.BitRate).To(Equal(320))
|
|
Expect(probe.SampleRate).To(Equal(44100))
|
|
Expect(probe.Channels).To(Equal(2))
|
|
|
|
// Verify persisted to DB
|
|
stored := mockMFRepo.Data["probe-1"]
|
|
Expect(stored.ProbeData).To(Equal(mf.ProbeData))
|
|
})
|
|
|
|
It("skips ffprobe when ProbeData is already set", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "probe-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2})
|
|
|
|
// Set error on mock — if ffprobe were called, this would fail
|
|
ff.Error = fmt.Errorf("should not be called")
|
|
|
|
svc := NewDecider(ds, ff).(*deciderService)
|
|
probe, err := svc.ensureProbed(ctx, mf)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(probe).To(BeNil())
|
|
})
|
|
|
|
It("returns error when ffprobe fails", func() {
|
|
mf := &model.MediaFile{ID: "probe-3", Suffix: "mp3"}
|
|
ff.Error = fmt.Errorf("ffprobe not found")
|
|
|
|
svc := NewDecider(ds, ff).(*deciderService)
|
|
_, err := svc.ensureProbed(ctx, mf)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("probing media file"))
|
|
Expect(mf.ProbeData).To(BeEmpty())
|
|
})
|
|
|
|
It("skips ffprobe when DevEnableMediaFileProbe is false", func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.DevEnableMediaFileProbe = false
|
|
|
|
mf := &model.MediaFile{ID: "probe-4", Suffix: "mp3"}
|
|
// Set a result — if ffprobe were called, ProbeData would be populated
|
|
ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{Codec: "mp3"}
|
|
|
|
svc := NewDecider(ds, ff).(*deciderService)
|
|
probe, err := svc.ensureProbed(ctx, mf)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(probe).To(BeNil())
|
|
Expect(mf.ProbeData).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
})
|