docs: Add historical reports and consolidated archives
This commit is contained in:
314
consolidation/archived/Tdarr_Plugin_misc_fixes.js
Normal file
314
consolidation/archived/Tdarr_Plugin_misc_fixes.js
Normal file
@@ -0,0 +1,314 @@
|
||||
/* 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: '3.0',
|
||||
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. Simplified Stream Conform (Redundant but kept as lightweight fallback) ---
|
||||
// Basic mapping for copy-remux
|
||||
let baseMap = '-map 0';
|
||||
|
||||
// Loop streams to find only critical issues (illegal metadata etc)
|
||||
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. Invalid Audio Stream Detection (Safety check)
|
||||
if (type === 'audio') {
|
||||
const channels = stream.channels || 0;
|
||||
const sampleRate = stream.sample_rate || 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Decision Time ---
|
||||
|
||||
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. ';
|
||||
|
||||
// Final Summary block
|
||||
response.infoLog += '\n\n📋 Final Processing Summary:\n';
|
||||
response.infoLog += ` Container: ${currentContainer.toUpperCase()}\n`;
|
||||
if (needsRemux) {
|
||||
response.infoLog += ` - Remuxing to: ${targetContainer.toUpperCase()}\n`;
|
||||
}
|
||||
if (genptsFlags) response.infoLog += ` - Timestamp fixes applied\n`;
|
||||
if (codecFlags !== '-c copy') response.infoLog += ` - Codec conversion enabled\n`;
|
||||
if (droppingStreams) response.infoLog += ` - Streams removed: ${extraMaps.length}\n`;
|
||||
if (needsReorder) response.infoLog += ` - Streams reordered\n`;
|
||||
|
||||
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;
|
||||
939
consolidation/archived/Tdarr_Plugin_stream_organizer.js
Normal file
939
consolidation/archived/Tdarr_Plugin_stream_organizer.js
Normal file
@@ -0,0 +1,939 @@
|
||||
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.12: Updated documentation - note that setDefaultFlags may conflict with audio_standardizer
|
||||
plugin if both are enabled. Recommend disabling setDefaultFlags when audio_standardizer
|
||||
is in the stack (audio_standardizer sets default by channel count after all processing).
|
||||
v4.11: Optimized requeue - only requeues when container is modified, not for extraction-only.
|
||||
v4.10: Fixed infinite loop - extracts subtitles to temp dir during plugin stack.
|
||||
v4.9: Refactored for better maintainability - extracted helper functions.
|
||||
`,
|
||||
Version: '4.13',
|
||||
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. NOTE: If audio_standardizer plugin is in your stack, consider disabling this (setDefaultFlags=false) as audio_standardizer sets default audio by channel count AFTER all processing including downmixes.',
|
||||
},
|
||||
{
|
||||
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, removeAfterExtract) => {
|
||||
return needsReorder || needsConversion || extractCount > 0 || ccActuallyExtracted || (ccExtractedFile && embedExtractedCC === 'true') || (extractCount > 0 && removeAfterExtract === 'true');
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the container itself needs to be modified (requires requeue)
|
||||
* Extraction-only operations don't modify the container and don't need requeue
|
||||
*/
|
||||
const needsContainerModification = (needsReorder, needsConversion, extractCount, removeAfterExtract, ccActuallyExtracted, ccExtractedFile, embedExtractedCC) => {
|
||||
// Container is modified when:
|
||||
// - Streams need reordering
|
||||
// - Subtitles need conversion (ASS/SSA/WebVTT -> SRT)
|
||||
// - Embedded subs are being removed after extraction
|
||||
// - CC is being extracted AND embedded back
|
||||
// - Existing CC file is being embedded
|
||||
return needsReorder ||
|
||||
needsConversion ||
|
||||
(extractCount > 0 && removeAfterExtract === 'true') ||
|
||||
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,
|
||||
inputs.removeAfterExtract
|
||||
)) {
|
||||
response.infoLog += '✅ No changes needed.';
|
||||
return response;
|
||||
}
|
||||
|
||||
response.processFile = true;
|
||||
|
||||
// Only requeue if container is being modified
|
||||
// Extraction-only (without removal) doesn't modify the container
|
||||
const containerModified = needsContainerModification(
|
||||
reorderResult.needsReorder,
|
||||
conversionAnalysis.needsConversion,
|
||||
extractionResult.extractCount,
|
||||
inputs.removeAfterExtract,
|
||||
ccResult.ccActuallyExtracted,
|
||||
ccResult.ccExtractedFile,
|
||||
inputs.embedExtractedCC
|
||||
);
|
||||
response.reQueueAfter = containerModified;
|
||||
|
||||
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;
|
||||
93
consolidation/consolidation_summary.md
Normal file
93
consolidation/consolidation_summary.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Plugin Consolidation and Optimization Summary
|
||||
|
||||
**Date**: January 27, 2026
|
||||
**Status**: ✅ Complete
|
||||
|
||||
## Overview
|
||||
|
||||
Consolidated and optimized the Local Plugins by removing redundancies, fixing bugs, and improving efficiency.
|
||||
|
||||
## Actions Taken
|
||||
|
||||
### 1. Archived Redundant Plugins
|
||||
|
||||
Moved the following redundant plugins to `agent-notes/archived/`:
|
||||
|
||||
#### `Tdarr_Plugin_misc_fixes.js`
|
||||
- **Reason**: Redundant "megamix" plugin that duplicates functionality from:
|
||||
- Plugin 01 (Container Remux)
|
||||
- Plugin 02 (Stream Cleanup)
|
||||
- Plugin 03 (Stream Ordering)
|
||||
- **Issues Found**:
|
||||
- Bug: Referenced undefined variable `needsReorder` on line 286
|
||||
- Incomplete implementation: `ensure_video_first` option not fully implemented
|
||||
|
||||
#### `Tdarr_Plugin_stream_organizer.js`
|
||||
- **Reason**: Redundant plugin that duplicates functionality from:
|
||||
- Plugin 03 (Stream Ordering)
|
||||
- Plugin 04 (Subtitle Conversion)
|
||||
- Plugin 05 (Subtitle Extraction)
|
||||
- Plugin 06 (CC Extraction)
|
||||
- **Note**: While feature-rich, the modular numbered plugins provide better separation of concerns
|
||||
|
||||
### 2. Optimizations Applied
|
||||
|
||||
#### Plugin 01 - Container Remux
|
||||
- **Optimization**: Consolidated `hasSubtitles()` function from filter+map to single-pass loop
|
||||
- **Impact**: Reduces stream iteration from 2 passes to 1 pass
|
||||
- **Code**: Lines 127-134
|
||||
|
||||
### 3. Remaining Active Plugins
|
||||
|
||||
The following plugins remain active and are optimized:
|
||||
|
||||
1. **Tdarr_Plugin_00_file_audit.js** - Read-only diagnostic plugin (unique purpose)
|
||||
2. **Tdarr_Plugin_01_container_remux.js** - Container remuxing with timestamp fixes (optimized)
|
||||
3. **Tdarr_Plugin_02_stream_cleanup.js** - Stream removal (already efficient)
|
||||
4. **Tdarr_Plugin_03_stream_ordering.js** - Stream reordering by language (well-structured)
|
||||
5. **Tdarr_Plugin_04_subtitle_conversion.js** - Subtitle codec conversion (efficient)
|
||||
6. **Tdarr_Plugin_05_subtitle_extraction.js** - External subtitle extraction (efficient)
|
||||
7. **Tdarr_Plugin_06_cc_extraction.js** - Closed caption extraction (efficient)
|
||||
8. **Tdarr_Plugin_av1_svt_converter.js** - AV1 video conversion (unique purpose)
|
||||
9. **Tdarr_Plugin_combined_audio_standardizer.js** - Audio standardization (unique purpose)
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
The remaining plugins follow a **modular, single-responsibility** design:
|
||||
|
||||
- **00**: Diagnostic/audit (read-only)
|
||||
- **01-06**: Sequential processing pipeline (container → cleanup → ordering → subtitles)
|
||||
- **AV1/Audio**: Specialized conversion plugins
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Reduced Redundancy**: Eliminated duplicate functionality
|
||||
2. **Better Maintainability**: Clear separation of concerns
|
||||
3. **Improved Performance**: Optimized stream processing loops
|
||||
4. **Bug Fixes**: Removed broken code (misc_fixes undefined variable)
|
||||
5. **Cleaner Codebase**: Focused, purpose-built plugins
|
||||
|
||||
## Verification
|
||||
|
||||
- ✅ All remaining plugins pass linter checks
|
||||
- ✅ No syntax errors
|
||||
- ✅ Plugins follow consistent patterns
|
||||
- ✅ Early exit optimizations in place
|
||||
- ✅ Efficient Set-based lookups for codec checks
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Use the numbered plugins (00-06)** in sequence for a complete processing pipeline
|
||||
2. **Avoid using archived plugins** - they are redundant and/or have bugs
|
||||
3. **Plugin order matters**: Follow the numbered sequence for best results
|
||||
|
||||
## Archive Location
|
||||
|
||||
Redundant plugins are preserved in:
|
||||
```
|
||||
agent-notes/archived/
|
||||
├── Tdarr_Plugin_misc_fixes.js
|
||||
└── Tdarr_Plugin_stream_organizer.js
|
||||
```
|
||||
|
||||
These can be referenced for historical purposes but should not be used in production.
|
||||
Reference in New Issue
Block a user