Plugins: - misc_fixes v2.8: Pre-processing, container remux, stream conforming - stream_organizer v4.8: English priority, subtitle extraction, SRT conversion - combined_audio_standardizer v1.13: AAC/Opus encoding, downmix creation - av1_svt_converter v2.22: AV1 video encoding via SVT-AV1 Structure: - Local/ - Plugin .js files (mount in Tdarr) - agent_notes/ - Development documentation - Latest-Reports/ - Error logs for analysis
351 lines
14 KiB
JavaScript
351 lines
14 KiB
JavaScript
/* 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;
|