Initial commit: Tdarr plugin stack

Plugins:
- misc_fixes v2.8: Pre-processing, container remux, stream conforming
- stream_organizer v4.8: English priority, subtitle extraction, SRT conversion
- combined_audio_standardizer v1.13: AAC/Opus encoding, downmix creation
- av1_svt_converter v2.22: AV1 video encoding via SVT-AV1

Structure:
- Local/ - Plugin .js files (mount in Tdarr)
- agent_notes/ - Development documentation
- Latest-Reports/ - Error logs for analysis
This commit is contained in:
Tdarr Plugin Developer
2025-12-15 11:33:36 -08:00
commit aa71eb96d7
24 changed files with 6757 additions and 0 deletions

View File

@@ -0,0 +1,757 @@
const details = () => ({
id: 'Tdarr_Plugin_av1_svt_converter',
Stage: 'Pre-processing',
Name: 'Convert to AV1 SVT-AV1',
Type: 'Video',
Operation: 'Transcode',
Description: `
AV1 conversion plugin with advanced quality control and performance optimizations for SVT-AV1 v3.0+ (2025).
Features resolution-aware CRF, improved threading, and flexible bitrate control (custom maxrate or source-relative strategies).
**Balanced high-quality defaults**: Preset 6, CRF 26, tune 0 (VQ), 10-bit, SCD 1, AQ 2, lookahead -1, TF on, keyint -2, fast-decode 0.
Use presets 35 and/or lower CRF for higher quality when speed is less important.
`,
Version: '2.22',
Tags: 'video,av1,svt,quality,performance,speed-optimized,capped-crf',
Inputs: [
{
name: 'crf',
type: 'string',
defaultValue: '26*',
inputUI: {
type: 'dropdown',
options: [
'22',
'24',
'26*',
'28',
'30',
'32',
'34',
'36',
'38',
'40',
'42'
],
},
tooltip: 'Quality setting (CRF). Higher = faster encoding, lower quality. (default: 26 for 1080p) 2428 = high quality, 30+ = faster/transcoding. 1020 = archival. For 4K, add +2; for 720p, subtract 2. [SVT-AV1 v3.0+]',
},
{
name: 'custom_maxrate',
type: 'string',
defaultValue: '0',
inputUI: {
type: 'text',
},
tooltip: 'Maximum bitrate in kbps (0 or empty = unlimited). Used when target_bitrate_strategy is \'static\'. Capped CRF saves bandwidth on easy scenes while preserving quality on complex ones.',
},
{
name: 'target_bitrate_strategy',
type: 'string',
defaultValue: 'static*',
inputUI: {
type: 'dropdown',
options: [
'static*',
'match_source',
'75%_source',
'50%_source',
'33%_source',
'25%_source'
],
},
tooltip: 'Target bitrate strategy. \'static\' uses custom_maxrate. Other options set maxrate relative to detected source bitrate.',
},
{
name: 'max_resolution',
type: 'string',
defaultValue: 'none*',
inputUI: {
type: 'dropdown',
options: [
'none*',
'480p',
'720p',
'1080p',
'1440p',
'2160p'
],
},
tooltip: 'Maximum output resolution. Videos exceeding this will be downscaled while maintaining aspect ratio. CRF adjustment (if enabled) applies to output resolution.',
},
{
name: 'resolution_crf_adjust',
type: 'string',
defaultValue: 'enabled*',
inputUI: {
type: 'dropdown',
options: [
'disabled',
'enabled*'
],
},
tooltip: 'Auto-adjust CRF based on resolution: 4K gets +2 CRF, 1080p baseline, 720p gets -2 CRF. Improves efficiency with minimal quality impact.',
},
{
name: 'preset',
type: 'string',
defaultValue: '6*',
inputUI: {
type: 'dropdown',
options: [
'-1',
'0',
'1',
'2',
'3',
'4',
'5',
'6*',
'7',
'8',
'9',
'10',
'11',
'12'
],
},
tooltip: 'SVT-AV1 preset. (default: 6) 6 = balanced speed/quality, 10 = fastest (real-time), 89 = very fast, 34 = best quality but slow. Higher = faster, lower = better quality. [v3.0+]',
},
{
name: 'tune',
type: 'string',
defaultValue: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'1',
'2'
],
},
tooltip: 'Tuning mode. (default: 0 VQ) 0 = VQ (best visual quality), 1 = PSNR (faster), 2 = SSIM (slowest). [v3.0+]',
},
{
name: 'scd',
type: 'string',
defaultValue: '1*',
inputUI: {
type: 'dropdown',
options: [
'0',
'1*'
],
},
tooltip: 'Scene Change Detection. (default: 1) 0 = Off (fastest), 1 = On (better keyframe placement, ~510% slower).',
},
{
name: 'aq_mode',
type: 'string',
defaultValue: '2*',
inputUI: {
type: 'dropdown',
options: [
'0',
'1',
'2*'
],
},
tooltip: 'Adaptive Quantization. (default: 2) 0 = Off (fastest), 1 = Variance AQ (better quality, minor speed loss), 2 = DeltaQ AQ (best quality, 1020% slower).',
},
{
name: 'lookahead',
type: 'string',
defaultValue: '-1*',
inputUI: {
type: 'dropdown',
options: [
'-1*',
'0',
'60',
'90',
'120'
],
},
tooltip: 'Lookahead frames. (default: -1) 0 = Off (fastest), -1 = Auto (good compromise), higher = better quality, slower encoding.',
},
{
name: 'enable_tf',
type: 'string',
defaultValue: '1*',
inputUI: {
type: 'dropdown',
options: [
'0',
'1*'
],
},
tooltip: 'Temporal Filtering. (default: 1) 0 = Off (fastest), 1 = On (better noise reduction/quality, ~1525% slower).',
},
{
name: 'threads',
type: 'string',
defaultValue: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'12',
'16',
'24',
'32'
],
},
tooltip: 'Number of encoding threads. 0 = Auto (use all cores, recommended). SVT-AV1 scales well with more threads.',
},
{
name: 'keyint',
type: 'string',
defaultValue: '-2*',
inputUI: {
type: 'dropdown',
options: [
'-2*',
'-1',
'120',
'240',
'360',
'480',
'600',
'720',
'900',
'1200'
],
},
tooltip: 'Keyframe interval. (default: -2 ≈5s) -2=~5 seconds, -1=infinite (CRF only), higher = smaller files but worse seeking; lower = better quality/seeking, larger files.',
},
{
name: 'hierarchical_levels',
type: 'string',
defaultValue: '4*',
inputUI: {
type: 'dropdown',
options: [
'2',
'3',
'4*',
'5'
],
},
tooltip: 'Hierarchical levels: 2=3 temporal layers, 3=4 temporal layers, 4=5 temporal layers (recommended), 5=6 temporal layers. Controls GOP structure complexity.',
},
{
name: 'film_grain',
type: 'string',
defaultValue: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'1',
'5',
'10',
'15',
'20',
'25',
'30',
'35',
'40',
'45',
'50'
],
},
tooltip: 'Film grain synthesis: 0 = Off (fastest), 150 = denoising level (slower, more natural grain).',
},
{
name: 'input_depth',
type: 'string',
defaultValue: '10*',
inputUI: {
type: 'dropdown',
options: [
'8',
'10*'
],
},
tooltip: 'Output bit depth: 8 = faster encoding, 10 = better quality (prevents banding), ~10-20% slower. Recommended: 10-bit for high-quality sources.',
},
{
name: 'fast_decode',
type: 'string',
defaultValue: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'1'
],
},
tooltip: 'Fast decode optimization. (default: 0) 1 = moderate decode speed improvement, 0 = off (best compression). [v3.0+]',
},
{
name: 'container',
type: 'string',
defaultValue: 'mp4*',
inputUI: {
type: 'dropdown',
options: [
'mp4*',
'mkv',
'webm',
'original'
],
},
tooltip: 'Output container format. "mp4" = best compatibility. "original" keeps input container.',
},
{
name: 'skip_hevc',
type: 'string',
defaultValue: 'enabled*',
inputUI: {
type: 'dropdown',
options: [
'disabled',
'enabled*'
],
},
tooltip: 'Skip HEVC/H.265 files without converting. Useful if you want to handle HEVC files separately or they are already efficient.',
},
{
name: 'force_transcode',
type: 'string',
defaultValue: 'disabled*',
inputUI: {
type: 'dropdown',
options: [
'disabled*',
'enabled'
],
},
tooltip: 'Force transcoding even if the file is already AV1. Useful for changing quality or preset.',
}
],
});
// 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: '',
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
const sanitized = {
crf: stripStar(inputs.crf),
preset: stripStar(inputs.preset),
tune: stripStar(inputs.tune),
scd: stripStar(inputs.scd),
aq_mode: stripStar(inputs.aq_mode),
threads: stripStar(inputs.threads),
keyint: stripStar(inputs.keyint),
hierarchical_levels: stripStar(inputs.hierarchical_levels),
film_grain: stripStar(inputs.film_grain),
input_depth: stripStar(inputs.input_depth),
fast_decode: stripStar(inputs.fast_decode),
lookahead: stripStar(inputs.lookahead),
enable_tf: stripStar(inputs.enable_tf),
container: stripStar(inputs.container),
max_resolution: stripStar(inputs.max_resolution),
resolution_crf_adjust: stripStar(inputs.resolution_crf_adjust),
custom_maxrate: stripStar(inputs.custom_maxrate),
target_bitrate_strategy: stripStar(inputs.target_bitrate_strategy),
skip_hevc: stripStar(inputs.skip_hevc),
force_transcode: stripStar(inputs.force_transcode),
};
// Detect actual input container format via ffprobe
const actualFormatName = file.ffProbeData?.format?.format_name || '';
const looksLikeAppleMp4Family = actualFormatName.includes('mov') || actualFormatName.includes('mp4');
// Detect Apple/broadcast streams that are problematic in MKV or missing codec name
const unsupportedSubtitleIdx = [];
const unsupportedDataIdx = [];
try {
file.ffProbeData.streams.forEach((s, idx) => {
if (s.codec_type === 'subtitle') {
const name = (s.codec_name || '').toLowerCase();
const tag = (s.codec_tag_string || '').toLowerCase();
if (!name) {
// skip subs missing codec_name (e.g., WEBVTT detection failures)
unsupportedSubtitleIdx.push(idx);
} else if (name === 'eia_608' || name === 'cc_dec') {
unsupportedSubtitleIdx.push(idx);
} else if (name === 'tx3g' || tag === 'tx3g') {
// tx3g sometimes shows as timed text in MP4; in mkv it may appear as bin_data
unsupportedSubtitleIdx.push(idx);
}
} else if (s.codec_type === 'data') {
const name = (s.codec_name || '').toLowerCase();
const tag = (s.codec_tag_string || '').toLowerCase();
if (name === 'bin_data' || tag === 'tx3g') {
unsupportedDataIdx.push(idx);
}
}
});
} catch (e) {
// ignore detection errors, continue safely
}
// Check if file is already AV1 and skip if not forcing transcode
const isAV1 = file.ffProbeData.streams.some(stream =>
stream.codec_type === 'video' &&
(stream.codec_name === 'av01' || stream.codec_name === 'av1' || stream.codec_name === 'libsvtav1')
);
if (isAV1 && sanitized.force_transcode !== 'enabled') {
response.processFile = false;
response.infoLog += 'File is already AV1 encoded and force_transcode is disabled. Skipping.\n';
return response;
}
// Check if file is HEVC and skip if skip_hevc is enabled
const isHEVC = file.ffProbeData.streams.some(stream =>
stream.codec_type === 'video' &&
(stream.codec_name === 'hevc' || stream.codec_name === 'h265' || stream.codec_name === 'libx265')
);
if (isHEVC && sanitized.skip_hevc === 'enabled') {
response.processFile = false;
response.infoLog += 'File is HEVC/H.265 encoded and skip_hevc is enabled. Skipping for separate processing.\n';
return response;
}
// Validate video stream exists
const videoStream = file.ffProbeData.streams.find(s => s.codec_type === 'video');
if (!videoStream) {
response.processFile = false;
response.infoLog += 'Error: No video stream found in file. Skipping.\n';
return response;
}
// Use specified preset
const finalPreset = sanitized.preset;
response.infoLog += `Using preset ${finalPreset} (speed-optimized default).\n`;
// Use specified thread count
const threadCount = sanitized.threads;
response.infoLog += `Using ${threadCount} encoding threads.\n`;
// Resolution mapping and downscaling logic
const resolutionMap = {
'480p': 480,
'720p': 720,
'1080p': 1080,
'1440p': 1440,
'2160p': 2160
};
// videoStream was validated and assigned earlier (after HEVC skip check)
let scaleFilter = '';
let outputHeight = null;
// Detect HDR metadata for color preservation
let hdrArgs = '';
const colorTransfer = videoStream.color_transfer || '';
const colorPrimaries = videoStream.color_primaries || '';
const colorSpace = videoStream.color_space || '';
// Check for HDR10, HLG, or PQ transfer characteristics
const isHDR10 = colorTransfer === 'smpte2084'; // PQ
const isHLG = colorTransfer === 'arib-std-b67'; // HLG
const isHDR = (isHDR10 || isHLG) && (
colorPrimaries === 'bt2020' ||
colorSpace === 'bt2020nc' ||
colorSpace === 'bt2020c'
);
if (isHDR) {
// Preserve HDR color metadata
hdrArgs = ` -colorspace ${colorSpace || 'bt2020nc'} -color_trc ${colorTransfer || 'smpte2084'} -color_primaries ${colorPrimaries || 'bt2020'}`;
response.infoLog += `HDR content detected (${colorTransfer}/${colorPrimaries}), preserving color metadata.\n`;
}
if (videoStream && videoStream.height && sanitized.max_resolution !== 'none') {
const inputHeight = videoStream.height;
const maxHeight = resolutionMap[sanitized.max_resolution];
if (maxHeight && inputHeight > maxHeight) {
// Downscale needed - use scale filter with -2 to maintain aspect ratio and ensure even dimensions
outputHeight = maxHeight;
scaleFilter = `-vf "scale=-2:${maxHeight}"`;
response.infoLog += `Downscaling from ${inputHeight}p to ${maxHeight}p while maintaining aspect ratio.\n`;
} else if (maxHeight) {
// Input is already at or below max resolution
outputHeight = inputHeight;
response.infoLog += `Input resolution ${inputHeight}p is within max limit of ${maxHeight}p, no downscaling needed.\n`;
} else {
// No max resolution set
outputHeight = inputHeight;
}
} else if (videoStream && videoStream.height) {
// No max resolution constraint
outputHeight = videoStream.height;
}
// Resolution-based CRF adjustment (applies to OUTPUT resolution after any downscaling)
let finalCrf = sanitized.crf;
if (sanitized.resolution_crf_adjust === 'enabled' && outputHeight) {
const baseCrf = parseInt(sanitized.crf);
// Validate CRF is a valid number
if (isNaN(baseCrf) || baseCrf < 0 || baseCrf > 63) {
response.infoLog += `Warning: Invalid CRF value "${sanitized.crf}", using default.\n`;
finalCrf = '26';
} else {
if (outputHeight >= 2160) { // 4K
finalCrf = Math.min(63, baseCrf + 2).toString();
response.infoLog += `4K output resolution detected, CRF adjusted from ${sanitized.crf} to ${finalCrf}.\n`;
} else if (outputHeight <= 720) { // 720p or lower
finalCrf = Math.max(1, baseCrf - 2).toString();
response.infoLog += `720p or lower output resolution detected, CRF adjusted from ${sanitized.crf} to ${finalCrf}.\n`;
} else {
response.infoLog += `1080p output resolution detected, using base CRF ${finalCrf}.\n`;
}
}
} else if (sanitized.resolution_crf_adjust === 'enabled') {
response.infoLog += `Resolution-based CRF adjustment enabled but video height not detected, using base CRF ${finalCrf}.\n`;
}
// Build SVT-AV1 parameters string
const svtParams = [
`preset=${finalPreset}`,
`tune=${sanitized.tune}`,
`scd=${sanitized.scd}`,
`aq-mode=${sanitized.aq_mode}`,
`lp=${threadCount}`,
`keyint=${sanitized.keyint}`,
`hierarchical-levels=${sanitized.hierarchical_levels}`,
`film-grain=${sanitized.film_grain}`,
`input-depth=${sanitized.input_depth}`,
`fast-decode=${sanitized.fast_decode}`,
`lookahead=${sanitized.lookahead}`,
`enable-tf=${sanitized.enable_tf}`
].join(':');
// Set up FFmpeg arguments for CRF quality control with fixed qmin/qmax
let qualityArgs = `-crf ${finalCrf} -qmin 10 -qmax 50`;
let bitrateControlInfo = `Using CRF mode with value ${finalCrf}`;
// Explicitly set pixel format for 10-bit to ensure correct encoding
if (sanitized.input_depth === '10') {
qualityArgs += ' -pix_fmt yuv420p10le';
response.infoLog += `10-bit encoding enabled with yuv420p10le pixel format.\n`;
}
// Source bitrate detection for target_bitrate_strategy
let sourceBitrateKbps = null;
if (videoStream) {
// Try to get bitrate from video stream first
if (videoStream.bit_rate) {
sourceBitrateKbps = Math.round(parseInt(videoStream.bit_rate) / 1000);
response.infoLog += `Detected video stream bitrate: ${sourceBitrateKbps}k.\n`;
} else if (file.ffProbeData?.format?.bit_rate) {
// Fall back to overall file bitrate
sourceBitrateKbps = Math.round(parseInt(file.ffProbeData.format.bit_rate) / 1000);
response.infoLog += `Detected file bitrate (video stream bitrate unavailable): ${sourceBitrateKbps}k.\n`;
}
}
// Calculate target maxrate using precedence logic
let calculatedMaxrate = null;
let maxrateSource = '';
// Priority 1: target_bitrate_strategy (if not static)
if (sanitized.target_bitrate_strategy !== 'static') {
if (sourceBitrateKbps) {
let multiplier = 1.0;
switch (sanitized.target_bitrate_strategy) {
case 'match_source':
multiplier = 1.0;
break;
case '75%_source':
multiplier = 0.75;
break;
case '50%_source':
multiplier = 0.50;
break;
case '33%_source':
multiplier = 0.33;
break;
case '25%_source':
multiplier = 0.25;
break;
}
calculatedMaxrate = Math.round(sourceBitrateKbps * multiplier);
maxrateSource = `target_bitrate_strategy '${sanitized.target_bitrate_strategy}': Source ${sourceBitrateKbps}k → Maxrate ${calculatedMaxrate}k`;
response.infoLog += `Using ${maxrateSource}.\n`;
} else {
response.infoLog += `Warning: target_bitrate_strategy '${sanitized.target_bitrate_strategy}' selected but source bitrate unavailable. Falling back to static mode.\n`;
}
}
// Priority 2: custom_maxrate (if strategy is static or failed)
if (!calculatedMaxrate && sanitized.custom_maxrate && sanitized.custom_maxrate !== '' && sanitized.custom_maxrate !== '0') {
const customValue = parseInt(sanitized.custom_maxrate);
if (!isNaN(customValue) && customValue > 0) {
calculatedMaxrate = customValue;
maxrateSource = `custom_maxrate: ${calculatedMaxrate}k`;
response.infoLog += `Using ${maxrateSource}.\n`;
} else {
response.infoLog += `Warning: Invalid custom_maxrate value '${sanitized.custom_maxrate}'. Using uncapped CRF.\n`;
}
}
// Apply calculated maxrate if any method succeeded
// Enforce minimum bitrate threshold to prevent unusable output (resolution-aware)
const getMinBitrate = (height) => {
if (height >= 2160) return 2000; // 4K
if (height >= 1440) return 1500; // 1440p
if (height >= 1080) return 800; // 1080p
if (height >= 720) return 500; // 720p
return 250; // 480p and below
};
const minBitrate = getMinBitrate(outputHeight || 1080);
if (calculatedMaxrate && calculatedMaxrate < minBitrate) {
response.infoLog += `Warning: Calculated maxrate ${calculatedMaxrate}k is below minimum for ${outputHeight || 1080}p. Raising to ${minBitrate}k.\n`;
calculatedMaxrate = minBitrate;
}
if (calculatedMaxrate) {
const bufsize = Math.round(calculatedMaxrate * 2.0); // Buffer size = 2.0x maxrate for stability
qualityArgs += ` -maxrate ${calculatedMaxrate}k -bufsize ${bufsize}k`;
bitrateControlInfo += ` with capped bitrate at ${calculatedMaxrate}k (bufsize: ${bufsize}k)`;
response.infoLog += `Capped CRF enabled: Max bitrate ${calculatedMaxrate}k, buffer size ${bufsize}k for optimal bandwidth management.\n`;
} else {
response.infoLog += `Using uncapped CRF for maximum quality efficiency.\n`;
}
// Add tile options for 4K content (improves parallel encoding/decoding)
let tileArgs = '';
if (outputHeight && outputHeight >= 2160) {
// 4K: 2x1 tiles = 3 tiles total (better parallelism for 4K encoding)
tileArgs = ':tile-columns=2:tile-rows=1';
response.infoLog += '4K content: Adding tile-columns=2, tile-rows=1 for improved parallelism.\n';
} else if (outputHeight && outputHeight >= 1440) {
// 1440p: 1x0 tiles = 2 tiles total (balanced for 1440p)
tileArgs = ':tile-columns=1:tile-rows=0';
response.infoLog += '1440p content: Adding tile-columns=1 for balanced parallelism.\n';
}
// 1080p and below: No tiles (overhead not worth it)
// Build mapping with per-stream exclusions if needed
let mapArgs = '-map 0';
const hasUnsupportedStreams = unsupportedSubtitleIdx.length > 0 || unsupportedDataIdx.length > 0;
if (hasUnsupportedStreams) {
[...unsupportedSubtitleIdx, ...unsupportedDataIdx].forEach((idx) => {
mapArgs += ` -map -0:${idx}`;
});
response.infoLog += `Excluding unsupported streams from mapping: subtitles[${unsupportedSubtitleIdx.join(', ')}] data[${unsupportedDataIdx.join(', ')}].\n`;
}
// Set up FFmpeg arguments for AV1 SVT conversion
// Use explicit stream mapping instead of -dn to handle data streams precisely
const svtParamsWithTiles = svtParams + tileArgs;
response.preset = `<io>${scaleFilter ? ' ' + scaleFilter : ''} -c:v libsvtav1 ${qualityArgs}${hdrArgs} -svtav1-params "${svtParamsWithTiles}" -c:a copy -c:s copy ${mapArgs}`;
// Set container with Apple-specific handling
// If user asked for MKV but input is MP4/MOV family and has unsupported streams, prefer MP4 to avoid mux errors
if (sanitized.container === 'original') {
response.container = `.${file.container}`;
if (looksLikeAppleMp4Family && response.container === '.mkv' && hasUnsupportedStreams) {
response.infoLog += 'Detected MP4/MOV input with Apple/broadcast streams; overriding output container to .mp4 to preserve compatibility.\n';
response.container = '.mp4';
}
} else {
response.container = `.${sanitized.container}`;
// WebM container validation - warn about potential compatibility
if (sanitized.container === 'webm') {
response.infoLog += 'Note: WebM container selected. Ensure audio is Opus/Vorbis for full compatibility.\n';
if (hasUnsupportedStreams) {
response.infoLog += 'Warning: WebM does not support all subtitle formats. Subtitles may be dropped.\n';
}
}
// MKV container handling with user warning
if (sanitized.container === 'mkv' && (looksLikeAppleMp4Family || hasUnsupportedStreams)) {
response.infoLog += 'Warning: MKV requested but file has Apple/broadcast streams that may cause issues. Consider using MP4 container.\n';
// Don't force override - let user decide, just warn
}
}
response.ffmpegMode = true;
response.handbrakeMode = false;
response.reQueueAfter = true;
response.processFile = true;
if (isAV1) {
response.infoLog += `File is AV1 but force transcoding is enabled. ${bitrateControlInfo}.\n`;
} else if (isHEVC) {
response.infoLog += `Converting HEVC to AV1. ${bitrateControlInfo}.\n`;
} else {
response.infoLog += `Converting ${file.ffProbeData.streams.find(s => s.codec_type === 'video')?.codec_name || 'unknown'} to AV1. ${bitrateControlInfo}.\n`;
}
response.infoLog += `Using SVT-AV1 preset: ${finalPreset}, tune: ${sanitized.tune} (VQ-optimized when 0), threads: ${threadCount}\n`;
response.infoLog += `Encoding params - SCD: ${sanitized.scd}, AQ: ${sanitized.aq_mode}, Lookahead: ${sanitized.lookahead}, TF: ${sanitized.enable_tf}\n`;
response.infoLog += `Quality control - CRF: ${finalCrf}, Fixed QMin: 10, Fixed QMax: 50, Film grain: ${sanitized.film_grain}\n`;
response.infoLog += `Output container: ${response.container}\n`;
return response;
} catch (error) {
// Comprehensive error handling
response.processFile = false;
response.preset = '';
response.container = `.${file.container || 'mkv'}`;
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;

View File

@@ -0,0 +1,878 @@
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;

View File

@@ -0,0 +1,350 @@
/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_misc_fixes',
Stage: 'Pre-processing',
Name: 'Misc Fixes',
Type: 'Video',
Operation: 'Transcode',
Description: `
A consolidated 'Megamix' of fixes for common video file issues.
Combines functionality from Migz Remux, Migz Image Removal, Lmg1 Reorder, and custom timestamp fixes.
Features:
- Fixes timestamps for TS/AVI/MPG files
- Optional TS audio recovery: extract + transcode audio to AAC for compatibility
- Remuxes to target container (MKV/MP4)
- Conforms streams to container (drops incompatible subtitles)
- Removes unwanted image streams (MJPEG/PNG/GIF)
- Ensures Video stream is ordered first
Should be placed FIRST in your plugin stack.
`,
Version: '2.8',
Tags: 'action,ffmpeg,ts,remux,fix,megamix',
Inputs: [
{
name: 'target_container',
type: 'string',
defaultValue: 'mkv',
inputUI: {
type: 'dropdown',
options: ['mkv', 'mp4'],
},
tooltip: 'Target container format',
},
{
name: 'force_conform',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: ['true*', 'false'],
},
tooltip: 'Drop streams incompatible with the target container (e.g. mov_text in MKV)',
},
{
name: 'remove_image_streams',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: ['true*', 'false'],
},
tooltip: 'Remove MJPEG, PNG, and GIF video streams (often cover art or spam)',
},
{
name: 'ensure_video_first',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: ['true*', 'false'],
},
tooltip: 'Reorder streams so Video is first, then Audio, then Subtitles',
},
{
name: 'fix_ts_timestamps',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: ['true*', 'false'],
},
tooltip: 'Apply special timestamp fixes for TS/AVI/MPG files (-fflags +genpts)',
},
{
name: 'ts_audio_recovery',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: ['false', 'true'],
},
tooltip: 'TS files only: Extract and transcode audio to AAC for compatibility. Ignored for non-TS files.',
},
],
});
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
// 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;
};
// Initialize response first for error handling
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: true,
reQueueAfter: true,
infoLog: '',
};
try {
inputs = lib.loadDefaultValues(inputs, details);
// Sanitize UI-starred defaults
Object.keys(inputs).forEach((key) => {
inputs[key] = stripStar(inputs[key]);
});
// Input validation
const VALID_CONTAINERS = ['mkv', 'mp4'];
const VALID_BOOLEAN = ['true', 'false'];
if (!VALID_CONTAINERS.includes(inputs.target_container)) {
response.infoLog += `❌ Invalid target_container: ${inputs.target_container}. `;
return response;
}
const booleanInputs = [
'force_conform',
'remove_image_streams',
'ensure_video_first',
'fix_ts_timestamps',
'ts_audio_recovery',
];
// eslint-disable-next-line no-restricted-syntax
for (const input of booleanInputs) {
const val = String(inputs[input]).toLowerCase();
if (!VALID_BOOLEAN.includes(val)) {
response.infoLog += `❌ Invalid ${input}: must be true or false. `;
return response;
}
inputs[input] = val; // Normalize to lowercase string
}
if (!Array.isArray(file.ffProbeData?.streams)) {
response.infoLog += '❌ No stream data available. ';
return response;
}
// --- Logic Setup (needed for skip checks below) ---
const targetContainer = inputs.target_container;
const currentContainer = file.container.toLowerCase();
const isTargetMkv = targetContainer === 'mkv';
const isTargetMp4 = targetContainer === 'mp4';
// Skip ISO/DVD files - these require specialized tools like HandBrake or MakeMKV
// These files often have corrupt MPEG-PS streams that cannot be reliably remuxed
if (['iso', 'vob', 'evo'].includes(currentContainer)) {
response.infoLog += '⚠️ ISO/DVD files require manual conversion with HandBrake or MakeMKV. Skipping automated processing.\n';
response.processFile = false;
return response;
}
// Skip TS files with severe timestamp corruption that cannot be fixed
// These files have missing or corrupt timestamps that FFmpeg cannot regenerate
if (['ts', 'mpegts', 'm2ts'].includes(currentContainer)) {
const hasCorruptStreams = file.ffProbeData.streams.some(s => {
// Check for audio streams with 0 channels (corrupt)
if (s.codec_type === 'audio' && s.channels === 0) return true;
// Check for streams missing duration (severe timestamp issues)
if (s.codec_type === 'video' && !s.duration && !s.duration_ts) return true;
return false;
});
if (hasCorruptStreams) {
response.infoLog += '⚠️ TS file has corrupt streams with unfixable timestamp issues. Skipping automated processing.\n';
response.infoLog += ' Consider manual conversion with HandBrake or re-recording the source.\n';
response.processFile = false;
return response;
}
}
// --- Stream Analysis ---
// Track actions
let needsRemux = currentContainer !== targetContainer;
let droppingStreams = false;
const extraMaps = []; // For negative mapping (-map -0:x)
let genptsFlags = '';
let codecFlags = '-c copy';
// --- 1. Timestamp Fixes (Migz + Custom) ---
if (inputs.fix_ts_timestamps === 'true') {
const brokenTypes = ['ts', 'mpegts', 'avi', 'mpg', 'mpeg'];
if (brokenTypes.includes(currentContainer)) {
if (['ts', 'mpegts'].includes(currentContainer)) {
// Enhanced TS timestamp fixes: generate PTS for all streams, handle negative timestamps
// Use genpts+igndts to regenerate timestamps where missing
// -copyts preserves existing timestamps, genpts fills in gaps
// make_zero handles negative timestamps by shifting to start at 0
// Note: For severely broken TS files with completely missing timestamps,
// transcoding (not copy) may be required as genpts only works for video streams
genptsFlags = '-fflags +genpts+igndts -avoid_negative_ts make_zero -copyts';
response.infoLog += '✅ Applying TS timestamp fixes. ';
needsRemux = true;
} else {
genptsFlags = '-fflags +genpts';
response.infoLog += `✅ Applying ${currentContainer} timestamp fixes. `;
needsRemux = true;
}
}
}
// --- 1b. Optional TS audio extraction + AAC transcode for compatibility ---
if (inputs.ts_audio_recovery === 'true') {
if (['ts', 'mpegts'].includes(currentContainer)) {
// Determine a sane AAC bitrate: preserve multichannel without starving
const firstAudio = file.ffProbeData.streams.find((s) => s.codec_type === 'audio');
const audioChannels = firstAudio?.channels || 2;
const audioBitrate = audioChannels > 2 ? '384k' : '192k';
codecFlags = `-c:v copy -c:a aac -b:a ${audioBitrate} -c:s copy -c:d copy -c:t copy`;
response.infoLog += `🎧 TS audio recovery enabled: extracting and transcoding audio to AAC (${audioChannels}ch -> ${audioBitrate}). `;
needsRemux = true;
} else {
response.infoLog += ' TS audio recovery enabled but file is not TS format, skipping. ';
}
}
// --- 2. Stream Sorting & Conform Loop ---
// Check if reordering is actually needed
const firstStreamIsVideo = file.ffProbeData.streams[0]?.codec_type === 'video';
const needsReorder = inputs.ensure_video_first === 'true' && !firstStreamIsVideo;
// Start with base map
let baseMap = '-map 0';
if (needsReorder) {
// Force order: Video -> Audio -> Subs -> Data -> Attachments
baseMap = '-map 0:v? -map 0:a? -map 0:s? -map 0:d? -map 0:t?';
}
// Loop streams to find things to DROP
for (let i = 0; i < file.ffProbeData.streams.length; i++) {
const stream = file.ffProbeData.streams[i];
const codec = (stream.codec_name || '').toLowerCase();
const type = (stream.codec_type || '').toLowerCase();
// A. Image Format Removal
if (inputs.remove_image_streams === 'true' && type === 'video') {
// Check for image codecs or attached pictures (excluding BMP - handled in container-specific logic)
const isAttachedPic = stream.disposition?.attached_pic === 1;
if (['mjpeg', 'png', 'gif'].includes(codec) || (isAttachedPic && !['bmp'].includes(codec))) {
extraMaps.push(`-map -0:${i}`);
response.infoLog += ` Removing image stream ${i} (${codec}${isAttachedPic ? ', attached pic' : ''}). `;
droppingStreams = true;
}
}
// B. Invalid Audio Stream Detection
// Skip audio streams with invalid parameters (0 channels, no sample rate, etc.)
if (type === 'audio') {
const channels = stream.channels || 0;
const sampleRate = stream.sample_rate || 0;
// Check for invalid audio streams (common in ISO/DVD sources)
if (channels === 0 || sampleRate === 0 || !codec || codec === 'unknown') {
extraMaps.push(`-map -0:${i}`);
response.infoLog += ` Dropping invalid audio stream ${i} (${codec || 'unknown'}, ${channels}ch, ${sampleRate}Hz). `;
droppingStreams = true;
continue; // Skip further checks for this stream
}
}
// C. Force Conform (Container Compatibility)
if (inputs.force_conform === 'true') {
if (isTargetMkv) {
// Migz logic for MKV: Drop mov_text, eia_608, timed_id3, data, and BMP (not supported)
if (['mov_text', 'eia_608', 'timed_id3', 'bmp'].includes(codec) || type === 'data') {
extraMaps.push(`-map -0:${i}`);
response.infoLog += ` Dropping incompatible stream ${i} (${codec}) for MKV. `;
droppingStreams = true;
}
} else if (isTargetMp4) {
// Migz logic for MP4: Drop hdmv_pgs_subtitle, eia_608, subrip, timed_id3
// Note: keeping 'subrip' drop to be safe per Migz, though some mp4s file allow it.
if (['hdmv_pgs_subtitle', 'eia_608', 'subrip', 'timed_id3'].includes(codec)) {
extraMaps.push(`-map -0:${i}`);
response.infoLog += ` Dropping incompatible stream ${i} (${codec}) for MP4. `;
droppingStreams = true;
}
}
}
}
// --- 3. Decision Time ---
// Reorder check was done earlier (line 198), apply to needsRemux if needed
if (needsReorder) {
response.infoLog += '✅ Reordering streams (Video first). ';
needsRemux = true;
}
if (needsRemux || droppingStreams) {
// Construct command
// Order: <genpts> <input> <baseMap> <extraMaps> <copy> <target>
const cmdParts = [];
if (genptsFlags) cmdParts.push(genptsFlags);
cmdParts.push(baseMap);
if (extraMaps.length > 0) cmdParts.push(extraMaps.join(' '));
cmdParts.push(codecFlags);
cmdParts.push('-max_muxing_queue_size 9999');
response.preset = `<io> ${cmdParts.join(' ')}`;
response.container = `.${targetContainer}`;
response.processFile = true;
// Log conversion reason
if (currentContainer !== targetContainer) {
response.infoLog += `✅ Remuxing ${currentContainer} to ${targetContainer}. `;
}
return response;
}
response.infoLog += '☑️ File meets all criteria. ';
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;

View File

@@ -0,0 +1,776 @@
const details = () => ({
id: 'Tdarr_Plugin_stream_organizer',
Stage: 'Pre-processing',
Name: 'Stream Organizer',
Type: 'Video',
Operation: 'Transcode',
Description: `
Organizes streams by language priority (English/custom codes first).
Converts text-based subtitles to SRT format and/or extracts them to external files.
Handles closed captions (eia_608/cc_dec) via CCExtractor.
All other streams are preserved in their original relative order.
WebVTT subtitles are always converted to SRT for compatibility.
`,
Version: '4.8',
Tags: 'action,subtitles,srt,extract,organize,language',
Inputs: [
{
name: 'includeAudio',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: [
'true*',
'false'
],
},
tooltip: 'Enable to reorder audio streams, putting English audio first',
},
{
name: 'includeSubtitles',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: [
'true*',
'false'
],
},
tooltip: 'Enable to reorder subtitle streams, putting English subtitles first',
},
{
name: 'standardizeToSRT',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: [
'true*',
'false'
],
},
tooltip: 'Convert text-based subtitles (ASS/SSA/WebVTT) to SRT format. Image subtitles (PGS/VobSub) will be copied.',
},
{
name: 'extractSubtitles',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: [
'false',
'true'
],
},
tooltip: 'Extract subtitle streams to external .srt files alongside the video',
},
{
name: 'removeAfterExtract',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: [
'false',
'true'
],
},
tooltip: 'Remove embedded subtitles after extracting them (only applies if Extract is enabled)',
},
{
name: 'skipCommentary',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: [
'true*',
'false'
],
},
tooltip: 'Skip extracting subtitles with "commentary" or "description" in the title',
},
{
name: 'setDefaultFlags',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: [
'false',
'true'
],
},
tooltip: 'Set default disposition flag on first English audio and subtitle streams',
},
{
name: 'customLanguageCodes',
type: 'string',
defaultValue: 'eng,en,english,en-us,en-gb,en-ca,en-au',
inputUI: {
type: 'text',
},
tooltip: 'Comma-separated list of language codes to consider as priority (max 20 codes). Default includes common English codes.',
},
{
name: 'useCCExtractor',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: [
'false',
'true'
],
},
tooltip: 'If enabled, attempts to extract closed captions (eia_608/cc_dec) to external SRT via ccextractor when present.',
},
{
name: 'embedExtractedCC',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: [
'false',
'true'
],
},
tooltip: 'If enabled, will map the newly extracted CC SRT back into the output container.',
},
],
});
const TEXT_SUBTITLE_CODECS = new Set(['ass', 'ssa', 'webvtt', 'mov_text', 'text', 'subrip']);
const IMAGE_SUBTITLE_CODECS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
const PROBLEMATIC_CODECS = new Set(['webvtt']);
const UNSUPPORTED_SUBTITLE_CODECS = new Set(['eia_608', 'cc_dec', 'tx3g']);
const VALID_BOOLEAN_VALUES = ['true', 'false'];
const MAX_LANGUAGE_CODES = 20;
const MIN_SUBTITLE_FILE_SIZE = 100; // bytes - minimum size for valid subtitle file
const MAX_EXTRACTION_ATTEMPTS = 3; // maximum attempts to extract a subtitle before giving up
const MAX_FILENAME_ATTEMPTS = 100; // maximum attempts to find unique filename
const isUnsupportedSubtitle = (stream) => {
const name = (stream.codec_name || '').toLowerCase();
const tag = (stream.codec_tag_string || '').toLowerCase();
return UNSUPPORTED_SUBTITLE_CODECS.has(name) || UNSUPPORTED_SUBTITLE_CODECS.has(tag);
};
const isClosedCaption = (stream) => {
const name = (stream.codec_name || '').toLowerCase();
const tag = (stream.codec_tag_string || '').toLowerCase();
return name === 'eia_608' || name === 'cc_dec' || tag === 'cc_dec';
};
const isEnglishStream = (stream, englishCodes) => {
const language = stream.tags?.language?.toLowerCase();
return language && englishCodes.includes(language);
};
const isTextSubtitle = (stream) => TEXT_SUBTITLE_CODECS.has(stream.codec_name);
const needsSRTConversion = (stream) => isTextSubtitle(stream) && stream.codec_name !== 'subrip';
const isProblematicSubtitle = (stream) => PROBLEMATIC_CODECS.has(stream.codec_name);
const shouldSkipSubtitle = (stream, skipCommentary) => {
if (skipCommentary !== 'true') return false;
const title = stream.tags?.title?.toLowerCase() || '';
return title.includes('commentary') || title.includes('description');
};
// Helper to check if any processing is needed
const needsProcessing = (needsReorder, needsConversion, extractCount, ccActuallyExtracted, ccExtractedFile, embedExtractedCC) => {
return needsReorder || needsConversion || extractCount > 0 || ccActuallyExtracted || (ccExtractedFile && embedExtractedCC === 'true');
};
const partitionStreams = (streams, predicate) => {
const matched = [];
const unmatched = [];
streams.forEach(s => (predicate(s) ? matched : unmatched).push(s));
return [matched, unmatched];
};
const buildSafeBasePath = (filePath) => {
const parsed = require('path').parse(filePath);
return require('path').join(parsed.dir, parsed.name);
};
/**
* Robust file existence check
* Uses fs.statSync to avoid caching issues with fs.existsSync
*/
const fileExistsRobust = (filePath, fs) => {
try {
const stats = fs.statSync(filePath);
// Verify file is not empty (sometimes extraction fails silently)
return stats.size > 0;
} catch (e) {
if (e.code === 'ENOENT') {
return false;
}
// Re-throw other errors (permission issues, etc)
throw new Error(`Error checking file existence for ${filePath}: ${e.message}`);
}
};
/**
* Check if subtitle file needs extraction
* Handles cases where file exists but is incomplete or outdated
*/
const needsSubtitleExtraction = (subsFile, sourceFile, fs) => {
// Check if file exists using robust method
if (!fileExistsRobust(subsFile, fs)) {
return true; // File doesn't exist, needs extraction
}
try {
const subsStats = fs.statSync(subsFile);
// If subtitle file is very small, it might be incomplete
if (subsStats.size < MIN_SUBTITLE_FILE_SIZE) {
return true; // Re-extract
}
// NOTE: We removed mtime comparison because:
// 1. During requeue, the "source" is a cache file with current timestamp
// 2. This always triggers re-extraction even when subs already exist
// 3. Size check is sufficient to detect incomplete extractions
return false; // Subtitle exists and has valid size
} catch (e) {
// If any error checking stats, assume needs extraction
return true;
}
};
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// Sanitization utilities (self-contained, no external libs)
// 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 string for safe shell usage (for FFmpeg output files)
// Use double quotes which work better with FFmpeg and Tdarr's command construction
const sanitizeForShell = (str) => {
if (typeof str !== 'string') {
throw new TypeError('Input must be a string');
}
// Remove null bytes
str = str.replace(/\0/g, '');
// Use double quotes and escape any double quotes, backslashes, and dollar signs
// This works better with FFmpeg and Tdarr's command parsing
// Example: file"name becomes "file\"name"
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
};
// Sanitize filename to remove dangerous characters
const sanitizeFilename = (name, maxLength = 100) => {
if (typeof name !== 'string') {
return 'file';
}
// Force extraction of basename (prevents directory traversal)
name = path.basename(name);
// Remove dangerous characters
name = name.replace(/[<>:"|?*\/\\\x00-\x1f\x7f]/g, '_');
// Remove leading/trailing dots and spaces
name = name.replace(/^[.\s]+|[.\s]+$/g, '');
// Ensure not empty
if (name.length === 0) {
name = 'file';
}
// Limit length
if (name.length > maxLength) {
const ext = path.extname(name);
const base = path.basename(name, ext);
name = base.substring(0, maxLength - ext.length) + ext;
}
return name;
};
// Validate and sanitize language codes
const validateLanguageCodes = (codesString, maxCodes = 20) => {
if (typeof codesString !== 'string') {
return [];
}
return codesString
.split(',')
.map(code => code.trim().toLowerCase())
.filter(code => {
// Validate format
if (code.length === 0 || code.length > 10) return false;
if (!/^[a-z0-9-]+$/.test(code)) return false;
// Prevent path traversal
if (code.includes('..') || code.includes('/')) return false;
return true;
})
.slice(0, maxCodes);
};
// Initialize response first for error handling
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: true,
reQueueAfter: false,
infoLog: '',
};
try {
inputs = lib.loadDefaultValues(inputs, details);
// Sanitize starred defaults
Object.keys(inputs).forEach(key => {
inputs[key] = stripStar(inputs[key]);
});
// Input validation
const validateInputs = (inputs) => {
const errors = [];
const booleanInputs = [
'includeAudio',
'includeSubtitles',
'standardizeToSRT',
'extractSubtitles',
'removeAfterExtract',
'skipCommentary',
'setDefaultFlags',
'useCCExtractor',
'embedExtractedCC'
];
for (const input of booleanInputs) {
if (!VALID_BOOLEAN_VALUES.includes(inputs[input])) {
errors.push(`Invalid ${input} value - must be "true" or "false"`);
}
}
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;
}
// Validate language codes
const customEnglishCodes = validateLanguageCodes(
inputs.customLanguageCodes,
MAX_LANGUAGE_CODES
);
if (customEnglishCodes.length === 0) {
customEnglishCodes.push('eng', 'en', 'english', 'en-us', 'en-gb', 'en-ca', 'en-au');
}
if (!Array.isArray(file.ffProbeData?.streams)) {
throw new Error('FFprobe was unable to extract any streams info on this file.');
}
// Optimize: Only copy what we need instead of deep cloning entire ffProbeData
const streams = file.ffProbeData.streams.map((stream, index) => ({
...stream,
typeIndex: index
}));
const originalOrder = streams.map(s => s.typeIndex);
const videoStreams = streams.filter(s => s.codec_type === 'video');
const audioStreams = streams.filter(s => s.codec_type === 'audio');
const subtitleStreams = streams.filter(s => s.codec_type === 'subtitle');
// Filter out BMP attached pictures early (incompatible with MKV)
const otherStreams = streams
.filter(s => !['video', 'audio', 'subtitle'].includes(s.codec_type))
.filter(stream => {
if (stream.disposition?.attached_pic === 1 && stream.codec_name === 'bmp') {
response.infoLog += ' Excluding BMP attached picture (unsupported in MKV). ';
return false;
}
return true;
});
let reorderedAudio, reorderedSubtitles;
if (inputs.includeAudio === 'true') {
const [englishAudio, otherAudio] = partitionStreams(audioStreams, s => isEnglishStream(s, customEnglishCodes));
reorderedAudio = [...englishAudio, ...otherAudio];
if (englishAudio.length > 0) {
response.infoLog += `${englishAudio.length} English audio first. `;
}
} else {
reorderedAudio = audioStreams;
}
if (inputs.includeSubtitles === 'true') {
const [englishSubtitles, otherSubtitles] = partitionStreams(subtitleStreams, s => isEnglishStream(s, customEnglishCodes));
reorderedSubtitles = [...englishSubtitles, ...otherSubtitles];
if (englishSubtitles.length > 0) {
response.infoLog += `${englishSubtitles.length} English subs first. `;
}
} else {
reorderedSubtitles = subtitleStreams;
}
const reorderedStreams = [
...videoStreams,
...reorderedAudio,
...reorderedSubtitles,
...otherStreams
];
const newOrder = reorderedStreams.map(s => s.typeIndex);
const needsReorder = JSON.stringify(originalOrder) !== JSON.stringify(newOrder);
let needsConversion = false;
let conversionCount = 0;
const hasProblematicSubs = subtitleStreams.some(isProblematicSubtitle);
if (inputs.standardizeToSRT === 'true' || hasProblematicSubs) {
subtitleStreams.forEach(stream => {
if (!stream.codec_name) return;
if (isUnsupportedSubtitle(stream)) return;
if (needsSRTConversion(stream)) {
needsConversion = true;
conversionCount++;
}
});
}
let extractCommand = '';
let extractCount = 0;
let ccExtractedFile = null;
let ccActuallyExtracted = false;
const extractedFiles = new Set();
const extractionAttempts = new Map(); // Track extraction attempts to prevent infinite loops
if (inputs.extractSubtitles === 'true' && subtitleStreams.length > 0) {
const { originalLibraryFile } = otherArguments;
// CRITICAL: Always use originalLibraryFile.file for extraction paths to avoid infinite loop
// On re-queue, file.file points to cache dir, but we need the original library path
if (!originalLibraryFile?.file) {
response.infoLog += '⚠️ Cannot extract subs: originalLibraryFile not available. ';
} else {
const baseFile = originalLibraryFile.file;
const baseName = buildSafeBasePath(baseFile);
for (const stream of subtitleStreams) {
if (!stream.codec_name) {
response.infoLog += ` Skipping subtitle ${stream.typeIndex} (no codec). `;
continue;
}
if (isUnsupportedSubtitle(stream)) {
response.infoLog += ` Skipping subtitle ${stream.typeIndex} (${stream.codec_name}) incompatible. `;
continue;
}
// Skip bitmap subtitles when extracting to SRT (can't convert bitmap to text)
if (IMAGE_SUBTITLE_CODECS.has(stream.codec_name)) {
response.infoLog += ` Skipping bitmap subtitle ${stream.typeIndex} (${stream.codec_name}) - cannot extract to SRT. `;
continue;
}
if (shouldSkipSubtitle(stream, inputs.skipCommentary)) {
const title = stream.tags?.title || 'unknown';
response.infoLog += ` Skipping ${title}. `;
continue;
}
const lang = stream.tags?.language || 'unknown';
const safeLang = sanitizeFilename(lang).substring(0, 20);
let subsFile = `${baseName}.${safeLang}.srt`;
let counter = 1;
// Find first available filename that hasn't been queued in this run
while (extractedFiles.has(subsFile) && counter < MAX_FILENAME_ATTEMPTS) {
subsFile = `${baseName}.${safeLang}.${counter}.srt`;
counter++;
}
// Check if we actually need to extract using improved detection
if (needsSubtitleExtraction(subsFile, baseFile, fs)) {
// Check extraction attempt count to prevent infinite loops
const attemptKey = `${baseFile}:${stream.typeIndex}`;
const attempts = extractionAttempts.get(attemptKey) || 0;
if (attempts >= MAX_EXTRACTION_ATTEMPTS) {
response.infoLog += `⚠️ Skipping ${path.basename(subsFile)} - extraction failed ${MAX_EXTRACTION_ATTEMPTS} times. `;
continue;
}
// File doesn't exist, is incomplete, or is outdated - extract it
extractionAttempts.set(attemptKey, attempts + 1);
const safeSubsFile = sanitizeForShell(subsFile);
extractCommand += ` -map 0:${stream.typeIndex} ${safeSubsFile}`;
extractedFiles.add(subsFile);
extractCount++;
} else {
// File exists and is valid, skip extraction
response.infoLog += ` ${path.basename(subsFile)} already exists, skipping. `;
}
}
if (extractCount > 0) {
response.infoLog += `✅ Extracting ${extractCount} subtitle(s). `;
}
}
}
if (inputs.useCCExtractor === 'true' && subtitleStreams.some(isClosedCaption)) {
const { originalLibraryFile } = otherArguments;
// CRITICAL: Use originalLibraryFile.file for CC paths to avoid infinite loop
if (!originalLibraryFile?.file) {
response.infoLog += '⚠️ Cannot extract CC: originalLibraryFile not available. ';
} else {
const baseFile = originalLibraryFile.file;
const baseName = buildSafeBasePath(baseFile);
const ccOut = `${baseName}.cc.srt`;
const ccLock = `${ccOut}.lock`;
// Cache file existence check
const ccFileExists = fileExistsRobust(ccOut, fs);
try {
// Try to create lock file atomically to prevent race conditions
fs.writeFileSync(ccLock, process.pid.toString(), { flag: 'wx' });
try {
// We have the lock, check if CC file actually exists
if (ccFileExists) {
response.infoLog += ' CC file exists. ';
if (inputs.embedExtractedCC === 'true') {
ccExtractedFile = ccOut;
ccActuallyExtracted = false;
}
} else {
// Need to extract, keep the lock (will be cleaned up after extraction)
ccExtractedFile = ccOut;
ccActuallyExtracted = true;
response.infoLog += '✅ Will extract CC via ccextractor. ';
}
} finally {
// Only release lock if we're not extracting (extraction command will clean it up)
if (!ccActuallyExtracted && fs.existsSync(ccLock)) {
fs.unlinkSync(ccLock);
}
}
} catch (e) {
if (e.code === 'EEXIST') {
// Another worker has the lock
response.infoLog += '⏭️ CC extraction in progress by another worker. ';
// Check if file exists (other worker may have just finished)
if (ccFileExists && inputs.embedExtractedCC === 'true') {
ccExtractedFile = ccOut;
ccActuallyExtracted = false;
}
} else if (e.code === 'EACCES' || e.code === 'EPERM') {
// Fatal: permission issue
throw new Error(`CC extraction failed: Permission denied - ${e.message}`);
} else {
// Other error - log and continue
response.infoLog += `⚠️ CC lock error: ${e.message}. `;
}
}
}
}
// Use helper function for complex conditional check
if (!needsProcessing(needsReorder, needsConversion, extractCount, ccActuallyExtracted, ccExtractedFile, inputs.embedExtractedCC)) {
response.infoLog += '✅ No changes needed.';
return response;
}
response.processFile = true;
response.reQueueAfter = true;
if (needsReorder) {
response.infoLog += '✅ Reordering streams. ';
}
if (needsConversion) {
if (hasProblematicSubs && inputs.standardizeToSRT !== 'true') {
response.infoLog += `✅ Converting ${conversionCount} WebVTT to SRT (compatibility). `;
} else {
response.infoLog += `✅ Converting ${conversionCount} to SRT. `;
}
}
let command = (inputs.extractSubtitles === 'true' && extractCount > 0) ? '-y <io>' : '<io>';
command += extractCommand;
if (inputs.removeAfterExtract === 'true' && inputs.extractSubtitles === 'true' && extractCount > 0) {
response.infoLog += '✅ Removing embedded subs. ';
// We proceed to build the map, but we'll filter out subs in the loop.
}
// Construct the main mapping command based on reordered streams
command += ' -c:v copy -c:a copy';
const includedSubtitleStreams = [];
let firstEnglishAudioIdx = null;
let firstEnglishSubIdx = null;
let audioOutputIdx = 0;
let subOutputIdx = 0;
reorderedStreams.forEach(stream => {
// If removing subtitles after extract, skip mapping subtitles from source
if (inputs.extractSubtitles === 'true' && inputs.removeAfterExtract === 'true' && stream.codec_type === 'subtitle') {
return;
}
if (stream.codec_type !== 'subtitle') {
command += ` -map 0:${stream.typeIndex}`;
// Track first English audio for default flag
if (stream.codec_type === 'audio' && firstEnglishAudioIdx === null && isEnglishStream(stream, customEnglishCodes)) {
firstEnglishAudioIdx = audioOutputIdx;
}
if (stream.codec_type === 'audio') {
audioOutputIdx++;
}
return;
}
if (!stream.codec_name) {
response.infoLog += ` Skipping map for subtitle ${stream.typeIndex} (no codec). `;
return;
}
if (isUnsupportedSubtitle(stream)) {
response.infoLog += ` Excluding subtitle ${stream.typeIndex} (${stream.codec_name}) for compatibility. `;
return;
}
includedSubtitleStreams.push(stream);
command += ` -map 0:${stream.typeIndex}`;
// Track first English subtitle for default flag
if (firstEnglishSubIdx === null && isEnglishStream(stream, customEnglishCodes)) {
firstEnglishSubIdx = subOutputIdx;
}
subOutputIdx++;
});
const allIncludedAreText = includedSubtitleStreams.length > 0 &&
includedSubtitleStreams.every(s => TEXT_SUBTITLE_CODECS.has(s.codec_name));
const shouldConvertToSRT = (inputs.standardizeToSRT === 'true' || hasProblematicSubs) && allIncludedAreText;
if (includedSubtitleStreams.length > 0) {
if (shouldConvertToSRT) {
command += ' -c:s srt';
} else if (inputs.standardizeToSRT === 'true' && !allIncludedAreText) {
response.infoLog += '✅ Mixed subtitle types; using per-stream codec. ';
includedSubtitleStreams.forEach((stream, idx) => {
if (isTextSubtitle(stream) && stream.codec_name !== 'subrip') {
command += ` -c:s:${idx} srt`;
} else {
command += ` -c:s:${idx} copy`;
}
});
} else if (hasProblematicSubs && !allIncludedAreText) {
includedSubtitleStreams.forEach((stream, idx) => {
if (isProblematicSubtitle(stream)) {
command += ` -c:s:${idx} srt`;
} else {
command += ` -c:s:${idx} copy`;
}
});
} else {
command += ' -c:s copy';
}
}
// Set default flags on first English streams if enabled
if (inputs.setDefaultFlags === 'true') {
if (firstEnglishAudioIdx !== null) {
command += ` -disposition:a:${firstEnglishAudioIdx} default`;
response.infoLog += `✅ Set default flag on English audio. `;
}
if (firstEnglishSubIdx !== null) {
command += ` -disposition:s:${firstEnglishSubIdx} default`;
response.infoLog += `✅ Set default flag on English subtitle. `;
}
}
if (ccExtractedFile && inputs.embedExtractedCC === 'true') {
// Validate CC file exists before attempting to embed (unless we're extracting it in this run)
if (ccActuallyExtracted || fs.existsSync(ccExtractedFile)) {
const safeCCFile = sanitizeForShell(ccExtractedFile);
// calculate index for the new subtitle stream (it will be after all mapped subs)
const newSubIdx = includedSubtitleStreams.length;
command += ` -i "${safeCCFile}" -map 1:0 -c:s:${newSubIdx} srt`;
command += ` -metadata:s:s:${newSubIdx} language=eng`;
command += ` -metadata:s:s:${newSubIdx} title="Closed Captions"`;
response.infoLog += '✅ Embedding extracted CC. ';
} else {
response.infoLog += '⚠️ CC file not found, skipping embed. ';
}
}
if (ccActuallyExtracted) {
const { originalLibraryFile } = otherArguments;
const sourceFile = (originalLibraryFile?.file) || file.file;
const baseName = buildSafeBasePath(sourceFile);
const ccLock = `${baseName}.cc.srt.lock`;
const safeInput = sanitizeForShell(sourceFile);
const safeCCFile = sanitizeForShell(ccExtractedFile);
const safeLock = sanitizeForShell(ccLock);
// Add lock cleanup to command
const cleanupCmd = `rm -f ${safeLock}`;
const ccCmd = `ccextractor ${safeInput} -o ${safeCCFile}`;
response.preset = `${ccCmd}; ${cleanupCmd}; ${command}`;
response.infoLog += ' CC extraction will run before main command. ';
} else {
response.preset = command;
}
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;