305 lines
12 KiB
JavaScript
305 lines
12 KiB
JavaScript
/* 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;
|