docs: Add historical reports and consolidated archives

This commit is contained in:
Tdarr Plugin Developer
2026-01-30 05:55:22 -08:00
parent b0c7ed3229
commit ddc9d09c41
24 changed files with 8134 additions and 33 deletions

View 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;

View 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;

View 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.