Files
tdarr-plugs/Local/Tdarr_Plugin_combined_audio_standardizer.js
Tdarr Plugin Developer aa71eb96d7 Initial commit: Tdarr plugin stack
Plugins:
- misc_fixes v2.8: Pre-processing, container remux, stream conforming
- stream_organizer v4.8: English priority, subtitle extraction, SRT conversion
- combined_audio_standardizer v1.13: AAC/Opus encoding, downmix creation
- av1_svt_converter v2.22: AV1 video encoding via SVT-AV1

Structure:
- Local/ - Plugin .js files (mount in Tdarr)
- agent_notes/ - Development documentation
- Latest-Reports/ - Error logs for analysis
2025-12-15 11:33:36 -08:00

879 lines
28 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const details = () => ({
id: 'Tdarr_Plugin_combined_audio_standardizer',
Stage: 'Pre-processing',
Name: 'Combined Audio Standardizer',
Type: 'Audio',
Operation: 'Transcode',
Description: `
Converts audio streams to specified codec (AAC/Opus) with configurable bitrate and channel options.
Can preserve existing channels or downmix from multichannel to stereo/mono. Also creates missing
downmixed tracks (8ch->6ch, 6ch/8ch->2ch) when they don't exist.
`,
Version: '1.13',
Tags: 'audio,aac,opus,channels,stereo,downmix,quality',
Inputs: [
{
name: 'codec',
type: 'string',
defaultValue: 'opus*',
inputUI: {
type: 'dropdown',
options: [
'aac',
'opus*'
],
},
tooltip: 'Target audio codec: AAC (best compatibility, larger files) or Opus (best efficiency, smaller files).',
},
{
name: 'skip_if_compatible',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: [
'true*',
'false'
],
},
tooltip: 'Skip conversion if audio is already AAC or Opus (either format acceptable). When false, converts to target codec.',
},
{
name: 'bitrate_per_channel',
type: 'string',
defaultValue: 'auto*',
inputUI: {
type: 'dropdown',
options: [
'auto*',
'64',
'80',
'96',
'128',
'160',
'192',
'original'
],
},
tooltip: 'Bitrate per channel in kbps for multichannel audio. "auto" uses min(64kbps/ch, source bitrate) for optimal quality/size. Total bitrate = channels × this value. Use "original" to keep exact source bitrate.',
},
{
name: 'channel_mode',
type: 'string',
defaultValue: 'preserve',
inputUI: {
type: 'dropdown',
options: [
'preserve',
'stereo',
'mono'
],
},
tooltip: 'Channel handling for existing tracks: preserve=keep original channels, stereo=downmix to 2.0, mono=downmix to 1.0.',
},
{
name: 'create_downmix',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: [
'false',
'true*'
],
},
tooltip: 'Create additional stereo (2ch) downmix tracks from multichannel audio (5.1/7.1).',
},
{
name: 'downmix_single_track',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: [
'false',
'true'
],
},
tooltip: 'Only downmix one track per channel count instead of all tracks.',
},
{
name: 'force_transcode',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: [
'false',
'true'
],
},
tooltip: 'Force transcoding even if audio is already in target codec. Useful for changing bitrate or channel layout.',
},
{
name: 'opus_application',
type: 'string',
defaultValue: 'audio',
inputUI: {
type: 'dropdown',
options: [
'audio',
'voip',
'lowdelay'
],
},
tooltip: 'Opus application (ignored for AAC): audio=music/general, voip=speech optimized, lowdelay=real-time apps.',
},
{
name: 'opus_vbr',
type: 'string',
defaultValue: 'on',
inputUI: {
type: 'dropdown',
options: [
'on',
'off',
'constrained'
],
},
tooltip: 'Opus VBR mode (ignored for AAC): on=VBR (best quality/size), off=CBR, constrained=CVBR.',
},
{
name: 'opus_compression',
type: 'string',
defaultValue: '10*',
inputUI: {
type: 'dropdown',
options: [
'0',
'5',
'8',
'10*'
],
},
tooltip: 'Opus compression level (ignored for AAC): 0=fastest/lower quality, 10=slowest/best quality. Default 10 recommended for archival.',
},
{
name: 'aac_profile',
type: 'string',
defaultValue: 'aac_low*',
inputUI: {
type: 'dropdown',
options: [
'aac_low*',
'aac_he',
'aac_he_v2'
],
},
tooltip: 'AAC profile (ignored for Opus): aac_low=AAC-LC (best quality/compatibility), aac_he=HE-AAC (better for low bitrate), aac_he_v2=HE-AACv2 (best for very low bitrate stereo).',
},
{
name: 'target_sample_rate',
type: 'string',
defaultValue: 'original*',
inputUI: {
type: 'dropdown',
options: [
'original*',
'48000',
'44100',
'32000'
],
},
tooltip: 'Target sample rate in Hz. "original" keeps source sample rate. 48000 recommended for streaming, 44100 for music.',
},
{
name: 'create_6ch_downmix',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: [
'false',
'true'
],
},
tooltip: 'Create additional 5.1 (6ch) downmix tracks from 7.1 (8ch) audio.',
},
{
name: 'preserve_metadata',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: [
'false',
'true*'
],
},
tooltip: 'Preserve audio metadata (title, language tags) from source streams.',
},
{
name: 'quality_preset',
type: 'string',
defaultValue: 'custom',
inputUI: {
type: 'dropdown',
options: [
'custom',
'high_quality',
'balanced',
'small_size'
],
},
tooltip: 'Quality presets automatically configure bitrate settings. Use "custom" to manually set bitrate per channel and other encoder options.',
}
],
});
const CODECS = {
AAC: 'aac',
OPUS: 'opus',
LIBOPUS: 'libopus'
};
const CHANNEL_MODES = {
PRESERVE: 'preserve',
STEREO: 'stereo',
MONO: 'mono'
};
const COMPATIBLE_CODECS = [CODECS.AAC, CODECS.OPUS, CODECS.LIBOPUS];
const VALID_BITRATES = ['auto', '64', '80', '96', '128', '160', '192', 'original'];
const VALID_BOOLEAN_VALUES = ['true', 'false'];
const VALID_OPUS_APPLICATIONS = ['audio', 'voip', 'lowdelay'];
const VALID_OPUS_VBR_MODES = ['on', 'off', 'constrained'];
const VALID_OPUS_COMPRESSION = ['0', '5', '8', '10'];
const VALID_AAC_PROFILES = ['aac_low', 'aac_he', 'aac_he_v2'];
const VALID_SAMPLE_RATES = ['original', '48000', '44100', '32000'];
const VALID_QUALITY_PRESETS = ['custom', 'high_quality', 'balanced', 'small_size'];
// Opus compatible layouts (whitelist approach is more reliable)
const OPUS_COMPATIBLE_LAYOUTS = new Set([
'mono',
'stereo',
'2.1',
'3.0',
'4.0',
'5.0',
'5.1',
'5.1(side)',
'7.1'
]);
const isOpusIncompatibleLayout = (layout) => {
if (!layout) return false;
// If not in compatible list, it's incompatible
return !OPUS_COMPATIBLE_LAYOUTS.has(layout);
};
const QUALITY_PRESETS = {
high_quality: {
aac_bitrate_per_channel: '128',
opus_bitrate_per_channel: '96',
opus_vbr: 'on',
opus_application: 'audio',
aac_profile: 'aac_low',
description: 'Maximum quality, larger files'
},
balanced: {
aac_bitrate_per_channel: '80',
opus_bitrate_per_channel: '64',
opus_vbr: 'on',
opus_application: 'audio',
aac_profile: 'aac_low',
description: 'Good quality, reasonable file sizes'
},
small_size: {
aac_bitrate_per_channel: '64',
opus_bitrate_per_channel: '64',
opus_vbr: 'constrained',
opus_application: 'audio',
aac_profile: 'aac_he',
description: 'Smaller files, acceptable quality'
}
};
const needsTranscoding = (stream, inputs, targetCodec) => {
// Force transcode if explicitly requested
if (inputs.force_transcode === 'true') return true;
// Check if channel layout needs changing
if (inputs.channel_mode === 'stereo' && stream.channels > 2) return true;
if (inputs.channel_mode === 'mono' && stream.channels > 1) return true;
// If skip_if_compatible is true, accept any compatible codec (AAC or Opus)
// This means: if codec is AAC or Opus, don't transcode (even if target is different)
if (inputs.skip_if_compatible === 'true') {
return !COMPATIBLE_CODECS.includes(stream.codec_name);
}
// Otherwise, only accept exact target codec match
// This means: if codec doesn't match target, transcode
return !targetCodec.includes(stream.codec_name);
};
const calculateBitrate = (inputs, channels, streamBitrate = null) => {
let targetBitrate;
if (inputs.bitrate_per_channel === 'auto') {
// Smart bitrate: min(64kbps per channel, source bitrate)
targetBitrate = 64 * channels;
if (streamBitrate && streamBitrate > 0) {
const sourceBitrateKbps = Math.round(streamBitrate / 1000);
targetBitrate = Math.min(targetBitrate, sourceBitrateKbps);
}
} else if (inputs.bitrate_per_channel === 'original') {
// Use original bitrate if available, otherwise calculate a reasonable default
if (streamBitrate && streamBitrate > 0) {
targetBitrate = Math.round(streamBitrate / 1000); // Convert to kbps
} else {
// Fallback: estimate based on channel count if original bitrate unavailable
targetBitrate = channels * 96; // 96kbps per channel as fallback
}
} else {
targetBitrate = parseInt(inputs.bitrate_per_channel) * channels;
}
// Enforce minimum bitrate threshold to prevent unusable audio
const MIN_BITRATE_KBPS = 32;
if (targetBitrate < MIN_BITRATE_KBPS) {
return MIN_BITRATE_KBPS;
}
return targetBitrate;
};
const applyQualityPreset = (inputs) => {
if (inputs.quality_preset === 'custom') {
return inputs;
}
const preset = QUALITY_PRESETS[inputs.quality_preset];
if (!preset) {
// Log warning if preset not found, fallback to custom
console.warn(`Warning: Quality preset '${inputs.quality_preset}' not found, using custom settings.`);
return inputs;
}
const modifiedInputs = { ...inputs };
if (inputs.codec === CODECS.AAC) {
modifiedInputs.bitrate_per_channel = preset.aac_bitrate_per_channel;
if (preset.aac_profile) {
modifiedInputs.aac_profile = preset.aac_profile;
}
} else if (inputs.codec === CODECS.OPUS) {
modifiedInputs.bitrate_per_channel = preset.opus_bitrate_per_channel;
modifiedInputs.opus_vbr = preset.opus_vbr;
modifiedInputs.opus_application = preset.opus_application;
}
return modifiedInputs;
};
const buildCodecArgs = (audioIdx, inputs, targetBitrate) => {
if (inputs.codec === CODECS.OPUS) {
// Note: -vbr, -application, -compression_level are encoder-global options
// They are added once at the end of the command via getOpusGlobalArgs()
return [
`-c:a:${audioIdx} libopus`,
targetBitrate ? `-b:a:${audioIdx} ${targetBitrate}k` : ''
].filter(Boolean).join(' ');
}
// AAC with profile selection
const aacProfile = inputs.aac_profile === 'aac_low' ? 'aac' : inputs.aac_profile;
return [
`-c:a:${audioIdx} ${aacProfile}`,
targetBitrate ? `-b:a:${audioIdx} ${targetBitrate}k` : '',
'-strict -2'
].filter(Boolean).join(' ');
};
// Returns global Opus encoder options (applied once per output)
const getOpusGlobalArgs = (inputs) => {
if (inputs.codec === CODECS.OPUS) {
return ` -vbr ${inputs.opus_vbr} -application ${inputs.opus_application} -compression_level ${inputs.opus_compression}`;
}
return '';
};
// Returns sample rate argument if resampling is needed
const getSampleRateArgs = (audioIdx, inputs) => {
if (inputs.target_sample_rate === 'original') {
return '';
}
return ` -ar:a:${audioIdx} ${inputs.target_sample_rate}`;
};
// Returns metadata preservation arguments
const getMetadataArgs = (audioIdx, stream, inputs, customTitle = null) => {
if (customTitle) {
return ` -metadata:s:a:${audioIdx} title="${customTitle}"`;
}
if (inputs.preserve_metadata !== 'true') {
return '';
}
const args = [];
if (stream.tags?.title) {
args.push(`-metadata:s:a:${audioIdx} title="${stream.tags.title}"`);
}
if (stream.tags?.language) {
args.push(`-metadata:s:a:${audioIdx} language="${stream.tags.language}"`);
}
return args.length > 0 ? ' ' + args.join(' ') : '';
};
const buildChannelArgs = (audioIdx, inputs) => {
switch (inputs.channel_mode) {
case CHANNEL_MODES.STEREO:
return ` -ac:a:${audioIdx} 2`;
case CHANNEL_MODES.MONO:
return ` -ac:a:${audioIdx} 1`;
default:
return '';
}
};
const buildDownmixArgs = (audioIdx, streamIndex, stream, inputs, channels) => {
const baseArgs = ` -map 0:${streamIndex} -c:a:${audioIdx} `;
// Calculate downmix bitrate
const downmixBitrate = calculateBitrate(inputs, channels, null);
if (inputs.codec === CODECS.OPUS) {
// Note: global Opus options (-vbr, -application, -compression_level) are added
// once at the end of the command via getOpusGlobalArgs()
return baseArgs + [
'libopus',
`-b:a:${audioIdx} ${downmixBitrate}k`,
`-ac ${channels}`,
getSampleRateArgs(audioIdx, inputs),
getMetadataArgs(audioIdx, stream, inputs, `${channels}.0 Downmix`)
].filter(Boolean).join(' ');
}
const aacProfile = inputs.aac_profile === 'aac_low' ? 'aac' : inputs.aac_profile;
return baseArgs + [
aacProfile,
`-b:a:${audioIdx} ${downmixBitrate}k`,
'-strict -2',
`-ac ${channels}`,
getSampleRateArgs(audioIdx, inputs),
getMetadataArgs(audioIdx, stream, inputs, `${channels}.0 Downmix`)
].filter(Boolean).join(' ');
};
const validateStream = (stream, index) => {
const warnings = [];
if (!stream.channels || stream.channels < 1 || stream.channels > 16) {
warnings.push(`Stream ${index}: Unusual channel count (${stream.channels})`);
}
if (stream.bit_rate && (stream.bit_rate < 16000 || stream.bit_rate > 5000000)) {
warnings.push(`Stream ${index}: Unusual bitrate (${stream.bit_rate})`);
}
return warnings;
};
const logStreamInfo = (stream, index) => {
const info = [
`Stream ${index}:`,
` Codec: ${stream.codec_name}`,
` Channels: ${stream.channels}`,
` Bitrate: ${stream.bit_rate ? Math.round(stream.bit_rate / 1000) + 'kbps' : 'unknown'}`,
` Sample Rate: ${stream.sample_rate ? stream.sample_rate + 'Hz' : 'unknown'}`,
` Language: ${stream.tags?.language || 'unknown'}`
].join('\n');
return info;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
// Initialize response first for error handling
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: false,
reQueueAfter: false,
infoLog: '',
};
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars,no-param-reassign
inputs = lib.loadDefaultValues(inputs, details);
// Strip UI star markers from input values (Tdarr uses '*' to indicate default values in UI)
const stripStar = (value) => {
if (typeof value === 'string') {
return value.replace(/\*/g, '');
}
return value;
};
// Sanitize UI-starred defaults
Object.keys(inputs).forEach(key => {
inputs[key] = stripStar(inputs[key]);
});
const validateInputs = (inputs) => {
const errors = [];
if (![CODECS.AAC, CODECS.OPUS].includes(inputs.codec)) {
errors.push(`Invalid codec selection - must be "${CODECS.AAC}" or "${CODECS.OPUS}"`);
}
const booleanInputs = [
'skip_if_compatible',
'create_downmix',
'create_6ch_downmix',
'downmix_single_track',
'force_transcode',
'preserve_metadata'
];
for (const input of booleanInputs) {
if (!VALID_BOOLEAN_VALUES.includes(inputs[input])) {
errors.push(`Invalid ${input} value - must be "true" or "false"`);
}
}
if (!VALID_BITRATES.includes(inputs.bitrate_per_channel)) {
errors.push(`Invalid bitrate_per_channel - must be one of: ${VALID_BITRATES.join(', ')}`);
}
if (!Object.values(CHANNEL_MODES).includes(inputs.channel_mode)) {
errors.push(`Invalid channel_mode - must be "${CHANNEL_MODES.PRESERVE}", "${CHANNEL_MODES.STEREO}", or "${CHANNEL_MODES.MONO}"`);
}
if (inputs.codec === CODECS.OPUS) {
if (!VALID_OPUS_APPLICATIONS.includes(inputs.opus_application)) {
errors.push(`Invalid opus_application - must be one of: ${VALID_OPUS_APPLICATIONS.join(', ')}`);
}
if (!VALID_OPUS_VBR_MODES.includes(inputs.opus_vbr)) {
errors.push(`Invalid opus_vbr - must be one of: ${VALID_OPUS_VBR_MODES.join(', ')}`);
}
if (!VALID_OPUS_COMPRESSION.includes(inputs.opus_compression)) {
errors.push(`Invalid opus_compression - must be one of: ${VALID_OPUS_COMPRESSION.join(', ')}`);
}
}
if (inputs.codec === CODECS.AAC) {
if (!VALID_AAC_PROFILES.includes(inputs.aac_profile)) {
errors.push(`Invalid aac_profile - must be one of: ${VALID_AAC_PROFILES.join(', ')}`);
}
}
if (!VALID_SAMPLE_RATES.includes(inputs.target_sample_rate)) {
errors.push(`Invalid target_sample_rate - must be one of: ${VALID_SAMPLE_RATES.join(', ')}`);
}
if (!VALID_QUALITY_PRESETS.includes(inputs.quality_preset)) {
errors.push(`Invalid quality_preset - must be one of: ${VALID_QUALITY_PRESETS.join(', ')}`);
}
return errors;
};
const validationErrors = validateInputs(inputs);
if (validationErrors.length > 0) {
response.infoLog += '❌ Input validation errors:\n';
validationErrors.forEach(error => {
response.infoLog += ` - ${error}\n`;
});
response.processFile = false;
return response;
}
const originalInputs = { ...inputs };
inputs = applyQualityPreset(inputs);
if (originalInputs.quality_preset !== 'custom' && originalInputs.quality_preset === inputs.quality_preset) {
const preset = QUALITY_PRESETS[inputs.quality_preset];
if (preset) {
response.infoLog += `🎯 Applied quality preset: ${inputs.quality_preset}\n`;
response.infoLog += ` Description: ${preset.description}\n`;
if (inputs.codec === CODECS.AAC) {
response.infoLog += ` AAC bitrate per channel: ${preset.aac_bitrate_per_channel}kbps\n`;
} else {
response.infoLog += ` Opus bitrate per channel: ${preset.opus_bitrate_per_channel}kbps\n`;
}
}
}
if (file.fileMedium !== 'video') {
response.infoLog += ' File is not video.\n';
response.processFile = false;
return response;
}
let audioStreams = [];
let needsTranscode = false;
let streamWarnings = [];
const targetCodec = inputs.codec === CODECS.OPUS ? [CODECS.OPUS, CODECS.LIBOPUS] : [CODECS.AAC];
try {
for (let i = 0; i < file.ffProbeData.streams.length; i++) {
const stream = file.ffProbeData.streams[i];
if (stream.codec_type === 'audio') {
audioStreams.push({ index: i, ...stream });
const warnings = validateStream(stream, i);
streamWarnings.push(...warnings);
if (needsTranscoding(stream, inputs, targetCodec)) {
needsTranscode = true;
}
}
}
} catch (error) {
response.infoLog += `❌ Error analyzing audio streams: ${error.message}\n`;
response.processFile = false;
return response;
}
if (audioStreams.length === 0) {
response.infoLog += ' No audio streams found.\n';
response.processFile = false;
return response;
}
response.infoLog += '🔍 Audio Stream Analysis:\n';
audioStreams.forEach(stream => {
response.infoLog += logStreamInfo(stream, stream.index) + '\n';
});
if (streamWarnings.length > 0) {
response.infoLog += '⚠️ Stream warnings:\n';
streamWarnings.forEach(warning => {
response.infoLog += ` - ${warning}\n`;
});
}
if (!needsTranscode && inputs.create_downmix !== 'true') {
response.infoLog += '✅ File already meets all requirements.\n';
return response;
}
// Check if file has attachment streams (fonts, cover art, etc.)
const hasAttachments = file.ffProbeData.streams.some(s => s.codec_type === 'attachment');
// Build stream mapping explicitly by type to prevent attachment processing errors
// Using -map 0 would map ALL streams including attachments, which causes muxing errors
// when combined with additional -map commands for downmix tracks
let streamMap = '-map 0:v -map 0:a -map 0:s';
if (hasAttachments) {
// Add attachments separately with copy codec
streamMap += ' -map 0:t -c:t copy';
}
let ffmpegArgs = `${streamMap} -c:v copy -c:s copy`;
let audioIdx = 0;
let processNeeded = false;
let is2channelAdded = false;
let transcodedStreams = 0;
let copiedStreams = 0;
let downmixStreams = 0;
try {
for (const stream of audioStreams) {
let streamNeedsTranscode = needsTranscoding(stream, inputs, targetCodec);
let forcePerStreamDownmix = false;
if (inputs.codec === CODECS.OPUS && isOpusIncompatibleLayout(stream.channel_layout)) {
if (!streamNeedsTranscode) {
streamNeedsTranscode = true;
}
if (inputs.channel_mode === CHANNEL_MODES.PRESERVE) {
forcePerStreamDownmix = true;
}
}
if (streamNeedsTranscode) {
const targetBitrate = calculateBitrate(inputs, stream.channels, stream.bit_rate);
const codecArgs = buildCodecArgs(audioIdx, inputs, targetBitrate);
let channelArgs = buildChannelArgs(audioIdx, inputs);
const sampleRateArgs = getSampleRateArgs(audioIdx, inputs);
const metadataArgs = getMetadataArgs(audioIdx, stream, inputs);
if (forcePerStreamDownmix) {
channelArgs = ` -ac:a:${audioIdx} 2`;
}
ffmpegArgs += ` ${codecArgs}${channelArgs}${sampleRateArgs}${metadataArgs}`;
processNeeded = true;
transcodedStreams++;
response.infoLog += `✅ Converting ${stream.codec_name} (${stream.channels}ch, ${stream.bit_rate ? Math.round(stream.bit_rate / 1000) + 'kbps' : 'unknown'}) to ${inputs.codec} at stream ${audioIdx}.\n`;
if (inputs.codec === CODECS.OPUS) {
if (forcePerStreamDownmix) {
response.infoLog += ` Detected incompatible layout "${stream.channel_layout}" → per-stream stereo downmix applied.\n`;
} else if (stream.channel_layout) {
response.infoLog += ` Layout "${stream.channel_layout}" deemed Opus-compatible.\n`;
}
}
if (targetBitrate) {
response.infoLog += ` Target bitrate: ${targetBitrate}kbps${inputs.bitrate_per_channel === 'original' ? ' (from source)' : ` (${inputs.bitrate_per_channel}kbps per channel)`}\n`;
}
} else {
ffmpegArgs += ` -c:a:${audioIdx} copy`;
copiedStreams++;
if (inputs.skip_if_compatible === 'true' && COMPATIBLE_CODECS.includes(stream.codec_name)) {
response.infoLog += `✅ Keeping ${stream.codec_name} (${stream.channels}ch) - compatible format.\n`;
}
}
audioIdx++;
}
} catch (error) {
response.infoLog += `❌ Error processing audio streams: ${error.message}\n`;
response.processFile = false;
return response;
}
if (inputs.create_downmix === 'true') {
const existing2chTracks = audioStreams.filter(s => s.channels === 2);
if (existing2chTracks.length > 0) {
response.infoLog += ` Skipping 2ch downmix - ${existing2chTracks.length} stereo track(s) already exist.\n`;
} else {
try {
for (const stream of audioStreams) {
if ((stream.channels === 6 || stream.channels === 8) &&
(inputs.downmix_single_track === 'false' || !is2channelAdded)) {
const downmixArgs = buildDownmixArgs(audioIdx, stream.index, stream, inputs, 2);
ffmpegArgs += downmixArgs;
response.infoLog += `✅ Creating 2ch downmix from ${stream.channels}ch audio.\n`;
processNeeded = true;
is2channelAdded = true;
downmixStreams++;
audioIdx++;
}
}
} catch (error) {
response.infoLog += `❌ Error creating downmix tracks: ${error.message}\n`;
response.processFile = false;
return response;
}
}
}
// Create 6ch (5.1) downmix from 8ch (7.1) if enabled
if (inputs.create_6ch_downmix === 'true') {
const existing6chTracks = audioStreams.filter(s => s.channels === 6);
const available8chTracks = audioStreams.filter(s => s.channels === 8);
if (existing6chTracks.length > 0) {
response.infoLog += ` Skipping 6ch downmix - ${existing6chTracks.length} 5.1 track(s) already exist.\n`;
} else if (available8chTracks.length === 0) {
response.infoLog += ' Skipping 6ch downmix - no 7.1 (8ch) tracks available to downmix.\n';
} else {
try {
let is6channelAdded = false;
for (const stream of audioStreams) {
if (stream.channels === 8 && (inputs.downmix_single_track === 'false' || !is6channelAdded)) {
const downmixArgs = buildDownmixArgs(audioIdx, stream.index, stream, inputs, 6);
ffmpegArgs += downmixArgs;
response.infoLog += '✅ Creating 6ch (5.1) downmix from 8ch (7.1) audio.\n';
processNeeded = true;
is6channelAdded = true;
downmixStreams++;
audioIdx++;
}
}
} catch (error) {
response.infoLog += `❌ Error creating 6ch downmix tracks: ${error.message}\n`;
response.processFile = false;
return response;
}
}
}
if (processNeeded) {
try {
response.processFile = true;
// Add global Opus encoder options once at the end if using Opus
const opusGlobalArgs = getOpusGlobalArgs(inputs);
response.preset = `<io>${ffmpegArgs}${opusGlobalArgs} -max_muxing_queue_size 9999`;
response.ffmpegMode = true;
response.reQueueAfter = true;
// Calculate actual numerical bitrate for display (not 'auto' or 'original')
const displayBitrate = calculateBitrate(inputs, 2, null);
const bitratePerChannelDisplay = inputs.bitrate_per_channel === 'auto' ? '64 (auto)' :
inputs.bitrate_per_channel === 'original' ? 'original' :
inputs.bitrate_per_channel;
response.infoLog += '\n📋 Final Processing Summary:\n';
response.infoLog += ` Codec: ${inputs.codec}\n`;
response.infoLog += ` Quality preset: ${inputs.quality_preset}\n`;
response.infoLog += ` Channel mode: ${inputs.channel_mode}\n`;
response.infoLog += ` Bitrate per channel: ${bitratePerChannelDisplay}kbps\n`;
response.infoLog += ` Stereo downmix bitrate: ${displayBitrate}kbps\n`;
response.infoLog += ` Streams to transcode: ${transcodedStreams}\n`;
response.infoLog += ` Streams to copy: ${copiedStreams}\n`;
response.infoLog += ` Downmix tracks to create: ${downmixStreams}\n`;
if (inputs.skip_if_compatible === 'true') {
response.infoLog += ' Compatibility mode: accepting both AAC and Opus\n';
}
if (inputs.create_downmix === 'true') {
response.infoLog += ' 2ch downmix creation enabled\n';
}
if (inputs.create_6ch_downmix === 'true') {
response.infoLog += ' 6ch downmix creation enabled\n';
}
} catch (error) {
response.infoLog += `❌ Error building FFmpeg command: ${error.message}\n`;
response.processFile = false;
return response;
}
} else {
response.infoLog += '✅ File already meets all requirements.\n';
response.processFile = false;
}
return response;
} catch (error) {
// Comprehensive error handling
response.processFile = false;
response.preset = '';
response.reQueueAfter = false;
// Provide detailed error information
response.infoLog = `💥 Plugin error: ${error.message}\n`;
// Add stack trace for debugging (first 5 lines)
if (error.stack) {
const stackLines = error.stack.split('\n').slice(0, 5).join('\n');
response.infoLog += `Stack trace:\n${stackLines}\n`;
}
// Log additional context
response.infoLog += `File: ${file.file}\n`;
response.infoLog += `Container: ${file.container}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;