Files
tdarr-plugs/Local/Tdarr_Plugin_combined_audio_standardizer.js

1292 lines
50 KiB
JavaScript
Raw Permalink 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.
ALWAYS preserves original channel count for all tracks. Creates additional downmixed tracks
(8ch->6ch, 6ch/8ch->2ch) as SECONDARY tracks when enabled. Original multichannel tracks are never
replaced - downmix tracks are added alongside them.
v1.24: Opus channel mapping fix - added mapping_family 1 for multichannel Opus and channel
reordering filters for incompatible source layouts (AC3 2.1, 4.1 etc.). Robust fallback
to AAC for layouts exceeding Opus limits (>8ch).
v1.23: CRITICAL FIX - Always preserves original channels. channel_mode now only affects whether
original tracks are downmixed (legacy option). create_downmix creates ADDITIONAL stereo
tracks, never replaces originals. Improved duplicate stereo detection per language.
v1.22: Fixed channel preservation - Opus-incompatible layouts now use AAC fallback instead of
stereo downmix. Smart downmix: one stereo per language, excludes commentary tracks.
v1.21: Added set_default_by_channels option - sets the audio track with the most channels as the
default stream. Ensures surround audio is preferred over stereo in players.
v1.20: Fixed channel preservation - now explicitly sets channel count to prevent FFmpeg defaulting to stereo.
Added channel count to all track titles. Updated default behavior to convert to OPUS unless already AAC.
`,
Version: '4.0.0',
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 true (default), keeps AAC as-is and converts other codecs to OPUS. When false, converts to exact 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 (RECOMMENDED), stereo=downmix original to 2.0 (legacy), mono=downmix to 1.0. Note: create_downmix creates ADDITIONAL tracks, original channels are always preserved when preserve is selected.',
},
{
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). Original channels are ALWAYS preserved. Only creates if no stereo track exists for that language AND multichannel source is present. Creates as SECONDARY track alongside original.',
},
{
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.',
},
{
name: 'set_default_by_channels',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: [
'false',
'true*'
],
},
tooltip: 'Set the default audio stream to the track with the most channels. This ensures surround audio is preferred by default in players. Runs after all audio processing is complete.',
}
],
});
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 = new Set(['auto', '64', '80', '96', '128', '160', '192', 'original']);
const VALID_BOOLEAN_VALUES = new Set(['true', 'false']);
const VALID_OPUS_APPLICATIONS = new Set(['audio', 'voip', 'lowdelay']);
const VALID_OPUS_VBR_MODES = new Set(['on', 'off', 'constrained']);
const VALID_OPUS_COMPRESSION = new Set(['0', '5', '8', '10']);
const VALID_AAC_PROFILES = new Set(['aac_low', 'aac_he', 'aac_he_v2']);
const VALID_SAMPLE_RATES = new Set(['original', '48000', '44100', '32000']);
const VALID_QUALITY_PRESETS = new Set(['custom', 'high_quality', 'balanced', 'small_size']);
// Container-aware subtitle compatibility
const MKV_INCOMPATIBLE_SUBS = new Set(['mov_text', 'tx3g']);
const MP4_INCOMPATIBLE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub', 'subrip', 'ass', 'ssa', 'webvtt']);
const MP4_CONVERTIBLE_SUBS = new Set(['subrip', 'ass', 'ssa', 'webvtt', 'text']);
const IMAGE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
// Opus supports up to 8 channels using Vorbis mapping (Family 1)
const OPUS_MAX_CHANNELS = 8;
/**
* Determines Opus encoding parameters for a stream based on standard Vorbis mapping rules.
* This fixes issues where players default to stereo or fail on multichannel Opus.
* Returns { family: number, filter: string | null, incompatible: boolean, reason: string | null }
*/
const getOpusMappingInfo = (stream) => {
const channels = stream.channels || 0;
const layout = stream.channel_layout || 'unknown';
const codec = stream.codec_name || '';
// Opus hard limit is 8 channels for most containers/players (Family 1)
if (channels < 1 || channels > OPUS_MAX_CHANNELS) {
return { incompatible: true, reason: `Channel count (${channels}) exceeds Opus limit of 8`, family: 0, filter: null };
}
const info = {
family: channels > 2 ? 1 : 0,
filter: null,
incompatible: false,
reason: null
};
// Channel reordering logic for specific source layouts that don't match Vorbis order.
// We use the 'pan' filter to ensure channels are in the correct position for Opus.
// Mapping logic based on standard broadcast and movie audio formats.
if (codec === 'ac3' || codec === 'eac3' || codec === 'dts') {
// 2.1 (L R LFE) -> Opus 3.0 (L C R) - Vorbis order expects L C R
// AC3 2.1 index: 0=L, 1=R, 2=LFE -> Target index: 0=L, 1=C(LFE), 2=R
if (layout === '2.1' || (channels === 3 && layout === 'unknown')) {
info.filter = 'pan=3.0|c0=c0|c1=c2|c2=c1';
}
// 3.1 (L C R LFE) -> Opus 4.0 (L R LS RS)
// Map LFE and Center to surround positions to preserve all elements
else if (layout === '3.1') {
info.filter = 'pan=4.0|c0=c0|c1=c2|c2=c1|c3=c3';
}
// 4.1 (L R LS RS LFE) -> Opus 5.0 (L C R LS RS)
// Map LFE to Center to preserve layout balance
else if (layout === '4.1') {
info.filter = 'pan=5.0|c0=c0|c1=c4|c2=c1|c3=c2|c4=c3';
}
// 5.1 and 7.1 usually match standard Vorbis order correctly in FFmpeg
}
// If layout is still unknown and channels are non-standard, we should be cautious
if (layout === 'unknown' && channels > 2 && channels !== 3 && channels !== 4 && channels !== 5 && channels !== 6 && channels !== 8) {
info.incompatible = true;
info.reason = `Non-standard channel count (${channels}) with unknown layout`;
}
return info;
};
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;
// IMPORTANT: channel_mode 'stereo' and 'mono' are legacy options that downmix original tracks.
// The recommended approach is 'preserve' + create_downmix=true to keep originals AND add downmix.
// We still support legacy mode for backward compatibility, but it's not recommended.
// Check if channel layout needs changing (legacy mode only)
if (inputs.channel_mode === 'stereo' && stream.channels > 2) return true;
if (inputs.channel_mode === 'mono' && stream.channels > 1) return true;
// Default behavior when skip_if_compatible is true: Convert to OPUS unless already AAC
// This means: keep AAC as-is, convert everything else (including Opus) to target codec
if (inputs.skip_if_compatible === 'true') {
if (inputs.codec === CODECS.OPUS) {
// Special case: Keep AAC, convert everything else to Opus
if (stream.codec_name === CODECS.AAC) {
return false; // Keep AAC
}
// If already Opus and matches target, skip (but allow re-encoding if bitrate/channels change)
if (targetCodec.includes(stream.codec_name)) {
return false; // Already correct codec
}
}
// For other cases, skip if already a compatible codec (AAC or Opus)
return !COMPATIBLE_CODECS.includes(stream.codec_name);
}
// When skip_if_compatible is false, only accept exact target codec match
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
// Log warning if preset not found, fallback to custom (should be caught by validateInputs though)
// console.warn(`Warning: Quality preset '${inputs.quality_preset}' not found, using custom settings.`);
// Changing to silent return as validation handles it, or could throw error.
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, channels = 0) => {
if (inputs.codec === CODECS.OPUS) {
// For Opus, apply mapping family 1 for multichannel (3-8 channels)
// This fixes issues where players default to stereo or fail on multichannel Opus
const mappingArgs = (channels > 2 && channels <= 8) ? ` -mapping_family:a:${audioIdx} 1` : '';
// 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` : '',
mappingArgs
].filter(Boolean).join(' ').trim();
}
// 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}`;
};
// Simple sanitizer to keep FFmpeg metadata titles/languages safe and unquoted
const sanitizeMetadataValue = (value) => {
if (!value) return '';
return String(value)
.replace(/["']/g, '') // strip both single and double quotes
.replace(/\s+/g, ' ') // collapse whitespace
.trim();
};
// Returns metadata preservation arguments
const getMetadataArgs = (audioIdx, stream, inputs, customTitle = null) => {
const args = [];
const channelCount = stream.channels || 0;
// Title handling
let rawTitle;
if (customTitle) {
rawTitle = customTitle;
} else if (inputs.preserve_metadata === 'true' && stream.tags?.title) {
rawTitle = `${stream.tags.title} (${channelCount}ch)`;
} else {
rawTitle = `${channelCount}ch`;
}
const safeTitle = sanitizeMetadataValue(rawTitle);
if (safeTitle) {
// Note: Wrapping the value in double quotes is necessary for titles with spaces
args.push(`-metadata:s:a:${audioIdx} title="${safeTitle}"`);
}
// Language handling
if (inputs.preserve_metadata === 'true' && stream.tags?.language) {
const safeLang = sanitizeMetadataValue(stream.tags.language).toLowerCase() || 'und';
args.push(`-metadata:s:a:${audioIdx} language="${safeLang}"`);
}
return args.length > 0 ? ' ' + args.join(' ') : '';
};
const buildChannelArgs = (audioIdx, inputs, streamChannels = null) => {
switch (inputs.channel_mode) {
case CHANNEL_MODES.STEREO:
// Legacy mode: downmix original track to stereo (not recommended)
// Recommended: use 'preserve' + create_downmix=true instead
return ` -ac:a:${audioIdx} 2`;
case CHANNEL_MODES.MONO:
// Legacy mode: downmix original track to mono (not recommended)
return ` -ac:a:${audioIdx} 1`;
case CHANNEL_MODES.PRESERVE:
default:
// ALWAYS preserve original channel count to prevent FFmpeg from defaulting to stereo
// This is the recommended mode - original channels are preserved, downmix tracks are added separately
if (streamChannels !== null && streamChannels > 0) {
return ` -ac:a:${audioIdx} ${streamChannels}`;
}
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);
// Determine codec for downmix: use AAC if source is AAC (and we're keeping it), otherwise use Opus
// This ensures downmix matches the codec of the preserved/transcoded track
const useAacForDownmix = stream.codec_name === CODECS.AAC &&
inputs.skip_if_compatible === 'true' &&
inputs.codec === CODECS.OPUS;
if (useAacForDownmix) {
// Use AAC for downmix when source is AAC
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 (${sanitizeMetadataValue(stream.tags?.title || 'Unknown Source')})`)
].filter(Boolean).join(' ');
}
// Default to Opus for downmix
// For Opus downmix, apply mapping family 1 if channels > 2 (e.g., 5.1 downmix)
const mappingArgs = (channels > 2 && channels <= 8) ? `-mapping_family:a:${audioIdx} 1` : '';
return baseArgs + [
'libopus',
`-b:a:${audioIdx} ${downmixBitrate}k`,
mappingArgs,
`-ac ${channels}`,
getSampleRateArgs(audioIdx, inputs),
getMetadataArgs(audioIdx, stream, inputs, `${channels}.0 Downmix (${sanitizeMetadataValue(stream.tags?.title || 'Unknown Source')})`)
].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;
};
// Inline utilities (Tdarr plugins must be self-contained)
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
const sanitizeInputsLocal = (inputs) => {
Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); });
return inputs;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: false,
reQueueAfter: false,
infoLog: '',
};
try {
inputs = sanitizeInputsLocal(lib.loadDefaultValues(inputs, details));
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',
'set_default_by_channels'
];
for (const input of booleanInputs) {
if (!VALID_BOOLEAN_VALUES.has(inputs[input])) {
errors.push(`Invalid ${input} value - must be "true" or "false"`);
}
}
if (!VALID_BITRATES.has(inputs.bitrate_per_channel)) {
errors.push(`Invalid bitrate_per_channel - must be one of: ${Array.from(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.has(inputs.opus_application)) {
errors.push(`Invalid opus_application - must be one of: ${Array.from(VALID_OPUS_APPLICATIONS).join(', ')}`);
}
if (!VALID_OPUS_VBR_MODES.has(inputs.opus_vbr)) {
errors.push(`Invalid opus_vbr - must be one of: ${Array.from(VALID_OPUS_VBR_MODES).join(', ')}`);
}
if (!VALID_OPUS_COMPRESSION.has(inputs.opus_compression)) {
errors.push(`Invalid opus_compression - must be one of: ${Array.from(VALID_OPUS_COMPRESSION).join(', ')}`);
}
}
if (inputs.codec === CODECS.AAC) {
if (!VALID_AAC_PROFILES.has(inputs.aac_profile)) {
errors.push(`Invalid aac_profile - must be one of: ${Array.from(VALID_AAC_PROFILES).join(', ')}`);
}
}
if (!VALID_SAMPLE_RATES.has(inputs.target_sample_rate)) {
errors.push(`Invalid target_sample_rate - must be one of: ${Array.from(VALID_SAMPLE_RATES).join(', ')}`);
}
if (!VALID_QUALITY_PRESETS.has(inputs.quality_preset)) {
errors.push(`Invalid quality_preset - must be one of: ${Array.from(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];
// Helper to resolve channel counts using MediaInfo when ffprobe fails
const getChannelCount = (stream, file) => {
let channels = parseInt(stream.channels || 0);
if (channels > 0) return channels;
// Try MediaInfo fallback
const miStreams = file?.mediaInfo?.track;
if (Array.isArray(miStreams)) {
const miStream = miStreams.find((t) => t['@type'] === 'Audio' && t.StreamOrder == stream.index);
const miChannels = parseInt(miStream?.Channels || 0);
if (miChannels > 0) return miChannels;
}
return channels;
};
try {
for (let i = 0; i < file.ffProbeData.streams.length; i++) {
const stream = file.ffProbeData.streams[i];
if (stream.codec_type === 'audio') {
const channels = getChannelCount(stream, file);
audioStreams.push({ index: i, ...stream, channels });
const warnings = validateStream({ ...stream, channels }, 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`;
});
}
// EARLY EXIT OPTIMIZATION:
// If no transcode needed AND (downmix disabled OR (downmix enabled but no multichannel source))
// We can exit before doing the heavy stream processing loop
const hasMultichannel = audioStreams.some(s => s.channels > 2);
if (!needsTranscode && inputs.create_downmix === 'true' && !hasMultichannel && inputs.create_6ch_downmix !== 'true') {
response.infoLog += '✅ File already meets all requirements (No transcoding needed, no multichannel audio for downmix).\n';
return response;
}
if (!needsTranscode && inputs.create_downmix !== 'true' && inputs.create_6ch_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
// Map video first, then we'll map audio streams individually as we process them
// This prevents conflicts when adding downmix tracks
let streamMap = '-map 0:v';
// Check if file has subtitle streams before mapping them
const hasSubtitles = file.ffProbeData.streams.some(s => s.codec_type === 'subtitle');
const container = (file.container || '').toLowerCase();
// Analyze subtitles for container compatibility
// Helper for robust subtitle identification (synced with Plugin 04)
const getSubtitleCodec = (stream, file) => {
let codecName = (stream.codec_name || '').toLowerCase();
if (codecName && codecName !== 'none' && codecName !== 'unknown') return codecName;
// FFmpeg reports 'none' for some codecs it can't identify (like WEBVTT in MKV)
const codecTag = (stream.codec_tag_string || '').toUpperCase();
if (codecTag.includes('WEBVTT')) return 'webvtt';
if (codecTag.includes('ASS')) return 'ass';
if (codecTag.includes('SSA')) return 'ssa';
const tagCodecId = (stream.tags?.CodecID || stream.tags?.codec_id || '').toUpperCase();
if (tagCodecId.includes('WEBVTT') || tagCodecId.includes('S_TEXT/WEBVTT')) return 'webvtt';
if (tagCodecId.includes('ASS') || tagCodecId.includes('S_TEXT/ASS')) return 'ass';
if (tagCodecId.includes('SSA') || tagCodecId.includes('S_TEXT/SSA')) return 'ssa';
const miStreams = file?.mediaInfo?.track;
if (Array.isArray(miStreams)) {
const miStream = miStreams.find((t) => t['@type'] === 'Text' && t.StreamOrder == stream.index);
const miCodec = (miStream?.CodecID || '').toLowerCase();
if (miCodec.includes('webvtt')) return 'webvtt';
if (miCodec.includes('ass')) return 'ass';
if (miCodec.includes('ssa')) return 'ssa';
}
// Try ExifTool (meta) fallback
const meta = file?.meta;
if (meta) {
const trackName = (stream.tags?.title || '').toLowerCase();
if (trackName.includes('webvtt')) return 'webvtt';
}
return codecName || 'unknown';
};
let subtitleHandling = '';
let subtitleLog = '';
if (hasSubtitles) {
const subtitleStreams = file.ffProbeData.streams.filter(s => s.codec_type === 'subtitle');
const imageSubs = subtitleStreams.filter(s => IMAGE_SUBS.has(getSubtitleCodec(s, file)));
const textSubs = subtitleStreams.filter(s => MP4_CONVERTIBLE_SUBS.has(getSubtitleCodec(s, file)));
if (container === 'mp4' || container === 'm4v') {
// MP4: drop image subs, convert text subs to mov_text
if (imageSubs.length > 0) {
// Don't map subtitles that are image-based for MP4
const compatibleSubs = subtitleStreams.filter(s => !IMAGE_SUBS.has(getSubtitleCodec(s, file)));
if (compatibleSubs.length > 0) {
// Map only compatible subs individually
compatibleSubs.forEach((s, i) => {
streamMap += ` -map 0:${file.ffProbeData.streams.indexOf(s)}`;
});
subtitleHandling = ' -c:s mov_text';
subtitleLog = `Dropping ${imageSubs.length} image sub(s), converting ${compatibleSubs.length} to mov_text. `;
} else {
subtitleLog = `Dropping ${imageSubs.length} image subtitle(s) (incompatible with MP4). `;
}
} else if (textSubs.length > 0) {
streamMap += ' -map 0:s';
subtitleHandling = ' -c:s mov_text';
subtitleLog = `Converting ${textSubs.length} subtitle(s) to mov_text. `;
} else {
streamMap += ' -map 0:s';
subtitleHandling = ' -c:s copy';
}
} else {
// MKV: convert mov_text to srt, keep others
streamMap += ' -map 0:s';
const movTextSubs = subtitleStreams.filter(s => getSubtitleCodec(s, file) === 'mov_text');
if (movTextSubs.length > 0) {
subtitleHandling = ' -c:s srt';
subtitleLog = `Converting ${movTextSubs.length} mov_text subtitle(s) to SRT. `;
} else {
subtitleHandling = ' -c:s copy';
}
}
}
if (subtitleLog) {
response.infoLog += `📁 ${subtitleLog}\n`;
}
let audioIdx = 0;
if (hasAttachments) {
// Map attachments individually to avoid FFmpeg 7.x muxing errors
// FFmpeg 7.x has stricter handling of attachment streams - broad mapping with -map 0:t
// can cause \"Received a packet for an attachment stream\" errors when combined with
// additional audio mapping for downmix tracks
const attachmentStreams = file.ffProbeData.streams
.map((s, i) => ({ stream: s, index: i }))
.filter(({ stream }) => stream.codec_type === 'attachment');
attachmentStreams.forEach(({ index }) => {
streamMap += ` -map 0:${index}`;
});
// Attachments always use copy codec
streamMap += ' -c:t copy';
}
// Build audio stream mapping as we process each stream
let audioMapArgs = '';
let ffmpegArgs = `${streamMap} -c:v copy${subtitleHandling}`;
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);
// Track if we need to use AAC fallback for Opus-incompatible layouts
let useAacFallback = false;
let opusMapping = null;
if (inputs.codec === CODECS.OPUS) {
opusMapping = getOpusMappingInfo(stream);
if (opusMapping.incompatible) {
// Fallback to AAC if Opus cannot handle this layout/channel count
useAacFallback = true;
if (!streamNeedsTranscode) {
streamNeedsTranscode = true;
}
}
}
// Map this audio stream individually
audioMapArgs += ` -map 0:${stream.index}`;
if (streamNeedsTranscode) {
const targetBitrate = calculateBitrate(inputs, stream.channels, stream.bit_rate);
let codecArgs;
if (useAacFallback) {
// Use AAC for incompatible layouts to preserve channel count
const aacProfile = inputs.aac_profile === 'aac_low' ? 'aac' : inputs.aac_profile;
codecArgs = [
`-c:a:${audioIdx} ${aacProfile}`,
targetBitrate ? `-b:a:${audioIdx} ${targetBitrate}k` : '',
'-strict -2'
].filter(Boolean).join(' ');
} else {
codecArgs = buildCodecArgs(audioIdx, inputs, targetBitrate, stream.channels);
// Add pan filter if needed for Opus reordering
if (opusMapping && opusMapping.filter) {
codecArgs += ` -af:a:${audioIdx} "${opusMapping.filter}"`;
}
}
const channelArgs = buildChannelArgs(audioIdx, inputs, stream.channels);
const sampleRateArgs = getSampleRateArgs(audioIdx, inputs);
const metadataArgs = getMetadataArgs(audioIdx, stream, inputs);
ffmpegArgs += ` ${codecArgs}${channelArgs}${sampleRateArgs}${metadataArgs}`;
processNeeded = true;
transcodedStreams++;
if (useAacFallback) {
const reason = (opusMapping && opusMapping.reason) ? ` (${opusMapping.reason})` : '';
response.infoLog += `✅ Converting ${stream.codec_name} (${stream.channels}ch) to AAC (Opus-incompatible layout${reason}).\n`;
} else {
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 (opusMapping && opusMapping.filter) {
response.infoLog += ` Applied Opus channel mapping fix (reordering filter) for layout "${stream.channel_layout}".\n`;
} else if (stream.channels > 2) {
response.infoLog += ` Applied Opus mapping family 1 for ${stream.channels}ch audio.\n`;
}
}
}
if (targetBitrate) {
const bitrateSource = inputs.bitrate_per_channel === 'original' ? ' (from source)' :
inputs.bitrate_per_channel === 'auto' ? ' (auto: 64kbps/ch)' :
` (${inputs.bitrate_per_channel}kbps/ch)`;
response.infoLog += ` Target bitrate: ${targetBitrate}kbps${bitrateSource}\n`;
}
} else {
// Even when copying, we should add metadata to indicate channel count
const metadataArgs = getMetadataArgs(audioIdx, stream, inputs);
ffmpegArgs += ` -c:a:${audioIdx} copy${metadataArgs}`;
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') {
// Helper to check if a track is commentary
const isCommentary = (stream) => {
const title = (stream.tags?.title || '').toLowerCase();
return title.includes('commentary') || title.includes('comment');
};
// Helper to get normalized language
const getLang = (stream) => (stream.tags?.language || 'und').toLowerCase();
// Track which languages already have a stereo track (non-commentary)
// This includes both existing stereo tracks AND any we're creating in this run
const langsWithStereo = new Set();
audioStreams.forEach(s => {
if (s.channels === 2 && !isCommentary(s)) {
langsWithStereo.add(getLang(s));
}
});
// Track which languages we've created downmixes for in this run
// This prevents creating multiple stereo tracks for the same language
const langsDownmixCreated = new Set();
try {
for (const stream of audioStreams) {
const lang = getLang(stream);
// Only create downmix from multichannel sources (6ch=5.1 or 8ch=7.1)
// Skip if not multichannel - we only downmix from surround sources
if (stream.channels !== 6 && stream.channels !== 8) continue;
// Skip commentary tracks - they usually don't need stereo versions
if (isCommentary(stream)) continue;
// Skip if this language already has a stereo track (existing or created in this run)
if (langsWithStereo.has(lang)) {
response.infoLog += ` Skipping ${lang} 2ch downmix - stereo track already exists for this language.\n`;
continue;
}
// Skip if we already created a downmix for this language (prevents duplicates)
if (langsDownmixCreated.has(lang)) {
response.infoLog += ` Skipping ${lang} 2ch downmix - already created one for this language.\n`;
continue;
}
// Create the ADDITIONAL downmix track (original channels are preserved above)
const downmixArgs = buildDownmixArgs(audioIdx, stream.index, stream, inputs, 2);
ffmpegArgs += downmixArgs;
response.infoLog += `✅ Creating ADDITIONAL 2ch downmix from ${stream.channels}ch ${lang} audio (original ${stream.channels}ch track preserved).\n`;
processNeeded = true;
is2channelAdded = true;
downmixStreams++;
audioIdx++;
// Track that we created a downmix for this language (prevents duplicates)
langsDownmixCreated.add(lang);
// Also mark this language as having stereo now (prevents future duplicates in same run)
langsWithStereo.add(lang);
// If single track mode, only create one total downmix across all languages
if (inputs.downmix_single_track === 'true') break;
}
} 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;
// Insert audio map arguments right after streamMap (before codec arguments)
// FFmpeg requires all -map commands before codec arguments
const audioMapInsertionPoint = streamMap.length;
ffmpegArgs = ffmpegArgs.slice(0, audioMapInsertionPoint) + audioMapArgs + ffmpegArgs.slice(audioMapInsertionPoint);
// Add global Opus encoder options once at the end if using Opus
const opusGlobalArgs = getOpusGlobalArgs(inputs);
// Build disposition flags for setting default audio by channel count
let dispositionArgs = '';
if (inputs.set_default_by_channels === 'true') {
// Track final channel counts for all audio streams in output order
// audioIdx at this point represents the total number of audio tracks in output
const finalAudioTracks = [];
let trackIdx = 0;
// Original audio streams (in processing order)
// IMPORTANT: When channel_mode is 'preserve' (recommended), original channels are ALWAYS preserved
// Downmix tracks are created as ADDITIONAL tracks, not replacements
for (const stream of audioStreams) {
let finalChannels = stream.channels;
// Account for legacy channel mode changes (only if not 'preserve')
// Note: 'preserve' mode is recommended - it keeps originals and adds downmix separately
if (inputs.channel_mode === CHANNEL_MODES.STEREO && stream.channels > 2) {
finalChannels = 2; // Legacy mode: downmix original
} else if (inputs.channel_mode === CHANNEL_MODES.MONO && stream.channels > 1) {
finalChannels = 1; // Legacy mode: downmix original
}
// When 'preserve' mode: original channels are kept (AAC fallback for Opus-incompatible layouts
// also preserves channel count)
finalAudioTracks.push({ idx: trackIdx, channels: finalChannels });
trackIdx++;
}
// Downmix tracks (2ch)
// Downmix tracks (2ch) - Simulate exactly what we did above
// These are ADDITIONAL tracks created alongside originals, not replacements
if (inputs.create_downmix === 'true') {
const isCommentary = (stream) => {
const title = (stream.tags?.title || '').toLowerCase();
return title.includes('commentary') || title.includes('comment');
};
const getLang = (stream) => (stream.tags?.language || 'und').toLowerCase();
const langsWithStereo = new Set();
audioStreams.forEach(s => {
if (s.channels === 2 && !isCommentary(s)) {
langsWithStereo.add(getLang(s));
}
});
const langsDownmixCreated = new Set();
for (const stream of audioStreams) {
const lang = getLang(stream);
// Logic must match downmix creation exactly
if (stream.channels !== 6 && stream.channels !== 8) continue;
if (isCommentary(stream)) continue;
if (langsWithStereo.has(lang)) continue;
if (langsDownmixCreated.has(lang)) continue;
// We create a 2ch downmix track here (original multichannel track is preserved above)
finalAudioTracks.push({ idx: trackIdx, channels: 2 });
trackIdx++;
langsDownmixCreated.add(lang);
// Mark language as having stereo to prevent duplicates
langsWithStereo.add(lang);
if (inputs.downmix_single_track === 'true') break;
}
}
// 6ch downmix tracks
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 && available8chTracks.length > 0) {
for (const stream of audioStreams) {
if (stream.channels === 8) {
finalAudioTracks.push({ idx: trackIdx, channels: 6 });
trackIdx++;
if (inputs.downmix_single_track === 'true') break;
}
}
}
}
// Find track with highest channel count
if (finalAudioTracks.length > 0) {
const maxChannels = Math.max(...finalAudioTracks.map(t => t.channels));
const defaultTrackIdx = finalAudioTracks.find(t => t.channels === maxChannels).idx;
// Set disposition flags: default on highest channel track, remove default from others
for (let i = 0; i < finalAudioTracks.length; i++) {
if (i === defaultTrackIdx) {
dispositionArgs += ` -disposition:a:${i} default`;
} else {
// Clear default flag from other audio tracks
dispositionArgs += ` -disposition:a:${i} 0`;
}
}
response.infoLog += `🎯 Set default audio: track ${defaultTrackIdx} (${maxChannels}ch - highest channel count after all processing).\n`;
}
}
response.preset = `<io>${ffmpegArgs}${opusGlobalArgs}${dispositionArgs} -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) {
response.processFile = false;
response.preset = '';
response.reQueueAfter = false;
response.infoLog = `❌ Plugin error: ${error.message}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;