191 lines
7.1 KiB
JavaScript
191 lines
7.1 KiB
JavaScript
/* 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 = `<io> -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 = `<io> -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;
|