fix(transcoding): update bitrate handling to use kilobits per second (kbps) across transcode decision logic

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-02-05 16:14:37 -05:00
parent 0c9e3221e9
commit 954139fa7b
5 changed files with 657 additions and 157 deletions

View File

@ -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

View File

@ -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))
})

View File

@ -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

View File

@ -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

View File

@ -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"))
})