Files
tdarr-plugs/Local/Tdarr_Plugin_06_cc_extraction.js

191 lines
7.1 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.
/* 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;