feat: Sync all plugin versions to 4.0.0 and reorganize Local directory
This commit is contained in:
567
Local/Tdarr_Plugin_00_file_audit.js
Normal file
567
Local/Tdarr_Plugin_00_file_audit.js
Normal 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;
|
||||
304
Local/Tdarr_Plugin_01_container_remux.js
Normal file
304
Local/Tdarr_Plugin_01_container_remux.js
Normal 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;
|
||||
210
Local/Tdarr_Plugin_02_stream_cleanup.js
Normal file
210
Local/Tdarr_Plugin_02_stream_cleanup.js
Normal 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;
|
||||
324
Local/Tdarr_Plugin_03_stream_ordering.js
Normal file
324
Local/Tdarr_Plugin_03_stream_ordering.js
Normal 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;
|
||||
237
Local/Tdarr_Plugin_04_subtitle_conversion.js
Normal file
237
Local/Tdarr_Plugin_04_subtitle_conversion.js
Normal 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;
|
||||
240
Local/Tdarr_Plugin_05_subtitle_extraction.js
Normal file
240
Local/Tdarr_Plugin_05_subtitle_extraction.js
Normal 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;
|
||||
190
Local/Tdarr_Plugin_06_cc_extraction.js
Normal file
190
Local/Tdarr_Plugin_06_cc_extraction.js
Normal 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
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user