mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* feat(smartplaylist): support isMissing/isPresent on mbz_* and lyrics fields Mark the six mbz_* MusicBrainz ID columns and the lyrics column as Nullable in the criteria field map, then extend missingExpr to handle string columns where absence is encoded as NULL or empty string (plus '[]' for lyrics). The Numeric/Boolean path (ReplayGain) is preserved via an explicit type check. * refactor(model): make MediaFile BPM and BitDepth nullable pointers Convert BPM and BitDepth fields in model.MediaFile from int to *int so that 'tag absent' is distinguishable from zero. The metadata mapper now uses NullableFloat for BPM (nil when absent or zero/unparseable) and only sets BitDepth when the audio property is non-zero (lossy codecs report 0). All read sites use gg.V() for zero-fallback deref so Subsonic API output and transcoding behaviour are byte-identical to before. The persistence layer bridges the existing NOT NULL DB columns by coercing nil to 0 on write and 0 back to nil on read in PostMapArgs/PostScan; a later migration task will drop those constraints. Hash upgrade safety is verified by a new MediaFile.Hash describe block: nil *int hashes identically to the old int(0) default via ZeroNil+IgnoreZeroValue, so no files will be spuriously re-imported after this change. Extra files touched beyond the plan's list: core/stream/legacy_client_test.go (BitDepth in model.MediaFile literals), persistence/mediafile_repository.go (NOT NULL bridge). * test(model): pin pre-conversion golden hashes for BPM/BitDepth * feat(smartplaylist): support isMissing/isPresent on bpm and bitDepth * feat(db): make bpm and bit_depth columns nullable, backfill 0 to NULL Drop the NOT NULL constraint on media_file.bpm and bit_depth via a lossless migration that converts legacy 0-means-absent values to real NULL. Remove the temporary shim in PostScan/PostMapArgs that was bridging the old NOT NULL columns to the *int model fields. Add round-trip persistence tests asserting NULL storage for nil pointers and correct value round-trip for non-nil pointers. * test(e2e): verify isMissing/isPresent partition for nullable fields Add DescribeTable covering bpm, bitdepth, lyrics, and mbz_recording_id: for each field, isMissing + isPresent song counts must equal the total library count, proving the nullable-column SQL is exhaustive and correct. * test(e2e): seed bpm tag so isMissing/isPresent partition is non-trivial * fix(model): omit bitDepth from JSON when absent instead of emitting null * feat(smartplaylist): support isMissing/isPresent on more string fields Enable isMissing/isPresent operators for album, comment, catalognumber, discsubtitle, albumcomment, sorttitle, sortalbum, sortartist, sortalbumartist, and explicitstatus by marking them Nullable in fieldMap. * refactor(smartplaylist): unify missingExpr column logic into one flow Collapse the numeric/string fork in missingExpr into a single empties-driven loop (numeric/boolean fields simply have no empties), and replace the duplicated IsTag/IsRole guard with a three-way switch that expresses the dispatch model once. No SQL semantics change for string fields; numeric/boolean fields now emit a single-element Or/And which squirrel parenthesizes (e.g. `(col IS NULL)` instead of bare `col IS NULL`) — update the affected test expectations accordingly.
459 lines
17 KiB
Go
459 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"
|
|
"github.com/navidrome/navidrome/utils/gg"
|
|
)
|
|
|
|
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 {
|
|
if !s.ff.IsProbeAvailable() {
|
|
log.Debug(ctx, "ffprobe not available, using tag metadata for transcode decision", "mediaID", mf.ID)
|
|
} else {
|
|
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
|
|
|
|
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 = gg.V(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
|
|
}
|
|
if maxCh := codecMaxChannels(ts.Codec); maxCh > 0 && ts.Channels > maxCh {
|
|
ts.Channels = maxCh
|
|
}
|
|
|
|
// 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. Compare against the
|
|
// already-clamped ts.Channels (not src.Channels) so the codec hard limit
|
|
// applied above is never raised by a looser profile setting.
|
|
if profile.MaxAudioChannels > 0 && ts.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
|
|
}
|