325 lines
14 KiB
JavaScript
325 lines
14 KiB
JavaScript
/* eslint-disable no-plusplus */
|
|
const details = () => ({
|
|
id: 'Tdarr_Plugin_03_stream_ordering',
|
|
Stage: 'Pre-processing',
|
|
Name: '03 - Stream Ordering',
|
|
Type: 'Video',
|
|
Operation: 'Transcode',
|
|
Description: `
|
|
Reorders streams by type and language priority.
|
|
- Ensures Video streams appear first
|
|
- Prioritizes specified language codes for Audio and Subtitles
|
|
- Optionally sets default disposition flags on first priority tracks
|
|
|
|
v1.6: Updated documentation - recommend using default_audio_mode='skip' when audio_standardizer
|
|
plugin is in the stack (audio_standardizer sets default by channel count after processing).
|
|
v1.5: Added default_audio_mode option - choose between language-based or channel-count-based
|
|
default audio selection. Improved stack compatibility with audio standardizer plugin.
|
|
|
|
**Single Responsibility**: Stream order only. No conversion or removal.
|
|
Run AFTER stream cleanup, BEFORE subtitle conversion.
|
|
`,
|
|
Version: '4.0.0',
|
|
Tags: 'action,ffmpeg,order,language,english',
|
|
Inputs: [
|
|
{
|
|
name: 'ensure_video_first',
|
|
type: 'string',
|
|
defaultValue: 'true*',
|
|
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
|
|
tooltip: 'Reorder streams so Video is first, then Audio, then Subtitles.',
|
|
},
|
|
{
|
|
name: 'reorder_audio',
|
|
type: 'string',
|
|
defaultValue: 'true*',
|
|
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
|
|
tooltip: 'Reorder audio streams to put priority language first.',
|
|
},
|
|
{
|
|
name: 'reorder_subtitles',
|
|
type: 'string',
|
|
defaultValue: 'true*',
|
|
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
|
|
tooltip: 'Reorder subtitle streams to put priority language first.',
|
|
},
|
|
{
|
|
name: 'priority_languages',
|
|
type: 'string',
|
|
defaultValue: 'eng,en,english,en-us,en-gb,en-ca,en-au',
|
|
inputUI: { type: 'text' },
|
|
tooltip: 'Comma-separated list of language codes to prioritize (max 20).',
|
|
},
|
|
{
|
|
name: 'set_default_flags',
|
|
type: 'string',
|
|
defaultValue: 'false',
|
|
inputUI: { type: 'dropdown', options: ['false', 'true'] },
|
|
tooltip: 'Enable setting default disposition flags. Use default_audio_mode to choose strategy.',
|
|
},
|
|
{
|
|
name: 'default_audio_mode',
|
|
type: 'string',
|
|
defaultValue: 'language*',
|
|
inputUI: { type: 'dropdown', options: ['language*', 'channels', 'skip'] },
|
|
tooltip: 'How to select default audio: language=first priority-language track, channels=track with most channels (BEFORE downmix creation), skip=don\'t set audio default (RECOMMENDED when audio_standardizer is in stack - it sets default by channel count AFTER all processing including downmixes).',
|
|
},
|
|
],
|
|
});
|
|
|
|
// Constants
|
|
const MAX_LANGUAGE_CODES = 20;
|
|
const BOOLEAN_INPUTS = ['ensure_video_first', 'reorder_audio', 'reorder_subtitles', 'set_default_flags'];
|
|
const VALID_DEFAULT_AUDIO_MODES = new Set(['language', 'channels', 'skip']);
|
|
const STREAM_TYPES = new Set(['video', 'audio', 'subtitle']);
|
|
|
|
// Container-aware subtitle compatibility
|
|
const MKV_INCOMPATIBLE_SUBS = new Set(['mov_text', 'tx3g']);
|
|
const MP4_INCOMPATIBLE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub', 'subrip', 'ass', 'ssa', 'webvtt']);
|
|
const MP4_CONVERTIBLE_SUBS = new Set(['subrip', 'ass', 'ssa', 'webvtt', 'text']);
|
|
const IMAGE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
|
|
|
|
// Utilities
|
|
// Note: stripStar is duplicated across all plugins due to Tdarr's self-contained architecture.
|
|
// Each plugin must be standalone without external dependencies.
|
|
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
|
|
|
|
const parseLanguages = (codesString) => {
|
|
if (typeof codesString !== 'string') return new Set();
|
|
const codes = codesString
|
|
.split(',')
|
|
.map((c) => c.trim().toLowerCase())
|
|
.filter((c) => c.length > 0 && c.length <= 10 && /^[a-z0-9-]+$/.test(c))
|
|
.slice(0, MAX_LANGUAGE_CODES);
|
|
return new Set(codes);
|
|
};
|
|
|
|
const isPriority = (stream, prioritySet) => {
|
|
const lang = stream.tags?.language?.toLowerCase();
|
|
return lang && prioritySet.has(lang);
|
|
};
|
|
|
|
const partition = (arr, predicate) => {
|
|
const matched = [];
|
|
const unmatched = [];
|
|
arr.forEach((item) => (predicate(item) ? matched : unmatched).push(item));
|
|
return [matched, unmatched];
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
// Parse priority languages into Set for O(1) lookup
|
|
let priorityLangs = parseLanguages(inputs.priority_languages);
|
|
if (priorityLangs.size === 0) {
|
|
priorityLangs = new Set(['eng', 'en', 'english', 'en-us', 'en-gb', 'en-ca', 'en-au']);
|
|
}
|
|
|
|
// Tag streams with original index
|
|
const taggedStreams = streams.map((s, i) => ({ ...s, originalIndex: i }));
|
|
|
|
const videoStreams = taggedStreams.filter((s) => s.codec_type === 'video');
|
|
let audioStreams = taggedStreams.filter((s) => s.codec_type === 'audio');
|
|
let subtitleStreams = taggedStreams.filter((s) => s.codec_type === 'subtitle');
|
|
const otherStreams = taggedStreams.filter((s) => !STREAM_TYPES.has(s.codec_type));
|
|
|
|
// Reorder by language priority
|
|
if (inputs.reorder_audio) {
|
|
const [priority, other] = partition(audioStreams, (s) => isPriority(s, priorityLangs));
|
|
audioStreams = [...priority, ...other];
|
|
if (priority.length) response.infoLog += `✅ ${priority.length} priority audio first. `;
|
|
}
|
|
|
|
if (inputs.reorder_subtitles) {
|
|
const [priority, other] = partition(subtitleStreams, (s) => isPriority(s, priorityLangs));
|
|
subtitleStreams = [...priority, ...other];
|
|
if (priority.length) response.infoLog += `✅ ${priority.length} priority subtitle(s) first. `;
|
|
}
|
|
|
|
// Build final order
|
|
let reorderedStreams;
|
|
if (inputs.ensure_video_first) {
|
|
reorderedStreams = [...videoStreams, ...audioStreams, ...subtitleStreams, ...otherStreams];
|
|
} else {
|
|
// Maintain relative order but apply language sorting
|
|
const audioQueue = [...audioStreams];
|
|
const subQueue = [...subtitleStreams];
|
|
reorderedStreams = taggedStreams.map((s) => {
|
|
if (s.codec_type === 'audio') return audioQueue.shift();
|
|
if (s.codec_type === 'subtitle') return subQueue.shift();
|
|
return s;
|
|
});
|
|
}
|
|
|
|
// Check if order changed
|
|
const originalOrder = taggedStreams.map((s) => s.originalIndex);
|
|
const newOrder = reorderedStreams.map((s) => s.originalIndex);
|
|
if (JSON.stringify(originalOrder) === JSON.stringify(newOrder)) {
|
|
response.infoLog += '✅ Stream order already correct. ';
|
|
return response;
|
|
}
|
|
|
|
// Build FFmpeg command with container-aware subtitle handling
|
|
const container = (file.container || '').toLowerCase();
|
|
let command = '<io>';
|
|
const subtitlesToDrop = [];
|
|
const subtitlesToConvert = [];
|
|
|
|
// Build stream mapping with container compatibility checks
|
|
reorderedStreams.forEach((s) => {
|
|
const codec = (s.codec_name || '').toLowerCase();
|
|
|
|
// Check subtitle compatibility with container
|
|
if (s.codec_type === 'subtitle') {
|
|
if (container === 'mp4' || container === 'm4v') {
|
|
if (IMAGE_SUBS.has(codec)) {
|
|
subtitlesToDrop.push(s.originalIndex);
|
|
return; // Don't map this stream
|
|
} else if (MP4_CONVERTIBLE_SUBS.has(codec)) {
|
|
subtitlesToConvert.push(s.originalIndex);
|
|
}
|
|
} else if (container === 'mkv' && MKV_INCOMPATIBLE_SUBS.has(codec)) {
|
|
subtitlesToConvert.push(s.originalIndex);
|
|
}
|
|
}
|
|
command += ` -map 0:${s.originalIndex}`;
|
|
});
|
|
|
|
// Log dropped/converted subtitles
|
|
if (subtitlesToDrop.length > 0) {
|
|
response.infoLog += `📁 Dropping ${subtitlesToDrop.length} image subtitle(s) (incompatible with MP4). `;
|
|
}
|
|
|
|
// Build codec arguments
|
|
command += ' -c:v copy -c:a copy';
|
|
|
|
// Handle subtitle codec conversion based on container
|
|
if (subtitlesToConvert.length > 0) {
|
|
if (container === 'mp4' || container === 'm4v') {
|
|
command += ' -c:s mov_text';
|
|
response.infoLog += `📁 Converting ${subtitlesToConvert.length} subtitle(s) to mov_text. `;
|
|
} else if (container === 'mkv') {
|
|
command += ' -c:s srt';
|
|
response.infoLog += `📁 Converting ${subtitlesToConvert.length} mov_text subtitle(s) to SRT. `;
|
|
} else {
|
|
command += ' -c:s copy';
|
|
}
|
|
} else {
|
|
command += ' -c:s copy';
|
|
}
|
|
|
|
// Set default disposition flags
|
|
if (inputs.set_default_flags) {
|
|
const audioStreamsOrdered = reorderedStreams.filter(s => s.codec_type === 'audio');
|
|
let subIdx = 0;
|
|
let firstPrioritySub = null;
|
|
|
|
// Handle subtitle default (always by language)
|
|
reorderedStreams.forEach((s) => {
|
|
if (s.codec_type === 'subtitle') {
|
|
if (firstPrioritySub === null && isPriority(s, priorityLangs)) firstPrioritySub = subIdx;
|
|
subIdx++;
|
|
}
|
|
});
|
|
|
|
// Handle audio default based on mode
|
|
let defaultAudioIdx = null;
|
|
const audioMode = inputs.default_audio_mode || 'language';
|
|
|
|
if (audioMode === 'language') {
|
|
// First priority-language track
|
|
for (let i = 0; i < audioStreamsOrdered.length; i++) {
|
|
if (isPriority(audioStreamsOrdered[i], priorityLangs)) {
|
|
defaultAudioIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
if (defaultAudioIdx !== null) {
|
|
command += ` -disposition:a:${defaultAudioIdx} default`;
|
|
response.infoLog += `✅ Default audio: track ${defaultAudioIdx} (language priority). `;
|
|
}
|
|
} else if (audioMode === 'channels') {
|
|
// Track with most channels
|
|
if (audioStreamsOrdered.length > 0) {
|
|
let maxChannels = 0;
|
|
audioStreamsOrdered.forEach((s, i) => {
|
|
const channels = s.channels || 0;
|
|
if (channels > maxChannels) {
|
|
maxChannels = channels;
|
|
defaultAudioIdx = i;
|
|
}
|
|
});
|
|
if (defaultAudioIdx !== null) {
|
|
command += ` -disposition:a:${defaultAudioIdx} default`;
|
|
response.infoLog += `✅ Default audio: track ${defaultAudioIdx} (${maxChannels}ch - highest). `;
|
|
}
|
|
}
|
|
}
|
|
// Mode 'skip' - don't set audio default, let other plugins handle it
|
|
|
|
// Clear default from other audio tracks when setting a default
|
|
if (defaultAudioIdx !== null && audioMode !== 'skip') {
|
|
for (let i = 0; i < audioStreamsOrdered.length; i++) {
|
|
if (i !== defaultAudioIdx) {
|
|
command += ` -disposition:a:${i} 0`;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (firstPrioritySub !== null) {
|
|
command += ` -disposition:s:${firstPrioritySub} default`;
|
|
response.infoLog += `✅ Default subtitle: track ${firstPrioritySub}. `;
|
|
}
|
|
}
|
|
|
|
command += ' -max_muxing_queue_size 9999';
|
|
|
|
response.preset = command;
|
|
response.processFile = true;
|
|
response.infoLog += '✅ Reordering streams. ';
|
|
|
|
// Final Summary block
|
|
response.infoLog += '\n\n📋 Final Processing Summary:\n';
|
|
response.infoLog += ` Action: Reordering streams\n`;
|
|
response.infoLog += ` Languages prioritized: ${inputs.priority_languages}\n`;
|
|
if (inputs.ensure_video_first) response.infoLog += ` - Ensuring video stream first\n`;
|
|
if (inputs.reorder_audio) response.infoLog += ` - Audio reordered by language\n`;
|
|
if (inputs.reorder_subtitles) response.infoLog += ` - Subtitles reordered by language\n`;
|
|
if (inputs.set_default_flags) response.infoLog += ` - Default flags updated\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;
|