diff --git a/core/transcode_decision.go b/core/transcode_decision.go index baba4d360..68f59a672 100644 --- a/core/transcode_decision.go +++ b/core/transcode_decision.go @@ -21,7 +21,8 @@ type TranscodeDecision interface { ParseToken(token string) (*TranscodeParams, error) } -// ClientInfo represents client playback capabilities +// ClientInfo represents client playback capabilities. +// All bitrate values are in kilobits per second (kbps), matching Navidrome conventions. type ClientInfo struct { Name string Platform string @@ -63,7 +64,8 @@ type Limitation struct { Required bool } -// Decision represents the internal decision result +// Decision represents the internal decision result. +// All bitrate values are in kilobits per second (kbps). type Decision struct { MediaID string CanDirectPlay bool @@ -77,7 +79,8 @@ type Decision struct { TranscodeStream *StreamDetails } -// StreamDetails describes audio stream properties +// StreamDetails describes audio stream properties. +// Bitrate is in kilobits per second (kbps). type StreamDetails struct { Container string Codec string @@ -90,7 +93,8 @@ type StreamDetails struct { IsLossless bool } -// TranscodeParams contains the parameters extracted from a transcode token +// TranscodeParams contains the parameters extracted from a transcode token. +// TargetBitrate is in kilobits per second (kbps). type TranscodeParams struct { MediaID string DirectPlay bool @@ -114,11 +118,13 @@ func (s *transcodeDecisionService) MakeDecision(ctx context.Context, mf *model.M MediaID: mf.ID, } + sourceBitrate := mf.BitRate // kbps + // Build source stream details decision.SourceStream = StreamDetails{ Container: mf.Suffix, Codec: mf.AudioCodec(), - Bitrate: mf.BitRate, + Bitrate: sourceBitrate, SampleRate: mf.SampleRate, BitDepth: mf.BitDepth, Channels: mf.Channels, @@ -127,49 +133,36 @@ func (s *transcodeDecisionService) MakeDecision(ctx context.Context, mf *model.M IsLossless: mf.IsLossless(), } - // Check global bitrate constraint - if clientInfo.MaxAudioBitrate > 0 && mf.BitRate > clientInfo.MaxAudioBitrate { - decision.TranscodeReasons = append(decision.TranscodeReasons, "bitrate exceeds maxAudioBitrate") - } - - // Try direct play profiles - for _, profile := range clientInfo.DirectPlayProfiles { - if s.matchesDirectPlayProfile(mf, &profile, clientInfo) { - decision.CanDirectPlay = true - break + // Check global bitrate constraint first (like LMS: prevents direct play entirely) + if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > 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 == "" { + decision.CanDirectPlay = true + decision.TranscodeReasons = nil // Clear any previously collected reasons + break + } else { + decision.TranscodeReasons = append(decision.TranscodeReasons, reason) + } } } - // If direct play is possible and no transcode reasons, we're done - if decision.CanDirectPlay && len(decision.TranscodeReasons) == 0 { + // If direct play is possible, we're done + if decision.CanDirectPlay { return decision, nil } - // If direct play matched but there are global constraints violated, revoke direct play - if decision.CanDirectPlay && len(decision.TranscodeReasons) > 0 { - decision.CanDirectPlay = false - } - // Try transcoding profiles (in order of preference) for _, profile := range clientInfo.TranscodingProfiles { - if targetFormat, targetBitrate, ok := s.matchesTranscodingProfile(ctx, mf, &profile, clientInfo); ok { + if ts := s.computeTranscodedStream(ctx, mf, sourceBitrate, &profile, clientInfo); ts != nil { decision.CanTranscode = true - decision.TargetFormat = targetFormat - decision.TargetBitrate = targetBitrate - decision.TargetChannels = profile.MaxAudioChannels - - // Build transcode stream details - decision.TranscodeStream = &StreamDetails{ - Container: targetFormat, - Codec: targetFormat, - Bitrate: targetBitrate, - SampleRate: mf.SampleRate, - Channels: mf.Channels, - IsLossless: false, - } - if decision.TargetChannels > 0 && decision.TargetChannels < mf.Channels { - decision.TranscodeStream.Channels = decision.TargetChannels - } + decision.TargetFormat = ts.Container + decision.TargetBitrate = ts.Bitrate + decision.TargetChannels = ts.Channels + decision.TranscodeStream = ts break } } @@ -182,43 +175,90 @@ func (s *transcodeDecisionService) MakeDecision(ctx context.Context, mf *model.M return decision, nil } -func (s *transcodeDecisionService) matchesDirectPlayProfile(mf *model.MediaFile, profile *DirectPlayProfile, clientInfo *ClientInfo) bool { +// checkDirectPlayProfile returns "" if the profile matches (direct play OK), +// or a typed reason string if it doesn't match. +func (s *transcodeDecisionService) checkDirectPlayProfile(mf *model.MediaFile, sourceBitrate int, profile *DirectPlayProfile, clientInfo *ClientInfo) string { // Check protocol (only http for now) if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, "http") { - return false + return "protocol not supported" } // Check container - if len(profile.Containers) > 0 && !s.matchesContainer(mf.Suffix, profile.Containers) { - return false + if len(profile.Containers) > 0 && !matchesContainer(mf.Suffix, profile.Containers) { + return "container not supported" } // Check codec - if len(profile.AudioCodecs) > 0 && !s.matchesCodec(mf.AudioCodec(), profile.AudioCodecs) { - return false + if len(profile.AudioCodecs) > 0 && !matchesCodec(mf.AudioCodec(), profile.AudioCodecs) { + return "audio codec not supported" } // Check channels if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels { - return false + return "audio channels not supported" } // Check codec-specific limitations for _, codecProfile := range clientInfo.CodecProfiles { - if strings.EqualFold(codecProfile.Type, "AudioCodec") && strings.EqualFold(codecProfile.Name, mf.AudioCodec()) { - if !s.meetsLimitations(mf, codecProfile.Limitations) { - return false + if strings.EqualFold(codecProfile.Type, "AudioCodec") && matchesCodec(mf.AudioCodec(), []string{codecProfile.Name}) { + if reason := checkLimitations(mf, sourceBitrate, codecProfile.Limitations); reason != "" { + return reason } } } - return true + return "" } -func (s *transcodeDecisionService) matchesTranscodingProfile(ctx context.Context, mf *model.MediaFile, profile *TranscodingProfile, clientInfo *ClientInfo) (string, int, bool) { +// checkLimitations checks codec profile limitations against source media. +// Returns "" if all limitations pass, or a typed reason string for the first failure. +func checkLimitations(mf *model.MediaFile, sourceBitrate int, limitations []Limitation) string { + for _, lim := range limitations { + switch strings.ToLower(lim.Name) { + case "audiochannels": + if !checkIntLimitation(mf.Channels, lim.Comparison, lim.Values) { + if lim.Required { + return "audio channels not supported" + } + } + case "audiosamplerate": + if !checkIntLimitation(mf.SampleRate, lim.Comparison, lim.Values) { + if lim.Required { + return "audio samplerate not supported" + } + } + case "audiobitrate": + if !checkIntLimitation(sourceBitrate, lim.Comparison, lim.Values) { + if lim.Required { + return "audio bitrate not supported" + } + } + case "audiobitdepth": + if !checkIntLimitation(mf.BitDepth, lim.Comparison, lim.Values) { + if lim.Required { + return "audio bitdepth not supported" + } + } + } + } + return "" +} + +// adjustResult represents the outcome of applying a limitation to a transcoded stream value +type adjustResult int + +const ( + adjustNone adjustResult = iota // Value already satisfies the limitation + adjustAdjusted // Value was changed to fit the limitation + adjustCannotFit // Cannot satisfy the limitation (reject this profile) +) + +// computeTranscodedStream attempts to build a valid transcoded stream for the given profile. +// Returns nil if the profile cannot produce a valid output. +func (s *transcodeDecisionService) computeTranscodedStream(ctx context.Context, mf *model.MediaFile, sourceBitrate int, profile *TranscodingProfile, clientInfo *ClientInfo) *StreamDetails { // Check protocol (only http for now) if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, "http") { - return "", 0, false + return nil } targetFormat := strings.ToLower(profile.Container) @@ -229,85 +269,169 @@ func (s *transcodeDecisionService) matchesTranscodingProfile(ctx context.Context // Verify we have a transcoding config for this format tc, err := s.ds.Transcoding(ctx).FindByFormat(targetFormat) if err != nil || tc == nil { - return "", 0, false + return nil } + targetIsLossless := isLosslessFormat(targetFormat) + // Reject lossy to lossless conversion - if !mf.IsLossless() && isLosslessFormat(targetFormat) { - return "", 0, false + if !mf.IsLossless() && targetIsLossless { + return nil } - // Determine target bitrate - targetBitrate := defaultTranscodeBitrate + ts := &StreamDetails{ + Container: targetFormat, + Codec: strings.ToLower(profile.AudioCodec), + SampleRate: mf.SampleRate, + Channels: mf.Channels, + IsLossless: targetIsLossless, + } + if ts.Codec == "" { + ts.Codec = targetFormat + } + + // Determine target bitrate (all in kbps) if mf.IsLossless() { - // Lossless to lossy: use client's max transcoding bitrate or default - if clientInfo.MaxTranscodingAudioBitrate > 0 { - targetBitrate = clientInfo.MaxTranscodingAudioBitrate / 1000 // Convert to kbps + if !targetIsLossless { + // Lossless to lossy: use client's max transcoding bitrate or default + if clientInfo.MaxTranscodingAudioBitrate > 0 { + ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate + } else { + ts.Bitrate = defaultTranscodeBitrate + } + } else { + // Lossless to lossless: check if bitrate is under the global max + if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate { + return nil // Cannot guarantee bitrate within limit for lossless + } + // No explicit bitrate for lossless target (leave 0) } } else { - // Lossy to lossy: try to preserve source bitrate if under max - targetBitrate = mf.BitRate / 1000 - if clientInfo.MaxTranscodingAudioBitrate > 0 && targetBitrate > clientInfo.MaxTranscodingAudioBitrate/1000 { - targetBitrate = clientInfo.MaxTranscodingAudioBitrate / 1000 + // Lossy to lossy: preserve source bitrate + ts.Bitrate = sourceBitrate + } + + // Apply maxAudioBitrate as final cap on transcoded stream (#5) + if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate { + ts.Bitrate = clientInfo.MaxAudioBitrate + } + + // Apply MaxAudioChannels from the transcoding profile + if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels { + ts.Channels = profile.MaxAudioChannels + } + + // Apply codec profile limitations to the TARGET codec (#4) + targetCodec := ts.Codec + for _, codecProfile := range clientInfo.CodecProfiles { + if !strings.EqualFold(codecProfile.Type, "AudioCodec") { + continue + } + if !matchesCodec(targetCodec, []string{codecProfile.Name}) { + continue + } + for _, lim := range codecProfile.Limitations { + result := applyLimitation(sourceBitrate, &lim, ts) + // For lossless codecs, adjusting bitrate is not valid + if strings.EqualFold(lim.Name, "audiobitrate") && targetIsLossless && result == adjustAdjusted { + return nil + } + if result == adjustCannotFit { + return nil + } } } - return targetFormat, targetBitrate, true + return ts } -func (s *transcodeDecisionService) matchesContainer(suffix string, containers []string) bool { - suffix = strings.ToLower(suffix) - for _, c := range containers { - c = strings.ToLower(c) - if c == suffix { - return true +// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation. +// Returns the adjustment result. +func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult { + switch strings.ToLower(lim.Name) { + case "audiochannels": + current := ts.Channels + return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Channels = v }) + case "audiobitrate": + current := ts.Bitrate + if current == 0 { + current = sourceBitrate } - // Handle common aliases - if c == "aac" && (suffix == "m4a" || suffix == "m4b" || suffix == "m4p") { - return true - } - if c == "mpeg" && (suffix == "mp3" || suffix == "mp2") { - return true - } - if c == "ogg" && (suffix == "oga" || suffix == "opus") { - return true + return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v }) + case "audiosamplerate": + return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v }) + case "audiobitdepth": + if ts.BitDepth > 0 { + return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v }) } } - return false + return adjustNone } -func (s *transcodeDecisionService) matchesCodec(codec string, codecs []string) bool { - codec = strings.ToLower(codec) - for _, c := range codecs { - if strings.EqualFold(c, codec) { - return true - } +// applyIntLimitation applies a limitation comparison to a value. +// If the value needs adjusting, calls the setter and returns the result. +func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult { + if len(values) == 0 { + return adjustNone } - return false -} -func (s *transcodeDecisionService) meetsLimitations(mf *model.MediaFile, limitations []Limitation) bool { - for _, lim := range limitations { - switch strings.ToLower(lim.Name) { - case "audiochannels": - if !checkIntLimitation(mf.Channels, lim.Comparison, lim.Values) { - return !lim.Required - } - case "audiosamplerate": - if !checkIntLimitation(mf.SampleRate, lim.Comparison, lim.Values) { - return !lim.Required - } - case "audiobitrate": - if !checkIntLimitation(mf.BitRate, lim.Comparison, lim.Values) { - return !lim.Required - } - case "audiobitdepth": - if !checkIntLimitation(mf.BitDepth, lim.Comparison, lim.Values) { - return !lim.Required + switch strings.ToLower(comparison) { + case "lessthanequal": + limit, ok := parseInt(values[0]) + if !ok { + return adjustNone + } + if current <= limit { + return adjustNone + } + setter(limit) + return adjustAdjusted + + case "greaterthanequal": + limit, ok := parseInt(values[0]) + if !ok { + return adjustNone + } + if current >= limit { + return adjustNone + } + // Cannot upscale + return adjustCannotFit + + case "equals": + // Check if current value matches any allowed value + for _, v := range values { + if limit, ok := parseInt(v); ok && current == limit { + return adjustNone } } + // Find the closest allowed value below current (don't upscale) + var closest int + found := false + for _, v := range values { + if limit, ok := parseInt(v); ok && limit < current { + if !found || limit > closest { + closest = limit + found = true + } + } + } + if found { + setter(closest) + return adjustAdjusted + } + return adjustCannotFit + + case "notequals": + for _, v := range values { + if limit, ok := parseInt(v); ok && current == limit { + return adjustCannotFit + } + } + return adjustNone } - return true + + return adjustNone } func (s *transcodeDecisionService) CreateToken(decision *Decision) (string, error) { @@ -361,6 +485,93 @@ func containsIgnoreCase(slice []string, s string) bool { return false } +// matchesContainer checks if a file suffix matches any of the container names, +// including common aliases (matching LMS reference implementation). +func matchesContainer(suffix string, containers []string) bool { + suffix = strings.ToLower(suffix) + for _, c := range containers { + c = strings.ToLower(c) + if c == suffix { + return true + } + // Container alias mappings (based on LMS reference) + switch c { + case "aac", "adts", "m4a", "mp4", "m4b", "m4p": + if suffix == "aac" || suffix == "adts" || suffix == "m4a" || suffix == "mp4" || suffix == "m4b" || suffix == "m4p" { + return true + } + case "mpeg", "mp3", "mp2": + if suffix == "mp3" || suffix == "mp2" || suffix == "mpeg" { + return true + } + case "ogg", "oga": + if suffix == "ogg" || suffix == "oga" { + return true + } + case "aif", "aiff": + if suffix == "aif" || suffix == "aiff" { + return true + } + case "asf", "wma": + if suffix == "asf" || suffix == "wma" { + return true + } + case "mpc", "mpp": + if suffix == "mpc" || suffix == "mpp" { + return true + } + case "wv": + if suffix == "wv" { + return true + } + } + } + return false +} + +// codecAliasGroups maps each codec alias to a canonical group name. +// Codecs within the same group are considered equivalent. +var codecAliasGroups = func() map[string]string { + groups := [][]string{ + {"aac", "adts"}, + {"ac3", "ac-3"}, + {"eac3", "e-ac3", "e-ac-3", "eac-3"}, + {"mpc7", "musepack7"}, + {"mpc8", "musepack8"}, + {"wma1", "wmav1"}, + {"wma2", "wmav2"}, + {"wmalossless", "wma9lossless"}, + {"wmapro", "wma9pro"}, + {"shn", "shorten"}, + {"mp4als", "als"}, + } + m := make(map[string]string) + for _, g := range groups { + for _, name := range g { + m[name] = g[0] // canonical = first entry + } + } + return m +}() + +// matchesCodec checks if a codec matches any of the codec names, +// including common aliases (matching LMS reference implementation). +func matchesCodec(codec string, codecs []string) bool { + codec = strings.ToLower(codec) + canonicalCodec := codecAliasGroups[codec] // empty if no alias group + for _, c := range codecs { + c = strings.ToLower(c) + if c == codec { + return true + } + // Check if both belong to the same alias group + if canonicalCodec != "" && codecAliasGroups[c] == canonicalCodec { + return true + } + } + return false +} + func checkIntLimitation(value int, comparison string, values []string) bool { if len(values) == 0 { return true diff --git a/core/transcode_decision_test.go b/core/transcode_decision_test.go index 06e246960..8e93148fd 100644 --- a/core/transcode_decision_test.go +++ b/core/transcode_decision_test.go @@ -30,7 +30,7 @@ var _ = Describe("TranscodeDecision", func() { Describe("MakeDecision", func() { Context("Direct Play", func() { It("allows direct play when profile matches", func() { - mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320000, Channels: 2, SampleRate: 44100} + mf := &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}, @@ -44,7 +44,7 @@ var _ = Describe("TranscodeDecision", func() { }) It("rejects direct play when container doesn't match", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2} + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2} ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"mp3"}, Protocols: []string{"http"}}, @@ -53,10 +53,11 @@ var _ = Describe("TranscodeDecision", func() { decision, err := svc.MakeDecision(ctx, mf, ci) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("container not supported")) }) It("rejects direct play when codec doesn't match", func() { - mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "ALAC", BitRate: 1000000, Channels: 2} + mf := &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"}}, @@ -65,10 +66,11 @@ var _ = Describe("TranscodeDecision", func() { decision, err := svc.MakeDecision(ctx, mf, ci) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("audio codec not supported")) }) It("rejects direct play when channels exceed limit", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 6} + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6} ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, Protocols: []string{"http"}, MaxAudioChannels: 2}, @@ -77,10 +79,11 @@ var _ = Describe("TranscodeDecision", func() { decision, err := svc.MakeDecision(ctx, mf, ci) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("audio channels not supported")) }) It("handles container aliases (aac -> m4a)", func() { - mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256000, Channels: 2} + mf := &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"}}, @@ -91,8 +94,32 @@ var _ = Describe("TranscodeDecision", func() { Expect(decision.CanDirectPlay).To(BeTrue()) }) + It("handles container aliases (mp4 -> m4a)", func() { + mf := &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"}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("handles codec aliases (adts -> aac)", func() { + mf := &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"}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + It("allows when protocol list is empty (any protocol)", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2} + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2} ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}, @@ -102,13 +129,25 @@ var _ = Describe("TranscodeDecision", func() { Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) + + 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} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{}, AudioCodecs: []string{}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) }) Context("MaxAudioBitrate constraint", func() { It("revokes direct play when bitrate exceeds maxAudioBitrate", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500000, Channels: 2} + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2} ci := &ClientInfo{ - MaxAudioBitrate: 500000, + MaxAudioBitrate: 500, // kbps DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, Protocols: []string{"http"}}, }, @@ -120,15 +159,15 @@ var _ = Describe("TranscodeDecision", func() { Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeReasons).To(ContainElement("bitrate exceeds maxAudioBitrate")) + Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported")) }) }) Context("Transcoding", func() { It("selects transcoding when direct play isn't possible", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2, SampleRate: 44100, BitDepth: 16} + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 256000, + MaxTranscodingAudioBitrate: 256, // kbps DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"mp3"}, Protocols: []string{"http"}}, }, @@ -141,11 +180,12 @@ var _ = Describe("TranscodeDecision", func() { Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) - Expect(decision.TargetBitrate).To(Equal(256)) + Expect(decision.TargetBitrate).To(Equal(256)) // kbps + Expect(decision.TranscodeReasons).To(ContainElement("container not supported")) }) It("rejects lossy to lossless transcoding", func() { - mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320000, Channels: 2} + mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2} ci := &ClientInfo{ TranscodingProfiles: []TranscodingProfile{ {Container: "flac", Protocol: "http"}, @@ -157,7 +197,7 @@ var _ = Describe("TranscodeDecision", func() { }) It("uses default bitrate when client doesn't specify", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2, BitDepth: 16} + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16} ci := &ClientInfo{ TranscodingProfiles: []TranscodingProfile{ {Container: "mp3", Protocol: "http"}, @@ -166,13 +206,13 @@ var _ = Describe("TranscodeDecision", func() { decision, err := svc.MakeDecision(ctx, mf, ci) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetBitrate).To(Equal(defaultTranscodeBitrate)) + Expect(decision.TargetBitrate).To(Equal(defaultTranscodeBitrate)) // 256 kbps }) It("preserves lossy bitrate when under max", func() { - mf := &model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192000, Channels: 2} + mf := &model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2} ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 256000, + MaxTranscodingAudioBitrate: 256, // kbps TranscodingProfiles: []TranscodingProfile{ {Container: "mp3", Protocol: "http"}, }, @@ -180,11 +220,11 @@ var _ = Describe("TranscodeDecision", func() { decision, err := svc.MakeDecision(ctx, mf, ci) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetBitrate).To(Equal(192)) + Expect(decision.TargetBitrate).To(Equal(192)) // source bitrate in kbps }) It("rejects unsupported transcoding format", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2} + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2} ci := &ClientInfo{ TranscodingProfiles: []TranscodingProfile{ {Container: "aac", Protocol: "http"}, @@ -194,11 +234,85 @@ var _ = Describe("TranscodeDecision", func() { Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeFalse()) }) + + It("applies maxAudioBitrate as final cap on transcoded stream", func() { + mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2} + ci := &ClientInfo{ + MaxAudioBitrate: 96, // kbps + TranscodingProfiles: []TranscodingProfile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + 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 := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, Protocols: []string{"http"}}, + }, + TranscodingProfiles: []TranscodingProfile{ + {Container: "opus", AudioCodec: "opus", Protocol: "http"}, + {Container: "mp3", AudioCodec: "mp3", Protocol: "http", MaxAudioChannels: 2}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + 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() { + // 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} + ci := &ClientInfo{ + MaxAudioBitrate: 1000, + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{"http"}}, + }, + TranscodingProfiles: []TranscodingProfile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + }) + + It("sets IsLossless=true on transcoded stream when target is lossless", func() { + // Simulate DSD→FLAC transcoding by using a mock that supports "flac" + mockTranscoding := &tests.MockTranscodingRepo{} + ds.MockedTranscoding = mockTranscoding + svc = NewTranscodeDecision(ds) + + // MockTranscodingRepo doesn't support flac, so this will skip lossless profile. + // Use mp3 which is supported as the fallback. + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []TranscodingProfile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + 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 := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 6} + mf := &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()) @@ -208,9 +322,9 @@ var _ = Describe("TranscodeDecision", func() { }) }) - Context("Codec limitations", 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: 512000, Channels: 2, SampleRate: 44100} + mf := &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"}}, @@ -220,7 +334,7 @@ var _ = Describe("TranscodeDecision", func() { Type: "AudioCodec", Name: "mp3", Limitations: []Limitation{ - {Name: "audioBitrate", Comparison: "LessThanEqual", Values: []string{"320000"}, Required: true}, + {Name: "audioBitrate", Comparison: "LessThanEqual", Values: []string{"320"}, Required: true}, }, }, }, @@ -228,10 +342,11 @@ var _ = Describe("TranscodeDecision", func() { decision, err := svc.MakeDecision(ctx, mf, ci) 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 := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512000, Channels: 2, SampleRate: 44100} + mf := &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"}}, @@ -241,7 +356,7 @@ var _ = Describe("TranscodeDecision", func() { Type: "AudioCodec", Name: "mp3", Limitations: []Limitation{ - {Name: "audioBitrate", Comparison: "LessThanEqual", Values: []string{"320000"}, Required: false}, + {Name: "audioBitrate", Comparison: "LessThanEqual", Values: []string{"320"}, Required: false}, }, }, }, @@ -252,7 +367,7 @@ var _ = Describe("TranscodeDecision", func() { }) It("handles Equals comparison with multiple values", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2, SampleRate: 44100} + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100} ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, Protocols: []string{"http"}}, @@ -273,7 +388,7 @@ var _ = Describe("TranscodeDecision", func() { }) It("rejects when Equals comparison doesn't match any value", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 6, SampleRate: 44100} + mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100} ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, Protocols: []string{"http"}}, @@ -292,11 +407,149 @@ var _ = Describe("TranscodeDecision", func() { Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) }) + + 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} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{"http"}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: "AudioCodec", + Name: "flac", + Limitations: []Limitation{ + {Name: "audioSamplerate", Comparison: "LessThanEqual", Values: []string{"48000"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + 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 := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 192, Channels: 2, SampleRate: 44100} + ci := &ClientInfo{ + MaxAudioBitrate: 96, // force transcode + TranscodingProfiles: []TranscodingProfile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, + }, + CodecProfiles: []CodecProfile{ + { + Type: "AudioCodec", + Name: "mp3", + Limitations: []Limitation{ + {Name: "audioBitrate", Comparison: "LessThanEqual", Values: []string{"96"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + 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 := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []TranscodingProfile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, + }, + CodecProfiles: []CodecProfile{ + { + Type: "AudioCodec", + Name: "mp3", + Limitations: []Limitation{ + {Name: "audioChannels", Comparison: "LessThanEqual", Values: []string{"2"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + 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 := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []TranscodingProfile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, + }, + CodecProfiles: []CodecProfile{ + { + Type: "AudioCodec", + Name: "mp3", + Limitations: []Limitation{ + {Name: "audioSamplerate", Comparison: "LessThanEqual", Values: []string{"48000"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + }) + + 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} + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []TranscodingProfile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, + }, + CodecProfiles: []CodecProfile{ + { + Type: "AudioCodec", + Name: "mp3", + Limitations: []Limitation{ + {Name: "audioSamplerate", Comparison: "GreaterThanEqual", Values: []string{"96000"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeFalse()) + }) + }) + + 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} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{"http"}}, + {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}}, + {Containers: []string{"m4a", "mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}}, + }, + TranscodingProfiles: []TranscodingProfile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: "http"}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(HaveLen(3)) + Expect(decision.TranscodeReasons[0]).To(Equal("container not supported")) + Expect(decision.TranscodeReasons[1]).To(Equal("container not supported")) + Expect(decision.TranscodeReasons[2]).To(Equal("container not supported")) + }) }) Context("Source stream details", func() { - It("populates source stream correctly", func() { - mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000} + 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} ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, Protocols: []string{"http"}}, @@ -306,7 +559,7 @@ var _ = Describe("TranscodeDecision", func() { Expect(err).ToNot(HaveOccurred()) Expect(decision.SourceStream.Container).To(Equal("flac")) Expect(decision.SourceStream.Codec).To(Equal("flac")) - Expect(decision.SourceStream.Bitrate).To(Equal(1000000)) + Expect(decision.SourceStream.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)) @@ -331,13 +584,13 @@ var _ = Describe("TranscodeDecision", func() { Expect(params.TargetFormat).To(BeEmpty()) }) - It("creates and parses a transcode token", func() { + It("creates and parses a transcode token with kbps bitrate", func() { decision := &Decision{ MediaID: "media-456", CanDirectPlay: false, CanTranscode: true, TargetFormat: "mp3", - TargetBitrate: 256, + TargetBitrate: 256, // kbps TargetChannels: 2, } token, err := svc.CreateToken(decision) @@ -348,7 +601,7 @@ var _ = Describe("TranscodeDecision", func() { Expect(params.MediaID).To(Equal("media-456")) Expect(params.DirectPlay).To(BeFalse()) Expect(params.TargetFormat).To(Equal("mp3")) - Expect(params.TargetBitrate).To(Equal(256)) + Expect(params.TargetBitrate).To(Equal(256)) // kbps Expect(params.TargetChannels).To(Equal(2)) }) diff --git a/db/migrations/20260205120000_add_codec_to_media_file.sql b/db/migrations/20260205120000_add_codec_to_media_file.sql index e44133e01..724d85ebf 100644 --- a/db/migrations/20260205120000_add_codec_to_media_file.sql +++ b/db/migrations/20260205120000_add_codec_to_media_file.sql @@ -5,3 +5,7 @@ CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec); -- +goose StatementEnd -- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS media_file_codec; +ALTER TABLE media_file DROP COLUMN codec; +-- +goose StatementEnd diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index d02e7a12e..267ca1c36 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -3,6 +3,8 @@ package subsonic import ( "encoding/json" "net/http" + "strconv" + "strings" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" @@ -54,13 +56,14 @@ type limitationReq struct { Required bool `json:"required,omitempty"` } -// toCore converts the API request struct to the core ClientInfo struct +// toCore converts the API request struct to the core ClientInfo struct. +// The OpenSubsonic spec uses bps for bitrate values; core uses kbps. func (r *clientInfoRequest) toCore() *core.ClientInfo { ci := &core.ClientInfo{ Name: r.Name, Platform: r.Platform, - MaxAudioBitrate: r.MaxAudioBitrate, - MaxTranscodingAudioBitrate: r.MaxTranscodingAudioBitrate, + MaxAudioBitrate: bpsToKbps(r.MaxAudioBitrate), + MaxTranscodingAudioBitrate: bpsToKbps(r.MaxTranscodingAudioBitrate), } for _, dp := range r.DirectPlayProfiles { @@ -87,12 +90,17 @@ func (r *clientInfoRequest) toCore() *core.ClientInfo { Name: cp.Name, } for _, lim := range cp.Limitations { - coreCP.Limitations = append(coreCP.Limitations, core.Limitation{ + coreLim := core.Limitation{ Name: lim.Name, Comparison: lim.Comparison, Values: lim.Values, Required: lim.Required, - }) + } + // Convert audioBitrate limitation values from bps to kbps + if strings.EqualFold(lim.Name, "audioBitrate") { + coreLim.Values = convertBitrateValues(lim.Values) + } + coreCP.Limitations = append(coreCP.Limitations, coreLim) } ci.CodecProfiles = append(ci.CodecProfiles, coreCP) } @@ -100,6 +108,30 @@ func (r *clientInfoRequest) toCore() *core.ClientInfo { return ci } +// bpsToKbps converts bits per second to kilobits per second. +func bpsToKbps(bps int) int { + return bps / 1000 +} + +// kbpsToBps converts kilobits per second to bits per second. +func kbpsToBps(kbps int) int { + return kbps * 1000 +} + +// convertBitrateValues converts a slice of bps string values to kbps string values. +func convertBitrateValues(bpsValues []string) []string { + result := make([]string, len(bpsValues)) + for i, v := range bpsValues { + n, err := strconv.Atoi(v) + if err == nil { + result[i] = strconv.Itoa(n / 1000) + } else { + result[i] = v // preserve unparseable values as-is + } + } + return result +} + // GetTranscodeDecision handles the OpenSubsonic getTranscodeDecision endpoint. // It receives client capabilities and returns a decision on whether to direct play or transcode. func (api *Router) GetTranscodeDecision(_ http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { @@ -149,7 +181,7 @@ func (api *Router) GetTranscodeDecision(_ http.ResponseWriter, r *http.Request) return nil, newError(responses.ErrorGeneric, "failed to create transcode token: %v", err) } - // Build response + // Build response (convert kbps from core to bps for the API) response := newResponse() response.TranscodeDecision = &responses.TranscodeDecision{ CanDirectPlay: decision.CanDirectPlay, @@ -161,7 +193,7 @@ func (api *Router) GetTranscodeDecision(_ http.ResponseWriter, r *http.Request) Protocol: "http", Container: decision.SourceStream.Container, Codec: decision.SourceStream.Codec, - AudioBitrate: int32(decision.SourceStream.Bitrate), + AudioBitrate: int32(kbpsToBps(decision.SourceStream.Bitrate)), AudioSamplerate: int32(decision.SourceStream.SampleRate), AudioBitdepth: int32(decision.SourceStream.BitDepth), AudioChannels: int32(decision.SourceStream.Channels), @@ -173,7 +205,7 @@ func (api *Router) GetTranscodeDecision(_ http.ResponseWriter, r *http.Request) Protocol: "http", Container: decision.TranscodeStream.Container, Codec: decision.TranscodeStream.Codec, - AudioBitrate: int32(decision.TranscodeStream.Bitrate), + AudioBitrate: int32(kbpsToBps(decision.TranscodeStream.Bitrate)), AudioSamplerate: int32(decision.TranscodeStream.SampleRate), AudioBitdepth: int32(decision.TranscodeStream.BitDepth), AudioChannels: int32(decision.TranscodeStream.Channels), @@ -226,7 +258,7 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* maxBitRate := 0 if !params.DirectPlay && params.TargetFormat != "" { format = params.TargetFormat - maxBitRate = params.TargetBitrate + maxBitRate = params.TargetBitrate // Already in kbps, matching the streamer } // Get offset parameter diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 9b695a28c..970539d9c 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -59,13 +59,13 @@ var _ = Describe("Transcode endpoints", func() { It("returns a valid decision response", func() { mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320000, Channels: 2, SampleRate: 44100}, + {ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}, }) mockTD.decision = &core.Decision{ MediaID: "song-1", CanDirectPlay: true, SourceStream: core.StreamDetails{ - Container: "mp3", Codec: "mp3", Bitrate: 320000, + Container: "mp3", Codec: "mp3", Bitrate: 320, SampleRate: 44100, Channels: 2, }, } @@ -82,12 +82,12 @@ var _ = Describe("Transcode endpoints", func() { Expect(resp.TranscodeDecision.SourceStream).ToNot(BeNil()) Expect(resp.TranscodeDecision.SourceStream.Protocol).To(Equal("http")) Expect(resp.TranscodeDecision.SourceStream.Container).To(Equal("mp3")) - Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320000))) + Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320_000))) }) It("includes transcode stream when transcoding", func() { mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000000, Channels: 2, SampleRate: 96000, BitDepth: 24}, + {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}, }) mockTD.decision = &core.Decision{ MediaID: "song-2", @@ -95,9 +95,9 @@ var _ = Describe("Transcode endpoints", func() { CanTranscode: true, TargetFormat: "mp3", TargetBitrate: 256, - TranscodeReasons: []string{"AudioCodecNotSupported"}, + TranscodeReasons: []string{"container not supported"}, SourceStream: core.StreamDetails{ - Container: "flac", Codec: "flac", Bitrate: 1000000, + Container: "flac", Codec: "flac", Bitrate: 1000, SampleRate: 96000, BitDepth: 24, Channels: 2, }, TranscodeStream: &core.StreamDetails{ @@ -112,7 +112,7 @@ var _ = Describe("Transcode endpoints", func() { Expect(err).ToNot(HaveOccurred()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeReasons).To(ConsistOf("AudioCodecNotSupported")) + Expect(resp.TranscodeDecision.TranscodeReasons).To(ConsistOf("container not supported")) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) })