/* 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 = ` ${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;