feat: Sync all plugin versions to 4.0.0 and reorganize Local directory

This commit is contained in:
Tdarr Plugin Developer
2026-01-30 05:55:19 -08:00
parent 5053386ca0
commit b0c7ed3229
11 changed files with 2996 additions and 1996 deletions

View File

@@ -0,0 +1,567 @@
/* eslint-disable no-plusplus */
/**
* Tdarr Plugin 00 - File Audit
*
* Read-only audit plugin that runs first in the pipeline.
* Logs file information and flags potential issues for downstream plugins.
* Makes NO changes to files - pure analysis and reporting.
*/
const details = () => ({
id: 'Tdarr_Plugin_00_file_audit',
Stage: 'Pre-processing',
Name: '00 - File Audit',
Type: 'Video',
Operation: 'Filter',
Description: `
**READ-ONLY** file auditor that logs comprehensive file information and flags potential issues.
Runs FIRST in the pipeline to provide early warning of problems.
**Reports**:
- Container format and compatibility notes for BOTH MKV and MP4
- All streams with codec details
- Potential issues (broken timestamps, incompatible codecs, corrupt streams)
- Standards compliance (HDR, color space, etc.)
**Never modifies files** - Filter type plugin that always passes files through.
`,
Version: '4.0.0',
Tags: 'filter,audit,analysis,diagnostic,pre-check',
Inputs: [
{
name: 'log_level',
type: 'string',
defaultValue: 'detailed*',
inputUI: {
type: 'dropdown',
options: ['minimal', 'detailed*', 'verbose'],
},
tooltip: 'minimal=issues only, detailed=streams+issues, verbose=everything including metadata',
},
],
});
// ============================================================================
// COMPATIBILITY DEFINITIONS
// ============================================================================
// Codecs incompatible with containers
const MKV_INCOMPATIBLE_CODECS = new Set(['mov_text', 'eia_608', 'timed_id3', 'bmp', 'tx3g']);
const MP4_INCOMPATIBLE_CODECS = new Set(['hdmv_pgs_subtitle', 'eia_608', 'subrip', 'timed_id3', 'ass', 'ssa', 'webvtt']);
// Containers with known timestamp issues
const TIMESTAMP_PROBLEM_CONTAINERS = new Set(['ts', 'mpegts', 'avi', 'mpg', 'mpeg', 'm2ts', 'vob']);
// Legacy codecs that often have timestamp/remux issues
const LEGACY_VIDEO_CODECS = {
'mpeg4': { risk: 'high', note: 'MPEG-4 Part 2 - often has timestamp issues' },
'msmpeg4v1': { risk: 'high', note: 'MS-MPEG4v1 - severe timestamp issues' },
'msmpeg4v2': { risk: 'high', note: 'MS-MPEG4v2 - severe timestamp issues' },
'msmpeg4v3': { risk: 'high', note: 'MS-MPEG4v3/DivX3 - severe timestamp issues' },
'mpeg1video': { risk: 'medium', note: 'MPEG-1 - may need re-encoding' },
'mpeg2video': { risk: 'medium', note: 'MPEG-2 - may have GOP issues' },
'wmv1': { risk: 'high', note: 'WMV7 - poor container compatibility' },
'wmv2': { risk: 'high', note: 'WMV8 - poor container compatibility' },
'wmv3': { risk: 'medium', note: 'WMV9 - may have issues in MKV/MP4' },
'rv10': { risk: 'high', note: 'RealVideo 1.0 - very limited support' },
'rv20': { risk: 'high', note: 'RealVideo 2.0 - very limited support' },
'rv30': { risk: 'high', note: 'RealVideo 3.0 - very limited support' },
'rv40': { risk: 'high', note: 'RealVideo 4.0 - very limited support' },
'vp6': { risk: 'medium', note: 'VP6 - legacy Flash codec' },
'vp6f': { risk: 'medium', note: 'VP6 Flash - legacy Flash codec' },
'flv1': { risk: 'medium', note: 'FLV/Sorenson Spark - legacy codec' },
};
// XviD/DivX codec tags that indicate packed bitstream issues
const XVID_DIVX_TAGS = new Set(['XVID', 'DIVX', 'DX50', 'DIV3', 'DIV4', 'DIV5', 'FMP4', 'MP4V', 'MP42', 'MP43']);
// Image codecs (cover art) that should be removed
const IMAGE_CODECS = new Set(['mjpeg', 'png', 'gif', 'bmp', 'webp', 'tiff']);
// Data stream codecs that cause issues
const PROBLEMATIC_DATA_CODECS = new Set(['bin_data', 'timed_id3', 'dvd_nav_packet']);
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
const sanitizeInputs = (inputs) => {
Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); });
return inputs;
};
const formatBitrate = (bps) => {
if (!bps || bps === 0) return 'unknown';
const kbps = Math.round(bps / 1000);
if (kbps >= 1000) return `${(kbps / 1000).toFixed(1)} Mbps`;
return `${kbps} kbps`;
};
const formatDuration = (seconds) => {
if (!seconds) return 'unknown';
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) return `${hrs}h ${mins}m ${secs}s`;
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
};
const formatSize = (bytes) => {
if (!bytes) return 'unknown';
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1) return `${gb.toFixed(2)} GB`;
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(1)} MB`;
};
// ============================================================================
// AUDIT CHECKS
// ============================================================================
/**
* Analyze container format and flag issues
*/
const auditContainer = (file) => {
const issues = [];
const info = [];
const container = (file.container || '').toLowerCase();
const formatName = file.ffProbeData?.format?.format_name || '';
info.push(`Container: ${container.toUpperCase()} (format: ${formatName})`);
// Check for timestamp-problematic containers
if (TIMESTAMP_PROBLEM_CONTAINERS.has(container)) {
issues.push(`⚠️ TIMESTAMP: ${container.toUpperCase()} containers often have timestamp issues requiring -fflags +genpts`);
}
// Check for containers that need special handling
if (['iso', 'vob', 'evo'].includes(container)) {
issues.push(`❌ UNSUPPORTED: ${container.toUpperCase()} requires manual conversion (HandBrake/MakeMKV)`);
}
// Note current container for user reference
if (!['mkv', 'mp4'].includes(container) && !['iso', 'vob', 'evo'].includes(container)) {
info.push(`📦 Current container will need remuxing to MKV or MP4`);
}
return { issues, info };
};
/**
* Analyze video streams
*/
const auditVideoStreams = (streams) => {
const issues = [];
const info = [];
const videoStreams = streams.filter(s => s.codec_type === 'video' && !IMAGE_CODECS.has((s.codec_name || '').toLowerCase()));
if (videoStreams.length === 0) {
issues.push('❌ NO VIDEO: No valid video stream found');
return { issues, info };
}
if (videoStreams.length > 1) {
issues.push(`⚠️ MULTI-VIDEO: ${videoStreams.length} video streams detected (unusual)`);
}
videoStreams.forEach((stream, idx) => {
const codec = (stream.codec_name || 'unknown').toLowerCase();
const codecTag = (stream.codec_tag_string || '').toUpperCase();
const width = stream.width || '?';
const height = stream.height || '?';
const fps = stream.r_frame_rate || stream.avg_frame_rate || '?';
const bitrate = stream.bit_rate || 0;
const pixFmt = stream.pix_fmt || 'unknown';
// Basic info
let streamInfo = `🎬 Video ${idx}: ${codec.toUpperCase()} ${width}x${height}`;
if (fps && fps !== '?') {
const [num, den] = fps.split('/').map(Number);
if (den && den > 0) streamInfo += ` @ ${(num / den).toFixed(2)}fps`;
}
if (bitrate) streamInfo += ` (${formatBitrate(bitrate)})`;
streamInfo += ` [${pixFmt}]`;
info.push(streamInfo);
// Check for legacy codec issues
if (LEGACY_VIDEO_CODECS[codec]) {
const legacy = LEGACY_VIDEO_CODECS[codec];
issues.push(`⚠️ LEGACY (${legacy.risk}): ${legacy.note}`);
}
// Check for XviD/DivX packed bitstream
if (codec === 'mpeg4' && XVID_DIVX_TAGS.has(codecTag)) {
issues.push(`⚠️ XVID/DIVX: ${codecTag} may have packed bitstream timestamp issues`);
}
// Check for divx_packed flag
if (stream.divx_packed === 'true' || stream.divx_packed === true) {
issues.push('❌ PACKED BITSTREAM: DivX packed bitstream detected - will need re-encoding');
}
// HDR detection
const colorTransfer = stream.color_transfer || '';
const colorPrimaries = stream.color_primaries || '';
const colorSpace = stream.color_space || '';
if (colorTransfer === 'smpte2084') {
info.push(' 🌈 HDR10 (PQ) detected - metadata preservation needed');
} else if (colorTransfer === 'arib-std-b67') {
info.push(' 🌈 HLG detected - metadata preservation needed');
}
if (colorPrimaries === 'bt2020' || colorSpace === 'bt2020nc') {
info.push(' 📺 BT.2020 color space detected');
}
// Check for unusual pixel formats
if (pixFmt.includes('12le') || pixFmt.includes('12be')) {
info.push(' ⚠️ 12-bit depth - may have limited player support');
}
// Check for interlaced content
if (stream.field_order && !['progressive', 'unknown'].includes(stream.field_order)) {
issues.push(`⚠️ INTERLACED: Field order "${stream.field_order}" - may need deinterlacing`);
}
});
return { issues, info };
};
/**
* Analyze audio streams - checks both MKV and MP4 compatibility
*/
const auditAudioStreams = (streams) => {
const issues = [];
const info = [];
const audioStreams = streams.filter(s => s.codec_type === 'audio');
if (audioStreams.length === 0) {
issues.push('⚠️ NO AUDIO: No audio streams found');
return { issues, info };
}
audioStreams.forEach((stream, idx) => {
const codec = (stream.codec_name || 'unknown').toLowerCase();
const channels = stream.channels || 0;
const sampleRate = stream.sample_rate || 0;
const bitrate = stream.bit_rate || 0;
const lang = stream.tags?.language || 'und';
const title = stream.tags?.title || '';
// Check for corrupt audio
if (channels === 0) {
issues.push(`❌ CORRUPT AUDIO ${idx}: 0 channels detected - stream will be removed`);
return;
}
if (sampleRate === 0) {
issues.push(`⚠️ CORRUPT AUDIO ${idx}: No sample rate detected`);
}
// Channel layout description
let channelDesc = `${channels}ch`;
if (channels === 1) channelDesc = 'Mono';
else if (channels === 2) channelDesc = 'Stereo';
else if (channels === 6) channelDesc = '5.1';
else if (channels === 8) channelDesc = '7.1';
let streamInfo = `🔊 Audio ${idx}: ${codec.toUpperCase()} ${channelDesc}`;
if (sampleRate) streamInfo += ` @ ${sampleRate}Hz`;
if (bitrate) streamInfo += ` (${formatBitrate(bitrate)})`;
streamInfo += ` [${lang}]`;
if (title) streamInfo += ` "${title}"`;
info.push(streamInfo);
// Check MP4-specific audio compatibility issues
if (['vorbis', 'opus'].includes(codec)) {
issues.push(`⚠️ [MP4 only] ${codec.toUpperCase()} has limited MP4 support (OK in MKV)`);
}
if (['dts', 'truehd'].includes(codec)) {
issues.push(`⚠️ [MP4 only] ${codec.toUpperCase()} not standard in MP4 (OK in MKV)`);
}
// Check for unusual audio codecs (both containers)
if (['cook', 'ra_144', 'ra_288', 'sipr', 'atrac3', 'atrac3p'].includes(codec)) {
issues.push(`⚠️ [BOTH] RARE CODEC: ${codec.toUpperCase()} - very limited support`);
}
});
return { issues, info };
};
/**
* Analyze subtitle streams - checks both MKV and MP4 compatibility
*/
const auditSubtitleStreams = (streams, file) => {
const issues = [];
const info = [];
const subStreams = streams.filter(s => s.codec_type === 'subtitle');
if (subStreams.length === 0) {
info.push('📝 Subtitles: None');
return { issues, info };
}
subStreams.forEach((stream, idx) => {
// Robust codec identification
let codec = (stream.codec_name || '').toLowerCase();
if (codec === 'none' || codec === 'unknown' || !codec) {
// Try metadata fallback
const codecTag = (stream.codec_tag_string || '').toUpperCase();
if (codecTag.includes('WEBVTT')) codec = 'webvtt';
else if (codecTag.includes('ASS')) codec = 'ass';
else if (codecTag.includes('SSA')) codec = 'ssa';
else {
const tagCodecId = (stream.tags?.CodecID || stream.tags?.codec_id || '').toUpperCase();
if (tagCodecId.includes('WEBVTT') || tagCodecId.includes('S_TEXT/WEBVTT')) codec = 'webvtt';
else if (tagCodecId.includes('ASS') || tagCodecId.includes('S_TEXT/ASS')) codec = 'ass';
else if (tagCodecId.includes('SSA') || tagCodecId.includes('S_TEXT/SSA')) codec = 'ssa';
}
}
// If still unknown, check MediaInfo/ExifTool if available
if (codec === 'none' || codec === 'unknown' || !codec) {
const mediaInfoCodec = (file.mediaInfo?.track?.find(t => t['@type'] === 'Text' && t.StreamOrder == stream.index)?.CodecID || '').toLowerCase();
if (mediaInfoCodec.includes('webvtt')) codec = 'webvtt';
else if (mediaInfoCodec.includes('ass')) codec = 'ass';
else if (mediaInfoCodec.includes('ssa')) codec = 'ssa';
}
codec = codec || 'unknown';
const lang = stream.tags?.language || 'und';
const title = stream.tags?.title || '';
const forced = stream.disposition?.forced === 1 ? ' [FORCED]' : '';
let streamInfo = `📝 Sub ${idx}: ${codec.toUpperCase()} [${lang}]${forced}`;
if (title) streamInfo += ` "${title}"`;
info.push(streamInfo);
// Check for specific problematic states
if (codec === 'unknown') {
issues.push(`⚠️ [BOTH] Subtitle stream ${idx} codec could not be identified - may cause transcode failure`);
}
// Check container-specific compatibility
const mkvIncompat = MKV_INCOMPATIBLE_CODECS.has(codec);
const mp4Incompat = MP4_INCOMPATIBLE_CODECS.has(codec);
if (mkvIncompat && mp4Incompat) {
issues.push(`⚠️ [BOTH] ${codec.toUpperCase()} incompatible with MKV and MP4`);
} else if (mkvIncompat) {
issues.push(`⚠️ [MKV only] ${codec.toUpperCase()} not compatible with MKV (OK in MP4)`);
} else if (mp4Incompat) {
issues.push(`⚠️ [MP4 only] ${codec.toUpperCase()} not compatible with MP4 (OK in MKV)`);
}
// Check for image-based subs that can't be converted to SRT
if (['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub'].includes(codec)) {
info.push(` Image-based subtitle - cannot convert to SRT`);
}
// Check for formats that will be converted
if (['ass', 'ssa', 'webvtt', 'mov_text'].includes(codec)) {
info.push(` Will convert to SRT for compatibility`);
}
});
return { issues, info };
};
/**
* Analyze other streams (data, attachments, images)
*/
const auditOtherStreams = (streams) => {
const issues = [];
const info = [];
// Image streams (cover art)
const imageStreams = streams.filter(s =>
(s.codec_type === 'video' && IMAGE_CODECS.has((s.codec_name || '').toLowerCase())) ||
s.disposition?.attached_pic === 1
);
if (imageStreams.length > 0) {
info.push(`🖼️ Cover Art: ${imageStreams.length} image stream(s) - will be removed`);
}
// Data streams
const dataStreams = streams.filter(s => s.codec_type === 'data');
dataStreams.forEach((stream, idx) => {
const codec = (stream.codec_name || 'unknown').toLowerCase();
if (PROBLEMATIC_DATA_CODECS.has(codec)) {
issues.push(`⚠️ DATA STREAM: ${codec} will cause muxing issues - will be removed`);
} else {
info.push(`📊 Data ${idx}: ${codec.toUpperCase()}`);
}
});
// Attachments (fonts, etc.)
const attachments = streams.filter(s => s.codec_type === 'attachment');
if (attachments.length > 0) {
info.push(`📎 Attachments: ${attachments.length} (fonts, etc.)`);
}
return { issues, info };
};
/**
* Analyze file-level metadata
*/
const auditFileMetadata = (file, logLevel) => {
const issues = [];
const info = [];
const format = file.ffProbeData?.format || {};
const duration = parseFloat(format.duration) || 0;
const size = file.statSync?.size || parseInt(format.size) || 0;
const bitrate = parseInt(format.bit_rate) || 0;
// Basic file info
info.push(`📁 Size: ${formatSize(size)} | Duration: ${formatDuration(duration)} | Bitrate: ${formatBitrate(bitrate)}`);
// Check for very short files
if (duration > 0 && duration < 10) {
issues.push('⚠️ SHORT FILE: Duration under 10 seconds');
}
// Check for suspiciously low bitrate
if (bitrate > 0 && bitrate < 100000) { // Under 100kbps
issues.push('⚠️ LOW BITRATE: File bitrate is very low - possible quality issues');
}
// Check for missing duration (common in broken files)
if (!duration || duration === 0) {
issues.push('⚠️ NO DURATION: Could not determine file duration - may be corrupt');
}
// Verbose: show all format tags
if (logLevel === 'verbose' && format.tags) {
const importantTags = ['title', 'encoder', 'creation_time', 'copyright'];
importantTags.forEach(tag => {
if (format.tags[tag]) {
info.push(` 📋 ${tag}: ${format.tags[tag]}`);
}
});
}
return { issues, info };
};
// ============================================================================
// MAIN PLUGIN
// ============================================================================
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const response = {
processFile: true, // MUST be true for Filter plugins to pass files through!
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: false,
reQueueAfter: false,
infoLog: '',
};
try {
inputs = sanitizeInputs(lib.loadDefaultValues(inputs, details));
const logLevel = inputs.log_level;
// Header
response.infoLog += '═══════════════════════════════════════════════════════════════\n';
response.infoLog += ' 📋 FILE AUDIT REPORT\n';
response.infoLog += '═══════════════════════════════════════════════════════════════\n\n';
if (!file.ffProbeData?.streams || !Array.isArray(file.ffProbeData.streams)) {
response.infoLog += '❌ CRITICAL: No stream data available - file may be corrupt\n';
return response;
}
const streams = file.ffProbeData.streams;
const allIssues = [];
const allInfo = [];
// Run all audits (no target container - checks both MKV and MP4)
const containerAudit = auditContainer(file);
const videoAudit = auditVideoStreams(streams);
const audioAudit = auditAudioStreams(streams);
const subtitleAudit = auditSubtitleStreams(streams, file);
const otherAudit = auditOtherStreams(streams);
const metadataAudit = auditFileMetadata(file, logLevel);
// Collect all results
allIssues.push(...containerAudit.issues, ...videoAudit.issues, ...audioAudit.issues,
...subtitleAudit.issues, ...otherAudit.issues, ...metadataAudit.issues);
allInfo.push(...metadataAudit.info, ...containerAudit.info, ...videoAudit.info,
...audioAudit.info, ...subtitleAudit.info, ...otherAudit.info);
// Output based on log level
if (logLevel === 'minimal') {
// Minimal: issues only
if (allIssues.length > 0) {
response.infoLog += `🔍 Found ${allIssues.length} potential issue(s):\n`;
allIssues.forEach(issue => {
response.infoLog += ` ${issue}\n`;
});
} else {
response.infoLog += '✅ No issues detected\n';
}
} else {
// Detailed/Verbose: show info and issues
allInfo.forEach(info => {
response.infoLog += `${info}\n`;
});
response.infoLog += '\n───────────────────────────────────────────────────────────────\n';
if (allIssues.length > 0) {
response.infoLog += `\n🔍 POTENTIAL ISSUES (${allIssues.length}):\n`;
response.infoLog += ' [MKV only] = Issue only affects MKV container\n';
response.infoLog += ' [MP4 only] = Issue only affects MP4 container\n';
response.infoLog += ' [BOTH] = Issue affects both containers\n\n';
allIssues.forEach(issue => {
response.infoLog += ` ${issue}\n`;
});
} else {
response.infoLog += '\n✅ No issues detected - file ready for processing\n';
}
}
// Stream count summary
const videoCount = streams.filter(s => s.codec_type === 'video' && !IMAGE_CODECS.has((s.codec_name || '').toLowerCase())).length;
const audioCount = streams.filter(s => s.codec_type === 'audio').length;
const subCount = streams.filter(s => s.codec_type === 'subtitle').length;
response.infoLog += '\n───────────────────────────────────────────────────────────────\n';
response.infoLog += `📊 Summary: ${videoCount}V ${audioCount}A ${subCount}S | Checked: MKV+MP4 | Issues: ${allIssues.length}\n`;
response.infoLog += '═══════════════════════════════════════════════════════════════\n';
// Final Summary block (for consistency with other plugins)
if (logLevel !== 'minimal') {
response.infoLog += '\n📋 Final Processing Summary:\n';
response.infoLog += ` Streams: ${videoCount} video, ${audioCount} audio, ${subCount} subtitle\n`;
response.infoLog += ` Issues detected: ${allIssues.length}\n`;
response.infoLog += ` Container compatibility: MKV + MP4 checked\n`;
}
return response;
} catch (error) {
response.infoLog = `❌ Audit plugin error: ${error.message}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;

