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.11: Optimized requeue - only requeues when container is modified, not for extraction-only. v4.10: Fixed infinite loop - extracts subtitles to temp dir during plugin stack. v4.9: Refactored for better maintainability - extracted helper functions. `, Version: '4.11', 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, removeAfterExtract) => { return needsReorder || needsConversion || extractCount > 0 || ccActuallyExtracted || (ccExtractedFile && embedExtractedCC === 'true') || (extractCount > 0 && removeAfterExtract === 'true'); }; /** * Checks if the container itself needs to be modified (requires requeue) * Extraction-only operations don't modify the container and don't need requeue */ const needsContainerModification = (needsReorder, needsConversion, extractCount, removeAfterExtract, ccActuallyExtracted, ccExtractedFile, embedExtractedCC) => { // Container is modified when: // - Streams need reordering // - Subtitles need conversion (ASS/SSA/WebVTT -> SRT) // - Embedded subs are being removed after extraction // - CC is being extracted AND embedded back // - Existing CC file is being embedded return needsReorder || needsConversion || (extractCount > 0 && removeAfterExtract === 'true') || 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, inputs.removeAfterExtract )) { response.infoLog += '✅ No changes needed.'; return response; } response.processFile = true; // Only requeue if container is being modified // Extraction-only (without removal) doesn't modify the container const containerModified = needsContainerModification( reorderResult.needsReorder, conversionAnalysis.needsConversion, extractionResult.extractCount, inputs.removeAfterExtract, ccResult.ccActuallyExtracted, ccResult.ccExtractedFile, inputs.embedExtractedCC ); response.reQueueAfter = containerModified; 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;