Files
tdarr-plugs/Local/Tdarr_Plugin_03_stream_ordering.js

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;