Files
tdarr-plugs/Local/Tdarr_Plugin_01_container_remux.js

305 lines
12 KiB
JavaScript
Raw Permalink Blame History

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