238 lines
8.9 KiB
JavaScript
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;
|