* fix(msi): include ffprobe executable in MSI build Signed-off-by: Deluan <deluan@navidrome.org> * feat(ffmpeg): add IsProbeAvailable() to FFmpeg interface Add runtime check for ffprobe binary availability with cached result and startup logging. When ffprobe is missing, logs a warning at startup. * feat(stream): guard MakeDecision behind ffprobe availability When ffprobe is not available, MakeDecision returns a decision with ErrorReason set and both CanDirectPlay and CanTranscode false, instead of failing with an opaque exec error. * feat(subsonic): only advertise transcoding extension when ffprobe is available The OpenSubsonic transcoding extension is now conditionally included based on ffprobe availability, so clients know not to call getTranscodeDecision when ffprobe is missing. * refactor(ffmpeg): move ffprobe startup warning to initial_setup Move the ffprobe availability warning from the lazy IsProbeAvailable() check to checkFFmpegInstallation() in server/initial_setup.go, alongside the existing ffmpeg warning. This ensures the warning appears at startup rather than on first endpoint call. * fix(e2e): set noopFFmpeg.IsProbeAvailable to true The e2e tests use pre-populated probe data and don't need a real ffprobe binary. Setting IsProbeAvailable to true allows the transcode decision logic to proceed normally in e2e tests. * fix(stream): only guard on ffprobe when probing is needed Move the IsProbeAvailable() guard inside the SkipProbe check so that legacy stream requests (which pass SkipProbe: true) are not blocked when ffprobe is missing. The guard only applies when probing is actually required (i.e., getTranscodeDecision endpoint). * refactor(stream): fall back to tag metadata when ffprobe is unavailable Instead of blocking getTranscodeDecision when ffprobe is missing, fall back to tag-based metadata (same behavior as /rest/stream). The transcoding extension is always advertised. A startup warning still alerts admins when ffprobe is not found. * fix(stream): downgrade ffprobe-unavailable log to Debug Avoids log spam when clients call getTranscodeDecision repeatedly without ffprobe installed. The startup warning in initial_setup.go already alerts admins at Warn level. --------- Signed-off-by: Deluan <deluan@navidrome.org>
1240 lines
50 KiB
Go
1240 lines
50 KiB
Go
package stream
|
|
|
|
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 TranscodeDecider
|
|
ctx context.Context
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctx = GinkgoT().Context()
|
|
ds = &tests.MockDataStore{
|
|
MockedProperty: &tests.MockedPropertyRepo{},
|
|
MockedTranscoding: &tests.MockTranscodingRepo{},
|
|
}
|
|
ff = tests.NewMockFFmpeg("")
|
|
auth.Init(ds)
|
|
svc = NewTranscodeDecider(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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.TranscodeReasons).To(ContainElement(And(
|
|
ContainSubstring("container 'flac' not supported"),
|
|
ContainSubstring("[mp3]"),
|
|
)))
|
|
})
|
|
|
|
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, TranscodeOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.TranscodeReasons).To(ContainElement(And(
|
|
ContainSubstring("audio codec 'alac' not supported"),
|
|
ContainSubstring("[m4a/aac]"),
|
|
)))
|
|
})
|
|
|
|
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, TranscodeOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.TranscodeReasons).To(ContainElement(And(
|
|
ContainSubstring("audio channels 6 not supported"),
|
|
ContainSubstring("[flac]"),
|
|
ContainSubstring("(max 2)"),
|
|
)))
|
|
})
|
|
|
|
It("accepts WAV source against a wav codec profile (pcm->wav bridge)", func() {
|
|
// ffprobe normalizes PCM variants (pcm_s16le etc) to codec "pcm", but
|
|
// browsers advertise WAV support as audioCodecs:["wav"] via audio/wav MIME.
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "wav", Codec: "pcm", BitRate: 1411, Channels: 2})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"wav"}, AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
|
|
It("does not accept AIFF (pcm in non-wav container) against a wav codec profile", func() {
|
|
// AIFF files also normalize to codec="pcm" but use container="aiff".
|
|
// Without the container guard they would falsely match a codec-only
|
|
// ["wav"] profile and be direct-played as if they were WAV.
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "aiff", Codec: "pcm", BitRate: 1411, Channels: 2})
|
|
ci := &ClientInfo{
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.TranscodeReasons).To(ContainElement(ContainSubstring("audio codec 'pcm'")))
|
|
})
|
|
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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(And(
|
|
ContainSubstring("container 'flac' not supported"),
|
|
ContainSubstring("[mp3]"),
|
|
)))
|
|
})
|
|
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanDirectPlay).To(BeFalse())
|
|
Expect(decision.TranscodeReasons).To(HaveLen(3))
|
|
Expect(decision.TranscodeReasons[0]).To(ContainSubstring("container 'ogg' not supported"))
|
|
Expect(decision.TranscodeReasons[0]).To(ContainSubstring("[flac]"))
|
|
Expect(decision.TranscodeReasons[1]).To(ContainSubstring("container 'ogg' not supported"))
|
|
Expect(decision.TranscodeReasons[1]).To(ContainSubstring("[mp3/mp3]"))
|
|
Expect(decision.TranscodeReasons[2]).To(ContainSubstring("container 'ogg' not supported"))
|
|
Expect(decision.TranscodeReasons[2]).To(ContainSubstring("[m4a,mp4/aac]"))
|
|
})
|
|
})
|
|
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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, TranscodeOptions{})
|
|
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 := NewTranscodeDecider(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 := NewTranscodeDecider(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 := NewTranscodeDecider(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 := NewTranscodeDecider(ds, ff).(*deciderService)
|
|
probe, err := svc.ensureProbed(ctx, mf)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(probe).To(BeNil())
|
|
Expect(mf.ProbeData).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
})
|