Files
tdarr-plugs/Local/Tdarr_Plugin_02_stream_cleanup.js

211 lines
8.5 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_02_stream_cleanup',
Stage: 'Pre-processing',
Name: '02 - Stream Cleanup',
Type: 'Video',
Operation: 'Transcode',
Description: `
Removes unwanted and incompatible streams from the container.
- Removes image streams (MJPEG/PNG/GIF cover art)
- Drops streams incompatible with current container (auto-detected)
- Removes corrupt/invalid audio streams (0 channels)
**Single Responsibility**: Stream removal only. No reordering.
Run AFTER container remux, BEFORE stream ordering.
Container is inherited from Plugin 01 (Container Remux).
`,
Version: '4.0.0',
Tags: 'action,ffmpeg,cleanup,streams,conform',
Inputs: [
{
name: 'remove_image_streams',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Remove MJPEG, PNG, GIF video streams and attached pictures (often cover art spam).',
},
{
name: 'force_conform',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Drop streams incompatible with target container (e.g., mov_text in MKV, PGS in MP4).',
},
{
name: 'remove_corrupt_audio',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Remove audio streams with invalid parameters (0 channels, no sample rate).',
},
{
name: 'remove_data_streams',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Remove data streams (bin_data, timed_id3) that cause muxing issues.',
},
{
name: 'remove_attachments',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Remove attachment streams (fonts, etc.) that often cause FFmpeg 7.x muxing errors.',
},
],
});
// Constants - Set for O(1) lookup
const MKV_INCOMPATIBLE = new Set(['mov_text', 'eia_608', 'timed_id3', 'bmp', 'tx3g']);
const MP4_INCOMPATIBLE = new Set(['hdmv_pgs_subtitle', 'eia_608', 'subrip', 'timed_id3', 'ass', 'ssa']);
const IMAGE_CODECS = new Set(['mjpeg', 'png', 'gif']);
const DATA_CODECS = new Set(['bin_data', 'timed_id3', 'dvd_nav_packet']);
const SUPPORTED_CONTAINERS = new Set(['mkv', 'mp4']);
const BOOLEAN_INPUTS = ['remove_image_streams', 'force_conform', 'remove_corrupt_audio', 'remove_data_streams', 'remove_attachments'];
// 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 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 currentContainer = (file.container || '').toLowerCase();
// Early exit optimization: unsupported container = nothing to do
if (!SUPPORTED_CONTAINERS.has(currentContainer)) {
response.infoLog += `⚠️ Container "${currentContainer}" not supported. Skipping conformance. `;
return response;
}
const isTargetMkv = currentContainer === 'mkv';
const incompatibleCodecs = isTargetMkv ? MKV_INCOMPATIBLE : (currentContainer === 'mp4' ? MP4_INCOMPATIBLE : new Set());
response.infoLog += ` Container: ${currentContainer.toUpperCase()}. `;
const streamsToDrop = [];
const stats = { image: 0, corrupt: 0, data: 0, incompatible: 0, attachment: 0 };
for (let i = 0; i < streams.length; i++) {
const stream = streams[i];
const codec = (stream.codec_name || '').toLowerCase();
const type = (stream.codec_type || '').toLowerCase();
// Remove image streams
if (inputs.remove_image_streams && type === 'video') {
const isAttachedPic = stream.disposition?.attached_pic === 1;
if (IMAGE_CODECS.has(codec) || isAttachedPic) {
streamsToDrop.push(i);
stats.image++;
continue;
}
}
// Remove corrupt audio
if (inputs.remove_corrupt_audio && type === 'audio') {
if (stream.channels === 0 || stream.sample_rate === 0 || !codec) {
streamsToDrop.push(i);
stats.corrupt++;
continue;
}
}
// Remove data streams
if (inputs.remove_data_streams && type === 'data') {
if (DATA_CODECS.has(codec)) {
streamsToDrop.push(i);
stats.data++;
continue;
}
}
// Remove attachments
if (inputs.remove_attachments && type === 'attachment') {
streamsToDrop.push(i);
stats.attachment++;
continue;
}
// POLICY: MP4 is incompatible with ALL subtitles - remove any that slipped through
if (currentContainer === 'mp4' && type === 'subtitle') {
streamsToDrop.push(i);
stats.incompatible++;
continue;
}
// Container conforming (for MKV and other edge cases)
if (inputs.force_conform) {
if (incompatibleCodecs.has(codec) || (type === 'data' && isTargetMkv) || !codec || codec === 'unknown' || codec === 'none') {
streamsToDrop.push(i);
stats.incompatible++;
continue;
}
}
}
// Early exit optimization: nothing to drop = no processing needed
if (streamsToDrop.length > 0) {
const dropMaps = streamsToDrop.map((i) => `-map -0:${i}`).join(' ');
response.preset = `<io> -map 0 ${dropMaps} -c copy -max_muxing_queue_size 9999`;
response.container = `.${file.container}`;
response.processFile = true;
const summary = [];
if (stats.image) summary.push(`${stats.image} image`);
if (stats.corrupt) summary.push(`${stats.corrupt} corrupt`);
if (stats.data) summary.push(`${stats.data} data`);
if (stats.incompatible) summary.push(`${stats.incompatible} incompatible`);
if (stats.attachment) summary.push(`${stats.attachment} attachment`);
response.infoLog += `✅ Dropping ${streamsToDrop.length} stream(s): ${summary.join(', ')}. `;
// Final Summary block
response.infoLog += '\n\n📋 Final Processing Summary:\n';
response.infoLog += ` Streams dropped: ${streamsToDrop.length}\n`;
if (stats.image) response.infoLog += ` - Image/Cover art: ${stats.image}\n`;
if (stats.corrupt) response.infoLog += ` - Corrupt audio: ${stats.corrupt}\n`;
if (stats.data) response.infoLog += ` - Data streams: ${stats.data}\n`;
if (stats.incompatible) response.infoLog += ` - Incompatible streams: ${stats.incompatible}\n`;
if (stats.attachment) response.infoLog += ` - Attachments: ${stats.attachment}\n`;
return response;
}
response.infoLog += '✅ No streams to remove. ';
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;