* fix: allow WAV direct play by aliasing pcm and wav codecs
WAV files were being transcoded to FLAC even when the browser declared
native WAV support. The backend normalizes ffprobe's pcm_s16le (and
similar PCM variants) to the internal codec name "pcm", while browsers
advertise WAV support as audioCodecs:["wav"] in their client profile.
The direct-play codec check compared these literally and rejected the
match with "audio codec not supported", forcing a needless FLAC
transcode.
Added {"pcm", "wav"} to codecAliasGroups so the matcher treats them
as equivalent. The container check runs first, so AIFF files (which also
normalize to codec "pcm" but use container "aiff") cannot
accidentally match a WAV direct-play profile.
* feat: include profile details in direct-play rejection reasons
The transcodeReason array returned by getTranscodeDecision previously
contained one generic string per failed DirectPlayProfile (e.g., five
copies of "container not supported"), making it hard to correlate a
reason with the profile that rejected the stream.
Each rejection reason now embeds the offending source value (in single
quotes) along with a compact representation of the full profile that
rejected it, rendered as [container/codec]. For example, clients with
two distinct ogg-container profiles (opus and vorbis) produced two
identical rejection strings; they now read "container 'wav' not
supported by profile [ogg/opus]" and "container 'wav' not supported
by profile [ogg/vorbis]", making each entry in the transcodeReason
array unique and self-describing.
A small describeProfile helper renders profiles as [container/codec]
(or [container] when no codec is constrained).
* refactor(stream): address code review — narrow pcm/wav match, tighten tests
Responds to reviewer feedback on the initial PR:
- Replace the symmetric pcm↔wav codec alias with a contextual
isPCMInWAVMatch check in checkDirectPlayProfile. The alias
unconditionally equated the two names in matchesCodec, which would
let AIFF sources (also normalized to codec "pcm") falsely satisfy
a codec-only ["wav"] direct-play profile that omitted containers.
The new check additionally requires src.Container == "wav" before
bridging the names, closing the false-positive path.
- Tighten the rejection-reason test assertions to verify the new
formatted output (source value + profile descriptor) instead of
just matching loose substrings like "container", preventing
unrelated rejections from satisfying the expectations.
- Add coverage for the WAV→wav-codec acceptance path and for the
AIFF-in-wav-codec-profile rejection path to pin down the contract
of isPCMInWAVMatch.
* refactor(codec): rename isPCMInWAVMatch to matchesPCMWAVBridge for clarity
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
460 lines
17 KiB
Go
460 lines
17 KiB
Go
package stream
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
)
|
|
|
|
const fallbackBitrate = 256 // kbps
|
|
|
|
// TranscodeDecider is the core service interface for making transcoding decisions
|
|
type TranscodeDecider interface {
|
|
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error)
|
|
CreateTranscodeParams(decision *TranscodeDecision) (string, error)
|
|
ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error)
|
|
ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request
|
|
}
|
|
|
|
func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider {
|
|
return &deciderService{
|
|
ds: ds,
|
|
ff: ff,
|
|
}
|
|
}
|
|
|
|
type deciderService struct {
|
|
ds model.DataStore
|
|
ff ffmpeg.FFmpeg
|
|
}
|
|
|
|
func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error) {
|
|
decision := &TranscodeDecision{
|
|
MediaID: mf.ID,
|
|
SourceUpdatedAt: mf.UpdatedAt,
|
|
}
|
|
|
|
var probe *ffmpeg.AudioProbeResult
|
|
if !opts.SkipProbe {
|
|
var err error
|
|
probe, err = s.ensureProbed(ctx, mf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Build source stream details (uses probe data if available)
|
|
decision.SourceStream = buildSourceStream(mf, probe)
|
|
src := &decision.SourceStream
|
|
|
|
// Check for server-side player transcoding override
|
|
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
|
|
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
|
|
} else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
|
|
if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
|
|
modified := *clientInfo
|
|
modified.MaxAudioBitrate = player.MaxBitRate
|
|
clientInfo = &modified
|
|
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
|
|
}
|
|
}
|
|
|
|
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container,
|
|
"codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels,
|
|
"sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name)
|
|
|
|
// Check global bitrate constraint first.
|
|
if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
|
|
log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
|
|
"sourceBitrate", src.Bitrate, "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(src, &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", src.Container, "codec", src.Codec)
|
|
return decision, nil
|
|
}
|
|
|
|
// Try transcoding profiles (in order of preference)
|
|
for _, profile := range clientInfo.TranscodingProfiles {
|
|
if ts, transcodeFormat := s.computeTranscodedStream(ctx, src, &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", src.Container, "codec", src.Codec, "reasons", decision.TranscodeReasons)
|
|
}
|
|
|
|
return decision, nil
|
|
}
|
|
|
|
func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Details {
|
|
sd := Details{
|
|
Container: mf.Suffix,
|
|
Duration: mf.Duration,
|
|
Size: mf.Size,
|
|
}
|
|
|
|
// Use pre-parsed probe result, or fall back to parsing stored probe data
|
|
if probe == nil {
|
|
probe, _ = parseProbeData(mf.ProbeData)
|
|
}
|
|
|
|
// Use probe data if available for authoritative values
|
|
if probe != nil {
|
|
sd.Codec = normalizeProbeCodec(probe.Codec)
|
|
sd.Profile = probe.Profile
|
|
sd.Bitrate = probe.BitRate
|
|
sd.SampleRate = probe.SampleRate
|
|
sd.BitDepth = probe.BitDepth
|
|
sd.Channels = probe.Channels
|
|
} else {
|
|
sd.Codec = mf.AudioCodec()
|
|
sd.Bitrate = mf.BitRate
|
|
sd.SampleRate = mf.SampleRate
|
|
sd.BitDepth = mf.BitDepth
|
|
sd.Channels = mf.Channels
|
|
}
|
|
sd.IsLossless = isLosslessFormat(sd.Codec)
|
|
|
|
return sd
|
|
}
|
|
|
|
// applyServerOverride replaces the client-provided profiles with synthetic ones
|
|
// matching the server-forced transcoding format and bitrate.
|
|
func applyServerOverride(ctx context.Context, original *ClientInfo, trc *model.Transcoding) *ClientInfo {
|
|
maxBitRate := trc.DefaultBitRate
|
|
if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
|
|
maxBitRate = player.MaxBitRate
|
|
}
|
|
|
|
log.Debug(ctx, "Applying server-side transcoding override",
|
|
"targetFormat", trc.TargetFormat, "maxBitRate", maxBitRate,
|
|
"client", original.Name)
|
|
|
|
return &ClientInfo{
|
|
Name: original.Name,
|
|
Platform: original.Platform,
|
|
MaxAudioBitrate: maxBitRate,
|
|
MaxTranscodingAudioBitrate: maxBitRate,
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{trc.TargetFormat}, AudioCodecs: []string{trc.TargetFormat}, Protocols: []string{ProtocolHTTP}},
|
|
},
|
|
TranscodingProfiles: []Profile{
|
|
{Container: trc.TargetFormat, AudioCodec: trc.TargetFormat, Protocol: ProtocolHTTP},
|
|
},
|
|
}
|
|
}
|
|
|
|
func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) {
|
|
if data == "" {
|
|
return nil, nil
|
|
}
|
|
var result ffmpeg.AudioProbeResult
|
|
if err := json.Unmarshal([]byte(data), &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// matchesPCMWAVBridge bridges Navidrome's internal "pcm" codec name with the
|
|
// "wav" codec name that browsers use to advertise audio/wav support. The match
|
|
// is scoped to WAV-container sources so AIFF files (which also normalize to
|
|
// codec "pcm" but use a different container) cannot false-match a codec-only
|
|
// ["wav"] profile.
|
|
func matchesPCMWAVBridge(src *Details, profile *DirectPlayProfile) bool {
|
|
return strings.EqualFold(src.Codec, "pcm") &&
|
|
strings.EqualFold(src.Container, "wav") &&
|
|
containsIgnoreCase(profile.AudioCodecs, "wav")
|
|
}
|
|
|
|
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
|
|
// or a typed reason string if it doesn't match.
|
|
func (s *deciderService) checkDirectPlayProfile(src *Details, 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(src.Container, profile.Containers) {
|
|
return fmt.Sprintf("container '%s' not supported by profile %s", src.Container, profile)
|
|
}
|
|
|
|
// Check codec
|
|
if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) && !matchesPCMWAVBridge(src, profile) {
|
|
return fmt.Sprintf("audio codec '%s' not supported by profile %s", src.Codec, profile)
|
|
}
|
|
|
|
// Check channels
|
|
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
|
|
return fmt.Sprintf("audio channels %d not supported by profile %s (max %d)", src.Channels, profile, profile.MaxAudioChannels)
|
|
}
|
|
|
|
// Check codec-specific limitations
|
|
for _, codecProfile := range clientInfo.CodecProfiles {
|
|
if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(src.Codec, []string{codecProfile.Name}) {
|
|
if reason := checkLimitations(src, 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, src *Details, profile *Profile, clientInfo *ClientInfo) (*Details, 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 := resolveTargetFormat(profile)
|
|
if targetFormat == "" {
|
|
return nil, ""
|
|
}
|
|
|
|
// Verify we have a transcoding command available (DB custom or built-in default)
|
|
if LookupTranscodeCommand(ctx, s.ds, targetFormat) == "" {
|
|
log.Trace(ctx, "Skipping transcoding profile: no transcoding command available", "targetFormat", targetFormat)
|
|
return nil, ""
|
|
}
|
|
|
|
targetIsLossless := isLosslessFormat(targetFormat)
|
|
|
|
// Reject lossy to lossless conversion
|
|
if !src.IsLossless && targetIsLossless {
|
|
log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat)
|
|
return nil, ""
|
|
}
|
|
|
|
ts := &Details{
|
|
Container: responseContainer,
|
|
Codec: strings.ToLower(profile.AudioCodec),
|
|
SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec),
|
|
Channels: src.Channels,
|
|
BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec),
|
|
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, src, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
|
return nil, ""
|
|
}
|
|
|
|
// Apply MaxAudioChannels from the transcoding profile
|
|
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
|
|
ts.Channels = profile.MaxAudioChannels
|
|
}
|
|
|
|
// Apply codec profile limitations to the TARGET codec
|
|
if ok := s.applyCodecLimitations(ctx, src.Bitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
|
return nil, ""
|
|
}
|
|
|
|
return ts, targetFormat
|
|
}
|
|
|
|
// lookupDefaultBitrate returns the default bitrate for the given format.
|
|
// It checks the DB first (for user-customized values), then falls back to
|
|
// the built-in defaults, and finally to fallbackBitrate.
|
|
func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string) int {
|
|
if t, err := ds.Transcoding(ctx).FindByFormat(format); err == nil && t.DefaultBitRate > 0 {
|
|
return t.DefaultBitRate
|
|
}
|
|
for _, dt := range consts.DefaultTranscodings {
|
|
if dt.TargetFormat == format && dt.DefaultBitRate > 0 {
|
|
return dt.DefaultBitRate
|
|
}
|
|
}
|
|
return fallbackBitrate
|
|
}
|
|
|
|
// LookupTranscodeCommand returns the ffmpeg command for the given format.
|
|
// It checks the DB first (for user-customized commands), then falls back to
|
|
// the built-in default command. Returns "" if the format is unknown.
|
|
func LookupTranscodeCommand(ctx context.Context, ds model.DataStore, format string) string {
|
|
t, err := ds.Transcoding(ctx).FindByFormat(format)
|
|
if err == nil && t.Command != "" {
|
|
return t.Command
|
|
}
|
|
// Fall back to built-in defaults
|
|
for _, dt := range consts.DefaultTranscodings {
|
|
if dt.TargetFormat == format {
|
|
return dt.Command
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// resolveTargetFormat determines the response container and internal target format
|
|
// from the profile's Container and AudioCodec fields. When an AudioCodec is specified
|
|
// it is preferred as targetFormat (e.g. container "mp4" with audioCodec "aac" → targetFormat "aac").
|
|
func resolveTargetFormat(profile *Profile) (responseContainer, targetFormat string) {
|
|
responseContainer = strings.ToLower(profile.Container)
|
|
targetFormat = responseContainer
|
|
|
|
// Prefer the audioCodec as targetFormat when provided (handles container-to-codec
|
|
// mapping like "mp4" → "aac", "ogg" → "opus").
|
|
if profile.AudioCodec != "" {
|
|
targetFormat = strings.ToLower(profile.AudioCodec)
|
|
}
|
|
|
|
// If neither container nor audioCodec is set, we can't resolve a format.
|
|
if targetFormat == "" {
|
|
return "", ""
|
|
}
|
|
|
|
// When no container was specified, use the targetFormat as container too.
|
|
if responseContainer == "" {
|
|
responseContainer = targetFormat
|
|
}
|
|
|
|
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, src *Details, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
|
|
if src.IsLossless {
|
|
if !targetIsLossless {
|
|
if clientInfo.MaxTranscodingAudioBitrate > 0 {
|
|
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
|
|
} else if clientInfo.MaxAudioBitrate > 0 {
|
|
ts.Bitrate = clientInfo.MaxAudioBitrate
|
|
} else {
|
|
ts.Bitrate = lookupDefaultBitrate(ctx, s.ds, targetFormat)
|
|
}
|
|
} else {
|
|
if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
|
|
log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit",
|
|
"targetFormat", targetFormat, "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
|
|
return false
|
|
}
|
|
}
|
|
} else {
|
|
ts.Bitrate = src.Bitrate
|
|
}
|
|
|
|
// 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 *Details) 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
|
|
}
|
|
|
|
// ensureProbed runs ffprobe if probe data is missing, persists it, and returns
|
|
// the parsed result. Returns (nil, nil) when probing is skipped or data already exists
|
|
// (in which case the caller should parse mf.ProbeData).
|
|
func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) (*ffmpeg.AudioProbeResult, error) {
|
|
if mf.ProbeData != "" {
|
|
return nil, nil
|
|
}
|
|
if !conf.Server.DevEnableMediaFileProbe {
|
|
return nil, nil
|
|
}
|
|
|
|
result, err := s.ff.ProbeAudioStream(ctx, mf.AbsolutePath())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("probing media file %s: %w", mf.ID, err)
|
|
}
|
|
|
|
data, err := json.Marshal(result)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshaling probe result for %s: %w", mf.ID, err)
|
|
}
|
|
mf.ProbeData = string(data)
|
|
|
|
if err := s.ds.MediaFile(ctx).UpdateProbeData(mf.ID, mf.ProbeData); err != nil {
|
|
log.Error(ctx, "Failed to persist probe data", "mediaID", mf.ID, err)
|
|
// Don't fail the decision — we have the data in memory
|
|
}
|
|
|
|
log.Debug(ctx, "Probed media file", "mediaID", mf.ID, "codec", result.Codec,
|
|
"profile", result.Profile, "bitRate", result.BitRate,
|
|
"sampleRate", result.SampleRate, "bitDepth", result.BitDepth, "channels", result.Channels)
|
|
return result, nil
|
|
}
|