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 = `${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;