937 lines
29 KiB
JavaScript
937 lines
29 KiB
JavaScript
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;
|