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