diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index c68142f08..ebb2031a1 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -1,6 +1,6 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5" +//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo" //go:build !wireinject // +build !wireinject @@ -106,7 +106,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(manager) - decider := transcode.NewDecider(dataStore) + decider := transcode.NewDecider(dataStore, fFmpeg) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, decider) return router } diff --git a/core/transcode/codec.go b/core/transcode/codec.go index 3e7dd3578..00635b860 100644 --- a/core/transcode/codec.go +++ b/core/transcode/codec.go @@ -2,6 +2,22 @@ package transcode import "strings" +// normalizeProbeCodec maps ffprobe codec_name values to the simplified internal +// codec names used throughout Navidrome (matching inferCodecFromSuffix output). +// Most ffprobe names match directly; this handles the exceptions. +func normalizeProbeCodec(codec string) string { + c := strings.ToLower(codec) + // DSD variants: dsd_lsbf_planar, dsd_msbf_planar, dsd_lsbf, dsd_msbf + if strings.HasPrefix(c, "dsd") { + return "dsd" + } + // PCM variants: pcm_s16le, pcm_s24le, pcm_s32be, pcm_f32le, etc. + if strings.HasPrefix(c, "pcm_") { + return "pcm" + } + return c +} + // isLosslessFormat returns true if the format is a lossless audio codec/format. // Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats // ffmpeg can produce as output (a smaller set). This function covers all known lossless formats diff --git a/core/transcode/limitations.go b/core/transcode/limitations.go index e3c4361f3..a146509b5 100644 --- a/core/transcode/limitations.go +++ b/core/transcode/limitations.go @@ -3,8 +3,6 @@ package transcode import ( "strconv" "strings" - - "github.com/navidrome/navidrome/model" ) // adjustResult represents the outcome of applying a limitation to a transcoded stream value @@ -16,29 +14,28 @@ const ( adjustCannotFit // Cannot satisfy the limitation (reject this profile) ) -// checkLimitations checks codec profile limitations against source media. +// checkLimitations checks codec profile limitations against source stream details. // Returns "" if all limitations pass, or a typed reason string for the first failure. -func checkLimitations(mf *model.MediaFile, sourceBitrate int, limitations []Limitation) string { +func checkLimitations(src *StreamDetails, limitations []Limitation) string { for _, lim := range limitations { var ok bool var reason string switch lim.Name { case LimitationAudioChannels: - ok = checkIntLimitation(mf.Channels, lim.Comparison, lim.Values) + ok = checkIntLimitation(src.Channels, lim.Comparison, lim.Values) reason = "audio channels not supported" case LimitationAudioSamplerate: - ok = checkIntLimitation(mf.SampleRate, lim.Comparison, lim.Values) + ok = checkIntLimitation(src.SampleRate, lim.Comparison, lim.Values) reason = "audio samplerate not supported" case LimitationAudioBitrate: - ok = checkIntLimitation(sourceBitrate, lim.Comparison, lim.Values) + ok = checkIntLimitation(src.Bitrate, lim.Comparison, lim.Values) reason = "audio bitrate not supported" case LimitationAudioBitdepth: - ok = checkIntLimitation(mf.BitDepth, lim.Comparison, lim.Values) + ok = checkIntLimitation(src.BitDepth, lim.Comparison, lim.Values) reason = "audio bitdepth not supported" case LimitationAudioProfile: - // TODO: populate source profile when MediaFile has audio profile info - ok = checkStringLimitation("", lim.Comparison, lim.Values) + ok = checkStringLimitation(src.Profile, lim.Comparison, lim.Values) reason = "audio profile not supported" default: continue diff --git a/core/transcode/transcode.go b/core/transcode/transcode.go index 254d56424..300af84b3 100644 --- a/core/transcode/transcode.go +++ b/core/transcode/transcode.go @@ -7,7 +7,10 @@ import ( "strings" "time" + "encoding/json" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -17,14 +20,16 @@ const ( defaultBitrate = 256 // kbps ) -func NewDecider(ds model.DataStore) Decider { +func NewDecider(ds model.DataStore, ff ffmpeg.FFmpeg) Decider { return &deciderService{ ds: ds, + ff: ff, } } type deciderService struct { ds model.DataStore + ff ffmpeg.FFmpeg } func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) { @@ -33,25 +38,28 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, SourceUpdatedAt: mf.UpdatedAt, } - sourceBitrate := mf.BitRate // kbps + if err := s.ensureProbed(ctx, mf); err != nil { + return nil, err + } - log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", mf.Suffix, - "codec", mf.AudioCodec(), "bitrate", sourceBitrate, "channels", mf.Channels, - "sampleRate", mf.SampleRate, "lossless", mf.IsLossless(), "client", clientInfo.Name) - - // Build source stream details + // Build source stream details (uses probe data if available) decision.SourceStream = buildSourceStream(mf) + src := &decision.SourceStream + + log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container, + "codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels, + "sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name) // Check global bitrate constraint first. - if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate { + if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate { log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play", - "sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate) + "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate) decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported") // Skip direct play profiles entirely — global constraint fails } else { // Try direct play profiles, collecting reasons for each failure for _, profile := range clientInfo.DirectPlayProfiles { - if reason := s.checkDirectPlayProfile(mf, sourceBitrate, &profile, clientInfo); reason == "" { + if reason := s.checkDirectPlayProfile(src, &profile, clientInfo); reason == "" { decision.CanDirectPlay = true decision.TranscodeReasons = nil // Clear any previously collected reasons break @@ -63,13 +71,13 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, // If direct play is possible, we're done if decision.CanDirectPlay { - log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", mf.Suffix, "codec", mf.AudioCodec()) + log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec) return decision, nil } // Try transcoding profiles (in order of preference) for _, profile := range clientInfo.TranscodingProfiles { - if ts, transcodeFormat := s.computeTranscodedStream(ctx, mf, sourceBitrate, &profile, clientInfo); ts != nil { + if ts, transcodeFormat := s.computeTranscodedStream(ctx, src, &profile, clientInfo); ts != nil { decision.CanTranscode = true decision.TargetFormat = transcodeFormat decision.TargetBitrate = ts.Bitrate @@ -91,53 +99,75 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, if !decision.CanDirectPlay && !decision.CanTranscode { decision.ErrorReason = "no compatible playback profile found" log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID, - "container", mf.Suffix, "codec", mf.AudioCodec(), "reasons", decision.TranscodeReasons) + "container", src.Container, "codec", src.Codec, "reasons", decision.TranscodeReasons) } return decision, nil } func buildSourceStream(mf *model.MediaFile) StreamDetails { - return StreamDetails{ - Container: mf.Suffix, - Codec: mf.AudioCodec(), - Bitrate: mf.BitRate, - SampleRate: mf.SampleRate, - BitDepth: mf.BitDepth, - Channels: mf.Channels, - Duration: mf.Duration, - Size: mf.Size, - IsLossless: mf.IsLossless(), + sd := StreamDetails{ + Container: mf.Suffix, + Duration: mf.Duration, + Size: mf.Size, } + + // Use probe data if available for authoritative values + if probe, err := parseProbeData(mf.ProbeData); err == nil && probe != nil { + sd.Codec = normalizeProbeCodec(probe.Codec) + sd.Profile = probe.Profile + sd.Bitrate = probe.BitRate + sd.SampleRate = probe.SampleRate + sd.BitDepth = probe.BitDepth + sd.Channels = probe.Channels + } else { + sd.Codec = mf.AudioCodec() + sd.Bitrate = mf.BitRate + sd.SampleRate = mf.SampleRate + sd.BitDepth = mf.BitDepth + sd.Channels = mf.Channels + } + + sd.IsLossless = mf.IsLossless() + return sd +} + +func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) { + if data == "" { + return nil, nil + } + var result ffmpeg.AudioProbeResult + err := json.Unmarshal([]byte(data), &result) + return &result, err } // checkDirectPlayProfile returns "" if the profile matches (direct play OK), // or a typed reason string if it doesn't match. -func (s *deciderService) checkDirectPlayProfile(mf *model.MediaFile, sourceBitrate int, profile *DirectPlayProfile, clientInfo *ClientInfo) string { +func (s *deciderService) checkDirectPlayProfile(src *StreamDetails, profile *DirectPlayProfile, clientInfo *ClientInfo) string { // Check protocol (only http for now) if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) { return "protocol not supported" } // Check container - if len(profile.Containers) > 0 && !matchesContainer(mf.Suffix, profile.Containers) { + if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) { return "container not supported" } // Check codec - if len(profile.AudioCodecs) > 0 && !matchesCodec(mf.AudioCodec(), profile.AudioCodecs) { + if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) { return "audio codec not supported" } // Check channels - if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels { + if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels { return "audio channels not supported" } // Check codec-specific limitations for _, codecProfile := range clientInfo.CodecProfiles { - if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(mf.AudioCodec(), []string{codecProfile.Name}) { - if reason := checkLimitations(mf, sourceBitrate, codecProfile.Limitations); reason != "" { + if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(src.Codec, []string{codecProfile.Name}) { + if reason := checkLimitations(src, codecProfile.Limitations); reason != "" { return reason } } @@ -150,7 +180,7 @@ func (s *deciderService) checkDirectPlayProfile(mf *model.MediaFile, sourceBitra // Returns the stream details and the internal transcoding format (which may differ from the // response container when a codec fallback occurs, e.g., "mp4"→"aac"). // Returns nil, "" if the profile cannot produce a valid output. -func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model.MediaFile, sourceBitrate int, profile *Profile, clientInfo *ClientInfo) (*StreamDetails, string) { +func (s *deciderService) computeTranscodedStream(ctx context.Context, src *StreamDetails, profile *Profile, clientInfo *ClientInfo) (*StreamDetails, string) { // Check protocol (only http for now) if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) { log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol) @@ -165,7 +195,7 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model. targetIsLossless := isLosslessFormat(targetFormat) // Reject lossy to lossless conversion - if !mf.IsLossless() && targetIsLossless { + if !src.IsLossless && targetIsLossless { log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat) return nil, "" } @@ -173,9 +203,9 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model. ts := &StreamDetails{ Container: responseContainer, Codec: strings.ToLower(profile.AudioCodec), - SampleRate: normalizeSourceSampleRate(mf.SampleRate, mf.AudioCodec()), - Channels: mf.Channels, - BitDepth: normalizeSourceBitDepth(mf.BitDepth, mf.AudioCodec()), + SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec), + Channels: src.Channels, + BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec), IsLossless: targetIsLossless, } if ts.Codec == "" { @@ -191,17 +221,17 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model. } // Determine target bitrate (all in kbps) - if ok := s.computeBitrate(ctx, mf, sourceBitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok { + if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok { return nil, "" } // Apply MaxAudioChannels from the transcoding profile - if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels { + if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels { ts.Channels = profile.MaxAudioChannels } // Apply codec profile limitations to the TARGET codec - if ok := s.applyCodecLimitations(ctx, sourceBitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok { + if ok := s.applyCodecLimitations(ctx, src.Bitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok { return nil, "" } @@ -241,8 +271,8 @@ func (s *deciderService) resolveTargetFormat(ctx context.Context, profile *Profi // computeBitrate determines the target bitrate for the transcoded stream. // Returns false if the profile should be rejected. -func (s *deciderService) computeBitrate(ctx context.Context, mf *model.MediaFile, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool { - if mf.IsLossless() { +func (s *deciderService) computeBitrate(ctx context.Context, src *StreamDetails, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool { + if src.IsLossless { if !targetIsLossless { if clientInfo.MaxTranscodingAudioBitrate > 0 { ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate @@ -250,14 +280,14 @@ func (s *deciderService) computeBitrate(ctx context.Context, mf *model.MediaFile ts.Bitrate = defaultBitrate } } else { - if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate { + if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate { log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit", - "targetFormat", targetFormat, "sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate) + "targetFormat", targetFormat, "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate) return false } } } else { - ts.Bitrate = sourceBitrate + ts.Bitrate = src.Bitrate } // Apply maxAudioBitrate as final cap @@ -296,6 +326,33 @@ func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrat return true } +func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) error { + if mf.ProbeData != "" { + return nil + } + + result, err := s.ff.ProbeAudioStream(ctx, mf.AbsolutePath()) + if err != nil { + return fmt.Errorf("probing media file %s: %w", mf.ID, err) + } + + data, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("marshaling probe result for %s: %w", mf.ID, err) + } + mf.ProbeData = string(data) + + if err := s.ds.MediaFile(ctx).UpdateProbeData(mf.ID, mf.ProbeData); err != nil { + log.Error(ctx, "Failed to persist probe data", "mediaID", mf.ID, err) + // Don't fail the decision — we have the data in memory + } + + log.Debug(ctx, "Probed media file", "mediaID", mf.ID, "codec", result.Codec, + "profile", result.Profile, "bitRate", result.BitRate, + "sampleRate", result.SampleRate, "bitDepth", result.BitDepth, "channels", result.Channels) + return nil +} + func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) { exp := time.Now().Add(tokenTTL) claims := auth.Claims{ diff --git a/core/transcode/transcode_test.go b/core/transcode/transcode_test.go index b4f0f112e..430a7d541 100644 --- a/core/transcode/transcode_test.go +++ b/core/transcode/transcode_test.go @@ -2,18 +2,36 @@ package transcode import ( "context" + "encoding/json" "time" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +// withProbe pre-populates ProbeData on a MediaFile from its own fields, +// so ensureProbed short-circuits and tests don't need mock ffprobe results. +func withProbe(mf *model.MediaFile) *model.MediaFile { + probe := ffmpeg.AudioProbeResult{ + Codec: mf.AudioCodec(), + BitRate: mf.BitRate, + SampleRate: mf.SampleRate, + BitDepth: mf.BitDepth, + Channels: mf.Channels, + } + data, _ := json.Marshal(probe) + mf.ProbeData = string(data) + return mf +} + var _ = Describe("Decider", func() { var ( ds *tests.MockDataStore + ff *tests.MockFFmpeg svc Decider ctx context.Context ) @@ -24,14 +42,15 @@ var _ = Describe("Decider", func() { MockedProperty: &tests.MockedPropertyRepo{}, MockedTranscoding: &tests.MockTranscodingRepo{}, } + ff = tests.NewMockFFmpeg("") auth.Init(ds) - svc = NewDecider(ds) + svc = NewDecider(ds, ff) }) Describe("MakeDecision", func() { Context("Direct Play", func() { It("allows direct play when profile matches", func() { - mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100} + 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{"http"}, MaxAudioChannels: 2}, @@ -45,7 +64,7 @@ var _ = Describe("Decider", func() { }) It("rejects direct play when container doesn't match", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"mp3"}, Protocols: []string{"http"}}, @@ -58,7 +77,7 @@ var _ = Describe("Decider", func() { }) It("rejects direct play when codec doesn't match", func() { - mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "ALAC", BitRate: 1000, Channels: 2} + 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{"http"}}, @@ -71,7 +90,7 @@ var _ = Describe("Decider", func() { }) It("rejects direct play when channels exceed limit", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}) ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, Protocols: []string{"http"}, MaxAudioChannels: 2}, @@ -84,7 +103,7 @@ var _ = Describe("Decider", func() { }) It("handles container aliases (aac -> m4a)", func() { - mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2} + 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{"http"}}, @@ -96,7 +115,7 @@ var _ = Describe("Decider", func() { }) It("handles container aliases (mp4 -> m4a)", func() { - mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2} + 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{"http"}}, @@ -108,7 +127,7 @@ var _ = Describe("Decider", func() { }) It("handles codec aliases (adts -> aac)", func() { - mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2} + 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{"http"}}, @@ -120,7 +139,7 @@ var _ = Describe("Decider", func() { }) It("allows when protocol list is empty (any protocol)", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}, @@ -132,7 +151,7 @@ var _ = Describe("Decider", func() { }) It("allows when both container and codec lists are empty (wildcard)", func() { - mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2}) ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{}, AudioCodecs: []string{}}, @@ -146,7 +165,7 @@ var _ = Describe("Decider", func() { Context("MaxAudioBitrate constraint", func() { It("revokes direct play when bitrate exceeds maxAudioBitrate", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2}) ci := &ClientInfo{ MaxAudioBitrate: 500, // kbps DirectPlayProfiles: []DirectPlayProfile{ @@ -166,7 +185,7 @@ var _ = Describe("Decider", func() { Context("Transcoding", func() { It("selects transcoding when direct play isn't possible", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + 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{ @@ -186,7 +205,7 @@ var _ = Describe("Decider", func() { }) It("rejects lossy to lossless transcoding", func() { - mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", Protocol: "http"}, @@ -198,7 +217,7 @@ var _ = Describe("Decider", func() { }) It("uses default bitrate when client doesn't specify", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "mp3", Protocol: "http"}, @@ -211,7 +230,7 @@ var _ = Describe("Decider", func() { }) It("preserves lossy bitrate when under max", func() { - mf := &model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 256, // kbps TranscodingProfiles: []Profile{ @@ -225,7 +244,7 @@ var _ = Describe("Decider", func() { }) It("rejects unsupported transcoding format", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "wav", Protocol: "http"}, @@ -237,7 +256,7 @@ var _ = Describe("Decider", func() { }) It("applies maxAudioBitrate as final cap on transcoded stream", func() { - mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}) ci := &ClientInfo{ MaxAudioBitrate: 96, // kbps TranscodingProfiles: []Profile{ @@ -251,7 +270,7 @@ var _ = Describe("Decider", func() { }) It("selects first valid transcoding profile in order", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, DirectPlayProfiles: []DirectPlayProfile{ @@ -273,7 +292,7 @@ var _ = Describe("Decider", func() { It("allows lossless to lossless when samplerate needs downsampling", func() { // MockTranscodingRepo doesn't support "flac" format, so this would fail to find a config. // This test documents the behavior: lossless→lossless requires server transcoding config. - mf := &model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1}) ci := &ClientInfo{ MaxAudioBitrate: 1000, DirectPlayProfiles: []DirectPlayProfile{ @@ -293,11 +312,11 @@ var _ = Describe("Decider", func() { // Simulate DSD→FLAC transcoding by using a mock that supports "flac" mockTranscoding := &tests.MockTranscodingRepo{} ds.MockedTranscoding = mockTranscoding - svc = NewDecider(ds) + svc = NewDecider(ds, ff) // Transcoding to mp3 (lossy) should result in IsLossless=false. // Use mp3 profile to test that lossy output is correctly identified. - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -313,7 +332,7 @@ var _ = Describe("Decider", func() { Context("No compatible profile", func() { It("returns error when nothing matches", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}) ci := &ClientInfo{} decision, err := svc.MakeDecision(ctx, mf, ci) Expect(err).ToNot(HaveOccurred()) @@ -325,7 +344,7 @@ var _ = Describe("Decider", func() { Context("Codec limitations on direct play", func() { It("rejects direct play when codec limitation fails (required)", func() { - mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100} + 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{"http"}}, @@ -347,7 +366,7 @@ var _ = Describe("Decider", func() { }) It("allows direct play when optional limitation fails", func() { - mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100} + 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{"http"}}, @@ -368,7 +387,7 @@ var _ = Describe("Decider", func() { }) It("handles Equals comparison with multiple values", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100} + 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{"http"}}, @@ -389,7 +408,7 @@ var _ = Describe("Decider", func() { }) It("rejects when Equals comparison doesn't match any value", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100} + 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{"http"}}, @@ -410,7 +429,7 @@ var _ = Describe("Decider", func() { }) It("rejects direct play when audioProfile limitation fails (required)", func() { - mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100} + 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{"http"}}, @@ -433,7 +452,7 @@ var _ = Describe("Decider", func() { }) It("allows direct play when audioProfile limitation is optional", func() { - mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100} + 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{"http"}}, @@ -454,7 +473,7 @@ var _ = Describe("Decider", func() { }) It("rejects direct play due to samplerate limitation", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24} + 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{"http"}}, @@ -478,7 +497,7 @@ var _ = Describe("Decider", func() { Context("Codec limitations on transcoded output", func() { It("applies bitrate limitation to transcoded stream", func() { - mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 192, Channels: 2, SampleRate: 44100} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 192, Channels: 2, SampleRate: 44100}) ci := &ClientInfo{ MaxAudioBitrate: 96, // force transcode TranscodingProfiles: []Profile{ @@ -501,7 +520,7 @@ var _ = Describe("Decider", func() { }) It("applies channel limitation to transcoded stream", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -524,7 +543,7 @@ var _ = Describe("Decider", func() { }) It("applies samplerate limitation to transcoded stream", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -547,7 +566,7 @@ var _ = Describe("Decider", func() { }) It("applies bitdepth limitation to transcoded stream", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24} + 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: "http"}, @@ -570,7 +589,7 @@ var _ = Describe("Decider", func() { }) It("preserves source bit depth when no limitation applies", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 24} + 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: "http"}, @@ -584,7 +603,7 @@ var _ = Describe("Decider", func() { }) It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -608,7 +627,7 @@ var _ = Describe("Decider", func() { Context("DSD sample rate conversion", func() { It("converts DSD sample rate to PCM-equivalent in decision", func() { - mf := &model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -628,7 +647,7 @@ var _ = Describe("Decider", func() { }) It("converts DSD sample rate for FLAC target without codec limit", func() { - mf := &model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1} + 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: "http"}, @@ -647,7 +666,7 @@ var _ = Describe("Decider", func() { }) It("applies codec profile limit to DSD-converted FLAC sample rate", func() { - mf := &model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1} + 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: "http"}, @@ -674,7 +693,7 @@ var _ = Describe("Decider", func() { }) It("applies audioBitdepth limitation to DSD-converted bit depth", func() { - mf := &model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1} + 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: "http"}, @@ -700,7 +719,7 @@ var _ = Describe("Decider", func() { Context("Opus fixed sample rate", func() { It("sets Opus output to 48000Hz regardless of input", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 128, TranscodingProfiles: []Profile{ @@ -717,7 +736,7 @@ var _ = Describe("Decider", func() { }) It("sets Opus output to 48000Hz even for 96kHz input", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 128, TranscodingProfiles: []Profile{ @@ -733,7 +752,7 @@ var _ = Describe("Decider", func() { Context("Container vs format separation", func() { It("preserves mp4 container when falling back to aac format", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 256, TranscodingProfiles: []Profile{ @@ -751,7 +770,7 @@ var _ = Describe("Decider", func() { }) It("uses container as format when container matches transcoding config", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 256, TranscodingProfiles: []Profile{ @@ -768,7 +787,7 @@ var _ = Describe("Decider", func() { Context("MP3 max sample rate", func() { It("caps sample rate at 48000 for MP3", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -782,7 +801,7 @@ var _ = Describe("Decider", func() { }) It("preserves sample rate at 44100 for MP3", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -798,7 +817,7 @@ var _ = Describe("Decider", func() { Context("AAC max sample rate", func() { It("caps sample rate at 96000 for AAC", func() { - mf := &model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1} + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -815,7 +834,7 @@ var _ = Describe("Decider", func() { Context("Typed transcode reasons from multiple profiles", func() { It("collects reasons from each failed direct play profile", func() { - mf := &model.MediaFile{ID: "1", Suffix: "ogg", Codec: "Vorbis", BitRate: 128, Channels: 2, SampleRate: 48000} + 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{"http"}}, @@ -838,7 +857,7 @@ var _ = Describe("Decider", func() { Context("Source stream details", func() { It("populates source stream correctly with kbps bitrate", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000} + 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{"http"}}, @@ -1070,4 +1089,36 @@ var _ = Describe("Decider", func() { Expect(err).To(MatchError(ErrTokenStale)) }) }) + + Describe("normalizeProbeCodec", func() { + It("passes through common codec names unchanged", func() { + Expect(normalizeProbeCodec("mp3")).To(Equal("mp3")) + Expect(normalizeProbeCodec("aac")).To(Equal("aac")) + Expect(normalizeProbeCodec("flac")).To(Equal("flac")) + Expect(normalizeProbeCodec("opus")).To(Equal("opus")) + Expect(normalizeProbeCodec("vorbis")).To(Equal("vorbis")) + Expect(normalizeProbeCodec("alac")).To(Equal("alac")) + Expect(normalizeProbeCodec("wmav2")).To(Equal("wmav2")) + }) + + It("normalizes DSD variants to dsd", func() { + Expect(normalizeProbeCodec("dsd_lsbf_planar")).To(Equal("dsd")) + Expect(normalizeProbeCodec("dsd_msbf_planar")).To(Equal("dsd")) + Expect(normalizeProbeCodec("dsd_lsbf")).To(Equal("dsd")) + Expect(normalizeProbeCodec("dsd_msbf")).To(Equal("dsd")) + }) + + It("normalizes PCM variants to pcm", func() { + Expect(normalizeProbeCodec("pcm_s16le")).To(Equal("pcm")) + Expect(normalizeProbeCodec("pcm_s24le")).To(Equal("pcm")) + Expect(normalizeProbeCodec("pcm_s32be")).To(Equal("pcm")) + Expect(normalizeProbeCodec("pcm_f32le")).To(Equal("pcm")) + }) + + It("lowercases input", func() { + Expect(normalizeProbeCodec("MP3")).To(Equal("mp3")) + Expect(normalizeProbeCodec("AAC")).To(Equal("aac")) + Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd")) + }) + }) }) diff --git a/core/transcode/types.go b/core/transcode/types.go index f7f6be0f1..922078e2e 100644 --- a/core/transcode/types.go +++ b/core/transcode/types.go @@ -116,7 +116,7 @@ type Decision struct { type StreamDetails struct { Container string Codec string - Profile string // Audio profile (e.g., "LC", "HE-AAC"). Empty until scanner support is added. + Profile string // Audio profile (e.g., "LC", "HE-AACv2"). Populated from ffprobe data. Bitrate int SampleRate int BitDepth int