From b0c7ed322921cd41442459b2649dd6b78bac957b Mon Sep 17 00:00:00 2001 From: Tdarr Plugin Developer Date: Fri, 30 Jan 2026 05:55:19 -0800 Subject: [PATCH] feat: Sync all plugin versions to 4.0.0 and reorganize Local directory --- Local/Tdarr_Plugin_00_file_audit.js | 567 +++++++++++ Local/Tdarr_Plugin_01_container_remux.js | 304 ++++++ Local/Tdarr_Plugin_02_stream_cleanup.js | 210 ++++ Local/Tdarr_Plugin_03_stream_ordering.js | 324 ++++++ Local/Tdarr_Plugin_04_subtitle_conversion.js | 237 +++++ Local/Tdarr_Plugin_05_subtitle_extraction.js | 240 +++++ Local/Tdarr_Plugin_06_cc_extraction.js | 190 ++++ Local/Tdarr_Plugin_av1_svt_converter.js | 932 +++++++----------- ...darr_Plugin_combined_audio_standardizer.js | 734 ++++++++++---- Local/Tdarr_Plugin_misc_fixes.js | 350 ------- Local/Tdarr_Plugin_stream_organizer.js | 904 ----------------- 11 files changed, 2996 insertions(+), 1996 deletions(-) create mode 100644 Local/Tdarr_Plugin_00_file_audit.js create mode 100644 Local/Tdarr_Plugin_01_container_remux.js create mode 100644 Local/Tdarr_Plugin_02_stream_cleanup.js create mode 100644 Local/Tdarr_Plugin_03_stream_ordering.js create mode 100644 Local/Tdarr_Plugin_04_subtitle_conversion.js create mode 100644 Local/Tdarr_Plugin_05_subtitle_extraction.js create mode 100644 Local/Tdarr_Plugin_06_cc_extraction.js delete mode 100644 Local/Tdarr_Plugin_misc_fixes.js delete mode 100644 Local/Tdarr_Plugin_stream_organizer.js diff --git a/Local/Tdarr_Plugin_00_file_audit.js b/Local/Tdarr_Plugin_00_file_audit.js new file mode 100644 index 0000000..7c7d652 --- /dev/null +++ b/Local/Tdarr_Plugin_00_file_audit.js @@ -0,0 +1,567 @@ +/* eslint-disable no-plusplus */ +/** + * Tdarr Plugin 00 - File Audit + * + * Read-only audit plugin that runs first in the pipeline. + * Logs file information and flags potential issues for downstream plugins. + * Makes NO changes to files - pure analysis and reporting. + */ + +const details = () => ({ + id: 'Tdarr_Plugin_00_file_audit', + Stage: 'Pre-processing', + Name: '00 - File Audit', + Type: 'Video', + Operation: 'Filter', + Description: ` + **READ-ONLY** file auditor that logs comprehensive file information and flags potential issues. + Runs FIRST in the pipeline to provide early warning of problems. + + **Reports**: + - Container format and compatibility notes for BOTH MKV and MP4 + - All streams with codec details + - Potential issues (broken timestamps, incompatible codecs, corrupt streams) + - Standards compliance (HDR, color space, etc.) + + **Never modifies files** - Filter type plugin that always passes files through. + `, + Version: '4.0.0', + Tags: 'filter,audit,analysis,diagnostic,pre-check', + Inputs: [ + { + name: 'log_level', + type: 'string', + defaultValue: 'detailed*', + inputUI: { + type: 'dropdown', + options: ['minimal', 'detailed*', 'verbose'], + }, + tooltip: 'minimal=issues only, detailed=streams+issues, verbose=everything including metadata', + }, + ], +}); + +// ============================================================================ +// COMPATIBILITY DEFINITIONS +// ============================================================================ + +// Codecs incompatible with containers +const MKV_INCOMPATIBLE_CODECS = new Set(['mov_text', 'eia_608', 'timed_id3', 'bmp', 'tx3g']); +const MP4_INCOMPATIBLE_CODECS = new Set(['hdmv_pgs_subtitle', 'eia_608', 'subrip', 'timed_id3', 'ass', 'ssa', 'webvtt']); + +// Containers with known timestamp issues +const TIMESTAMP_PROBLEM_CONTAINERS = new Set(['ts', 'mpegts', 'avi', 'mpg', 'mpeg', 'm2ts', 'vob']); + +// Legacy codecs that often have timestamp/remux issues +const LEGACY_VIDEO_CODECS = { + 'mpeg4': { risk: 'high', note: 'MPEG-4 Part 2 - often has timestamp issues' }, + 'msmpeg4v1': { risk: 'high', note: 'MS-MPEG4v1 - severe timestamp issues' }, + 'msmpeg4v2': { risk: 'high', note: 'MS-MPEG4v2 - severe timestamp issues' }, + 'msmpeg4v3': { risk: 'high', note: 'MS-MPEG4v3/DivX3 - severe timestamp issues' }, + 'mpeg1video': { risk: 'medium', note: 'MPEG-1 - may need re-encoding' }, + 'mpeg2video': { risk: 'medium', note: 'MPEG-2 - may have GOP issues' }, + 'wmv1': { risk: 'high', note: 'WMV7 - poor container compatibility' }, + 'wmv2': { risk: 'high', note: 'WMV8 - poor container compatibility' }, + 'wmv3': { risk: 'medium', note: 'WMV9 - may have issues in MKV/MP4' }, + 'rv10': { risk: 'high', note: 'RealVideo 1.0 - very limited support' }, + 'rv20': { risk: 'high', note: 'RealVideo 2.0 - very limited support' }, + 'rv30': { risk: 'high', note: 'RealVideo 3.0 - very limited support' }, + 'rv40': { risk: 'high', note: 'RealVideo 4.0 - very limited support' }, + 'vp6': { risk: 'medium', note: 'VP6 - legacy Flash codec' }, + 'vp6f': { risk: 'medium', note: 'VP6 Flash - legacy Flash codec' }, + 'flv1': { risk: 'medium', note: 'FLV/Sorenson Spark - legacy codec' }, +}; + +// XviD/DivX codec tags that indicate packed bitstream issues +const XVID_DIVX_TAGS = new Set(['XVID', 'DIVX', 'DX50', 'DIV3', 'DIV4', 'DIV5', 'FMP4', 'MP4V', 'MP42', 'MP43']); + +// Image codecs (cover art) that should be removed +const IMAGE_CODECS = new Set(['mjpeg', 'png', 'gif', 'bmp', 'webp', 'tiff']); + +// Data stream codecs that cause issues +const PROBLEMATIC_DATA_CODECS = new Set(['bin_data', 'timed_id3', 'dvd_nav_packet']); + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v); + +const sanitizeInputs = (inputs) => { + Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); }); + return inputs; +}; + +const formatBitrate = (bps) => { + if (!bps || bps === 0) return 'unknown'; + const kbps = Math.round(bps / 1000); + if (kbps >= 1000) return `${(kbps / 1000).toFixed(1)} Mbps`; + return `${kbps} kbps`; +}; + +const formatDuration = (seconds) => { + if (!seconds) return 'unknown'; + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + if (hrs > 0) return `${hrs}h ${mins}m ${secs}s`; + if (mins > 0) return `${mins}m ${secs}s`; + return `${secs}s`; +}; + +const formatSize = (bytes) => { + if (!bytes) return 'unknown'; + const gb = bytes / (1024 * 1024 * 1024); + if (gb >= 1) return `${gb.toFixed(2)} GB`; + const mb = bytes / (1024 * 1024); + return `${mb.toFixed(1)} MB`; +}; + +// ============================================================================ +// AUDIT CHECKS +// ============================================================================ + +/** + * Analyze container format and flag issues + */ +const auditContainer = (file) => { + const issues = []; + const info = []; + + const container = (file.container || '').toLowerCase(); + const formatName = file.ffProbeData?.format?.format_name || ''; + + info.push(`Container: ${container.toUpperCase()} (format: ${formatName})`); + + // Check for timestamp-problematic containers + if (TIMESTAMP_PROBLEM_CONTAINERS.has(container)) { + issues.push(`⚠️ TIMESTAMP: ${container.toUpperCase()} containers often have timestamp issues requiring -fflags +genpts`); + } + + // Check for containers that need special handling + if (['iso', 'vob', 'evo'].includes(container)) { + issues.push(`❌ UNSUPPORTED: ${container.toUpperCase()} requires manual conversion (HandBrake/MakeMKV)`); + } + + // Note current container for user reference + if (!['mkv', 'mp4'].includes(container) && !['iso', 'vob', 'evo'].includes(container)) { + info.push(`📦 Current container will need remuxing to MKV or MP4`); + } + + return { issues, info }; +}; + +/** + * Analyze video streams + */ +const auditVideoStreams = (streams) => { + const issues = []; + const info = []; + + const videoStreams = streams.filter(s => s.codec_type === 'video' && !IMAGE_CODECS.has((s.codec_name || '').toLowerCase())); + + if (videoStreams.length === 0) { + issues.push('❌ NO VIDEO: No valid video stream found'); + return { issues, info }; + } + + if (videoStreams.length > 1) { + issues.push(`⚠️ MULTI-VIDEO: ${videoStreams.length} video streams detected (unusual)`); + } + + videoStreams.forEach((stream, idx) => { + const codec = (stream.codec_name || 'unknown').toLowerCase(); + const codecTag = (stream.codec_tag_string || '').toUpperCase(); + const width = stream.width || '?'; + const height = stream.height || '?'; + const fps = stream.r_frame_rate || stream.avg_frame_rate || '?'; + const bitrate = stream.bit_rate || 0; + const pixFmt = stream.pix_fmt || 'unknown'; + + // Basic info + let streamInfo = `🎬 Video ${idx}: ${codec.toUpperCase()} ${width}x${height}`; + if (fps && fps !== '?') { + const [num, den] = fps.split('/').map(Number); + if (den && den > 0) streamInfo += ` @ ${(num / den).toFixed(2)}fps`; + } + if (bitrate) streamInfo += ` (${formatBitrate(bitrate)})`; + streamInfo += ` [${pixFmt}]`; + info.push(streamInfo); + + // Check for legacy codec issues + if (LEGACY_VIDEO_CODECS[codec]) { + const legacy = LEGACY_VIDEO_CODECS[codec]; + issues.push(`⚠️ LEGACY (${legacy.risk}): ${legacy.note}`); + } + + // Check for XviD/DivX packed bitstream + if (codec === 'mpeg4' && XVID_DIVX_TAGS.has(codecTag)) { + issues.push(`⚠️ XVID/DIVX: ${codecTag} may have packed bitstream timestamp issues`); + } + + // Check for divx_packed flag + if (stream.divx_packed === 'true' || stream.divx_packed === true) { + issues.push('❌ PACKED BITSTREAM: DivX packed bitstream detected - will need re-encoding'); + } + + // HDR detection + const colorTransfer = stream.color_transfer || ''; + const colorPrimaries = stream.color_primaries || ''; + const colorSpace = stream.color_space || ''; + + if (colorTransfer === 'smpte2084') { + info.push(' 🌈 HDR10 (PQ) detected - metadata preservation needed'); + } else if (colorTransfer === 'arib-std-b67') { + info.push(' 🌈 HLG detected - metadata preservation needed'); + } + + if (colorPrimaries === 'bt2020' || colorSpace === 'bt2020nc') { + info.push(' 📺 BT.2020 color space detected'); + } + + // Check for unusual pixel formats + if (pixFmt.includes('12le') || pixFmt.includes('12be')) { + info.push(' ⚠️ 12-bit depth - may have limited player support'); + } + + // Check for interlaced content + if (stream.field_order && !['progressive', 'unknown'].includes(stream.field_order)) { + issues.push(`⚠️ INTERLACED: Field order "${stream.field_order}" - may need deinterlacing`); + } + }); + + return { issues, info }; +}; + +/** + * Analyze audio streams - checks both MKV and MP4 compatibility + */ +const auditAudioStreams = (streams) => { + const issues = []; + const info = []; + + const audioStreams = streams.filter(s => s.codec_type === 'audio'); + + if (audioStreams.length === 0) { + issues.push('⚠️ NO AUDIO: No audio streams found'); + return { issues, info }; + } + + audioStreams.forEach((stream, idx) => { + const codec = (stream.codec_name || 'unknown').toLowerCase(); + const channels = stream.channels || 0; + const sampleRate = stream.sample_rate || 0; + const bitrate = stream.bit_rate || 0; + const lang = stream.tags?.language || 'und'; + const title = stream.tags?.title || ''; + + // Check for corrupt audio + if (channels === 0) { + issues.push(`❌ CORRUPT AUDIO ${idx}: 0 channels detected - stream will be removed`); + return; + } + + if (sampleRate === 0) { + issues.push(`⚠️ CORRUPT AUDIO ${idx}: No sample rate detected`); + } + + // Channel layout description + let channelDesc = `${channels}ch`; + if (channels === 1) channelDesc = 'Mono'; + else if (channels === 2) channelDesc = 'Stereo'; + else if (channels === 6) channelDesc = '5.1'; + else if (channels === 8) channelDesc = '7.1'; + + let streamInfo = `🔊 Audio ${idx}: ${codec.toUpperCase()} ${channelDesc}`; + if (sampleRate) streamInfo += ` @ ${sampleRate}Hz`; + if (bitrate) streamInfo += ` (${formatBitrate(bitrate)})`; + streamInfo += ` [${lang}]`; + if (title) streamInfo += ` "${title}"`; + info.push(streamInfo); + + // Check MP4-specific audio compatibility issues + if (['vorbis', 'opus'].includes(codec)) { + issues.push(`⚠️ [MP4 only] ${codec.toUpperCase()} has limited MP4 support (OK in MKV)`); + } + if (['dts', 'truehd'].includes(codec)) { + issues.push(`⚠️ [MP4 only] ${codec.toUpperCase()} not standard in MP4 (OK in MKV)`); + } + + // Check for unusual audio codecs (both containers) + if (['cook', 'ra_144', 'ra_288', 'sipr', 'atrac3', 'atrac3p'].includes(codec)) { + issues.push(`⚠️ [BOTH] RARE CODEC: ${codec.toUpperCase()} - very limited support`); + } + }); + + return { issues, info }; +}; + +/** + * Analyze subtitle streams - checks both MKV and MP4 compatibility + */ +const auditSubtitleStreams = (streams, file) => { + const issues = []; + const info = []; + + const subStreams = streams.filter(s => s.codec_type === 'subtitle'); + + if (subStreams.length === 0) { + info.push('📝 Subtitles: None'); + return { issues, info }; + } + + subStreams.forEach((stream, idx) => { + // Robust codec identification + let codec = (stream.codec_name || '').toLowerCase(); + if (codec === 'none' || codec === 'unknown' || !codec) { + // Try metadata fallback + const codecTag = (stream.codec_tag_string || '').toUpperCase(); + if (codecTag.includes('WEBVTT')) codec = 'webvtt'; + else if (codecTag.includes('ASS')) codec = 'ass'; + else if (codecTag.includes('SSA')) codec = 'ssa'; + else { + const tagCodecId = (stream.tags?.CodecID || stream.tags?.codec_id || '').toUpperCase(); + if (tagCodecId.includes('WEBVTT') || tagCodecId.includes('S_TEXT/WEBVTT')) codec = 'webvtt'; + else if (tagCodecId.includes('ASS') || tagCodecId.includes('S_TEXT/ASS')) codec = 'ass'; + else if (tagCodecId.includes('SSA') || tagCodecId.includes('S_TEXT/SSA')) codec = 'ssa'; + } + } + + // If still unknown, check MediaInfo/ExifTool if available + if (codec === 'none' || codec === 'unknown' || !codec) { + const mediaInfoCodec = (file.mediaInfo?.track?.find(t => t['@type'] === 'Text' && t.StreamOrder == stream.index)?.CodecID || '').toLowerCase(); + if (mediaInfoCodec.includes('webvtt')) codec = 'webvtt'; + else if (mediaInfoCodec.includes('ass')) codec = 'ass'; + else if (mediaInfoCodec.includes('ssa')) codec = 'ssa'; + } + + codec = codec || 'unknown'; + + const lang = stream.tags?.language || 'und'; + const title = stream.tags?.title || ''; + const forced = stream.disposition?.forced === 1 ? ' [FORCED]' : ''; + + let streamInfo = `📝 Sub ${idx}: ${codec.toUpperCase()} [${lang}]${forced}`; + if (title) streamInfo += ` "${title}"`; + info.push(streamInfo); + + // Check for specific problematic states + if (codec === 'unknown') { + issues.push(`⚠️ [BOTH] Subtitle stream ${idx} codec could not be identified - may cause transcode failure`); + } + + // Check container-specific compatibility + const mkvIncompat = MKV_INCOMPATIBLE_CODECS.has(codec); + const mp4Incompat = MP4_INCOMPATIBLE_CODECS.has(codec); + + if (mkvIncompat && mp4Incompat) { + issues.push(`⚠️ [BOTH] ${codec.toUpperCase()} incompatible with MKV and MP4`); + } else if (mkvIncompat) { + issues.push(`⚠️ [MKV only] ${codec.toUpperCase()} not compatible with MKV (OK in MP4)`); + } else if (mp4Incompat) { + issues.push(`⚠️ [MP4 only] ${codec.toUpperCase()} not compatible with MP4 (OK in MKV)`); + } + + // Check for image-based subs that can't be converted to SRT + if (['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub'].includes(codec)) { + info.push(` ℹ️ Image-based subtitle - cannot convert to SRT`); + } + + // Check for formats that will be converted + if (['ass', 'ssa', 'webvtt', 'mov_text'].includes(codec)) { + info.push(` ℹ️ Will convert to SRT for compatibility`); + } + }); + + return { issues, info }; +}; + +/** + * Analyze other streams (data, attachments, images) + */ +const auditOtherStreams = (streams) => { + const issues = []; + const info = []; + + // Image streams (cover art) + const imageStreams = streams.filter(s => + (s.codec_type === 'video' && IMAGE_CODECS.has((s.codec_name || '').toLowerCase())) || + s.disposition?.attached_pic === 1 + ); + + if (imageStreams.length > 0) { + info.push(`🖼️ Cover Art: ${imageStreams.length} image stream(s) - will be removed`); + } + + // Data streams + const dataStreams = streams.filter(s => s.codec_type === 'data'); + dataStreams.forEach((stream, idx) => { + const codec = (stream.codec_name || 'unknown').toLowerCase(); + + if (PROBLEMATIC_DATA_CODECS.has(codec)) { + issues.push(`⚠️ DATA STREAM: ${codec} will cause muxing issues - will be removed`); + } else { + info.push(`📊 Data ${idx}: ${codec.toUpperCase()}`); + } + }); + + // Attachments (fonts, etc.) + const attachments = streams.filter(s => s.codec_type === 'attachment'); + if (attachments.length > 0) { + info.push(`📎 Attachments: ${attachments.length} (fonts, etc.)`); + } + + return { issues, info }; +}; + +/** + * Analyze file-level metadata + */ +const auditFileMetadata = (file, logLevel) => { + const issues = []; + const info = []; + + const format = file.ffProbeData?.format || {}; + const duration = parseFloat(format.duration) || 0; + const size = file.statSync?.size || parseInt(format.size) || 0; + const bitrate = parseInt(format.bit_rate) || 0; + + // Basic file info + info.push(`📁 Size: ${formatSize(size)} | Duration: ${formatDuration(duration)} | Bitrate: ${formatBitrate(bitrate)}`); + + // Check for very short files + if (duration > 0 && duration < 10) { + issues.push('⚠️ SHORT FILE: Duration under 10 seconds'); + } + + // Check for suspiciously low bitrate + if (bitrate > 0 && bitrate < 100000) { // Under 100kbps + issues.push('⚠️ LOW BITRATE: File bitrate is very low - possible quality issues'); + } + + // Check for missing duration (common in broken files) + if (!duration || duration === 0) { + issues.push('⚠️ NO DURATION: Could not determine file duration - may be corrupt'); + } + + // Verbose: show all format tags + if (logLevel === 'verbose' && format.tags) { + const importantTags = ['title', 'encoder', 'creation_time', 'copyright']; + importantTags.forEach(tag => { + if (format.tags[tag]) { + info.push(` 📋 ${tag}: ${format.tags[tag]}`); + } + }); + } + + return { issues, info }; +}; + +// ============================================================================ +// MAIN PLUGIN +// ============================================================================ + +const plugin = (file, librarySettings, inputs, otherArguments) => { + const lib = require('../methods/lib')(); + + const response = { + processFile: true, // MUST be true for Filter plugins to pass files through! + preset: '', + container: `.${file.container}`, + handbrakeMode: false, + ffmpegMode: false, + reQueueAfter: false, + infoLog: '', + }; + + try { + inputs = sanitizeInputs(lib.loadDefaultValues(inputs, details)); + const logLevel = inputs.log_level; + + // Header + response.infoLog += '═══════════════════════════════════════════════════════════════\n'; + response.infoLog += ' 📋 FILE AUDIT REPORT\n'; + response.infoLog += '═══════════════════════════════════════════════════════════════\n\n'; + + if (!file.ffProbeData?.streams || !Array.isArray(file.ffProbeData.streams)) { + response.infoLog += '❌ CRITICAL: No stream data available - file may be corrupt\n'; + return response; + } + + const streams = file.ffProbeData.streams; + const allIssues = []; + const allInfo = []; + + // Run all audits (no target container - checks both MKV and MP4) + const containerAudit = auditContainer(file); + const videoAudit = auditVideoStreams(streams); + const audioAudit = auditAudioStreams(streams); + const subtitleAudit = auditSubtitleStreams(streams, file); + const otherAudit = auditOtherStreams(streams); + const metadataAudit = auditFileMetadata(file, logLevel); + + // Collect all results + allIssues.push(...containerAudit.issues, ...videoAudit.issues, ...audioAudit.issues, + ...subtitleAudit.issues, ...otherAudit.issues, ...metadataAudit.issues); + allInfo.push(...metadataAudit.info, ...containerAudit.info, ...videoAudit.info, + ...audioAudit.info, ...subtitleAudit.info, ...otherAudit.info); + + // Output based on log level + if (logLevel === 'minimal') { + // Minimal: issues only + if (allIssues.length > 0) { + response.infoLog += `🔍 Found ${allIssues.length} potential issue(s):\n`; + allIssues.forEach(issue => { + response.infoLog += ` ${issue}\n`; + }); + } else { + response.infoLog += '✅ No issues detected\n'; + } + } else { + // Detailed/Verbose: show info and issues + allInfo.forEach(info => { + response.infoLog += `${info}\n`; + }); + + response.infoLog += '\n───────────────────────────────────────────────────────────────\n'; + + if (allIssues.length > 0) { + response.infoLog += `\n🔍 POTENTIAL ISSUES (${allIssues.length}):\n`; + response.infoLog += ' [MKV only] = Issue only affects MKV container\n'; + response.infoLog += ' [MP4 only] = Issue only affects MP4 container\n'; + response.infoLog += ' [BOTH] = Issue affects both containers\n\n'; + allIssues.forEach(issue => { + response.infoLog += ` ${issue}\n`; + }); + } else { + response.infoLog += '\n✅ No issues detected - file ready for processing\n'; + } + } + + // Stream count summary + const videoCount = streams.filter(s => s.codec_type === 'video' && !IMAGE_CODECS.has((s.codec_name || '').toLowerCase())).length; + const audioCount = streams.filter(s => s.codec_type === 'audio').length; + const subCount = streams.filter(s => s.codec_type === 'subtitle').length; + + response.infoLog += '\n───────────────────────────────────────────────────────────────\n'; + response.infoLog += `📊 Summary: ${videoCount}V ${audioCount}A ${subCount}S | Checked: MKV+MP4 | Issues: ${allIssues.length}\n`; + response.infoLog += '═══════════════════════════════════════════════════════════════\n'; + + // Final Summary block (for consistency with other plugins) + if (logLevel !== 'minimal') { + response.infoLog += '\n📋 Final Processing Summary:\n'; + response.infoLog += ` Streams: ${videoCount} video, ${audioCount} audio, ${subCount} subtitle\n`; + response.infoLog += ` Issues detected: ${allIssues.length}\n`; + response.infoLog += ` Container compatibility: MKV + MP4 checked\n`; + } + + return response; + + } catch (error) { + response.infoLog = `❌ Audit plugin error: ${error.message}\n`; + return response; + } +}; + +module.exports.details = details; +module.exports.plugin = plugin; diff --git a/Local/Tdarr_Plugin_01_container_remux.js b/Local/Tdarr_Plugin_01_container_remux.js new file mode 100644 index 0000000..4e271ab --- /dev/null +++ b/Local/Tdarr_Plugin_01_container_remux.js @@ -0,0 +1,304 @@ +/* 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; diff --git a/Local/Tdarr_Plugin_02_stream_cleanup.js b/Local/Tdarr_Plugin_02_stream_cleanup.js new file mode 100644 index 0000000..ee10129 --- /dev/null +++ b/Local/Tdarr_Plugin_02_stream_cleanup.js @@ -0,0 +1,210 @@ +/* eslint-disable no-plusplus */ +const details = () => ({ + id: 'Tdarr_Plugin_02_stream_cleanup', + Stage: 'Pre-processing', + Name: '02 - Stream Cleanup', + Type: 'Video', + Operation: 'Transcode', + Description: ` + Removes unwanted and incompatible streams from the container. + - Removes image streams (MJPEG/PNG/GIF cover art) + - Drops streams incompatible with current container (auto-detected) + - Removes corrupt/invalid audio streams (0 channels) + + **Single Responsibility**: Stream removal only. No reordering. + Run AFTER container remux, BEFORE stream ordering. + Container is inherited from Plugin 01 (Container Remux). + `, + Version: '4.0.0', + Tags: 'action,ffmpeg,cleanup,streams,conform', + Inputs: [ + { + name: 'remove_image_streams', + type: 'string', + defaultValue: 'true*', + inputUI: { type: 'dropdown', options: ['true*', 'false'] }, + tooltip: 'Remove MJPEG, PNG, GIF video streams and attached pictures (often cover art spam).', + }, + { + name: 'force_conform', + type: 'string', + defaultValue: 'true*', + inputUI: { type: 'dropdown', options: ['true*', 'false'] }, + tooltip: 'Drop streams incompatible with target container (e.g., mov_text in MKV, PGS in MP4).', + }, + { + name: 'remove_corrupt_audio', + type: 'string', + defaultValue: 'true*', + inputUI: { type: 'dropdown', options: ['true*', 'false'] }, + tooltip: 'Remove audio streams with invalid parameters (0 channels, no sample rate).', + }, + { + name: 'remove_data_streams', + type: 'string', + defaultValue: 'true*', + inputUI: { type: 'dropdown', options: ['true*', 'false'] }, + tooltip: 'Remove data streams (bin_data, timed_id3) that cause muxing issues.', + }, + { + name: 'remove_attachments', + type: 'string', + defaultValue: 'true*', + inputUI: { type: 'dropdown', options: ['true*', 'false'] }, + tooltip: 'Remove attachment streams (fonts, etc.) that often cause FFmpeg 7.x muxing errors.', + }, + ], +}); + +// Constants - Set for O(1) lookup +const MKV_INCOMPATIBLE = new Set(['mov_text', 'eia_608', 'timed_id3', 'bmp', 'tx3g']); +const MP4_INCOMPATIBLE = new Set(['hdmv_pgs_subtitle', 'eia_608', 'subrip', 'timed_id3', 'ass', 'ssa']); +const IMAGE_CODECS = new Set(['mjpeg', 'png', 'gif']); +const DATA_CODECS = new Set(['bin_data', 'timed_id3', 'dvd_nav_packet']); +const SUPPORTED_CONTAINERS = new Set(['mkv', 'mp4']); +const BOOLEAN_INPUTS = ['remove_image_streams', 'force_conform', 'remove_corrupt_audio', 'remove_data_streams', 'remove_attachments']; + +// Utilities +// Note: stripStar is duplicated across all plugins due to Tdarr's self-contained architecture. +// Each plugin must be standalone without external dependencies. +const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v); + +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 and convert booleans + inputs = lib.loadDefaultValues(inputs, details); + Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); }); + BOOLEAN_INPUTS.forEach((k) => { inputs[k] = inputs[k] === 'true'; }); + + const streams = file.ffProbeData?.streams; + if (!Array.isArray(streams)) { + response.infoLog = '❌ No stream data available. '; + return response; + } + + const currentContainer = (file.container || '').toLowerCase(); + // Early exit optimization: unsupported container = nothing to do + if (!SUPPORTED_CONTAINERS.has(currentContainer)) { + response.infoLog += `⚠️ Container "${currentContainer}" not supported. Skipping conformance. `; + return response; + } + + const isTargetMkv = currentContainer === 'mkv'; + const incompatibleCodecs = isTargetMkv ? MKV_INCOMPATIBLE : (currentContainer === 'mp4' ? MP4_INCOMPATIBLE : new Set()); + + response.infoLog += `ℹ️ Container: ${currentContainer.toUpperCase()}. `; + + const streamsToDrop = []; + const stats = { image: 0, corrupt: 0, data: 0, incompatible: 0, attachment: 0 }; + + for (let i = 0; i < streams.length; i++) { + const stream = streams[i]; + const codec = (stream.codec_name || '').toLowerCase(); + const type = (stream.codec_type || '').toLowerCase(); + + // Remove image streams + if (inputs.remove_image_streams && type === 'video') { + const isAttachedPic = stream.disposition?.attached_pic === 1; + if (IMAGE_CODECS.has(codec) || isAttachedPic) { + streamsToDrop.push(i); + stats.image++; + continue; + } + } + + // Remove corrupt audio + if (inputs.remove_corrupt_audio && type === 'audio') { + if (stream.channels === 0 || stream.sample_rate === 0 || !codec) { + streamsToDrop.push(i); + stats.corrupt++; + continue; + } + } + + // Remove data streams + if (inputs.remove_data_streams && type === 'data') { + if (DATA_CODECS.has(codec)) { + streamsToDrop.push(i); + stats.data++; + continue; + } + } + + // Remove attachments + if (inputs.remove_attachments && type === 'attachment') { + streamsToDrop.push(i); + stats.attachment++; + continue; + } + + // POLICY: MP4 is incompatible with ALL subtitles - remove any that slipped through + if (currentContainer === 'mp4' && type === 'subtitle') { + streamsToDrop.push(i); + stats.incompatible++; + continue; + } + + // Container conforming (for MKV and other edge cases) + if (inputs.force_conform) { + if (incompatibleCodecs.has(codec) || (type === 'data' && isTargetMkv) || !codec || codec === 'unknown' || codec === 'none') { + streamsToDrop.push(i); + stats.incompatible++; + continue; + } + } + } + + // Early exit optimization: nothing to drop = no processing needed + if (streamsToDrop.length > 0) { + const dropMaps = streamsToDrop.map((i) => `-map -0:${i}`).join(' '); + response.preset = ` -map 0 ${dropMaps} -c copy -max_muxing_queue_size 9999`; + response.container = `.${file.container}`; + response.processFile = true; + + const summary = []; + if (stats.image) summary.push(`${stats.image} image`); + if (stats.corrupt) summary.push(`${stats.corrupt} corrupt`); + if (stats.data) summary.push(`${stats.data} data`); + if (stats.incompatible) summary.push(`${stats.incompatible} incompatible`); + if (stats.attachment) summary.push(`${stats.attachment} attachment`); + + response.infoLog += `✅ Dropping ${streamsToDrop.length} stream(s): ${summary.join(', ')}. `; + + // Final Summary block + response.infoLog += '\n\n📋 Final Processing Summary:\n'; + response.infoLog += ` Streams dropped: ${streamsToDrop.length}\n`; + if (stats.image) response.infoLog += ` - Image/Cover art: ${stats.image}\n`; + if (stats.corrupt) response.infoLog += ` - Corrupt audio: ${stats.corrupt}\n`; + if (stats.data) response.infoLog += ` - Data streams: ${stats.data}\n`; + if (stats.incompatible) response.infoLog += ` - Incompatible streams: ${stats.incompatible}\n`; + if (stats.attachment) response.infoLog += ` - Attachments: ${stats.attachment}\n`; + + return response; + } + + response.infoLog += '✅ No streams to remove. '; + 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; diff --git a/Local/Tdarr_Plugin_03_stream_ordering.js b/Local/Tdarr_Plugin_03_stream_ordering.js new file mode 100644 index 0000000..78295d6 --- /dev/null +++ b/Local/Tdarr_Plugin_03_stream_ordering.js @@ -0,0 +1,324 @@ +/* eslint-disable no-plusplus */ +const details = () => ({ + id: 'Tdarr_Plugin_03_stream_ordering', + Stage: 'Pre-processing', + Name: '03 - Stream Ordering', + Type: 'Video', + Operation: 'Transcode', + Description: ` + Reorders streams by type and language priority. + - Ensures Video streams appear first + - Prioritizes specified language codes for Audio and Subtitles + - Optionally sets default disposition flags on first priority tracks + + v1.6: Updated documentation - recommend using default_audio_mode='skip' when audio_standardizer + plugin is in the stack (audio_standardizer sets default by channel count after processing). + v1.5: Added default_audio_mode option - choose between language-based or channel-count-based + default audio selection. Improved stack compatibility with audio standardizer plugin. + + **Single Responsibility**: Stream order only. No conversion or removal. + Run AFTER stream cleanup, BEFORE subtitle conversion. + `, + Version: '4.0.0', + Tags: 'action,ffmpeg,order,language,english', + Inputs: [ + { + 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: 'reorder_audio', + type: 'string', + defaultValue: 'true*', + inputUI: { type: 'dropdown', options: ['true*', 'false'] }, + tooltip: 'Reorder audio streams to put priority language first.', + }, + { + name: 'reorder_subtitles', + type: 'string', + defaultValue: 'true*', + inputUI: { type: 'dropdown', options: ['true*', 'false'] }, + tooltip: 'Reorder subtitle streams to put priority language first.', + }, + { + name: 'priority_languages', + 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 prioritize (max 20).', + }, + { + name: 'set_default_flags', + type: 'string', + defaultValue: 'false', + inputUI: { type: 'dropdown', options: ['false', 'true'] }, + tooltip: 'Enable setting default disposition flags. Use default_audio_mode to choose strategy.', + }, + { + name: 'default_audio_mode', + type: 'string', + defaultValue: 'language*', + inputUI: { type: 'dropdown', options: ['language*', 'channels', 'skip'] }, + tooltip: 'How to select default audio: language=first priority-language track, channels=track with most channels (BEFORE downmix creation), skip=don\'t set audio default (RECOMMENDED when audio_standardizer is in stack - it sets default by channel count AFTER all processing including downmixes).', + }, + ], +}); + +// Constants +const MAX_LANGUAGE_CODES = 20; +const BOOLEAN_INPUTS = ['ensure_video_first', 'reorder_audio', 'reorder_subtitles', 'set_default_flags']; +const VALID_DEFAULT_AUDIO_MODES = new Set(['language', 'channels', 'skip']); +const STREAM_TYPES = new Set(['video', 'audio', 'subtitle']); + +// Container-aware subtitle compatibility +const MKV_INCOMPATIBLE_SUBS = new Set(['mov_text', 'tx3g']); +const MP4_INCOMPATIBLE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub', 'subrip', 'ass', 'ssa', 'webvtt']); +const MP4_CONVERTIBLE_SUBS = new Set(['subrip', 'ass', 'ssa', 'webvtt', 'text']); +const IMAGE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']); + +// Utilities +// Note: stripStar is duplicated across all plugins due to Tdarr's self-contained architecture. +// Each plugin must be standalone without external dependencies. +const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v); + +const parseLanguages = (codesString) => { + if (typeof codesString !== 'string') return new Set(); + const codes = codesString + .split(',') + .map((c) => c.trim().toLowerCase()) + .filter((c) => c.length > 0 && c.length <= 10 && /^[a-z0-9-]+$/.test(c)) + .slice(0, MAX_LANGUAGE_CODES); + return new Set(codes); +}; + +const isPriority = (stream, prioritySet) => { + const lang = stream.tags?.language?.toLowerCase(); + return lang && prioritySet.has(lang); +}; + +const partition = (arr, predicate) => { + const matched = []; + const unmatched = []; + arr.forEach((item) => (predicate(item) ? matched : unmatched).push(item)); + return [matched, unmatched]; +}; + +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 and convert booleans + inputs = lib.loadDefaultValues(inputs, details); + Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); }); + BOOLEAN_INPUTS.forEach((k) => { inputs[k] = inputs[k] === 'true'; }); + + const streams = file.ffProbeData?.streams; + if (!Array.isArray(streams)) { + response.infoLog = '❌ No stream data available. '; + return response; + } + + // Parse priority languages into Set for O(1) lookup + let priorityLangs = parseLanguages(inputs.priority_languages); + if (priorityLangs.size === 0) { + priorityLangs = new Set(['eng', 'en', 'english', 'en-us', 'en-gb', 'en-ca', 'en-au']); + } + + // Tag streams with original index + const taggedStreams = streams.map((s, i) => ({ ...s, originalIndex: i })); + + const videoStreams = taggedStreams.filter((s) => s.codec_type === 'video'); + let audioStreams = taggedStreams.filter((s) => s.codec_type === 'audio'); + let subtitleStreams = taggedStreams.filter((s) => s.codec_type === 'subtitle'); + const otherStreams = taggedStreams.filter((s) => !STREAM_TYPES.has(s.codec_type)); + + // Reorder by language priority + if (inputs.reorder_audio) { + const [priority, other] = partition(audioStreams, (s) => isPriority(s, priorityLangs)); + audioStreams = [...priority, ...other]; + if (priority.length) response.infoLog += `✅ ${priority.length} priority audio first. `; + } + + if (inputs.reorder_subtitles) { + const [priority, other] = partition(subtitleStreams, (s) => isPriority(s, priorityLangs)); + subtitleStreams = [...priority, ...other]; + if (priority.length) response.infoLog += `✅ ${priority.length} priority subtitle(s) first. `; + } + + // Build final order + let reorderedStreams; + if (inputs.ensure_video_first) { + reorderedStreams = [...videoStreams, ...audioStreams, ...subtitleStreams, ...otherStreams]; + } else { + // Maintain relative order but apply language sorting + const audioQueue = [...audioStreams]; + const subQueue = [...subtitleStreams]; + reorderedStreams = taggedStreams.map((s) => { + if (s.codec_type === 'audio') return audioQueue.shift(); + if (s.codec_type === 'subtitle') return subQueue.shift(); + return s; + }); + } + + // Check if order changed + const originalOrder = taggedStreams.map((s) => s.originalIndex); + const newOrder = reorderedStreams.map((s) => s.originalIndex); + if (JSON.stringify(originalOrder) === JSON.stringify(newOrder)) { + response.infoLog += '✅ Stream order already correct. '; + return response; + } + + // Build FFmpeg command with container-aware subtitle handling + const container = (file.container || '').toLowerCase(); + let command = ''; + const subtitlesToDrop = []; + const subtitlesToConvert = []; + + // Build stream mapping with container compatibility checks + reorderedStreams.forEach((s) => { + const codec = (s.codec_name || '').toLowerCase(); + + // Check subtitle compatibility with container + if (s.codec_type === 'subtitle') { + if (container === 'mp4' || container === 'm4v') { + if (IMAGE_SUBS.has(codec)) { + subtitlesToDrop.push(s.originalIndex); + return; // Don't map this stream + } else if (MP4_CONVERTIBLE_SUBS.has(codec)) { + subtitlesToConvert.push(s.originalIndex); + } + } else if (container === 'mkv' && MKV_INCOMPATIBLE_SUBS.has(codec)) { + subtitlesToConvert.push(s.originalIndex); + } + } + command += ` -map 0:${s.originalIndex}`; + }); + + // Log dropped/converted subtitles + if (subtitlesToDrop.length > 0) { + response.infoLog += `📁 Dropping ${subtitlesToDrop.length} image subtitle(s) (incompatible with MP4). `; + } + + // Build codec arguments + command += ' -c:v copy -c:a copy'; + + // Handle subtitle codec conversion based on container + if (subtitlesToConvert.length > 0) { + if (container === 'mp4' || container === 'm4v') { + command += ' -c:s mov_text'; + response.infoLog += `📁 Converting ${subtitlesToConvert.length} subtitle(s) to mov_text. `; + } else if (container === 'mkv') { + command += ' -c:s srt'; + response.infoLog += `📁 Converting ${subtitlesToConvert.length} mov_text subtitle(s) to SRT. `; + } else { + command += ' -c:s copy'; + } + } else { + command += ' -c:s copy'; + } + + // Set default disposition flags + if (inputs.set_default_flags) { + const audioStreamsOrdered = reorderedStreams.filter(s => s.codec_type === 'audio'); + let subIdx = 0; + let firstPrioritySub = null; + + // Handle subtitle default (always by language) + reorderedStreams.forEach((s) => { + if (s.codec_type === 'subtitle') { + if (firstPrioritySub === null && isPriority(s, priorityLangs)) firstPrioritySub = subIdx; + subIdx++; + } + }); + + // Handle audio default based on mode + let defaultAudioIdx = null; + const audioMode = inputs.default_audio_mode || 'language'; + + if (audioMode === 'language') { + // First priority-language track + for (let i = 0; i < audioStreamsOrdered.length; i++) { + if (isPriority(audioStreamsOrdered[i], priorityLangs)) { + defaultAudioIdx = i; + break; + } + } + if (defaultAudioIdx !== null) { + command += ` -disposition:a:${defaultAudioIdx} default`; + response.infoLog += `✅ Default audio: track ${defaultAudioIdx} (language priority). `; + } + } else if (audioMode === 'channels') { + // Track with most channels + if (audioStreamsOrdered.length > 0) { + let maxChannels = 0; + audioStreamsOrdered.forEach((s, i) => { + const channels = s.channels || 0; + if (channels > maxChannels) { + maxChannels = channels; + defaultAudioIdx = i; + } + }); + if (defaultAudioIdx !== null) { + command += ` -disposition:a:${defaultAudioIdx} default`; + response.infoLog += `✅ Default audio: track ${defaultAudioIdx} (${maxChannels}ch - highest). `; + } + } + } + // Mode 'skip' - don't set audio default, let other plugins handle it + + // Clear default from other audio tracks when setting a default + if (defaultAudioIdx !== null && audioMode !== 'skip') { + for (let i = 0; i < audioStreamsOrdered.length; i++) { + if (i !== defaultAudioIdx) { + command += ` -disposition:a:${i} 0`; + } + } + } + + if (firstPrioritySub !== null) { + command += ` -disposition:s:${firstPrioritySub} default`; + response.infoLog += `✅ Default subtitle: track ${firstPrioritySub}. `; + } + } + + command += ' -max_muxing_queue_size 9999'; + + response.preset = command; + response.processFile = true; + response.infoLog += '✅ Reordering streams. '; + + // Final Summary block + response.infoLog += '\n\n📋 Final Processing Summary:\n'; + response.infoLog += ` Action: Reordering streams\n`; + response.infoLog += ` Languages prioritized: ${inputs.priority_languages}\n`; + if (inputs.ensure_video_first) response.infoLog += ` - Ensuring video stream first\n`; + if (inputs.reorder_audio) response.infoLog += ` - Audio reordered by language\n`; + if (inputs.reorder_subtitles) response.infoLog += ` - Subtitles reordered by language\n`; + if (inputs.set_default_flags) response.infoLog += ` - Default flags updated\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; diff --git a/Local/Tdarr_Plugin_04_subtitle_conversion.js b/Local/Tdarr_Plugin_04_subtitle_conversion.js new file mode 100644 index 0000000..232edf0 --- /dev/null +++ b/Local/Tdarr_Plugin_04_subtitle_conversion.js @@ -0,0 +1,237 @@ +/* eslint-disable no-plusplus */ +const details = () => ({ + id: 'Tdarr_Plugin_04_subtitle_conversion', + Stage: 'Pre-processing', + Name: '04 - Subtitle Conversion', + Type: 'Video', + Operation: 'Transcode', + Description: ` + **Container-Aware** subtitle conversion for maximum compatibility. + - MKV target → Converts to SRT (universal text format) + - MP4 target → Converts to mov_text (native MP4 format) + + Converts: ASS/SSA, WebVTT, mov_text, SRT (cross-converts as needed) + Image subtitles (PGS/VobSub) are copied as-is (cannot convert to text). + + **Single Responsibility**: In-container subtitle codec conversion only. + Container is inherited from Plugin 01 (Container Remux). + Run AFTER stream ordering, BEFORE subtitle extraction. + `, + Version: '4.0.0', + Tags: 'action,ffmpeg,subtitles,srt,mov_text,convert,container-aware', + Inputs: [ + { + name: 'enable_conversion', + type: 'string', + defaultValue: 'true*', + inputUI: { type: 'dropdown', options: ['true*', 'false'] }, + tooltip: 'Enable container-aware subtitle conversion (MKV→SRT, MP4→mov_text).', + }, + { + name: 'always_convert_webvtt', + type: 'string', + defaultValue: 'true*', + inputUI: { type: 'dropdown', options: ['true*', 'false'] }, + tooltip: 'Always convert WebVTT regardless of other settings (problematic in most containers).', + }, + ], +}); + +// Constants - Set for O(1) lookup +const TEXT_SUBTITLES = new Set(['ass', 'ssa', 'webvtt', 'vtt', 'mov_text', 'text', 'subrip', 'srt']); +const IMAGE_SUBTITLES = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']); +const UNSUPPORTED_SUBTITLES = new Set(['eia_608', 'cc_dec', 'tx3g']); +const WEBVTT_CODECS = new Set(['webvtt', 'vtt']); +const BOOLEAN_INPUTS = ['enable_conversion', 'always_convert_webvtt']; + +const CONTAINER_TARGET = { + mkv: 'srt', + mp4: 'mov_text', + m4v: 'mov_text', + mov: 'mov_text', +}; + +// Utilities +const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v); + +/** + * Get subtitle codec, handling edge cases (WebVTT in MKV may report codec_name as 'none'). + */ +const getSubtitleCodec = (stream, file) => { + let codecName = (stream.codec_name || '').toLowerCase(); + if (codecName && codecName !== 'none' && codecName !== 'unknown') return codecName; + + // FFmpeg reports 'none' for some codecs it can't identify (like WEBVTT in MKV) + // Try metadata fallback using tags/codec_tag + const codecTag = (stream.codec_tag_string || '').toUpperCase(); + if (codecTag.includes('WEBVTT')) return 'webvtt'; + if (codecTag.includes('ASS')) return 'ass'; + if (codecTag.includes('SSA')) return 'ssa'; + + const tagCodecId = (stream.tags?.CodecID || stream.tags?.codec_id || '').toUpperCase(); + if (tagCodecId.includes('WEBVTT') || tagCodecId.includes('S_TEXT/WEBVTT')) return 'webvtt'; + if (tagCodecId.includes('ASS') || tagCodecId.includes('S_TEXT/ASS')) return 'ass'; + if (tagCodecId.includes('SSA') || tagCodecId.includes('S_TEXT/SSA')) return 'ssa'; + + // Try MediaInfo fallback + const miStreams = file?.mediaInfo?.track; + if (Array.isArray(miStreams)) { + const miStream = miStreams.find((t) => t['@type'] === 'Text' && t.StreamOrder == stream.index); + const miCodec = (miStream?.CodecID || '').toLowerCase(); + if (miCodec.includes('webvtt')) return 'webvtt'; + if (miCodec.includes('ass')) return 'ass'; + if (miCodec.includes('ssa')) return 'ssa'; + } + + // Try ExifTool (meta) fallback + const meta = file?.meta; + if (meta) { + // ExifTool often provides codec information in the TrackLanguage or TrackName if everything else fails + const trackName = (stream.tags?.title || '').toLowerCase(); + if (trackName.includes('webvtt')) return 'webvtt'; + } + + return codecName || 'unknown'; +}; + +/** + * Normalize codec name for comparison. + */ +const normalizeCodec = (codec) => { + if (codec === 'srt' || codec === 'subrip') return 'srt'; + if (codec === 'vtt' || codec === 'webvtt') return 'webvtt'; + return codec; +}; + +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 and convert booleans + inputs = lib.loadDefaultValues(inputs, details); + Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); }); + BOOLEAN_INPUTS.forEach((k) => { inputs[k] = inputs[k] === 'true'; }); + + const streams = file.ffProbeData?.streams; + if (!Array.isArray(streams)) { + response.infoLog = '❌ No stream data available. '; + return response; + } + + const container = (file.container || '').toLowerCase(); + + + const targetCodec = CONTAINER_TARGET[container] || 'srt'; + const targetDisplay = targetCodec === 'srt' ? 'SRT' : 'mov_text'; + + response.infoLog += `📦 ${container.toUpperCase()} → ${targetDisplay}. `; + + const subtitleStreams = streams + .map((s, i) => ({ ...s, index: i })) + .filter((s) => s.codec_type === 'subtitle'); + + // Early exit optimization: no subtitles = nothing to do + if (subtitleStreams.length === 0) { + response.infoLog += '✅ No subtitle streams. '; + return response; + } + + const toConvert = []; + const reasons = []; + + subtitleStreams.forEach((stream) => { + const codec = getSubtitleCodec(stream, file); + const normalized = normalizeCodec(codec); + const streamDisplay = `Stream ${stream.index} (${codec.toUpperCase()})`; + + // Skip unsupported formats + if (UNSUPPORTED_SUBTITLES.has(codec)) { + reasons.push(`${streamDisplay}: Unsupported format, skipping`); + return; + } + + // Image-based formats: Copy as-is (cannot convert to text) + if (IMAGE_SUBTITLES.has(codec)) { + reasons.push(`${streamDisplay}: Image-based, copying as-is`); + return; + } + + // Check if conversion to target is needed + if (!inputs.enable_conversion) { + // Still convert WebVTT if that option is enabled (special case for compatibility) + if (WEBVTT_CODECS.has(codec) && inputs.always_convert_webvtt) { + toConvert.push(stream); + reasons.push(`${streamDisplay} → ${targetDisplay} (special WebVTT rule)`); + } else { + reasons.push(`${streamDisplay}: Keeping original (conversion disabled)`); + } + return; + } + + // WebVTT always converted if enabled + if (WEBVTT_CODECS.has(codec) && inputs.always_convert_webvtt) { + toConvert.push(stream); + reasons.push(`${streamDisplay} → ${targetDisplay}`); + return; + } + + // Already in target format + if (normalized === normalizeCodec(targetCodec)) { + reasons.push(`${streamDisplay}: Already ${targetDisplay}, copying`); + return; + } + + // Text subtitle that needs conversion + if (TEXT_SUBTITLES.has(codec)) { + toConvert.push(stream); + reasons.push(`${streamDisplay} → ${targetDisplay}`); + } else { + reasons.push(`${streamDisplay}: Unknown format, copying as-is`); + } + }); + + // Early exit optimization: all compatible = no conversion needed + if (toConvert.length === 0) { + response.infoLog += `✅ All ${subtitleStreams.length} subtitle(s) compatible. `; + return response; + } + + // Build FFmpeg command + let command = ' -map 0 -c copy'; + toConvert.forEach((s) => { command += ` -c:${s.index} ${targetCodec}`; }); + command += ' -max_muxing_queue_size 9999'; + + response.preset = command; + response.processFile = true; + response.infoLog += `✅ Converting ${toConvert.length} subtitle(s):\n`; + reasons.forEach((r) => { response.infoLog += ` ${r}\n`; }); + + // Final Summary block + response.infoLog += '\n📋 Final Processing Summary:\n'; + response.infoLog += ` Target format: ${targetDisplay}\n`; + response.infoLog += ` Total subtitles analyzed: ${subtitleStreams.length}\n`; + response.infoLog += ` Subtitles converted: ${toConvert.length}\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; diff --git a/Local/Tdarr_Plugin_05_subtitle_extraction.js b/Local/Tdarr_Plugin_05_subtitle_extraction.js new file mode 100644 index 0000000..d79a567 --- /dev/null +++ b/Local/Tdarr_Plugin_05_subtitle_extraction.js @@ -0,0 +1,240 @@ +/* eslint-disable no-plusplus */ +const details = () => ({ + id: 'Tdarr_Plugin_05_subtitle_extraction', + Stage: 'Pre-processing', + Name: '05 - Subtitle Extraction', + Type: 'Video', + Operation: 'Transcode', + Description: ` + Extracts embedded subtitles to external .srt files. + - Optionally removes embedded subtitles after extraction + - Skips commentary/description tracks if configured + - Skips image-based subtitles (PGS/VobSub - cannot extract to SRT) + + **Single Responsibility**: External file extraction only. + Run AFTER subtitle conversion. + `, + Version: '4.0.0', + Tags: 'action,ffmpeg,subtitles,srt,extract', + Inputs: [ + { + name: 'extract_subtitles', + type: 'string', + defaultValue: 'true*', + inputUI: { type: 'dropdown', options: ['true*', 'false'] }, + tooltip: 'Extract embedded text subtitles to external .srt files.', + }, + { + name: 'remove_after_extract', + type: 'string', + defaultValue: 'false', + inputUI: { type: 'dropdown', options: ['false', 'true'] }, + tooltip: 'Remove embedded subtitles from container after extracting them.', + }, + { + name: 'skip_commentary', + type: 'string', + defaultValue: 'true*', + inputUI: { type: 'dropdown', options: ['true*', 'false'] }, + tooltip: 'Skip extracting subtitles with "commentary" or "description" in the title.', + }, + { + name: 'extract_languages', + type: 'string', + defaultValue: '', + inputUI: { type: 'text' }, + tooltip: 'Comma-separated language codes to extract. Empty = extract all.', + }, + ], +}); + +// Constants +const IMAGE_SUBTITLES = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']); +const UNSUPPORTED_SUBTITLES = new Set(['eia_608', 'cc_dec', 'tx3g']); +const MIN_SUBTITLE_SIZE = 100; +const MAX_FILENAME_ATTEMPTS = 100; +const BOOLEAN_INPUTS = ['extract_subtitles', 'remove_after_extract', 'skip_commentary']; + +// Utilities +const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v); + +const sanitizeFilename = (name, maxLen = 50) => { + if (typeof name !== 'string') return 'file'; + name = name.replace(/[<>:"|?*\/\\\x00-\x1f\x7f]/g, '_').replace(/^[.\s]+|[.\s]+$/g, ''); + return name.length === 0 ? 'file' : name.substring(0, maxLen); +}; + +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 fileExistsValid = (filePath, fs) => { + try { return fs.statSync(filePath).size > MIN_SUBTITLE_SIZE; } + catch { return false; } +}; + +const plugin = (file, librarySettings, inputs, otherArguments) => { + const lib = require('../methods/lib')(); + const fs = require('fs'); + const path = require('path'); + const { execSync } = require('child_process'); + + const response = { + processFile: false, + preset: '', + container: `.${file.container}`, + handbrakeMode: false, + ffmpegMode: true, + reQueueAfter: false, + infoLog: '', + }; + + try { + // Sanitize inputs and convert booleans + inputs = lib.loadDefaultValues(inputs, details); + Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); }); + BOOLEAN_INPUTS.forEach((k) => { inputs[k] = inputs[k] === 'true'; }); + + if (!inputs.extract_subtitles) { + response.infoLog = '✅ Subtitle extraction disabled. '; + return response; + } + + const streams = file.ffProbeData?.streams; + if (!Array.isArray(streams)) { + response.infoLog = '❌ No stream data available. '; + return response; + } + + // Parse language filter + const extractLangs = inputs.extract_languages + ? new Set(inputs.extract_languages.split(',').map((l) => l.trim().toLowerCase()).filter(Boolean)) + : null; + + const subtitleStreams = streams + .map((s, i) => ({ ...s, index: i })) + .filter((s) => s.codec_type === 'subtitle'); + + if (subtitleStreams.length === 0) { + response.infoLog = '✅ No subtitle streams to extract. '; + return response; + } + + // Detect cache cycle + const isInCache = (file._id || file.file).includes('-TdarrCacheFile-'); + const stableId = (file._id || file.file).replace(/-TdarrCacheFile-[a-zA-Z0-9]+/, ''); + const basePath = path.join(path.dirname(file.file), path.basename(stableId, path.extname(stableId))); + + // Skip if in cache and NOT removing subtitles (prevents infinite loop) + if (isInCache && !inputs.remove_after_extract) { + response.infoLog = 'ℹ️ In cache cycle, skipping to prevent loop. '; + return response; + } + + const extractedFiles = new Set(); + const extractArgs = []; + const streamsToRemove = []; + + for (const stream of subtitleStreams) { + const codec = (stream.codec_name || '').toLowerCase(); + + // Skip unsupported + if (UNSUPPORTED_SUBTITLES.has(codec) || IMAGE_SUBTITLES.has(codec)) continue; + + // Check language filter + const lang = stream.tags?.language?.toLowerCase() || 'unknown'; + if (extractLangs && !extractLangs.has(lang)) continue; + + // Skip commentary + if (inputs.skip_commentary) { + const title = (stream.tags?.title || '').toLowerCase(); + if (title.includes('commentary') || title.includes('description')) continue; + } + + // Build unique filename + const safeLang = sanitizeFilename(lang); + let subsFile = `${basePath}.${safeLang}.srt`; + let counter = 1; + while ((extractedFiles.has(subsFile) || fileExistsValid(subsFile, fs)) && counter < MAX_FILENAME_ATTEMPTS) { + subsFile = `${basePath}.${safeLang}.${counter}.srt`; + counter++; + } + + if (fileExistsValid(subsFile, fs)) continue; + + extractArgs.push('-map', `0:${stream.index}`, subsFile); + extractedFiles.add(subsFile); + streamsToRemove.push(stream.index); + } + + if (extractArgs.length === 0) { + response.infoLog = '✅ No subtitles to extract (all exist or filtered). '; + return response; + } + + // Execute extraction + const ffmpegPath = otherArguments?.ffmpegPath || 'tdarr-ffmpeg'; + const cmdParts = [ffmpegPath, '-y', '-i', sanitizeForShell(file.file)]; + for (let i = 0; i < extractArgs.length; i++) { + if (extractArgs[i] === '-map') { + cmdParts.push('-map', extractArgs[i + 1]); + i++; + } else { + cmdParts.push(sanitizeForShell(extractArgs[i])); + } + } + + const extractCount = streamsToRemove.length; + response.infoLog += `✅ Extracting ${extractCount} subtitle(s)... `; + + try { + const execCmd = cmdParts.join(' '); + execSync(execCmd, { stdio: 'pipe', timeout: 300000, maxBuffer: 10 * 1024 * 1024 }); + response.infoLog += 'Done. '; + } catch (e) { + const errorMsg = e.stderr ? e.stderr.toString() : e.message; + response.infoLog += `⚠️ Extraction failed: ${errorMsg}. `; + if (!inputs.remove_after_extract) return response; + response.infoLog += 'Proceeding with removal regardless. '; + } + + // Remove subtitles from container if requested + if (inputs.remove_after_extract && streamsToRemove.length > 0) { + let preset = ' -map 0'; + streamsToRemove.forEach((idx) => { preset += ` -map -0:${idx}`; }); + preset += ' -c copy -max_muxing_queue_size 9999'; + + response.preset = preset; + response.processFile = true; + response.reQueueAfter = true; + response.infoLog += `✅ Removing ${streamsToRemove.length} embedded subtitle(s). `; + } else { + response.infoLog += '✅ Subtitles extracted, container unchanged. '; + } + + // Final Summary block + if (extractCount > 0) { + response.infoLog += '\n\n📋 Final Processing Summary:\n'; + response.infoLog += ` Subtitles extracted: ${extractCount}\n`; + if (inputs.remove_after_extract) { + response.infoLog += ` - Embedded subtitles removed from container\n`; + } else { + response.infoLog += ` - Embedded subtitles preserved\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; diff --git a/Local/Tdarr_Plugin_06_cc_extraction.js b/Local/Tdarr_Plugin_06_cc_extraction.js new file mode 100644 index 0000000..8a23291 --- /dev/null +++ b/Local/Tdarr_Plugin_06_cc_extraction.js @@ -0,0 +1,190 @@ +/* eslint-disable no-plusplus */ +const details = () => ({ + id: 'Tdarr_Plugin_06_cc_extraction', + Stage: 'Pre-processing', + Name: '06 - CC Extraction (CCExtractor)', + Type: 'Video', + Operation: 'Transcode', + Description: ` + Extracts closed captions (eia_608/cc_dec) from video files using CCExtractor. + - Outputs to external .cc.srt file alongside the video + - Optionally embeds extracted CC back into the container as a subtitle track + + **Requirements**: CCExtractor must be installed and available in PATH. + + **Single Responsibility**: Closed caption extraction only. + Run AFTER subtitle extraction, BEFORE audio standardizer. + `, + Version: '4.0.0', + Tags: 'action,ffmpeg,subtitles,cc,ccextractor', + Inputs: [ + { + name: 'extract_cc', + type: 'string', + defaultValue: 'false', + inputUI: { type: 'dropdown', options: ['false', 'true'] }, + tooltip: 'Enable CC extraction via CCExtractor. Requires CCExtractor installed.', + }, + { + name: 'embed_extracted_cc', + type: 'string', + defaultValue: 'false', + inputUI: { type: 'dropdown', options: ['false', 'true'] }, + tooltip: 'Embed the extracted CC file back into the container as a subtitle track.', + }, + ], +}); + +// Constants +const CC_CODECS = new Set(['eia_608', 'cc_dec']); +const BOOLEAN_INPUTS = ['extract_cc', 'embed_extracted_cc']; +const MIN_CC_SIZE = 50; + +// Utilities +const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v); + +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 hasClosedCaptions = (streams) => streams.some((s) => { + const codec = (s.codec_name || '').toLowerCase(); + const tag = (s.codec_tag_string || '').toLowerCase(); + return CC_CODECS.has(codec) || tag === 'cc_dec'; +}); + +const plugin = (file, librarySettings, inputs, otherArguments) => { + const lib = require('../methods/lib')(); + const fs = require('fs'); + const path = require('path'); + const { execSync } = require('child_process'); + + const response = { + processFile: false, + preset: '', + container: `.${file.container}`, + handbrakeMode: false, + ffmpegMode: true, + reQueueAfter: false, + infoLog: '', + }; + + try { + // Sanitize inputs and convert booleans + inputs = lib.loadDefaultValues(inputs, details); + Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); }); + BOOLEAN_INPUTS.forEach((k) => { inputs[k] = inputs[k] === 'true'; }); + + if (!inputs.extract_cc) { + response.infoLog = '✅ CC extraction disabled. '; + return response; + } + + const streams = file.ffProbeData?.streams; + if (!Array.isArray(streams)) { + response.infoLog = '❌ No stream data available. '; + return response; + } + + // Early exit optimization: no CC streams = nothing to do + if (!hasClosedCaptions(streams)) { + response.infoLog = '✅ No closed captions detected. '; + return response; + } + + // Build CC output path + const basePath = path.join(path.dirname(file.file), path.basename(file.file, path.extname(file.file))); + const ccFile = `${basePath}.cc.srt`; + const ccLockFile = `${ccFile}.lock`; + + // Check if CC file already exists + try { + const stats = fs.statSync(ccFile); + if (stats.size > MIN_CC_SIZE) { + response.infoLog = 'ℹ️ CC file already exists. '; + + if (inputs.embed_extracted_cc) { + const safeCCFile = sanitizeForShell(ccFile); + const subCount = streams.filter((s) => s.codec_type === 'subtitle').length; + response.preset = ` -i ${safeCCFile} -map 0 -map 1:0 -c copy -c:s:${subCount} srt -metadata:s:s:${subCount} language=eng -metadata:s:s:${subCount} title="Closed Captions" -max_muxing_queue_size 9999`; + response.processFile = true; + response.reQueueAfter = true; + response.infoLog += '✅ Embedding existing CC file. '; + } + return response; + } + } catch { /* File doesn't exist, proceed */ } + + // Prevent concurrent extraction via lock file + try { + fs.writeFileSync(ccLockFile, process.pid.toString(), { flag: 'wx' }); + } catch (e) { + if (e.code === 'EEXIST') { + response.infoLog = 'ℹ️ CC extraction in progress by another worker. '; + return response; + } + throw e; + } + + // Execute CCExtractor + const safeInput = sanitizeForShell(file.file); + const safeCCFile = sanitizeForShell(ccFile); + response.infoLog += '✅ Extracting CC... '; + + try { + execSync(`ccextractor ${safeInput} -o ${safeCCFile}`, { stdio: 'pipe', timeout: 180000, maxBuffer: 10 * 1024 * 1024 }); + response.infoLog += 'Done. '; + } catch (e) { + const errorMsg = e.stderr ? e.stderr.toString() : e.message; + response.infoLog += `⚠️ CCExtractor failed: ${errorMsg}. `; + try { fs.unlinkSync(ccLockFile); } catch { /* ignore */ } + return response; + } + + // Clean up lock file + try { fs.unlinkSync(ccLockFile); } catch { /* ignore */ } + + // Verify CC file + try { + if (fs.statSync(ccFile).size < MIN_CC_SIZE) { + response.infoLog += 'ℹ️ No closed captions found. '; + return response; + } + } catch { + response.infoLog += '⚠️ CC file not created. '; + return response; + } + + // Embed if requested + if (inputs.embed_extracted_cc) { + const subCount = streams.filter((s) => s.codec_type === 'subtitle').length; + response.preset = ` -i ${safeCCFile} -map 0 -map 1:0 -c copy -c:s:${subCount} srt -metadata:s:s:${subCount} language=eng -metadata:s:s:${subCount} title="Closed Captions" -max_muxing_queue_size 9999`; + response.processFile = true; + response.reQueueAfter = true; + response.infoLog += '✅ Embedding CC file. '; + } else { + response.infoLog += '✅ CC extracted to external file. '; + } + + // Final Summary block + if (inputs.embed_extracted_cc) { + response.infoLog += '\n\n📋 Final Processing Summary:\n'; + response.infoLog += ` CC extraction: Completed\n`; + response.infoLog += ` - CC embedded as subtitle track\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; diff --git a/Local/Tdarr_Plugin_av1_svt_converter.js b/Local/Tdarr_Plugin_av1_svt_converter.js index 48488b8..de8c74e 100644 --- a/Local/Tdarr_Plugin_av1_svt_converter.js +++ b/Local/Tdarr_Plugin_av1_svt_converter.js @@ -5,25 +5,49 @@ const details = () => ({ Type: 'Video', Operation: 'Transcode', Description: ` - AV1 conversion plugin with advanced quality control for SVT-AV1 v3.0+ (2025). - **Rate Control Modes**: VBR (predictable file sizes), CRF (quality-based), or VMAF (quality-targeted with ab-av1 crf-search). - Features resolution-aware CRF, source-relative bitrate strategies, ab-av1 auto-CRF, and performance optimizations. - **Balanced defaults**: Preset 6, CRF 26, tune 0 (VQ), 10-bit, SCD 1, AQ 2, lookahead -1, TF on, keyint -2, fast-decode 0. + AV1 conversion plugin with simplified quality control for SVT-AV1 v3.0+ (2025). + **Rate Control**: CRF (quality-based, optional maxrate cap) or VBR (bitrate-based with target + maxrate). + **Quality Presets**: Use quality_preset for easy configuration, or set custom CRF/qmin/qmax values. + **Bitrate Awareness**: Optionally skip files that are already very low bitrate to prevent size bloat. + **Source Codec Awareness**: Optionally increase CRF for HEVC sources to prevent re-encoding bloat. + + **Note**: Run AFTER stream_cleanup plugin to ensure problematic streams are removed. + + v3.18: No code changes - version bump for compatibility with updated audio_standardizer v1.23. `, - Version: '2.34', - Tags: 'video,av1,svt,quality,performance,speed-optimized,vbr,crf,vmaf', + Version: '4.0.0', + Tags: 'video,av1,svt,quality,performance,speed-optimized,vbr,crf', Inputs: [ { - name: 'crf', + name: 'quality_preset', type: 'string', - defaultValue: '26*', + defaultValue: 'balanced*', inputUI: { type: 'dropdown', options: [ + 'archival', + 'high', + 'balanced*', + 'efficient', + 'custom' + ], + }, + tooltip: 'Quality presets auto-configure CRF/qmin/qmax. archival=CRF18/qmax35, high=CRF22/qmax40, balanced=CRF28/qmax45, efficient=CRF30/qmax55. Use "custom" to set values manually below.', + }, + { + name: 'crf', + type: 'string', + defaultValue: '28*', + inputUI: { + type: 'dropdown', + options: [ + '16', + '18', + '20', '22', '24', - '26*', - '28', + '26', + '28*', '30', '32', '34', @@ -33,33 +57,60 @@ const details = () => ({ '42' ], }, - tooltip: 'Quality setting (CRF). Higher = faster encoding, lower quality. (default: 26 for 1080p) 24–28 = high quality, 30+ = faster/transcoding. 10–20 = archival. For 4K, add +2; for 720p, subtract 2. [SVT-AV1 v3.0+]', + tooltip: 'Quality setting (CRF). Lower = better quality/larger files. 16-20=archival, 22-26=high quality, 28-32=balanced, 34+=efficient. Only used when quality_preset=custom.', }, { - name: 'custom_maxrate', + name: 'qmin', + type: 'string', + defaultValue: '10*', + inputUI: { + type: 'dropdown', + options: [ + '1', + '5', + '10*', + '15', + '20' + ], + }, + tooltip: 'Minimum quantizer (quality ceiling). Lower = allows better quality but may not improve much. Only used when quality_preset=custom.', + }, + { + name: 'qmax', + type: 'string', + defaultValue: '45*', + inputUI: { + type: 'dropdown', + options: [ + '30', + '35', + '40', + '45*', + '48', + '50', + '55', + '60' + ], + }, + tooltip: 'Maximum quantizer (quality floor). Lower = prevents excessive compression, larger files. 35=archival, 40=high, 45=balanced, 55=efficient. Only used when quality_preset=custom.', + }, + { + name: 'maxrate', type: 'string', defaultValue: '0', inputUI: { type: 'text', }, - tooltip: 'Maximum bitrate in kbps (0 or empty = unlimited). Used when target_bitrate_strategy is \'static\'. Capped CRF saves bandwidth on easy scenes while preserving quality on complex ones.', + tooltip: 'Maximum bitrate in kbps (0 = unlimited). Optional cap for both CRF and VBR modes. Prevents bitrate spikes. ~3500 kbps for 1080p.', }, { - name: 'target_bitrate_strategy', + name: 'target_bitrate', type: 'string', - defaultValue: 'static*', + defaultValue: '2200', inputUI: { - type: 'dropdown', - options: [ - 'static*', - 'match_source', - '75%_source', - '50%_source', - '33%_source', - '25%_source' - ], + type: 'text', }, - tooltip: 'Target bitrate strategy. \'static\' uses custom_maxrate. Other options set target/maxrate relative to detected source bitrate.', + tooltip: 'Target average bitrate in kbps for VBR mode. ~2200 kbps = 1GB/hour @ 1080p. Ignored in CRF mode.', }, { name: 'rate_control_mode', @@ -69,43 +120,12 @@ const details = () => ({ type: 'dropdown', options: [ 'crf*', - 'vbr', - 'vmaf' + 'vbr' ], }, - tooltip: 'Rate control mode. \'crf\' = Quality-based (CRF + maxrate cap), \'vbr\' = Bitrate-based (target average + maxrate peaks), \'vmaf\' = Quality-targeted (ab-av1 auto CRF selection, requires ab-av1 binary).', - }, - { - name: 'vmaf_target', - type: 'string', - defaultValue: '95*', - inputUI: { - type: 'dropdown', - options: [ - '85', - '90', - '95*', - '97', - '99' - ], - }, - tooltip: 'Target VMAF quality score (vmaf mode only). Higher = better quality but larger files. 95 = visually transparent (recommended), 90 = good quality, 85 = acceptable quality.', - }, - { - name: 'vmaf_samples', - type: 'string', - defaultValue: '4*', - inputUI: { - type: 'dropdown', - options: [ - '2', - '4*', - '6', - '8' - ], - }, - tooltip: 'Number of sample segments for ab-av1 quality analysis (vmaf mode only). More samples = more accurate CRF selection but slower analysis. 4 samples is a good balance.', + tooltip: 'Rate control mode. \'crf\' = Quality-based (CRF + optional maxrate cap for bandwidth control), \'vbr\' = Bitrate-based (target average bitrate + maxrate peaks for streaming/bandwidth-limited scenarios).', }, + { name: 'max_resolution', type: 'string', @@ -134,7 +154,20 @@ const details = () => ({ 'enabled*' ], }, - tooltip: 'Auto-adjust CRF based on resolution: 4K gets +2 CRF, 1080p baseline, 720p gets -2 CRF. Improves efficiency with minimal quality impact.', + tooltip: 'Auto-adjust CRF based on resolution: 4K gets +2 CRF, 1080p/720p baseline, 480p and below gets +2 CRF. Helps prevent size bloat on low-bitrate SD content.', + }, + { + name: 'source_codec_awareness', + type: 'string', + defaultValue: 'enabled*', + inputUI: { + type: 'dropdown', + options: [ + 'disabled', + 'enabled*' + ], + }, + tooltip: 'Auto-adjust CRF +2 when source is HEVC/H.265 to prevent size bloat from re-encoding already-efficient codecs.', }, { name: 'preset', @@ -205,18 +238,17 @@ const details = () => ({ { name: 'lookahead', type: 'string', - defaultValue: '-1*', + defaultValue: '0*', inputUI: { type: 'dropdown', options: [ - '-1*', - '0', + '0*', '60', '90', '120' ], }, - tooltip: 'Lookahead frames. (default: -1) 0 = Off (fastest), -1 = Auto (good compromise), higher = better quality, slower encoding.', + tooltip: 'Lookahead frames. 0 = Off/Auto (fastest, lets SVT-AV1 decide), 60-120 = higher quality but slower encoding.', }, { name: 'enable_tf', @@ -343,17 +375,17 @@ const details = () => ({ { name: 'container', type: 'string', - defaultValue: 'mp4*', + defaultValue: 'original*', inputUI: { type: 'dropdown', options: [ - 'mp4*', + 'original*', 'mkv', - 'webm', - 'original' + 'mp4', + 'webm' ], }, - tooltip: 'Output container format. "mp4" = best compatibility. "original" keeps input container.', + tooltip: 'Output container. "original" inherits from Plugin 01 (recommended, avoids subtitle issues). MKV supports all codecs/subs. MP4 for device compatibility (but may drop some subtitle formats).', }, { name: 'skip_hevc', @@ -380,15 +412,150 @@ const details = () => ({ ], }, tooltip: 'Force transcoding even if the file is already AV1. Useful for changing quality or preset.', + }, + { + name: 'bitrate_awareness', + type: 'string', + defaultValue: 'enabled*', + inputUI: { + type: 'dropdown', + options: [ + 'disabled', + 'enabled*' + ], + }, + tooltip: 'Skip files that are already lower than the threshold bitrate. Prevents wasting CPU on tiny files that will likely increase in size.', + }, + { + name: 'min_source_bitrate', + type: 'string', + defaultValue: '400*', + inputUI: { + type: 'dropdown', + options: [ + '150', + '200', + '250', + '300', + '350', + '400*', + '500', + '600', + '800' + ], + }, + tooltip: 'Minimum source bitrate (kbps). Only used when bitrate_awareness is enabled. 400 kbps is usually the floor for 480p quality.', } ], }); +// Inline utilities (Tdarr plugins must be self-contained) +const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v); +const sanitizeInputs = (inputs) => { + Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); }); + return inputs; +}; + +// Container-aware subtitle compatibility +// Subtitles incompatible with MKV container +const MKV_INCOMPATIBLE_SUBS = new Set(['mov_text', 'tx3g']); +// Subtitles incompatible with MP4 container (most text/image subs) +const MP4_INCOMPATIBLE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub', 'subrip', 'srt', 'ass', 'ssa', 'webvtt']); +// Text subtitles that can be converted to mov_text for MP4 +const MP4_CONVERTIBLE_SUBS = new Set(['subrip', 'srt', 'ass', 'ssa', 'webvtt', 'text']); +// Image subtitles that must be dropped for MP4 (cannot be converted) +const IMAGE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']); + +/** + * Build container-aware subtitle handling arguments + * @param {Array} streams - ffprobe streams array + * @param {string} targetContainer - target container (mkv, mp4, webm) + * @returns {Object} { subtitleArgs: string, subtitleLog: string } + */ +const buildSubtitleArgs = (streams, targetContainer) => { + const subtitleStreams = streams + .map((s, i) => ({ ...s, index: i })) + .filter(s => s.codec_type === 'subtitle'); + + if (subtitleStreams.length === 0) { + return { subtitleArgs: '', subtitleLog: '' }; + } + + const container = targetContainer.toLowerCase(); + let args = ''; + let log = ''; + + if (container === 'mp4' || container === 'm4v') { + // MP4: Convert compatible text subs to mov_text, drop image subs + const toConvert = []; + const toDrop = []; + + subtitleStreams.forEach(s => { + const codec = (s.codec_name || '').toLowerCase(); + if (IMAGE_SUBS.has(codec)) { + toDrop.push(s); + } else if (MP4_CONVERTIBLE_SUBS.has(codec)) { + toConvert.push(s); + } else if (codec === 'mov_text') { + // Already compatible, will be copied + } else { + // Unknown format - try to convert, FFmpeg will error if it can't + toConvert.push(s); + } + }); + + if (toDrop.length > 0) { + // Build negative mapping for dropped streams + toDrop.forEach(s => { + args += ` -map -0:${s.index}`; + }); + log += `Dropping ${toDrop.length} image subtitle(s) (incompatible with MP4). `; + } + + if (toConvert.length > 0) { + // Convert text subs to mov_text + args += ' -c:s mov_text'; + log += `Converting ${toConvert.length} subtitle(s) to mov_text for MP4. `; + } else if (toDrop.length === 0) { + args += ' -c:s copy'; + } + } else if (container === 'webm') { + // WebM: Only supports WebVTT, drop everything else + const incompatible = subtitleStreams.filter(s => { + const codec = (s.codec_name || '').toLowerCase(); + return codec !== 'webvtt'; + }); + + if (incompatible.length > 0) { + incompatible.forEach(s => { + args += ` -map -0:${s.index}`; + }); + log += `Dropping ${incompatible.length} subtitle(s) (WebM only supports WebVTT). `; + } + if (incompatible.length < subtitleStreams.length) { + args += ' -c:s copy'; + } + } else { + // MKV: Very permissive, just convert mov_text to srt + const movTextStreams = subtitleStreams.filter(s => + (s.codec_name || '').toLowerCase() === 'mov_text' + ); + + if (movTextStreams.length > 0) { + args += ' -c:s srt'; + log += `Converting ${movTextStreams.length} mov_text subtitle(s) to SRT for MKV. `; + } else { + args += ' -c:s copy'; + } + } + + return { subtitleArgs: args, subtitleLog: log }; +}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const plugin = (file, librarySettings, inputs, otherArguments) => { const lib = require('../methods/lib')(); - // Initialize response first for error handling const response = { processFile: false, preset: '', @@ -400,238 +567,14 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { }; try { - // eslint-disable-next-line @typescript-eslint/no-unused-vars,no-param-reassign - inputs = lib.loadDefaultValues(inputs, details); - - // 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; - }; - - // Sanitize UI-starred defaults - const sanitized = { - crf: stripStar(inputs.crf), - preset: stripStar(inputs.preset), - tune: stripStar(inputs.tune), - scd: stripStar(inputs.scd), - aq_mode: stripStar(inputs.aq_mode), - threads: stripStar(inputs.threads), - keyint: stripStar(inputs.keyint), - hierarchical_levels: stripStar(inputs.hierarchical_levels), - film_grain: stripStar(inputs.film_grain), - input_depth: stripStar(inputs.input_depth), - fast_decode: stripStar(inputs.fast_decode), - lookahead: stripStar(inputs.lookahead), - enable_tf: stripStar(inputs.enable_tf), - container: stripStar(inputs.container), - max_resolution: stripStar(inputs.max_resolution), - resolution_crf_adjust: stripStar(inputs.resolution_crf_adjust), - custom_maxrate: stripStar(inputs.custom_maxrate), - target_bitrate_strategy: stripStar(inputs.target_bitrate_strategy), - rate_control_mode: stripStar(inputs.rate_control_mode), - skip_hevc: stripStar(inputs.skip_hevc), - force_transcode: stripStar(inputs.force_transcode), - vmaf_target: stripStar(inputs.vmaf_target), - vmaf_samples: stripStar(inputs.vmaf_samples), - }; - - // Detect ab-av1 binary path with multi-level fallback - const getAbAv1Path = () => { - const fs = require('fs'); - const { execSync } = require('child_process'); - - // Try environment variable first - const envPath = (process.env.ABAV1_PATH || '').trim(); - if (envPath) { - try { - if (fs.existsSync(envPath)) { - // Try to check executable, but don't fail if check errors - try { - fs.accessSync(envPath, fs.constants.X_OK); - } catch (accessErr) { - // File exists but X_OK check failed - try anyway (Docker mount issue) - response.infoLog += `Note: ab-av1 at ${envPath} exists but X_OK check failed, trying anyway.\n`; - } - return envPath; - } - } catch (e) { - // Continue to next detection method - } - } - - // Try common installation paths - const commonPaths = [ - '/usr/local/bin/ab-av1', - '/usr/bin/ab-av1', - ]; - - for (const checkPath of commonPaths) { - try { - if (fs.existsSync(checkPath)) { - // Try to check executable, but don't fail if check errors - try { - fs.accessSync(checkPath, fs.constants.X_OK); - } catch (accessErr) { - // File exists but X_OK check failed - try anyway (Docker mount issue) - response.infoLog += `Note: ab-av1 at ${checkPath} exists but X_OK check failed, trying anyway.\n`; - } - return checkPath; - } - } catch (e) { - // Continue to next path - } - } - - // Fallback: Try 'which' command to find ab-av1 in PATH - try { - const whichResult = execSync('which ab-av1', { encoding: 'utf8', timeout: 5000 }).trim(); - if (whichResult && fs.existsSync(whichResult)) { - response.infoLog += `Found ab-av1 via 'which': ${whichResult}\n`; - return whichResult; - } - } catch (e) { - // which failed or ab-av1 not in PATH - } - - // Not found in any known location - return null; - }; - - // Execute ab-av1 crf-search synchronously to find optimal CRF for target VMAF - // Returns { success: boolean, crf: number|null, vmaf: number|null, error: string|null } - const executeAbAv1CrfSearch = (abav1Path, inputFile, vmafTarget, sampleCount, preset) => { - const { execSync } = require('child_process'); - const fs = require('fs'); - const os = require('os'); - const path = require('path'); - - // Create temp directory for ffmpeg/ffprobe wrappers - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ab-av1-')); - const ffmpegWrapper = path.join(tempDir, 'ffmpeg'); - const ffprobeWrapper = path.join(tempDir, 'ffprobe'); - - try { - // Create wrapper scripts that call tdarr-ffmpeg - const wrapperScript = '#!/bin/sh\nexec tdarr-ffmpeg "$@"\n'; - fs.writeFileSync(ffmpegWrapper, wrapperScript, { mode: 0o755 }); - fs.writeFileSync(ffprobeWrapper, wrapperScript, { mode: 0o755 }); - - // Build ab-av1 command - // --min-vmaf is the target VMAF score to achieve - // --samples controls how many sample segments to test - // --encoder specifies the encoder (libsvtav1 for FFmpeg) - const args = [ - 'crf-search', - '-i', `"${inputFile}"`, - '--min-vmaf', vmafTarget.toString(), - '--samples', sampleCount.toString(), - '--encoder', 'libsvtav1', - '--preset', preset.toString(), - ]; - - const command = `${abav1Path} ${args.join(' ')}`; - - // Execute with timeout (5 minutes should be enough for sample encodes) - // ab-av1 looks for 'ffmpeg' and 'ffprobe' in PATH - // Prepend temp directory to PATH so our wrappers are found first - const output = execSync(command, { - encoding: 'utf8', - timeout: 300000, // 5 minute timeout - maxBuffer: 10 * 1024 * 1024, // 10MB buffer - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - // Prepend temp dir so ab-av1 finds our ffmpeg/ffprobe wrappers - PATH: `${tempDir}:${process.env.PATH || ''}`, - } - }); - - // Parse ab-av1 output for CRF value - // Expected format: "crf 28, VMAF 95.2" or similar - // Also matches: "Best crf: 28" or "crf: 28 vmaf: 95.2" - const crfMatch = output.match(/(?:crf|CRF)[:\s]+(\d+)/i); - const vmafMatch = output.match(/(?:vmaf|VMAF)[:\s]+([\d.]+)/i); - - if (crfMatch) { - return { - success: true, - crf: parseInt(crfMatch[1]), - vmaf: vmafMatch ? parseFloat(vmafMatch[1]) : null, - error: null, - output: output.substring(0, 500) // Truncate for logging - }; - } else { - return { - success: false, - crf: null, - vmaf: null, - error: 'Could not parse CRF from ab-av1 output', - output: output.substring(0, 500) - }; - } - } catch (error) { - // Handle execution errors - let errorMsg = error.message; - if (error.killed) { - errorMsg = 'ab-av1 timed out after 5 minutes'; - } else if (error.status) { - errorMsg = `ab-av1 exited with code ${error.status}`; - } - - return { - success: false, - crf: null, - vmaf: null, - error: errorMsg, - output: error.stderr ? error.stderr.substring(0, 500) : '' - }; - } finally { - // Clean up temp directory and wrapper scripts - try { - if (fs.existsSync(ffmpegWrapper)) fs.unlinkSync(ffmpegWrapper); - if (fs.existsSync(ffprobeWrapper)) fs.unlinkSync(ffprobeWrapper); - if (fs.existsSync(tempDir)) fs.rmdirSync(tempDir); - } catch (cleanupError) { - // Ignore cleanup errors - } - } - }; + const sanitized = sanitizeInputs(lib.loadDefaultValues(inputs, details)); // Detect actual input container format via ffprobe const actualFormatName = file.ffProbeData?.format?.format_name || ''; const looksLikeAppleMp4Family = actualFormatName.includes('mov') || actualFormatName.includes('mp4'); - // Detect Apple/broadcast streams that are problematic in MKV or missing codec name - const unsupportedSubtitleIdx = []; - const unsupportedDataIdx = []; - try { - file.ffProbeData.streams.forEach((s, idx) => { - if (s.codec_type === 'subtitle') { - const name = (s.codec_name || '').toLowerCase(); - const tag = (s.codec_tag_string || '').toLowerCase(); - if (!name) { - // skip subs missing codec_name (e.g., WEBVTT detection failures) - unsupportedSubtitleIdx.push(idx); - } else if (name === 'eia_608' || name === 'cc_dec') { - unsupportedSubtitleIdx.push(idx); - } else if (name === 'tx3g' || tag === 'tx3g') { - // tx3g sometimes shows as timed text in MP4; in mkv it may appear as bin_data - unsupportedSubtitleIdx.push(idx); - } - } else if (s.codec_type === 'data') { - const name = (s.codec_name || '').toLowerCase(); - const tag = (s.codec_tag_string || '').toLowerCase(); - if (name === 'bin_data' || tag === 'tx3g') { - unsupportedDataIdx.push(idx); - } - } - }); - } catch (e) { - // ignore detection errors, continue safely - } + // NOTE: Stream cleanup is now handled by the stream_cleanup plugin earlier in the pipeline. + // We use simple -map 0 mapping, relying on stream_cleanup to remove problematic streams. // Check if file is already AV1 and skip if not forcing transcode const isAV1 = file.ffProbeData.streams.some(stream => @@ -641,7 +584,7 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { if (isAV1 && sanitized.force_transcode !== 'enabled') { response.processFile = false; - response.infoLog += 'File is already AV1 encoded and force_transcode is disabled. Skipping.\n'; + response.infoLog += '✅ File is already AV1 encoded and force_transcode is disabled. Skipping.\n'; return response; } @@ -653,15 +596,39 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { if (isHEVC && sanitized.skip_hevc === 'enabled') { response.processFile = false; - response.infoLog += 'File is HEVC/H.265 encoded and skip_hevc is enabled. Skipping for separate processing.\n'; + response.infoLog += 'ℹ️ File is HEVC/H.265 encoded and skip_hevc is enabled. Skipping for separate processing.\n'; return response; } + // Source Bitrate Awareness Check + const duration = parseFloat(file.ffProbeData?.format?.duration) || 0; + const sourceSize = file.statSync?.size || 0; + let sourceBitrateKbps = 0; + + if (duration > 0 && sourceSize > 0) { + sourceBitrateKbps = Math.round((sourceSize * 8) / (duration * 1000)); + } + + if (sanitized.bitrate_awareness === 'enabled') { + const minBitrate = parseInt(sanitized.min_source_bitrate) || 400; + if (sourceBitrateKbps === 0) { + response.infoLog += `Warning: Could not calculate source bitrate (duration: ${duration}s, size: ${sourceSize}). Skipping bitrate check.\n`; + } else if (sourceBitrateKbps < minBitrate) { + response.processFile = false; + response.infoLog += `Source bitrate (${sourceBitrateKbps} kbps) is below minimum threshold (${minBitrate} kbps). Skipping to prevent size bloat.\n`; + return response; + } else { + response.infoLog += `Source bitrate: ${sourceBitrateKbps} kbps (Threshold: ${minBitrate} kbps). Proceeding.\n`; + } + } else { + response.infoLog += `Source bitrate: ${sourceBitrateKbps} kbps. Awareness disabled.\n`; + } + // Validate video stream exists const videoStream = file.ffProbeData.streams.find(s => s.codec_type === 'video'); if (!videoStream) { response.processFile = false; - response.infoLog += 'Error: No video stream found in file. Skipping.\n'; + response.infoLog += '❌ Error: No video stream found in file. Skipping.\n'; return response; } @@ -729,32 +696,70 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { outputHeight = videoStream.height; } + // Apply quality preset to determine CRF, qmin, qmax values + // Presets override manual values unless quality_preset is 'custom' + let effectiveCrf = sanitized.crf; + let effectiveQmin = sanitized.qmin; + let effectiveQmax = sanitized.qmax; + + const qualityPresets = { + archival: { crf: '18', qmin: '5', qmax: '35' }, + high: { crf: '22', qmin: '10', qmax: '40' }, + balanced: { crf: '28', qmin: '10', qmax: '45' }, + efficient: { crf: '30', qmin: '10', qmax: '55' }, + }; + + if (sanitized.quality_preset !== 'custom' && qualityPresets[sanitized.quality_preset]) { + const preset = qualityPresets[sanitized.quality_preset]; + effectiveCrf = preset.crf; + effectiveQmin = preset.qmin; + effectiveQmax = preset.qmax; + response.infoLog += `Quality preset "${sanitized.quality_preset}" applied: CRF ${effectiveCrf}, qmin ${effectiveQmin}, qmax ${effectiveQmax}.\n`; + } else if (sanitized.quality_preset === 'custom') { + response.infoLog += `Custom quality settings: CRF ${effectiveCrf}, qmin ${effectiveQmin}, qmax ${effectiveQmax}.\n`; + } + // Resolution-based CRF adjustment (applies to OUTPUT resolution after any downscaling) - let finalCrf = sanitized.crf; + let finalCrf = effectiveCrf; if (sanitized.resolution_crf_adjust === 'enabled' && outputHeight) { - const baseCrf = parseInt(sanitized.crf); + const baseCrf = parseInt(effectiveCrf); // Validate CRF is a valid number if (isNaN(baseCrf) || baseCrf < 0 || baseCrf > 63) { - response.infoLog += `Warning: Invalid CRF value "${sanitized.crf}", using default.\n`; + response.infoLog += `Warning: Invalid CRF value "${effectiveCrf}", using default.\n`; finalCrf = '26'; } else { if (outputHeight >= 2160) { // 4K finalCrf = Math.min(63, baseCrf + 2).toString(); - response.infoLog += `4K output resolution detected, CRF adjusted from ${sanitized.crf} to ${finalCrf}.\n`; - } else if (outputHeight <= 720) { // 720p or lower - finalCrf = Math.max(1, baseCrf - 2).toString(); - response.infoLog += `720p or lower output resolution detected, CRF adjusted from ${sanitized.crf} to ${finalCrf}.\n`; + response.infoLog += `4K output resolution detected, CRF adjusted from ${effectiveCrf} to ${finalCrf}.\n`; + } else if (outputHeight <= 480) { // 480p or lower + finalCrf = Math.min(63, baseCrf + 2).toString(); + response.infoLog += `480p or lower output resolution detected, CRF adjusted from ${effectiveCrf} to ${finalCrf}.\n`; + } else if (outputHeight <= 720) { // 720p + response.infoLog += `720p output resolution detected, using base CRF ${finalCrf}.\n`; } else { response.infoLog += `1080p output resolution detected, using base CRF ${finalCrf}.\n`; } } + } + + // Source codec awareness - increase CRF for already-efficient codecs + if (sanitized.source_codec_awareness === 'enabled') { + const sourceCodec = videoStream.codec_name?.toLowerCase() || ''; + const efficientCodecs = ['hevc', 'h265', 'libx265']; + + if (efficientCodecs.includes(sourceCodec)) { + const currentCrf = parseInt(finalCrf); + finalCrf = Math.min(63, currentCrf + 2).toString(); + response.infoLog += `Source codec "${sourceCodec}" is already efficient: CRF adjusted +2 to ${finalCrf} to prevent bloat.\n`; + } } else if (sanitized.resolution_crf_adjust === 'enabled') { response.infoLog += `Resolution-based CRF adjustment enabled but video height not detected, using base CRF ${finalCrf}.\n`; } // Build SVT-AV1 parameters string - const svtParams = [ + // Note: lookahead is only passed when > 0 (SVT-AV1 v3.x rejects -1 and may have issues with 0 via FFmpeg wrapper) + const svtParamsArray = [ `preset=${finalPreset}`, `tune=${sanitized.tune}`, `scd=${sanitized.scd}`, @@ -765,13 +770,20 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { `film-grain=${sanitized.film_grain}`, `input-depth=${sanitized.input_depth}`, `fast-decode=${sanitized.fast_decode}`, - `lookahead=${sanitized.lookahead}`, `enable-tf=${sanitized.enable_tf}` - ].join(':'); + ]; - // Set up FFmpeg arguments for CRF quality control with fixed qmin/qmax - let qualityArgs = `-crf ${finalCrf} -qmin 10 -qmax 50`; - let bitrateControlInfo = `Using CRF mode with value ${finalCrf}`; + // Only add lookahead if explicitly set to a positive value + const lookaheadVal = parseInt(sanitized.lookahead); + if (lookaheadVal > 0) { + svtParamsArray.push(`lookahead=${lookaheadVal}`); + response.infoLog += `Lookahead set to ${lookaheadVal} frames.\\n`; + } + + const svtParams = svtParamsArray.join(':'); + + // Set up FFmpeg arguments for CRF quality control with configurable qmin/qmax + let qualityArgs = `-crf ${finalCrf} -qmin ${effectiveQmin} -qmax ${effectiveQmax}`; // Explicitly set pixel format for 10-bit to ensure correct encoding if (sanitized.input_depth === '10') { @@ -779,231 +791,27 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { response.infoLog += `10-bit encoding enabled with yuv420p10le pixel format.\n`; } - // Source bitrate detection for target_bitrate_strategy - let sourceBitrateKbps = null; - if (videoStream) { - // Try to get bitrate from video stream first - if (videoStream.bit_rate) { - sourceBitrateKbps = Math.round(parseInt(videoStream.bit_rate) / 1000); - response.infoLog += `Detected video stream bitrate: ${sourceBitrateKbps}k.\n`; - } else if (file.ffProbeData?.format?.bit_rate) { - // Fall back to overall file bitrate - sourceBitrateKbps = Math.round(parseInt(file.ffProbeData.format.bit_rate) / 1000); - response.infoLog += `Detected file bitrate (video stream bitrate unavailable): ${sourceBitrateKbps}k.\n`; - } - } + // Build quality/bitrate arguments based on rate control mode + let bitrateControlInfo = `Using CRF mode with value ${finalCrf}`; - // Estimate expected average bitrate for a given CRF and resolution - // Based on SVT-AV1 CRF 30, preset ~6, average movie content (VMAF ~95) - // Lower CRF = higher bitrate (roughly 10-15% increase per CRF step down) - const estimateCrfBitrate = (crf, height) => { - // Baseline bitrates for CRF 30 - let baselineCrf30 = 3000; // Default to 1080p + if (sanitized.rate_control_mode === 'vbr') { + // VBR Mode: Use target bitrate. SVT-AV v3.1+ doesn't support -maxrate with VBR. + const targetBitrate = parseInt(sanitized.target_bitrate) || 2200; + qualityArgs = `-b:v ${targetBitrate}k -qmin ${effectiveQmin} -qmax ${effectiveQmax}`; - if (height >= 2160) { - baselineCrf30 = 12000; // 4K average - } else if (height >= 1440) { - baselineCrf30 = 6000; // 1440p estimate (between 1080p and 4K) - } else if (height >= 1080) { - baselineCrf30 = 3000; // 1080p average - } else if (height >= 720) { - baselineCrf30 = 2000; // 720p average - } else { - baselineCrf30 = 1200; // 480p average - } - - // Adjust for CRF difference from baseline (CRF 30) - // Each CRF step down increases bitrate by ~12% - const crfDiff = 30 - parseInt(crf); - const bitrateFactor = Math.pow(1.12, crfDiff); - - return Math.round(baselineCrf30 * bitrateFactor); - }; - - // Calculate target bitrate and maxrate based on rate control mode - let calculatedTargetBitrate = null; // For VBR mode - let calculatedMaxrate = null; // For both modes - let bitrateSource = ''; - - // Step 1: Calculate base bitrate from strategy - if (sanitized.target_bitrate_strategy !== 'static') { - if (sourceBitrateKbps) { - let multiplier = 1.0; - - switch (sanitized.target_bitrate_strategy) { - case 'match_source': - multiplier = 1.0; - break; - case '75%_source': - multiplier = 0.75; - break; - case '50%_source': - multiplier = 0.50; - break; - case '33%_source': - multiplier = 0.33; - break; - case '25%_source': - multiplier = 0.25; - break; - } - - const baseBitrate = Math.round(sourceBitrateKbps * multiplier); - - // Step 2: Apply mode-specific logic - if (sanitized.rate_control_mode === 'vbr') { - // VBR Mode: Target average = base, Maxrate = base * 2.0 (headroom for peaks) - calculatedTargetBitrate = baseBitrate; - calculatedMaxrate = Math.round(baseBitrate * 2.0); - bitrateSource = `VBR mode with target_bitrate_strategy '${sanitized.target_bitrate_strategy}': Source ${sourceBitrateKbps}k * ${multiplier} = Target ${calculatedTargetBitrate}k, Maxrate ${calculatedMaxrate}k (2.0x headroom)`; - response.infoLog += `Using ${bitrateSource}.\n`; - } else { - // CRF Mode: Ensure maxrate is higher than what CRF would naturally produce - // Estimate what the CRF will average based on resolution - const estimatedCrfAvg = estimateCrfBitrate(finalCrf, outputHeight || 1080); - - // Set maxrate to the higher of: user's calculated value OR 1.8x estimated CRF average - // The 1.8x ensures headroom for peaks above the CRF average - const minSafeMaxrate = Math.round(estimatedCrfAvg * 1.8); - - if (baseBitrate < minSafeMaxrate) { - calculatedMaxrate = minSafeMaxrate; - bitrateSource = `CRF mode: Calculated ${baseBitrate}k from strategy, but CRF ${finalCrf} @ ${outputHeight || 1080}p averages ~${estimatedCrfAvg}k. Using Maxrate ${calculatedMaxrate}k (1.8x avg) for headroom`; - response.infoLog += `${bitrateSource}.\n`; - } else { - calculatedMaxrate = baseBitrate; - bitrateSource = `CRF mode with target_bitrate_strategy '${sanitized.target_bitrate_strategy}': Source ${sourceBitrateKbps}k * ${multiplier} = Maxrate ${calculatedMaxrate}k (above CRF estimate)`; - response.infoLog += `Using ${bitrateSource}.\n`; - } - } - } else { - response.infoLog += `Warning: target_bitrate_strategy '${sanitized.target_bitrate_strategy}' selected but source bitrate unavailable. Falling back to static mode.\n`; - } - } - - // Priority 2: custom_maxrate (if strategy is static or failed) - if (!calculatedMaxrate && sanitized.custom_maxrate && sanitized.custom_maxrate !== '' && sanitized.custom_maxrate !== '0') { - const customValue = parseInt(sanitized.custom_maxrate); - if (!isNaN(customValue) && customValue > 0) { - if (sanitized.rate_control_mode === 'vbr') { - // VBR mode: Custom value is the target, maxrate = target * 2.0 - calculatedTargetBitrate = customValue; - calculatedMaxrate = Math.round(customValue * 2.0); - bitrateSource = `VBR mode with custom_maxrate: Target ${calculatedTargetBitrate}k, Maxrate ${calculatedMaxrate}k (2.0x headroom)`; - response.infoLog += `Using ${bitrateSource}.\n`; - } else { - // CRF mode: Ensure custom maxrate is reasonable for the CRF - const estimatedCrfAvg = estimateCrfBitrate(finalCrf, outputHeight || 1080); - const minSafeMaxrate = Math.round(estimatedCrfAvg * 1.8); - - if (customValue < minSafeMaxrate) { - calculatedMaxrate = minSafeMaxrate; - bitrateSource = `CRF mode: Custom ${customValue}k is below safe minimum for CRF ${finalCrf} @ ${outputHeight || 1080}p (est. ~${estimatedCrfAvg}k avg). Using ${calculatedMaxrate}k (1.8x) for headroom`; - response.infoLog += `${bitrateSource}.\n`; - } else { - calculatedMaxrate = customValue; - bitrateSource = `CRF mode with custom_maxrate: Maxrate ${calculatedMaxrate}k`; - response.infoLog += `Using ${bitrateSource}.\n`; - } - } - } else { - response.infoLog += `Warning: Invalid custom_maxrate value '${sanitized.custom_maxrate}'. Using uncapped CRF.\n`; - } - } - - // Apply calculated maxrate if any method succeeded - // Enforce minimum bitrate threshold to prevent unusable output (resolution-aware) - const getMinBitrate = (height) => { - if (height >= 2160) return 2000; // 4K - if (height >= 1440) return 1500; // 1440p - if (height >= 1080) return 800; // 1080p - if (height >= 720) return 500; // 720p - return 250; // 480p and below - }; - - const minBitrate = getMinBitrate(outputHeight || 1080); - - // Adjust target and maxrate if below minimum - if (calculatedTargetBitrate && calculatedTargetBitrate < minBitrate) { - response.infoLog += `Warning: Calculated target bitrate ${calculatedTargetBitrate}k is below minimum for ${outputHeight || 1080}p. Raising to ${minBitrate}k.\n`; - calculatedTargetBitrate = minBitrate; - calculatedMaxrate = Math.round(minBitrate * 2.0); // Adjust maxrate proportionally - } else if (calculatedMaxrate && calculatedMaxrate < minBitrate) { - response.infoLog += `Warning: Calculated maxrate ${calculatedMaxrate}k is below minimum for ${outputHeight || 1080}p. Raising to ${minBitrate}k.\n`; - calculatedMaxrate = minBitrate; - } - - // Step 3: Build quality/bitrate arguments based on mode - if (sanitized.rate_control_mode === 'vbr' && calculatedTargetBitrate) { - // VBR Mode: Use target bitrate + maxrate - const bufsize = calculatedMaxrate; // Buffer size = maxrate for VBR - qualityArgs += ` -b:v ${calculatedTargetBitrate}k -maxrate ${calculatedMaxrate}k -bufsize ${bufsize}k`; - bitrateControlInfo += ` with VBR target ${calculatedTargetBitrate}k, maxrate ${calculatedMaxrate}k (bufsize: ${bufsize}k)`; - response.infoLog += `VBR encoding: Target average ${calculatedTargetBitrate}k, peak ${calculatedMaxrate}k, buffer ${bufsize}k.\n`; - } else if (sanitized.rate_control_mode === 'vmaf') { - // VMAF Mode: Use ab-av1 for automatic CRF calculation - const abav1Path = getAbAv1Path(); - - if (!abav1Path) { - response.infoLog += 'VMAF mode selected but ab-av1 binary not found. Falling back to CRF mode.\n'; - response.infoLog += 'To use VMAF mode, ensure ab-av1 is installed and accessible (check ABAV1_PATH env var or /usr/local/bin/ab-av1).\n'; - // Fall through to standard CRF encoding - use user's configured CRF - bitrateControlInfo = `CRF ${finalCrf} (VMAF fallback - ab-av1 not found)`; - } else { - response.infoLog += `Using ab-av1 for quality-targeted encoding (target VMAF ${sanitized.vmaf_target}).\n`; - response.infoLog += `ab-av1 binary: ${abav1Path}\n`; - - const vmafTarget = parseInt(sanitized.vmaf_target); - const sampleCount = parseInt(sanitized.vmaf_samples); - - response.infoLog += `Running ab-av1 crf-search to find optimal CRF for VMAF ${vmafTarget}...\n`; - response.infoLog += `Using ${sampleCount} sample segments for quality analysis.\n`; - - // Execute ab-av1 crf-search synchronously - const crfResult = executeAbAv1CrfSearch( - abav1Path, - file.file, - vmafTarget, - sampleCount, - finalPreset - ); - - if (crfResult.success && crfResult.crf !== null) { - // Success! Use the found CRF - const foundCrf = crfResult.crf; - response.infoLog += `✅ ab-av1 found optimal CRF: ${foundCrf}`; - if (crfResult.vmaf) { - response.infoLog += ` (predicted VMAF: ${crfResult.vmaf})`; - } - response.infoLog += '\n'; - - // Update qualityArgs with the ab-av1 determined CRF - // Replace the CRF in qualityArgs (which was set earlier with user's default) - qualityArgs = qualityArgs.replace(/-crf \d+/, `-crf ${foundCrf}`); - bitrateControlInfo = `VMAF-targeted CRF ${foundCrf} (target VMAF: ${vmafTarget}, achieved: ${crfResult.vmaf || 'unknown'})`; - - // Store metadata for logging/debugging - response.abav1CrfResult = foundCrf; - response.abav1VmafResult = crfResult.vmaf; - } else { - // ab-av1 failed - fall back to user's configured CRF - response.infoLog += `⚠️ ab-av1 crf-search failed: ${crfResult.error}\n`; - if (crfResult.output) { - response.infoLog += `ab-av1 output: ${crfResult.output}\n`; - } - response.infoLog += `Falling back to configured CRF ${finalCrf}.\n`; - bitrateControlInfo = `CRF ${finalCrf} (VMAF fallback - ab-av1 failed)`; - } - } - } else if (calculatedMaxrate) { - // CRF Mode with maxrate cap - const bufsize = Math.round(calculatedMaxrate * 2.0); // Buffer size = 2.0x maxrate for stability - qualityArgs += ` -maxrate ${calculatedMaxrate}k -bufsize ${bufsize}k`; - bitrateControlInfo += ` with capped bitrate at ${calculatedMaxrate}k (bufsize: ${bufsize}k)`; - response.infoLog += `Capped CRF enabled: Max bitrate ${calculatedMaxrate}k, buffer size ${bufsize}k for optimal bandwidth management.\n`; + bitrateControlInfo = `VBR mode: target ${targetBitrate}k`; + response.infoLog += `VBR encoding: Target average ${targetBitrate}k.\n`; } else { - // Uncapped CRF - response.infoLog += `Using uncapped CRF for maximum quality efficiency.\n`; + // CRF Mode: Quality-based with optional maxrate cap + if (sanitized.maxrate && parseInt(sanitized.maxrate) > 0) { + const maxrateValue = parseInt(sanitized.maxrate); + const bufsize = Math.round(maxrateValue * 2.0); // Buffer = 2x maxrate + qualityArgs += ` -maxrate ${maxrateValue}k -bufsize ${bufsize}k`; + bitrateControlInfo += ` with maxrate cap at ${maxrateValue}k`; + response.infoLog += `Capped CRF enabled: Max bitrate ${maxrateValue}k, buffer ${bufsize}k.\n`; + } else { + response.infoLog += `Using uncapped CRF for maximum quality efficiency.\n`; + } } @@ -1020,46 +828,34 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { } // 1080p and below: No tiles (overhead not worth it) + // Determine target container for subtitle handling + const targetContainer = sanitized.container === 'original' ? file.container : sanitized.container; - // Build mapping with per-stream exclusions if needed - let mapArgs = '-map 0'; - const hasUnsupportedStreams = unsupportedSubtitleIdx.length > 0 || unsupportedDataIdx.length > 0; - if (hasUnsupportedStreams) { - [...unsupportedSubtitleIdx, ...unsupportedDataIdx].forEach((idx) => { - mapArgs += ` -map -0:${idx}`; - }); - response.infoLog += `Excluding unsupported streams from mapping: subtitles[${unsupportedSubtitleIdx.join(', ')}] data[${unsupportedDataIdx.join(', ')}].\n`; + // Build container-aware subtitle arguments + const { subtitleArgs, subtitleLog } = buildSubtitleArgs(file.ffProbeData.streams, targetContainer); + if (subtitleLog) { + response.infoLog += `📁 ${subtitleLog}\\n`; } - // Set up FFmpeg arguments for AV1 SVT conversion - // Use explicit stream mapping instead of -dn to handle data streams precisely + // Use explicit stream mapping to prevent data/attachment streams from causing muxing errors const svtParamsWithTiles = svtParams + tileArgs; - response.preset = `${scaleFilter ? ' ' + scaleFilter : ''} -c:v libsvtav1 ${qualityArgs}${hdrArgs} -svtav1-params "${svtParamsWithTiles}" -c:a copy -c:s copy ${mapArgs}`; + response.preset = `${scaleFilter ? ' ' + scaleFilter : ''} -map 0:v -map 0:a? -map 0:s?${subtitleArgs} -c:v libsvtav1 ${qualityArgs}${hdrArgs} -svtav1-params "${svtParamsWithTiles}" -c:a copy -max_muxing_queue_size 9999`; - // Set container with Apple-specific handling - // If user asked for MKV but input is MP4/MOV family and has unsupported streams, prefer MP4 to avoid mux errors + // Set container if (sanitized.container === 'original') { response.container = `.${file.container}`; - if (looksLikeAppleMp4Family && response.container === '.mkv' && hasUnsupportedStreams) { - response.infoLog += 'Detected MP4/MOV input with Apple/broadcast streams; overriding output container to .mp4 to preserve compatibility.\n'; - response.container = '.mp4'; - } } else { response.container = `.${sanitized.container}`; // WebM container validation - warn about potential compatibility if (sanitized.container === 'webm') { response.infoLog += 'Note: WebM container selected. Ensure audio is Opus/Vorbis for full compatibility.\n'; - if (hasUnsupportedStreams) { - response.infoLog += 'Warning: WebM does not support all subtitle formats. Subtitles may be dropped.\n'; - } } // MKV container handling with user warning - if (sanitized.container === 'mkv' && (looksLikeAppleMp4Family || hasUnsupportedStreams)) { - response.infoLog += 'Warning: MKV requested but file has Apple/broadcast streams that may cause issues. Consider using MP4 container.\n'; - // Don't force override - let user decide, just warn + if (sanitized.container === 'mkv' && looksLikeAppleMp4Family) { + response.infoLog += 'Note: MKV output with Apple/MP4 source. Ensure stream_cleanup ran first.\n'; } } @@ -1077,32 +873,18 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { } response.infoLog += `Using SVT-AV1 preset: ${finalPreset}, tune: ${sanitized.tune} (VQ-optimized when 0), threads: ${threadCount}\n`; - response.infoLog += `Encoding params - SCD: ${sanitized.scd}, AQ: ${sanitized.aq_mode}, Lookahead: ${sanitized.lookahead}, TF: ${sanitized.enable_tf}\n`; - response.infoLog += `Quality control - CRF: ${finalCrf}, Fixed QMin: 10, Fixed QMax: 50, Film grain: ${sanitized.film_grain}\n`; + response.infoLog += `Encoding params - SCD: ${sanitized.scd}, AQ: ${sanitized.aq_mode}, Lookahead: ${lookaheadVal > 0 ? lookaheadVal : 'auto'}, TF: ${sanitized.enable_tf}\\n`; + response.infoLog += `Quality control - CRF: ${finalCrf}, QMin: ${effectiveQmin}, QMax: ${effectiveQmax}, Film grain: ${sanitized.film_grain}\n`; response.infoLog += `Output container: ${response.container}\n`; return response; } catch (error) { - // Comprehensive error handling response.processFile = false; response.preset = ''; response.container = `.${file.container || 'mkv'}`; 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`; - + response.infoLog = `❌ Plugin error: ${error.message}\n`; return response; } }; diff --git a/Local/Tdarr_Plugin_combined_audio_standardizer.js b/Local/Tdarr_Plugin_combined_audio_standardizer.js index c74bef0..51c7bd9 100644 --- a/Local/Tdarr_Plugin_combined_audio_standardizer.js +++ b/Local/Tdarr_Plugin_combined_audio_standardizer.js @@ -6,13 +6,24 @@ const details = () => ({ Operation: 'Transcode', Description: ` Converts audio streams to specified codec (AAC/Opus) with configurable bitrate and channel options. - Can preserve existing channels or downmix from multichannel to stereo/mono. Also creates missing - downmixed tracks (8ch->6ch, 6ch/8ch->2ch) when they don't exist. + ALWAYS preserves original channel count for all tracks. Creates additional downmixed tracks + (8ch->6ch, 6ch/8ch->2ch) as SECONDARY tracks when enabled. Original multichannel tracks are never + replaced - downmix tracks are added alongside them. - v1.15: Fixed duplicate description, added default markers, improved tooltips. - v1.14: Fixed crash when input file has no subtitles (conditional mapping). + v1.24: Opus channel mapping fix - added mapping_family 1 for multichannel Opus and channel + reordering filters for incompatible source layouts (AC3 2.1, 4.1 etc.). Robust fallback + to AAC for layouts exceeding Opus limits (>8ch). + v1.23: CRITICAL FIX - Always preserves original channels. channel_mode now only affects whether + original tracks are downmixed (legacy option). create_downmix creates ADDITIONAL stereo + tracks, never replaces originals. Improved duplicate stereo detection per language. + v1.22: Fixed channel preservation - Opus-incompatible layouts now use AAC fallback instead of + stereo downmix. Smart downmix: one stereo per language, excludes commentary tracks. + v1.21: Added set_default_by_channels option - sets the audio track with the most channels as the + default stream. Ensures surround audio is preferred over stereo in players. + v1.20: Fixed channel preservation - now explicitly sets channel count to prevent FFmpeg defaulting to stereo. + Added channel count to all track titles. Updated default behavior to convert to OPUS unless already AAC. `, - Version: '1.15', + Version: '4.0.0', Tags: 'audio,aac,opus,channels,stereo,downmix,quality', Inputs: [ { @@ -39,7 +50,7 @@ const details = () => ({ 'false' ], }, - tooltip: 'Skip conversion if audio is already AAC or Opus (either format acceptable). When false, converts to target codec.', + tooltip: 'Skip conversion if audio is already AAC or Opus (either format acceptable). When true (default), keeps AAC as-is and converts other codecs to OPUS. When false, converts to exact target codec.', }, { name: 'bitrate_per_channel', @@ -72,7 +83,7 @@ const details = () => ({ 'mono' ], }, - tooltip: 'Channel handling for existing tracks: preserve=keep original channels, stereo=downmix to 2.0, mono=downmix to 1.0.', + tooltip: 'Channel handling for existing tracks: preserve=keep original channels (RECOMMENDED), stereo=downmix original to 2.0 (legacy), mono=downmix to 1.0. Note: create_downmix creates ADDITIONAL tracks, original channels are always preserved when preserve is selected.', }, { name: 'create_downmix', @@ -85,7 +96,7 @@ const details = () => ({ 'true*' ], }, - tooltip: 'Create stereo (2ch) downmix from multichannel audio (5.1/7.1). Only creates if no stereo track exists AND multichannel source is present.', + tooltip: 'Create ADDITIONAL stereo (2ch) downmix tracks from multichannel audio (5.1/7.1). Original channels are ALWAYS preserved. Only creates if no stereo track exists for that language AND multichannel source is present. Creates as SECONDARY track alongside original.', }, { name: 'downmix_single_track', @@ -225,6 +236,19 @@ const details = () => ({ ], }, tooltip: 'Quality presets automatically configure bitrate settings. Use "custom" to manually set bitrate per channel and other encoder options.', + }, + { + name: 'set_default_by_channels', + type: 'string', + defaultValue: 'true*', + inputUI: { + type: 'dropdown', + options: [ + 'false', + 'true*' + ], + }, + tooltip: 'Set the default audio stream to the track with the most channels. This ensures surround audio is preferred by default in players. Runs after all audio processing is complete.', } ], }); @@ -243,32 +267,75 @@ const CHANNEL_MODES = { const COMPATIBLE_CODECS = [CODECS.AAC, CODECS.OPUS, CODECS.LIBOPUS]; -const VALID_BITRATES = ['auto', '64', '80', '96', '128', '160', '192', 'original']; -const VALID_BOOLEAN_VALUES = ['true', 'false']; -const VALID_OPUS_APPLICATIONS = ['audio', 'voip', 'lowdelay']; -const VALID_OPUS_VBR_MODES = ['on', 'off', 'constrained']; -const VALID_OPUS_COMPRESSION = ['0', '5', '8', '10']; -const VALID_AAC_PROFILES = ['aac_low', 'aac_he', 'aac_he_v2']; -const VALID_SAMPLE_RATES = ['original', '48000', '44100', '32000']; -const VALID_QUALITY_PRESETS = ['custom', 'high_quality', 'balanced', 'small_size']; +const VALID_BITRATES = new Set(['auto', '64', '80', '96', '128', '160', '192', 'original']); +const VALID_BOOLEAN_VALUES = new Set(['true', 'false']); +const VALID_OPUS_APPLICATIONS = new Set(['audio', 'voip', 'lowdelay']); +const VALID_OPUS_VBR_MODES = new Set(['on', 'off', 'constrained']); +const VALID_OPUS_COMPRESSION = new Set(['0', '5', '8', '10']); +const VALID_AAC_PROFILES = new Set(['aac_low', 'aac_he', 'aac_he_v2']); +const VALID_SAMPLE_RATES = new Set(['original', '48000', '44100', '32000']); +const VALID_QUALITY_PRESETS = new Set(['custom', 'high_quality', 'balanced', 'small_size']); -// Opus compatible layouts (whitelist approach is more reliable) -const OPUS_COMPATIBLE_LAYOUTS = new Set([ - 'mono', - 'stereo', - '2.1', - '3.0', - '4.0', - '5.0', - '5.1', - '5.1(side)', - '7.1' -]); +// Container-aware subtitle compatibility +const MKV_INCOMPATIBLE_SUBS = new Set(['mov_text', 'tx3g']); +const MP4_INCOMPATIBLE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub', 'subrip', 'ass', 'ssa', 'webvtt']); +const MP4_CONVERTIBLE_SUBS = new Set(['subrip', 'ass', 'ssa', 'webvtt', 'text']); +const IMAGE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']); -const isOpusIncompatibleLayout = (layout) => { - if (!layout) return false; - // If not in compatible list, it's incompatible - return !OPUS_COMPATIBLE_LAYOUTS.has(layout); +// Opus supports up to 8 channels using Vorbis mapping (Family 1) +const OPUS_MAX_CHANNELS = 8; + +/** + * Determines Opus encoding parameters for a stream based on standard Vorbis mapping rules. + * This fixes issues where players default to stereo or fail on multichannel Opus. + * Returns { family: number, filter: string | null, incompatible: boolean, reason: string | null } + */ +const getOpusMappingInfo = (stream) => { + const channels = stream.channels || 0; + const layout = stream.channel_layout || 'unknown'; + const codec = stream.codec_name || ''; + + // Opus hard limit is 8 channels for most containers/players (Family 1) + if (channels < 1 || channels > OPUS_MAX_CHANNELS) { + return { incompatible: true, reason: `Channel count (${channels}) exceeds Opus limit of 8`, family: 0, filter: null }; + } + + const info = { + family: channels > 2 ? 1 : 0, + filter: null, + incompatible: false, + reason: null + }; + + // Channel reordering logic for specific source layouts that don't match Vorbis order. + // We use the 'pan' filter to ensure channels are in the correct position for Opus. + // Mapping logic based on standard broadcast and movie audio formats. + if (codec === 'ac3' || codec === 'eac3' || codec === 'dts') { + // 2.1 (L R LFE) -> Opus 3.0 (L C R) - Vorbis order expects L C R + // AC3 2.1 index: 0=L, 1=R, 2=LFE -> Target index: 0=L, 1=C(LFE), 2=R + if (layout === '2.1' || (channels === 3 && layout === 'unknown')) { + info.filter = 'pan=3.0|c0=c0|c1=c2|c2=c1'; + } + // 3.1 (L C R LFE) -> Opus 4.0 (L R LS RS) + // Map LFE and Center to surround positions to preserve all elements + else if (layout === '3.1') { + info.filter = 'pan=4.0|c0=c0|c1=c2|c2=c1|c3=c3'; + } + // 4.1 (L R LS RS LFE) -> Opus 5.0 (L C R LS RS) + // Map LFE to Center to preserve layout balance + else if (layout === '4.1') { + info.filter = 'pan=5.0|c0=c0|c1=c4|c2=c1|c3=c2|c4=c3'; + } + // 5.1 and 7.1 usually match standard Vorbis order correctly in FFmpeg + } + + // If layout is still unknown and channels are non-standard, we should be cautious + if (layout === 'unknown' && channels > 2 && channels !== 3 && channels !== 4 && channels !== 5 && channels !== 6 && channels !== 8) { + info.incompatible = true; + info.reason = `Non-standard channel count (${channels}) with unknown layout`; + } + + return info; }; const QUALITY_PRESETS = { @@ -302,18 +369,31 @@ const needsTranscoding = (stream, inputs, targetCodec) => { // Force transcode if explicitly requested if (inputs.force_transcode === 'true') return true; - // Check if channel layout needs changing + // IMPORTANT: channel_mode 'stereo' and 'mono' are legacy options that downmix original tracks. + // The recommended approach is 'preserve' + create_downmix=true to keep originals AND add downmix. + // We still support legacy mode for backward compatibility, but it's not recommended. + // Check if channel layout needs changing (legacy mode only) if (inputs.channel_mode === 'stereo' && stream.channels > 2) return true; if (inputs.channel_mode === 'mono' && stream.channels > 1) return true; - // If skip_if_compatible is true, accept any compatible codec (AAC or Opus) - // This means: if codec is AAC or Opus, don't transcode (even if target is different) + // Default behavior when skip_if_compatible is true: Convert to OPUS unless already AAC + // This means: keep AAC as-is, convert everything else (including Opus) to target codec if (inputs.skip_if_compatible === 'true') { + if (inputs.codec === CODECS.OPUS) { + // Special case: Keep AAC, convert everything else to Opus + if (stream.codec_name === CODECS.AAC) { + return false; // Keep AAC + } + // If already Opus and matches target, skip (but allow re-encoding if bitrate/channels change) + if (targetCodec.includes(stream.codec_name)) { + return false; // Already correct codec + } + } + // For other cases, skip if already a compatible codec (AAC or Opus) return !COMPATIBLE_CODECS.includes(stream.codec_name); } - // Otherwise, only accept exact target codec match - // This means: if codec doesn't match target, transcode + // When skip_if_compatible is false, only accept exact target codec match return !targetCodec.includes(stream.codec_name); }; @@ -356,7 +436,9 @@ const applyQualityPreset = (inputs) => { const preset = QUALITY_PRESETS[inputs.quality_preset]; if (!preset) { // Log warning if preset not found, fallback to custom - console.warn(`Warning: Quality preset '${inputs.quality_preset}' not found, using custom settings.`); + // Log warning if preset not found, fallback to custom (should be caught by validateInputs though) + // console.warn(`Warning: Quality preset '${inputs.quality_preset}' not found, using custom settings.`); + // Changing to silent return as validation handles it, or could throw error. return inputs; } @@ -376,14 +458,19 @@ const applyQualityPreset = (inputs) => { return modifiedInputs; }; -const buildCodecArgs = (audioIdx, inputs, targetBitrate) => { +const buildCodecArgs = (audioIdx, inputs, targetBitrate, channels = 0) => { if (inputs.codec === CODECS.OPUS) { + // For Opus, apply mapping family 1 for multichannel (3-8 channels) + // This fixes issues where players default to stereo or fail on multichannel Opus + const mappingArgs = (channels > 2 && channels <= 8) ? ` -mapping_family:a:${audioIdx} 1` : ''; + // Note: -vbr, -application, -compression_level are encoder-global options // They are added once at the end of the command via getOpusGlobalArgs() return [ `-c:a:${audioIdx} libopus`, - targetBitrate ? `-b:a:${audioIdx} ${targetBitrate}k` : '' - ].filter(Boolean).join(' '); + targetBitrate ? `-b:a:${audioIdx} ${targetBitrate}k` : '', + mappingArgs + ].filter(Boolean).join(' ').trim(); } // AAC with profile selection @@ -411,31 +498,61 @@ const getSampleRateArgs = (audioIdx, inputs) => { return ` -ar:a:${audioIdx} ${inputs.target_sample_rate}`; }; +// Simple sanitizer to keep FFmpeg metadata titles/languages safe and unquoted +const sanitizeMetadataValue = (value) => { + if (!value) return ''; + return String(value) + .replace(/["']/g, '') // strip both single and double quotes + .replace(/\s+/g, ' ') // collapse whitespace + .trim(); +}; + // Returns metadata preservation arguments const getMetadataArgs = (audioIdx, stream, inputs, customTitle = null) => { - if (customTitle) { - return ` -metadata:s:a:${audioIdx} title="${customTitle}"`; - } - if (inputs.preserve_metadata !== 'true') { - return ''; - } const args = []; - if (stream.tags?.title) { - args.push(`-metadata:s:a:${audioIdx} title="${stream.tags.title}"`); + const channelCount = stream.channels || 0; + + // Title handling + let rawTitle; + if (customTitle) { + rawTitle = customTitle; + } else if (inputs.preserve_metadata === 'true' && stream.tags?.title) { + rawTitle = `${stream.tags.title} (${channelCount}ch)`; + } else { + rawTitle = `${channelCount}ch`; } - if (stream.tags?.language) { - args.push(`-metadata:s:a:${audioIdx} language="${stream.tags.language}"`); + + const safeTitle = sanitizeMetadataValue(rawTitle); + if (safeTitle) { + // Note: Wrapping the value in double quotes is necessary for titles with spaces + args.push(`-metadata:s:a:${audioIdx} title="${safeTitle}"`); } + + // Language handling + if (inputs.preserve_metadata === 'true' && stream.tags?.language) { + const safeLang = sanitizeMetadataValue(stream.tags.language).toLowerCase() || 'und'; + args.push(`-metadata:s:a:${audioIdx} language="${safeLang}"`); + } + return args.length > 0 ? ' ' + args.join(' ') : ''; }; -const buildChannelArgs = (audioIdx, inputs) => { +const buildChannelArgs = (audioIdx, inputs, streamChannels = null) => { switch (inputs.channel_mode) { case CHANNEL_MODES.STEREO: + // Legacy mode: downmix original track to stereo (not recommended) + // Recommended: use 'preserve' + create_downmix=true instead return ` -ac:a:${audioIdx} 2`; case CHANNEL_MODES.MONO: + // Legacy mode: downmix original track to mono (not recommended) return ` -ac:a:${audioIdx} 1`; + case CHANNEL_MODES.PRESERVE: default: + // ALWAYS preserve original channel count to prevent FFmpeg from defaulting to stereo + // This is the recommended mode - original channels are preserved, downmix tracks are added separately + if (streamChannels !== null && streamChannels > 0) { + return ` -ac:a:${audioIdx} ${streamChannels}`; + } return ''; } }; @@ -446,26 +563,36 @@ const buildDownmixArgs = (audioIdx, streamIndex, stream, inputs, channels) => { // Calculate downmix bitrate const downmixBitrate = calculateBitrate(inputs, channels, null); - if (inputs.codec === CODECS.OPUS) { - // Note: global Opus options (-vbr, -application, -compression_level) are added - // once at the end of the command via getOpusGlobalArgs() + // Determine codec for downmix: use AAC if source is AAC (and we're keeping it), otherwise use Opus + // This ensures downmix matches the codec of the preserved/transcoded track + const useAacForDownmix = stream.codec_name === CODECS.AAC && + inputs.skip_if_compatible === 'true' && + inputs.codec === CODECS.OPUS; + + if (useAacForDownmix) { + // Use AAC for downmix when source is AAC + const aacProfile = inputs.aac_profile === 'aac_low' ? 'aac' : inputs.aac_profile; return baseArgs + [ - 'libopus', + aacProfile, `-b:a:${audioIdx} ${downmixBitrate}k`, + '-strict -2', `-ac ${channels}`, getSampleRateArgs(audioIdx, inputs), - getMetadataArgs(audioIdx, stream, inputs, `${channels}.0 Downmix`) + getMetadataArgs(audioIdx, stream, inputs, `${channels}.0 Downmix (${sanitizeMetadataValue(stream.tags?.title || 'Unknown Source')})`) ].filter(Boolean).join(' '); } - const aacProfile = inputs.aac_profile === 'aac_low' ? 'aac' : inputs.aac_profile; + // Default to Opus for downmix + // For Opus downmix, apply mapping family 1 if channels > 2 (e.g., 5.1 downmix) + const mappingArgs = (channels > 2 && channels <= 8) ? `-mapping_family:a:${audioIdx} 1` : ''; + return baseArgs + [ - aacProfile, + 'libopus', `-b:a:${audioIdx} ${downmixBitrate}k`, - '-strict -2', + mappingArgs, `-ac ${channels}`, getSampleRateArgs(audioIdx, inputs), - getMetadataArgs(audioIdx, stream, inputs, `${channels}.0 Downmix`) + getMetadataArgs(audioIdx, stream, inputs, `${channels}.0 Downmix (${sanitizeMetadataValue(stream.tags?.title || 'Unknown Source')})`) ].filter(Boolean).join(' '); }; @@ -496,11 +623,17 @@ const logStreamInfo = (stream, index) => { return info; }; +// Inline utilities (Tdarr plugins must be self-contained) +const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v); +const sanitizeInputsLocal = (inputs) => { + Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); }); + return inputs; +}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const plugin = (file, librarySettings, inputs, otherArguments) => { const lib = require('../methods/lib')(); - // Initialize response first for error handling const response = { processFile: false, preset: '', @@ -512,21 +645,7 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { }; try { - // eslint-disable-next-line @typescript-eslint/no-unused-vars,no-param-reassign - inputs = lib.loadDefaultValues(inputs, details); - - // 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; - }; - - // Sanitize UI-starred defaults - Object.keys(inputs).forEach(key => { - inputs[key] = stripStar(inputs[key]); - }); + inputs = sanitizeInputsLocal(lib.loadDefaultValues(inputs, details)); const validateInputs = (inputs) => { const errors = []; @@ -541,17 +660,18 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { 'create_6ch_downmix', 'downmix_single_track', 'force_transcode', - 'preserve_metadata' + 'preserve_metadata', + 'set_default_by_channels' ]; for (const input of booleanInputs) { - if (!VALID_BOOLEAN_VALUES.includes(inputs[input])) { + if (!VALID_BOOLEAN_VALUES.has(inputs[input])) { errors.push(`Invalid ${input} value - must be "true" or "false"`); } } - if (!VALID_BITRATES.includes(inputs.bitrate_per_channel)) { - errors.push(`Invalid bitrate_per_channel - must be one of: ${VALID_BITRATES.join(', ')}`); + if (!VALID_BITRATES.has(inputs.bitrate_per_channel)) { + errors.push(`Invalid bitrate_per_channel - must be one of: ${Array.from(VALID_BITRATES).join(', ')}`); } if (!Object.values(CHANNEL_MODES).includes(inputs.channel_mode)) { @@ -559,31 +679,31 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { } if (inputs.codec === CODECS.OPUS) { - if (!VALID_OPUS_APPLICATIONS.includes(inputs.opus_application)) { - errors.push(`Invalid opus_application - must be one of: ${VALID_OPUS_APPLICATIONS.join(', ')}`); + if (!VALID_OPUS_APPLICATIONS.has(inputs.opus_application)) { + errors.push(`Invalid opus_application - must be one of: ${Array.from(VALID_OPUS_APPLICATIONS).join(', ')}`); } - if (!VALID_OPUS_VBR_MODES.includes(inputs.opus_vbr)) { - errors.push(`Invalid opus_vbr - must be one of: ${VALID_OPUS_VBR_MODES.join(', ')}`); + if (!VALID_OPUS_VBR_MODES.has(inputs.opus_vbr)) { + errors.push(`Invalid opus_vbr - must be one of: ${Array.from(VALID_OPUS_VBR_MODES).join(', ')}`); } - if (!VALID_OPUS_COMPRESSION.includes(inputs.opus_compression)) { - errors.push(`Invalid opus_compression - must be one of: ${VALID_OPUS_COMPRESSION.join(', ')}`); + if (!VALID_OPUS_COMPRESSION.has(inputs.opus_compression)) { + errors.push(`Invalid opus_compression - must be one of: ${Array.from(VALID_OPUS_COMPRESSION).join(', ')}`); } } if (inputs.codec === CODECS.AAC) { - if (!VALID_AAC_PROFILES.includes(inputs.aac_profile)) { - errors.push(`Invalid aac_profile - must be one of: ${VALID_AAC_PROFILES.join(', ')}`); + if (!VALID_AAC_PROFILES.has(inputs.aac_profile)) { + errors.push(`Invalid aac_profile - must be one of: ${Array.from(VALID_AAC_PROFILES).join(', ')}`); } } - if (!VALID_SAMPLE_RATES.includes(inputs.target_sample_rate)) { - errors.push(`Invalid target_sample_rate - must be one of: ${VALID_SAMPLE_RATES.join(', ')}`); + if (!VALID_SAMPLE_RATES.has(inputs.target_sample_rate)) { + errors.push(`Invalid target_sample_rate - must be one of: ${Array.from(VALID_SAMPLE_RATES).join(', ')}`); } - if (!VALID_QUALITY_PRESETS.includes(inputs.quality_preset)) { - errors.push(`Invalid quality_preset - must be one of: ${VALID_QUALITY_PRESETS.join(', ')}`); + if (!VALID_QUALITY_PRESETS.has(inputs.quality_preset)) { + errors.push(`Invalid quality_preset - must be one of: ${Array.from(VALID_QUALITY_PRESETS).join(', ')}`); } return errors; @@ -627,13 +747,30 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { const targetCodec = inputs.codec === CODECS.OPUS ? [CODECS.OPUS, CODECS.LIBOPUS] : [CODECS.AAC]; + // Helper to resolve channel counts using MediaInfo when ffprobe fails + const getChannelCount = (stream, file) => { + let channels = parseInt(stream.channels || 0); + if (channels > 0) return channels; + + // Try MediaInfo fallback + const miStreams = file?.mediaInfo?.track; + if (Array.isArray(miStreams)) { + const miStream = miStreams.find((t) => t['@type'] === 'Audio' && t.StreamOrder == stream.index); + const miChannels = parseInt(miStream?.Channels || 0); + if (miChannels > 0) return miChannels; + } + + return channels; + }; + try { for (let i = 0; i < file.ffProbeData.streams.length; i++) { const stream = file.ffProbeData.streams[i]; if (stream.codec_type === 'audio') { - audioStreams.push({ index: i, ...stream }); + const channels = getChannelCount(stream, file); + audioStreams.push({ index: i, ...stream, channels }); - const warnings = validateStream(stream, i); + const warnings = validateStream({ ...stream, channels }, i); streamWarnings.push(...warnings); if (needsTranscoding(stream, inputs, targetCodec)) { @@ -665,7 +802,16 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { }); } - if (!needsTranscode && inputs.create_downmix !== 'true') { + // EARLY EXIT OPTIMIZATION: + // If no transcode needed AND (downmix disabled OR (downmix enabled but no multichannel source)) + // We can exit before doing the heavy stream processing loop + const hasMultichannel = audioStreams.some(s => s.channels > 2); + if (!needsTranscode && inputs.create_downmix === 'true' && !hasMultichannel && inputs.create_6ch_downmix !== 'true') { + response.infoLog += '✅ File already meets all requirements (No transcoding needed, no multichannel audio for downmix).\n'; + return response; + } + + if (!needsTranscode && inputs.create_downmix !== 'true' && inputs.create_6ch_downmix !== 'true') { response.infoLog += '✅ File already meets all requirements.\n'; return response; } @@ -674,26 +820,118 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { const hasAttachments = file.ffProbeData.streams.some(s => s.codec_type === 'attachment'); // Build stream mapping explicitly by type to prevent attachment processing errors - // Using -map 0 would map ALL streams including attachments, which causes muxing errors - // when combined with additional -map commands for downmix tracks - let streamMap = '-map 0:v -map 0:a'; + // Map video first, then we'll map audio streams individually as we process them + // This prevents conflicts when adding downmix tracks + let streamMap = '-map 0:v'; // Check if file has subtitle streams before mapping them const hasSubtitles = file.ffProbeData.streams.some(s => s.codec_type === 'subtitle'); + const container = (file.container || '').toLowerCase(); + + // Analyze subtitles for container compatibility + // Helper for robust subtitle identification (synced with Plugin 04) + const getSubtitleCodec = (stream, file) => { + let codecName = (stream.codec_name || '').toLowerCase(); + if (codecName && codecName !== 'none' && codecName !== 'unknown') return codecName; + + // FFmpeg reports 'none' for some codecs it can't identify (like WEBVTT in MKV) + const codecTag = (stream.codec_tag_string || '').toUpperCase(); + if (codecTag.includes('WEBVTT')) return 'webvtt'; + if (codecTag.includes('ASS')) return 'ass'; + if (codecTag.includes('SSA')) return 'ssa'; + + const tagCodecId = (stream.tags?.CodecID || stream.tags?.codec_id || '').toUpperCase(); + if (tagCodecId.includes('WEBVTT') || tagCodecId.includes('S_TEXT/WEBVTT')) return 'webvtt'; + if (tagCodecId.includes('ASS') || tagCodecId.includes('S_TEXT/ASS')) return 'ass'; + if (tagCodecId.includes('SSA') || tagCodecId.includes('S_TEXT/SSA')) return 'ssa'; + + const miStreams = file?.mediaInfo?.track; + if (Array.isArray(miStreams)) { + const miStream = miStreams.find((t) => t['@type'] === 'Text' && t.StreamOrder == stream.index); + const miCodec = (miStream?.CodecID || '').toLowerCase(); + if (miCodec.includes('webvtt')) return 'webvtt'; + if (miCodec.includes('ass')) return 'ass'; + if (miCodec.includes('ssa')) return 'ssa'; + } + + // Try ExifTool (meta) fallback + const meta = file?.meta; + if (meta) { + const trackName = (stream.tags?.title || '').toLowerCase(); + if (trackName.includes('webvtt')) return 'webvtt'; + } + + return codecName || 'unknown'; + }; + + let subtitleHandling = ''; + let subtitleLog = ''; if (hasSubtitles) { - streamMap += ' -map 0:s'; + const subtitleStreams = file.ffProbeData.streams.filter(s => s.codec_type === 'subtitle'); + const imageSubs = subtitleStreams.filter(s => IMAGE_SUBS.has(getSubtitleCodec(s, file))); + const textSubs = subtitleStreams.filter(s => MP4_CONVERTIBLE_SUBS.has(getSubtitleCodec(s, file))); + + if (container === 'mp4' || container === 'm4v') { + // MP4: drop image subs, convert text subs to mov_text + if (imageSubs.length > 0) { + // Don't map subtitles that are image-based for MP4 + const compatibleSubs = subtitleStreams.filter(s => !IMAGE_SUBS.has(getSubtitleCodec(s, file))); + if (compatibleSubs.length > 0) { + // Map only compatible subs individually + compatibleSubs.forEach((s, i) => { + streamMap += ` -map 0:${file.ffProbeData.streams.indexOf(s)}`; + }); + subtitleHandling = ' -c:s mov_text'; + subtitleLog = `Dropping ${imageSubs.length} image sub(s), converting ${compatibleSubs.length} to mov_text. `; + } else { + subtitleLog = `Dropping ${imageSubs.length} image subtitle(s) (incompatible with MP4). `; + } + } else if (textSubs.length > 0) { + streamMap += ' -map 0:s'; + subtitleHandling = ' -c:s mov_text'; + subtitleLog = `Converting ${textSubs.length} subtitle(s) to mov_text. `; + } else { + streamMap += ' -map 0:s'; + subtitleHandling = ' -c:s copy'; + } + } else { + // MKV: convert mov_text to srt, keep others + streamMap += ' -map 0:s'; + const movTextSubs = subtitleStreams.filter(s => getSubtitleCodec(s, file) === 'mov_text'); + if (movTextSubs.length > 0) { + subtitleHandling = ' -c:s srt'; + subtitleLog = `Converting ${movTextSubs.length} mov_text subtitle(s) to SRT. `; + } else { + subtitleHandling = ' -c:s copy'; + } + } } - if (hasAttachments) { - // Add attachments separately with copy codec - streamMap += ' -map 0:t -c:t copy'; - } - - let ffmpegArgs = `${streamMap} -c:v copy`; - if (hasSubtitles) { - ffmpegArgs += ' -c:s copy'; + if (subtitleLog) { + response.infoLog += `📁 ${subtitleLog}\n`; } let audioIdx = 0; + + if (hasAttachments) { + // Map attachments individually to avoid FFmpeg 7.x muxing errors + // FFmpeg 7.x has stricter handling of attachment streams - broad mapping with -map 0:t + // can cause \"Received a packet for an attachment stream\" errors when combined with + // additional audio mapping for downmix tracks + const attachmentStreams = file.ffProbeData.streams + .map((s, i) => ({ stream: s, index: i })) + .filter(({ stream }) => stream.codec_type === 'attachment'); + + attachmentStreams.forEach(({ index }) => { + streamMap += ` -map 0:${index}`; + }); + + // Attachments always use copy codec + streamMap += ' -c:t copy'; + } + + // Build audio stream mapping as we process each stream + let audioMapArgs = ''; + let ffmpegArgs = `${streamMap} -c:v copy${subtitleHandling}`; let processNeeded = false; let is2channelAdded = false; let transcodedStreams = 0; @@ -704,45 +942,75 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { for (const stream of audioStreams) { let streamNeedsTranscode = needsTranscoding(stream, inputs, targetCodec); - let forcePerStreamDownmix = false; - if (inputs.codec === CODECS.OPUS && isOpusIncompatibleLayout(stream.channel_layout)) { - if (!streamNeedsTranscode) { - streamNeedsTranscode = true; - } - if (inputs.channel_mode === CHANNEL_MODES.PRESERVE) { - forcePerStreamDownmix = true; + // Track if we need to use AAC fallback for Opus-incompatible layouts + let useAacFallback = false; + let opusMapping = null; + + if (inputs.codec === CODECS.OPUS) { + opusMapping = getOpusMappingInfo(stream); + if (opusMapping.incompatible) { + // Fallback to AAC if Opus cannot handle this layout/channel count + useAacFallback = true; + if (!streamNeedsTranscode) { + streamNeedsTranscode = true; + } } } + // Map this audio stream individually + audioMapArgs += ` -map 0:${stream.index}`; + if (streamNeedsTranscode) { const targetBitrate = calculateBitrate(inputs, stream.channels, stream.bit_rate); - const codecArgs = buildCodecArgs(audioIdx, inputs, targetBitrate); - let channelArgs = buildChannelArgs(audioIdx, inputs); + let codecArgs; + if (useAacFallback) { + // Use AAC for incompatible layouts to preserve channel count + const aacProfile = inputs.aac_profile === 'aac_low' ? 'aac' : inputs.aac_profile; + codecArgs = [ + `-c:a:${audioIdx} ${aacProfile}`, + targetBitrate ? `-b:a:${audioIdx} ${targetBitrate}k` : '', + '-strict -2' + ].filter(Boolean).join(' '); + } else { + codecArgs = buildCodecArgs(audioIdx, inputs, targetBitrate, stream.channels); + // Add pan filter if needed for Opus reordering + if (opusMapping && opusMapping.filter) { + codecArgs += ` -af:a:${audioIdx} "${opusMapping.filter}"`; + } + } + + const channelArgs = buildChannelArgs(audioIdx, inputs, stream.channels); const sampleRateArgs = getSampleRateArgs(audioIdx, inputs); const metadataArgs = getMetadataArgs(audioIdx, stream, inputs); - if (forcePerStreamDownmix) { - channelArgs = ` -ac:a:${audioIdx} 2`; - } - ffmpegArgs += ` ${codecArgs}${channelArgs}${sampleRateArgs}${metadataArgs}`; processNeeded = true; transcodedStreams++; - response.infoLog += `✅ Converting ${stream.codec_name} (${stream.channels}ch, ${stream.bit_rate ? Math.round(stream.bit_rate / 1000) + 'kbps' : 'unknown'}) to ${inputs.codec} at stream ${audioIdx}.\n`; - if (inputs.codec === CODECS.OPUS) { - if (forcePerStreamDownmix) { - response.infoLog += ` Detected incompatible layout "${stream.channel_layout}" → per-stream stereo downmix applied.\n`; - } else if (stream.channel_layout) { - response.infoLog += ` Layout "${stream.channel_layout}" deemed Opus-compatible.\n`; + if (useAacFallback) { + const reason = (opusMapping && opusMapping.reason) ? ` (${opusMapping.reason})` : ''; + response.infoLog += `✅ Converting ${stream.codec_name} (${stream.channels}ch) to AAC (Opus-incompatible layout${reason}).\n`; + } else { + response.infoLog += `✅ Converting ${stream.codec_name} (${stream.channels}ch, ${stream.bit_rate ? Math.round(stream.bit_rate / 1000) + 'kbps' : 'unknown'}) to ${inputs.codec} at stream ${audioIdx}.\n`; + if (inputs.codec === CODECS.OPUS) { + if (opusMapping && opusMapping.filter) { + response.infoLog += ` Applied Opus channel mapping fix (reordering filter) for layout "${stream.channel_layout}".\n`; + } else if (stream.channels > 2) { + response.infoLog += ` Applied Opus mapping family 1 for ${stream.channels}ch audio.\n`; + } } } if (targetBitrate) { - response.infoLog += ` Target bitrate: ${targetBitrate}kbps${inputs.bitrate_per_channel === 'original' ? ' (from source)' : ` (${inputs.bitrate_per_channel}kbps per channel)`}\n`; + const bitrateSource = inputs.bitrate_per_channel === 'original' ? ' (from source)' : + inputs.bitrate_per_channel === 'auto' ? ' (auto: 64kbps/ch)' : + ` (${inputs.bitrate_per_channel}kbps/ch)`; + response.infoLog += ` Target bitrate: ${targetBitrate}kbps${bitrateSource}\n`; } } else { - ffmpegArgs += ` -c:a:${audioIdx} copy`; + // Even when copying, we should add metadata to indicate channel count + const metadataArgs = getMetadataArgs(audioIdx, stream, inputs); + ffmpegArgs += ` -c:a:${audioIdx} copy${metadataArgs}`; copiedStreams++; if (inputs.skip_if_compatible === 'true' && COMPATIBLE_CODECS.includes(stream.codec_name)) { response.infoLog += `✅ Keeping ${stream.codec_name} (${stream.channels}ch) - compatible format.\n`; @@ -758,31 +1026,73 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { } if (inputs.create_downmix === 'true') { - const existing2chTracks = audioStreams.filter(s => s.channels === 2); + // Helper to check if a track is commentary + const isCommentary = (stream) => { + const title = (stream.tags?.title || '').toLowerCase(); + return title.includes('commentary') || title.includes('comment'); + }; - if (existing2chTracks.length > 0) { - response.infoLog += `ℹ️ Skipping 2ch downmix - ${existing2chTracks.length} stereo track(s) already exist.\n`; - } else { - try { - for (const stream of audioStreams) { - if ((stream.channels === 6 || stream.channels === 8) && - (inputs.downmix_single_track === 'false' || !is2channelAdded)) { + // Helper to get normalized language + const getLang = (stream) => (stream.tags?.language || 'und').toLowerCase(); - const downmixArgs = buildDownmixArgs(audioIdx, stream.index, stream, inputs, 2); - ffmpegArgs += downmixArgs; - - response.infoLog += `✅ Creating 2ch downmix from ${stream.channels}ch audio.\n`; - processNeeded = true; - is2channelAdded = true; - downmixStreams++; - audioIdx++; - } - } - } catch (error) { - response.infoLog += `❌ Error creating downmix tracks: ${error.message}\n`; - response.processFile = false; - return response; + // Track which languages already have a stereo track (non-commentary) + // This includes both existing stereo tracks AND any we're creating in this run + const langsWithStereo = new Set(); + audioStreams.forEach(s => { + if (s.channels === 2 && !isCommentary(s)) { + langsWithStereo.add(getLang(s)); } + }); + + // Track which languages we've created downmixes for in this run + // This prevents creating multiple stereo tracks for the same language + const langsDownmixCreated = new Set(); + + try { + for (const stream of audioStreams) { + const lang = getLang(stream); + + // Only create downmix from multichannel sources (6ch=5.1 or 8ch=7.1) + // Skip if not multichannel - we only downmix from surround sources + if (stream.channels !== 6 && stream.channels !== 8) continue; + + // Skip commentary tracks - they usually don't need stereo versions + if (isCommentary(stream)) continue; + + // Skip if this language already has a stereo track (existing or created in this run) + if (langsWithStereo.has(lang)) { + response.infoLog += `ℹ️ Skipping ${lang} 2ch downmix - stereo track already exists for this language.\n`; + continue; + } + + // Skip if we already created a downmix for this language (prevents duplicates) + if (langsDownmixCreated.has(lang)) { + response.infoLog += `ℹ️ Skipping ${lang} 2ch downmix - already created one for this language.\n`; + continue; + } + + // Create the ADDITIONAL downmix track (original channels are preserved above) + const downmixArgs = buildDownmixArgs(audioIdx, stream.index, stream, inputs, 2); + ffmpegArgs += downmixArgs; + + response.infoLog += `✅ Creating ADDITIONAL 2ch downmix from ${stream.channels}ch ${lang} audio (original ${stream.channels}ch track preserved).\n`; + processNeeded = true; + is2channelAdded = true; + downmixStreams++; + audioIdx++; + + // Track that we created a downmix for this language (prevents duplicates) + langsDownmixCreated.add(lang); + // Also mark this language as having stereo now (prevents future duplicates in same run) + langsWithStereo.add(lang); + + // If single track mode, only create one total downmix across all languages + if (inputs.downmix_single_track === 'true') break; + } + } catch (error) { + response.infoLog += `❌ Error creating downmix tracks: ${error.message}\n`; + response.processFile = false; + return response; } } @@ -821,9 +1131,113 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { if (processNeeded) { try { response.processFile = true; + // Insert audio map arguments right after streamMap (before codec arguments) + // FFmpeg requires all -map commands before codec arguments + const audioMapInsertionPoint = streamMap.length; + ffmpegArgs = ffmpegArgs.slice(0, audioMapInsertionPoint) + audioMapArgs + ffmpegArgs.slice(audioMapInsertionPoint); // Add global Opus encoder options once at the end if using Opus const opusGlobalArgs = getOpusGlobalArgs(inputs); - response.preset = `${ffmpegArgs}${opusGlobalArgs} -max_muxing_queue_size 9999`; + + // Build disposition flags for setting default audio by channel count + let dispositionArgs = ''; + if (inputs.set_default_by_channels === 'true') { + // Track final channel counts for all audio streams in output order + // audioIdx at this point represents the total number of audio tracks in output + const finalAudioTracks = []; + let trackIdx = 0; + + // Original audio streams (in processing order) + // IMPORTANT: When channel_mode is 'preserve' (recommended), original channels are ALWAYS preserved + // Downmix tracks are created as ADDITIONAL tracks, not replacements + for (const stream of audioStreams) { + let finalChannels = stream.channels; + // Account for legacy channel mode changes (only if not 'preserve') + // Note: 'preserve' mode is recommended - it keeps originals and adds downmix separately + if (inputs.channel_mode === CHANNEL_MODES.STEREO && stream.channels > 2) { + finalChannels = 2; // Legacy mode: downmix original + } else if (inputs.channel_mode === CHANNEL_MODES.MONO && stream.channels > 1) { + finalChannels = 1; // Legacy mode: downmix original + } + // When 'preserve' mode: original channels are kept (AAC fallback for Opus-incompatible layouts + // also preserves channel count) + finalAudioTracks.push({ idx: trackIdx, channels: finalChannels }); + trackIdx++; + } + + // Downmix tracks (2ch) + // Downmix tracks (2ch) - Simulate exactly what we did above + // These are ADDITIONAL tracks created alongside originals, not replacements + if (inputs.create_downmix === 'true') { + const isCommentary = (stream) => { + const title = (stream.tags?.title || '').toLowerCase(); + return title.includes('commentary') || title.includes('comment'); + }; + const getLang = (stream) => (stream.tags?.language || 'und').toLowerCase(); + + const langsWithStereo = new Set(); + audioStreams.forEach(s => { + if (s.channels === 2 && !isCommentary(s)) { + langsWithStereo.add(getLang(s)); + } + }); + + const langsDownmixCreated = new Set(); + + for (const stream of audioStreams) { + const lang = getLang(stream); + + // Logic must match downmix creation exactly + if (stream.channels !== 6 && stream.channels !== 8) continue; + if (isCommentary(stream)) continue; + if (langsWithStereo.has(lang)) continue; + if (langsDownmixCreated.has(lang)) continue; + + // We create a 2ch downmix track here (original multichannel track is preserved above) + finalAudioTracks.push({ idx: trackIdx, channels: 2 }); + trackIdx++; + + langsDownmixCreated.add(lang); + // Mark language as having stereo to prevent duplicates + langsWithStereo.add(lang); + if (inputs.downmix_single_track === 'true') break; + } + } + + // 6ch downmix tracks + if (inputs.create_6ch_downmix === 'true') { + const existing6chTracks = audioStreams.filter(s => s.channels === 6); + const available8chTracks = audioStreams.filter(s => s.channels === 8); + if (existing6chTracks.length === 0 && available8chTracks.length > 0) { + for (const stream of audioStreams) { + if (stream.channels === 8) { + finalAudioTracks.push({ idx: trackIdx, channels: 6 }); + trackIdx++; + if (inputs.downmix_single_track === 'true') break; + } + } + } + } + + // Find track with highest channel count + if (finalAudioTracks.length > 0) { + const maxChannels = Math.max(...finalAudioTracks.map(t => t.channels)); + const defaultTrackIdx = finalAudioTracks.find(t => t.channels === maxChannels).idx; + + // Set disposition flags: default on highest channel track, remove default from others + for (let i = 0; i < finalAudioTracks.length; i++) { + if (i === defaultTrackIdx) { + dispositionArgs += ` -disposition:a:${i} default`; + } else { + // Clear default flag from other audio tracks + dispositionArgs += ` -disposition:a:${i} 0`; + } + } + + response.infoLog += `🎯 Set default audio: track ${defaultTrackIdx} (${maxChannels}ch - highest channel count after all processing).\n`; + } + } + + response.preset = `${ffmpegArgs}${opusGlobalArgs}${dispositionArgs} -max_muxing_queue_size 9999`; response.ffmpegMode = true; response.reQueueAfter = true; @@ -865,24 +1279,10 @@ const plugin = (file, librarySettings, inputs, otherArguments) => { 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`; - + response.infoLog = `❌ Plugin error: ${error.message}\n`; return response; } }; diff --git a/Local/Tdarr_Plugin_misc_fixes.js b/Local/Tdarr_Plugin_misc_fixes.js deleted file mode 100644 index a61e61a..0000000 --- a/Local/Tdarr_Plugin_misc_fixes.js +++ /dev/null @@ -1,350 +0,0 @@ -/* 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: - - 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 = ` ${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; diff --git a/Local/Tdarr_Plugin_stream_organizer.js b/Local/Tdarr_Plugin_stream_organizer.js deleted file mode 100644 index f228b56..0000000 --- a/Local/Tdarr_Plugin_stream_organizer.js +++ /dev/null @@ -1,904 +0,0 @@ -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.10: Fixed infinite loop - extracts subtitles to temp dir during plugin stack. - v4.9: Refactored for better maintainability - extracted helper functions. - `, - Version: '4.10', - 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', - }, - { - 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) => { - return needsReorder || needsConversion || extractCount > 0 || 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 ' : ''; - 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 - )) { - response.infoLog += '✅ No changes needed.'; - return response; - } - - response.processFile = true; - response.reQueueAfter = true; - - 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;