View File

@@ -0,0 +1,304 @@
/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_01_container_remux',
Stage: 'Pre-processing',
Name: '01 - Container Remux',
Type: 'Video',
Operation: 'Transcode',
Description: `
Remuxes video files to target container (MKV/MP4).
Applies timestamp fixes for problematic formats (TS/AVI/MPG/XviD/DivX).
Also detects XviD/DivX/MPEG-4 video with packed bitstreams that cause timestamp issues.
Optional audio recovery for TS files with broken audio streams.
MPG re-encoding fallback for severely broken timestamp issues.
**Single Responsibility**: Container format only. No stream modifications.
Should be placed FIRST in your plugin stack.
`,
Version: '4.0.0',
Tags: 'action,ffmpeg,ts,remux,container,avi,xvid,divx',
Inputs: [
{
name: 'target_container',
type: 'string',
defaultValue: 'mkv',
inputUI: { type: 'dropdown', options: ['mkv', 'mp4'] },
tooltip: 'Target container format. MKV supports all codecs/subs, MP4 has wider device compatibility.',
},
{
name: 'fix_timestamps',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Apply timestamp fixes for legacy formats (TS/AVI/MPG/XviD/DivX/MPEG-4). Uses -fflags +genpts.',
},
{
name: 'avi_reencode_fallback',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'AVI files: Re-encode video instead of copy to fix broken timestamps. Uses libx264 CRF 18.',
},
{
name: 'mpg_reencode_fallback',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'MPG/MPEG files: Re-encode video to fix severe timestamp issues. Uses libx264 CRF 18.',
},
{
name: 'xvid_reencode_fallback',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'XviD/DivX packed bitstream files: Re-encode video to fix severe timestamp corruption.',
},
{
name: 'ts_audio_recovery',
type: 'string',
defaultValue: 'false',
inputUI: { type: 'dropdown', options: ['false', 'true'] },
tooltip: 'TS files only: Transcode audio to AAC for compatibility. Use when TS audio is corrupt.',
},
],
});
// Constants
const VALID_CONTAINERS = new Set(['mkv', 'mp4']);
const BOOLEAN_INPUTS = ['fix_timestamps', 'avi_reencode_fallback', 'mpg_reencode_fallback', 'xvid_reencode_fallback', 'ts_audio_recovery'];
const TIMESTAMP_CONTAINERS = new Set(['ts', 'mpegts', 'avi', 'mpg', 'mpeg', 'm2ts']);
const TS_CONTAINERS = new Set(['ts', 'mpegts']);
const MPG_CONTAINERS = new Set(['mpg', 'mpeg', 'vob']);
const SKIP_CONTAINERS = new Set(['iso', 'vob', 'evo']);
const XVID_TAGS = new Set(['XVID', 'DIVX', 'DX50', 'DIV3', 'DIV4', 'DIV5', 'FMP4', 'MP4V', 'MP42', 'MP43']);
const MSMPEG4_CODECS = new Set(['msmpeg4v1', 'msmpeg4v2', 'msmpeg4v3', 'msmpeg4']);
const DVD_SUB_CODECS = new Set(['dvd_subtitle', 'dvdsub']);
// Subtitle codec compatibility
const MKV_INCOMPATIBLE_SUBS = new Set(['mov_text', 'tx3g']);
const MP4_TEXT_SUBS = new Set(['subrip', 'srt', 'ass', 'ssa', 'webvtt', 'vtt']);
const IMAGE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
// Utilities
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
/**
* Detects XviD/DivX/MPEG-4 codec-level timestamp issues.
*/
const detectXvidIssues = (streams) => {
const video = streams.find((s) => s.codec_type === 'video');
if (!video) return null;
const codec = (video.codec_name || '').toLowerCase();
const tag = (video.codec_tag_string || '').toUpperCase();
if (video.divx_packed === 'true' || video.divx_packed === true) {
return 'XviD/DivX packed bitstream';
}
if (codec === 'mpeg4' && XVID_TAGS.has(tag)) {
return `MPEG-4/${tag}`;
}
if (MSMPEG4_CODECS.has(codec)) {
return 'MSMPEG4';
}
return null;
};
/**
* Check if TS/M2TS file has unrecoverable corrupt streams.
*/
const hasCorruptStreams = (streams) => streams.some((s) => {
if (s.codec_type === 'audio' && s.channels === 0) return true;
if (s.codec_type === 'video' && !s.duration && !s.duration_ts) return true;
return false;
});
/**
* Check if file has DVD subtitles (which have timestamp issues).
*/
const hasDvdSubtitles = (streams) => streams.some(
(s) => s.codec_type === 'subtitle' && DVD_SUB_CODECS.has(s.codec_name)
);
/**
* POLICY: MP4 incompatible with all subtitles, MKV subtitle conversion handled by Plugin 04.
* This function now only detects if subtitles exist.
* Optimized: Single pass through streams instead of filter + map.
*/
const hasSubtitles = (streams) => {
const subtitleCodecs = [];
for (const s of streams) {
if (s.codec_type === 'subtitle') {
subtitleCodecs.push(s.codec_name || 'unknown');
}
}
return {
hasSubtitles: subtitleCodecs.length > 0,
subtitleCount: subtitleCodecs.length,
subtitleCodecs,
};
};
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: true,
reQueueAfter: true,
infoLog: '',
};
try {
// Sanitize inputs
inputs = lib.loadDefaultValues(inputs, details);
Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); });
// Validate container
if (!VALID_CONTAINERS.has(inputs.target_container)) {
response.infoLog = `❌ Invalid target_container: ${inputs.target_container}. `;
return response;
}
// Validate and normalize boolean inputs
for (const key of BOOLEAN_INPUTS) {
const val = String(inputs[key]).toLowerCase();
if (val !== 'true' && val !== 'false') {
response.infoLog = `❌ Invalid ${key}: must be true or false. `;
return response;
}
inputs[key] = val === 'true';
}
const streams = file.ffProbeData?.streams;
if (!Array.isArray(streams)) {
response.infoLog = '❌ No stream data available. ';
return response;
}
const targetContainer = inputs.target_container;
const currentContainer = file.container.toLowerCase();
const containerNeedsChange = currentContainer !== targetContainer;
// Skip unsupported formats
if (SKIP_CONTAINERS.has(currentContainer)) {
response.infoLog = '⚠️ ISO/DVD files require manual conversion with HandBrake or MakeMKV. Skipping.\n';
return response;
}
// Determine what fixes are needed
const isTS = TS_CONTAINERS.has(currentContainer);
const isMPG = MPG_CONTAINERS.has(currentContainer);
const xvidIssue = inputs.fix_timestamps ? detectXvidIssues(streams) : null;
const containerNeedsTimestampFix = inputs.fix_timestamps && TIMESTAMP_CONTAINERS.has(currentContainer);
const needsTimestampFix = containerNeedsTimestampFix || xvidIssue;
const needsAudioRecovery = inputs.ts_audio_recovery && isTS;
// Early exit if nothing to do (optimization: check before expensive operations)
if (!containerNeedsChange && !needsTimestampFix && !needsAudioRecovery) {
response.infoLog = '✅ Container already correct, no fixes needed. ';
return response;
}
// Skip corrupt TS/M2TS files
if ((isTS || currentContainer === 'm2ts') && hasCorruptStreams(streams)) {
response.infoLog = '⚠️ TS file has corrupt streams with unfixable timestamp issues. Skipping.\n';
return response;
}
// Build FFmpeg command parts
const cmdParts = [];
let codecFlags = '-c copy';
const logs = [];
// Timestamp fixes
if (needsTimestampFix) {
if (isTS) {
cmdParts.push('-fflags +genpts+igndts -avoid_negative_ts make_zero -start_at_zero');
logs.push('🔧 Applying TS timestamp fixes.');
} else if (currentContainer === 'avi' && inputs.avi_reencode_fallback) {
cmdParts.push('-fflags +genpts');
codecFlags = '-c:v libx264 -preset ultrafast -crf 18 -c:a aac -b:a 192k -c:s copy';
logs.push('🔧 AVI re-encode: Fixing timestamps via video re-encoding.');
} else if (xvidIssue && !containerNeedsTimestampFix && inputs.xvid_reencode_fallback) {
cmdParts.push('-fflags +genpts');
codecFlags = '-c:v libx264 -preset ultrafast -crf 18 -c:a copy -c:s copy';
logs.push(`🔧 Detected ${xvidIssue}. Re-encoding video to fix timestamps.`);
} else {
cmdParts.push('-fflags +genpts');
logs.push(`🔧 Applying ${currentContainer.toUpperCase()} timestamp fixes.`);
}
}
// MPG re-encoding (if enabled)
if (isMPG && inputs.mpg_reencode_fallback) {
codecFlags = '-c:v libx264 -preset ultrafast -crf 18 -c:a copy -c:s copy';
logs.push('🔧 MPG re-encode: Fixing timestamps via video re-encoding.');
}
// TS audio recovery
if (needsAudioRecovery) {
const firstAudio = streams.find((s) => s.codec_type === 'audio');
const channels = firstAudio?.channels || 2;
const bitrate = channels > 2 ? '384k' : '192k';
codecFlags = `-c:v copy -c:a aac -b:a ${bitrate} -c:s copy`;
logs.push(`🎧 TS audio recovery: ${channels}ch → AAC ${bitrate}.`);
}
// POLICY: Skip all subtitle streams in container remux
// - MP4: Drops all subtitles (MP4 considered incompatible per policy)
// - MKV: Lets Plugin 04 (Subtitle Conversion) handle subtitle processing
const subInfo = hasSubtitles(streams);
// Stream mapping: video, audio, and subtitles (data streams dropped)
cmdParts.push('-map 0:v -map 0:a? -map 0:s?');
if (subInfo.hasSubtitles) {
logs.push(` Detected ${subInfo.subtitleCount} subtitle stream(s) (Compatibility handled by downstream plugins).`);
}
cmdParts.push(codecFlags, '-max_muxing_queue_size 9999');
// Final response
response.preset = `<io> ${cmdParts.join(' ')}`;
response.container = `.${targetContainer}`;
response.processFile = true;
if (containerNeedsChange) {
logs.push(`✅ Remuxing ${currentContainer.toUpperCase()}${targetContainer.toUpperCase()}.`);
} else {
logs.push('✅ Applying fixes (container unchanged).');
}
response.infoLog = logs.join(' ');
// Final Summary block
response.infoLog += '\n\n📋 Final Processing Summary:\n';
response.infoLog += ` Target container: ${targetContainer.toUpperCase()}\n`;
if (containerNeedsChange) response.infoLog += ` - Container remux: ${currentContainer.toUpperCase()}${targetContainer.toUpperCase()}\n`;
if (needsTimestampFix) response.infoLog += ` - Timestamp fixes applied\n`;
if (needsAudioRecovery) response.infoLog += ` - TS audio recovery enabled\n`;
if (subInfo.hasSubtitles) {
if (targetContainer === 'mp4') {
response.infoLog += ` - Subtitles: ${subInfo.subtitleCount} dropped (MP4 incompatible)\n`;
} else {
response.infoLog += ` - Subtitles: ${subInfo.subtitleCount} skipped (Plugin 04 will handle)\n`;
}
}
return response;
} catch (error) {
response.processFile = false;
response.preset = '';
response.reQueueAfter = false;
response.infoLog = `❌ Plugin error: ${error.message}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;

View File

@@ -0,0 +1,210 @@
/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_02_stream_cleanup',
Stage: 'Pre-processing',
Name: '02 - Stream Cleanup',
Type: 'Video',
Operation: 'Transcode',
Description: `
Removes unwanted and incompatible streams from the container.
- Removes image streams (MJPEG/PNG/GIF cover art)
- Drops streams incompatible with current container (auto-detected)
- Removes corrupt/invalid audio streams (0 channels)
**Single Responsibility**: Stream removal only. No reordering.
Run AFTER container remux, BEFORE stream ordering.
Container is inherited from Plugin 01 (Container Remux).
`,
Version: '4.0.0',
Tags: 'action,ffmpeg,cleanup,streams,conform',
Inputs: [
{
name: 'remove_image_streams',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Remove MJPEG, PNG, GIF video streams and attached pictures (often cover art spam).',
},
{
name: 'force_conform',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Drop streams incompatible with target container (e.g., mov_text in MKV, PGS in MP4).',
},
{
name: 'remove_corrupt_audio',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Remove audio streams with invalid parameters (0 channels, no sample rate).',
},
{
name: 'remove_data_streams',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Remove data streams (bin_data, timed_id3) that cause muxing issues.',
},
{
name: 'remove_attachments',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Remove attachment streams (fonts, etc.) that often cause FFmpeg 7.x muxing errors.',
},
],
});
// Constants - Set for O(1) lookup
const MKV_INCOMPATIBLE = new Set(['mov_text', 'eia_608', 'timed_id3', 'bmp', 'tx3g']);
const MP4_INCOMPATIBLE = new Set(['hdmv_pgs_subtitle', 'eia_608', 'subrip', 'timed_id3', 'ass', 'ssa']);
const IMAGE_CODECS = new Set(['mjpeg', 'png', 'gif']);
const DATA_CODECS = new Set(['bin_data', 'timed_id3', 'dvd_nav_packet']);
const SUPPORTED_CONTAINERS = new Set(['mkv', 'mp4']);
const BOOLEAN_INPUTS = ['remove_image_streams', 'force_conform', 'remove_corrupt_audio', 'remove_data_streams', 'remove_attachments'];
// Utilities
// Note: stripStar is duplicated across all plugins due to Tdarr's self-contained architecture.
// Each plugin must be standalone without external dependencies.
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: true,
reQueueAfter: true,
infoLog: '',
};
try {
// Sanitize inputs and convert booleans
inputs = lib.loadDefaultValues(inputs, details);
Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); });
BOOLEAN_INPUTS.forEach((k) => { inputs[k] = inputs[k] === 'true'; });
const streams = file.ffProbeData?.streams;
if (!Array.isArray(streams)) {
response.infoLog = '❌ No stream data available. ';
return response;
}
const currentContainer = (file.container || '').toLowerCase();
// Early exit optimization: unsupported container = nothing to do
if (!SUPPORTED_CONTAINERS.has(currentContainer)) {
response.infoLog += `⚠️ Container "${currentContainer}" not supported. Skipping conformance. `;
return response;
}
const isTargetMkv = currentContainer === 'mkv';
const incompatibleCodecs = isTargetMkv ? MKV_INCOMPATIBLE : (currentContainer === 'mp4' ? MP4_INCOMPATIBLE : new Set());
response.infoLog += ` Container: ${currentContainer.toUpperCase()}. `;
const streamsToDrop = [];
const stats = { image: 0, corrupt: 0, data: 0, incompatible: 0, attachment: 0 };
for (let i = 0; i < streams.length; i++) {
const stream = streams[i];
const codec = (stream.codec_name || '').toLowerCase();
const type = (stream.codec_type || '').toLowerCase();
// Remove image streams
if (inputs.remove_image_streams && type === 'video') {
const isAttachedPic = stream.disposition?.attached_pic === 1;
if (IMAGE_CODECS.has(codec) || isAttachedPic) {
streamsToDrop.push(i);
stats.image++;
continue;
}
}
// Remove corrupt audio
if (inputs.remove_corrupt_audio && type === 'audio') {
if (stream.channels === 0 || stream.sample_rate === 0 || !codec) {
streamsToDrop.push(i);
stats.corrupt++;
continue;
}
}
// Remove data streams
if (inputs.remove_data_streams && type === 'data') {
if (DATA_CODECS.has(codec)) {
streamsToDrop.push(i);
stats.data++;
continue;
}
}
// Remove attachments
if (inputs.remove_attachments && type === 'attachment') {
streamsToDrop.push(i);
stats.attachment++;
continue;
}
// POLICY: MP4 is incompatible with ALL subtitles - remove any that slipped through
if (currentContainer === 'mp4' && type === 'subtitle') {
streamsToDrop.push(i);
stats.incompatible++;
continue;
}
// Container conforming (for MKV and other edge cases)
if (inputs.force_conform) {
if (incompatibleCodecs.has(codec) || (type === 'data' && isTargetMkv) || !codec || codec === 'unknown' || codec === 'none') {
streamsToDrop.push(i);
stats.incompatible++;
continue;
}
}
}
// Early exit optimization: nothing to drop = no processing needed
if (streamsToDrop.length > 0) {
const dropMaps = streamsToDrop.map((i) => `-map -0:${i}`).join(' ');
response.preset = `<io> -map 0 ${dropMaps} -c copy -max_muxing_queue_size 9999`;
response.container = `.${file.container}`;
response.processFile = true;
const summary = [];
if (stats.image) summary.push(`${stats.image} image`);
if (stats.corrupt) summary.push(`${stats.corrupt} corrupt`);
if (stats.data) summary.push(`${stats.data} data`);
if (stats.incompatible) summary.push(`${stats.incompatible} incompatible`);
if (stats.attachment) summary.push(`${stats.attachment} attachment`);
response.infoLog += `✅ Dropping ${streamsToDrop.length} stream(s): ${summary.join(', ')}. `;
// Final Summary block
response.infoLog += '\n\n📋 Final Processing Summary:\n';
response.infoLog += ` Streams dropped: ${streamsToDrop.length}\n`;
if (stats.image) response.infoLog += ` - Image/Cover art: ${stats.image}\n`;
if (stats.corrupt) response.infoLog += ` - Corrupt audio: ${stats.corrupt}\n`;
if (stats.data) response.infoLog += ` - Data streams: ${stats.data}\n`;
if (stats.incompatible) response.infoLog += ` - Incompatible streams: ${stats.incompatible}\n`;
if (stats.attachment) response.infoLog += ` - Attachments: ${stats.attachment}\n`;
return response;
}
response.infoLog += '✅ No streams to remove. ';
return response;
} catch (error) {
response.processFile = false;
response.preset = '';
response.reQueueAfter = false;
response.infoLog = `❌ Plugin error: ${error.message}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;

View File

@@ -0,0 +1,324 @@
/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_03_stream_ordering',
Stage: 'Pre-processing',
Name: '03 - Stream Ordering',
Type: 'Video',
Operation: 'Transcode',
Description: `
Reorders streams by type and language priority.
- Ensures Video streams appear first
- Prioritizes specified language codes for Audio and Subtitles
- Optionally sets default disposition flags on first priority tracks
v1.6: Updated documentation - recommend using default_audio_mode='skip' when audio_standardizer
plugin is in the stack (audio_standardizer sets default by channel count after processing).
v1.5: Added default_audio_mode option - choose between language-based or channel-count-based
default audio selection. Improved stack compatibility with audio standardizer plugin.
**Single Responsibility**: Stream order only. No conversion or removal.
Run AFTER stream cleanup, BEFORE subtitle conversion.
`,
Version: '4.0.0',
Tags: 'action,ffmpeg,order,language,english',
Inputs: [
{
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: 'reorder_audio',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Reorder audio streams to put priority language first.',
},
{
name: 'reorder_subtitles',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Reorder subtitle streams to put priority language first.',
},
{
name: 'priority_languages',
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 prioritize (max 20).',
},
{
name: 'set_default_flags',
type: 'string',
defaultValue: 'false',
inputUI: { type: 'dropdown', options: ['false', 'true'] },
tooltip: 'Enable setting default disposition flags. Use default_audio_mode to choose strategy.',
},
{
name: 'default_audio_mode',
type: 'string',
defaultValue: 'language*',
inputUI: { type: 'dropdown', options: ['language*', 'channels', 'skip'] },
tooltip: 'How to select default audio: language=first priority-language track, channels=track with most channels (BEFORE downmix creation), skip=don\'t set audio default (RECOMMENDED when audio_standardizer is in stack - it sets default by channel count AFTER all processing including downmixes).',
},
],
});
// Constants
const MAX_LANGUAGE_CODES = 20;
const BOOLEAN_INPUTS = ['ensure_video_first', 'reorder_audio', 'reorder_subtitles', 'set_default_flags'];
const VALID_DEFAULT_AUDIO_MODES = new Set(['language', 'channels', 'skip']);
const STREAM_TYPES = new Set(['video', 'audio', 'subtitle']);
// Container-aware subtitle compatibility
const MKV_INCOMPATIBLE_SUBS = new Set(['mov_text', 'tx3g']);
const MP4_INCOMPATIBLE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub', 'subrip', 'ass', 'ssa', 'webvtt']);
const MP4_CONVERTIBLE_SUBS = new Set(['subrip', 'ass', 'ssa', 'webvtt', 'text']);
const IMAGE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
// Utilities
// Note: stripStar is duplicated across all plugins due to Tdarr's self-contained architecture.
// Each plugin must be standalone without external dependencies.
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
const parseLanguages = (codesString) => {
if (typeof codesString !== 'string') return new Set();
const codes = codesString
.split(',')
.map((c) => c.trim().toLowerCase())
.filter((c) => c.length > 0 && c.length <= 10 && /^[a-z0-9-]+$/.test(c))
.slice(0, MAX_LANGUAGE_CODES);
return new Set(codes);
};
const isPriority = (stream, prioritySet) => {
const lang = stream.tags?.language?.toLowerCase();
return lang && prioritySet.has(lang);
};
const partition = (arr, predicate) => {
const matched = [];
const unmatched = [];
arr.forEach((item) => (predicate(item) ? matched : unmatched).push(item));
return [matched, unmatched];
};
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: true,
reQueueAfter: true,
infoLog: '',
};
try {
// Sanitize inputs and convert booleans
inputs = lib.loadDefaultValues(inputs, details);
Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); });
BOOLEAN_INPUTS.forEach((k) => { inputs[k] = inputs[k] === 'true'; });
const streams = file.ffProbeData?.streams;
if (!Array.isArray(streams)) {
response.infoLog = '❌ No stream data available. ';
return response;
}
// Parse priority languages into Set for O(1) lookup
let priorityLangs = parseLanguages(inputs.priority_languages);
if (priorityLangs.size === 0) {
priorityLangs = new Set(['eng', 'en', 'english', 'en-us', 'en-gb', 'en-ca', 'en-au']);
}
// Tag streams with original index
const taggedStreams = streams.map((s, i) => ({ ...s, originalIndex: i }));
const videoStreams = taggedStreams.filter((s) => s.codec_type === 'video');
let audioStreams = taggedStreams.filter((s) => s.codec_type === 'audio');
let subtitleStreams = taggedStreams.filter((s) => s.codec_type === 'subtitle');
const otherStreams = taggedStreams.filter((s) => !STREAM_TYPES.has(s.codec_type));
// Reorder by language priority
if (inputs.reorder_audio) {
const [priority, other] = partition(audioStreams, (s) => isPriority(s, priorityLangs));
audioStreams = [...priority, ...other];
if (priority.length) response.infoLog += `${priority.length} priority audio first. `;
}
if (inputs.reorder_subtitles) {
const [priority, other] = partition(subtitleStreams, (s) => isPriority(s, priorityLangs));
subtitleStreams = [...priority, ...other];
if (priority.length) response.infoLog += `${priority.length} priority subtitle(s) first. `;
}
// Build final order
let reorderedStreams;
if (inputs.ensure_video_first) {
reorderedStreams = [...videoStreams, ...audioStreams, ...subtitleStreams, ...otherStreams];
} else {
// Maintain relative order but apply language sorting
const audioQueue = [...audioStreams];
const subQueue = [...subtitleStreams];
reorderedStreams = taggedStreams.map((s) => {
if (s.codec_type === 'audio') return audioQueue.shift();
if (s.codec_type === 'subtitle') return subQueue.shift();
return s;
});
}
// Check if order changed
const originalOrder = taggedStreams.map((s) => s.originalIndex);
const newOrder = reorderedStreams.map((s) => s.originalIndex);
if (JSON.stringify(originalOrder) === JSON.stringify(newOrder)) {
response.infoLog += '✅ Stream order already correct. ';
return response;
}
// Build FFmpeg command with container-aware subtitle handling
const container = (file.container || '').toLowerCase();
let command = '<io>';
const subtitlesToDrop = [];
const subtitlesToConvert = [];
// Build stream mapping with container compatibility checks
reorderedStreams.forEach((s) => {
const codec = (s.codec_name || '').toLowerCase();
// Check subtitle compatibility with container
if (s.codec_type === 'subtitle') {
if (container === 'mp4' || container === 'm4v') {
if (IMAGE_SUBS.has(codec)) {
subtitlesToDrop.push(s.originalIndex);
return; // Don't map this stream
} else if (MP4_CONVERTIBLE_SUBS.has(codec)) {
subtitlesToConvert.push(s.originalIndex);
}
} else if (container === 'mkv' && MKV_INCOMPATIBLE_SUBS.has(codec)) {
subtitlesToConvert.push(s.originalIndex);
}
}
command += ` -map 0:${s.originalIndex}`;
});
// Log dropped/converted subtitles
if (subtitlesToDrop.length > 0) {
response.infoLog += `📁 Dropping ${subtitlesToDrop.length} image subtitle(s) (incompatible with MP4). `;
}
// Build codec arguments
command += ' -c:v copy -c:a copy';
// Handle subtitle codec conversion based on container
if (subtitlesToConvert.length > 0) {
if (container === 'mp4' || container === 'm4v') {
command += ' -c:s mov_text';
response.infoLog += `📁 Converting ${subtitlesToConvert.length} subtitle(s) to mov_text. `;
} else if (container === 'mkv') {
command += ' -c:s srt';
response.infoLog += `📁 Converting ${subtitlesToConvert.length} mov_text subtitle(s) to SRT. `;
} else {
command += ' -c:s copy';
}
} else {
command += ' -c:s copy';
}
// Set default disposition flags
if (inputs.set_default_flags) {
const audioStreamsOrdered = reorderedStreams.filter(s => s.codec_type === 'audio');
let subIdx = 0;
let firstPrioritySub = null;
// Handle subtitle default (always by language)
reorderedStreams.forEach((s) => {
if (s.codec_type === 'subtitle') {
if (firstPrioritySub === null && isPriority(s, priorityLangs)) firstPrioritySub = subIdx;
subIdx++;
}
});
// Handle audio default based on mode
let defaultAudioIdx = null;
const audioMode = inputs.default_audio_mode || 'language';
if (audioMode === 'language') {
// First priority-language track
for (let i = 0; i < audioStreamsOrdered.length; i++) {
if (isPriority(audioStreamsOrdered[i], priorityLangs)) {
defaultAudioIdx = i;
break;
}
}
if (defaultAudioIdx !== null) {
command += ` -disposition:a:${defaultAudioIdx} default`;
response.infoLog += `✅ Default audio: track ${defaultAudioIdx} (language priority). `;
}
} else if (audioMode === 'channels') {
// Track with most channels
if (audioStreamsOrdered.length > 0) {
let maxChannels = 0;
audioStreamsOrdered.forEach((s, i) => {
const channels = s.channels || 0;
if (channels > maxChannels) {
maxChannels = channels;
defaultAudioIdx = i;
}
});
if (defaultAudioIdx !== null) {
command += ` -disposition:a:${defaultAudioIdx} default`;
response.infoLog += `✅ Default audio: track ${defaultAudioIdx} (${maxChannels}ch - highest). `;
}
}
}
// Mode 'skip' - don't set audio default, let other plugins handle it
// Clear default from other audio tracks when setting a default
if (defaultAudioIdx !== null && audioMode !== 'skip') {
for (let i = 0; i < audioStreamsOrdered.length; i++) {
if (i !== defaultAudioIdx) {
command += ` -disposition:a:${i} 0`;
}
}
}
if (firstPrioritySub !== null) {
command += ` -disposition:s:${firstPrioritySub} default`;
response.infoLog += `✅ Default subtitle: track ${firstPrioritySub}. `;
}
}
command += ' -max_muxing_queue_size 9999';
response.preset = command;
response.processFile = true;
response.infoLog += '✅ Reordering streams. ';
// Final Summary block
response.infoLog += '\n\n📋 Final Processing Summary:\n';
response.infoLog += ` Action: Reordering streams\n`;
response.infoLog += ` Languages prioritized: ${inputs.priority_languages}\n`;
if (inputs.ensure_video_first) response.infoLog += ` - Ensuring video stream first\n`;
if (inputs.reorder_audio) response.infoLog += ` - Audio reordered by language\n`;
if (inputs.reorder_subtitles) response.infoLog += ` - Subtitles reordered by language\n`;
if (inputs.set_default_flags) response.infoLog += ` - Default flags updated\n`;
return response;
} catch (error) {
response.processFile = false;
response.preset = '';
response.reQueueAfter = false;
response.infoLog = `❌ Plugin error: ${error.message}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;

View File

@@ -0,0 +1,237 @@
/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_04_subtitle_conversion',
Stage: 'Pre-processing',
Name: '04 - Subtitle Conversion',
Type: 'Video',
Operation: 'Transcode',
Description: `
**Container-Aware** subtitle conversion for maximum compatibility.
- MKV target → Converts to SRT (universal text format)
- MP4 target → Converts to mov_text (native MP4 format)
Converts: ASS/SSA, WebVTT, mov_text, SRT (cross-converts as needed)
Image subtitles (PGS/VobSub) are copied as-is (cannot convert to text).
**Single Responsibility**: In-container subtitle codec conversion only.
Container is inherited from Plugin 01 (Container Remux).
Run AFTER stream ordering, BEFORE subtitle extraction.
`,
Version: '4.0.0',
Tags: 'action,ffmpeg,subtitles,srt,mov_text,convert,container-aware',
Inputs: [
{
name: 'enable_conversion',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Enable container-aware subtitle conversion (MKV→SRT, MP4→mov_text).',
},
{
name: 'always_convert_webvtt',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Always convert WebVTT regardless of other settings (problematic in most containers).',
},
],
});
// Constants - Set for O(1) lookup
const TEXT_SUBTITLES = new Set(['ass', 'ssa', 'webvtt', 'vtt', 'mov_text', 'text', 'subrip', 'srt']);
const IMAGE_SUBTITLES = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
const UNSUPPORTED_SUBTITLES = new Set(['eia_608', 'cc_dec', 'tx3g']);
const WEBVTT_CODECS = new Set(['webvtt', 'vtt']);
const BOOLEAN_INPUTS = ['enable_conversion', 'always_convert_webvtt'];
const CONTAINER_TARGET = {
mkv: 'srt',
mp4: 'mov_text',
m4v: 'mov_text',
mov: 'mov_text',
};
// Utilities
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
/**
* Get subtitle codec, handling edge cases (WebVTT in MKV may report codec_name as 'none').
*/
const getSubtitleCodec = (stream, file) => {
let codecName = (stream.codec_name || '').toLowerCase();
if (codecName && codecName !== 'none' && codecName !== 'unknown') return codecName;
// FFmpeg reports 'none' for some codecs it can't identify (like WEBVTT in MKV)
// Try metadata fallback using tags/codec_tag
const codecTag = (stream.codec_tag_string || '').toUpperCase();
if (codecTag.includes('WEBVTT')) return 'webvtt';
if (codecTag.includes('ASS')) return 'ass';
if (codecTag.includes('SSA')) return 'ssa';
const tagCodecId = (stream.tags?.CodecID || stream.tags?.codec_id || '').toUpperCase();
if (tagCodecId.includes('WEBVTT') || tagCodecId.includes('S_TEXT/WEBVTT')) return 'webvtt';
if (tagCodecId.includes('ASS') || tagCodecId.includes('S_TEXT/ASS')) return 'ass';
if (tagCodecId.includes('SSA') || tagCodecId.includes('S_TEXT/SSA')) return 'ssa';
// Try MediaInfo fallback
const miStreams = file?.mediaInfo?.track;
if (Array.isArray(miStreams)) {
const miStream = miStreams.find((t) => t['@type'] === 'Text' && t.StreamOrder == stream.index);
const miCodec = (miStream?.CodecID || '').toLowerCase();
if (miCodec.includes('webvtt')) return 'webvtt';
if (miCodec.includes('ass')) return 'ass';
if (miCodec.includes('ssa')) return 'ssa';
}
// Try ExifTool (meta) fallback
const meta = file?.meta;
if (meta) {
// ExifTool often provides codec information in the TrackLanguage or TrackName if everything else fails
const trackName = (stream.tags?.title || '').toLowerCase();
if (trackName.includes('webvtt')) return 'webvtt';
}
return codecName || 'unknown';
};
/**
* Normalize codec name for comparison.
*/
const normalizeCodec = (codec) => {
if (codec === 'srt' || codec === 'subrip') return 'srt';
if (codec === 'vtt' || codec === 'webvtt') return 'webvtt';
return codec;
};
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: true,
reQueueAfter: true,
infoLog: '',
};
try {
// Sanitize inputs and convert booleans
inputs = lib.loadDefaultValues(inputs, details);
Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); });
BOOLEAN_INPUTS.forEach((k) => { inputs[k] = inputs[k] === 'true'; });
const streams = file.ffProbeData?.streams;
if (!Array.isArray(streams)) {
response.infoLog = '❌ No stream data available. ';
return response;
}
const container = (file.container || '').toLowerCase();
const targetCodec = CONTAINER_TARGET[container] || 'srt';
const targetDisplay = targetCodec === 'srt' ? 'SRT' : 'mov_text';
response.infoLog += `📦 ${container.toUpperCase()}${targetDisplay}. `;
const subtitleStreams = streams
.map((s, i) => ({ ...s, index: i }))
.filter((s) => s.codec_type === 'subtitle');
// Early exit optimization: no subtitles = nothing to do
if (subtitleStreams.length === 0) {
response.infoLog += '✅ No subtitle streams. ';
return response;
}
const toConvert = [];
const reasons = [];
subtitleStreams.forEach((stream) => {
const codec = getSubtitleCodec(stream, file);
const normalized = normalizeCodec(codec);
const streamDisplay = `Stream ${stream.index} (${codec.toUpperCase()})`;
// Skip unsupported formats
if (UNSUPPORTED_SUBTITLES.has(codec)) {
reasons.push(`${streamDisplay}: Unsupported format, skipping`);
return;
}
// Image-based formats: Copy as-is (cannot convert to text)
if (IMAGE_SUBTITLES.has(codec)) {
reasons.push(`${streamDisplay}: Image-based, copying as-is`);
return;
}
// Check if conversion to target is needed
if (!inputs.enable_conversion) {
// Still convert WebVTT if that option is enabled (special case for compatibility)
if (WEBVTT_CODECS.has(codec) && inputs.always_convert_webvtt) {
toConvert.push(stream);
reasons.push(`${streamDisplay}${targetDisplay} (special WebVTT rule)`);
} else {
reasons.push(`${streamDisplay}: Keeping original (conversion disabled)`);
}
return;
}
// WebVTT always converted if enabled
if (WEBVTT_CODECS.has(codec) && inputs.always_convert_webvtt) {
toConvert.push(stream);
reasons.push(`${streamDisplay}${targetDisplay}`);
return;
}
// Already in target format
if (normalized === normalizeCodec(targetCodec)) {
reasons.push(`${streamDisplay}: Already ${targetDisplay}, copying`);
return;
}
// Text subtitle that needs conversion
if (TEXT_SUBTITLES.has(codec)) {
toConvert.push(stream);
reasons.push(`${streamDisplay}${targetDisplay}`);
} else {
reasons.push(`${streamDisplay}: Unknown format, copying as-is`);
}
});
// Early exit optimization: all compatible = no conversion needed
if (toConvert.length === 0) {
response.infoLog += `✅ All ${subtitleStreams.length} subtitle(s) compatible. `;
return response;
}
// Build FFmpeg command
let command = '<io> -map 0 -c copy';
toConvert.forEach((s) => { command += ` -c:${s.index} ${targetCodec}`; });
command += ' -max_muxing_queue_size 9999';
response.preset = command;
response.processFile = true;
response.infoLog += `✅ Converting ${toConvert.length} subtitle(s):\n`;
reasons.forEach((r) => { response.infoLog += ` ${r}\n`; });
// Final Summary block
response.infoLog += '\n📋 Final Processing Summary:\n';
response.infoLog += ` Target format: ${targetDisplay}\n`;
response.infoLog += ` Total subtitles analyzed: ${subtitleStreams.length}\n`;
response.infoLog += ` Subtitles converted: ${toConvert.length}\n`;
return response;
} catch (error) {
response.processFile = false;
response.preset = '';
response.reQueueAfter = false;
response.infoLog = `❌ Plugin error: ${error.message}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;

View File

@@ -0,0 +1,240 @@
/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_05_subtitle_extraction',
Stage: 'Pre-processing',
Name: '05 - Subtitle Extraction',
Type: 'Video',
Operation: 'Transcode',
Description: `
Extracts embedded subtitles to external .srt files.
- Optionally removes embedded subtitles after extraction
- Skips commentary/description tracks if configured
- Skips image-based subtitles (PGS/VobSub - cannot extract to SRT)
**Single Responsibility**: External file extraction only.
Run AFTER subtitle conversion.
`,
Version: '4.0.0',
Tags: 'action,ffmpeg,subtitles,srt,extract',
Inputs: [
{
name: 'extract_subtitles',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Extract embedded text subtitles to external .srt files.',
},
{
name: 'remove_after_extract',
type: 'string',
defaultValue: 'false',
inputUI: { type: 'dropdown', options: ['false', 'true'] },
tooltip: 'Remove embedded subtitles from container after extracting them.',
},
{
name: 'skip_commentary',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Skip extracting subtitles with "commentary" or "description" in the title.',
},
{
name: 'extract_languages',
type: 'string',
defaultValue: '',
inputUI: { type: 'text' },
tooltip: 'Comma-separated language codes to extract. Empty = extract all.',
},
],
});
// Constants
const IMAGE_SUBTITLES = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
const UNSUPPORTED_SUBTITLES = new Set(['eia_608', 'cc_dec', 'tx3g']);
const MIN_SUBTITLE_SIZE = 100;
const MAX_FILENAME_ATTEMPTS = 100;
const BOOLEAN_INPUTS = ['extract_subtitles', 'remove_after_extract', 'skip_commentary'];
// Utilities
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
const sanitizeFilename = (name, maxLen = 50) => {
if (typeof name !== 'string') return 'file';
name = name.replace(/[<>:"|?*\/\\\x00-\x1f\x7f]/g, '_').replace(/^[.\s]+|[.\s]+$/g, '');
return name.length === 0 ? 'file' : name.substring(0, maxLen);
};
const sanitizeForShell = (str) => {
if (typeof str !== 'string') throw new TypeError('Input must be a string');
str = str.replace(/\0/g, '');
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
};
const fileExistsValid = (filePath, fs) => {
try { return fs.statSync(filePath).size > MIN_SUBTITLE_SIZE; }
catch { return false; }
};
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: true,
reQueueAfter: false,
infoLog: '',
};
try {
// Sanitize inputs and convert booleans
inputs = lib.loadDefaultValues(inputs, details);
Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); });
BOOLEAN_INPUTS.forEach((k) => { inputs[k] = inputs[k] === 'true'; });
if (!inputs.extract_subtitles) {
response.infoLog = '✅ Subtitle extraction disabled. ';
return response;
}
const streams = file.ffProbeData?.streams;
if (!Array.isArray(streams)) {
response.infoLog = '❌ No stream data available. ';
return response;
}
// Parse language filter
const extractLangs = inputs.extract_languages
? new Set(inputs.extract_languages.split(',').map((l) => l.trim().toLowerCase()).filter(Boolean))
: null;
const subtitleStreams = streams
.map((s, i) => ({ ...s, index: i }))
.filter((s) => s.codec_type === 'subtitle');
if (subtitleStreams.length === 0) {
response.infoLog = '✅ No subtitle streams to extract. ';
return response;
}
// Detect cache cycle
const isInCache = (file._id || file.file).includes('-TdarrCacheFile-');
const stableId = (file._id || file.file).replace(/-TdarrCacheFile-[a-zA-Z0-9]+/, '');
const basePath = path.join(path.dirname(file.file), path.basename(stableId, path.extname(stableId)));
// Skip if in cache and NOT removing subtitles (prevents infinite loop)
if (isInCache && !inputs.remove_after_extract) {
response.infoLog = ' In cache cycle, skipping to prevent loop. ';
return response;
}
const extractedFiles = new Set();
const extractArgs = [];
const streamsToRemove = [];
for (const stream of subtitleStreams) {
const codec = (stream.codec_name || '').toLowerCase();
// Skip unsupported
if (UNSUPPORTED_SUBTITLES.has(codec) || IMAGE_SUBTITLES.has(codec)) continue;
// Check language filter
const lang = stream.tags?.language?.toLowerCase() || 'unknown';
if (extractLangs && !extractLangs.has(lang)) continue;
// Skip commentary
if (inputs.skip_commentary) {
const title = (stream.tags?.title || '').toLowerCase();
if (title.includes('commentary') || title.includes('description')) continue;
}
// Build unique filename
const safeLang = sanitizeFilename(lang);
let subsFile = `${basePath}.${safeLang}.srt`;
let counter = 1;
while ((extractedFiles.has(subsFile) || fileExistsValid(subsFile, fs)) && counter < MAX_FILENAME_ATTEMPTS) {
subsFile = `${basePath}.${safeLang}.${counter}.srt`;
counter++;
}
if (fileExistsValid(subsFile, fs)) continue;
extractArgs.push('-map', `0:${stream.index}`, subsFile);
extractedFiles.add(subsFile);
streamsToRemove.push(stream.index);
}
if (extractArgs.length === 0) {
response.infoLog = '✅ No subtitles to extract (all exist or filtered). ';
return response;
}
// Execute extraction
const ffmpegPath = otherArguments?.ffmpegPath || 'tdarr-ffmpeg';
const cmdParts = [ffmpegPath, '-y', '-i', sanitizeForShell(file.file)];
for (let i = 0; i < extractArgs.length; i++) {
if (extractArgs[i] === '-map') {
cmdParts.push('-map', extractArgs[i + 1]);
i++;
} else {
cmdParts.push(sanitizeForShell(extractArgs[i]));
}
}
const extractCount = streamsToRemove.length;
response.infoLog += `✅ Extracting ${extractCount} subtitle(s)... `;
try {
const execCmd = cmdParts.join(' ');
execSync(execCmd, { stdio: 'pipe', timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
response.infoLog += 'Done. ';
} catch (e) {
const errorMsg = e.stderr ? e.stderr.toString() : e.message;
response.infoLog += `⚠️ Extraction failed: ${errorMsg}. `;
if (!inputs.remove_after_extract) return response;
response.infoLog += 'Proceeding with removal regardless. ';
}
// Remove subtitles from container if requested
if (inputs.remove_after_extract && streamsToRemove.length > 0) {
let preset = '<io> -map 0';
streamsToRemove.forEach((idx) => { preset += ` -map -0:${idx}`; });
preset += ' -c copy -max_muxing_queue_size 9999';
response.preset = preset;
response.processFile = true;
response.reQueueAfter = true;
response.infoLog += `✅ Removing ${streamsToRemove.length} embedded subtitle(s). `;
} else {
response.infoLog += '✅ Subtitles extracted, container unchanged. ';
}
// Final Summary block
if (extractCount > 0) {
response.infoLog += '\n\n📋 Final Processing Summary:\n';
response.infoLog += ` Subtitles extracted: ${extractCount}\n`;
if (inputs.remove_after_extract) {
response.infoLog += ` - Embedded subtitles removed from container\n`;
} else {
response.infoLog += ` - Embedded subtitles preserved\n`;
}
}
return response;
} catch (error) {
response.processFile = false;
response.preset = '';
response.reQueueAfter = false;
response.infoLog = `❌ Plugin error: ${error.message}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;

View File

@@ -0,0 +1,190 @@
/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_06_cc_extraction',
Stage: 'Pre-processing',
Name: '06 - CC Extraction (CCExtractor)',
Type: 'Video',
Operation: 'Transcode',
Description: `
Extracts closed captions (eia_608/cc_dec) from video files using CCExtractor.
- Outputs to external .cc.srt file alongside the video
- Optionally embeds extracted CC back into the container as a subtitle track
**Requirements**: CCExtractor must be installed and available in PATH.
**Single Responsibility**: Closed caption extraction only.
Run AFTER subtitle extraction, BEFORE audio standardizer.
`,
Version: '4.0.0',
Tags: 'action,ffmpeg,subtitles,cc,ccextractor',
Inputs: [
{
name: 'extract_cc',
type: 'string',
defaultValue: 'false',
inputUI: { type: 'dropdown', options: ['false', 'true'] },
tooltip: 'Enable CC extraction via CCExtractor. Requires CCExtractor installed.',
},
{
name: 'embed_extracted_cc',
type: 'string',
defaultValue: 'false',
inputUI: { type: 'dropdown', options: ['false', 'true'] },
tooltip: 'Embed the extracted CC file back into the container as a subtitle track.',
},
],
});
// Constants
const CC_CODECS = new Set(['eia_608', 'cc_dec']);
const BOOLEAN_INPUTS = ['extract_cc', 'embed_extracted_cc'];
const MIN_CC_SIZE = 50;
// Utilities
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
const sanitizeForShell = (str) => {
if (typeof str !== 'string') throw new TypeError('Input must be a string');
str = str.replace(/\0/g, '');
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
};
const hasClosedCaptions = (streams) => streams.some((s) => {
const codec = (s.codec_name || '').toLowerCase();
const tag = (s.codec_tag_string || '').toLowerCase();
return CC_CODECS.has(codec) || tag === 'cc_dec';
});
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: true,
reQueueAfter: false,
infoLog: '',
};
try {
// Sanitize inputs and convert booleans
inputs = lib.loadDefaultValues(inputs, details);
Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); });
BOOLEAN_INPUTS.forEach((k) => { inputs[k] = inputs[k] === 'true'; });
if (!inputs.extract_cc) {
response.infoLog = '✅ CC extraction disabled. ';
return response;
}
const streams = file.ffProbeData?.streams;
if (!Array.isArray(streams)) {
response.infoLog = '❌ No stream data available. ';
return response;
}
// Early exit optimization: no CC streams = nothing to do
if (!hasClosedCaptions(streams)) {
response.infoLog = '✅ No closed captions detected. ';
return response;
}
// Build CC output path
const basePath = path.join(path.dirname(file.file), path.basename(file.file, path.extname(file.file)));
const ccFile = `${basePath}.cc.srt`;
const ccLockFile = `${ccFile}.lock`;
// Check if CC file already exists
try {
const stats = fs.statSync(ccFile);
if (stats.size > MIN_CC_SIZE) {
response.infoLog = ' CC file already exists. ';
if (inputs.embed_extracted_cc) {
const safeCCFile = sanitizeForShell(ccFile);
const subCount = streams.filter((s) => s.codec_type === 'subtitle').length;
response.preset = `<io> -i ${safeCCFile} -map 0 -map 1:0 -c copy -c:s:${subCount} srt -metadata:s:s:${subCount} language=eng -metadata:s:s:${subCount} title="Closed Captions" -max_muxing_queue_size 9999`;
response.processFile = true;
response.reQueueAfter = true;
response.infoLog += '✅ Embedding existing CC file. ';
}
return response;
}
} catch { /* File doesn't exist, proceed */ }
// Prevent concurrent extraction via lock file
try {
fs.writeFileSync(ccLockFile, process.pid.toString(), { flag: 'wx' });
} catch (e) {
if (e.code === 'EEXIST') {
response.infoLog = ' CC extraction in progress by another worker. ';
return response;
}
throw e;
}
// Execute CCExtractor
const safeInput = sanitizeForShell(file.file);
const safeCCFile = sanitizeForShell(ccFile);
response.infoLog += '✅ Extracting CC... ';
try {
execSync(`ccextractor ${safeInput} -o ${safeCCFile}`, { stdio: 'pipe', timeout: 180000, maxBuffer: 10 * 1024 * 1024 });
response.infoLog += 'Done. ';
} catch (e) {
const errorMsg = e.stderr ? e.stderr.toString() : e.message;
response.infoLog += `⚠️ CCExtractor failed: ${errorMsg}. `;
try { fs.unlinkSync(ccLockFile); } catch { /* ignore */ }
return response;
}
// Clean up lock file
try { fs.unlinkSync(ccLockFile); } catch { /* ignore */ }
// Verify CC file
try {
if (fs.statSync(ccFile).size < MIN_CC_SIZE) {
response.infoLog += ' No closed captions found. ';
return response;
}
} catch {
response.infoLog += '⚠️ CC file not created. ';
return response;
}
// Embed if requested
if (inputs.embed_extracted_cc) {
const subCount = streams.filter((s) => s.codec_type === 'subtitle').length;
response.preset = `<io> -i ${safeCCFile} -map 0 -map 1:0 -c copy -c:s:${subCount} srt -metadata:s:s:${subCount} language=eng -metadata:s:s:${subCount} title="Closed Captions" -max_muxing_queue_size 9999`;
response.processFile = true;
response.reQueueAfter = true;
response.infoLog += '✅ Embedding CC file. ';
} else {
response.infoLog += '✅ CC extracted to external file. ';
}
// Final Summary block
if (inputs.embed_extracted_cc) {
response.infoLog += '\n\n📋 Final Processing Summary:\n';
response.infoLog += ` CC extraction: Completed\n`;
response.infoLog += ` - CC embedded as subtitle track\n`;
}
return response;
} catch (error) {
response.processFile = false;
response.preset = '';
response.reQueueAfter = false;
response.infoLog = `❌ Plugin error: ${error.message}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,24 @@ const details = () => ({
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.
ALWAYS preserves original channel count for all tracks. Creates additional downmixed tracks
(8ch->6ch, 6ch/8ch->2ch) as SECONDARY tracks when enabled. Original multichannel tracks are never
replaced - downmix tracks are added alongside them.
v1.15: Fixed duplicate description, added default markers, improved tooltips.
v1.14: Fixed crash when input file has no subtitles (conditional mapping).
v1.24: Opus channel mapping fix - added mapping_family 1 for multichannel Opus and channel
reordering filters for incompatible source layouts (AC3 2.1, 4.1 etc.). Robust fallback
to AAC for layouts exceeding Opus limits (>8ch).
v1.23: CRITICAL FIX - Always preserves original channels. channel_mode now only affects whether
original tracks are downmixed (legacy option). create_downmix creates ADDITIONAL stereo
tracks, never replaces originals. Improved duplicate stereo detection per language.
v1.22: Fixed channel preservation - Opus-incompatible layouts now use AAC fallback instead of
stereo downmix. Smart downmix: one stereo per language, excludes commentary tracks.
v1.21: Added set_default_by_channels option - sets the audio track with the most channels as the
default stream. Ensures surround audio is preferred over stereo in players.
v1.20: Fixed channel preservation - now explicitly sets channel count to prevent FFmpeg defaulting to stereo.
Added channel count to all track titles. Updated default behavior to convert to OPUS unless already AAC.
`,
Version: '1.15',
Version: '4.0.0',
Tags: 'audio,aac,opus,channels,stereo,downmix,quality',
Inputs: [
{
@@ -39,7 +50,7 @@ const details = () => ({
'false'
],
},
tooltip: 'Skip conversion if audio is already AAC or Opus (either format acceptable). When false, converts to target codec.',
tooltip: 'Skip conversion if audio is already AAC or Opus (either format acceptable). When true (default), keeps AAC as-is and converts other codecs to OPUS. When false, converts to exact target codec.',
},
{
name: 'bitrate_per_channel',
@@ -72,7 +83,7 @@ const details = () => ({
'mono'
],
},
tooltip: 'Channel handling for existing tracks: preserve=keep original channels, stereo=downmix to 2.0, mono=downmix to 1.0.',
tooltip: 'Channel handling for existing tracks: preserve=keep original channels (RECOMMENDED), stereo=downmix original to 2.0 (legacy), mono=downmix to 1.0. Note: create_downmix creates ADDITIONAL tracks, original channels are always preserved when preserve is selected.',
},
{
name: 'create_downmix',
@@ -85,7 +96,7 @@ const details = () => ({
'true*'
],
},
tooltip: 'Create stereo (2ch) downmix from multichannel audio (5.1/7.1). Only creates if no stereo track exists AND multichannel source is present.',
tooltip: 'Create ADDITIONAL stereo (2ch) downmix tracks from multichannel audio (5.1/7.1). Original channels are ALWAYS preserved. Only creates if no stereo track exists for that language AND multichannel source is present. Creates as SECONDARY track alongside original.',
},
{
name: 'downmix_single_track',
@@ -225,6 +236,19 @@ const details = () => ({
],
},
tooltip: 'Quality presets automatically configure bitrate settings. Use "custom" to manually set bitrate per channel and other encoder options.',
},
{
name: 'set_default_by_channels',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: [
'false',
'true*'
],
},
tooltip: 'Set the default audio stream to the track with the most channels. This ensures surround audio is preferred by default in players. Runs after all audio processing is complete.',
}
],
});
@@ -243,32 +267,75 @@ const CHANNEL_MODES = {
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'];
const VALID_BITRATES = new Set(['auto', '64', '80', '96', '128', '160', '192', 'original']);
const VALID_BOOLEAN_VALUES = new Set(['true', 'false']);
const VALID_OPUS_APPLICATIONS = new Set(['audio', 'voip', 'lowdelay']);
const VALID_OPUS_VBR_MODES = new Set(['on', 'off', 'constrained']);
const VALID_OPUS_COMPRESSION = new Set(['0', '5', '8', '10']);
const VALID_AAC_PROFILES = new Set(['aac_low', 'aac_he', 'aac_he_v2']);
const VALID_SAMPLE_RATES = new Set(['original', '48000', '44100', '32000']);
const VALID_QUALITY_PRESETS = new Set(['custom', 'high_quality', 'balanced', 'small_size']);
// 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'
]);
// Container-aware subtitle compatibility
const MKV_INCOMPATIBLE_SUBS = new Set(['mov_text', 'tx3g']);
const MP4_INCOMPATIBLE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub', 'subrip', 'ass', 'ssa', 'webvtt']);
const MP4_CONVERTIBLE_SUBS = new Set(['subrip', 'ass', 'ssa', 'webvtt', 'text']);
const IMAGE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
const isOpusIncompatibleLayout = (layout) => {
if (!layout) return false;
// If not in compatible list, it's incompatible
return !OPUS_COMPATIBLE_LAYOUTS.has(layout);
// Opus supports up to 8 channels using Vorbis mapping (Family 1)
const OPUS_MAX_CHANNELS = 8;
/**
* Determines Opus encoding parameters for a stream based on standard Vorbis mapping rules.
* This fixes issues where players default to stereo or fail on multichannel Opus.
* Returns { family: number, filter: string | null, incompatible: boolean, reason: string | null }
*/
const getOpusMappingInfo = (stream) => {
const channels = stream.channels || 0;
const layout = stream.channel_layout || 'unknown';
const codec = stream.codec_name || '';
// Opus hard limit is 8 channels for most containers/players (Family 1)
if (channels < 1 || channels > OPUS_MAX_CHANNELS) {
return { incompatible: true, reason: `Channel count (${channels}) exceeds Opus limit of 8`, family: 0, filter: null };
}
const info = {
family: channels > 2 ? 1 : 0,
filter: null,
incompatible: false,
reason: null
};
// Channel reordering logic for specific source layouts that don't match Vorbis order.
// We use the 'pan' filter to ensure channels are in the correct position for Opus.
// Mapping logic based on standard broadcast and movie audio formats.
if (codec === 'ac3' || codec === 'eac3' || codec === 'dts') {
// 2.1 (L R LFE) -> Opus 3.0 (L C R) - Vorbis order expects L C R
// AC3 2.1 index: 0=L, 1=R, 2=LFE -> Target index: 0=L, 1=C(LFE), 2=R
if (layout === '2.1' || (channels === 3 && layout === 'unknown')) {
info.filter = 'pan=3.0|c0=c0|c1=c2|c2=c1';
}
// 3.1 (L C R LFE) -> Opus 4.0 (L R LS RS)
// Map LFE and Center to surround positions to preserve all elements
else if (layout === '3.1') {
info.filter = 'pan=4.0|c0=c0|c1=c2|c2=c1|c3=c3';
}
// 4.1 (L R LS RS LFE) -> Opus 5.0 (L C R LS RS)
// Map LFE to Center to preserve layout balance
else if (layout === '4.1') {
info.filter = 'pan=5.0|c0=c0|c1=c4|c2=c1|c3=c2|c4=c3';
}
// 5.1 and 7.1 usually match standard Vorbis order correctly in FFmpeg
}
// If layout is still unknown and channels are non-standard, we should be cautious
if (layout === 'unknown' && channels > 2 && channels !== 3 && channels !== 4 && channels !== 5 && channels !== 6 && channels !== 8) {
info.incompatible = true;
info.reason = `Non-standard channel count (${channels}) with unknown layout`;
}
return info;
};
const QUALITY_PRESETS = {
@@ -302,18 +369,31 @@ const needsTranscoding = (stream, inputs, targetCodec) => {
// Force transcode if explicitly requested
if (inputs.force_transcode === 'true') return true;
// Check if channel layout needs changing
// IMPORTANT: channel_mode 'stereo' and 'mono' are legacy options that downmix original tracks.
// The recommended approach is 'preserve' + create_downmix=true to keep originals AND add downmix.
// We still support legacy mode for backward compatibility, but it's not recommended.
// Check if channel layout needs changing (legacy mode only)
if (inputs.channel_mode === 'stereo' && stream.channels > 2) return true;
if (inputs.channel_mode === 'mono' && stream.channels > 1) return true;
// 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)
// Default behavior when skip_if_compatible is true: Convert to OPUS unless already AAC
// This means: keep AAC as-is, convert everything else (including Opus) to target codec
if (inputs.skip_if_compatible === 'true') {
if (inputs.codec === CODECS.OPUS) {
// Special case: Keep AAC, convert everything else to Opus
if (stream.codec_name === CODECS.AAC) {
return false; // Keep AAC
}
// If already Opus and matches target, skip (but allow re-encoding if bitrate/channels change)
if (targetCodec.includes(stream.codec_name)) {
return false; // Already correct codec
}
}
// For other cases, skip if already a compatible codec (AAC or Opus)
return !COMPATIBLE_CODECS.includes(stream.codec_name);
}
// Otherwise, only accept exact target codec match
// This means: if codec doesn't match target, transcode
// When skip_if_compatible is false, only accept exact target codec match
return !targetCodec.includes(stream.codec_name);
};
@@ -356,7 +436,9 @@ const applyQualityPreset = (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.`);
// Log warning if preset not found, fallback to custom (should be caught by validateInputs though)
// console.warn(`Warning: Quality preset '${inputs.quality_preset}' not found, using custom settings.`);
// Changing to silent return as validation handles it, or could throw error.
return inputs;
}
@@ -376,14 +458,19 @@ const applyQualityPreset = (inputs) => {
return modifiedInputs;
};
const buildCodecArgs = (audioIdx, inputs, targetBitrate) => {
const buildCodecArgs = (audioIdx, inputs, targetBitrate, channels = 0) => {
if (inputs.codec === CODECS.OPUS) {
// For Opus, apply mapping family 1 for multichannel (3-8 channels)
// This fixes issues where players default to stereo or fail on multichannel Opus
const mappingArgs = (channels > 2 && channels <= 8) ? ` -mapping_family:a:${audioIdx} 1` : '';
// Note: -vbr, -application, -compression_level are encoder-global options
// They are added once at the end of the command via getOpusGlobalArgs()
return [
`-c:a:${audioIdx} libopus`,
targetBitrate ? `-b:a:${audioIdx} ${targetBitrate}k` : ''
].filter(Boolean).join(' ');
targetBitrate ? `-b:a:${audioIdx} ${targetBitrate}k` : '',
mappingArgs
].filter(Boolean).join(' ').trim();
}
// AAC with profile selection
@@ -411,31 +498,61 @@ const getSampleRateArgs = (audioIdx, inputs) => {
return ` -ar:a:${audioIdx} ${inputs.target_sample_rate}`;
};
// Simple sanitizer to keep FFmpeg metadata titles/languages safe and unquoted
const sanitizeMetadataValue = (value) => {
if (!value) return '';
return String(value)
.replace(/["']/g, '') // strip both single and double quotes
.replace(/\s+/g, ' ') // collapse whitespace
.trim();
};
// Returns metadata preservation arguments
const getMetadataArgs = (audioIdx, stream, inputs, customTitle = null) => {
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}"`);
const channelCount = stream.channels || 0;
// Title handling
let rawTitle;
if (customTitle) {
rawTitle = customTitle;
} else if (inputs.preserve_metadata === 'true' && stream.tags?.title) {
rawTitle = `${stream.tags.title} (${channelCount}ch)`;
} else {
rawTitle = `${channelCount}ch`;
}
if (stream.tags?.language) {
args.push(`-metadata:s:a:${audioIdx} language="${stream.tags.language}"`);
const safeTitle = sanitizeMetadataValue(rawTitle);
if (safeTitle) {
// Note: Wrapping the value in double quotes is necessary for titles with spaces
args.push(`-metadata:s:a:${audioIdx} title="${safeTitle}"`);
}
// Language handling
if (inputs.preserve_metadata === 'true' && stream.tags?.language) {
const safeLang = sanitizeMetadataValue(stream.tags.language).toLowerCase() || 'und';
args.push(`-metadata:s:a:${audioIdx} language="${safeLang}"`);
}
return args.length > 0 ? ' ' + args.join(' ') : '';
};
const buildChannelArgs = (audioIdx, inputs) => {
const buildChannelArgs = (audioIdx, inputs, streamChannels = null) => {
switch (inputs.channel_mode) {
case CHANNEL_MODES.STEREO:
// Legacy mode: downmix original track to stereo (not recommended)
// Recommended: use 'preserve' + create_downmix=true instead
return ` -ac:a:${audioIdx} 2`;
case CHANNEL_MODES.MONO:
// Legacy mode: downmix original track to mono (not recommended)
return ` -ac:a:${audioIdx} 1`;
case CHANNEL_MODES.PRESERVE:
default:
// ALWAYS preserve original channel count to prevent FFmpeg from defaulting to stereo
// This is the recommended mode - original channels are preserved, downmix tracks are added separately
if (streamChannels !== null && streamChannels > 0) {
return ` -ac:a:${audioIdx} ${streamChannels}`;
}
return '';
}
};
@@ -446,18 +563,14 @@ const buildDownmixArgs = (audioIdx, streamIndex, stream, inputs, channels) => {
// 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(' ');
}
// Determine codec for downmix: use AAC if source is AAC (and we're keeping it), otherwise use Opus
// This ensures downmix matches the codec of the preserved/transcoded track
const useAacForDownmix = stream.codec_name === CODECS.AAC &&
inputs.skip_if_compatible === 'true' &&
inputs.codec === CODECS.OPUS;
if (useAacForDownmix) {
// Use AAC for downmix when source is AAC
const aacProfile = inputs.aac_profile === 'aac_low' ? 'aac' : inputs.aac_profile;
return baseArgs + [
aacProfile,
@@ -465,7 +578,21 @@ const buildDownmixArgs = (audioIdx, streamIndex, stream, inputs, channels) => {
'-strict -2',
`-ac ${channels}`,
getSampleRateArgs(audioIdx, inputs),
getMetadataArgs(audioIdx, stream, inputs, `${channels}.0 Downmix`)
getMetadataArgs(audioIdx, stream, inputs, `${channels}.0 Downmix (${sanitizeMetadataValue(stream.tags?.title || 'Unknown Source')})`)
].filter(Boolean).join(' ');
}
// Default to Opus for downmix
// For Opus downmix, apply mapping family 1 if channels > 2 (e.g., 5.1 downmix)
const mappingArgs = (channels > 2 && channels <= 8) ? `-mapping_family:a:${audioIdx} 1` : '';
return baseArgs + [
'libopus',
`-b:a:${audioIdx} ${downmixBitrate}k`,
mappingArgs,
`-ac ${channels}`,
getSampleRateArgs(audioIdx, inputs),
getMetadataArgs(audioIdx, stream, inputs, `${channels}.0 Downmix (${sanitizeMetadataValue(stream.tags?.title || 'Unknown Source')})`)
].filter(Boolean).join(' ');
};
@@ -496,11 +623,17 @@ const logStreamInfo = (stream, index) => {
return info;
};
// Inline utilities (Tdarr plugins must be self-contained)
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
const sanitizeInputsLocal = (inputs) => {
Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); });
return inputs;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
// Initialize response first for error handling
const response = {
processFile: false,
preset: '',
@@ -512,21 +645,7 @@ const plugin = (file, librarySettings, inputs, otherArguments) => {
};
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]);
});
inputs = sanitizeInputsLocal(lib.loadDefaultValues(inputs, details));
const validateInputs = (inputs) => {
const errors = [];
@@ -541,17 +660,18 @@ const plugin = (file, librarySettings, inputs, otherArguments) => {
'create_6ch_downmix',
'downmix_single_track',
'force_transcode',
'preserve_metadata'
'preserve_metadata',
'set_default_by_channels'
];
for (const input of booleanInputs) {
if (!VALID_BOOLEAN_VALUES.includes(inputs[input])) {
if (!VALID_BOOLEAN_VALUES.has(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 (!VALID_BITRATES.has(inputs.bitrate_per_channel)) {
errors.push(`Invalid bitrate_per_channel - must be one of: ${Array.from(VALID_BITRATES).join(', ')}`);
}
if (!Object.values(CHANNEL_MODES).includes(inputs.channel_mode)) {
@@ -559,31 +679,31 @@ const plugin = (file, librarySettings, inputs, otherArguments) => {
}
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_APPLICATIONS.has(inputs.opus_application)) {
errors.push(`Invalid opus_application - must be one of: ${Array.from(VALID_OPUS_APPLICATIONS).join(', ')}`);
}
if (!VALID_OPUS_VBR_MODES.includes(inputs.opus_vbr)) {
errors.push(`Invalid opus_vbr - must be one of: ${VALID_OPUS_VBR_MODES.join(', ')}`);
if (!VALID_OPUS_VBR_MODES.has(inputs.opus_vbr)) {
errors.push(`Invalid opus_vbr - must be one of: ${Array.from(VALID_OPUS_VBR_MODES).join(', ')}`);
}
if (!VALID_OPUS_COMPRESSION.includes(inputs.opus_compression)) {
errors.push(`Invalid opus_compression - must be one of: ${VALID_OPUS_COMPRESSION.join(', ')}`);
if (!VALID_OPUS_COMPRESSION.has(inputs.opus_compression)) {
errors.push(`Invalid opus_compression - must be one of: ${Array.from(VALID_OPUS_COMPRESSION).join(', ')}`);
}
}
if (inputs.codec === CODECS.AAC) {
if (!VALID_AAC_PROFILES.includes(inputs.aac_profile)) {
errors.push(`Invalid aac_profile - must be one of: ${VALID_AAC_PROFILES.join(', ')}`);
if (!VALID_AAC_PROFILES.has(inputs.aac_profile)) {
errors.push(`Invalid aac_profile - must be one of: ${Array.from(VALID_AAC_PROFILES).join(', ')}`);
}
}
if (!VALID_SAMPLE_RATES.includes(inputs.target_sample_rate)) {
errors.push(`Invalid target_sample_rate - must be one of: ${VALID_SAMPLE_RATES.join(', ')}`);
if (!VALID_SAMPLE_RATES.has(inputs.target_sample_rate)) {
errors.push(`Invalid target_sample_rate - must be one of: ${Array.from(VALID_SAMPLE_RATES).join(', ')}`);
}
if (!VALID_QUALITY_PRESETS.includes(inputs.quality_preset)) {
errors.push(`Invalid quality_preset - must be one of: ${VALID_QUALITY_PRESETS.join(', ')}`);
if (!VALID_QUALITY_PRESETS.has(inputs.quality_preset)) {
errors.push(`Invalid quality_preset - must be one of: ${Array.from(VALID_QUALITY_PRESETS).join(', ')}`);
}
return errors;
@@ -627,13 +747,30 @@ const plugin = (file, librarySettings, inputs, otherArguments) => {
const targetCodec = inputs.codec === CODECS.OPUS ? [CODECS.OPUS, CODECS.LIBOPUS] : [CODECS.AAC];
// Helper to resolve channel counts using MediaInfo when ffprobe fails
const getChannelCount = (stream, file) => {
let channels = parseInt(stream.channels || 0);
if (channels > 0) return channels;
// Try MediaInfo fallback
const miStreams = file?.mediaInfo?.track;
if (Array.isArray(miStreams)) {
const miStream = miStreams.find((t) => t['@type'] === 'Audio' && t.StreamOrder == stream.index);
const miChannels = parseInt(miStream?.Channels || 0);
if (miChannels > 0) return miChannels;
}
return channels;
};
try {
for (let i = 0; i < file.ffProbeData.streams.length; i++) {
const stream = file.ffProbeData.streams[i];
if (stream.codec_type === 'audio') {
audioStreams.push({ index: i, ...stream });
const channels = getChannelCount(stream, file);
audioStreams.push({ index: i, ...stream, channels });
const warnings = validateStream(stream, i);
const warnings = validateStream({ ...stream, channels }, i);
streamWarnings.push(...warnings);
if (needsTranscoding(stream, inputs, targetCodec)) {
@@ -665,7 +802,16 @@ const plugin = (file, librarySettings, inputs, otherArguments) => {
});
}
if (!needsTranscode && inputs.create_downmix !== 'true') {
// EARLY EXIT OPTIMIZATION:
// If no transcode needed AND (downmix disabled OR (downmix enabled but no multichannel source))
// We can exit before doing the heavy stream processing loop
const hasMultichannel = audioStreams.some(s => s.channels > 2);
if (!needsTranscode && inputs.create_downmix === 'true' && !hasMultichannel && inputs.create_6ch_downmix !== 'true') {
response.infoLog += '✅ File already meets all requirements (No transcoding needed, no multichannel audio for downmix).\n';
return response;
}
if (!needsTranscode && inputs.create_downmix !== 'true' && inputs.create_6ch_downmix !== 'true') {
response.infoLog += '✅ File already meets all requirements.\n';
return response;
}
@@ -674,26 +820,118 @@ const plugin = (file, librarySettings, inputs, otherArguments) => {
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 video first, then we'll map audio streams individually as we process them
// This prevents conflicts when adding downmix tracks
let streamMap = '-map 0:v';
// Check if file has subtitle streams before mapping them
const hasSubtitles = file.ffProbeData.streams.some(s => s.codec_type === 'subtitle');
const container = (file.container || '').toLowerCase();
// Analyze subtitles for container compatibility
// Helper for robust subtitle identification (synced with Plugin 04)
const getSubtitleCodec = (stream, file) => {
let codecName = (stream.codec_name || '').toLowerCase();
if (codecName && codecName !== 'none' && codecName !== 'unknown') return codecName;
// FFmpeg reports 'none' for some codecs it can't identify (like WEBVTT in MKV)
const codecTag = (stream.codec_tag_string || '').toUpperCase();
if (codecTag.includes('WEBVTT')) return 'webvtt';
if (codecTag.includes('ASS')) return 'ass';
if (codecTag.includes('SSA')) return 'ssa';
const tagCodecId = (stream.tags?.CodecID || stream.tags?.codec_id || '').toUpperCase();
if (tagCodecId.includes('WEBVTT') || tagCodecId.includes('S_TEXT/WEBVTT')) return 'webvtt';
if (tagCodecId.includes('ASS') || tagCodecId.includes('S_TEXT/ASS')) return 'ass';
if (tagCodecId.includes('SSA') || tagCodecId.includes('S_TEXT/SSA')) return 'ssa';
const miStreams = file?.mediaInfo?.track;
if (Array.isArray(miStreams)) {
const miStream = miStreams.find((t) => t['@type'] === 'Text' && t.StreamOrder == stream.index);
const miCodec = (miStream?.CodecID || '').toLowerCase();
if (miCodec.includes('webvtt')) return 'webvtt';
if (miCodec.includes('ass')) return 'ass';
if (miCodec.includes('ssa')) return 'ssa';
}
// Try ExifTool (meta) fallback
const meta = file?.meta;
if (meta) {
const trackName = (stream.tags?.title || '').toLowerCase();
if (trackName.includes('webvtt')) return 'webvtt';
}
return codecName || 'unknown';
};
let subtitleHandling = '';
let subtitleLog = '';
if (hasSubtitles) {
const subtitleStreams = file.ffProbeData.streams.filter(s => s.codec_type === 'subtitle');
const imageSubs = subtitleStreams.filter(s => IMAGE_SUBS.has(getSubtitleCodec(s, file)));
const textSubs = subtitleStreams.filter(s => MP4_CONVERTIBLE_SUBS.has(getSubtitleCodec(s, file)));
if (container === 'mp4' || container === 'm4v') {
// MP4: drop image subs, convert text subs to mov_text
if (imageSubs.length > 0) {
// Don't map subtitles that are image-based for MP4
const compatibleSubs = subtitleStreams.filter(s => !IMAGE_SUBS.has(getSubtitleCodec(s, file)));
if (compatibleSubs.length > 0) {
// Map only compatible subs individually
compatibleSubs.forEach((s, i) => {
streamMap += ` -map 0:${file.ffProbeData.streams.indexOf(s)}`;
});
subtitleHandling = ' -c:s mov_text';
subtitleLog = `Dropping ${imageSubs.length} image sub(s), converting ${compatibleSubs.length} to mov_text. `;
} else {
subtitleLog = `Dropping ${imageSubs.length} image subtitle(s) (incompatible with MP4). `;
}
} else if (textSubs.length > 0) {
streamMap += ' -map 0:s';
subtitleHandling = ' -c:s mov_text';
subtitleLog = `Converting ${textSubs.length} subtitle(s) to mov_text. `;
} else {
streamMap += ' -map 0:s';
subtitleHandling = ' -c:s copy';
}
} else {
// MKV: convert mov_text to srt, keep others
streamMap += ' -map 0:s';
const movTextSubs = subtitleStreams.filter(s => getSubtitleCodec(s, file) === 'mov_text');
if (movTextSubs.length > 0) {
subtitleHandling = ' -c:s srt';
subtitleLog = `Converting ${movTextSubs.length} mov_text subtitle(s) to SRT. `;
} else {
subtitleHandling = ' -c:s copy';
}
}
}
if (hasAttachments) {
// Add attachments separately with copy codec
streamMap += ' -map 0:t -c:t copy';
}
let ffmpegArgs = `${streamMap} -c:v copy`;
if (hasSubtitles) {
ffmpegArgs += ' -c:s copy';
if (subtitleLog) {
response.infoLog += `📁 ${subtitleLog}\n`;
}
let audioIdx = 0;
if (hasAttachments) {
// Map attachments individually to avoid FFmpeg 7.x muxing errors
// FFmpeg 7.x has stricter handling of attachment streams - broad mapping with -map 0:t
// can cause \"Received a packet for an attachment stream\" errors when combined with
// additional audio mapping for downmix tracks
const attachmentStreams = file.ffProbeData.streams
.map((s, i) => ({ stream: s, index: i }))
.filter(({ stream }) => stream.codec_type === 'attachment');
attachmentStreams.forEach(({ index }) => {
streamMap += ` -map 0:${index}`;
});
// Attachments always use copy codec
streamMap += ' -c:t copy';
}
// Build audio stream mapping as we process each stream
let audioMapArgs = '';
let ffmpegArgs = `${streamMap} -c:v copy${subtitleHandling}`;
let processNeeded = false;
let is2channelAdded = false;
let transcodedStreams = 0;
@@ -704,45 +942,75 @@ const plugin = (file, librarySettings, inputs, otherArguments) => {
for (const stream of audioStreams) {
let streamNeedsTranscode = needsTranscoding(stream, inputs, targetCodec);
let forcePerStreamDownmix = false;
if (inputs.codec === CODECS.OPUS && isOpusIncompatibleLayout(stream.channel_layout)) {
// Track if we need to use AAC fallback for Opus-incompatible layouts
let useAacFallback = false;
let opusMapping = null;
if (inputs.codec === CODECS.OPUS) {
opusMapping = getOpusMappingInfo(stream);
if (opusMapping.incompatible) {
// Fallback to AAC if Opus cannot handle this layout/channel count
useAacFallback = true;
if (!streamNeedsTranscode) {
streamNeedsTranscode = true;
}
if (inputs.channel_mode === CHANNEL_MODES.PRESERVE) {
forcePerStreamDownmix = true;
}
}
// Map this audio stream individually
audioMapArgs += ` -map 0:${stream.index}`;
if (streamNeedsTranscode) {
const targetBitrate = calculateBitrate(inputs, stream.channels, stream.bit_rate);
const codecArgs = buildCodecArgs(audioIdx, inputs, targetBitrate);
let channelArgs = buildChannelArgs(audioIdx, inputs);
let codecArgs;
if (useAacFallback) {
// Use AAC for incompatible layouts to preserve channel count
const aacProfile = inputs.aac_profile === 'aac_low' ? 'aac' : inputs.aac_profile;
codecArgs = [
`-c:a:${audioIdx} ${aacProfile}`,
targetBitrate ? `-b:a:${audioIdx} ${targetBitrate}k` : '',
'-strict -2'
].filter(Boolean).join(' ');
} else {
codecArgs = buildCodecArgs(audioIdx, inputs, targetBitrate, stream.channels);
// Add pan filter if needed for Opus reordering
if (opusMapping && opusMapping.filter) {
codecArgs += ` -af:a:${audioIdx} "${opusMapping.filter}"`;
}
}
const channelArgs = buildChannelArgs(audioIdx, inputs, stream.channels);
const sampleRateArgs = getSampleRateArgs(audioIdx, inputs);
const metadataArgs = getMetadataArgs(audioIdx, stream, inputs);
if (forcePerStreamDownmix) {
channelArgs = ` -ac:a:${audioIdx} 2`;
}
ffmpegArgs += ` ${codecArgs}${channelArgs}${sampleRateArgs}${metadataArgs}`;
processNeeded = true;
transcodedStreams++;
if (useAacFallback) {
const reason = (opusMapping && opusMapping.reason) ? ` (${opusMapping.reason})` : '';
response.infoLog += `✅ Converting ${stream.codec_name} (${stream.channels}ch) to AAC (Opus-incompatible layout${reason}).\n`;
} else {
response.infoLog += `✅ Converting ${stream.codec_name} (${stream.channels}ch, ${stream.bit_rate ? Math.round(stream.bit_rate / 1000) + 'kbps' : 'unknown'}) to ${inputs.codec} at stream ${audioIdx}.\n`;
if (inputs.codec === CODECS.OPUS) {
if (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 (opusMapping && opusMapping.filter) {
response.infoLog += ` Applied Opus channel mapping fix (reordering filter) for layout "${stream.channel_layout}".\n`;
} else if (stream.channels > 2) {
response.infoLog += ` Applied Opus mapping family 1 for ${stream.channels}ch audio.\n`;
}
}
}
if (targetBitrate) {
response.infoLog += ` Target bitrate: ${targetBitrate}kbps${inputs.bitrate_per_channel === 'original' ? ' (from source)' : ` (${inputs.bitrate_per_channel}kbps per channel)`}\n`;
const bitrateSource = inputs.bitrate_per_channel === 'original' ? ' (from source)' :
inputs.bitrate_per_channel === 'auto' ? ' (auto: 64kbps/ch)' :
` (${inputs.bitrate_per_channel}kbps/ch)`;
response.infoLog += ` Target bitrate: ${targetBitrate}kbps${bitrateSource}\n`;
}
} else {
ffmpegArgs += ` -c:a:${audioIdx} copy`;
// Even when copying, we should add metadata to indicate channel count
const metadataArgs = getMetadataArgs(audioIdx, stream, inputs);
ffmpegArgs += ` -c:a:${audioIdx} copy${metadataArgs}`;
copiedStreams++;
if (inputs.skip_if_compatible === 'true' && COMPATIBLE_CODECS.includes(stream.codec_name)) {
response.infoLog += `✅ Keeping ${stream.codec_name} (${stream.channels}ch) - compatible format.\n`;
@@ -758,25 +1026,68 @@ const plugin = (file, librarySettings, inputs, otherArguments) => {
}
if (inputs.create_downmix === 'true') {
const existing2chTracks = audioStreams.filter(s => s.channels === 2);
// Helper to check if a track is commentary
const isCommentary = (stream) => {
const title = (stream.tags?.title || '').toLowerCase();
return title.includes('commentary') || title.includes('comment');
};
// Helper to get normalized language
const getLang = (stream) => (stream.tags?.language || 'und').toLowerCase();
// Track which languages already have a stereo track (non-commentary)
// This includes both existing stereo tracks AND any we're creating in this run
const langsWithStereo = new Set();
audioStreams.forEach(s => {
if (s.channels === 2 && !isCommentary(s)) {
langsWithStereo.add(getLang(s));
}
});
// Track which languages we've created downmixes for in this run
// This prevents creating multiple stereo tracks for the same language
const langsDownmixCreated = new Set();
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 lang = getLang(stream);
// Only create downmix from multichannel sources (6ch=5.1 or 8ch=7.1)
// Skip if not multichannel - we only downmix from surround sources
if (stream.channels !== 6 && stream.channels !== 8) continue;
// Skip commentary tracks - they usually don't need stereo versions
if (isCommentary(stream)) continue;
// Skip if this language already has a stereo track (existing or created in this run)
if (langsWithStereo.has(lang)) {
response.infoLog += ` Skipping ${lang} 2ch downmix - stereo track already exists for this language.\n`;
continue;
}
// Skip if we already created a downmix for this language (prevents duplicates)
if (langsDownmixCreated.has(lang)) {
response.infoLog += ` Skipping ${lang} 2ch downmix - already created one for this language.\n`;
continue;
}
// Create the ADDITIONAL downmix track (original channels are preserved above)
const downmixArgs = buildDownmixArgs(audioIdx, stream.index, stream, inputs, 2);
ffmpegArgs += downmixArgs;
response.infoLog += `✅ Creating 2ch downmix from ${stream.channels}ch audio.\n`;
response.infoLog += `✅ Creating ADDITIONAL 2ch downmix from ${stream.channels}ch ${lang} audio (original ${stream.channels}ch track preserved).\n`;
processNeeded = true;
is2channelAdded = true;
downmixStreams++;
audioIdx++;
}
// Track that we created a downmix for this language (prevents duplicates)
langsDownmixCreated.add(lang);
// Also mark this language as having stereo now (prevents future duplicates in same run)
langsWithStereo.add(lang);
// If single track mode, only create one total downmix across all languages
if (inputs.downmix_single_track === 'true') break;
}
} catch (error) {
response.infoLog += `❌ Error creating downmix tracks: ${error.message}\n`;
@@ -784,7 +1095,6 @@ const plugin = (file, librarySettings, inputs, otherArguments) => {
return response;
}
}
}
// Create 6ch (5.1) downmix from 8ch (7.1) if enabled
if (inputs.create_6ch_downmix === 'true') {
@@ -821,9 +1131,113 @@ const plugin = (file, librarySettings, inputs, otherArguments) => {
if (processNeeded) {
try {
response.processFile = true;
// Insert audio map arguments right after streamMap (before codec arguments)
// FFmpeg requires all -map commands before codec arguments
const audioMapInsertionPoint = streamMap.length;
ffmpegArgs = ffmpegArgs.slice(0, audioMapInsertionPoint) + audioMapArgs + ffmpegArgs.slice(audioMapInsertionPoint);
// Add global Opus encoder options once at the end if using Opus
const opusGlobalArgs = getOpusGlobalArgs(inputs);
response.preset = `<io>${ffmpegArgs}${opusGlobalArgs} -max_muxing_queue_size 9999`;
// Build disposition flags for setting default audio by channel count
let dispositionArgs = '';
if (inputs.set_default_by_channels === 'true') {
// Track final channel counts for all audio streams in output order
// audioIdx at this point represents the total number of audio tracks in output
const finalAudioTracks = [];
let trackIdx = 0;
// Original audio streams (in processing order)
// IMPORTANT: When channel_mode is 'preserve' (recommended), original channels are ALWAYS preserved
// Downmix tracks are created as ADDITIONAL tracks, not replacements
for (const stream of audioStreams) {
let finalChannels = stream.channels;
// Account for legacy channel mode changes (only if not 'preserve')
// Note: 'preserve' mode is recommended - it keeps originals and adds downmix separately
if (inputs.channel_mode === CHANNEL_MODES.STEREO && stream.channels > 2) {
finalChannels = 2; // Legacy mode: downmix original
} else if (inputs.channel_mode === CHANNEL_MODES.MONO && stream.channels > 1) {
finalChannels = 1; // Legacy mode: downmix original
}
// When 'preserve' mode: original channels are kept (AAC fallback for Opus-incompatible layouts
// also preserves channel count)
finalAudioTracks.push({ idx: trackIdx, channels: finalChannels });
trackIdx++;
}
// Downmix tracks (2ch)
// Downmix tracks (2ch) - Simulate exactly what we did above
// These are ADDITIONAL tracks created alongside originals, not replacements
if (inputs.create_downmix === 'true') {
const isCommentary = (stream) => {
const title = (stream.tags?.title || '').toLowerCase();
return title.includes('commentary') || title.includes('comment');
};
const getLang = (stream) => (stream.tags?.language || 'und').toLowerCase();
const langsWithStereo = new Set();
audioStreams.forEach(s => {
if (s.channels === 2 && !isCommentary(s)) {
langsWithStereo.add(getLang(s));
}
});
const langsDownmixCreated = new Set();
for (const stream of audioStreams) {
const lang = getLang(stream);
// Logic must match downmix creation exactly
if (stream.channels !== 6 && stream.channels !== 8) continue;
if (isCommentary(stream)) continue;
if (langsWithStereo.has(lang)) continue;
if (langsDownmixCreated.has(lang)) continue;
// We create a 2ch downmix track here (original multichannel track is preserved above)
finalAudioTracks.push({ idx: trackIdx, channels: 2 });
trackIdx++;
langsDownmixCreated.add(lang);
// Mark language as having stereo to prevent duplicates
langsWithStereo.add(lang);
if (inputs.downmix_single_track === 'true') break;
}
}
// 6ch downmix tracks
if (inputs.create_6ch_downmix === 'true') {
const existing6chTracks = audioStreams.filter(s => s.channels === 6);
const available8chTracks = audioStreams.filter(s => s.channels === 8);
if (existing6chTracks.length === 0 && available8chTracks.length > 0) {
for (const stream of audioStreams) {
if (stream.channels === 8) {
finalAudioTracks.push({ idx: trackIdx, channels: 6 });
trackIdx++;
if (inputs.downmix_single_track === 'true') break;
}
}
}
}
// Find track with highest channel count
if (finalAudioTracks.length > 0) {
const maxChannels = Math.max(...finalAudioTracks.map(t => t.channels));
const defaultTrackIdx = finalAudioTracks.find(t => t.channels === maxChannels).idx;
// Set disposition flags: default on highest channel track, remove default from others
for (let i = 0; i < finalAudioTracks.length; i++) {
if (i === defaultTrackIdx) {
dispositionArgs += ` -disposition:a:${i} default`;
} else {
// Clear default flag from other audio tracks
dispositionArgs += ` -disposition:a:${i} 0`;
}
}
response.infoLog += `🎯 Set default audio: track ${defaultTrackIdx} (${maxChannels}ch - highest channel count after all processing).\n`;
}
}
response.preset = `<io>${ffmpegArgs}${opusGlobalArgs}${dispositionArgs} -max_muxing_queue_size 9999`;
response.ffmpegMode = true;
response.reQueueAfter = true;
@@ -865,24 +1279,10 @@ const plugin = (file, librarySettings, inputs, otherArguments) => {
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`;
response.infoLog = `❌ Plugin error: ${error.message}\n`;
return response;
}
};

View File

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

View File

@@ -1,904 +0,0 @@
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.
v4.10: Fixed infinite loop - extracts subtitles to temp dir during plugin stack.
v4.9: Refactored for better maintainability - extracted helper functions.
`,
Version: '4.10',
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.',
},
],
});
// ============================================================================
// CONSTANTS
// ============================================================================
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
// ============================================================================
// HELPER PREDICATES
// ============================================================================
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');
};
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
const stripStar = (value) => {
if (typeof value === 'string') {
return value.replace(/\*/g, '');
}
return value;
};
const sanitizeForShell = (str) => {
if (typeof str !== 'string') {
throw new TypeError('Input must be a string');
}
str = str.replace(/\0/g, '');
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
};
const sanitizeFilename = (name, maxLength = 100) => {
const path = require('path');
if (typeof name !== 'string') {
return 'file';
}
name = path.basename(name);
name = name.replace(/[<>:"|?*\/\\\x00-\x1f\x7f]/g, '_');
name = name.replace(/^[\.\s]+|[\.\s]+$/g, '');
if (name.length === 0) {
name = 'file';
}
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;
};
const validateLanguageCodes = (codesString, maxCodes = 20) => {
if (typeof codesString !== 'string') {
return [];
}
return codesString
.split(',')
.map(code => code.trim().toLowerCase())
.filter(code => {
if (code.length === 0 || code.length > 10) return false;
if (!/^[a-z0-9-]+$/.test(code)) return false;
if (code.includes('..') || code.includes('/')) return false;
return true;
})
.slice(0, maxCodes);
};
const buildSafeBasePath = (filePath) => {
const path = require('path');
const parsed = path.parse(filePath);
return path.join(parsed.dir, parsed.name);
};
const fileExistsRobust = (filePath, fs) => {
try {
const stats = fs.statSync(filePath);
return stats.size > 0;
} catch (e) {
if (e.code === 'ENOENT') {
return false;
}
throw new Error(`Error checking file existence for ${filePath}: ${e.message}`);
}
};
const needsSubtitleExtraction = (subsFile, sourceFile, fs) => {
if (!fileExistsRobust(subsFile, fs)) {
return true;
}
try {
const subsStats = fs.statSync(subsFile);
if (subsStats.size < MIN_SUBTITLE_FILE_SIZE) {
return true;
}
return false;
} catch (e) {
return true;
}
};
// ============================================================================
// STREAM ANALYSIS FUNCTIONS
// ============================================================================
/**
* Partitions streams into matched and unmatched based on predicate
*/
const partitionStreams = (streams, predicate) => {
const matched = [];
const unmatched = [];
streams.forEach(s => (predicate(s) ? matched : unmatched).push(s));
return [matched, unmatched];
};
/**
* Categorizes and enriches streams from ffProbeData
*/
const categorizeStreams = (file) => {
const streams = file.ffProbeData.streams.map((stream, index) => ({
...stream,
typeIndex: index
}));
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');
const otherStreams = streams
.filter(s => !['video', 'audio', 'subtitle'].includes(s.codec_type))
.filter(stream => {
// Filter out BMP attached pictures (incompatible with MKV)
if (stream.disposition?.attached_pic === 1 && stream.codec_name === 'bmp') {
return false;
}
return true;
});
return {
all: streams,
original: streams.map(s => s.typeIndex),
video: videoStreams,
audio: audioStreams,
subtitle: subtitleStreams,
other: otherStreams
};
};
/**
* Reorders audio and subtitle streams by language priority
*/
const reorderStreamsByLanguage = (categorized, inputs, customEnglishCodes) => {
let reorderedAudio, reorderedSubtitles;
if (inputs.includeAudio === 'true') {
const [englishAudio, otherAudio] = partitionStreams(
categorized.audio,
s => isEnglishStream(s, customEnglishCodes)
);
reorderedAudio = [...englishAudio, ...otherAudio];
} else {
reorderedAudio = categorized.audio;
}
if (inputs.includeSubtitles === 'true') {
const [englishSubtitles, otherSubtitles] = partitionStreams(
categorized.subtitle,
s => isEnglishStream(s, customEnglishCodes)
);
reorderedSubtitles = [...englishSubtitles, ...otherSubtitles];
} else {
reorderedSubtitles = categorized.subtitle;
}
const reorderedStreams = [
...categorized.video,
...reorderedAudio,
...reorderedSubtitles,
...categorized.other
];
return {
reorderedStreams,
reorderedAudio,
reorderedSubtitles,
newOrder: reorderedStreams.map(s => s.typeIndex),
needsReorder: JSON.stringify(categorized.original) !== JSON.stringify(reorderedStreams.map(s => s.typeIndex))
};
};
/**
* Analyzes subtitle streams for conversion needs
*/
const analyzeSubtitleConversion = (subtitleStreams, inputs) => {
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++;
}
});
}
return {
needsConversion,
conversionCount,
hasProblematicSubs
};
};
// ============================================================================
// SUBTITLE EXTRACTION FUNCTIONS
// ============================================================================
/**
* Processes subtitle extraction - returns extraction command and metadata
*/
const processSubtitleExtraction = (subtitleStreams, inputs, otherArguments, file, fs, path, infoLog) => {
let extractCommand = '';
let extractCount = 0;
const extractedFiles = new Set();
const extractionAttempts = new Map();
if (inputs.extractSubtitles !== 'true' || subtitleStreams.length === 0) {
return { extractCommand, extractCount, extractedFiles, extractionAttempts, infoLog };
}
const { originalLibraryFile } = otherArguments;
if (!originalLibraryFile?.file) {
infoLog += '⚠️ Cannot extract subs: originalLibraryFile not available. ';
return { extractCommand, extractCount, extractedFiles, extractionAttempts, infoLog };
}
const baseFile = file.file;
const baseName = buildSafeBasePath(baseFile);
for (const stream of subtitleStreams) {
if (!stream.codec_name) {
infoLog += ` Skipping subtitle ${stream.typeIndex} (no codec). `;
continue;
}
if (isUnsupportedSubtitle(stream)) {
infoLog += ` Skipping subtitle ${stream.typeIndex} (${stream.codec_name}) incompatible. `;
continue;
}
if (IMAGE_SUBTITLE_CODECS.has(stream.codec_name)) {
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';
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;
while (extractedFiles.has(subsFile) && counter < MAX_FILENAME_ATTEMPTS) {
subsFile = `${baseName}.${safeLang}.${counter}.srt`;
counter++;
}
if (needsSubtitleExtraction(subsFile, baseFile, fs)) {
const attemptKey = `${baseFile}:${stream.typeIndex}`;
const attempts = extractionAttempts.get(attemptKey) || 0;
if (attempts >= MAX_EXTRACTION_ATTEMPTS) {
infoLog += `⚠️ Skipping ${path.basename(subsFile)} - extraction failed ${MAX_EXTRACTION_ATTEMPTS} times. `;
continue;
}
extractionAttempts.set(attemptKey, attempts + 1);
const safeSubsFile = sanitizeForShell(subsFile);
extractCommand += ` -map 0:${stream.typeIndex} ${safeSubsFile}`;
extractedFiles.add(subsFile);
extractCount++;
} else {
infoLog += ` ${path.basename(subsFile)} already exists, skipping. `;
}
}
if (extractCount > 0) {
infoLog += `✅ Extracting ${extractCount} subtitle(s). `;
}
return { extractCommand, extractCount, extractedFiles, extractionAttempts, infoLog };
};
/**
* Processes CC extraction via ccextractor
*/
const processCCExtraction = (subtitleStreams, inputs, otherArguments, fs, infoLog) => {
let ccExtractedFile = null;
let ccActuallyExtracted = false;
if (inputs.useCCExtractor !== 'true' || !subtitleStreams.some(isClosedCaption)) {
return { ccExtractedFile, ccActuallyExtracted, infoLog };
}
const { originalLibraryFile } = otherArguments;
if (!originalLibraryFile?.file) {
infoLog += '⚠️ Cannot extract CC: originalLibraryFile not available. ';
return { ccExtractedFile, ccActuallyExtracted, infoLog };
}
const baseFile = originalLibraryFile.file;
const baseName = buildSafeBasePath(baseFile);
const ccOut = `${baseName}.cc.srt`;
const ccLock = `${ccOut}.lock`;
const ccFileExists = fileExistsRobust(ccOut, fs);
try {
fs.writeFileSync(ccLock, process.pid.toString(), { flag: 'wx' });
try {
if (ccFileExists) {
infoLog += ' CC file exists. ';
if (inputs.embedExtractedCC === 'true') {
ccExtractedFile = ccOut;
ccActuallyExtracted = false;
}
} else {
ccExtractedFile = ccOut;
ccActuallyExtracted = true;
infoLog += '✅ Will extract CC via ccextractor. ';
}
} finally {
if (!ccActuallyExtracted && fs.existsSync(ccLock)) {
fs.unlinkSync(ccLock);
}
}
} catch (e) {
if (e.code === 'EEXIST') {
infoLog += '⏭️ CC extraction in progress by another worker. ';
if (ccFileExists && inputs.embedExtractedCC === 'true') {
ccExtractedFile = ccOut;
ccActuallyExtracted = false;
}
} else if (e.code === 'EACCES' || e.code === 'EPERM') {
throw new Error(`CC extraction failed: Permission denied - ${e.message}`);
} else {
infoLog += `⚠️ CC lock error: ${e.message}. `;
}
}
return { ccExtractedFile, ccActuallyExtracted, infoLog };
};
// ============================================================================
// FFMPEG COMMAND BUILDING FUNCTIONS
// ============================================================================
/**
* Checks if any processing is needed
*/
const needsProcessing = (needsReorder, needsConversion, extractCount, ccActuallyExtracted, ccExtractedFile, embedExtractedCC) => {
return needsReorder || needsConversion || extractCount > 0 || ccActuallyExtracted || (ccExtractedFile && embedExtractedCC === 'true');
};
/**
* Builds FFmpeg command for stream mapping and subtitle processing
*/
const buildFFmpegCommand = (analysis, inputs, customEnglishCodes) => {
const {
reorderedStreams,
needsConversion,
conversionCount,
hasProblematicSubs,
extractCommand,
extractCount,
ccExtractedFile,
ccActuallyExtracted
} = analysis;
let command = (inputs.extractSubtitles === 'true' && extractCount > 0) ? '-y <io>' : '<io>';
command += extractCommand;
if (inputs.removeAfterExtract === 'true' && inputs.extractSubtitles === 'true' && extractCount > 0) {
// Note: This message is added to infoLog outside this function
}
command += ' -c:v copy -c:a copy';
const includedSubtitleStreams = [];
let firstEnglishAudioIdx = null;
let firstEnglishSubIdx = null;
let audioOutputIdx = 0;
let subOutputIdx = 0;
// Build stream mapping
reorderedStreams.forEach(stream => {
if (inputs.extractSubtitles === 'true' && inputs.removeAfterExtract === 'true' && stream.codec_type === 'subtitle') {
return;
}
if (stream.codec_type !== 'subtitle') {
command += ` -map 0:${stream.typeIndex}`;
if (stream.codec_type === 'audio' && firstEnglishAudioIdx === null && isEnglishStream(stream, customEnglishCodes)) {
firstEnglishAudioIdx = audioOutputIdx;
}
if (stream.codec_type === 'audio') {
audioOutputIdx++;
}
return;
}
if (!stream.codec_name) {
return;
}
if (isUnsupportedSubtitle(stream)) {
return;
}
includedSubtitleStreams.push(stream);
command += ` -map 0:${stream.typeIndex}`;
if (firstEnglishSubIdx === null && isEnglishStream(stream, customEnglishCodes)) {
firstEnglishSubIdx = subOutputIdx;
}
subOutputIdx++;
});
// Build codec arguments for subtitles
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) {
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
if (inputs.setDefaultFlags === 'true') {
if (firstEnglishAudioIdx !== null) {
command += ` -disposition:a:${firstEnglishAudioIdx} default`;
}
if (firstEnglishSubIdx !== null) {
command += ` -disposition:s:${firstEnglishSubIdx} default`;
}
}
// Embed CC if needed
if (ccExtractedFile && inputs.embedExtractedCC === 'true') {
const fs = require('fs');
if (ccActuallyExtracted || fs.existsSync(ccExtractedFile)) {
const safeCCFile = sanitizeForShell(ccExtractedFile);
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"`;
}
}
return {
command,
firstEnglishAudioIdx,
firstEnglishSubIdx,
includedSubtitleCount: includedSubtitleStreams.length
};
};
/**
* Builds CC extraction command wrapper
*/
const buildCCExtractionCommand = (command, ccExtractedFile, otherArguments) => {
const { originalLibraryFile } = otherArguments;
const sourceFile = (originalLibraryFile?.file) || '';
const baseName = buildSafeBasePath(sourceFile);
const ccLock = `${baseName}.cc.srt.lock`;
const safeInput = sanitizeForShell(sourceFile);
const safeCCFile = sanitizeForShell(ccExtractedFile);
const safeLock = sanitizeForShell(ccLock);
const cleanupCmd = `rm -f ${safeLock}`;
const ccCmd = `ccextractor ${safeInput} -o ${safeCCFile}`;
return `${ccCmd}; ${cleanupCmd}; ${command}`;
};
// ============================================================================
// MAIN PLUGIN FUNCTION
// ============================================================================
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const fs = require('fs');
const path = require('path');
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]);
});
// Validate inputs
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
let customEnglishCodes = validateLanguageCodes(inputs.customLanguageCodes, MAX_LANGUAGE_CODES);
if (customEnglishCodes.length === 0) {
customEnglishCodes = ['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.');
}
// Categorize and reorder streams
const categorized = categorizeStreams(file);
const reorderResult = reorderStreamsByLanguage(categorized, inputs, customEnglishCodes);
// Log English stream counts
if (inputs.includeAudio === 'true') {
const englishAudioCount = categorized.audio.filter(s => isEnglishStream(s, customEnglishCodes)).length;
if (englishAudioCount > 0) {
response.infoLog += `${englishAudioCount} English audio first. `;
}
}
if (inputs.includeSubtitles === 'true') {
const englishSubCount = categorized.subtitle.filter(s => isEnglishStream(s, customEnglishCodes)).length;
if (englishSubCount > 0) {
response.infoLog += `${englishSubCount} English subs first. `;
}
}
// Filter BMP message
if (categorized.other.length < file.ffProbeData.streams.filter(s => !['video', 'audio', 'subtitle'].includes(s.codec_type)).length) {
response.infoLog += ' Excluding BMP attached picture (unsupported in MKV). ';
}
// Analyze subtitle conversion needs
const conversionAnalysis = analyzeSubtitleConversion(categorized.subtitle, inputs);
// Process subtitle extraction
const extractionResult = processSubtitleExtraction(
categorized.subtitle,
inputs,
otherArguments,
file,
fs,
path,
response.infoLog
);
response.infoLog = extractionResult.infoLog;
// Process CC extraction
const ccResult = processCCExtraction(
categorized.subtitle,
inputs,
otherArguments,
fs,
response.infoLog
);
response.infoLog = ccResult.infoLog;
// Check if processing is needed
if (!needsProcessing(
reorderResult.needsReorder,
conversionAnalysis.needsConversion,
extractionResult.extractCount,
ccResult.ccActuallyExtracted,
ccResult.ccExtractedFile,
inputs.embedExtractedCC
)) {
response.infoLog += '✅ No changes needed.';
return response;
}
response.processFile = true;
response.reQueueAfter = true;
if (reorderResult.needsReorder) {
response.infoLog += '✅ Reordering streams. ';
}
if (conversionAnalysis.needsConversion) {
if (conversionAnalysis.hasProblematicSubs && inputs.standardizeToSRT !== 'true') {
response.infoLog += `✅ Converting ${conversionAnalysis.conversionCount} WebVTT to SRT (compatibility). `;
} else {
response.infoLog += `✅ Converting ${conversionAnalysis.conversionCount} to SRT. `;
}
}
if (inputs.removeAfterExtract === 'true' && inputs.extractSubtitles === 'true' && extractionResult.extractCount > 0) {
response.infoLog += '✅ Removing embedded subs. ';
}
// Build FFmpeg command
const commandResult = buildFFmpegCommand({
reorderedStreams: reorderResult.reorderedStreams,
needsConversion: conversionAnalysis.needsConversion,
conversionCount: conversionAnalysis.conversionCount,
hasProblematicSubs: conversionAnalysis.hasProblematicSubs,
extractCommand: extractionResult.extractCommand,
extractCount: extractionResult.extractCount,
ccExtractedFile: ccResult.ccExtractedFile,
ccActuallyExtracted: ccResult.ccActuallyExtracted
}, inputs, customEnglishCodes);
// Set response preset
if (ccResult.ccActuallyExtracted) {
response.preset = buildCCExtractionCommand(
commandResult.command,
ccResult.ccExtractedFile,
otherArguments
);
response.infoLog += ' CC extraction will run before main command. ';
} else {
response.preset = commandResult.command;
}
// Add final flags info
if (inputs.setDefaultFlags === 'true') {
if (commandResult.firstEnglishAudioIdx !== null) {
response.infoLog += `✅ Set default flag on English audio. `;
}
if (commandResult.firstEnglishSubIdx !== null) {
response.infoLog += `✅ Set default flag on English subtitle. `;
}
}
if (ccResult.ccExtractedFile && inputs.embedExtractedCC === 'true') {
if (ccResult.ccActuallyExtracted || fs.existsSync(ccResult.ccExtractedFile)) {
response.infoLog += '✅ Embedding extracted CC. ';
} else {
response.infoLog += '⚠️ CC file not found, skipping embed. ';
}
}
return response;
} catch (error) {
response.processFile = false;
response.preset = '';
response.reQueueAfter = false;
response.infoLog = `💥 Plugin error: ${error.message}\n`;
if (error.stack) {
const stackLines = error.stack.split('\n').slice(0, 5).join('\n');
response.infoLog += `Stack trace:\n${stackLines}\n`;
}
response.infoLog += `File: ${file.file}\n`;
response.infoLog += `Container: ${file.container}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;