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:
757
Local/Tdarr_Plugin_av1_svt_converter.js
Normal file
757
Local/Tdarr_Plugin_av1_svt_converter.js
Normal 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 3–5 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) 24–28 = high quality, 30+ = faster/transcoding. 10–20 = 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), 8–9 = very fast, 3–4 = 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, ~5–10% 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, 10–20% 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, ~15–25% 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), 1–50 = 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;
|
||||
878
Local/Tdarr_Plugin_combined_audio_standardizer.js
Normal file
878
Local/Tdarr_Plugin_combined_audio_standardizer.js
Normal 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;
|
||||
350
Local/Tdarr_Plugin_misc_fixes.js
Normal file
350
Local/Tdarr_Plugin_misc_fixes.js
Normal 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;
|
||||
776
Local/Tdarr_Plugin_stream_organizer.js
Normal file
776
Local/Tdarr_Plugin_stream_organizer.js
Normal 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;
|
||||
Reference in New Issue
Block a user