Files
tdarr-plugs/Local/Tdarr_Plugin_00_file_audit.js

568 lines
23 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 */
/**
* Tdarr Plugin 00 - File Audit
*
* Read-only audit plugin that runs first in the pipeline.
* Logs file information and flags potential issues for downstream plugins.
* Makes NO changes to files - pure analysis and reporting.
*/
const details = () => ({
id: 'Tdarr_Plugin_00_file_audit',
Stage: 'Pre-processing',
Name: '00 - File Audit',
Type: 'Video',
Operation: 'Filter',
Description: `
**READ-ONLY** file auditor that logs comprehensive file information and flags potential issues.
Runs FIRST in the pipeline to provide early warning of problems.
**Reports**:
- Container format and compatibility notes for BOTH MKV and MP4
- All streams with codec details
- Potential issues (broken timestamps, incompatible codecs, corrupt streams)
- Standards compliance (HDR, color space, etc.)
**Never modifies files** - Filter type plugin that always passes files through.
`,
Version: '4.0.0',
Tags: 'filter,audit,analysis,diagnostic,pre-check',
Inputs: [
{
name: 'log_level',
type: 'string',
defaultValue: 'detailed*',
inputUI: {
type: 'dropdown',
options: ['minimal', 'detailed*', 'verbose'],
},
tooltip: 'minimal=issues only, detailed=streams+issues, verbose=everything including metadata',
},
],
});
// ============================================================================
// COMPATIBILITY DEFINITIONS
// ============================================================================
// Codecs incompatible with containers
const MKV_INCOMPATIBLE_CODECS = new Set(['mov_text', 'eia_608', 'timed_id3', 'bmp', 'tx3g']);
const MP4_INCOMPATIBLE_CODECS = new Set(['hdmv_pgs_subtitle', 'eia_608', 'subrip', 'timed_id3', 'ass', 'ssa', 'webvtt']);
// Containers with known timestamp issues
const TIMESTAMP_PROBLEM_CONTAINERS = new Set(['ts', 'mpegts', 'avi', 'mpg', 'mpeg', 'm2ts', 'vob']);
// Legacy codecs that often have timestamp/remux issues
const LEGACY_VIDEO_CODECS = {
'mpeg4': { risk: 'high', note: 'MPEG-4 Part 2 - often has timestamp issues' },
'msmpeg4v1': { risk: 'high', note: 'MS-MPEG4v1 - severe timestamp issues' },
'msmpeg4v2': { risk: 'high', note: 'MS-MPEG4v2 - severe timestamp issues' },
'msmpeg4v3': { risk: 'high', note: 'MS-MPEG4v3/DivX3 - severe timestamp issues' },
'mpeg1video': { risk: 'medium', note: 'MPEG-1 - may need re-encoding' },
'mpeg2video': { risk: 'medium', note: 'MPEG-2 - may have GOP issues' },
'wmv1': { risk: 'high', note: 'WMV7 - poor container compatibility' },
'wmv2': { risk: 'high', note: 'WMV8 - poor container compatibility' },
'wmv3': { risk: 'medium', note: 'WMV9 - may have issues in MKV/MP4' },
'rv10': { risk: 'high', note: 'RealVideo 1.0 - very limited support' },
'rv20': { risk: 'high', note: 'RealVideo 2.0 - very limited support' },
'rv30': { risk: 'high', note: 'RealVideo 3.0 - very limited support' },
'rv40': { risk: 'high', note: 'RealVideo 4.0 - very limited support' },
'vp6': { risk: 'medium', note: 'VP6 - legacy Flash codec' },
'vp6f': { risk: 'medium', note: 'VP6 Flash - legacy Flash codec' },
'flv1': { risk: 'medium', note: 'FLV/Sorenson Spark - legacy codec' },
};
// XviD/DivX codec tags that indicate packed bitstream issues
const XVID_DIVX_TAGS = new Set(['XVID', 'DIVX', 'DX50', 'DIV3', 'DIV4', 'DIV5', 'FMP4', 'MP4V', 'MP42', 'MP43']);
// Image codecs (cover art) that should be removed
const IMAGE_CODECS = new Set(['mjpeg', 'png', 'gif', 'bmp', 'webp', 'tiff']);
// Data stream codecs that cause issues
const PROBLEMATIC_DATA_CODECS = new Set(['bin_data', 'timed_id3', 'dvd_nav_packet']);
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
const sanitizeInputs = (inputs) => {
Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); });
return inputs;
};
const formatBitrate = (bps) => {
if (!bps || bps === 0) return 'unknown';
const kbps = Math.round(bps / 1000);
if (kbps >= 1000) return `${(kbps / 1000).toFixed(1)} Mbps`;
return `${kbps} kbps`;
};
const formatDuration = (seconds) => {
if (!seconds) return 'unknown';
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) return `${hrs}h ${mins}m ${secs}s`;
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
};
const formatSize = (bytes) => {
if (!bytes) return 'unknown';
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1) return `${gb.toFixed(2)} GB`;
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(1)} MB`;
};
// ============================================================================
// AUDIT CHECKS
// ============================================================================
/**
* Analyze container format and flag issues
*/
const auditContainer = (file) => {
const issues = [];
const info = [];
const container = (file.container || '').toLowerCase();
const formatName = file.ffProbeData?.format?.format_name || '';
info.push(`Container: ${container.toUpperCase()} (format: ${formatName})`);
// Check for timestamp-problematic containers
if (TIMESTAMP_PROBLEM_CONTAINERS.has(container)) {
issues.push(`⚠️ TIMESTAMP: ${container.toUpperCase()} containers often have timestamp issues requiring -fflags +genpts`);
}
// Check for containers that need special handling
if (['iso', 'vob', 'evo'].includes(container)) {
issues.push(`❌ UNSUPPORTED: ${container.toUpperCase()} requires manual conversion (HandBrake/MakeMKV)`);
}
// Note current container for user reference
if (!['mkv', 'mp4'].includes(container) && !['iso', 'vob', 'evo'].includes(container)) {
info.push(`📦 Current container will need remuxing to MKV or MP4`);
}
return { issues, info };
};
/**
* Analyze video streams
*/
const auditVideoStreams = (streams) => {
const issues = [];
const info = [];
const videoStreams = streams.filter(s => s.codec_type === 'video' && !IMAGE_CODECS.has((s.codec_name || '').toLowerCase()));
if (videoStreams.length === 0) {
issues.push('❌ NO VIDEO: No valid video stream found');
return { issues, info };
}
if (videoStreams.length > 1) {
issues.push(`⚠️ MULTI-VIDEO: ${videoStreams.length} video streams detected (unusual)`);
}
videoStreams.forEach((stream, idx) => {
const codec = (stream.codec_name || 'unknown').toLowerCase();
const codecTag = (stream.codec_tag_string || '').toUpperCase();
const width = stream.width || '?';
const height = stream.height || '?';
const fps = stream.r_frame_rate || stream.avg_frame_rate || '?';
const bitrate = stream.bit_rate || 0;
const pixFmt = stream.pix_fmt || 'unknown';
// Basic info
let streamInfo = `🎬 Video ${idx}: ${codec.toUpperCase()} ${width}x${height}`;
if (fps && fps !== '?') {
const [num, den] = fps.split('/').map(Number);
if (den && den > 0) streamInfo += ` @ ${(num / den).toFixed(2)}fps`;
}
if (bitrate) streamInfo += ` (${formatBitrate(bitrate)})`;
streamInfo += ` [${pixFmt}]`;
info.push(streamInfo);
// Check for legacy codec issues
if (LEGACY_VIDEO_CODECS[codec]) {
const legacy = LEGACY_VIDEO_CODECS[codec];
issues.push(`⚠️ LEGACY (${legacy.risk}): ${legacy.note}`);
}
// Check for XviD/DivX packed bitstream
if (codec === 'mpeg4' && XVID_DIVX_TAGS.has(codecTag)) {
issues.push(`⚠️ XVID/DIVX: ${codecTag} may have packed bitstream timestamp issues`);
}
// Check for divx_packed flag
if (stream.divx_packed === 'true' || stream.divx_packed === true) {
issues.push('❌ PACKED BITSTREAM: DivX packed bitstream detected - will need re-encoding');
}
// HDR detection
const colorTransfer = stream.color_transfer || '';
const colorPrimaries = stream.color_primaries || '';
const colorSpace = stream.color_space || '';
if (colorTransfer === 'smpte2084') {
info.push(' 🌈 HDR10 (PQ) detected - metadata preservation needed');
} else if (colorTransfer === 'arib-std-b67') {
info.push(' 🌈 HLG detected - metadata preservation needed');
}
if (colorPrimaries === 'bt2020' || colorSpace === 'bt2020nc') {
info.push(' 📺 BT.2020 color space detected');
}
// Check for unusual pixel formats
if (pixFmt.includes('12le') || pixFmt.includes('12be')) {
info.push(' ⚠️ 12-bit depth - may have limited player support');
}
// Check for interlaced content
if (stream.field_order && !['progressive', 'unknown'].includes(stream.field_order)) {
issues.push(`⚠️ INTERLACED: Field order "${stream.field_order}" - may need deinterlacing`);
}
});
return { issues, info };
};
/**
* Analyze audio streams - checks both MKV and MP4 compatibility
*/
const auditAudioStreams = (streams) => {
const issues = [];
const info = [];
const audioStreams = streams.filter(s => s.codec_type === 'audio');
if (audioStreams.length === 0) {
issues.push('⚠️ NO AUDIO: No audio streams found');
return { issues, info };
}
audioStreams.forEach((stream, idx) => {
const codec = (stream.codec_name || 'unknown').toLowerCase();
const channels = stream.channels || 0;
const sampleRate = stream.sample_rate || 0;
const bitrate = stream.bit_rate || 0;
const lang = stream.tags?.language || 'und';
const title = stream.tags?.title || '';
// Check for corrupt audio
if (channels === 0) {
issues.push(`❌ CORRUPT AUDIO ${idx}: 0 channels detected - stream will be removed`);
return;
}
if (sampleRate === 0) {
issues.push(`⚠️ CORRUPT AUDIO ${idx}: No sample rate detected`);
}
// Channel layout description
let channelDesc = `${channels}ch`;
if (channels === 1) channelDesc = 'Mono';
else if (channels === 2) channelDesc = 'Stereo';
else if (channels === 6) channelDesc = '5.1';
else if (channels === 8) channelDesc = '7.1';
let streamInfo = `🔊 Audio ${idx}: ${codec.toUpperCase()} ${channelDesc}`;
if (sampleRate) streamInfo += ` @ ${sampleRate}Hz`;
if (bitrate) streamInfo += ` (${formatBitrate(bitrate)})`;
streamInfo += ` [${lang}]`;
if (title) streamInfo += ` "${title}"`;
info.push(streamInfo);
// Check MP4-specific audio compatibility issues
if (['vorbis', 'opus'].includes(codec)) {
issues.push(`⚠️ [MP4 only] ${codec.toUpperCase()} has limited MP4 support (OK in MKV)`);
}
if (['dts', 'truehd'].includes(codec)) {
issues.push(`⚠️ [MP4 only] ${codec.toUpperCase()} not standard in MP4 (OK in MKV)`);
}
// Check for unusual audio codecs (both containers)
if (['cook', 'ra_144', 'ra_288', 'sipr', 'atrac3', 'atrac3p'].includes(codec)) {
issues.push(`⚠️ [BOTH] RARE CODEC: ${codec.toUpperCase()} - very limited support`);
}
});
return { issues, info };
};
/**
* Analyze subtitle streams - checks both MKV and MP4 compatibility
*/
const auditSubtitleStreams = (streams, file) => {
const issues = [];
const info = [];
const subStreams = streams.filter(s => s.codec_type === 'subtitle');
if (subStreams.length === 0) {
info.push('📝 Subtitles: None');
return { issues, info };
}
subStreams.forEach((stream, idx) => {
// Robust codec identification
let codec = (stream.codec_name || '').toLowerCase();
if (codec === 'none' || codec === 'unknown' || !codec) {
// Try metadata fallback
const codecTag = (stream.codec_tag_string || '').toUpperCase();
if (codecTag.includes('WEBVTT')) codec = 'webvtt';
else if (codecTag.includes('ASS')) codec = 'ass';
else if (codecTag.includes('SSA')) codec = 'ssa';
else {
const tagCodecId = (stream.tags?.CodecID || stream.tags?.codec_id || '').toUpperCase();
if (tagCodecId.includes('WEBVTT') || tagCodecId.includes('S_TEXT/WEBVTT')) codec = 'webvtt';
else if (tagCodecId.includes('ASS') || tagCodecId.includes('S_TEXT/ASS')) codec = 'ass';
else if (tagCodecId.includes('SSA') || tagCodecId.includes('S_TEXT/SSA')) codec = 'ssa';
}
}
// If still unknown, check MediaInfo/ExifTool if available
if (codec === 'none' || codec === 'unknown' || !codec) {
const mediaInfoCodec = (file.mediaInfo?.track?.find(t => t['@type'] === 'Text' && t.StreamOrder == stream.index)?.CodecID || '').toLowerCase();
if (mediaInfoCodec.includes('webvtt')) codec = 'webvtt';
else if (mediaInfoCodec.includes('ass')) codec = 'ass';
else if (mediaInfoCodec.includes('ssa')) codec = 'ssa';
}
codec = codec || 'unknown';
const lang = stream.tags?.language || 'und';
const title = stream.tags?.title || '';
const forced = stream.disposition?.forced === 1 ? ' [FORCED]' : '';
let streamInfo = `📝 Sub ${idx}: ${codec.toUpperCase()} [${lang}]${forced}`;
if (title) streamInfo += ` "${title}"`;
info.push(streamInfo);
// Check for specific problematic states
if (codec === 'unknown') {
issues.push(`⚠️ [BOTH] Subtitle stream ${idx} codec could not be identified - may cause transcode failure`);
}
// Check container-specific compatibility
const mkvIncompat = MKV_INCOMPATIBLE_CODECS.has(codec);
const mp4Incompat = MP4_INCOMPATIBLE_CODECS.has(codec);
if (mkvIncompat && mp4Incompat) {
issues.push(`⚠️ [BOTH] ${codec.toUpperCase()} incompatible with MKV and MP4`);
} else if (mkvIncompat) {
issues.push(`⚠️ [MKV only] ${codec.toUpperCase()} not compatible with MKV (OK in MP4)`);
} else if (mp4Incompat) {
issues.push(`⚠️ [MP4 only] ${codec.toUpperCase()} not compatible with MP4 (OK in MKV)`);
}
// Check for image-based subs that can't be converted to SRT
if (['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub'].includes(codec)) {
info.push(` Image-based subtitle - cannot convert to SRT`);
}
// Check for formats that will be converted
if (['ass', 'ssa', 'webvtt', 'mov_text'].includes(codec)) {
info.push(` Will convert to SRT for compatibility`);
}
});
return { issues, info };
};
/**
* Analyze other streams (data, attachments, images)
*/
const auditOtherStreams = (streams) => {
const issues = [];
const info = [];
// Image streams (cover art)
const imageStreams = streams.filter(s =>
(s.codec_type === 'video' && IMAGE_CODECS.has((s.codec_name || '').toLowerCase())) ||
s.disposition?.attached_pic === 1
);
if (imageStreams.length > 0) {
info.push(`🖼️ Cover Art: ${imageStreams.length} image stream(s) - will be removed`);
}
// Data streams
const dataStreams = streams.filter(s => s.codec_type === 'data');
dataStreams.forEach((stream, idx) => {
const codec = (stream.codec_name || 'unknown').toLowerCase();
if (PROBLEMATIC_DATA_CODECS.has(codec)) {
issues.push(`⚠️ DATA STREAM: ${codec} will cause muxing issues - will be removed`);
} else {
info.push(`📊 Data ${idx}: ${codec.toUpperCase()}`);
}
});
// Attachments (fonts, etc.)
const attachments = streams.filter(s => s.codec_type === 'attachment');
if (attachments.length > 0) {
info.push(`📎 Attachments: ${attachments.length} (fonts, etc.)`);
}
return { issues, info };
};
/**
* Analyze file-level metadata
*/
const auditFileMetadata = (file, logLevel) => {
const issues = [];
const info = [];
const format = file.ffProbeData?.format || {};
const duration = parseFloat(format.duration) || 0;
const size = file.statSync?.size || parseInt(format.size) || 0;
const bitrate = parseInt(format.bit_rate) || 0;
// Basic file info
info.push(`📁 Size: ${formatSize(size)} | Duration: ${formatDuration(duration)} | Bitrate: ${formatBitrate(bitrate)}`);
// Check for very short files
if (duration > 0 && duration < 10) {
issues.push('⚠️ SHORT FILE: Duration under 10 seconds');
}
// Check for suspiciously low bitrate
if (bitrate > 0 && bitrate < 100000) { // Under 100kbps
issues.push('⚠️ LOW BITRATE: File bitrate is very low - possible quality issues');
}
// Check for missing duration (common in broken files)
if (!duration || duration === 0) {
issues.push('⚠️ NO DURATION: Could not determine file duration - may be corrupt');
}
// Verbose: show all format tags
if (logLevel === 'verbose' && format.tags) {
const importantTags = ['title', 'encoder', 'creation_time', 'copyright'];
importantTags.forEach(tag => {
if (format.tags[tag]) {
info.push(` 📋 ${tag}: ${format.tags[tag]}`);
}
});
}
return { issues, info };
};
// ============================================================================
// MAIN PLUGIN
// ============================================================================
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const response = {
processFile: true, // MUST be true for Filter plugins to pass files through!
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: false,
reQueueAfter: false,
infoLog: '',
};
try {
inputs = sanitizeInputs(lib.loadDefaultValues(inputs, details));
const logLevel = inputs.log_level;
// Header
response.infoLog += '═══════════════════════════════════════════════════════════════\n';
response.infoLog += ' 📋 FILE AUDIT REPORT\n';
response.infoLog += '═══════════════════════════════════════════════════════════════\n\n';
if (!file.ffProbeData?.streams || !Array.isArray(file.ffProbeData.streams)) {
response.infoLog += '❌ CRITICAL: No stream data available - file may be corrupt\n';
return response;
}
const streams = file.ffProbeData.streams;
const allIssues = [];
const allInfo = [];
// Run all audits (no target container - checks both MKV and MP4)
const containerAudit = auditContainer(file);
const videoAudit = auditVideoStreams(streams);
const audioAudit = auditAudioStreams(streams);
const subtitleAudit = auditSubtitleStreams(streams, file);
const otherAudit = auditOtherStreams(streams);
const metadataAudit = auditFileMetadata(file, logLevel);
// Collect all results
allIssues.push(...containerAudit.issues, ...videoAudit.issues, ...audioAudit.issues,
...subtitleAudit.issues, ...otherAudit.issues, ...metadataAudit.issues);
allInfo.push(...metadataAudit.info, ...containerAudit.info, ...videoAudit.info,
...audioAudit.info, ...subtitleAudit.info, ...otherAudit.info);
// Output based on log level
if (logLevel === 'minimal') {
// Minimal: issues only
if (allIssues.length > 0) {
response.infoLog += `🔍 Found ${allIssues.length} potential issue(s):\n`;
allIssues.forEach(issue => {
response.infoLog += ` ${issue}\n`;
});
} else {
response.infoLog += '✅ No issues detected\n';
}
} else {
// Detailed/Verbose: show info and issues
allInfo.forEach(info => {
response.infoLog += `${info}\n`;
});
response.infoLog += '\n───────────────────────────────────────────────────────────────\n';
if (allIssues.length > 0) {
response.infoLog += `\n🔍 POTENTIAL ISSUES (${allIssues.length}):\n`;
response.infoLog += ' [MKV only] = Issue only affects MKV container\n';
response.infoLog += ' [MP4 only] = Issue only affects MP4 container\n';
response.infoLog += ' [BOTH] = Issue affects both containers\n\n';
allIssues.forEach(issue => {
response.infoLog += ` ${issue}\n`;
});
} else {
response.infoLog += '\n✅ No issues detected - file ready for processing\n';
}
}
// Stream count summary
const videoCount = streams.filter(s => s.codec_type === 'video' && !IMAGE_CODECS.has((s.codec_name || '').toLowerCase())).length;
const audioCount = streams.filter(s => s.codec_type === 'audio').length;
const subCount = streams.filter(s => s.codec_type === 'subtitle').length;
response.infoLog += '\n───────────────────────────────────────────────────────────────\n';
response.infoLog += `📊 Summary: ${videoCount}V ${audioCount}A ${subCount}S | Checked: MKV+MP4 | Issues: ${allIssues.length}\n`;
response.infoLog += '═══════════════════════════════════════════════════════════════\n';
// Final Summary block (for consistency with other plugins)
if (logLevel !== 'minimal') {
response.infoLog += '\n📋 Final Processing Summary:\n';
response.infoLog += ` Streams: ${videoCount} video, ${audioCount} audio, ${subCount} subtitle\n`;
response.infoLog += ` Issues detected: ${allIssues.length}\n`;
response.infoLog += ` Container compatibility: MKV + MP4 checked\n`;
}
return response;
} catch (error) {
response.infoLog = `❌ Audit plugin error: ${error.message}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;