Files
tdarr-plugs/agent_notes/archive/Tdarr_Plugin_stream_organizer.js
2026-01-30 05:55:22 -08:00

937 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <io>' : '<io>';
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;