feat: Sync all plugin versions to 4.0.0 and reorganize Local directory
This commit is contained in:
237
Local/Tdarr_Plugin_04_subtitle_conversion.js
Normal file
237
Local/Tdarr_Plugin_04_subtitle_conversion.js
Normal file
@@ -0,0 +1,237 @@
|
||||
/* 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;
|
||||
Reference in New Issue
Block a user