1292 lines
50 KiB
JavaScript
1292 lines
50 KiB
JavaScript
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;
|