mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
Updated mock AAC transcoding command to use the new default (ipod with fragmented MP4) matching the migration, ensuring tests exercise the same buildDynamicArgs code path as production. Improved archiver test mock to match on the whole StreamRequest struct instead of decomposing fields, making it resilient to future field additions. Added named constants for JWT claim keys in the transcode token and wrapped ParseTranscodeParams errors with ErrTokenInvalid for consistency. Documented the IsLossless BitDepth fallback heuristic as temporary until Codec column is populated. Signed-off-by: Deluan <deluan@navidrome.org>
401 lines
14 KiB
Go
401 lines
14 KiB
Go
package transcode
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
)
|
|
|
|
const (
|
|
tokenTTL = 12 * time.Hour
|
|
defaultBitrate = 256 // kbps
|
|
|
|
// JWT claim keys for transcode params tokens
|
|
claimMediaID = "mid" // Media file ID
|
|
claimDirectPlay = "dp" // Direct play flag (bool)
|
|
claimUpdatedAt = "ua" // Source file updated-at (Unix seconds)
|
|
claimFormat = "fmt" // Target transcoding format
|
|
claimBitrate = "br" // Target bitrate (kbps)
|
|
claimChannels = "ch" // Target channels
|
|
claimSampleRate = "sr" // Target sample rate (Hz)
|
|
claimBitDepth = "bd" // Target bit depth
|
|
)
|
|
|
|
func NewDecider(ds model.DataStore) Decider {
|
|
return &deciderService{
|
|
ds: ds,
|
|
}
|
|
}
|
|
|
|
type deciderService struct {
|
|
ds model.DataStore
|
|
}
|
|
|
|
func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) {
|
|
decision := &Decision{
|
|
MediaID: mf.ID,
|
|
SourceUpdatedAt: mf.UpdatedAt,
|
|
}
|
|
|
|
sourceBitrate := mf.BitRate // kbps
|
|
|
|
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", mf.Suffix,
|
|
"codec", mf.AudioCodec(), "bitrate", sourceBitrate, "channels", mf.Channels,
|
|
"sampleRate", mf.SampleRate, "lossless", mf.IsLossless(), "client", clientInfo.Name)
|
|
|
|
// Build source stream details
|
|
decision.SourceStream = buildSourceStream(mf)
|
|
|
|
// Check global bitrate constraint first.
|
|
if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate {
|
|
log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
|
|
"sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
|
|
decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported")
|
|
// Skip direct play profiles entirely — global constraint fails
|
|
} else {
|
|
// Try direct play profiles, collecting reasons for each failure
|
|
for _, profile := range clientInfo.DirectPlayProfiles {
|
|
if reason := s.checkDirectPlayProfile(mf, sourceBitrate, &profile, clientInfo); reason == "" {
|
|
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 {
|
|
log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", mf.Suffix, "codec", mf.AudioCodec())
|
|
return decision, nil
|
|
}
|
|
|
|
// Try transcoding profiles (in order of preference)
|
|
for _, profile := range clientInfo.TranscodingProfiles {
|
|
if ts, transcodeFormat := s.computeTranscodedStream(ctx, mf, sourceBitrate, &profile, clientInfo); ts != nil {
|
|
decision.CanTranscode = true
|
|
decision.TargetFormat = transcodeFormat
|
|
decision.TargetBitrate = ts.Bitrate
|
|
decision.TargetChannels = ts.Channels
|
|
decision.TargetSampleRate = ts.SampleRate
|
|
decision.TargetBitDepth = ts.BitDepth
|
|
decision.TranscodeStream = ts
|
|
break
|
|
}
|
|
}
|
|
|
|
if decision.CanTranscode {
|
|
log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID,
|
|
"targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate,
|
|
"targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons)
|
|
}
|
|
|
|
// If neither direct play nor transcode is possible
|
|
if !decision.CanDirectPlay && !decision.CanTranscode {
|
|
decision.ErrorReason = "no compatible playback profile found"
|
|
log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID,
|
|
"container", mf.Suffix, "codec", mf.AudioCodec(), "reasons", decision.TranscodeReasons)
|
|
}
|
|
|
|
return decision, nil
|
|
}
|
|
|
|
func buildSourceStream(mf *model.MediaFile) StreamDetails {
|
|
return StreamDetails{
|
|
Container: mf.Suffix,
|
|
Codec: mf.AudioCodec(),
|
|
Bitrate: mf.BitRate,
|
|
SampleRate: mf.SampleRate,
|
|
BitDepth: mf.BitDepth,
|
|
Channels: mf.Channels,
|
|
Duration: mf.Duration,
|
|
Size: mf.Size,
|
|
IsLossless: mf.IsLossless(),
|
|
}
|
|
}
|
|
|
|
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
|
|
// or a typed reason string if it doesn't match.
|
|
func (s *deciderService) checkDirectPlayProfile(mf *model.MediaFile, sourceBitrate int, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
|
|
// 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 ""
|
|
}
|
|
|
|
// computeTranscodedStream attempts to build a valid transcoded stream for the given profile.
|
|
// Returns the stream details and the internal transcoding format (which may differ from the
|
|
// response container when a codec fallback occurs, e.g., "mp4"→"aac").
|
|
// Returns nil, "" if the profile cannot produce a valid output.
|
|
func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model.MediaFile, sourceBitrate int, profile *Profile, clientInfo *ClientInfo) (*StreamDetails, string) {
|
|
// Check protocol (only http for now)
|
|
if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) {
|
|
log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol)
|
|
return nil, ""
|
|
}
|
|
|
|
responseContainer, targetFormat := s.resolveTargetFormat(ctx, profile)
|
|
if targetFormat == "" {
|
|
return nil, ""
|
|
}
|
|
|
|
targetIsLossless := isLosslessFormat(targetFormat)
|
|
|
|
// Reject lossy to lossless conversion
|
|
if !mf.IsLossless() && targetIsLossless {
|
|
log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat)
|
|
return nil, ""
|
|
}
|
|
|
|
ts := &StreamDetails{
|
|
Container: responseContainer,
|
|
Codec: strings.ToLower(profile.AudioCodec),
|
|
SampleRate: normalizeSourceSampleRate(mf.SampleRate, mf.AudioCodec()),
|
|
Channels: mf.Channels,
|
|
BitDepth: normalizeSourceBitDepth(mf.BitDepth, mf.AudioCodec()),
|
|
IsLossless: targetIsLossless,
|
|
}
|
|
if ts.Codec == "" {
|
|
ts.Codec = targetFormat
|
|
}
|
|
|
|
// Apply codec-intrinsic sample rate adjustments before codec profile limitations
|
|
if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 {
|
|
ts.SampleRate = fixedRate
|
|
}
|
|
if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate {
|
|
ts.SampleRate = maxRate
|
|
}
|
|
|
|
// Determine target bitrate (all in kbps)
|
|
if ok := s.computeBitrate(ctx, mf, sourceBitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
|
return nil, ""
|
|
}
|
|
|
|
// 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
|
|
if ok := s.applyCodecLimitations(ctx, sourceBitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
|
return nil, ""
|
|
}
|
|
|
|
return ts, targetFormat
|
|
}
|
|
|
|
// resolveTargetFormat determines the response container and internal target format
|
|
// by looking up transcoding configs. Returns ("", "") if no config found.
|
|
func (s *deciderService) resolveTargetFormat(ctx context.Context, profile *Profile) (responseContainer, targetFormat string) {
|
|
responseContainer = strings.ToLower(profile.Container)
|
|
targetFormat = responseContainer
|
|
if targetFormat == "" {
|
|
targetFormat = strings.ToLower(profile.AudioCodec)
|
|
responseContainer = targetFormat
|
|
}
|
|
|
|
// Try the container first, then fall back to the audioCodec (e.g. "ogg" → "opus", "mp4" → "aac").
|
|
_, err := s.ds.Transcoding(ctx).FindByFormat(targetFormat)
|
|
if errors.Is(err, model.ErrNotFound) && profile.AudioCodec != "" && !strings.EqualFold(targetFormat, profile.AudioCodec) {
|
|
codec := strings.ToLower(profile.AudioCodec)
|
|
log.Trace(ctx, "No transcoding config for container, trying audioCodec", "container", targetFormat, "audioCodec", codec)
|
|
_, err = s.ds.Transcoding(ctx).FindByFormat(codec)
|
|
if err == nil {
|
|
targetFormat = codec
|
|
}
|
|
}
|
|
if err != nil {
|
|
if !errors.Is(err, model.ErrNotFound) {
|
|
log.Error(ctx, "Error looking up transcoding config", "format", targetFormat, err)
|
|
} else {
|
|
log.Trace(ctx, "Skipping transcoding profile: no transcoding config", "targetFormat", targetFormat)
|
|
}
|
|
return "", ""
|
|
}
|
|
return responseContainer, targetFormat
|
|
}
|
|
|
|
// computeBitrate determines the target bitrate for the transcoded stream.
|
|
// Returns false if the profile should be rejected.
|
|
func (s *deciderService) computeBitrate(ctx context.Context, mf *model.MediaFile, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool {
|
|
if mf.IsLossless() {
|
|
if !targetIsLossless {
|
|
if clientInfo.MaxTranscodingAudioBitrate > 0 {
|
|
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
|
|
} else {
|
|
ts.Bitrate = defaultBitrate
|
|
}
|
|
} else {
|
|
if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate {
|
|
log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit",
|
|
"targetFormat", targetFormat, "sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
|
|
return false
|
|
}
|
|
}
|
|
} else {
|
|
ts.Bitrate = sourceBitrate
|
|
}
|
|
|
|
// Apply maxAudioBitrate as final cap
|
|
if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate {
|
|
ts.Bitrate = clientInfo.MaxAudioBitrate
|
|
}
|
|
return true
|
|
}
|
|
|
|
// applyCodecLimitations applies codec profile limitations to the transcoded stream.
|
|
// Returns false if the profile should be rejected.
|
|
func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool {
|
|
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)
|
|
if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted {
|
|
log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target",
|
|
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name)
|
|
return false
|
|
}
|
|
if result == adjustCannotFit {
|
|
log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied",
|
|
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name,
|
|
"comparison", lim.Comparison, "values", lim.Values)
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) {
|
|
exp := time.Now().Add(tokenTTL)
|
|
claims := map[string]any{
|
|
claimMediaID: decision.MediaID,
|
|
claimDirectPlay: decision.CanDirectPlay,
|
|
claimUpdatedAt: decision.SourceUpdatedAt.Truncate(time.Second).Unix(),
|
|
}
|
|
if decision.CanTranscode && decision.TargetFormat != "" {
|
|
claims[claimFormat] = decision.TargetFormat
|
|
claims[claimBitrate] = decision.TargetBitrate
|
|
if decision.TargetChannels > 0 {
|
|
claims[claimChannels] = decision.TargetChannels
|
|
}
|
|
if decision.TargetSampleRate > 0 {
|
|
claims[claimSampleRate] = decision.TargetSampleRate
|
|
}
|
|
if decision.TargetBitDepth > 0 {
|
|
claims[claimBitDepth] = decision.TargetBitDepth
|
|
}
|
|
}
|
|
return auth.CreateExpiringPublicToken(exp, claims)
|
|
}
|
|
|
|
func (s *deciderService) ParseTranscodeParams(token string) (*Params, error) {
|
|
claims, err := auth.Validate(token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
params := &Params{}
|
|
|
|
// Required claims
|
|
mid, ok := claims[claimMediaID].(string)
|
|
if !ok || mid == "" {
|
|
return nil, fmt.Errorf("%w: invalid transcode token: missing media ID", ErrTokenInvalid)
|
|
}
|
|
params.MediaID = mid
|
|
|
|
dp, ok := claims[claimDirectPlay].(bool)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: invalid transcode token: missing direct play flag", ErrTokenInvalid)
|
|
}
|
|
params.DirectPlay = dp
|
|
|
|
// Optional claims (legitimately absent for direct-play tokens)
|
|
if f, ok := claims[claimFormat].(string); ok {
|
|
params.TargetFormat = f
|
|
}
|
|
if br, ok := claims[claimBitrate].(float64); ok {
|
|
params.TargetBitrate = int(br)
|
|
}
|
|
if ch, ok := claims[claimChannels].(float64); ok {
|
|
params.TargetChannels = int(ch)
|
|
}
|
|
if sr, ok := claims[claimSampleRate].(float64); ok {
|
|
params.TargetSampleRate = int(sr)
|
|
}
|
|
if bd, ok := claims[claimBitDepth].(float64); ok {
|
|
params.TargetBitDepth = int(bd)
|
|
}
|
|
|
|
ua, ok := claims[claimUpdatedAt].(float64)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: invalid transcode token: missing source timestamp", ErrTokenInvalid)
|
|
}
|
|
params.SourceUpdatedAt = time.Unix(int64(ua), 0)
|
|
|
|
return params, nil
|
|
}
|
|
|
|
func (s *deciderService) ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error) {
|
|
params, err := s.ParseTranscodeParams(token)
|
|
if err != nil {
|
|
return nil, nil, errors.Join(ErrTokenInvalid, err)
|
|
}
|
|
if params.MediaID != mediaID {
|
|
return nil, nil, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, params.MediaID, mediaID)
|
|
}
|
|
mf, err := s.ds.MediaFile(ctx).Get(mediaID)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
return nil, nil, ErrMediaNotFound
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
if !mf.UpdatedAt.Truncate(time.Second).Equal(params.SourceUpdatedAt) {
|
|
log.Info(ctx, "Transcode token is stale", "mediaID", mediaID,
|
|
"tokenUpdatedAt", params.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt)
|
|
return nil, nil, ErrTokenStale
|
|
}
|
|
return params, mf, nil
|
|
}
|