docs: Add historical reports and consolidated archives

This commit is contained in:
Tdarr Plugin Developer
2026-01-30 05:55:22 -08:00
parent b0c7ed3229
commit ddc9d09c41
24 changed files with 8134 additions and 33 deletions

View File

@@ -0,0 +1,133 @@
# Tdarr Plugin Stack Code Review
**Date:** 2025-12-15
**Reviewer:** Antigravity Agent
**Scope:** `/Local/*.js` plugins
---
## 1. Stack Architecture Overview
The plugin stack operates in a **sequential re-queue model**. Each plugin that modifies the file triggers a `reQueueAfter: true`, causing Tdarr to process the output and restart the stack from the beginning with the new file.
**Current Order:**
1. `misc_fixes` (Container/Remux/Clean)
2. `stream_organizer` (Subtitle Extraction/Reorder)
3. `combined_audio_standardizer` (Audio Transcode/Downmix)
4. `av1_svt_converter` (Video Transcode)
### 🚨 Architectural Risks & Findings
* **High I/O Overhead:** This stack can potentially trigger **4 separate transcode/remux cycles** per file.
* Pass 1: Remux to MKV (misc_fixes)
* Pass 2: Reorder/Extract Subtitles (stream_organizer)
* Pass 3: Audio Transcode (audio_standardizer)
* Pass 4: Video Transcode (av1_converter)
* *Recommendation:* Consider combining logic where possible, or accepting high I/O for modularity.
* **Race Conditions:** `stream_organizer` handles CC extraction via mostly atomic locks, but file existence checks in Tdarr's distributed environment are always tricky. The current `reQueueAfter` logic relies heavily on "state convergence" (eventually the file meets criteria).
---
## 2. Individual Plugin Analysis
### A. Tdarr_Plugin_misc_fixes.js (v2.8)
**Overview:** Handles container standardization, stream cleaning, and TS fixes.
**Strengths:**
* **Correct Logic Flow:** Checks for "work already done" (e.g., `currentContainer !== targetContainer`, `firstStreamIsVideo`) to prevent infinite loops.
* **Robust Skip Logic:** Correctly identifies unfixable ISO/TS files early.
**Issues & Improvements:**
1. **Complexity/Refactoring:** The `plugin` function is becoming monolithic.
* *Suggestion:* Extract `analyzeStreams` and `buildFFmpegCommand` into helper functions.
2. **Hardcoded Lists:** `brokenTypes`, `image codecs` are defined inside the function.
* *Fix:* Move `const` definitions (like `BROKEN_TYPES`, `IMAGE_CODECS`) to top-level scope for readability and memory efficiency.
3. **Variable Shadowing:** `inputs` is reassigned (`inputs = lib.loadDefaultValues...`). Ideally, use `const settings = ...` to avoid mutating arguments.
### B. Tdarr_Plugin_stream_organizer.js (v4.8)
**Overview:** Manages subtitles, language ordering, and extraction.
**Strengths:**
* **Sanitization:** Strong input/filename sanitization (`sanitizeFilename`, `sanitizeForShell`).
* **Loop Protection:** Excellent use of `MAX_EXTRACTION_ATTEMPTS` and `extractionAttempts` map (though memory-only).
* **Robustness:** `fileExistsRobust` wrapper helps with filesystem flakes.
**Issues & Improvements:**
1. **Massive Function Size:** The `plugin` function is ~500 lines. It violates Single Responsibility Principle.
* *Critical Refactor:* Move stream analysis, extraction logic, and command building into separate functions: `getReorderedStreams()`, `processSubtitles()`, `buildFFmpegArgs()`.
2. **Redundant Logic:** `isEnglishStream` is used in partitioning and mapping loops separately.
3. **CC Extraction Lock:** The lock file mechanism (`.lock`) is decent but relies on `process.pid`. If a node crashes hard, the lock remains.
* *Recommendation:* Add a "stale lock" check (e.g., if lock file > 1 hour old, ignore/delete it).
4. **Efficiency:** The `partitionStreams` logic iterates arrays multiple times.
### C. Tdarr_Plugin_combined_audio_standardizer.js (v1.13)
**Overview:** Complex audio mapping, downmixing, and transcoding.
**Strengths:**
* **Modular Helpers:** `buildCodecArgs`, `buildDownmixArgs`, `calculateBitrate` are well-separated. Good code structure.
* **Explicit Mapping:** Correctly handles attachments via `streamMap` construction (prevents the "muxing overhead 400%" issues).
**Issues & Improvements:**
1. **Complex Conditionals:** The `needsTranscoding` logic is a bit nested.
2. **Downmix Logic Risk:** `buildDownmixArgs` assumes the source stream is compatible with the `downmix` filter. Usually safe, but edge cases exist.
3. **Attachment Handling:** It maps `0:t` copies, but `misc_fixes` might have stripped images.
* *Check:* If `misc_fixes` runs first, it removes images. `audio_standardizer` won't see them in `file.ffProbeData` (sourced from Tdarr DB).
* *Risk:* If Tdarr DB is stale (scan didn't happen after misc_fixes?), `combined_audio` might try to map non-existent streams.
* *Mitigation:* `reQueueAfter` usually forces a rescan, so this should be safe.
### D. Tdarr_Plugin_av1_svt_converter.js (v2.22)
**Overview:** AV1 video encoding.
**Strengths:**
* **Modern AV1 Handling:** Good use of SVT-AV1 parameters (SCD, TF, etc.).
* **Resolution Awareness:** Smart CRF adjustment logic based on resolution.
* **Input Handling:** Explicit checks for HDR/10-bit.
**Issues & Improvements:**
1. **Argument Injection Risk (Low):** `svtParams` is constructed from inputs. While inputs are sanitized (stripped stars), strict type validation would be better before injection.
2. **Parsing Logic:** `resolutionMap` is hardcoded.
3. **Bitrate Strategy:** The `target_bitrate_strategy` logic is complex and relies on accurate source bitrate detection, which isn't always available in `ffProbeData`.
* *Suggestion:* Add fallback if `bit_rate` is missing/NaN (currently defaults to safe uncapped, which is acceptable).
---
## 3. General Best Practice Violations
1. **Shared Helpers Duplication:**
* `stripStar` is defined in EVERY plugin.
* `sanitizeForShell` is in multiple plugins.
* *Fix:* You have a `lib/sanitization.js` (referenced in chat history), but plugins currently duplicate this code. They should `require` the shared library if Tdarr environment permits, OR (if Tdarr requires self-contained plugins) this duplication is a necessary evil.
* *Observation:* Plugins currently require `../methods/lib` (Tdarr internal). Custom libs in `/Local` might not be reliably accessible across nodes unless explicitly distributed.
2. **Magic Numbers:**
* `MAX_EXTRACTION_ATTEMPTS = 3`
* `MIN_SUBTITLE_FILE_SIZE = 100`
* Defined as constants in some files, literals in others. Standardize.
3. **Error Handling Patterns:**
* Most plugins use `response.processFile = false` + `infoLog` on error. This is good Tdarr practice (don't crash the node).
---
## 4. Recommendations & Refactoring Plan
### Priority 1: Safety & Stability (Immediate)
* **Stale Lock Cleanup:** Implement stale lock check in `stream_organizer` (CC extraction).
* **Argument Validation:** Strengthen input validation in `av1_svt_converter` to ensure `svt-params` injection is perfectly safe.
### Priority 2: Code Quality (Short-term)
* **De-duplication:** If Tdarr allows, strictly enforce using a shared `utils.js` for `stripStar`, `sanitizeFilename`, etc.
* **Modularization:** Refactor `stream_organizer.js` to break up the 500-line `plugin` function.
### Priority 3: Architecture (Long-term)
* **Combine Passes:** Investigate merging `misc_fixes` and `stream_organizer` logic?
* *Counter-argument:* Keeping them separate is better for maintenance.
* *Alternative:* Use Tdarr's "Flows" (if upgrading to Tdarr V2 flows) or accept the I/O cost for robustness.
## 5. Conclusion
The plugins are currently **FUNCTIONAL and SAFE** (after recent fixes). The code quality is generally high but suffers from "script creep" where functions have grown too large. Logic for infinite loop prevention is verified in place.
**No immediate code changes required for safety**, but refactoring `stream_organizer` is highly recommended for maintainability.

View File

@@ -0,0 +1,350 @@
/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_misc_fixes',
Stage: 'Pre-processing',
Name: 'Misc Fixes',
Type: 'Video',
Operation: 'Transcode',
Description: `
A consolidated 'Megamix' of fixes for common video file issues.
Combines functionality from Migz Remux, Migz Image Removal, Lmg1 Reorder, and custom timestamp fixes.
Features:
- Fixes timestamps for TS/AVI/MPG files
- Optional TS audio recovery: extract + transcode audio to AAC for compatibility
- Remuxes to target container (MKV/MP4)
- Conforms streams to container (drops incompatible subtitles)
- Removes unwanted image streams (MJPEG/PNG/GIF)
- Ensures Video stream is ordered first
Should be placed FIRST in your plugin stack.
`,
Version: '2.8',
Tags: 'action,ffmpeg,ts,remux,fix,megamix',
Inputs: [
{
name: 'target_container',
type: 'string',
defaultValue: 'mkv',
inputUI: {
type: 'dropdown',
options: ['mkv', 'mp4'],
},
tooltip: 'Target container format',
},
{
name: 'force_conform',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: ['true*', 'false'],
},
tooltip: 'Drop streams incompatible with the target container (e.g. mov_text in MKV)',
},
{
name: 'remove_image_streams',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: ['true*', 'false'],
},
tooltip: 'Remove MJPEG, PNG, and GIF video streams (often cover art or spam)',
},
{
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: 'fix_ts_timestamps',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: ['true*', 'false'],
},
tooltip: 'Apply special timestamp fixes for TS/AVI/MPG files (-fflags +genpts)',
},
{
name: 'ts_audio_recovery',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: ['false', 'true'],
},
tooltip: 'TS files only: Extract and transcode audio to AAC for compatibility. Ignored for non-TS files.',
},
],
});
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
// Strip UI star markers from input values (Tdarr uses '*' to indicate default values in UI)
const stripStar = (value) => {
if (typeof value === 'string') {
return value.replace(/\*/g, '');
}
return value;
};
// Initialize response first for error handling
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: true,
reQueueAfter: true,
infoLog: '',
};
try {
inputs = lib.loadDefaultValues(inputs, details);
// Sanitize UI-starred defaults
Object.keys(inputs).forEach((key) => {
inputs[key] = stripStar(inputs[key]);
});
// Input validation
const VALID_CONTAINERS = ['mkv', 'mp4'];
const VALID_BOOLEAN = ['true', 'false'];
if (!VALID_CONTAINERS.includes(inputs.target_container)) {
response.infoLog += `❌ Invalid target_container: ${inputs.target_container}. `;
return response;
}
const booleanInputs = [
'force_conform',
'remove_image_streams',
'ensure_video_first',
'fix_ts_timestamps',
'ts_audio_recovery',
];
// eslint-disable-next-line no-restricted-syntax
for (const input of booleanInputs) {
const val = String(inputs[input]).toLowerCase();
if (!VALID_BOOLEAN.includes(val)) {
response.infoLog += `❌ Invalid ${input}: must be true or false. `;
return response;
}
inputs[input] = val; // Normalize to lowercase string
}
if (!Array.isArray(file.ffProbeData?.streams)) {
response.infoLog += '❌ No stream data available. ';
return response;
}
// --- Logic Setup (needed for skip checks below) ---
const targetContainer = inputs.target_container;
const currentContainer = file.container.toLowerCase();
const isTargetMkv = targetContainer === 'mkv';
const isTargetMp4 = targetContainer === 'mp4';
// Skip ISO/DVD files - these require specialized tools like HandBrake or MakeMKV
// These files often have corrupt MPEG-PS streams that cannot be reliably remuxed
if (['iso', 'vob', 'evo'].includes(currentContainer)) {
response.infoLog += '⚠️ ISO/DVD files require manual conversion with HandBrake or MakeMKV. Skipping automated processing.\n';
response.processFile = false;
return response;
}
// Skip TS files with severe timestamp corruption that cannot be fixed
// These files have missing or corrupt timestamps that FFmpeg cannot regenerate
if (['ts', 'mpegts', 'm2ts'].includes(currentContainer)) {
const hasCorruptStreams = file.ffProbeData.streams.some(s => {
// Check for audio streams with 0 channels (corrupt)
if (s.codec_type === 'audio' && s.channels === 0) return true;
// Check for streams missing duration (severe timestamp issues)
if (s.codec_type === 'video' && !s.duration && !s.duration_ts) return true;
return false;
});
if (hasCorruptStreams) {
response.infoLog += '⚠️ TS file has corrupt streams with unfixable timestamp issues. Skipping automated processing.\n';
response.infoLog += ' Consider manual conversion with HandBrake or re-recording the source.\n';
response.processFile = false;
return response;
}
}
// --- Stream Analysis ---
// Track actions
let needsRemux = currentContainer !== targetContainer;
let droppingStreams = false;
const extraMaps = []; // For negative mapping (-map -0:x)
let genptsFlags = '';
let codecFlags = '-c copy';
// --- 1. Timestamp Fixes (Migz + Custom) ---
if (inputs.fix_ts_timestamps === 'true') {
const brokenTypes = ['ts', 'mpegts', 'avi', 'mpg', 'mpeg'];
if (brokenTypes.includes(currentContainer)) {
if (['ts', 'mpegts'].includes(currentContainer)) {
// Enhanced TS timestamp fixes: generate PTS for all streams, handle negative timestamps
// Use genpts+igndts to regenerate timestamps where missing
// -copyts preserves existing timestamps, genpts fills in gaps
// make_zero handles negative timestamps by shifting to start at 0
// Note: For severely broken TS files with completely missing timestamps,
// transcoding (not copy) may be required as genpts only works for video streams
genptsFlags = '-fflags +genpts+igndts -avoid_negative_ts make_zero -copyts';
response.infoLog += '✅ Applying TS timestamp fixes. ';
needsRemux = true;
} else {
genptsFlags = '-fflags +genpts';
response.infoLog += `✅ Applying ${currentContainer} timestamp fixes. `;
needsRemux = true;
}
}
}
// --- 1b. Optional TS audio extraction + AAC transcode for compatibility ---
if (inputs.ts_audio_recovery === 'true') {
if (['ts', 'mpegts'].includes(currentContainer)) {
// Determine a sane AAC bitrate: preserve multichannel without starving
const firstAudio = file.ffProbeData.streams.find((s) => s.codec_type === 'audio');
const audioChannels = firstAudio?.channels || 2;
const audioBitrate = audioChannels > 2 ? '384k' : '192k';
codecFlags = `-c:v copy -c:a aac -b:a ${audioBitrate} -c:s copy -c:d copy -c:t copy`;
response.infoLog += `🎧 TS audio recovery enabled: extracting and transcoding audio to AAC (${audioChannels}ch -> ${audioBitrate}). `;
needsRemux = true;
} else {
response.infoLog += ' TS audio recovery enabled but file is not TS format, skipping. ';
}
}
// --- 2. Stream Sorting & Conform Loop ---
// Check if reordering is actually needed
const firstStreamIsVideo = file.ffProbeData.streams[0]?.codec_type === 'video';
const needsReorder = inputs.ensure_video_first === 'true' && !firstStreamIsVideo;
// Start with base map
let baseMap = '-map 0';
if (needsReorder) {
// Force order: Video -> Audio -> Subs -> Data -> Attachments
baseMap = '-map 0:v? -map 0:a? -map 0:s? -map 0:d? -map 0:t?';
}
// Loop streams to find things to DROP
for (let i = 0; i < file.ffProbeData.streams.length; i++) {
const stream = file.ffProbeData.streams[i];
const codec = (stream.codec_name || '').toLowerCase();
const type = (stream.codec_type || '').toLowerCase();
// A. Image Format Removal
if (inputs.remove_image_streams === 'true' && type === 'video') {
// Check for image codecs or attached pictures (excluding BMP - handled in container-specific logic)
const isAttachedPic = stream.disposition?.attached_pic === 1;
if (['mjpeg', 'png', 'gif'].includes(codec) || (isAttachedPic && !['bmp'].includes(codec))) {
extraMaps.push(`-map -0:${i}`);
response.infoLog += ` Removing image stream ${i} (${codec}${isAttachedPic ? ', attached pic' : ''}). `;
droppingStreams = true;
}
}
// B. Invalid Audio Stream Detection
// Skip audio streams with invalid parameters (0 channels, no sample rate, etc.)
if (type === 'audio') {
const channels = stream.channels || 0;
const sampleRate = stream.sample_rate || 0;
// Check for invalid audio streams (common in ISO/DVD sources)
if (channels === 0 || sampleRate === 0 || !codec || codec === 'unknown') {
extraMaps.push(`-map -0:${i}`);
response.infoLog += ` Dropping invalid audio stream ${i} (${codec || 'unknown'}, ${channels}ch, ${sampleRate}Hz). `;
droppingStreams = true;
continue; // Skip further checks for this stream
}
}
// C. Force Conform (Container Compatibility)
if (inputs.force_conform === 'true') {
if (isTargetMkv) {
// Migz logic for MKV: Drop mov_text, eia_608, timed_id3, data, and BMP (not supported)
if (['mov_text', 'eia_608', 'timed_id3', 'bmp'].includes(codec) || type === 'data') {
extraMaps.push(`-map -0:${i}`);
response.infoLog += ` Dropping incompatible stream ${i} (${codec}) for MKV. `;
droppingStreams = true;
}
} else if (isTargetMp4) {
// Migz logic for MP4: Drop hdmv_pgs_subtitle, eia_608, subrip, timed_id3
// Note: keeping 'subrip' drop to be safe per Migz, though some mp4s file allow it.
if (['hdmv_pgs_subtitle', 'eia_608', 'subrip', 'timed_id3'].includes(codec)) {
extraMaps.push(`-map -0:${i}`);
response.infoLog += ` Dropping incompatible stream ${i} (${codec}) for MP4. `;
droppingStreams = true;
}
}
}
}
// --- 3. Decision Time ---
// Reorder check was done earlier (line 198), apply to needsRemux if needed
if (needsReorder) {
response.infoLog += '✅ Reordering streams (Video first). ';
needsRemux = true;
}
if (needsRemux || droppingStreams) {
// Construct command
// Order: <genpts> <input> <baseMap> <extraMaps> <copy> <target>
const cmdParts = [];
if (genptsFlags) cmdParts.push(genptsFlags);
cmdParts.push(baseMap);
if (extraMaps.length > 0) cmdParts.push(extraMaps.join(' '));
cmdParts.push(codecFlags);
cmdParts.push('-max_muxing_queue_size 9999');
response.preset = `<io> ${cmdParts.join(' ')}`;
response.container = `.${targetContainer}`;
response.processFile = true;
// Log conversion reason
if (currentContainer !== targetContainer) {
response.infoLog += `✅ Remuxing ${currentContainer} to ${targetContainer}. `;
}
return response;
}
response.infoLog += '☑️ File meets all criteria. ';
return response;
} catch (error) {
// Comprehensive error handling
response.processFile = false;
response.preset = '';
response.reQueueAfter = false;
// Provide detailed error information
response.infoLog = `💥 Plugin error: ${error.message}\n`;
// Add stack trace for debugging (first 5 lines)
if (error.stack) {
const stackLines = error.stack.split('\n').slice(0, 5).join('\n');
response.infoLog += `Stack trace:\n${stackLines}\n`;
}
// Log additional context
response.infoLog += `File: ${file.file}\n`;
response.infoLog += `Container: ${file.container}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;

View File

@@ -0,0 +1,936 @@
const details = () => ({
id: 'Tdarr_Plugin_stream_organizer',
Stage: 'Pre-processing',
Name: 'Stream Organizer',
Type: 'Video',
Operation: 'Transcode',
Description: `
Organizes streams by language priority (English/custom codes first).
Converts text-based subtitles to SRT format and/or extracts them to external files.
Handles closed captions (eia_608/cc_dec) via CCExtractor.
All other streams are preserved in their original relative order.
WebVTT subtitles are always converted to SRT for compatibility.
v4.11: Optimized requeue - only requeues when container is modified, not for extraction-only.
v4.10: Fixed infinite loop - extracts subtitles to temp dir during plugin stack.
v4.9: Refactored for better maintainability - extracted helper functions.
`,
Version: '4.11',
Tags: 'action,subtitles,srt,extract,organize,language',
Inputs: [
{
name: 'includeAudio',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: [
'true*',
'false'
],
},
tooltip: 'Enable to reorder audio streams, putting English audio first',
},
{
name: 'includeSubtitles',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: [
'true*',
'false'
],
},
tooltip: 'Enable to reorder subtitle streams, putting English subtitles first',
},
{
name: 'standardizeToSRT',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: [
'true*',
'false'
],
},
tooltip: 'Convert text-based subtitles (ASS/SSA/WebVTT) to SRT format. Image subtitles (PGS/VobSub) will be copied.',
},
{
name: 'extractSubtitles',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: [
'false',
'true'
],
},
tooltip: 'Extract subtitle streams to external .srt files alongside the video',
},
{
name: 'removeAfterExtract',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: [
'false',
'true'
],
},
tooltip: 'Remove embedded subtitles after extracting them (only applies if Extract is enabled)',
},
{
name: 'skipCommentary',
type: 'string',
defaultValue: 'true*',
inputUI: {
type: 'dropdown',
options: [
'true*',
'false'
],
},
tooltip: 'Skip extracting subtitles with "commentary" or "description" in the title',
},
{
name: 'setDefaultFlags',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: [
'false',
'true'
],
},
tooltip: 'Set default disposition flag on first English audio and subtitle streams',
},
{
name: 'customLanguageCodes',
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 consider as priority (max 20 codes). Default includes common English codes.',
},
{
name: 'useCCExtractor',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: [
'false',
'true'
],
},
tooltip: 'If enabled, attempts to extract closed captions (eia_608/cc_dec) to external SRT via ccextractor when present.',
},
{
name: 'embedExtractedCC',
type: 'string',
defaultValue: 'false',
inputUI: {
type: 'dropdown',
options: [
'false',
'true'
],
},
tooltip: 'If enabled, will map the newly extracted CC SRT back into the output container.',
},
],
});
// ============================================================================
// CONSTANTS
// ============================================================================
const TEXT_SUBTITLE_CODECS = new Set(['ass', 'ssa', 'webvtt', 'mov_text', 'text', 'subrip']);
const IMAGE_SUBTITLE_CODECS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
const PROBLEMATIC_CODECS = new Set(['webvtt']);
const UNSUPPORTED_SUBTITLE_CODECS = new Set(['eia_608', 'cc_dec', 'tx3g']);
const VALID_BOOLEAN_VALUES = ['true', 'false'];
const MAX_LANGUAGE_CODES = 20;
const MIN_SUBTITLE_FILE_SIZE = 100; // bytes - minimum size for valid subtitle file
const MAX_EXTRACTION_ATTEMPTS = 3; // maximum attempts to extract a subtitle before giving up
const MAX_FILENAME_ATTEMPTS = 100; // maximum attempts to find unique filename
// ============================================================================
// HELPER PREDICATES
// ============================================================================
const isUnsupportedSubtitle = (stream) => {
const name = (stream.codec_name || '').toLowerCase();
const tag = (stream.codec_tag_string || '').toLowerCase();
return UNSUPPORTED_SUBTITLE_CODECS.has(name) || UNSUPPORTED_SUBTITLE_CODECS.has(tag);
};
const isClosedCaption = (stream) => {
const name = (stream.codec_name || '').toLowerCase();
const tag = (stream.codec_tag_string || '').toLowerCase();
return name === 'eia_608' || name === 'cc_dec' || tag === 'cc_dec';
};
const isEnglishStream = (stream, englishCodes) => {
const language = stream.tags?.language?.toLowerCase();
return language && englishCodes.includes(language);
};
const isTextSubtitle = (stream) => TEXT_SUBTITLE_CODECS.has(stream.codec_name);
const needsSRTConversion = (stream) => isTextSubtitle(stream) && stream.codec_name !== 'subrip';
const isProblematicSubtitle = (stream) => PROBLEMATIC_CODECS.has(stream.codec_name);
const shouldSkipSubtitle = (stream, skipCommentary) => {
if (skipCommentary !== 'true') return false;
const title = stream.tags?.title?.toLowerCase() || '';
return title.includes('commentary') || title.includes('description');
};
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
const stripStar = (value) => {
if (typeof value === 'string') {
return value.replace(/\*/g, '');
}
return value;
};
const sanitizeForShell = (str) => {
if (typeof str !== 'string') {
throw new TypeError('Input must be a string');
}
str = str.replace(/\0/g, '');
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
};
const sanitizeFilename = (name, maxLength = 100) => {
const path = require('path');
if (typeof name !== 'string') {
return 'file';
}
name = path.basename(name);
name = name.replace(/[<>:"|?*\/\\\x00-\x1f\x7f]/g, '_');
name = name.replace(/^[\.\s]+|[\.\s]+$/g, '');
if (name.length === 0) {
name = 'file';
}
if (name.length > maxLength) {
const ext = path.extname(name);
const base = path.basename(name, ext);
name = base.substring(0, maxLength - ext.length) + ext;
}
return name;
};
const validateLanguageCodes = (codesString, maxCodes = 20) => {
if (typeof codesString !== 'string') {
return [];
}
return codesString
.split(',')
.map(code => code.trim().toLowerCase())
.filter(code => {
if (code.length === 0 || code.length > 10) return false;
if (!/^[a-z0-9-]+$/.test(code)) return false;
if (code.includes('..') || code.includes('/')) return false;
return true;
})
.slice(0, maxCodes);
};
const buildSafeBasePath = (filePath) => {
const path = require('path');
const parsed = path.parse(filePath);
return path.join(parsed.dir, parsed.name);
};
const fileExistsRobust = (filePath, fs) => {
try {
const stats = fs.statSync(filePath);
return stats.size > 0;
} catch (e) {
if (e.code === 'ENOENT') {
return false;
}
throw new Error(`Error checking file existence for ${filePath}: ${e.message}`);
}
};
const needsSubtitleExtraction = (subsFile, sourceFile, fs) => {
if (!fileExistsRobust(subsFile, fs)) {
return true;
}
try {
const subsStats = fs.statSync(subsFile);
if (subsStats.size < MIN_SUBTITLE_FILE_SIZE) {
return true;
}
return false;
} catch (e) {
return true;
}
};
// ============================================================================
// STREAM ANALYSIS FUNCTIONS
// ============================================================================
/**
* Partitions streams into matched and unmatched based on predicate
*/
const partitionStreams = (streams, predicate) => {
const matched = [];
const unmatched = [];
streams.forEach(s => (predicate(s) ? matched : unmatched).push(s));
return [matched, unmatched];
};
/**
* Categorizes and enriches streams from ffProbeData
*/
const categorizeStreams = (file) => {
const streams = file.ffProbeData.streams.map((stream, index) => ({
...stream,
typeIndex: index
}));
const videoStreams = streams.filter(s => s.codec_type === 'video');
const audioStreams = streams.filter(s => s.codec_type === 'audio');
const subtitleStreams = streams.filter(s => s.codec_type === 'subtitle');
const otherStreams = streams
.filter(s => !['video', 'audio', 'subtitle'].includes(s.codec_type))
.filter(stream => {
// Filter out BMP attached pictures (incompatible with MKV)
if (stream.disposition?.attached_pic === 1 && stream.codec_name === 'bmp') {
return false;
}
return true;
});
return {
all: streams,
original: streams.map(s => s.typeIndex),
video: videoStreams,
audio: audioStreams,
subtitle: subtitleStreams,
other: otherStreams
};
};
/**
* Reorders audio and subtitle streams by language priority
*/
const reorderStreamsByLanguage = (categorized, inputs, customEnglishCodes) => {
let reorderedAudio, reorderedSubtitles;
if (inputs.includeAudio === 'true') {
const [englishAudio, otherAudio] = partitionStreams(
categorized.audio,
s => isEnglishStream(s, customEnglishCodes)
);
reorderedAudio = [...englishAudio, ...otherAudio];
} else {
reorderedAudio = categorized.audio;
}
if (inputs.includeSubtitles === 'true') {
const [englishSubtitles, otherSubtitles] = partitionStreams(
categorized.subtitle,
s => isEnglishStream(s, customEnglishCodes)
);
reorderedSubtitles = [...englishSubtitles, ...otherSubtitles];
} else {
reorderedSubtitles = categorized.subtitle;
}
const reorderedStreams = [
...categorized.video,
...reorderedAudio,
...reorderedSubtitles,
...categorized.other
];
return {
reorderedStreams,
reorderedAudio,
reorderedSubtitles,
newOrder: reorderedStreams.map(s => s.typeIndex),
needsReorder: JSON.stringify(categorized.original) !== JSON.stringify(reorderedStreams.map(s => s.typeIndex))
};
};
/**
* Analyzes subtitle streams for conversion needs
*/
const analyzeSubtitleConversion = (subtitleStreams, inputs) => {
let needsConversion = false;
let conversionCount = 0;
const hasProblematicSubs = subtitleStreams.some(isProblematicSubtitle);
if (inputs.standardizeToSRT === 'true' || hasProblematicSubs) {
subtitleStreams.forEach(stream => {
if (!stream.codec_name) return;
if (isUnsupportedSubtitle(stream)) return;
if (needsSRTConversion(stream)) {
needsConversion = true;
conversionCount++;
}
});
}
return {
needsConversion,
conversionCount,
hasProblematicSubs
};
};
// ============================================================================
// SUBTITLE EXTRACTION FUNCTIONS
// ============================================================================
/**
* Processes subtitle extraction - returns extraction command and metadata
*/
const processSubtitleExtraction = (subtitleStreams, inputs, otherArguments, file, fs, path, infoLog) => {
let extractCommand = '';
let extractCount = 0;
const extractedFiles = new Set();
const extractionAttempts = new Map();
if (inputs.extractSubtitles !== 'true' || subtitleStreams.length === 0) {
return { extractCommand, extractCount, extractedFiles, extractionAttempts, infoLog };
}
const { originalLibraryFile } = otherArguments;
if (!originalLibraryFile?.file) {
infoLog += '⚠️ Cannot extract subs: originalLibraryFile not available. ';
return { extractCommand, extractCount, extractedFiles, extractionAttempts, infoLog };
}
const baseFile = file.file;
const baseName = buildSafeBasePath(baseFile);
for (const stream of subtitleStreams) {
if (!stream.codec_name) {
infoLog += ` Skipping subtitle ${stream.typeIndex} (no codec). `;
continue;
}
if (isUnsupportedSubtitle(stream)) {
infoLog += ` Skipping subtitle ${stream.typeIndex} (${stream.codec_name}) incompatible. `;
continue;
}
if (IMAGE_SUBTITLE_CODECS.has(stream.codec_name)) {
infoLog += ` Skipping bitmap subtitle ${stream.typeIndex} (${stream.codec_name}) - cannot extract to SRT. `;
continue;
}
if (shouldSkipSubtitle(stream, inputs.skipCommentary)) {
const title = stream.tags?.title || 'unknown';
infoLog += ` Skipping ${title}. `;
continue;
}
const lang = stream.tags?.language || 'unknown';
const safeLang = sanitizeFilename(lang).substring(0, 20);
let subsFile = `${baseName}.${safeLang}.srt`;
let counter = 1;
while (extractedFiles.has(subsFile) && counter < MAX_FILENAME_ATTEMPTS) {
subsFile = `${baseName}.${safeLang}.${counter}.srt`;
counter++;
}
if (needsSubtitleExtraction(subsFile, baseFile, fs)) {
const attemptKey = `${baseFile}:${stream.typeIndex}`;
const attempts = extractionAttempts.get(attemptKey) || 0;
if (attempts >= MAX_EXTRACTION_ATTEMPTS) {
infoLog += `⚠️ Skipping ${path.basename(subsFile)} - extraction failed ${MAX_EXTRACTION_ATTEMPTS} times. `;
continue;
}
extractionAttempts.set(attemptKey, attempts + 1);
const safeSubsFile = sanitizeForShell(subsFile);
extractCommand += ` -map 0:${stream.typeIndex} ${safeSubsFile}`;
extractedFiles.add(subsFile);
extractCount++;
} else {
infoLog += ` ${path.basename(subsFile)} already exists, skipping. `;
}
}
if (extractCount > 0) {
infoLog += `✅ Extracting ${extractCount} subtitle(s). `;
}
return { extractCommand, extractCount, extractedFiles, extractionAttempts, infoLog };
};
/**
* Processes CC extraction via ccextractor
*/
const processCCExtraction = (subtitleStreams, inputs, otherArguments, fs, infoLog) => {
let ccExtractedFile = null;
let ccActuallyExtracted = false;
if (inputs.useCCExtractor !== 'true' || !subtitleStreams.some(isClosedCaption)) {
return { ccExtractedFile, ccActuallyExtracted, infoLog };
}
const { originalLibraryFile } = otherArguments;
if (!originalLibraryFile?.file) {
infoLog += '⚠️ Cannot extract CC: originalLibraryFile not available. ';
return { ccExtractedFile, ccActuallyExtracted, infoLog };
}
const baseFile = originalLibraryFile.file;
const baseName = buildSafeBasePath(baseFile);
const ccOut = `${baseName}.cc.srt`;
const ccLock = `${ccOut}.lock`;
const ccFileExists = fileExistsRobust(ccOut, fs);
try {
fs.writeFileSync(ccLock, process.pid.toString(), { flag: 'wx' });
try {
if (ccFileExists) {
infoLog += ' CC file exists. ';
if (inputs.embedExtractedCC === 'true') {
ccExtractedFile = ccOut;
ccActuallyExtracted = false;
}
} else {
ccExtractedFile = ccOut;
ccActuallyExtracted = true;
infoLog += '✅ Will extract CC via ccextractor. ';
}
} finally {
if (!ccActuallyExtracted && fs.existsSync(ccLock)) {
fs.unlinkSync(ccLock);
}
}
} catch (e) {
if (e.code === 'EEXIST') {
infoLog += '⏭️ CC extraction in progress by another worker. ';
if (ccFileExists && inputs.embedExtractedCC === 'true') {
ccExtractedFile = ccOut;
ccActuallyExtracted = false;
}
} else if (e.code === 'EACCES' || e.code === 'EPERM') {
throw new Error(`CC extraction failed: Permission denied - ${e.message}`);
} else {
infoLog += `⚠️ CC lock error: ${e.message}. `;
}
}
return { ccExtractedFile, ccActuallyExtracted, infoLog };
};
// ============================================================================
// FFMPEG COMMAND BUILDING FUNCTIONS
// ============================================================================
/**
* Checks if any processing is needed
*/
const needsProcessing = (needsReorder, needsConversion, extractCount, ccActuallyExtracted, ccExtractedFile, embedExtractedCC, removeAfterExtract) => {
return needsReorder || needsConversion || extractCount > 0 || ccActuallyExtracted || (ccExtractedFile && embedExtractedCC === 'true') || (extractCount > 0 && removeAfterExtract === 'true');
};
/**
* Checks if the container itself needs to be modified (requires requeue)
* Extraction-only operations don't modify the container and don't need requeue
*/
const needsContainerModification = (needsReorder, needsConversion, extractCount, removeAfterExtract, ccActuallyExtracted, ccExtractedFile, embedExtractedCC) => {
// Container is modified when:
// - Streams need reordering
// - Subtitles need conversion (ASS/SSA/WebVTT -> SRT)
// - Embedded subs are being removed after extraction
// - CC is being extracted AND embedded back
// - Existing CC file is being embedded
return needsReorder ||
needsConversion ||
(extractCount > 0 && removeAfterExtract === 'true') ||
ccActuallyExtracted ||
(ccExtractedFile && embedExtractedCC === 'true');
};
/**
* Builds FFmpeg command for stream mapping and subtitle processing
*/
const buildFFmpegCommand = (analysis, inputs, customEnglishCodes) => {
const {
reorderedStreams,
needsConversion,
conversionCount,
hasProblematicSubs,
extractCommand,
extractCount,
ccExtractedFile,
ccActuallyExtracted
} = analysis;
let command = (inputs.extractSubtitles === 'true' && extractCount > 0) ? '-y <io>' : '<io>';
command += extractCommand;
if (inputs.removeAfterExtract === 'true' && inputs.extractSubtitles === 'true' && extractCount > 0) {
// Note: This message is added to infoLog outside this function
}
command += ' -c:v copy -c:a copy';
const includedSubtitleStreams = [];
let firstEnglishAudioIdx = null;
let firstEnglishSubIdx = null;
let audioOutputIdx = 0;
let subOutputIdx = 0;
// Build stream mapping
reorderedStreams.forEach(stream => {
if (inputs.extractSubtitles === 'true' && inputs.removeAfterExtract === 'true' && stream.codec_type === 'subtitle') {
return;
}
if (stream.codec_type !== 'subtitle') {
command += ` -map 0:${stream.typeIndex}`;
if (stream.codec_type === 'audio' && firstEnglishAudioIdx === null && isEnglishStream(stream, customEnglishCodes)) {
firstEnglishAudioIdx = audioOutputIdx;
}
if (stream.codec_type === 'audio') {
audioOutputIdx++;
}
return;
}
if (!stream.codec_name) {
return;
}
if (isUnsupportedSubtitle(stream)) {
return;
}
includedSubtitleStreams.push(stream);
command += ` -map 0:${stream.typeIndex}`;
if (firstEnglishSubIdx === null && isEnglishStream(stream, customEnglishCodes)) {
firstEnglishSubIdx = subOutputIdx;
}
subOutputIdx++;
});
// Build codec arguments for subtitles
const allIncludedAreText = includedSubtitleStreams.length > 0 &&
includedSubtitleStreams.every(s => TEXT_SUBTITLE_CODECS.has(s.codec_name));
const shouldConvertToSRT = (inputs.standardizeToSRT === 'true' || hasProblematicSubs) && allIncludedAreText;
if (includedSubtitleStreams.length > 0) {
if (shouldConvertToSRT) {
command += ' -c:s srt';
} else if (inputs.standardizeToSRT === 'true' && !allIncludedAreText) {
includedSubtitleStreams.forEach((stream, idx) => {
if (isTextSubtitle(stream) && stream.codec_name !== 'subrip') {
command += ` -c:s:${idx} srt`;
} else {
command += ` -c:s:${idx} copy`;
}
});
} else if (hasProblematicSubs && !allIncludedAreText) {
includedSubtitleStreams.forEach((stream, idx) => {
if (isProblematicSubtitle(stream)) {
command += ` -c:s:${idx} srt`;
} else {
command += ` -c:s:${idx} copy`;
}
});
} else {
command += ' -c:s copy';
}
}
// Set default flags
if (inputs.setDefaultFlags === 'true') {
if (firstEnglishAudioIdx !== null) {
command += ` -disposition:a:${firstEnglishAudioIdx} default`;
}
if (firstEnglishSubIdx !== null) {
command += ` -disposition:s:${firstEnglishSubIdx} default`;
}
}
// Embed CC if needed
if (ccExtractedFile && inputs.embedExtractedCC === 'true') {
const fs = require('fs');
if (ccActuallyExtracted || fs.existsSync(ccExtractedFile)) {
const safeCCFile = sanitizeForShell(ccExtractedFile);
const newSubIdx = includedSubtitleStreams.length;
command += ` -i "${safeCCFile}" -map 1:0 -c:s:${newSubIdx} srt`;
command += ` -metadata:s:s:${newSubIdx} language=eng`;
command += ` -metadata:s:s:${newSubIdx} title="Closed Captions"`;
}
}
return {
command,
firstEnglishAudioIdx,
firstEnglishSubIdx,
includedSubtitleCount: includedSubtitleStreams.length
};
};
/**
* Builds CC extraction command wrapper
*/
const buildCCExtractionCommand = (command, ccExtractedFile, otherArguments) => {
const { originalLibraryFile } = otherArguments;
const sourceFile = (originalLibraryFile?.file) || '';
const baseName = buildSafeBasePath(sourceFile);
const ccLock = `${baseName}.cc.srt.lock`;
const safeInput = sanitizeForShell(sourceFile);
const safeCCFile = sanitizeForShell(ccExtractedFile);
const safeLock = sanitizeForShell(ccLock);
const cleanupCmd = `rm -f ${safeLock}`;
const ccCmd = `ccextractor ${safeInput} -o ${safeCCFile}`;
return `${ccCmd}; ${cleanupCmd}; ${command}`;
};
// ============================================================================
// MAIN PLUGIN FUNCTION
// ============================================================================
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const fs = require('fs');
const path = require('path');
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: true,
reQueueAfter: false,
infoLog: '',
};
try {
inputs = lib.loadDefaultValues(inputs, details);
// Sanitize starred defaults
Object.keys(inputs).forEach(key => {
inputs[key] = stripStar(inputs[key]);
});
// Validate inputs
const validateInputs = (inputs) => {
const errors = [];
const booleanInputs = [
'includeAudio', 'includeSubtitles', 'standardizeToSRT',
'extractSubtitles', 'removeAfterExtract', 'skipCommentary',
'setDefaultFlags', 'useCCExtractor', 'embedExtractedCC'
];
for (const input of booleanInputs) {
if (!VALID_BOOLEAN_VALUES.includes(inputs[input])) {
errors.push(`Invalid ${input} value - must be "true" or "false"`);
}
}
return errors;
};
const validationErrors = validateInputs(inputs);
if (validationErrors.length > 0) {
response.infoLog += '❌ Input validation errors:\n';
validationErrors.forEach(error => {
response.infoLog += ` - ${error}\n`;
});
response.processFile = false;
return response;
}
// Validate language codes
let customEnglishCodes = validateLanguageCodes(inputs.customLanguageCodes, MAX_LANGUAGE_CODES);
if (customEnglishCodes.length === 0) {
customEnglishCodes = ['eng', 'en', 'english', 'en-us', 'en-gb', 'en-ca', 'en-au'];
}
if (!Array.isArray(file.ffProbeData?.streams)) {
throw new Error('FFprobe was unable to extract any streams info on this file.');
}
// Categorize and reorder streams
const categorized = categorizeStreams(file);
const reorderResult = reorderStreamsByLanguage(categorized, inputs, customEnglishCodes);
// Log English stream counts
if (inputs.includeAudio === 'true') {
const englishAudioCount = categorized.audio.filter(s => isEnglishStream(s, customEnglishCodes)).length;
if (englishAudioCount > 0) {
response.infoLog += `${englishAudioCount} English audio first. `;
}
}
if (inputs.includeSubtitles === 'true') {
const englishSubCount = categorized.subtitle.filter(s => isEnglishStream(s, customEnglishCodes)).length;
if (englishSubCount > 0) {
response.infoLog += `${englishSubCount} English subs first. `;
}
}
// Filter BMP message
if (categorized.other.length < file.ffProbeData.streams.filter(s => !['video', 'audio', 'subtitle'].includes(s.codec_type)).length) {
response.infoLog += ' Excluding BMP attached picture (unsupported in MKV). ';
}
// Analyze subtitle conversion needs
const conversionAnalysis = analyzeSubtitleConversion(categorized.subtitle, inputs);
// Process subtitle extraction
const extractionResult = processSubtitleExtraction(
categorized.subtitle,
inputs,
otherArguments,
file,
fs,
path,
response.infoLog
);
response.infoLog = extractionResult.infoLog;
// Process CC extraction
const ccResult = processCCExtraction(
categorized.subtitle,
inputs,
otherArguments,
fs,
response.infoLog
);
response.infoLog = ccResult.infoLog;
// Check if processing is needed
if (!needsProcessing(
reorderResult.needsReorder,
conversionAnalysis.needsConversion,
extractionResult.extractCount,
ccResult.ccActuallyExtracted,
ccResult.ccExtractedFile,
inputs.embedExtractedCC,
inputs.removeAfterExtract
)) {
response.infoLog += '✅ No changes needed.';
return response;
}
response.processFile = true;
// Only requeue if container is being modified
// Extraction-only (without removal) doesn't modify the container
const containerModified = needsContainerModification(
reorderResult.needsReorder,
conversionAnalysis.needsConversion,
extractionResult.extractCount,
inputs.removeAfterExtract,
ccResult.ccActuallyExtracted,
ccResult.ccExtractedFile,
inputs.embedExtractedCC
);
response.reQueueAfter = containerModified;
if (reorderResult.needsReorder) {
response.infoLog += '✅ Reordering streams. ';
}
if (conversionAnalysis.needsConversion) {
if (conversionAnalysis.hasProblematicSubs && inputs.standardizeToSRT !== 'true') {
response.infoLog += `✅ Converting ${conversionAnalysis.conversionCount} WebVTT to SRT (compatibility). `;
} else {
response.infoLog += `✅ Converting ${conversionAnalysis.conversionCount} to SRT. `;
}
}
if (inputs.removeAfterExtract === 'true' && inputs.extractSubtitles === 'true' && extractionResult.extractCount > 0) {
response.infoLog += '✅ Removing embedded subs. ';
}
// Build FFmpeg command
const commandResult = buildFFmpegCommand({
reorderedStreams: reorderResult.reorderedStreams,
needsConversion: conversionAnalysis.needsConversion,
conversionCount: conversionAnalysis.conversionCount,
hasProblematicSubs: conversionAnalysis.hasProblematicSubs,
extractCommand: extractionResult.extractCommand,
extractCount: extractionResult.extractCount,
ccExtractedFile: ccResult.ccExtractedFile,
ccActuallyExtracted: ccResult.ccActuallyExtracted
}, inputs, customEnglishCodes);
// Set response preset
if (ccResult.ccActuallyExtracted) {
response.preset = buildCCExtractionCommand(
commandResult.command,
ccResult.ccExtractedFile,
otherArguments
);
response.infoLog += ' CC extraction will run before main command. ';
} else {
response.preset = commandResult.command;
}
// Add final flags info
if (inputs.setDefaultFlags === 'true') {
if (commandResult.firstEnglishAudioIdx !== null) {
response.infoLog += `✅ Set default flag on English audio. `;
}
if (commandResult.firstEnglishSubIdx !== null) {
response.infoLog += `✅ Set default flag on English subtitle. `;
}
}
if (ccResult.ccExtractedFile && inputs.embedExtractedCC === 'true') {
if (ccResult.ccActuallyExtracted || fs.existsSync(ccResult.ccExtractedFile)) {
response.infoLog += '✅ Embedding extracted CC. ';
} else {
response.infoLog += '⚠️ CC file not found, skipping embed. ';
}
}
return response;
} catch (error) {
response.processFile = false;
response.preset = '';
response.reQueueAfter = false;
response.infoLog = `💥 Plugin error: ${error.message}\n`;
if (error.stack) {
const stackLines = error.stack.split('\n').slice(0, 5).join('\n');
response.infoLog += `Stack trace:\n${stackLines}\n`;
}
response.infoLog += `File: ${file.file}\n`;
response.infoLog += `Container: ${file.container}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;

View File

@@ -0,0 +1,567 @@
/* 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: '1.4',
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;

View File

@@ -0,0 +1,304 @@
/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_01_container_remux',
Stage: 'Pre-processing',
Name: '01 - Container Remux',
Type: 'Video',
Operation: 'Transcode',
Description: `
Remuxes video files to target container (MKV/MP4).
Applies timestamp fixes for problematic formats (TS/AVI/MPG/XviD/DivX).
Also detects XviD/DivX/MPEG-4 video with packed bitstreams that cause timestamp issues.
Optional audio recovery for TS files with broken audio streams.
MPG re-encoding fallback for severely broken timestamp issues.
**Single Responsibility**: Container format only. No stream modifications.
Should be placed FIRST in your plugin stack.
`,
Version: '2.3',
Tags: 'action,ffmpeg,ts,remux,container,avi,xvid,divx',
Inputs: [
{
name: 'target_container',
type: 'string',
defaultValue: 'mkv',
inputUI: { type: 'dropdown', options: ['mkv', 'mp4'] },
tooltip: 'Target container format. MKV supports all codecs/subs, MP4 has wider device compatibility.',
},
{
name: 'fix_timestamps',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Apply timestamp fixes for legacy formats (TS/AVI/MPG/XviD/DivX/MPEG-4). Uses -fflags +genpts.',
},
{
name: 'avi_reencode_fallback',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'AVI files: Re-encode video instead of copy to fix broken timestamps. Uses libx264 CRF 18.',
},
{
name: 'mpg_reencode_fallback',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'MPG/MPEG files: Re-encode video to fix severe timestamp issues. Uses libx264 CRF 18.',
},
{
name: 'xvid_reencode_fallback',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'XviD/DivX packed bitstream files: Re-encode video to fix severe timestamp corruption.',
},
{
name: 'ts_audio_recovery',
type: 'string',
defaultValue: 'false',
inputUI: { type: 'dropdown', options: ['false', 'true'] },
tooltip: 'TS files only: Transcode audio to AAC for compatibility. Use when TS audio is corrupt.',
},
],
});
// Constants
const VALID_CONTAINERS = new Set(['mkv', 'mp4']);
const BOOLEAN_INPUTS = ['fix_timestamps', 'avi_reencode_fallback', 'mpg_reencode_fallback', 'xvid_reencode_fallback', 'ts_audio_recovery'];
const TIMESTAMP_CONTAINERS = new Set(['ts', 'mpegts', 'avi', 'mpg', 'mpeg', 'm2ts']);
const TS_CONTAINERS = new Set(['ts', 'mpegts']);
const MPG_CONTAINERS = new Set(['mpg', 'mpeg', 'vob']);
const SKIP_CONTAINERS = new Set(['iso', 'vob', 'evo']);
const XVID_TAGS = new Set(['XVID', 'DIVX', 'DX50', 'DIV3', 'DIV4', 'DIV5', 'FMP4', 'MP4V', 'MP42', 'MP43']);
const MSMPEG4_CODECS = new Set(['msmpeg4v1', 'msmpeg4v2', 'msmpeg4v3', 'msmpeg4']);
const DVD_SUB_CODECS = new Set(['dvd_subtitle', 'dvdsub']);
// Subtitle codec compatibility
const MKV_INCOMPATIBLE_SUBS = new Set(['mov_text', 'tx3g']);
const MP4_TEXT_SUBS = new Set(['subrip', 'srt', 'ass', 'ssa', 'webvtt', 'vtt']);
const IMAGE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
// Utilities
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
/**
* Detects XviD/DivX/MPEG-4 codec-level timestamp issues.
*/
const detectXvidIssues = (streams) => {
const video = streams.find((s) => s.codec_type === 'video');
if (!video) return null;
const codec = (video.codec_name || '').toLowerCase();
const tag = (video.codec_tag_string || '').toUpperCase();
if (video.divx_packed === 'true' || video.divx_packed === true) {
return 'XviD/DivX packed bitstream';
}
if (codec === 'mpeg4' && XVID_TAGS.has(tag)) {
return `MPEG-4/${tag}`;
}
if (MSMPEG4_CODECS.has(codec)) {
return 'MSMPEG4';
}
return null;
};
/**
* Check if TS/M2TS file has unrecoverable corrupt streams.
*/
const hasCorruptStreams = (streams) => streams.some((s) => {
if (s.codec_type === 'audio' && s.channels === 0) return true;
if (s.codec_type === 'video' && !s.duration && !s.duration_ts) return true;
return false;
});
/**
* Check if file has DVD subtitles (which have timestamp issues).
*/
const hasDvdSubtitles = (streams) => streams.some(
(s) => s.codec_type === 'subtitle' && DVD_SUB_CODECS.has(s.codec_name)
);
/**
* POLICY: MP4 incompatible with all subtitles, MKV subtitle conversion handled by Plugin 04.
* This function now only detects if subtitles exist.
* Optimized: Single pass through streams instead of filter + map.
*/
const hasSubtitles = (streams) => {
const subtitleCodecs = [];
for (const s of streams) {
if (s.codec_type === 'subtitle') {
subtitleCodecs.push(s.codec_name || 'unknown');
}
}
return {
hasSubtitles: subtitleCodecs.length > 0,
subtitleCount: subtitleCodecs.length,
subtitleCodecs,
};
};
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
inputs = lib.loadDefaultValues(inputs, details);
Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); });
// Validate container
if (!VALID_CONTAINERS.has(inputs.target_container)) {
response.infoLog = `❌ Invalid target_container: ${inputs.target_container}. `;
return response;
}
// Validate and normalize boolean inputs
for (const key of BOOLEAN_INPUTS) {
const val = String(inputs[key]).toLowerCase();
if (val !== 'true' && val !== 'false') {
response.infoLog = `❌ Invalid ${key}: must be true or false. `;
return response;
}
inputs[key] = val === 'true';
}
const streams = file.ffProbeData?.streams;
if (!Array.isArray(streams)) {
response.infoLog = '❌ No stream data available. ';
return response;
}
const targetContainer = inputs.target_container;
const currentContainer = file.container.toLowerCase();
const containerNeedsChange = currentContainer !== targetContainer;
// Skip unsupported formats
if (SKIP_CONTAINERS.has(currentContainer)) {
response.infoLog = '⚠️ ISO/DVD files require manual conversion with HandBrake or MakeMKV. Skipping.\n';
return response;
}
// Determine what fixes are needed
const isTS = TS_CONTAINERS.has(currentContainer);
const isMPG = MPG_CONTAINERS.has(currentContainer);
const xvidIssue = inputs.fix_timestamps ? detectXvidIssues(streams) : null;
const containerNeedsTimestampFix = inputs.fix_timestamps && TIMESTAMP_CONTAINERS.has(currentContainer);
const needsTimestampFix = containerNeedsTimestampFix || xvidIssue;
const needsAudioRecovery = inputs.ts_audio_recovery && isTS;
// Early exit if nothing to do (optimization: check before expensive operations)
if (!containerNeedsChange && !needsTimestampFix && !needsAudioRecovery) {
response.infoLog = '✅ Container already correct, no fixes needed. ';
return response;
}
// Skip corrupt TS/M2TS files
if ((isTS || currentContainer === 'm2ts') && hasCorruptStreams(streams)) {
response.infoLog = '⚠️ TS file has corrupt streams with unfixable timestamp issues. Skipping.\n';
return response;
}
// Build FFmpeg command parts
const cmdParts = [];
let codecFlags = '-c copy';
const logs = [];
// Timestamp fixes
if (needsTimestampFix) {
if (isTS) {
cmdParts.push('-fflags +genpts+igndts -avoid_negative_ts make_zero -start_at_zero');
logs.push('🔧 Applying TS timestamp fixes.');
} else if (currentContainer === 'avi' && inputs.avi_reencode_fallback) {
cmdParts.push('-fflags +genpts');
codecFlags = '-c:v libx264 -preset ultrafast -crf 18 -c:a aac -b:a 192k -c:s copy';
logs.push('🔧 AVI re-encode: Fixing timestamps via video re-encoding.');
} else if (xvidIssue && !containerNeedsTimestampFix && inputs.xvid_reencode_fallback) {
cmdParts.push('-fflags +genpts');
codecFlags = '-c:v libx264 -preset ultrafast -crf 18 -c:a copy -c:s copy';
logs.push(`🔧 Detected ${xvidIssue}. Re-encoding video to fix timestamps.`);
} else {
cmdParts.push('-fflags +genpts');
logs.push(`🔧 Applying ${currentContainer.toUpperCase()} timestamp fixes.`);
}
}
// MPG re-encoding (if enabled)
if (isMPG && inputs.mpg_reencode_fallback) {
codecFlags = '-c:v libx264 -preset ultrafast -crf 18 -c:a copy -c:s copy';
logs.push('🔧 MPG re-encode: Fixing timestamps via video re-encoding.');
}
// TS audio recovery
if (needsAudioRecovery) {
const firstAudio = streams.find((s) => s.codec_type === 'audio');
const channels = firstAudio?.channels || 2;
const bitrate = channels > 2 ? '384k' : '192k';
codecFlags = `-c:v copy -c:a aac -b:a ${bitrate} -c:s copy`;
logs.push(`🎧 TS audio recovery: ${channels}ch → AAC ${bitrate}.`);
}
// POLICY: Skip all subtitle streams in container remux
// - MP4: Drops all subtitles (MP4 considered incompatible per policy)
// - MKV: Lets Plugin 04 (Subtitle Conversion) handle subtitle processing
const subInfo = hasSubtitles(streams);
// Stream mapping: video, audio, and subtitles (data streams dropped)
cmdParts.push('-map 0:v -map 0:a? -map 0:s?');
if (subInfo.hasSubtitles) {
logs.push(` Detected ${subInfo.subtitleCount} subtitle stream(s) (Compatibility handled by downstream plugins).`);
}
cmdParts.push(codecFlags, '-max_muxing_queue_size 9999');
// Final response
response.preset = `<io> ${cmdParts.join(' ')}`;
response.container = `.${targetContainer}`;
response.processFile = true;
if (containerNeedsChange) {
logs.push(`✅ Remuxing ${currentContainer.toUpperCase()}${targetContainer.toUpperCase()}.`);
} else {
logs.push('✅ Applying fixes (container unchanged).');
}
response.infoLog = logs.join(' ');
// Final Summary block
response.infoLog += '\n\n📋 Final Processing Summary:\n';
response.infoLog += ` Target container: ${targetContainer.toUpperCase()}\n`;
if (containerNeedsChange) response.infoLog += ` - Container remux: ${currentContainer.toUpperCase()}${targetContainer.toUpperCase()}\n`;
if (needsTimestampFix) response.infoLog += ` - Timestamp fixes applied\n`;
if (needsAudioRecovery) response.infoLog += ` - TS audio recovery enabled\n`;
if (subInfo.hasSubtitles) {
if (targetContainer === 'mp4') {
response.infoLog += ` - Subtitles: ${subInfo.subtitleCount} dropped (MP4 incompatible)\n`;
} else {
response.infoLog += ` - Subtitles: ${subInfo.subtitleCount} skipped (Plugin 04 will handle)\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;

View File

@@ -0,0 +1,210 @@
/* 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: '1.7',
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;

View File

@@ -0,0 +1,324 @@
/* 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: '1.7',
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;

View File

@@ -0,0 +1,237 @@
/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_04_subtitle_conversion',
Stage: 'Pre-processing',
Name: '04 - Subtitle Conversion',
Type: 'Video',
Operation: 'Transcode',
Description: `
**Container-Aware** subtitle conversion for maximum compatibility.
- MKV target → Converts to SRT (universal text format)
- MP4 target → Converts to mov_text (native MP4 format)
Converts: ASS/SSA, WebVTT, mov_text, SRT (cross-converts as needed)
Image subtitles (PGS/VobSub) are copied as-is (cannot convert to text).
**Single Responsibility**: In-container subtitle codec conversion only.
Container is inherited from Plugin 01 (Container Remux).
Run AFTER stream ordering, BEFORE subtitle extraction.
`,
Version: '2.3',
Tags: 'action,ffmpeg,subtitles,srt,mov_text,convert,container-aware',
Inputs: [
{
name: 'enable_conversion',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Enable container-aware subtitle conversion (MKV→SRT, MP4→mov_text).',
},
{
name: 'always_convert_webvtt',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Always convert WebVTT regardless of other settings (problematic in most containers).',
},
],
});
// Constants - Set for O(1) lookup
const TEXT_SUBTITLES = new Set(['ass', 'ssa', 'webvtt', 'vtt', 'mov_text', 'text', 'subrip', 'srt']);
const IMAGE_SUBTITLES = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
const UNSUPPORTED_SUBTITLES = new Set(['eia_608', 'cc_dec', 'tx3g']);
const WEBVTT_CODECS = new Set(['webvtt', 'vtt']);
const BOOLEAN_INPUTS = ['enable_conversion', 'always_convert_webvtt'];
const CONTAINER_TARGET = {
mkv: 'srt',
mp4: 'mov_text',
m4v: 'mov_text',
mov: 'mov_text',
};
// Utilities
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
/**
* Get subtitle codec, handling edge cases (WebVTT in MKV may report codec_name as 'none').
*/
const getSubtitleCodec = (stream, file) => {
let codecName = (stream.codec_name || '').toLowerCase();
if (codecName && codecName !== 'none' && codecName !== 'unknown') return codecName;
// FFmpeg reports 'none' for some codecs it can't identify (like WEBVTT in MKV)
// Try metadata fallback using tags/codec_tag
const codecTag = (stream.codec_tag_string || '').toUpperCase();
if (codecTag.includes('WEBVTT')) return 'webvtt';
if (codecTag.includes('ASS')) return 'ass';
if (codecTag.includes('SSA')) return 'ssa';
const tagCodecId = (stream.tags?.CodecID || stream.tags?.codec_id || '').toUpperCase();
if (tagCodecId.includes('WEBVTT') || tagCodecId.includes('S_TEXT/WEBVTT')) return 'webvtt';
if (tagCodecId.includes('ASS') || tagCodecId.includes('S_TEXT/ASS')) return 'ass';
if (tagCodecId.includes('SSA') || tagCodecId.includes('S_TEXT/SSA')) return 'ssa';
// Try MediaInfo fallback
const miStreams = file?.mediaInfo?.track;
if (Array.isArray(miStreams)) {
const miStream = miStreams.find((t) => t['@type'] === 'Text' && t.StreamOrder == stream.index);
const miCodec = (miStream?.CodecID || '').toLowerCase();
if (miCodec.includes('webvtt')) return 'webvtt';
if (miCodec.includes('ass')) return 'ass';
if (miCodec.includes('ssa')) return 'ssa';
}
// Try ExifTool (meta) fallback
const meta = file?.meta;
if (meta) {
// ExifTool often provides codec information in the TrackLanguage or TrackName if everything else fails
const trackName = (stream.tags?.title || '').toLowerCase();
if (trackName.includes('webvtt')) return 'webvtt';
}
return codecName || 'unknown';
};
/**
* Normalize codec name for comparison.
*/
const normalizeCodec = (codec) => {
if (codec === 'srt' || codec === 'subrip') return 'srt';
if (codec === 'vtt' || codec === 'webvtt') return 'webvtt';
return codec;
};
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 container = (file.container || '').toLowerCase();
const targetCodec = CONTAINER_TARGET[container] || 'srt';
const targetDisplay = targetCodec === 'srt' ? 'SRT' : 'mov_text';
response.infoLog += `📦 ${container.toUpperCase()}${targetDisplay}. `;
const subtitleStreams = streams
.map((s, i) => ({ ...s, index: i }))
.filter((s) => s.codec_type === 'subtitle');
// Early exit optimization: no subtitles = nothing to do
if (subtitleStreams.length === 0) {
response.infoLog += '✅ No subtitle streams. ';
return response;
}
const toConvert = [];
const reasons = [];
subtitleStreams.forEach((stream) => {
const codec = getSubtitleCodec(stream, file);
const normalized = normalizeCodec(codec);
const streamDisplay = `Stream ${stream.index} (${codec.toUpperCase()})`;
// Skip unsupported formats
if (UNSUPPORTED_SUBTITLES.has(codec)) {
reasons.push(`${streamDisplay}: Unsupported format, skipping`);
return;
}
// Image-based formats: Copy as-is (cannot convert to text)
if (IMAGE_SUBTITLES.has(codec)) {
reasons.push(`${streamDisplay}: Image-based, copying as-is`);
return;
}
// Check if conversion to target is needed
if (!inputs.enable_conversion) {
// Still convert WebVTT if that option is enabled (special case for compatibility)
if (WEBVTT_CODECS.has(codec) && inputs.always_convert_webvtt) {
toConvert.push(stream);
reasons.push(`${streamDisplay}${targetDisplay} (special WebVTT rule)`);
} else {
reasons.push(`${streamDisplay}: Keeping original (conversion disabled)`);
}
return;
}
// WebVTT always converted if enabled
if (WEBVTT_CODECS.has(codec) && inputs.always_convert_webvtt) {
toConvert.push(stream);
reasons.push(`${streamDisplay}${targetDisplay}`);
return;
}
// Already in target format
if (normalized === normalizeCodec(targetCodec)) {
reasons.push(`${streamDisplay}: Already ${targetDisplay}, copying`);
return;
}
// Text subtitle that needs conversion
if (TEXT_SUBTITLES.has(codec)) {
toConvert.push(stream);
reasons.push(`${streamDisplay}${targetDisplay}`);
} else {
reasons.push(`${streamDisplay}: Unknown format, copying as-is`);
}
});
// Early exit optimization: all compatible = no conversion needed
if (toConvert.length === 0) {
response.infoLog += `✅ All ${subtitleStreams.length} subtitle(s) compatible. `;
return response;
}
// Build FFmpeg command
let command = '<io> -map 0 -c copy';
toConvert.forEach((s) => { command += ` -c:${s.index} ${targetCodec}`; });
command += ' -max_muxing_queue_size 9999';
response.preset = command;
response.processFile = true;
response.infoLog += `✅ Converting ${toConvert.length} subtitle(s):\n`;
reasons.forEach((r) => { response.infoLog += ` ${r}\n`; });
// Final Summary block
response.infoLog += '\n📋 Final Processing Summary:\n';
response.infoLog += ` Target format: ${targetDisplay}\n`;
response.infoLog += ` Total subtitles analyzed: ${subtitleStreams.length}\n`;
response.infoLog += ` Subtitles converted: ${toConvert.length}\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;

View File

@@ -0,0 +1,240 @@
/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_05_subtitle_extraction',
Stage: 'Pre-processing',
Name: '05 - Subtitle Extraction',
Type: 'Video',
Operation: 'Transcode',
Description: `
Extracts embedded subtitles to external .srt files.
- Optionally removes embedded subtitles after extraction
- Skips commentary/description tracks if configured
- Skips image-based subtitles (PGS/VobSub - cannot extract to SRT)
**Single Responsibility**: External file extraction only.
Run AFTER subtitle conversion.
`,
Version: '1.6',
Tags: 'action,ffmpeg,subtitles,srt,extract',
Inputs: [
{
name: 'extract_subtitles',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Extract embedded text subtitles to external .srt files.',
},
{
name: 'remove_after_extract',
type: 'string',
defaultValue: 'false',
inputUI: { type: 'dropdown', options: ['false', 'true'] },
tooltip: 'Remove embedded subtitles from container after extracting them.',
},
{
name: 'skip_commentary',
type: 'string',
defaultValue: 'true*',
inputUI: { type: 'dropdown', options: ['true*', 'false'] },
tooltip: 'Skip extracting subtitles with "commentary" or "description" in the title.',
},
{
name: 'extract_languages',
type: 'string',
defaultValue: '',
inputUI: { type: 'text' },
tooltip: 'Comma-separated language codes to extract. Empty = extract all.',
},
],
});
// Constants
const IMAGE_SUBTITLES = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
const UNSUPPORTED_SUBTITLES = new Set(['eia_608', 'cc_dec', 'tx3g']);
const MIN_SUBTITLE_SIZE = 100;
const MAX_FILENAME_ATTEMPTS = 100;
const BOOLEAN_INPUTS = ['extract_subtitles', 'remove_after_extract', 'skip_commentary'];
// Utilities
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
const sanitizeFilename = (name, maxLen = 50) => {
if (typeof name !== 'string') return 'file';
name = name.replace(/[<>:"|?*\/\\\x00-\x1f\x7f]/g, '_').replace(/^[.\s]+|[.\s]+$/g, '');
return name.length === 0 ? 'file' : name.substring(0, maxLen);
};
const sanitizeForShell = (str) => {
if (typeof str !== 'string') throw new TypeError('Input must be a string');
str = str.replace(/\0/g, '');
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
};
const fileExistsValid = (filePath, fs) => {
try { return fs.statSync(filePath).size > MIN_SUBTITLE_SIZE; }
catch { return false; }
};
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: true,
reQueueAfter: false,
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'; });
if (!inputs.extract_subtitles) {
response.infoLog = '✅ Subtitle extraction disabled. ';
return response;
}
const streams = file.ffProbeData?.streams;
if (!Array.isArray(streams)) {
response.infoLog = '❌ No stream data available. ';
return response;
}
// Parse language filter
const extractLangs = inputs.extract_languages
? new Set(inputs.extract_languages.split(',').map((l) => l.trim().toLowerCase()).filter(Boolean))
: null;
const subtitleStreams = streams
.map((s, i) => ({ ...s, index: i }))
.filter((s) => s.codec_type === 'subtitle');
if (subtitleStreams.length === 0) {
response.infoLog = '✅ No subtitle streams to extract. ';
return response;
}
// Detect cache cycle
const isInCache = (file._id || file.file).includes('-TdarrCacheFile-');
const stableId = (file._id || file.file).replace(/-TdarrCacheFile-[a-zA-Z0-9]+/, '');
const basePath = path.join(path.dirname(file.file), path.basename(stableId, path.extname(stableId)));
// Skip if in cache and NOT removing subtitles (prevents infinite loop)
if (isInCache && !inputs.remove_after_extract) {
response.infoLog = ' In cache cycle, skipping to prevent loop. ';
return response;
}
const extractedFiles = new Set();
const extractArgs = [];
const streamsToRemove = [];
for (const stream of subtitleStreams) {
const codec = (stream.codec_name || '').toLowerCase();
// Skip unsupported
if (UNSUPPORTED_SUBTITLES.has(codec) || IMAGE_SUBTITLES.has(codec)) continue;
// Check language filter
const lang = stream.tags?.language?.toLowerCase() || 'unknown';
if (extractLangs && !extractLangs.has(lang)) continue;
// Skip commentary
if (inputs.skip_commentary) {
const title = (stream.tags?.title || '').toLowerCase();
if (title.includes('commentary') || title.includes('description')) continue;
}
// Build unique filename
const safeLang = sanitizeFilename(lang);
let subsFile = `${basePath}.${safeLang}.srt`;
let counter = 1;
while ((extractedFiles.has(subsFile) || fileExistsValid(subsFile, fs)) && counter < MAX_FILENAME_ATTEMPTS) {
subsFile = `${basePath}.${safeLang}.${counter}.srt`;
counter++;
}
if (fileExistsValid(subsFile, fs)) continue;
extractArgs.push('-map', `0:${stream.index}`, subsFile);
extractedFiles.add(subsFile);
streamsToRemove.push(stream.index);
}
if (extractArgs.length === 0) {
response.infoLog = '✅ No subtitles to extract (all exist or filtered). ';
return response;
}
// Execute extraction
const ffmpegPath = otherArguments?.ffmpegPath || 'tdarr-ffmpeg';
const cmdParts = [ffmpegPath, '-y', '-i', sanitizeForShell(file.file)];
for (let i = 0; i < extractArgs.length; i++) {
if (extractArgs[i] === '-map') {
cmdParts.push('-map', extractArgs[i + 1]);
i++;
} else {
cmdParts.push(sanitizeForShell(extractArgs[i]));
}
}
const extractCount = streamsToRemove.length;
response.infoLog += `✅ Extracting ${extractCount} subtitle(s)... `;
try {
const execCmd = cmdParts.join(' ');
execSync(execCmd, { stdio: 'pipe', timeout: 300000, maxBuffer: 10 * 1024 * 1024 });
response.infoLog += 'Done. ';
} catch (e) {
const errorMsg = e.stderr ? e.stderr.toString() : e.message;
response.infoLog += `⚠️ Extraction failed: ${errorMsg}. `;
if (!inputs.remove_after_extract) return response;
response.infoLog += 'Proceeding with removal regardless. ';
}
// Remove subtitles from container if requested
if (inputs.remove_after_extract && streamsToRemove.length > 0) {
let preset = '<io> -map 0';
streamsToRemove.forEach((idx) => { preset += ` -map -0:${idx}`; });
preset += ' -c copy -max_muxing_queue_size 9999';
response.preset = preset;
response.processFile = true;
response.reQueueAfter = true;
response.infoLog += `✅ Removing ${streamsToRemove.length} embedded subtitle(s). `;
} else {
response.infoLog += '✅ Subtitles extracted, container unchanged. ';
}
// Final Summary block
if (extractCount > 0) {
response.infoLog += '\n\n📋 Final Processing Summary:\n';
response.infoLog += ` Subtitles extracted: ${extractCount}\n`;
if (inputs.remove_after_extract) {
response.infoLog += ` - Embedded subtitles removed from container\n`;
} else {
response.infoLog += ` - Embedded subtitles preserved\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;

View File

@@ -0,0 +1,190 @@
/* eslint-disable no-plusplus */
const details = () => ({
id: 'Tdarr_Plugin_06_cc_extraction',
Stage: 'Pre-processing',
Name: '06 - CC Extraction (CCExtractor)',
Type: 'Video',
Operation: 'Transcode',
Description: `
Extracts closed captions (eia_608/cc_dec) from video files using CCExtractor.
- Outputs to external .cc.srt file alongside the video
- Optionally embeds extracted CC back into the container as a subtitle track
**Requirements**: CCExtractor must be installed and available in PATH.
**Single Responsibility**: Closed caption extraction only.
Run AFTER subtitle extraction, BEFORE audio standardizer.
`,
Version: '1.6',
Tags: 'action,ffmpeg,subtitles,cc,ccextractor',
Inputs: [
{
name: 'extract_cc',
type: 'string',
defaultValue: 'false',
inputUI: { type: 'dropdown', options: ['false', 'true'] },
tooltip: 'Enable CC extraction via CCExtractor. Requires CCExtractor installed.',
},
{
name: 'embed_extracted_cc',
type: 'string',
defaultValue: 'false',
inputUI: { type: 'dropdown', options: ['false', 'true'] },
tooltip: 'Embed the extracted CC file back into the container as a subtitle track.',
},
],
});
// Constants
const CC_CODECS = new Set(['eia_608', 'cc_dec']);
const BOOLEAN_INPUTS = ['extract_cc', 'embed_extracted_cc'];
const MIN_CC_SIZE = 50;
// Utilities
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
const sanitizeForShell = (str) => {
if (typeof str !== 'string') throw new TypeError('Input must be a string');
str = str.replace(/\0/g, '');
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
};
const hasClosedCaptions = (streams) => streams.some((s) => {
const codec = (s.codec_name || '').toLowerCase();
const tag = (s.codec_tag_string || '').toLowerCase();
return CC_CODECS.has(codec) || tag === 'cc_dec';
});
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const response = {
processFile: false,
preset: '',
container: `.${file.container}`,
handbrakeMode: false,
ffmpegMode: true,
reQueueAfter: false,
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'; });
if (!inputs.extract_cc) {
response.infoLog = '✅ CC extraction disabled. ';
return response;
}
const streams = file.ffProbeData?.streams;
if (!Array.isArray(streams)) {
response.infoLog = '❌ No stream data available. ';
return response;
}
// Early exit optimization: no CC streams = nothing to do
if (!hasClosedCaptions(streams)) {
response.infoLog = '✅ No closed captions detected. ';
return response;
}
// Build CC output path
const basePath = path.join(path.dirname(file.file), path.basename(file.file, path.extname(file.file)));
const ccFile = `${basePath}.cc.srt`;
const ccLockFile = `${ccFile}.lock`;
// Check if CC file already exists
try {
const stats = fs.statSync(ccFile);
if (stats.size > MIN_CC_SIZE) {
response.infoLog = ' CC file already exists. ';
if (inputs.embed_extracted_cc) {
const safeCCFile = sanitizeForShell(ccFile);
const subCount = streams.filter((s) => s.codec_type === 'subtitle').length;
response.preset = `<io> -i ${safeCCFile} -map 0 -map 1:0 -c copy -c:s:${subCount} srt -metadata:s:s:${subCount} language=eng -metadata:s:s:${subCount} title="Closed Captions" -max_muxing_queue_size 9999`;
response.processFile = true;
response.reQueueAfter = true;
response.infoLog += '✅ Embedding existing CC file. ';
}
return response;
}
} catch { /* File doesn't exist, proceed */ }
// Prevent concurrent extraction via lock file
try {
fs.writeFileSync(ccLockFile, process.pid.toString(), { flag: 'wx' });
} catch (e) {
if (e.code === 'EEXIST') {
response.infoLog = ' CC extraction in progress by another worker. ';
return response;
}
throw e;
}
// Execute CCExtractor
const safeInput = sanitizeForShell(file.file);
const safeCCFile = sanitizeForShell(ccFile);
response.infoLog += '✅ Extracting CC... ';
try {
execSync(`ccextractor ${safeInput} -o ${safeCCFile}`, { stdio: 'pipe', timeout: 180000, maxBuffer: 10 * 1024 * 1024 });
response.infoLog += 'Done. ';
} catch (e) {
const errorMsg = e.stderr ? e.stderr.toString() : e.message;
response.infoLog += `⚠️ CCExtractor failed: ${errorMsg}. `;
try { fs.unlinkSync(ccLockFile); } catch { /* ignore */ }
return response;
}
// Clean up lock file
try { fs.unlinkSync(ccLockFile); } catch { /* ignore */ }
// Verify CC file
try {
if (fs.statSync(ccFile).size < MIN_CC_SIZE) {
response.infoLog += ' No closed captions found. ';
return response;
}
} catch {
response.infoLog += '⚠️ CC file not created. ';
return response;
}
// Embed if requested
if (inputs.embed_extracted_cc) {
const subCount = streams.filter((s) => s.codec_type === 'subtitle').length;
response.preset = `<io> -i ${safeCCFile} -map 0 -map 1:0 -c copy -c:s:${subCount} srt -metadata:s:s:${subCount} language=eng -metadata:s:s:${subCount} title="Closed Captions" -max_muxing_queue_size 9999`;
response.processFile = true;
response.reQueueAfter = true;
response.infoLog += '✅ Embedding CC file. ';
} else {
response.infoLog += '✅ CC extracted to external file. ';
}
// Final Summary block
if (inputs.embed_extracted_cc) {
response.infoLog += '\n\n📋 Final Processing Summary:\n';
response.infoLog += ` CC extraction: Completed\n`;
response.infoLog += ` - CC embedded as subtitle track\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;

View File

@@ -0,0 +1,893 @@
const details = () => ({
id: 'Tdarr_Plugin_av1_svt_converter',
Stage: 'Pre-processing',
Name: 'Convert to AV1 SVT-AV1',
Type: 'Video',
Operation: 'Transcode',
Description: `
AV1 conversion plugin with simplified quality control for SVT-AV1 v3.0+ (2025).
**Rate Control**: CRF (quality-based, optional maxrate cap) or VBR (bitrate-based with target + maxrate).
**Quality Presets**: Use quality_preset for easy configuration, or set custom CRF/qmin/qmax values.
**Bitrate Awareness**: Optionally skip files that are already very low bitrate to prevent size bloat.
**Source Codec Awareness**: Optionally increase CRF for HEVC sources to prevent re-encoding bloat.
**Note**: Run AFTER stream_cleanup plugin to ensure problematic streams are removed.
v3.18: No code changes - version bump for compatibility with updated audio_standardizer v1.23.
`,
Version: '3.19',
Tags: 'video,av1,svt,quality,performance,speed-optimized,vbr,crf',
Inputs: [
{
name: 'quality_preset',
type: 'string',
defaultValue: 'balanced*',
inputUI: {
type: 'dropdown',
options: [
'archival',
'high',
'balanced*',
'efficient',
'custom'
],
},
tooltip: 'Quality presets auto-configure CRF/qmin/qmax. archival=CRF18/qmax35, high=CRF22/qmax40, balanced=CRF28/qmax45, efficient=CRF30/qmax55. Use "custom" to set values manually below.',
},
{
name: 'crf',
type: 'string',
defaultValue: '28*',
inputUI: {
type: 'dropdown',
options: [
'16',
'18',
'20',
'22',
'24',
'26',
'28*',
'30',
'32',
'34',
'36',
'38',
'40',
'42'
],
},
tooltip: 'Quality setting (CRF). Lower = better quality/larger files. 16-20=archival, 22-26=high quality, 28-32=balanced, 34+=efficient. Only used when quality_preset=custom.',
},
{
name: 'qmin',
type: 'string',
defaultValue: '10*',
inputUI: {
type: 'dropdown',
options: [
'1',
'5',
'10*',
'15',
'20'
],
},
tooltip: 'Minimum quantizer (quality ceiling). Lower = allows better quality but may not improve much. Only used when quality_preset=custom.',
},
{
name: 'qmax',
type: 'string',
defaultValue: '45*',
inputUI: {
type: 'dropdown',
options: [
'30',
'35',
'40',
'45*',
'48',
'50',
'55',
'60'
],
},
tooltip: 'Maximum quantizer (quality floor). Lower = prevents excessive compression, larger files. 35=archival, 40=high, 45=balanced, 55=efficient. Only used when quality_preset=custom.',
},
{
name: 'maxrate',
type: 'string',
defaultValue: '0',
inputUI: {
type: 'text',
},
tooltip: 'Maximum bitrate in kbps (0 = unlimited). Optional cap for both CRF and VBR modes. Prevents bitrate spikes. ~3500 kbps for 1080p.',
},
{
name: 'target_bitrate',
type: 'string',
defaultValue: '2200',
inputUI: {
type: 'text',
},
tooltip: 'Target average bitrate in kbps for VBR mode. ~2200 kbps = 1GB/hour @ 1080p. Ignored in CRF mode.',
},
{
name: 'rate_control_mode',
type: 'string',
defaultValue: 'crf*',
inputUI: {
type: 'dropdown',
options: [
'crf*',
'vbr'
],
},
tooltip: 'Rate control mode. \'crf\' = Quality-based (CRF + optional maxrate cap for bandwidth control), \'vbr\' = Bitrate-based (target average bitrate + maxrate peaks for streaming/bandwidth-limited scenarios).',
},
{
name: 'max_resolution',
type: 'string',
defaultValue: 'none*',
inputUI: {
type: 'dropdown',
options: [
'none*',
'480p',
'720p',
'1080p',
'1440p',
'2160p'
],
},
tooltip: 'Maximum output resolution. Videos exceeding this will be downscaled while maintaining aspect ratio. CRF adjustment (if enabled) applies to output resolution.',
},
{
name: 'resolution_crf_adjust',
type: 'string',
defaultValue: 'enabled*',
inputUI: {
type: 'dropdown',
options: [
'disabled',
'enabled*'
],
},
tooltip: 'Auto-adjust CRF based on resolution: 4K gets +2 CRF, 1080p/720p baseline, 480p and below gets +2 CRF. Helps prevent size bloat on low-bitrate SD content.',
},
{
name: 'source_codec_awareness',
type: 'string',
defaultValue: 'enabled*',
inputUI: {
type: 'dropdown',
options: [
'disabled',
'enabled*'
],
},
tooltip: 'Auto-adjust CRF +2 when source is HEVC/H.265 to prevent size bloat from re-encoding already-efficient codecs.',
},
{
name: 'preset',
type: 'string',
defaultValue: '6*',
inputUI: {
type: 'dropdown',
options: [
'-1',
'0',
'1',
'2',
'3',
'4',
'5',
'6*',
'7',
'8',
'9',
'10',
'11',
'12'
],
},
tooltip: 'SVT-AV1 preset. (default: 6) 6 = balanced speed/quality, 10 = fastest (real-time), 89 = very fast, 34 = best quality but slow. Higher = faster, lower = better quality. [v3.0+]',
},
{
name: 'tune',
type: 'string',
defaultValue: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'1',
'2'
],
},
tooltip: 'Tuning mode. (default: 0 VQ) 0 = VQ (best visual quality), 1 = PSNR (faster), 2 = SSIM (slowest). [v3.0+]',
},
{
name: 'scd',
type: 'string',
defaultValue: '1*',
inputUI: {
type: 'dropdown',
options: [
'0',
'1*'
],
},
tooltip: 'Scene Change Detection. (default: 1) 0 = Off (fastest), 1 = On (better keyframe placement, ~510% slower).',
},
{
name: 'aq_mode',
type: 'string',
defaultValue: '2*',
inputUI: {
type: 'dropdown',
options: [
'0',
'1',
'2*'
],
},
tooltip: 'Adaptive Quantization. (default: 2) 0 = Off (fastest), 1 = Variance AQ (better quality, minor speed loss), 2 = DeltaQ AQ (best quality, 1020% slower).',
},
{
name: 'lookahead',
type: 'string',
defaultValue: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'60',
'90',
'120'
],
},
tooltip: 'Lookahead frames. 0 = Off/Auto (fastest, lets SVT-AV1 decide), 60-120 = higher quality but slower encoding.',
},
{
name: 'enable_tf',
type: 'string',
defaultValue: '1*',
inputUI: {
type: 'dropdown',
options: [
'0',
'1*'
],
},
tooltip: 'Temporal Filtering. (default: 1) 0 = Off (fastest), 1 = On (better noise reduction/quality, ~1525% slower).',
},
{
name: 'threads',
type: 'string',
defaultValue: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'12',
'16',
'24',
'32'
],
},
tooltip: 'Number of encoding threads. 0 = Auto (use all cores, recommended). SVT-AV1 scales well with more threads.',
},
{
name: 'keyint',
type: 'string',
defaultValue: '-2*',
inputUI: {
type: 'dropdown',
options: [
'-2*',
'-1',
'120',
'240',
'360',
'480',
'600',
'720',
'900',
'1200'
],
},
tooltip: 'Keyframe interval. (default: -2 ≈5s) -2=~5 seconds, -1=infinite (CRF only), higher = smaller files but worse seeking; lower = better quality/seeking, larger files.',
},
{
name: 'hierarchical_levels',
type: 'string',
defaultValue: '4*',
inputUI: {
type: 'dropdown',
options: [
'2',
'3',
'4*',
'5'
],
},
tooltip: 'Hierarchical levels: 2=3 temporal layers, 3=4 temporal layers, 4=5 temporal layers (recommended), 5=6 temporal layers. Controls GOP structure complexity.',
},
{
name: 'film_grain',
type: 'string',
defaultValue: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'1',
'5',
'10',
'15',
'20',
'25',
'30',
'35',
'40',
'45',
'50'
],
},
tooltip: 'Film grain synthesis: 0 = Off (fastest), 150 = denoising level (slower, more natural grain).',
},
{
name: 'input_depth',
type: 'string',
defaultValue: '10*',
inputUI: {
type: 'dropdown',
options: [
'8',
'10*'
],
},
tooltip: 'Output bit depth: 8 = faster encoding, 10 = better quality (prevents banding), ~10-20% slower. Recommended: 10-bit for high-quality sources.',
},
{
name: 'fast_decode',
type: 'string',
defaultValue: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'1'
],
},
tooltip: 'Fast decode optimization. (default: 0) 1 = moderate decode speed improvement, 0 = off (best compression). [v3.0+]',
},
{
name: 'container',
type: 'string',
defaultValue: 'original*',
inputUI: {
type: 'dropdown',
options: [
'original*',
'mkv',
'mp4',
'webm'
],
},
tooltip: 'Output container. "original" inherits from Plugin 01 (recommended, avoids subtitle issues). MKV supports all codecs/subs. MP4 for device compatibility (but may drop some subtitle formats).',
},
{
name: 'skip_hevc',
type: 'string',
defaultValue: 'enabled*',
inputUI: {
type: 'dropdown',
options: [
'disabled',
'enabled*'
],
},
tooltip: 'Skip HEVC/H.265 files without converting. Useful if you want to handle HEVC files separately or they are already efficient.',
},
{
name: 'force_transcode',
type: 'string',
defaultValue: 'disabled*',
inputUI: {
type: 'dropdown',
options: [
'disabled*',
'enabled'
],
},
tooltip: 'Force transcoding even if the file is already AV1. Useful for changing quality or preset.',
},
{
name: 'bitrate_awareness',
type: 'string',
defaultValue: 'enabled*',
inputUI: {
type: 'dropdown',
options: [
'disabled',
'enabled*'
],
},
tooltip: 'Skip files that are already lower than the threshold bitrate. Prevents wasting CPU on tiny files that will likely increase in size.',
},
{
name: 'min_source_bitrate',
type: 'string',
defaultValue: '400*',
inputUI: {
type: 'dropdown',
options: [
'150',
'200',
'250',
'300',
'350',
'400*',
'500',
'600',
'800'
],
},
tooltip: 'Minimum source bitrate (kbps). Only used when bitrate_awareness is enabled. 400 kbps is usually the floor for 480p quality.',
}
],
});
// Inline utilities (Tdarr plugins must be self-contained)
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;
};
// Container-aware subtitle compatibility
// Subtitles incompatible with MKV container
const MKV_INCOMPATIBLE_SUBS = new Set(['mov_text', 'tx3g']);
// Subtitles incompatible with MP4 container (most text/image subs)
const MP4_INCOMPATIBLE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub', 'subrip', 'srt', 'ass', 'ssa', 'webvtt']);
// Text subtitles that can be converted to mov_text for MP4
const MP4_CONVERTIBLE_SUBS = new Set(['subrip', 'srt', 'ass', 'ssa', 'webvtt', 'text']);
// Image subtitles that must be dropped for MP4 (cannot be converted)
const IMAGE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
/**
* Build container-aware subtitle handling arguments
* @param {Array} streams - ffprobe streams array
* @param {string} targetContainer - target container (mkv, mp4, webm)
* @returns {Object} { subtitleArgs: string, subtitleLog: string }
*/
const buildSubtitleArgs = (streams, targetContainer) => {
const subtitleStreams = streams
.map((s, i) => ({ ...s, index: i }))
.filter(s => s.codec_type === 'subtitle');
if (subtitleStreams.length === 0) {
return { subtitleArgs: '', subtitleLog: '' };
}
const container = targetContainer.toLowerCase();
let args = '';
let log = '';
if (container === 'mp4' || container === 'm4v') {
// MP4: Convert compatible text subs to mov_text, drop image subs
const toConvert = [];
const toDrop = [];
subtitleStreams.forEach(s => {
const codec = (s.codec_name || '').toLowerCase();
if (IMAGE_SUBS.has(codec)) {
toDrop.push(s);
} else if (MP4_CONVERTIBLE_SUBS.has(codec)) {
toConvert.push(s);
} else if (codec === 'mov_text') {
// Already compatible, will be copied
} else {
// Unknown format - try to convert, FFmpeg will error if it can't
toConvert.push(s);
}
});
if (toDrop.length > 0) {
// Build negative mapping for dropped streams
toDrop.forEach(s => {
args += ` -map -0:${s.index}`;
});
log += `Dropping ${toDrop.length} image subtitle(s) (incompatible with MP4). `;
}
if (toConvert.length > 0) {
// Convert text subs to mov_text
args += ' -c:s mov_text';
log += `Converting ${toConvert.length} subtitle(s) to mov_text for MP4. `;
} else if (toDrop.length === 0) {
args += ' -c:s copy';
}
} else if (container === 'webm') {
// WebM: Only supports WebVTT, drop everything else
const incompatible = subtitleStreams.filter(s => {
const codec = (s.codec_name || '').toLowerCase();
return codec !== 'webvtt';
});
if (incompatible.length > 0) {
incompatible.forEach(s => {
args += ` -map -0:${s.index}`;
});
log += `Dropping ${incompatible.length} subtitle(s) (WebM only supports WebVTT). `;
}
if (incompatible.length < subtitleStreams.length) {
args += ' -c:s copy';
}
} else {
// MKV: Very permissive, just convert mov_text to srt
const movTextStreams = subtitleStreams.filter(s =>
(s.codec_name || '').toLowerCase() === 'mov_text'
);
if (movTextStreams.length > 0) {
args += ' -c:s srt';
log += `Converting ${movTextStreams.length} mov_text subtitle(s) to SRT for MKV. `;
} else {
args += ' -c:s copy';
}
}
return { subtitleArgs: args, subtitleLog: log };
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const response = {
processFile: false,
preset: '',
container: '',
handbrakeMode: false,
ffmpegMode: false,
reQueueAfter: false,
infoLog: '',
};
try {
const sanitized = sanitizeInputs(lib.loadDefaultValues(inputs, details));
// Detect actual input container format via ffprobe
const actualFormatName = file.ffProbeData?.format?.format_name || '';
const looksLikeAppleMp4Family = actualFormatName.includes('mov') || actualFormatName.includes('mp4');
// NOTE: Stream cleanup is now handled by the stream_cleanup plugin earlier in the pipeline.
// We use simple -map 0 mapping, relying on stream_cleanup to remove problematic streams.
// Check if file is already AV1 and skip if not forcing transcode
const isAV1 = file.ffProbeData.streams.some(stream =>
stream.codec_type === 'video' &&
(stream.codec_name === 'av01' || stream.codec_name === 'av1' || stream.codec_name === 'libsvtav1')
);
if (isAV1 && sanitized.force_transcode !== 'enabled') {
response.processFile = false;
response.infoLog += '✅ File is already AV1 encoded and force_transcode is disabled. Skipping.\n';
return response;
}
// Check if file is HEVC and skip if skip_hevc is enabled
const isHEVC = file.ffProbeData.streams.some(stream =>
stream.codec_type === 'video' &&
(stream.codec_name === 'hevc' || stream.codec_name === 'h265' || stream.codec_name === 'libx265')
);
if (isHEVC && sanitized.skip_hevc === 'enabled') {
response.processFile = false;
response.infoLog += ' File is HEVC/H.265 encoded and skip_hevc is enabled. Skipping for separate processing.\n';
return response;
}
// Source Bitrate Awareness Check
const duration = parseFloat(file.ffProbeData?.format?.duration) || 0;
const sourceSize = file.statSync?.size || 0;
let sourceBitrateKbps = 0;
if (duration > 0 && sourceSize > 0) {
sourceBitrateKbps = Math.round((sourceSize * 8) / (duration * 1000));
}
if (sanitized.bitrate_awareness === 'enabled') {
const minBitrate = parseInt(sanitized.min_source_bitrate) || 400;
if (sourceBitrateKbps === 0) {
response.infoLog += `Warning: Could not calculate source bitrate (duration: ${duration}s, size: ${sourceSize}). Skipping bitrate check.\n`;
} else if (sourceBitrateKbps < minBitrate) {
response.processFile = false;
response.infoLog += `Source bitrate (${sourceBitrateKbps} kbps) is below minimum threshold (${minBitrate} kbps). Skipping to prevent size bloat.\n`;
return response;
} else {
response.infoLog += `Source bitrate: ${sourceBitrateKbps} kbps (Threshold: ${minBitrate} kbps). Proceeding.\n`;
}
} else {
response.infoLog += `Source bitrate: ${sourceBitrateKbps} kbps. Awareness disabled.\n`;
}
// Validate video stream exists
const videoStream = file.ffProbeData.streams.find(s => s.codec_type === 'video');
if (!videoStream) {
response.processFile = false;
response.infoLog += '❌ Error: No video stream found in file. Skipping.\n';
return response;
}
// Use specified preset
const finalPreset = sanitized.preset;
response.infoLog += `Using preset ${finalPreset} (speed-optimized default).\n`;
// Use specified thread count
const threadCount = sanitized.threads;
response.infoLog += `Using ${threadCount} encoding threads.\n`;
// Resolution mapping and downscaling logic
const resolutionMap = {
'480p': 480,
'720p': 720,
'1080p': 1080,
'1440p': 1440,
'2160p': 2160
};
// videoStream was validated and assigned earlier (after HEVC skip check)
let scaleFilter = '';
let outputHeight = null;
// Detect HDR metadata for color preservation
let hdrArgs = '';
const colorTransfer = videoStream.color_transfer || '';
const colorPrimaries = videoStream.color_primaries || '';
const colorSpace = videoStream.color_space || '';
// Check for HDR10, HLG, or PQ transfer characteristics
const isHDR10 = colorTransfer === 'smpte2084'; // PQ
const isHLG = colorTransfer === 'arib-std-b67'; // HLG
const isHDR = (isHDR10 || isHLG) && (
colorPrimaries === 'bt2020' ||
colorSpace === 'bt2020nc' ||
colorSpace === 'bt2020c'
);
if (isHDR) {
// Preserve HDR color metadata
hdrArgs = ` -colorspace ${colorSpace || 'bt2020nc'} -color_trc ${colorTransfer || 'smpte2084'} -color_primaries ${colorPrimaries || 'bt2020'}`;
response.infoLog += `HDR content detected (${colorTransfer}/${colorPrimaries}), preserving color metadata.\n`;
}
if (videoStream && videoStream.height && sanitized.max_resolution !== 'none') {
const inputHeight = videoStream.height;
const maxHeight = resolutionMap[sanitized.max_resolution];
if (maxHeight && inputHeight > maxHeight) {
// Downscale needed - use scale filter with -2 to maintain aspect ratio and ensure even dimensions
outputHeight = maxHeight;
scaleFilter = `-vf "scale=-2:${maxHeight}"`;
response.infoLog += `Downscaling from ${inputHeight}p to ${maxHeight}p while maintaining aspect ratio.\n`;
} else if (maxHeight) {
// Input is already at or below max resolution
outputHeight = inputHeight;
response.infoLog += `Input resolution ${inputHeight}p is within max limit of ${maxHeight}p, no downscaling needed.\n`;
} else {
// No max resolution set
outputHeight = inputHeight;
}
} else if (videoStream && videoStream.height) {
// No max resolution constraint
outputHeight = videoStream.height;
}
// Apply quality preset to determine CRF, qmin, qmax values
// Presets override manual values unless quality_preset is 'custom'
let effectiveCrf = sanitized.crf;
let effectiveQmin = sanitized.qmin;
let effectiveQmax = sanitized.qmax;
const qualityPresets = {
archival: { crf: '18', qmin: '5', qmax: '35' },
high: { crf: '22', qmin: '10', qmax: '40' },
balanced: { crf: '28', qmin: '10', qmax: '45' },
efficient: { crf: '30', qmin: '10', qmax: '55' },
};
if (sanitized.quality_preset !== 'custom' && qualityPresets[sanitized.quality_preset]) {
const preset = qualityPresets[sanitized.quality_preset];
effectiveCrf = preset.crf;
effectiveQmin = preset.qmin;
effectiveQmax = preset.qmax;
response.infoLog += `Quality preset "${sanitized.quality_preset}" applied: CRF ${effectiveCrf}, qmin ${effectiveQmin}, qmax ${effectiveQmax}.\n`;
} else if (sanitized.quality_preset === 'custom') {
response.infoLog += `Custom quality settings: CRF ${effectiveCrf}, qmin ${effectiveQmin}, qmax ${effectiveQmax}.\n`;
}
// Resolution-based CRF adjustment (applies to OUTPUT resolution after any downscaling)
let finalCrf = effectiveCrf;
if (sanitized.resolution_crf_adjust === 'enabled' && outputHeight) {
const baseCrf = parseInt(effectiveCrf);
// Validate CRF is a valid number
if (isNaN(baseCrf) || baseCrf < 0 || baseCrf > 63) {
response.infoLog += `Warning: Invalid CRF value "${effectiveCrf}", using default.\n`;
finalCrf = '26';
} else {
if (outputHeight >= 2160) { // 4K
finalCrf = Math.min(63, baseCrf + 2).toString();
response.infoLog += `4K output resolution detected, CRF adjusted from ${effectiveCrf} to ${finalCrf}.\n`;
} else if (outputHeight <= 480) { // 480p or lower
finalCrf = Math.min(63, baseCrf + 2).toString();
response.infoLog += `480p or lower output resolution detected, CRF adjusted from ${effectiveCrf} to ${finalCrf}.\n`;
} else if (outputHeight <= 720) { // 720p
response.infoLog += `720p output resolution detected, using base CRF ${finalCrf}.\n`;
} else {
response.infoLog += `1080p output resolution detected, using base CRF ${finalCrf}.\n`;
}
}
}
// Source codec awareness - increase CRF for already-efficient codecs
if (sanitized.source_codec_awareness === 'enabled') {
const sourceCodec = videoStream.codec_name?.toLowerCase() || '';
const efficientCodecs = ['hevc', 'h265', 'libx265'];
if (efficientCodecs.includes(sourceCodec)) {
const currentCrf = parseInt(finalCrf);
finalCrf = Math.min(63, currentCrf + 2).toString();
response.infoLog += `Source codec "${sourceCodec}" is already efficient: CRF adjusted +2 to ${finalCrf} to prevent bloat.\n`;
}
} else if (sanitized.resolution_crf_adjust === 'enabled') {
response.infoLog += `Resolution-based CRF adjustment enabled but video height not detected, using base CRF ${finalCrf}.\n`;
}
// Build SVT-AV1 parameters string
// Note: lookahead is only passed when > 0 (SVT-AV1 v3.x rejects -1 and may have issues with 0 via FFmpeg wrapper)
const svtParamsArray = [
`preset=${finalPreset}`,
`tune=${sanitized.tune}`,
`scd=${sanitized.scd}`,
`aq-mode=${sanitized.aq_mode}`,
`lp=${threadCount}`,
`keyint=${sanitized.keyint}`,
`hierarchical-levels=${sanitized.hierarchical_levels}`,
`film-grain=${sanitized.film_grain}`,
`input-depth=${sanitized.input_depth}`,
`fast-decode=${sanitized.fast_decode}`,
`enable-tf=${sanitized.enable_tf}`
];
// Only add lookahead if explicitly set to a positive value
const lookaheadVal = parseInt(sanitized.lookahead);
if (lookaheadVal > 0) {
svtParamsArray.push(`lookahead=${lookaheadVal}`);
response.infoLog += `Lookahead set to ${lookaheadVal} frames.\\n`;
}
const svtParams = svtParamsArray.join(':');
// Set up FFmpeg arguments for CRF quality control with configurable qmin/qmax
let qualityArgs = `-crf ${finalCrf} -qmin ${effectiveQmin} -qmax ${effectiveQmax}`;
// Explicitly set pixel format for 10-bit to ensure correct encoding
if (sanitized.input_depth === '10') {
qualityArgs += ' -pix_fmt yuv420p10le';
response.infoLog += `10-bit encoding enabled with yuv420p10le pixel format.\n`;
}
// Build quality/bitrate arguments based on rate control mode
let bitrateControlInfo = `Using CRF mode with value ${finalCrf}`;
if (sanitized.rate_control_mode === 'vbr') {
// VBR Mode: Use target bitrate. SVT-AV v3.1+ doesn't support -maxrate with VBR.
const targetBitrate = parseInt(sanitized.target_bitrate) || 2200;
qualityArgs = `-b:v ${targetBitrate}k -qmin ${effectiveQmin} -qmax ${effectiveQmax}`;
bitrateControlInfo = `VBR mode: target ${targetBitrate}k`;
response.infoLog += `VBR encoding: Target average ${targetBitrate}k.\n`;
} else {
// CRF Mode: Quality-based with optional maxrate cap
if (sanitized.maxrate && parseInt(sanitized.maxrate) > 0) {
const maxrateValue = parseInt(sanitized.maxrate);
const bufsize = Math.round(maxrateValue * 2.0); // Buffer = 2x maxrate
qualityArgs += ` -maxrate ${maxrateValue}k -bufsize ${bufsize}k`;
bitrateControlInfo += ` with maxrate cap at ${maxrateValue}k`;
response.infoLog += `Capped CRF enabled: Max bitrate ${maxrateValue}k, buffer ${bufsize}k.\n`;
} else {
response.infoLog += `Using uncapped CRF for maximum quality efficiency.\n`;
}
}
// Add tile options for 4K content (improves parallel encoding/decoding)
let tileArgs = '';
if (outputHeight && outputHeight >= 2160) {
// 4K: 2x1 tiles = 3 tiles total (better parallelism for 4K encoding)
tileArgs = ':tile-columns=2:tile-rows=1';
response.infoLog += '4K content: Adding tile-columns=2, tile-rows=1 for improved parallelism.\n';
} else if (outputHeight && outputHeight >= 1440) {
// 1440p: 1x0 tiles = 2 tiles total (balanced for 1440p)
tileArgs = ':tile-columns=1:tile-rows=0';
response.infoLog += '1440p content: Adding tile-columns=1 for balanced parallelism.\n';
}
// 1080p and below: No tiles (overhead not worth it)
// Determine target container for subtitle handling
const targetContainer = sanitized.container === 'original' ? file.container : sanitized.container;
// Build container-aware subtitle arguments
const { subtitleArgs, subtitleLog } = buildSubtitleArgs(file.ffProbeData.streams, targetContainer);
if (subtitleLog) {
response.infoLog += `📁 ${subtitleLog}\\n`;
}
// Set up FFmpeg arguments for AV1 SVT conversion
// Use explicit stream mapping to prevent data/attachment streams from causing muxing errors
const svtParamsWithTiles = svtParams + tileArgs;
response.preset = `<io>${scaleFilter ? ' ' + scaleFilter : ''} -map 0:v -map 0:a? -map 0:s?${subtitleArgs} -c:v libsvtav1 ${qualityArgs}${hdrArgs} -svtav1-params "${svtParamsWithTiles}" -c:a copy -max_muxing_queue_size 9999`;
// Set container
if (sanitized.container === 'original') {
response.container = `.${file.container}`;
} else {
response.container = `.${sanitized.container}`;
// WebM container validation - warn about potential compatibility
if (sanitized.container === 'webm') {
response.infoLog += 'Note: WebM container selected. Ensure audio is Opus/Vorbis for full compatibility.\n';
}
// MKV container handling with user warning
if (sanitized.container === 'mkv' && looksLikeAppleMp4Family) {
response.infoLog += 'Note: MKV output with Apple/MP4 source. Ensure stream_cleanup ran first.\n';
}
}
response.ffmpegMode = true;
response.handbrakeMode = false;
response.reQueueAfter = true;
response.processFile = true;
if (isAV1) {
response.infoLog += `File is AV1 but force transcoding is enabled. ${bitrateControlInfo}.\n`;
} else if (isHEVC) {
response.infoLog += `Converting HEVC to AV1. ${bitrateControlInfo}.\n`;
} else {
response.infoLog += `Converting ${file.ffProbeData.streams.find(s => s.codec_type === 'video')?.codec_name || 'unknown'} to AV1. ${bitrateControlInfo}.\n`;
}
response.infoLog += `Using SVT-AV1 preset: ${finalPreset}, tune: ${sanitized.tune} (VQ-optimized when 0), threads: ${threadCount}\n`;
response.infoLog += `Encoding params - SCD: ${sanitized.scd}, AQ: ${sanitized.aq_mode}, Lookahead: ${lookaheadVal > 0 ? lookaheadVal : 'auto'}, TF: ${sanitized.enable_tf}\\n`;
response.infoLog += `Quality control - CRF: ${finalCrf}, QMin: ${effectiveQmin}, QMax: ${effectiveQmax}, Film grain: ${sanitized.film_grain}\n`;
response.infoLog += `Output container: ${response.container}\n`;
return response;
} catch (error) {
response.processFile = false;
response.preset = '';
response.container = `.${file.container || 'mkv'}`;
response.reQueueAfter = false;
response.infoLog = `❌ Plugin error: ${error.message}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;

File diff suppressed because it is too large Load Diff

View File

@@ -83,3 +83,23 @@ Each entry should include:
- **Timestamp** (from ADDITIONAL_METADATA)
- **User Prompt** (exact or summarized request)
- **Action Taken** (brief description of what was done)
---
## 2026-01-28 Session
### 06:15:00 - Job Reports → Modular Plugins
**User Prompt:**
> examine the job reports and do the needful to the plugins
**Action Taken:**
- Reviewed latest successful job report (`2MQgQoHQd-log.txt`) to confirm new audio/AV1 plugins behave as expected.
- Verified current `Tdarr_Plugin_combined_audio_standardizer` (v1.24) and `Tdarr_Plugin_av1_svt_converter` (v3.19) already address prior issues.
### 06:25:00 - Documentation & Folder Organization
**User Prompt:**
> do the needful to update all documentation and notes, organize folders
**Action Taken:**
- Updated `README.md` and `PLUGIN_DOCUMENTATION.md` to document the 9plugin modular stack and current directory layout (`JobReports`, `agent_notes/archive`, etc.).
- Recorded this session in `agent_notes/prompt_log.md` to keep prompt history in sync with the new documentation.