Files
tdarr-plugs/Local/Tdarr_Plugin_04_subtitle_conversion.js

238 lines
8.9 KiB
JavaScript

/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_04_subtitle_conversion',
Stage: 'Pre-processing',
Name: '04 - Subtitle Conversion',
Type: 'Video',
Operation: 'Transcode',
Description: `
**Container-Aware** subtitle conversion for maximum compatibility.
- MKV target → Converts to SRT (universal text format)
- MP4 target → Converts to mov_text (native MP4 format)
Converts: ASS/SSA, WebVTT, mov_text, SRT (cross-converts as needed)
Image subtitles (PGS/VobSub) are copied as-is (cannot convert to text).
**Single Responsibility**: In-container subtitle codec conversion only.
Container is inherited from Plugin 01 (Container Remux).
Run AFTER stream ordering, BEFORE subtitle extraction.
`,
Version: '4.0.0',
Tags: 'action,ffmpeg,subtitles,srt,mov_text,convert,container-aware',
Inputs: [
{
name: 'enable_conversion',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Enable container-aware subtitle conversion (MKV→SRT, MP4→mov_text).',
},
{
name: 'always_convert_webvtt',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Always convert WebVTT regardless of other settings (problematic in most containers).',
},
],
});
// Constants - Set for O(1) lookup
const TEXT_SUBTITLES = new Set(['ass', 'ssa', 'webvtt', 'vtt', 'mov_text', 'text', 'subrip', 'srt']);
const IMAGE_SUBTITLES = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
const UNSUPPORTED_SUBTITLES = new Set(['eia_608', 'cc_dec', 'tx3g']);
const WEBVTT_CODECS = new Set(['webvtt', 'vtt']);
const BOOLEAN_INPUTS = ['enable_conversion', 'always_convert_webvtt'];
const CONTAINER_TARGET = {
mkv: 'srt',
mp4: 'mov_text',
m4v: 'mov_text',
mov: 'mov_text',
};
// Utilities
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
/**
* Get subtitle codec, handling edge cases (WebVTT in MKV may report codec_name as 'none').
*/
const getSubtitleCodec = (stream, file) => {
let codecName = (stream.codec_name || '').toLowerCase();
if (codecName && codecName !== 'none' && codecName !== 'unknown') return codecName;
// FFmpeg reports 'none' for some codecs it can't identify (like WEBVTT in MKV)
// Try metadata fallback using tags/codec_tag
const codecTag = (stream.codec_tag_string || '').toUpperCase();
if (codecTag.includes('WEBVTT')) return 'webvtt';
if (codecTag.includes('ASS')) return 'ass';
if (codecTag.includes('SSA')) return 'ssa';
const tagCodecId = (stream.tags?.CodecID || stream.tags?.codec_id || '').toUpperCase();
if (tagCodecId.includes('WEBVTT') || tagCodecId.includes('S_TEXT/WEBVTT')) return 'webvtt';
if (tagCodecId.includes('ASS') || tagCodecId.includes('S_TEXT/ASS')) return 'ass';
if (tagCodecId.includes('SSA') || tagCodecId.includes('S_TEXT/SSA')) return 'ssa';
// Try MediaInfo fallback
const miStreams = file?.mediaInfo?.track;
if (Array.isArray(miStreams)) {
const miStream = miStreams.find((t) => t['@type'] === 'Text' && t.StreamOrder == stream.index);
const miCodec = (miStream?.CodecID || '').toLowerCase();
if (miCodec.includes('webvtt')) return 'webvtt';
if (miCodec.includes('ass')) return 'ass';
if (miCodec.includes('ssa')) return 'ssa';
}
// Try ExifTool (meta) fallback
const meta = file?.meta;
if (meta) {
// ExifTool often provides codec information in the TrackLanguage or TrackName if everything else fails
const trackName = (stream.tags?.title || '').toLowerCase();
if (trackName.includes('webvtt')) return 'webvtt';
}
return codecName || 'unknown';
};
/**
* Normalize codec name for comparison.
*/
const normalizeCodec = (codec) => {
if (codec === 'srt' || codec === 'subrip') return 'srt';
if (codec === 'vtt' || codec === 'webvtt') return 'webvtt';
return codec;
};
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: true,
reQueueAfter: true,
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'; });
const streams = file.ffProbeData?.streams;
if (!Array.isArray(streams)) {
response.infoLog = '❌ No stream data available. ';
return response;
}
const container = (file.container || '').toLowerCase();
const targetCodec = CONTAINER_TARGET[container] || 'srt';
const targetDisplay = targetCodec === 'srt' ? 'SRT' : 'mov_text';
response.infoLog += `📦 ${container.toUpperCase()}${targetDisplay}. `;
const subtitleStreams = streams
.map((s, i) => ({ ...s, index: i }))
.filter((s) => s.codec_type === 'subtitle');
// Early exit optimization: no subtitles = nothing to do
if (subtitleStreams.length === 0) {
response.infoLog += '✅ No subtitle streams. ';
return response;
}
const toConvert = [];
const reasons = [];
subtitleStreams.forEach((stream) => {
const codec = getSubtitleCodec(stream, file);
const normalized = normalizeCodec(codec);
const streamDisplay = `Stream ${stream.index} (${codec.toUpperCase()})`;
// Skip unsupported formats
if (UNSUPPORTED_SUBTITLES.has(codec)) {
reasons.push(`${streamDisplay}: Unsupported format, skipping`);
return;
}
// Image-based formats: Copy as-is (cannot convert to text)
if (IMAGE_SUBTITLES.has(codec)) {
reasons.push(`${streamDisplay}: Image-based, copying as-is`);
return;
}
// Check if conversion to target is needed
if (!inputs.enable_conversion) {
// Still convert WebVTT if that option is enabled (special case for compatibility)
if (WEBVTT_CODECS.has(codec) && inputs.always_convert_webvtt) {
toConvert.push(stream);
reasons.push(`${streamDisplay}${targetDisplay} (special WebVTT rule)`);
} else {
reasons.push(`${streamDisplay}: Keeping original (conversion disabled)`);
}
return;
}
// WebVTT always converted if enabled
if (WEBVTT_CODECS.has(codec) && inputs.always_convert_webvtt) {
toConvert.push(stream);
reasons.push(`${streamDisplay}${targetDisplay}`);
return;
}
// Already in target format
if (normalized === normalizeCodec(targetCodec)) {
reasons.push(`${streamDisplay}: Already ${targetDisplay}, copying`);
return;
}
// Text subtitle that needs conversion
if (TEXT_SUBTITLES.has(codec)) {
toConvert.push(stream);
reasons.push(`${streamDisplay}${targetDisplay}`);
} else {
reasons.push(`${streamDisplay}: Unknown format, copying as-is`);
}
});
// Early exit optimization: all compatible = no conversion needed
if (toConvert.length === 0) {
response.infoLog += `✅ All ${subtitleStreams.length} subtitle(s) compatible. `;
return response;
}
// Build FFmpeg command
let command = '<io> -map 0 -c copy';
toConvert.forEach((s) => { command += ` -c:${s.index} ${targetCodec}`; });
command += ' -max_muxing_queue_size 9999';
response.preset = command;
response.processFile = true;
response.infoLog += `✅ Converting ${toConvert.length} subtitle(s):\n`;
reasons.forEach((r) => { response.infoLog += ` ${r}\n`; });
// Final Summary block
response.infoLog += '\n📋 Final Processing Summary:\n';
response.infoLog += ` Target format: ${targetDisplay}\n`;
response.infoLog += ` Total subtitles analyzed: ${subtitleStreams.length}\n`;
response.infoLog += ` Subtitles converted: ${toConvert.length}\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;