mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-05 20:41:07 -05:00
Compare commits
1 Commits
transcodin
...
fix-parent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c82d2fdc31 |
@@ -64,7 +64,6 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
Channels: int(props.Channels),
|
||||
SampleRate: int(props.SampleRate),
|
||||
BitDepth: int(props.BitsPerSample),
|
||||
Codec: props.Codec,
|
||||
}
|
||||
|
||||
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
|
||||
|
||||
@@ -102,8 +102,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
transcodeDecision := core.NewTranscodeDecision(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics, transcodeDecision)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
|
||||
return router
|
||||
}
|
||||
|
||||
|
||||
@@ -1,663 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const (
|
||||
transcodeTokenTTL = 12 * time.Hour
|
||||
defaultTranscodeBitrate = 256 // kbps
|
||||
)
|
||||
|
||||
// TranscodeDecision is the core service interface for making transcoding decisions
|
||||
type TranscodeDecision interface {
|
||||
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error)
|
||||
CreateToken(decision *Decision) (string, error)
|
||||
ParseToken(token string) (*TranscodeParams, error)
|
||||
}
|
||||
|
||||
// ClientInfo represents client playback capabilities.
|
||||
// All bitrate values are in kilobits per second (kbps), matching Navidrome conventions.
|
||||
type ClientInfo struct {
|
||||
Name string
|
||||
Platform string
|
||||
MaxAudioBitrate int
|
||||
MaxTranscodingAudioBitrate int
|
||||
DirectPlayProfiles []DirectPlayProfile
|
||||
TranscodingProfiles []TranscodingProfile
|
||||
CodecProfiles []CodecProfile
|
||||
}
|
||||
|
||||
// DirectPlayProfile describes a format the client can play directly
|
||||
type DirectPlayProfile struct {
|
||||
Containers []string
|
||||
AudioCodecs []string
|
||||
Protocols []string
|
||||
MaxAudioChannels int
|
||||
}
|
||||
|
||||
// TranscodingProfile describes a transcoding target the client supports
|
||||
type TranscodingProfile struct {
|
||||
Container string
|
||||
AudioCodec string
|
||||
Protocol string
|
||||
MaxAudioChannels int
|
||||
}
|
||||
|
||||
// CodecProfile describes codec-specific limitations
|
||||
type CodecProfile struct {
|
||||
Type string
|
||||
Name string
|
||||
Limitations []Limitation
|
||||
}
|
||||
|
||||
// Limitation describes a specific codec limitation
|
||||
type Limitation struct {
|
||||
Name string
|
||||
Comparison string
|
||||
Values []string
|
||||
Required bool
|
||||
}
|
||||
|
||||
// Protocol values (OpenSubsonic spec enum)
|
||||
const (
|
||||
ProtocolHTTP = "http"
|
||||
ProtocolHLS = "hls"
|
||||
)
|
||||
|
||||
// Comparison operators (OpenSubsonic spec enum)
|
||||
const (
|
||||
ComparisonEquals = "Equals"
|
||||
ComparisonNotEquals = "NotEquals"
|
||||
ComparisonLessThanEqual = "LessThanEqual"
|
||||
ComparisonGreaterThanEqual = "GreaterThanEqual"
|
||||
)
|
||||
|
||||
// Limitation names (OpenSubsonic spec enum)
|
||||
const (
|
||||
LimitationAudioChannels = "audioChannels"
|
||||
LimitationAudioBitrate = "audioBitrate"
|
||||
LimitationAudioProfile = "audioProfile"
|
||||
LimitationAudioSamplerate = "audioSamplerate"
|
||||
LimitationAudioBitdepth = "audioBitdepth"
|
||||
)
|
||||
|
||||
// Codec profile types (OpenSubsonic spec enum)
|
||||
const (
|
||||
CodecProfileTypeAudio = "AudioCodec"
|
||||
)
|
||||
|
||||
// Decision represents the internal decision result.
|
||||
// All bitrate values are in kilobits per second (kbps).
|
||||
type Decision struct {
|
||||
MediaID string
|
||||
CanDirectPlay bool
|
||||
CanTranscode bool
|
||||
TranscodeReasons []string
|
||||
ErrorReason string
|
||||
TargetFormat string
|
||||
TargetBitrate int
|
||||
TargetChannels int
|
||||
SourceStream StreamDetails
|
||||
TranscodeStream *StreamDetails
|
||||
}
|
||||
|
||||
// StreamDetails describes audio stream properties.
|
||||
// Bitrate is in kilobits per second (kbps).
|
||||
type StreamDetails struct {
|
||||
Container string
|
||||
Codec string
|
||||
Profile string // Audio profile (e.g., "LC", "HE-AAC"). Empty until scanner support is added.
|
||||
Bitrate int
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
Channels int
|
||||
Duration float32
|
||||
Size int64
|
||||
IsLossless bool
|
||||
}
|
||||
|
||||
// TranscodeParams contains the parameters extracted from a transcode token.
|
||||
// TargetBitrate is in kilobits per second (kbps).
|
||||
type TranscodeParams struct {
|
||||
MediaID string
|
||||
DirectPlay bool
|
||||
TargetFormat string
|
||||
TargetBitrate int
|
||||
TargetChannels int
|
||||
}
|
||||
|
||||
func NewTranscodeDecision(ds model.DataStore) TranscodeDecision {
|
||||
return &transcodeDecisionService{
|
||||
ds: ds,
|
||||
}
|
||||
}
|
||||
|
||||
type transcodeDecisionService struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (s *transcodeDecisionService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) {
|
||||
decision := &Decision{
|
||||
MediaID: mf.ID,
|
||||
}
|
||||
|
||||
sourceBitrate := mf.BitRate // kbps
|
||||
|
||||
// Build source stream details
|
||||
decision.SourceStream = StreamDetails{
|
||||
Container: mf.Suffix,
|
||||
Codec: mf.AudioCodec(),
|
||||
Bitrate: sourceBitrate,
|
||||
SampleRate: mf.SampleRate,
|
||||
BitDepth: mf.BitDepth,
|
||||
Channels: mf.Channels,
|
||||
Duration: mf.Duration,
|
||||
Size: mf.Size,
|
||||
IsLossless: mf.IsLossless(),
|
||||
}
|
||||
|
||||
// Check global bitrate constraint first.
|
||||
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, we're done
|
||||
if decision.CanDirectPlay {
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// Try transcoding profiles (in order of preference)
|
||||
for _, profile := range clientInfo.TranscodingProfiles {
|
||||
if ts := s.computeTranscodedStream(ctx, mf, sourceBitrate, &profile, clientInfo); ts != nil {
|
||||
decision.CanTranscode = true
|
||||
decision.TargetFormat = ts.Container
|
||||
decision.TargetBitrate = ts.Bitrate
|
||||
decision.TargetChannels = ts.Channels
|
||||
decision.TranscodeStream = ts
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If neither direct play nor transcode is possible
|
||||
if !decision.CanDirectPlay && !decision.CanTranscode {
|
||||
decision.ErrorReason = "no compatible playback profile found"
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// 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, ProtocolHTTP) {
|
||||
return "protocol not supported"
|
||||
}
|
||||
|
||||
// Check container
|
||||
if len(profile.Containers) > 0 && !matchesContainer(mf.Suffix, profile.Containers) {
|
||||
return "container not supported"
|
||||
}
|
||||
|
||||
// Check codec
|
||||
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 "audio channels not supported"
|
||||
}
|
||||
|
||||
// Check codec-specific limitations
|
||||
for _, codecProfile := range clientInfo.CodecProfiles {
|
||||
if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(mf.AudioCodec(), []string{codecProfile.Name}) {
|
||||
if reason := checkLimitations(mf, sourceBitrate, codecProfile.Limitations); reason != "" {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var ok bool
|
||||
var reason string
|
||||
|
||||
switch lim.Name {
|
||||
case LimitationAudioChannels:
|
||||
ok = checkIntLimitation(mf.Channels, lim.Comparison, lim.Values)
|
||||
reason = "audio channels not supported"
|
||||
case LimitationAudioSamplerate:
|
||||
ok = checkIntLimitation(mf.SampleRate, lim.Comparison, lim.Values)
|
||||
reason = "audio samplerate not supported"
|
||||
case LimitationAudioBitrate:
|
||||
ok = checkIntLimitation(sourceBitrate, lim.Comparison, lim.Values)
|
||||
reason = "audio bitrate not supported"
|
||||
case LimitationAudioBitdepth:
|
||||
ok = checkIntLimitation(mf.BitDepth, lim.Comparison, lim.Values)
|
||||
reason = "audio bitdepth not supported"
|
||||
case LimitationAudioProfile:
|
||||
// TODO: populate source profile when MediaFile has audio profile info
|
||||
ok = checkStringLimitation("", lim.Comparison, lim.Values)
|
||||
reason = "audio profile not supported"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if !ok && lim.Required {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
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, ProtocolHTTP) {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetFormat := strings.ToLower(profile.Container)
|
||||
if targetFormat == "" {
|
||||
targetFormat = strings.ToLower(profile.AudioCodec)
|
||||
}
|
||||
|
||||
// Verify we have a transcoding config for this format
|
||||
tc, err := s.ds.Transcoding(ctx).FindByFormat(targetFormat)
|
||||
if err != nil || tc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetIsLossless := isLosslessFormat(targetFormat)
|
||||
|
||||
// Reject lossy to lossless conversion
|
||||
if !mf.IsLossless() && targetIsLossless {
|
||||
return nil
|
||||
}
|
||||
|
||||
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() {
|
||||
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: 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, CodecProfileTypeAudio) {
|
||||
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, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted {
|
||||
return nil
|
||||
}
|
||||
if result == adjustCannotFit {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ts
|
||||
}
|
||||
|
||||
// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation.
|
||||
// Returns the adjustment result.
|
||||
func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult {
|
||||
switch lim.Name {
|
||||
case LimitationAudioChannels:
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v })
|
||||
case LimitationAudioBitrate:
|
||||
current := ts.Bitrate
|
||||
if current == 0 {
|
||||
current = sourceBitrate
|
||||
}
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v })
|
||||
case LimitationAudioSamplerate:
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v })
|
||||
case LimitationAudioBitdepth:
|
||||
if ts.BitDepth > 0 {
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v })
|
||||
}
|
||||
case LimitationAudioProfile:
|
||||
// TODO: implement when audio profile data is available
|
||||
}
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
switch comparison {
|
||||
case ComparisonLessThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return adjustNone
|
||||
}
|
||||
if current <= limit {
|
||||
return adjustNone
|
||||
}
|
||||
setter(limit)
|
||||
return adjustAdjusted
|
||||
case ComparisonGreaterThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return adjustNone
|
||||
}
|
||||
if current >= limit {
|
||||
return adjustNone
|
||||
}
|
||||
// Cannot upscale
|
||||
return adjustCannotFit
|
||||
case ComparisonEquals:
|
||||
// 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 ComparisonNotEquals:
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && current == limit {
|
||||
return adjustCannotFit
|
||||
}
|
||||
}
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
func (s *transcodeDecisionService) CreateToken(decision *Decision) (string, error) {
|
||||
exp := time.Now().Add(transcodeTokenTTL)
|
||||
claims := map[string]any{
|
||||
"mid": decision.MediaID,
|
||||
"dp": decision.CanDirectPlay,
|
||||
}
|
||||
if decision.CanTranscode && decision.TargetFormat != "" {
|
||||
claims["fmt"] = decision.TargetFormat
|
||||
claims["br"] = decision.TargetBitrate
|
||||
if decision.TargetChannels > 0 {
|
||||
claims["ch"] = decision.TargetChannels
|
||||
}
|
||||
}
|
||||
return auth.CreateExpiringPublicToken(exp, claims)
|
||||
}
|
||||
|
||||
func (s *transcodeDecisionService) ParseToken(token string) (*TranscodeParams, error) {
|
||||
claims, err := auth.Validate(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := &TranscodeParams{}
|
||||
if mid, ok := claims["mid"].(string); ok {
|
||||
params.MediaID = mid
|
||||
}
|
||||
if dp, ok := claims["dp"].(bool); ok {
|
||||
params.DirectPlay = dp
|
||||
}
|
||||
if fmt, ok := claims["fmt"].(string); ok {
|
||||
params.TargetFormat = fmt
|
||||
}
|
||||
if br, ok := claims["br"].(float64); ok {
|
||||
params.TargetBitrate = int(br)
|
||||
}
|
||||
if ch, ok := claims["ch"].(float64); ok {
|
||||
params.TargetChannels = int(ch)
|
||||
}
|
||||
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func containsIgnoreCase(slice []string, s string) bool {
|
||||
return slices.ContainsFunc(slice, func(item string) bool {
|
||||
return strings.EqualFold(item, s)
|
||||
})
|
||||
}
|
||||
|
||||
// containerAliasGroups maps each container alias to a canonical group name.
|
||||
var containerAliasGroups = func() map[string]string {
|
||||
groups := [][]string{
|
||||
{"aac", "adts", "m4a", "mp4", "m4b", "m4p"},
|
||||
{"mpeg", "mp3", "mp2"},
|
||||
{"ogg", "oga"},
|
||||
{"aif", "aiff"},
|
||||
{"asf", "wma"},
|
||||
{"mpc", "mpp"},
|
||||
{"wv"},
|
||||
}
|
||||
m := make(map[string]string)
|
||||
for _, g := range groups {
|
||||
canonical := g[0]
|
||||
for _, name := range g {
|
||||
m[name] = canonical
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// matchesWithAliases checks if a value matches any entry in candidates,
|
||||
// consulting the alias map for equivalent names.
|
||||
func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool {
|
||||
value = strings.ToLower(value)
|
||||
canonical := aliases[value]
|
||||
for _, c := range candidates {
|
||||
c = strings.ToLower(c)
|
||||
if c == value {
|
||||
return true
|
||||
}
|
||||
if canonical != "" && aliases[c] == canonical {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesContainer checks if a file suffix matches any of the container names,
|
||||
// including common aliases.
|
||||
func matchesContainer(suffix string, containers []string) bool {
|
||||
return matchesWithAliases(suffix, containers, containerAliasGroups)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func matchesCodec(codec string, codecs []string) bool {
|
||||
return matchesWithAliases(codec, codecs, codecAliasGroups)
|
||||
}
|
||||
|
||||
func checkIntLimitation(value int, comparison string, values []string) bool {
|
||||
if len(values) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
switch comparison {
|
||||
case ComparisonLessThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return value <= limit
|
||||
case ComparisonGreaterThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return value >= limit
|
||||
case ComparisonEquals:
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && value == limit {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case ComparisonNotEquals:
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && value == limit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// checkStringLimitation checks a string value against a limitation.
|
||||
// Only Equals and NotEquals comparisons are meaningful for strings.
|
||||
// LessThanEqual/GreaterThanEqual are not applicable and always pass.
|
||||
func checkStringLimitation(value string, comparison string, values []string) bool {
|
||||
switch comparison {
|
||||
case ComparisonEquals:
|
||||
for _, v := range values {
|
||||
if strings.EqualFold(value, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case ComparisonNotEquals:
|
||||
for _, v := range values {
|
||||
if strings.EqualFold(value, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseInt(s string) (int, bool) {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil || v < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func isLosslessFormat(format string) bool {
|
||||
switch strings.ToLower(format) {
|
||||
case "flac", "alac", "wav", "aiff", "ape", "wv", "tta", "tak", "shn", "dsd":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,657 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TranscodeDecision", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
svc TranscodeDecision
|
||||
ctx context.Context
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
}
|
||||
auth.Init(ds)
|
||||
svc = NewTranscodeDecision(ds)
|
||||
})
|
||||
|
||||
Describe("MakeDecision", func() {
|
||||
Context("Direct Play", func() {
|
||||
It("allows direct play when profile matches", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}, MaxAudioChannels: 2},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
Expect(decision.CanTranscode).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("rejects direct play when container doesn't match", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp3"}, Protocols: []string{"http"}},
|
||||
},
|
||||
}
|
||||
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: 1000, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
}
|
||||
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: 1000, Channels: 6}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, Protocols: []string{"http"}, MaxAudioChannels: 2},
|
||||
},
|
||||
}
|
||||
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: 256, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"aac"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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: 1000, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
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: 1500, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
MaxAudioBitrate: 500, // kbps
|
||||
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.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Transcoding", func() {
|
||||
It("selects transcoding when direct play isn't possible", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 256, // kbps
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp3"}, Protocols: []string{"http"}},
|
||||
},
|
||||
TranscodingProfiles: []TranscodingProfile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: "http", MaxAudioChannels: 2},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetFormat).To(Equal("mp3"))
|
||||
Expect(decision.TargetBitrate).To(Equal(256)) // kbps
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("container not supported"))
|
||||
})
|
||||
|
||||
It("rejects lossy to lossless transcoding", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []TranscodingProfile{
|
||||
{Container: "flac", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeFalse())
|
||||
})
|
||||
|
||||
It("uses default bitrate when client doesn't specify", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16}
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []TranscodingProfile{
|
||||
{Container: "mp3", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetBitrate).To(Equal(defaultTranscodeBitrate)) // 256 kbps
|
||||
})
|
||||
|
||||
It("preserves lossy bitrate when under max", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 256, // kbps
|
||||
TranscodingProfiles: []TranscodingProfile{
|
||||
{Container: "mp3", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
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: 1000, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []TranscodingProfile{
|
||||
{Container: "aac", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
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: 1000, Channels: 6}
|
||||
ci := &ClientInfo{}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.CanTranscode).To(BeFalse())
|
||||
Expect(decision.ErrorReason).To(Equal("no compatible playback profile found"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Codec limitations on direct play", func() {
|
||||
It("rejects direct play when codec limitation fails (required)", func() {
|
||||
mf := &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"}},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "mp3",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
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: 512, Channels: 2, SampleRate: 44100}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "mp3",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("handles Equals comparison with multiple values", func() {
|
||||
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"}},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "flac",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("rejects when Equals comparison doesn't match any value", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "flac",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
})
|
||||
|
||||
It("rejects direct play when audioProfile limitation fails (required)", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "aac",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Source profile is empty (not yet populated from scanner), so Equals("LC") fails
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("audio profile not supported"))
|
||||
})
|
||||
|
||||
It("allows direct play when audioProfile limitation is optional", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "aac",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
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: CodecProfileTypeAudio,
|
||||
Name: "flac",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, 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: CodecProfileTypeAudio,
|
||||
Name: "mp3",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, 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: CodecProfileTypeAudio,
|
||||
Name: "mp3",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioChannels, Comparison: ComparisonLessThanEqual, 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: CodecProfileTypeAudio,
|
||||
Name: "mp3",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, 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: CodecProfileTypeAudio,
|
||||
Name: "mp3",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioSamplerate, Comparison: ComparisonGreaterThanEqual, 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 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"}},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.SourceStream.Container).To(Equal("flac"))
|
||||
Expect(decision.SourceStream.Codec).To(Equal("flac"))
|
||||
Expect(decision.SourceStream.Bitrate).To(Equal(1000)) // kbps
|
||||
Expect(decision.SourceStream.SampleRate).To(Equal(96000))
|
||||
Expect(decision.SourceStream.BitDepth).To(Equal(24))
|
||||
Expect(decision.SourceStream.Channels).To(Equal(2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Token round-trip", func() {
|
||||
It("creates and parses a direct play token", func() {
|
||||
decision := &Decision{
|
||||
MediaID: "media-123",
|
||||
CanDirectPlay: true,
|
||||
}
|
||||
token, err := svc.CreateToken(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(token).ToNot(BeEmpty())
|
||||
|
||||
params, err := svc.ParseToken(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-123"))
|
||||
Expect(params.DirectPlay).To(BeTrue())
|
||||
Expect(params.TargetFormat).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("creates and parses a transcode token with kbps bitrate", func() {
|
||||
decision := &Decision{
|
||||
MediaID: "media-456",
|
||||
CanDirectPlay: false,
|
||||
CanTranscode: true,
|
||||
TargetFormat: "mp3",
|
||||
TargetBitrate: 256, // kbps
|
||||
TargetChannels: 2,
|
||||
}
|
||||
token, err := svc.CreateToken(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := svc.ParseToken(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-456"))
|
||||
Expect(params.DirectPlay).To(BeFalse())
|
||||
Expect(params.TargetFormat).To(Equal("mp3"))
|
||||
Expect(params.TargetBitrate).To(Equal(256)) // kbps
|
||||
Expect(params.TargetChannels).To(Equal(2))
|
||||
})
|
||||
|
||||
It("rejects an invalid token", func() {
|
||||
_, err := svc.ParseToken("invalid-token")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,6 @@ var Set = wire.NewSet(
|
||||
NewLibrary,
|
||||
NewUser,
|
||||
NewMaintenance,
|
||||
NewTranscodeDecision,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL;
|
||||
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
|
||||
2
go.mod
2
go.mod
@@ -7,7 +7,7 @@ replace (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
|
||||
// Fork to implement raw tags support
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260205042457-5d80806aee57
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
4
go.sum
4
go.sum
@@ -36,8 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/deluan/go-taglib v0.0.0-20260205042457-5d80806aee57 h1:SXIwfjzTv0UzoUWpFREl8p3AxXVLmbcto1/ISih11a0=
|
||||
github.com/deluan/go-taglib v0.0.0-20260205042457-5d80806aee57/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798 h1:q4fvcIK/LxElpyQILCejG6WPYjVb2F/4P93+k017ANk=
|
||||
github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
confmime "github.com/navidrome/navidrome/conf/mime"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
@@ -57,7 +56,6 @@ type MediaFile struct {
|
||||
SampleRate int `structs:"sample_rate" json:"sampleRate"`
|
||||
BitDepth int `structs:"bit_depth" json:"bitDepth"`
|
||||
Channels int `structs:"channels" json:"channels"`
|
||||
Codec string `structs:"codec" json:"codec"`
|
||||
Genre string `structs:"genre" json:"genre"`
|
||||
Genres Genres `structs:"-" json:"genres,omitempty"`
|
||||
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
|
||||
@@ -163,79 +161,6 @@ func (mf MediaFile) AbsolutePath() string {
|
||||
return filepath.Join(mf.LibraryPath, mf.Path)
|
||||
}
|
||||
|
||||
// AudioCodec returns the audio codec for this file.
|
||||
// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties.
|
||||
func (mf MediaFile) AudioCodec() string {
|
||||
// If we have a stored codec from scanning, normalize and return it
|
||||
if mf.Codec != "" {
|
||||
return strings.ToLower(mf.Codec)
|
||||
}
|
||||
// Fallback: infer from Suffix + BitDepth
|
||||
return mf.inferCodecFromSuffix()
|
||||
}
|
||||
|
||||
// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty.
|
||||
func (mf MediaFile) inferCodecFromSuffix() string {
|
||||
switch strings.ToLower(mf.Suffix) {
|
||||
case "mp3", "mpga":
|
||||
return "mp3"
|
||||
case "mp2":
|
||||
return "mp2"
|
||||
case "ogg", "oga":
|
||||
return "vorbis"
|
||||
case "opus":
|
||||
return "opus"
|
||||
case "mpc":
|
||||
return "mpc"
|
||||
case "wma":
|
||||
return "wma"
|
||||
case "flac":
|
||||
return "flac"
|
||||
case "wav":
|
||||
return "pcm"
|
||||
case "aif", "aiff", "aifc":
|
||||
return "pcm"
|
||||
case "ape":
|
||||
return "ape"
|
||||
case "wv", "wvp":
|
||||
return "wv"
|
||||
case "tta":
|
||||
return "tta"
|
||||
case "tak":
|
||||
return "tak"
|
||||
case "shn":
|
||||
return "shn"
|
||||
case "dsf", "dff":
|
||||
return "dsd"
|
||||
case "m4a":
|
||||
// AAC if BitDepth==0, ALAC if BitDepth>0
|
||||
if mf.BitDepth > 0 {
|
||||
return "alac"
|
||||
}
|
||||
return "aac"
|
||||
case "m4b", "m4p", "m4r":
|
||||
return "aac"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// IsLossless returns true if this file uses a lossless codec.
|
||||
func (mf MediaFile) IsLossless() bool {
|
||||
codec := mf.AudioCodec()
|
||||
// Primary: codec-based check (most accurate for containers like M4A)
|
||||
switch codec {
|
||||
case "flac", "alac", "pcm", "ape", "wv", "tta", "tak", "shn", "dsd":
|
||||
return true
|
||||
}
|
||||
// Secondary: suffix-based check using configurable list from YAML
|
||||
if slices.Contains(confmime.LosslessFormats, mf.Suffix) {
|
||||
return true
|
||||
}
|
||||
// Fallback heuristic: if BitDepth is set, it's likely lossless
|
||||
return mf.BitDepth > 0
|
||||
}
|
||||
|
||||
type MediaFiles []MediaFile
|
||||
|
||||
// ToAlbum creates an Album object based on the attributes of this MediaFiles collection.
|
||||
|
||||
@@ -475,7 +475,7 @@ var _ = Describe("MediaFile", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
})
|
||||
Describe("CoverArtId", func() {
|
||||
Describe(".CoverArtId()", func() {
|
||||
It("returns its own id if it HasCoverArt", func() {
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||
id := mf.CoverArtID()
|
||||
@@ -496,94 +496,6 @@ var _ = Describe("MediaFile", func() {
|
||||
Expect(id.ID).To(Equal(mf.AlbumID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AudioCodec", func() {
|
||||
It("returns normalized stored codec when available", func() {
|
||||
mf := MediaFile{Codec: "AAC", Suffix: "m4a"}
|
||||
Expect(mf.AudioCodec()).To(Equal("aac"))
|
||||
})
|
||||
|
||||
It("returns stored codec lowercased", func() {
|
||||
mf := MediaFile{Codec: "ALAC", Suffix: "m4a"}
|
||||
Expect(mf.AudioCodec()).To(Equal("alac"))
|
||||
})
|
||||
|
||||
DescribeTable("infers codec from suffix when Codec field is empty",
|
||||
func(suffix string, bitDepth int, expected string) {
|
||||
mf := MediaFile{Suffix: suffix, BitDepth: bitDepth}
|
||||
Expect(mf.AudioCodec()).To(Equal(expected))
|
||||
},
|
||||
Entry("mp3", "mp3", 0, "mp3"),
|
||||
Entry("mpga", "mpga", 0, "mp3"),
|
||||
Entry("mp2", "mp2", 0, "mp2"),
|
||||
Entry("ogg", "ogg", 0, "vorbis"),
|
||||
Entry("oga", "oga", 0, "vorbis"),
|
||||
Entry("opus", "opus", 0, "opus"),
|
||||
Entry("mpc", "mpc", 0, "mpc"),
|
||||
Entry("wma", "wma", 0, "wma"),
|
||||
Entry("flac", "flac", 0, "flac"),
|
||||
Entry("wav", "wav", 0, "pcm"),
|
||||
Entry("aif", "aif", 0, "pcm"),
|
||||
Entry("aiff", "aiff", 0, "pcm"),
|
||||
Entry("aifc", "aifc", 0, "pcm"),
|
||||
Entry("ape", "ape", 0, "ape"),
|
||||
Entry("wv", "wv", 0, "wv"),
|
||||
Entry("wvp", "wvp", 0, "wv"),
|
||||
Entry("tta", "tta", 0, "tta"),
|
||||
Entry("tak", "tak", 0, "tak"),
|
||||
Entry("shn", "shn", 0, "shn"),
|
||||
Entry("dsf", "dsf", 0, "dsd"),
|
||||
Entry("dff", "dff", 0, "dsd"),
|
||||
Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"),
|
||||
Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"),
|
||||
Entry("m4b", "m4b", 0, "aac"),
|
||||
Entry("m4p", "m4p", 0, "aac"),
|
||||
Entry("m4r", "m4r", 0, "aac"),
|
||||
Entry("unknown suffix", "xyz", 0, ""),
|
||||
)
|
||||
|
||||
It("prefers stored codec over suffix inference", func() {
|
||||
mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0}
|
||||
Expect(mf.AudioCodec()).To(Equal("alac"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("IsLossless", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
DescribeTable("detects lossless codecs",
|
||||
func(codec string, suffix string, bitDepth int, expected bool) {
|
||||
mf := MediaFile{Codec: codec, Suffix: suffix, BitDepth: bitDepth}
|
||||
Expect(mf.IsLossless()).To(Equal(expected))
|
||||
},
|
||||
Entry("flac", "FLAC", "flac", 16, true),
|
||||
Entry("alac", "ALAC", "m4a", 24, true),
|
||||
Entry("pcm via wav", "", "wav", 16, true),
|
||||
Entry("pcm via aiff", "", "aiff", 24, true),
|
||||
Entry("ape", "", "ape", 16, true),
|
||||
Entry("wv", "", "wv", 0, true),
|
||||
Entry("tta", "", "tta", 0, true),
|
||||
Entry("tak", "", "tak", 0, true),
|
||||
Entry("shn", "", "shn", 0, true),
|
||||
Entry("dsd", "", "dsf", 0, true),
|
||||
Entry("mp3 is lossy", "MP3", "mp3", 0, false),
|
||||
Entry("aac is lossy", "AAC", "m4a", 0, false),
|
||||
Entry("vorbis is lossy", "", "ogg", 0, false),
|
||||
Entry("opus is lossy", "", "opus", 0, false),
|
||||
)
|
||||
|
||||
It("detects lossless via BitDepth fallback when codec is unknown", func() {
|
||||
mf := MediaFile{Suffix: "xyz", BitDepth: 24}
|
||||
Expect(mf.IsLossless()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for unknown with no BitDepth", func() {
|
||||
mf := MediaFile{Suffix: "xyz", BitDepth: 0}
|
||||
Expect(mf.IsLossless()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func t(v string) time.Time {
|
||||
|
||||
@@ -65,7 +65,6 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
||||
mf.SampleRate = md.AudioProperties().SampleRate
|
||||
mf.BitDepth = md.AudioProperties().BitDepth
|
||||
mf.Channels = md.AudioProperties().Channels
|
||||
mf.Codec = md.AudioProperties().Codec
|
||||
mf.Path = md.FilePath()
|
||||
mf.Suffix = md.Suffix()
|
||||
mf.Size = md.Size()
|
||||
|
||||
@@ -35,7 +35,6 @@ type AudioProperties struct {
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Channels int
|
||||
Codec string
|
||||
}
|
||||
|
||||
type Date string
|
||||
@@ -251,7 +250,15 @@ func processPairMapping(name model.TagName, mapping model.TagConf, lowered model
|
||||
id3Base := parseID3Pairs(name, lowered)
|
||||
|
||||
if len(aliasValues) > 0 {
|
||||
id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
|
||||
// For lyrics, don't use parseVorbisPairs as parentheses in lyrics content
|
||||
// should not be interpreted as language keys (e.g. "(intro)" is not a language)
|
||||
if name == model.TagLyrics {
|
||||
for _, v := range aliasValues {
|
||||
id3Base = append(id3Base, NewPair("xxx", v))
|
||||
}
|
||||
} else {
|
||||
id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
|
||||
}
|
||||
}
|
||||
return id3Base
|
||||
}
|
||||
|
||||
@@ -246,6 +246,18 @@ var _ = Describe("Metadata", func() {
|
||||
metadata.NewPair("eng", "Lyrics"),
|
||||
))
|
||||
})
|
||||
|
||||
It("should preserve lyrics starting with parentheses from alias tags", func() {
|
||||
props.Tags = model.RawTags{
|
||||
"LYRICS": {"(line one)\nline two\nline three"},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.All()).To(HaveKey(model.TagLyrics))
|
||||
Expect(md.Strings(model.TagLyrics)).To(ContainElements(
|
||||
metadata.NewPair("xxx", "(line one)\nline two\nline three"),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReplayGain", func() {
|
||||
|
||||
@@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
|
||||
@@ -31,42 +31,40 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic,
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
artwork artwork.Artwork
|
||||
streamer core.MediaStreamer
|
||||
archiver core.Archiver
|
||||
players core.Players
|
||||
provider external.Provider
|
||||
playlists core.Playlists
|
||||
scanner model.Scanner
|
||||
broker events.Broker
|
||||
scrobbler scrobbler.PlayTracker
|
||||
share core.Share
|
||||
playback playback.PlaybackServer
|
||||
metrics metrics.Metrics
|
||||
transcodeDecision core.TranscodeDecision
|
||||
ds model.DataStore
|
||||
artwork artwork.Artwork
|
||||
streamer core.MediaStreamer
|
||||
archiver core.Archiver
|
||||
players core.Players
|
||||
provider external.Provider
|
||||
playlists core.Playlists
|
||||
scanner model.Scanner
|
||||
broker events.Broker
|
||||
scrobbler scrobbler.PlayTracker
|
||||
share core.Share
|
||||
playback playback.PlaybackServer
|
||||
metrics metrics.Metrics
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
||||
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
|
||||
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
||||
metrics metrics.Metrics, transcodeDecision core.TranscodeDecision,
|
||||
metrics metrics.Metrics,
|
||||
) *Router {
|
||||
r := &Router{
|
||||
ds: ds,
|
||||
artwork: artwork,
|
||||
streamer: streamer,
|
||||
archiver: archiver,
|
||||
players: players,
|
||||
provider: provider,
|
||||
playlists: playlists,
|
||||
scanner: scanner,
|
||||
broker: broker,
|
||||
scrobbler: scrobbler,
|
||||
share: share,
|
||||
playback: playback,
|
||||
metrics: metrics,
|
||||
transcodeDecision: transcodeDecision,
|
||||
ds: ds,
|
||||
artwork: artwork,
|
||||
streamer: streamer,
|
||||
archiver: archiver,
|
||||
players: players,
|
||||
provider: provider,
|
||||
playlists: playlists,
|
||||
scanner: scanner,
|
||||
broker: broker,
|
||||
scrobbler: scrobbler,
|
||||
share: share,
|
||||
playback: playback,
|
||||
metrics: metrics,
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
@@ -171,8 +169,6 @@ func (api *Router) routes() http.Handler {
|
||||
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
|
||||
hr(r, "stream", api.Stream)
|
||||
hr(r, "download", api.Download)
|
||||
hr(r, "getTranscodeDecision", api.GetTranscodeDecision)
|
||||
hr(r, "getTranscodeStream", api.GetTranscodeStream)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
// configure request throttling
|
||||
|
||||
@@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
playTracker = &fakePlayTracker{}
|
||||
eventBroker = &fakeEventBroker{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil)
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
|
||||
@@ -33,7 +33,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
||||
MockedMediaFile: mockRepo,
|
||||
}
|
||||
artwork = &fakeArtwork{data: "image data"}
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LyricsPriority = "embedded,.lrc"
|
||||
|
||||
@@ -13,7 +13,6 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
|
||||
{Name: "formPost", Versions: []int32{1}},
|
||||
{Name: "songLyrics", Versions: []int32{1}},
|
||||
{Name: "indexBasedQueue", Versions: []int32{1}},
|
||||
{Name: "transcoding", Versions: []int32{1}},
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
|
||||
})
|
||||
@@ -35,12 +35,11 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(5),
|
||||
HaveLen(4),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ var _ = Describe("buildPlaylist", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
ctx = context.Background()
|
||||
|
||||
createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
@@ -125,7 +125,7 @@ var _ = Describe("UpdatePlaylist", func() {
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
playlists = &fakePlaylists{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
It("clears the comment when parameter is empty", func() {
|
||||
|
||||
@@ -61,7 +61,6 @@ type Subsonic struct {
|
||||
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
||||
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
|
||||
TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -612,26 +611,3 @@ func marshalJSONArray[T any](v []T) ([]byte, error) {
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// TranscodeDecision represents the response for getTranscodeDecision (OpenSubsonic transcoding extension)
|
||||
type TranscodeDecision struct {
|
||||
CanDirectPlay bool `xml:"canDirectPlay,attr" json:"canDirectPlay"`
|
||||
CanTranscode bool `xml:"canTranscode,attr" json:"canTranscode"`
|
||||
TranscodeReasons []string `xml:"transcodeReason,omitempty" json:"transcodeReason,omitempty"`
|
||||
ErrorReason string `xml:"errorReason,attr,omitempty" json:"errorReason,omitempty"`
|
||||
TranscodeParams string `xml:"transcodeParams,attr,omitempty" json:"transcodeParams,omitempty"`
|
||||
SourceStream *StreamDetails `xml:"sourceStream,omitempty" json:"sourceStream,omitempty"`
|
||||
TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"`
|
||||
}
|
||||
|
||||
// StreamDetails describes audio stream properties for transcoding decisions
|
||||
type StreamDetails struct {
|
||||
Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"`
|
||||
Container string `xml:"container,attr,omitempty" json:"container,omitempty"`
|
||||
Codec string `xml:"codec,attr,omitempty" json:"codec,omitempty"`
|
||||
AudioChannels int32 `xml:"audioChannels,attr,omitempty" json:"audioChannels,omitempty"`
|
||||
AudioBitrate int32 `xml:"audioBitrate,attr,omitempty" json:"audioBitrate,omitempty"`
|
||||
AudioProfile string `xml:"audioProfile,attr,omitempty" json:"audioProfile,omitempty"`
|
||||
AudioSamplerate int32 `xml:"audioSamplerate,attr,omitempty" json:"audioSamplerate,omitempty"`
|
||||
AudioBitdepth int32 `xml:"audioBitdepth,attr,omitempty" json:"audioBitdepth,omitempty"`
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ var _ = Describe("Search", func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
// Get references to the mock repositories so we can inspect their Options
|
||||
mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
// API-layer request structs for JSON unmarshaling (decoupled from core structs)
|
||||
|
||||
// clientInfoRequest represents client playback capabilities from the request body
|
||||
type clientInfoRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
MaxAudioBitrate int `json:"maxAudioBitrate,omitempty"`
|
||||
MaxTranscodingAudioBitrate int `json:"maxTranscodingAudioBitrate,omitempty"`
|
||||
DirectPlayProfiles []directPlayProfileReq `json:"directPlayProfiles,omitempty"`
|
||||
TranscodingProfiles []transcodingProfileReq `json:"transcodingProfiles,omitempty"`
|
||||
CodecProfiles []codecProfileReq `json:"codecProfiles,omitempty"`
|
||||
}
|
||||
|
||||
// directPlayProfileReq describes a format the client can play directly
|
||||
type directPlayProfileReq struct {
|
||||
Containers []string `json:"containers,omitempty"`
|
||||
AudioCodecs []string `json:"audioCodecs,omitempty"`
|
||||
Protocols []string `json:"protocols,omitempty"`
|
||||
MaxAudioChannels int `json:"maxAudioChannels,omitempty"`
|
||||
}
|
||||
|
||||
// transcodingProfileReq describes a transcoding target the client supports
|
||||
type transcodingProfileReq struct {
|
||||
Container string `json:"container,omitempty"`
|
||||
AudioCodec string `json:"audioCodec,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
MaxAudioChannels int `json:"maxAudioChannels,omitempty"`
|
||||
}
|
||||
|
||||
// codecProfileReq describes codec-specific limitations
|
||||
type codecProfileReq struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Limitations []limitationReq `json:"limitations,omitempty"`
|
||||
}
|
||||
|
||||
// limitationReq describes a specific codec limitation
|
||||
type limitationReq struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Comparison string `json:"comparison,omitempty"`
|
||||
Values []string `json:"values,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
// 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: bpsToKbps(r.MaxAudioBitrate),
|
||||
MaxTranscodingAudioBitrate: bpsToKbps(r.MaxTranscodingAudioBitrate),
|
||||
}
|
||||
|
||||
for _, dp := range r.DirectPlayProfiles {
|
||||
ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, core.DirectPlayProfile{
|
||||
Containers: dp.Containers,
|
||||
AudioCodecs: dp.AudioCodecs,
|
||||
Protocols: dp.Protocols,
|
||||
MaxAudioChannels: dp.MaxAudioChannels,
|
||||
})
|
||||
}
|
||||
|
||||
for _, tp := range r.TranscodingProfiles {
|
||||
ci.TranscodingProfiles = append(ci.TranscodingProfiles, core.TranscodingProfile{
|
||||
Container: tp.Container,
|
||||
AudioCodec: tp.AudioCodec,
|
||||
Protocol: tp.Protocol,
|
||||
MaxAudioChannels: tp.MaxAudioChannels,
|
||||
})
|
||||
}
|
||||
|
||||
for _, cp := range r.CodecProfiles {
|
||||
coreCP := core.CodecProfile{
|
||||
Type: cp.Type,
|
||||
Name: cp.Name,
|
||||
}
|
||||
for _, lim := range cp.Limitations {
|
||||
coreLim := core.Limitation{
|
||||
Name: lim.Name,
|
||||
Comparison: lim.Comparison,
|
||||
Values: lim.Values,
|
||||
Required: lim.Required,
|
||||
}
|
||||
// Convert audioBitrate limitation values from bps to kbps
|
||||
if lim.Name == core.LimitationAudioBitrate {
|
||||
coreLim.Values = convertBitrateValues(lim.Values)
|
||||
}
|
||||
coreCP.Limitations = append(coreCP.Limitations, coreLim)
|
||||
}
|
||||
ci.CodecProfiles = append(ci.CodecProfiles, coreCP)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// validate checks that all enum fields in the request contain valid values per the OpenSubsonic spec.
|
||||
func (r *clientInfoRequest) validate() error {
|
||||
for _, dp := range r.DirectPlayProfiles {
|
||||
for _, p := range dp.Protocols {
|
||||
if !isValidProtocol(p) {
|
||||
return fmt.Errorf("invalid protocol: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, tp := range r.TranscodingProfiles {
|
||||
if tp.Protocol != "" && !isValidProtocol(tp.Protocol) {
|
||||
return fmt.Errorf("invalid protocol: %s", tp.Protocol)
|
||||
}
|
||||
}
|
||||
for _, cp := range r.CodecProfiles {
|
||||
if !isValidCodecProfileType(cp.Type) {
|
||||
return fmt.Errorf("invalid codec profile type: %s", cp.Type)
|
||||
}
|
||||
for _, lim := range cp.Limitations {
|
||||
if !isValidLimitationName(lim.Name) {
|
||||
return fmt.Errorf("invalid limitation name: %s", lim.Name)
|
||||
}
|
||||
if !isValidComparison(lim.Comparison) {
|
||||
return fmt.Errorf("invalid comparison: %s", lim.Comparison)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidProtocol(p string) bool {
|
||||
return p == core.ProtocolHTTP || p == core.ProtocolHLS
|
||||
}
|
||||
|
||||
func isValidCodecProfileType(t string) bool {
|
||||
return t == core.CodecProfileTypeAudio
|
||||
}
|
||||
|
||||
func isValidLimitationName(n string) bool {
|
||||
return n == core.LimitationAudioChannels ||
|
||||
n == core.LimitationAudioBitrate ||
|
||||
n == core.LimitationAudioProfile ||
|
||||
n == core.LimitationAudioSamplerate ||
|
||||
n == core.LimitationAudioBitdepth
|
||||
}
|
||||
|
||||
func isValidComparison(c string) bool {
|
||||
return c == core.ComparisonEquals ||
|
||||
c == core.ComparisonNotEquals ||
|
||||
c == core.ComparisonLessThanEqual ||
|
||||
c == core.ComparisonGreaterThanEqual
|
||||
}
|
||||
|
||||
// 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(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
|
||||
mediaID, err := p.String("mediaId")
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaId")
|
||||
}
|
||||
|
||||
mediaType, err := p.String("mediaType")
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaType")
|
||||
}
|
||||
|
||||
// Only support songs for now
|
||||
if mediaType != "song" {
|
||||
return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType)
|
||||
}
|
||||
|
||||
// Parse and validate ClientInfo from request body (required per OpenSubsonic spec)
|
||||
var clientInfoReq clientInfoRequest
|
||||
if r.Body == nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing required JSON request body")
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&clientInfoReq); err != nil {
|
||||
return nil, newError(responses.ErrorGeneric, "invalid JSON request body")
|
||||
}
|
||||
if err := clientInfoReq.validate(); err != nil {
|
||||
return nil, newError(responses.ErrorGeneric, "%v", err)
|
||||
}
|
||||
clientInfo := clientInfoReq.toCore()
|
||||
|
||||
// Get media file
|
||||
mf, err := api.ds.MediaFile(ctx).Get(mediaID)
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorDataNotFound, "media file not found: %s", mediaID)
|
||||
}
|
||||
|
||||
// Make the decision
|
||||
decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo)
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorGeneric, "failed to make transcode decision: %v", err)
|
||||
}
|
||||
|
||||
// Create token
|
||||
transcodeParams, err := api.transcodeDecision.CreateToken(decision)
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorGeneric, "failed to create transcode token: %v", err)
|
||||
}
|
||||
|
||||
// Build response (convert kbps from core to bps for the API)
|
||||
response := newResponse()
|
||||
response.TranscodeDecision = &responses.TranscodeDecision{
|
||||
CanDirectPlay: decision.CanDirectPlay,
|
||||
CanTranscode: decision.CanTranscode,
|
||||
TranscodeReasons: decision.TranscodeReasons,
|
||||
ErrorReason: decision.ErrorReason,
|
||||
TranscodeParams: transcodeParams,
|
||||
SourceStream: &responses.StreamDetails{
|
||||
Protocol: "http",
|
||||
Container: decision.SourceStream.Container,
|
||||
Codec: decision.SourceStream.Codec,
|
||||
AudioBitrate: int32(kbpsToBps(decision.SourceStream.Bitrate)),
|
||||
AudioProfile: decision.SourceStream.Profile,
|
||||
AudioSamplerate: int32(decision.SourceStream.SampleRate),
|
||||
AudioBitdepth: int32(decision.SourceStream.BitDepth),
|
||||
AudioChannels: int32(decision.SourceStream.Channels),
|
||||
},
|
||||
}
|
||||
|
||||
if decision.TranscodeStream != nil {
|
||||
response.TranscodeDecision.TranscodeStream = &responses.StreamDetails{
|
||||
Protocol: "http",
|
||||
Container: decision.TranscodeStream.Container,
|
||||
Codec: decision.TranscodeStream.Codec,
|
||||
AudioBitrate: int32(kbpsToBps(decision.TranscodeStream.Bitrate)),
|
||||
AudioProfile: decision.TranscodeStream.Profile,
|
||||
AudioSamplerate: int32(decision.TranscodeStream.SampleRate),
|
||||
AudioBitdepth: int32(decision.TranscodeStream.BitDepth),
|
||||
AudioChannels: int32(decision.TranscodeStream.Channels),
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint.
|
||||
// It streams media using the decision encoded in the transcodeParams JWT token.
|
||||
func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
|
||||
mediaID, err := p.String("mediaId")
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaId")
|
||||
}
|
||||
|
||||
mediaType, err := p.String("mediaType")
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaType")
|
||||
}
|
||||
|
||||
transcodeParams, err := p.String("transcodeParams")
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: transcodeParams")
|
||||
}
|
||||
|
||||
// Only support songs for now
|
||||
if mediaType != "song" {
|
||||
return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType)
|
||||
}
|
||||
|
||||
// Parse and validate the token
|
||||
params, err := api.transcodeDecision.ParseToken(transcodeParams)
|
||||
if err != nil {
|
||||
log.Debug(ctx, "Failed to parse transcode token", err)
|
||||
return nil, newError(responses.ErrorDataNotFound, "invalid or expired transcodeParams token")
|
||||
}
|
||||
|
||||
// Verify mediaId matches token
|
||||
if params.MediaID != mediaID {
|
||||
return nil, newError(responses.ErrorDataNotFound, "mediaId does not match token")
|
||||
}
|
||||
|
||||
// Determine streaming parameters
|
||||
format := ""
|
||||
maxBitRate := 0
|
||||
if !params.DirectPlay && params.TargetFormat != "" {
|
||||
format = params.TargetFormat
|
||||
maxBitRate = params.TargetBitrate // Already in kbps, matching the streamer
|
||||
}
|
||||
|
||||
// Get offset parameter
|
||||
offset := p.IntOr("offset", 0)
|
||||
|
||||
// Create stream
|
||||
stream, err := api.streamer.NewStream(ctx, mediaID, format, maxBitRate, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure the stream will be closed at the end
|
||||
defer func() {
|
||||
if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error("Error closing stream", "id", mediaID, "file", stream.Name(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
api.serveStream(ctx, w, r, stream, mediaID)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Transcode endpoints", func() {
|
||||
var (
|
||||
router *Router
|
||||
ds *tests.MockDataStore
|
||||
mockTD *mockTranscodeDecision
|
||||
w *httptest.ResponseRecorder
|
||||
mockMFRepo *tests.MockMediaFileRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
mockMFRepo = &tests.MockMediaFileRepo{}
|
||||
ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo}
|
||||
mockTD = &mockTranscodeDecision{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
Describe("GetTranscodeDecision", func() {
|
||||
It("returns 405 for non-POST requests", func() {
|
||||
r := newGetRequest("mediaId=123", "mediaType=song")
|
||||
resp, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).To(BeNil())
|
||||
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
|
||||
Expect(w.Header().Get("Allow")).To(Equal("POST"))
|
||||
})
|
||||
|
||||
It("returns error when mediaId is missing", func() {
|
||||
r := newJSONPostRequest("mediaType=song", "{}")
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error when mediaType is missing", func() {
|
||||
r := newJSONPostRequest("mediaId=123", "{}")
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for unsupported mediaType", func() {
|
||||
r := newJSONPostRequest("mediaId=123&mediaType=podcast", "{}")
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("not yet supported"))
|
||||
})
|
||||
|
||||
It("returns error when media file not found", func() {
|
||||
mockMFRepo.SetError(true)
|
||||
r := newJSONPostRequest("mediaId=notfound&mediaType=song", "{}")
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error when body is empty", func() {
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "")
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error when body contains invalid JSON", func() {
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "not-json{{{")
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for invalid protocol in direct play profile", func() {
|
||||
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["ftp"]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
|
||||
})
|
||||
|
||||
It("returns error for invalid comparison operator", func() {
|
||||
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"InvalidOp","values":["320"]}]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
|
||||
})
|
||||
|
||||
It("returns error for invalid limitation name", func() {
|
||||
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"unknownField","comparison":"Equals","values":["320"]}]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
|
||||
})
|
||||
|
||||
It("returns error for invalid codec profile type", func() {
|
||||
body := `{"codecProfiles":[{"type":"VideoCodec","name":"mp3"}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
|
||||
})
|
||||
|
||||
It("rejects wrong-case protocol", func() {
|
||||
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["HTTP"]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
|
||||
})
|
||||
|
||||
It("rejects wrong-case codec profile type", func() {
|
||||
body := `{"codecProfiles":[{"type":"audiocodec","name":"mp3"}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
|
||||
})
|
||||
|
||||
It("rejects wrong-case comparison operator", func() {
|
||||
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"lessthanequal","values":["320"]}]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
|
||||
})
|
||||
|
||||
It("rejects wrong-case limitation name", func() {
|
||||
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"AudioBitrate","comparison":"Equals","values":["320"]}]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
|
||||
})
|
||||
|
||||
It("returns a valid decision response", func() {
|
||||
mockMFRepo.SetData(model.MediaFiles{
|
||||
{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: 320,
|
||||
SampleRate: 44100, Channels: 2,
|
||||
},
|
||||
}
|
||||
mockTD.token = "test-jwt-token"
|
||||
|
||||
body := `{"directPlayProfiles":[{"containers":["mp3"],"protocols":["http"]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
resp, err := router.GetTranscodeDecision(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
|
||||
Expect(resp.TranscodeDecision.TranscodeParams).To(Equal("test-jwt-token"))
|
||||
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(320_000)))
|
||||
})
|
||||
|
||||
It("includes transcode stream when transcoding", func() {
|
||||
mockMFRepo.SetData(model.MediaFiles{
|
||||
{ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24},
|
||||
})
|
||||
mockTD.decision = &core.Decision{
|
||||
MediaID: "song-2",
|
||||
CanDirectPlay: false,
|
||||
CanTranscode: true,
|
||||
TargetFormat: "mp3",
|
||||
TargetBitrate: 256,
|
||||
TranscodeReasons: []string{"container not supported"},
|
||||
SourceStream: core.StreamDetails{
|
||||
Container: "flac", Codec: "flac", Bitrate: 1000,
|
||||
SampleRate: 96000, BitDepth: 24, Channels: 2,
|
||||
},
|
||||
TranscodeStream: &core.StreamDetails{
|
||||
Container: "mp3", Codec: "mp3", Bitrate: 256,
|
||||
SampleRate: 96000, Channels: 2,
|
||||
},
|
||||
}
|
||||
mockTD.token = "transcode-token"
|
||||
|
||||
r := newJSONPostRequest("mediaId=song-2&mediaType=song", "{}")
|
||||
resp, err := router.GetTranscodeDecision(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||
Expect(resp.TranscodeDecision.TranscodeReasons).To(ConsistOf("container not supported"))
|
||||
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
||||
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetTranscodeStream", func() {
|
||||
It("returns error when mediaId is missing", func() {
|
||||
r := newGetRequest("mediaType=song", "transcodeParams=abc")
|
||||
_, err := router.GetTranscodeStream(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error when transcodeParams is missing", func() {
|
||||
r := newGetRequest("mediaId=123", "mediaType=song")
|
||||
_, err := router.GetTranscodeStream(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for invalid token", func() {
|
||||
mockTD.parseErr = model.ErrNotFound
|
||||
r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token")
|
||||
_, err := router.GetTranscodeStream(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error when mediaId doesn't match token", func() {
|
||||
mockTD.params = &core.TranscodeParams{MediaID: "other-id", DirectPlay: true}
|
||||
r := newGetRequest("mediaId=wrong-id", "mediaType=song", "transcodeParams=valid-token")
|
||||
_, err := router.GetTranscodeStream(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("does not match"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// newJSONPostRequest creates an HTTP POST request with JSON body and query params
|
||||
func newJSONPostRequest(queryParams string, jsonBody string) *http.Request {
|
||||
r := httptest.NewRequest("POST", "/getTranscodeDecision?"+queryParams, bytes.NewBufferString(jsonBody))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
return r
|
||||
}
|
||||
|
||||
// mockTranscodeDecision is a test double for core.TranscodeDecision
|
||||
type mockTranscodeDecision struct {
|
||||
decision *core.Decision
|
||||
token string
|
||||
tokenErr error
|
||||
params *core.TranscodeParams
|
||||
parseErr error
|
||||
}
|
||||
|
||||
func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *core.ClientInfo) (*core.Decision, error) {
|
||||
if m.decision != nil {
|
||||
return m.decision, nil
|
||||
}
|
||||
return &core.Decision{}, nil
|
||||
}
|
||||
|
||||
func (m *mockTranscodeDecision) CreateToken(_ *core.Decision) (string, error) {
|
||||
return m.token, m.tokenErr
|
||||
}
|
||||
|
||||
func (m *mockTranscodeDecision) ParseToken(_ string) (*core.TranscodeParams, error) {
|
||||
if m.parseErr != nil {
|
||||
return nil, m.parseErr
|
||||
}
|
||||
return m.params, nil
|
||||
}
|
||||
Reference in New Issue
Block a user