mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* feat(smartplaylist): support isMissing/isPresent on mbz_* and lyrics fields Mark the six mbz_* MusicBrainz ID columns and the lyrics column as Nullable in the criteria field map, then extend missingExpr to handle string columns where absence is encoded as NULL or empty string (plus '[]' for lyrics). The Numeric/Boolean path (ReplayGain) is preserved via an explicit type check. * refactor(model): make MediaFile BPM and BitDepth nullable pointers Convert BPM and BitDepth fields in model.MediaFile from int to *int so that 'tag absent' is distinguishable from zero. The metadata mapper now uses NullableFloat for BPM (nil when absent or zero/unparseable) and only sets BitDepth when the audio property is non-zero (lossy codecs report 0). All read sites use gg.V() for zero-fallback deref so Subsonic API output and transcoding behaviour are byte-identical to before. The persistence layer bridges the existing NOT NULL DB columns by coercing nil to 0 on write and 0 back to nil on read in PostMapArgs/PostScan; a later migration task will drop those constraints. Hash upgrade safety is verified by a new MediaFile.Hash describe block: nil *int hashes identically to the old int(0) default via ZeroNil+IgnoreZeroValue, so no files will be spuriously re-imported after this change. Extra files touched beyond the plan's list: core/stream/legacy_client_test.go (BitDepth in model.MediaFile literals), persistence/mediafile_repository.go (NOT NULL bridge). * test(model): pin pre-conversion golden hashes for BPM/BitDepth * feat(smartplaylist): support isMissing/isPresent on bpm and bitDepth * feat(db): make bpm and bit_depth columns nullable, backfill 0 to NULL Drop the NOT NULL constraint on media_file.bpm and bit_depth via a lossless migration that converts legacy 0-means-absent values to real NULL. Remove the temporary shim in PostScan/PostMapArgs that was bridging the old NOT NULL columns to the *int model fields. Add round-trip persistence tests asserting NULL storage for nil pointers and correct value round-trip for non-nil pointers. * test(e2e): verify isMissing/isPresent partition for nullable fields Add DescribeTable covering bpm, bitdepth, lyrics, and mbz_recording_id: for each field, isMissing + isPresent song counts must equal the total library count, proving the nullable-column SQL is exhaustive and correct. * test(e2e): seed bpm tag so isMissing/isPresent partition is non-trivial * fix(model): omit bitDepth from JSON when absent instead of emitting null * feat(smartplaylist): support isMissing/isPresent on more string fields Enable isMissing/isPresent operators for album, comment, catalognumber, discsubtitle, albumcomment, sorttitle, sortalbum, sortartist, sortalbumartist, and explicitstatus by marking them Nullable in fieldMap. * refactor(smartplaylist): unify missingExpr column logic into one flow Collapse the numeric/string fork in missingExpr into a single empties-driven loop (numeric/boolean fields simply have no empties), and replace the duplicated IsTag/IsRole guard with a three-way switch that expresses the dispatch model once. No SQL semantics change for string fields; numeric/boolean fields now emit a single-element Or/And which squirrel parenthesizes (e.g. `(col IS NULL)` instead of bare `col IS NULL`) — update the affected test expectations accordingly.
1182 lines
48 KiB
Go
1182 lines
48 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/navidrome/navidrome/utils/gg"
|
|
. "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: gg.V(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: new(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: new(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: new(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: new(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: new(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: new(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: new(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: new(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: new(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: new(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: new(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: new(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: new(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: new(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: new(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("Codec channel limits", func() {
|
|
It("clamps 6-channel FLAC to 2 channels when transcoding to MP3", func() {
|
|
// Regression test for #5336: ffmpeg's mp3 encoder rejects >2 channels.
|
|
// The decider must clamp to the codec's hard limit even when no
|
|
// transcoding profile MaxAudioChannels is configured.
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(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.TargetFormat).To(Equal("mp3"))
|
|
Expect(decision.TranscodeStream.Channels).To(Equal(2))
|
|
Expect(decision.TargetChannels).To(Equal(2))
|
|
})
|
|
|
|
It("honors a stricter profile MaxAudioChannels over the codec clamp", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 320,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 1},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TranscodeStream.Channels).To(Equal(1))
|
|
Expect(decision.TargetChannels).To(Equal(1))
|
|
})
|
|
|
|
It("applies the codec clamp when the profile limit is looser", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 320,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 4},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TranscodeStream.Channels).To(Equal(2))
|
|
Expect(decision.TargetChannels).To(Equal(2))
|
|
})
|
|
|
|
It("passes channels through unchanged for codecs with no hard limit", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
|
|
ci := &ClientInfo{
|
|
MaxTranscodingAudioBitrate: 320,
|
|
TranscodingProfiles: []Profile{
|
|
{Container: "m4a", AudioCodec: "aac", Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(decision.CanTranscode).To(BeTrue())
|
|
Expect(decision.TargetFormat).To(Equal("aac"))
|
|
Expect(decision.TranscodeStream.Channels).To(Equal(6))
|
|
Expect(decision.TargetChannels).To(Equal(6))
|
|
})
|
|
})
|
|
|
|
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: new(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: new(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: new(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: new(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: new(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: new(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: new(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: new(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: new(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 context is ignored by MakeDecision", func() {
|
|
It("ignores transcoding override 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}},
|
|
},
|
|
}
|
|
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(BeTrue())
|
|
})
|
|
|
|
It("ignores player MaxBitRate in context", func() {
|
|
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
|
ci := &ClientInfo{
|
|
Name: "TestClient",
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
}
|
|
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
|
|
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: new(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: new(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())
|
|
})
|
|
})
|
|
|
|
})
|