/* 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 = ''; 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;