package encoding import ( "fmt" "os/exec" "strconv" "strings" ) // AudioStandardizer contains audio standardization parameters type AudioStandardizer struct { Codec string // "aac" or "opus" SkipIfCompatible bool // Default: true BitratePerChannel int // Default: 80 StereoBitrate int // Default: 160 ChannelMode string // "preserve", "stereo", "mono" CreateDownmix bool // Default: false DownmixSingleTrack bool // Default: false ForceTranscode bool // Default: false QualityPreset string // "custom", "high_quality", "balanced", "small_size" // Opus-specific OpusApplication string // Default: "audio" OpusVBR string // Default: "on" } // DefaultAudioStandardizer returns default audio standardization parameters // Updated to align with Tdarr defaults (AAC) func DefaultAudioStandardizer() AudioStandardizer { return AudioStandardizer{ Codec: "aac", SkipIfCompatible: true, BitratePerChannel: 80, StereoBitrate: 160, ChannelMode: "preserve", CreateDownmix: false, DownmixSingleTrack: false, ForceTranscode: false, QualityPreset: "custom", OpusApplication: "audio", OpusVBR: "on", } } // AudioStreamInfo contains information about an audio stream type AudioStreamInfo struct { Index int Codec string Channels int Bitrate int SampleRate int Language string ChannelLayout string } // AnalyzeAudioStreams analyzes audio streams in a video file func AnalyzeAudioStreams(file string) ([]AudioStreamInfo, error) { // Get all audio stream information using ffprobe cmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "a", "-show_entries", "stream=index,codec_name,channels,bit_rate,sample_rate,channel_layout", "-show_entries", "stream_tags=language", "-of", "json", file) output, err := cmd.Output() if err != nil { return nil, err } // Parse JSON output (simplified - in production, use proper JSON parsing) // For now, we'll use a simpler approach with individual queries var streams []AudioStreamInfo // Get stream count first countCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", file) countOutput, err := countCmd.Output() if err != nil { return nil, err } indices := strings.Split(strings.TrimSpace(string(countOutput)), "\n") for _, idxStr := range indices { if idxStr == "" { continue } stream := AudioStreamInfo{} stream.Index, _ = strconv.Atoi(strings.TrimSpace(idxStr)) // Get codec codecCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", fmt.Sprintf("a:%d", stream.Index), "-show_entries", "stream=codec_name", "-of", "csv=p=0", file) if codecOut, err := codecCmd.Output(); err == nil { stream.Codec = strings.TrimSpace(string(codecOut)) } // Get channels channelsCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", fmt.Sprintf("a:%d", stream.Index), "-show_entries", "stream=channels", "-of", "csv=p=0", file) if channelsOut, err := channelsCmd.Output(); err == nil { stream.Channels, _ = strconv.Atoi(strings.TrimSpace(string(channelsOut))) } // Get bitrate bitrateCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", fmt.Sprintf("a:%d", stream.Index), "-show_entries", "stream=bit_rate", "-of", "csv=p=0", file) if bitrateOut, err := bitrateCmd.Output(); err == nil { stream.Bitrate, _ = strconv.Atoi(strings.TrimSpace(string(bitrateOut))) } // Get sample rate sampleRateCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", fmt.Sprintf("a:%d", stream.Index), "-show_entries", "stream=sample_rate", "-of", "csv=p=0", file) if sampleRateOut, err := sampleRateCmd.Output(); err == nil { stream.SampleRate, _ = strconv.Atoi(strings.TrimSpace(string(sampleRateOut))) } // Get language langCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", fmt.Sprintf("a:%d", stream.Index), "-show_entries", "stream_tags=language", "-of", "csv=p=0", file) if langOut, err := langCmd.Output(); err == nil { stream.Language = strings.TrimSpace(string(langOut)) } // Get channel layout layoutCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", fmt.Sprintf("a:%d", stream.Index), "-show_entries", "stream=channel_layout", "-of", "csv=p=0", file) if layoutOut, err := layoutCmd.Output(); err == nil { stream.ChannelLayout = strings.TrimSpace(string(layoutOut)) } streams = append(streams, stream) } _ = output // Suppress unused variable warning return streams, nil } // IsCompatibleCodec checks if a codec is compatible with the target func IsCompatibleCodec(codec string, targetCodec string) bool { codec = strings.ToLower(codec) targetCodec = strings.ToLower(targetCodec) // Opus compatibility if targetCodec == "opus" { return codec == "opus" || codec == "libopus" } // AAC compatibility if targetCodec == "aac" { return codec == "aac" } return false } // NeedsTranscoding checks if a stream needs transcoding func NeedsTranscoding(stream AudioStreamInfo, standardizer AudioStandardizer) bool { if standardizer.ForceTranscode { return true } if standardizer.SkipIfCompatible { // Accept both AAC and Opus as compatible compatible := []string{"aac", "opus", "libopus"} for _, c := range compatible { if strings.ToLower(stream.Codec) == c { return false } } return true } // Must match exact target codec return !IsCompatibleCodec(stream.Codec, standardizer.Codec) } // CalculateBitrate calculates target bitrate based on channels func CalculateBitrate(standardizer AudioStandardizer, channels int) int { // Check for "original" bitrate (handled as 0) if standardizer.BitratePerChannel == 0 { return 0 // Use original bitrate (don't specify in FFmpeg) } return standardizer.BitratePerChannel * channels } // BuildAudioCodecArgs builds FFmpeg arguments for audio encoding func BuildAudioCodecArgs(audioIdx int, standardizer AudioStandardizer, targetBitrate int) string { if standardizer.Codec == "opus" { args := []string{ fmt.Sprintf("-c:a:%d", audioIdx), "libopus", } if targetBitrate > 0 { args = append(args, fmt.Sprintf("-b:a:%d", audioIdx), fmt.Sprintf("%dk", targetBitrate)) } args = append(args, "-vbr", standardizer.OpusVBR, "-application", standardizer.OpusApplication, "-compression_level", "10") return strings.Join(args, " ") } // AAC args := []string{ fmt.Sprintf("-c:a:%d", audioIdx), "aac", } if targetBitrate > 0 { args = append(args, fmt.Sprintf("-b:a:%d", audioIdx), fmt.Sprintf("%dk", targetBitrate)) } args = append(args, "-strict", "-2") return strings.Join(args, " ") } // BuildChannelArgs builds FFmpeg arguments for channel handling func BuildChannelArgs(audioIdx int, standardizer AudioStandardizer) string { switch standardizer.ChannelMode { case "stereo": return fmt.Sprintf("-ac:a:%d 2", audioIdx) case "mono": return fmt.Sprintf("-ac:a:%d 1", audioIdx) default: return "" // preserve } } // OpusIncompatibleLayouts lists channel layouts incompatible with Opus var OpusIncompatibleLayouts = []string{ "3.0(back)", "3.0(front)", "4.0", "5.0(side)", "6.0", "6.1", "7.0", "7.0(front)", } // IsOpusIncompatibleLayout checks if a channel layout is incompatible with Opus func IsOpusIncompatibleLayout(layout string) bool { layout = strings.ToLower(layout) for _, incompatible := range OpusIncompatibleLayouts { if strings.ToLower(incompatible) == layout { return true } } return false } // QualityPresetConfig contains preset configurations type QualityPresetConfig struct { AACBitratePerChannel int OpusBitratePerChannel int StereoBitrate int OpusVBR string OpusApplication string Description string } // QualityPresets contains predefined quality configurations var QualityPresets = map[string]QualityPresetConfig{ "high_quality": { AACBitratePerChannel: 128, OpusBitratePerChannel: 96, StereoBitrate: 256, OpusVBR: "on", OpusApplication: "audio", Description: "Maximum quality, larger files", }, "balanced": { AACBitratePerChannel: 80, OpusBitratePerChannel: 64, StereoBitrate: 160, OpusVBR: "on", OpusApplication: "audio", Description: "Good quality, reasonable file sizes", }, "small_size": { AACBitratePerChannel: 64, OpusBitratePerChannel: 48, StereoBitrate: 128, OpusVBR: "constrained", OpusApplication: "audio", Description: "Smaller files, acceptable quality", }, } // ApplyQualityPreset applies quality preset settings to standardizer func ApplyQualityPreset(standardizer AudioStandardizer) AudioStandardizer { if standardizer.QualityPreset == "custom" { return standardizer } preset, exists := QualityPresets[standardizer.QualityPreset] if !exists { return standardizer } // Apply preset based on codec if standardizer.Codec == "aac" { standardizer.BitratePerChannel = preset.AACBitratePerChannel } else if standardizer.Codec == "opus" { standardizer.BitratePerChannel = preset.OpusBitratePerChannel standardizer.OpusVBR = preset.OpusVBR standardizer.OpusApplication = preset.OpusApplication } standardizer.StereoBitrate = preset.StereoBitrate return standardizer } // BuildDownmixArgs builds FFmpeg arguments for creating downmix tracks func BuildDownmixArgs(audioIdx int, streamIndex int, standardizer AudioStandardizer) string { baseArgs := fmt.Sprintf("-map 0:%d", streamIndex) if standardizer.Codec == "opus" { return fmt.Sprintf("%s -c:a:%d libopus -b:a:%d %dk -vbr %s -application %s -ac 2 -metadata:s:a:%d title=\"2.0 Downmix\"", baseArgs, audioIdx, audioIdx, standardizer.StereoBitrate, standardizer.OpusVBR, standardizer.OpusApplication, audioIdx) } return fmt.Sprintf("%s -c:a:%d aac -b:a:%d %dk -strict -2 -ac 2 -metadata:s:a:%d title=\"2.0 Downmix\"", baseArgs, audioIdx, audioIdx, standardizer.StereoBitrate, audioIdx) }