mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
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:
parent
0c9e3221e9
commit
954139fa7b
@ -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
|
||||
|
||||
@ -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))
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user