211 lines
8.5 KiB
JavaScript
211 lines
8.5 KiB
JavaScript
/* 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;
|