Fix copy progress, remove v2.0.0, fix subtitle default naming, enforce strict no-upcoding on audio
Build / Lint & Test (push) Successful in 3m15s

This commit is contained in:
PickleRick committed 2026-09-30 17:26:19 +02:00
1 parent 7ee5a5bc48
commit 86448988ed
12 files changed
+462 -340

No files matched your search

+139 -46
View File
@@ -1,27 +1,70 @@
'use strict'; 'use strict';
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const MAX_RETRIES = 2; const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 1000; const RETRY_DELAY_MS = 1000;
const PROGRESS_INTERVAL_MS = 250; const LOG_INTERVAL_MS = 3000;
const sleep = (milliseconds) => new Promise((resolve) => { const sleep = (milliseconds) => new Promise((resolve) => {
setTimeout(resolve, milliseconds); setTimeout(resolve, milliseconds);
}); });
const copyWithProgress = async ({ source, destination, jobLog, updateProgress, label }) => { const formatEta = (seconds) => {
const sourceSize = fs.statSync(source).size; if (!Number.isFinite(seconds) || seconds < 0) return '00:00:00';
const sourceSizeMb = sourceSize / 1024 / 1024; const hrs = Math.floor(seconds / 3600);
let lastError; const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};
const report = (progress, message) => { /**
* Copies a file from source to destination with real-time stream-based
* progress reporting, speed / ETA tracking, Tdarr worker updates, size verification,
* and automatic retry with cleanup.
*/
const copyWithProgress = async ({
source,
destination,
jobLog,
updateWorker,
updateProgress,
label = 'copy',
}) => {
if (!fs.existsSync(source)) {
throw new Error(`[${label}] Source file not found: ${source}`);
}
const sourceStat = fs.statSync(source);
const sourceSize = sourceStat.size;
const sourceSizeMb = sourceSize / 1024 / 1024;
const destDir = path.dirname(destination);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const report = (percent, message, eta) => {
if (typeof updateWorker === 'function') {
updateWorker({
percentage: Math.min(percent, 100),
ETA: eta || '00:00:00',
});
}
if (typeof updateProgress === 'function') { if (typeof updateProgress === 'function') {
updateProgress({ progress, jobLog: message }); updateProgress({
progress: Math.min(percent, 100),
jobLog: message,
});
}
if (message && typeof jobLog === 'function') {
jobLog(`${message}\n`);
} }
if (typeof jobLog === 'function') jobLog(`${message}\n`);
}; };
let lastError;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) { for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
if (attempt > 0) { if (attempt > 0) {
report(0, `[${label}] Retrying copy (${attempt}/${MAX_RETRIES})...`); report(0, `[${label}] Retrying copy (${attempt}/${MAX_RETRIES})...`);
@@ -29,49 +72,98 @@ const copyWithProgress = async ({ source, destination, jobLog, updateProgress, l
} }
try { try {
if (fs.existsSync(destination)) fs.unlinkSync(destination); if (fs.existsSync(destination)) {
fs.unlinkSync(destination);
}
const startedAt = Date.now(); // Special case: empty file (0 bytes)
let lastReportedPercent = -1; if (sourceSize === 0) {
let previousBytes = 0; fs.writeFileSync(destination, Buffer.alloc(0));
let previousAt = startedAt; report(100, `[${label}] Copied 0-byte file successfully.`, '00:00:00');
let copying = true;
const progressTimer = setInterval(() => {
if (!copying || !fs.existsSync(destination)) return;
let copiedBytes;
try {
copiedBytes = fs.statSync(destination).size;
} catch (error) {
if (error.code === 'ENOENT') return;
return; return;
} }
const percent = sourceSize === 0
? 99 const startedAt = Date.now();
: Math.min(Math.floor((copiedBytes / sourceSize) * 100), 99); let copiedBytes = 0;
if (percent <= lastReportedPercent) return; let lastReportedPercent = -1;
let lastLoggedPercent = -10;
let lastLoggedTime = startedAt;
let intervalBytes = 0;
let intervalTime = startedAt;
await new Promise((resolve, reject) => {
const rd = fs.createReadStream(source, { highWaterMark: 1024 * 1024 });
const wr = fs.createWriteStream(destination, { highWaterMark: 1024 * 1024 });
rd.on('error', (err) => {
wr.destroy();
reject(err);
});
wr.on('error', (err) => {
rd.destroy();
reject(err);
});
wr.on('finish', resolve);
rd.on('data', (chunk) => {
copiedBytes += chunk.length;
intervalBytes += chunk.length;
const now = Date.now(); const now = Date.now();
const intervalSeconds = Math.max((now - previousAt) / 1000, 0.001); const percent = Math.min(Math.floor((copiedBytes / sourceSize) * 100), 99);
const intervalSpeed = (copiedBytes - previousBytes) / 1024 / 1024 / intervalSeconds;
report(
percent,
`[${label}] Progress: ${percent}% `
+ `(${(copiedBytes / 1024 / 1024).toFixed(1)}/${sourceSizeMb.toFixed(1)} MB, `
+ `${intervalSpeed.toFixed(1)} MB/s)`,
);
lastReportedPercent = percent;
previousBytes = copiedBytes;
previousAt = now;
}, PROGRESS_INTERVAL_MS);
try { // Calculate ETA and speed
await fs.promises.copyFile(source, destination); const totalElapsedSec = Math.max((now - startedAt) / 1000, 0.001);
} finally { const avgSpeedBps = copiedBytes / totalElapsedSec;
copying = false; const remainingBytes = Math.max(0, sourceSize - copiedBytes);
clearInterval(progressTimer); const remainingSec = avgSpeedBps > 0 ? remainingBytes / avgSpeedBps : 0;
const eta = formatEta(remainingSec);
// Update Tdarr UI on every percentage increase
if (percent > lastReportedPercent) {
if (typeof updateWorker === 'function') {
updateWorker({
percentage: percent,
ETA: eta,
});
}
if (typeof updateProgress === 'function') {
updateProgress({ progress: percent });
}
lastReportedPercent = percent;
} }
// Throttle jobLog output: every 10% or every LOG_INTERVAL_MS
const timeSinceLastLog = now - lastLoggedTime;
if (percent >= lastLoggedPercent + 10 || (timeSinceLastLog >= LOG_INTERVAL_MS && percent !== lastLoggedPercent)) {
const intSec = Math.max((now - intervalTime) / 1000, 0.001);
const currentSpeedMb = (intervalBytes / 1024 / 1024 / intSec).toFixed(1);
const copiedMb = (copiedBytes / 1024 / 1024).toFixed(1);
if (typeof jobLog === 'function') {
jobLog(
`[${label}] Progress: ${percent}% `
+ `(${copiedMb}/${sourceSizeMb.toFixed(1)} MB, `
+ `${currentSpeedMb} MB/s, ETA: ${eta})\n`,
);
}
lastLoggedPercent = percent;
lastLoggedTime = now;
intervalBytes = 0;
intervalTime = now;
}
});
rd.pipe(wr);
});
// Verify destination size
if (!fs.existsSync(destination)) {
throw new Error(`Destination file does not exist after copy: ${destination}`);
}
const destinationSize = fs.statSync(destination).size; const destinationSize = fs.statSync(destination).size;
if (destinationSize !== sourceSize) { if (destinationSize !== sourceSize) {
throw new Error( throw new Error(
@@ -79,12 +171,13 @@ const copyWithProgress = async ({ source, destination, jobLog, updateProgress, l
); );
} }
const elapsedSeconds = Math.max((Date.now() - startedAt) / 1000, 0.001); const totalElapsed = Math.max((Date.now() - startedAt) / 1000, 0.001);
const averageSpeed = sourceSize / 1024 / 1024 / elapsedSeconds; const avgSpeed = (sourceSizeMb / totalElapsed).toFixed(1);
report( report(
100, 100,
`[${label}] Done: ${sourceSizeMb.toFixed(1)} MB copied in ` `[${label}] Done: ${sourceSizeMb.toFixed(1)} MB copied in `
+ `${elapsedSeconds.toFixed(1)}s (${averageSpeed.toFixed(1)} MB/s average)`, + `${totalElapsed.toFixed(1)}s (${avgSpeed} MB/s average)`,
'00:00:00',
); );
return; return;
} catch (error) { } catch (error) {
@@ -1,4 +1,4 @@
'use strict'; 'use strict';
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
@@ -41,7 +41,20 @@ const getCodecWeight = (stream) => {
const getSourceBitrateKbps = (stream) => { const getSourceBitrateKbps = (stream) => {
const br = parseInt(stream.bit_rate || '0', 10); const br = parseInt(stream.bit_rate || '0', 10);
return br > 0 ? Math.round(br / 1000) : 0; if (br > 0) return Math.round(br / 1000);
// Check tags for BPS (common in Matroska containers)
const tags = stream.tags || {};
const bpsEntry = Object.entries(tags).find(([key]) => {
const k = String(key).toLowerCase();
return k === 'bps' || k === 'bps-eng' || k === 'bps-ita' || k.startsWith('bps');
});
if (bpsEntry) {
const bps = parseInt(bpsEntry[1], 10);
if (bps > 0) return Math.round(bps / 1000);
}
return 0;
}; };
const calcOpusBitrate = (stream, isMovie, inputs) => { const calcOpusBitrate = (stream, isMovie, inputs) => {
@@ -52,18 +65,18 @@ const calcOpusBitrate = (stream, isMovie, inputs) => {
const serThreshold = parseInt(inputs.seriesMultiChannelThreshold || '384', 10); const serThreshold = parseInt(inputs.seriesMultiChannelThreshold || '384', 10);
if (ch <= 2) { if (ch <= 2) {
// Stereo: min(max, source) — no upcoding // Stereo: min(max, source) — strict no upcoding
return srcKbps > 0 ? Math.min(stereoMax, srcKbps) : stereoMax; return srcKbps > 0 ? Math.min(stereoMax, srcKbps) : stereoMax;
} }
// Multichannel // Multichannel target cap
if (isMovie) { const maxTarget = isMovie ? filmTarget : serThreshold;
return srcKbps > 0 ? Math.min(filmTarget, srcKbps) : filmTarget;
// Strict no upcoding: if source bitrate is known, never exceed source bitrate
if (srcKbps > 0) {
return Math.min(maxTarget, srcKbps);
} }
// Series return maxTarget;
// If bitrate is unknown (0), assume high-quality source -> use 384k
const effectiveKbps = srcKbps > 0 ? srcKbps : serThreshold;
return effectiveKbps >= serThreshold ? 384 : 256;
}; };
const details = () => ({ const details = () => ({
@@ -118,7 +131,8 @@ const plugin = async (args) => {
jobLog(`[audioSelectorClamping] Processing: ${filePath}\n`); jobLog(`[audioSelectorClamping] Processing: ${filePath}\n`);
const isMovie = variables.isMovie === 'true'; const isMovie = variables.isMovie === 'true';
jobLog(`[audioSelectorClamping] isMovie=${isMovie}\n`); const isAnimation = variables.isAnimation === 'true';
jobLog(`[audioSelectorClamping] isMovie=${isMovie} isAnimation=${isAnimation}\n`);
const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || []; const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || [];
const audioStreams = streams.filter((s) => s.codec_type === 'audio'); const audioStreams = streams.filter((s) => s.codec_type === 'audio');
@@ -76,7 +76,8 @@ const buildSuffix = (lang, stream) => {
const title = ((stream.tags && (stream.tags.title || stream.tags.TITLE)) || '').toLowerCase(); const title = ((stream.tags && (stream.tags.title || stream.tags.TITLE)) || '').toLowerCase();
const isForced = Boolean(forced || title.includes('forced')); const isForced = Boolean(forced || title.includes('forced'));
if (isForced) parts.push('forced'); if (isForced) parts.push('forced');
if (isForced || (stream.disposition && stream.disposition.default)) parts.push('default'); // Only ita.forced can be default. Do not add default to eng, non-forced, or other tracks.
if (lang === 'ita' && isForced) parts.push('default');
if (stream.disposition && stream.disposition.hearing_impaired) parts.push('sdh'); if (stream.disposition && stream.disposition.hearing_impaired) parts.push('sdh');
if (!parts.includes('sdh') && (title.includes('sdh') || title.includes('hearing impaired') if (!parts.includes('sdh') && (title.includes('sdh') || title.includes('hearing impaired')
|| title.includes('hearing_impaired'))) parts.push('sdh'); || title.includes('hearing_impaired'))) parts.push('sdh');
@@ -2,12 +2,13 @@
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { copyWithProgress } = require('../../../../FlowHelpers/1.0.0/copyWithProgress');
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
/** /**
* copyToWorkDir * copyToWorkDir
* Copies the source file to the worker's local workDir before processing. * Copies the source file into the worker's local workDir before processing.
* *
* Use this plugin BEFORE any transcode/extract plugin when: * Use this plugin BEFORE any transcode/extract plugin when:
* - The source file lives on a network share / mapped drive * - The source file lives on a network share / mapped drive
@@ -15,21 +16,20 @@ const fs = require('fs');
* - The network path is slow or unreliable during long encodes * - The network path is slow or unreliable during long encodes
* *
* Flow: * Flow:
* 1. Copies source → workDir/<basename>_old<ext> (streams, no RAM load) * 1. Copies source -> workDir/<basename>_old<ext> (streamed with real-time progress)
* 2. Updates outputFileObj._id to the local copy path * 2. Updates outputFileObj._id to the local copy path
* 3. Downstream plugins operate on the local copy * 3. Downstream plugins operate on the local copy
* 4. Tdarr replaces the original with the final output as usual * 4. Tdarr replaces the original with the final output via replaceOriginalFile
*/ */
const details = () => ({ const details = () => ({
name: 'Copy Source to Work Dir', name: 'Copy Source to Work Dir',
description: description:
'Copies the source file into the worker local workDir before processing. ' 'Copies the source file into the worker local workDir using a streamed, verified copy '
+ 'Use before transcode plugins when the source is on a slow network share ' + 'with real-time progress, speed, and ETA tracking. Use before transcode plugins '
+ 'to avoid I/O bottleneck during encode. ' + 'when the source is on a network share to avoid I/O bottlenecks during encoding.',
+ 'The local copy is used by all subsequent plugins in this flow.',
style: { borderColor: '#607D8B' }, style: { borderColor: '#607D8B' },
tags: 'utility', tags: 'utility,copy,progress',
isStartPlugin: false, isStartPlugin: false,
pType: '', pType: '',
requiresVersion: '2.11.01', requiresVersion: '2.11.01',
@@ -55,27 +55,35 @@ const details = () => ({
], ],
}); });
// ─── Plugin ───────────────────────────────────────────────────────────────────
const plugin = async (args) => { const plugin = async (args) => {
const { jobLog, inputFileObj, variables, workDir } = args; const { jobLog, inputFileObj, variables, workDir, updateWorker } = args;
const inputFile = inputFileObj._id; const inputFile = inputFileObj._id;
const ext = path.extname(inputFile); const ext = path.extname(inputFile);
const baseName = path.basename(inputFile, ext); const baseName = path.basename(inputFile, ext);
const localCopy = path.join(workDir, `${baseName}_old${ext}`); const localCopy = path.join(workDir, `${baseName}_old${ext}`);
const skipIfLocal = (args.inputs.skipIfLocal || 'true') === 'true';
jobLog(`[copyToWorkDir] Source : ${inputFile}\n`); jobLog(`[copyToWorkDir] Source : ${inputFile}\n`);
jobLog(`[copyToWorkDir] WorkDir: ${workDir}\n`); jobLog(`[copyToWorkDir] WorkDir: ${workDir}\n`);
// ── Skip only if file is already located inside workDir ────────────────── // Skip if source file is already inside workDir
const skipIfLocal = (args.inputs.skipIfLocal || 'true') === 'true'; const relativeInput = path.relative(path.resolve(workDir), path.resolve(inputFile));
if (skipIfLocal && inputFile.startsWith(workDir)) { if (
skipIfLocal
&& relativeInput !== '..'
&& !relativeInput.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relativeInput)
) {
jobLog('[copyToWorkDir] Source file is already inside workDir — skipping copy.\n'); jobLog('[copyToWorkDir] Source file is already inside workDir — skipping copy.\n');
return { outputNumber: 1, outputFileObj: inputFileObj, variables }; return { outputNumber: 1, outputFileObj: inputFileObj, variables };
} }
// ── Preserve original library paths for downstream plugins ────────────── if (!fs.existsSync(inputFile)) {
throw new Error(`[copyToWorkDir] Source file not found: ${inputFile}`);
}
// Preserve original library paths for downstream plugins
if (!variables.originalLibraryFile) { if (!variables.originalLibraryFile) {
variables.originalLibraryFile = inputFile; variables.originalLibraryFile = inputFile;
variables.originalLibraryDir = path.dirname(inputFile); variables.originalLibraryDir = path.dirname(inputFile);
@@ -83,43 +91,16 @@ const plugin = async (args) => {
variables.workDirSourceBaseName = baseName; variables.workDirSourceBaseName = baseName;
variables.workDirCopyApplied = 'true'; variables.workDirCopyApplied = 'true';
// ── Copy using streams (no RAM spike for large files) ──────────────────── jobLog(`[copyToWorkDir] Copying ${inputFile} to ${localCopy}\n`);
const sourceSize = fs.statSync(inputFile).size; await copyWithProgress({
const sourceSizeMb = (sourceSize / 1024 / 1024).toFixed(1); source: inputFile,
jobLog(`[copyToWorkDir] Copying ${sourceSizeMb} MB to: ${localCopy}\n`); destination: localCopy,
jobLog,
const startMs = Date.now(); updateWorker,
let copiedBytes = 0; updateProgress: args.updateProgress,
let nextProgressPercent = 10; label: 'copyToWorkDir',
await new Promise((resolve, reject) => {
const rd = fs.createReadStream(inputFile);
const wr = fs.createWriteStream(localCopy);
rd.on('error', reject);
wr.on('error', reject);
wr.on('finish', resolve);
rd.on('data', (chunk) => {
copiedBytes += chunk.length;
if (sourceSize <= 0) return;
const percent = Math.floor((copiedBytes / sourceSize) * 100);
if (percent < nextProgressPercent) return;
const elapsedSeconds = Math.max((Date.now() - startMs) / 1000, 0.1);
const copiedMb = (copiedBytes / 1024 / 1024).toFixed(1);
const speedMbPerSecond = (copiedBytes / 1024 / 1024 / elapsedSeconds).toFixed(1);
jobLog(`[copyToWorkDir] Progress: ${Math.min(percent, 100)}% (${copiedMb}/${sourceSizeMb} MB, ${speedMbPerSecond} MB/s)\n`);
nextProgressPercent = (Math.floor(percent / 10) + 1) * 10;
});
rd.pipe(wr);
}); });
const elapsed = ((Date.now() - startMs) / 1000).toFixed(1);
const sizeMb = (fs.statSync(localCopy).size / 1024 / 1024).toFixed(1);
jobLog(`[copyToWorkDir] Done: ${sizeMb} MB copied in ${elapsed}s\n`);
// Return updated inputFileObj pointing to the local copy
return { return {
outputNumber: 1, outputNumber: 1,
outputFileObj: { ...inputFileObj, _id: localCopy }, outputFileObj: { ...inputFileObj, _id: localCopy },
@@ -1,70 +0,0 @@
'use strict';
const path = require('path');
const fs = require('fs');
const { copyWithProgress } = require('../../../../FlowHelpers/1.0.0/copyWithProgress');
const details = () => ({
name: 'Copy Source to Work Dir (Progress)',
description:
'Copies the source file into the worker local workDir using a streamed, verified copy '
+ 'with progress updates. Use before transcode plugins for files on network shares.',
style: { borderColor: '#607D8B' },
tags: 'utility,copy,progress',
isStartPlugin: false,
pType: '',
requiresVersion: '2.11.01',
sidebarPosition: 0,
icon: 'faCopy',
inputs: [
{
label: 'Skip if already local',
name: 'skipIfLocal',
type: 'boolean',
defaultValue: 'true',
inputUI: { type: 'dropdown', options: ['true', 'false'] },
tooltip: 'Skip when the source is already inside workDir.',
},
],
outputs: [{ number: 1, tooltip: 'File is ready in workDir — continue to next plugin' }],
});
const plugin = async (args) => {
const { jobLog, inputFileObj, variables, workDir } = args;
const inputFile = inputFileObj._id;
const ext = path.extname(inputFile);
const baseName = path.basename(inputFile, ext);
const localCopy = path.join(workDir, `${baseName}_old${ext}`);
const skipIfLocal = (args.inputs.skipIfLocal || 'true') === 'true';
const relativeInput = path.relative(path.resolve(workDir), path.resolve(inputFile));
if (skipIfLocal && relativeInput !== '..' && !relativeInput.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relativeInput)) {
jobLog('[copyToWorkDir] Source file is already inside workDir — skipping copy.\n');
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
}
if (!fs.existsSync(inputFile)) throw new Error(`[copyToWorkDir] Source file not found: ${inputFile}`);
variables.originalLibraryFile = variables.originalLibraryFile || inputFile;
variables.originalLibraryDir = variables.originalLibraryDir || path.dirname(inputFile);
variables.workDirSourceBaseName = baseName;
variables.workDirCopyApplied = 'true';
jobLog(`[copyToWorkDir] Copying ${inputFile} to ${localCopy}\n`);
await copyWithProgress({
source: inputFile,
destination: localCopy,
jobLog,
updateProgress: args.updateProgress,
label: 'copyToWorkDir',
});
return {
outputNumber: 1,
outputFileObj: { ...inputFileObj, _id: localCopy },
variables,
};
};
module.exports.details = details;
module.exports.plugin = plugin;
@@ -35,9 +35,16 @@ const normalizeSubtitleParts = (parts) => {
const normalized = []; const normalized = [];
for (const part of parts) { for (const part of parts) {
const value = part.toLowerCase(); const value = part.toLowerCase();
if (LANGUAGE_CODES[value]) normalized.push(LANGUAGE_CODES[value]); if (LANGUAGE_CODES[value]) {
else if (!normalized.includes(value)) normalized.push(value); normalized.push(LANGUAGE_CODES[value]);
} else if (value === 'default') {
// Discard 'default'; it will only be re-added below if it is ita + forced
continue;
} else if (!normalized.includes(value)) {
normalized.push(value);
} }
}
// Only ita.forced can be marked as default; default must be removed from all other subtitles
if (normalized.includes('ita') && normalized.includes('forced') if (normalized.includes('ita') && normalized.includes('forced')
&& !normalized.includes('default')) { && !normalized.includes('default')) {
normalized.splice(normalized.indexOf('forced') + 1, 0, 'default'); normalized.splice(normalized.indexOf('forced') + 1, 0, 'default');
@@ -151,9 +158,10 @@ const plugin = async (args) => {
} }
for (const item of metadata) { for (const item of metadata) {
const isItaForced = item.language === 'ita' && item.forced;
const expected = `${mediaBaseName}.${item.language}` const expected = `${mediaBaseName}.${item.language}`
+ (item.forced ? '.forced' : '') + (item.forced ? '.forced' : '')
+ (item.forced || item.default ? '.default' : '') + (isItaForced ? '.default' : '')
+ (item.sdh ? '.sdh' : '') + '.srt'; + (item.sdh ? '.sdh' : '') + '.srt';
if (!fs.existsSync(path.join(finalDir, expected))) { if (!fs.existsSync(path.join(finalDir, expected))) {
jobLog(`[finalizeMedia] WARNING: Expected subtitle sidecar is missing: ${expected}\n`); jobLog(`[finalizeMedia] WARNING: Expected subtitle sidecar is missing: ${expected}\n`);
@@ -2,14 +2,17 @@
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { copyWithProgress } = require('../../../../FlowHelpers/1.0.0/copyWithProgress');
/* eslint-disable no-param-reassign */
const details = () => ({ const details = () => ({
name: 'Replace Original File', name: 'Replace Original File',
description: description:
'Copies the processed file to the original library path, verifies the copy, ' 'Copies the processed file to the original library path with streamed progress, '
+ 'and atomically replaces the original with rollback if replacement fails.', + 'size verification, an atomic rename, and rollback if replacement fails.',
style: { borderColor: '#FF9800' }, style: { borderColor: '#FF9800' },
tags: 'utility,replace', tags: 'utility,replace,progress',
isStartPlugin: false, isStartPlugin: false,
pType: '', pType: '',
requiresVersion: '2.11.01', requiresVersion: '2.11.01',
@@ -25,22 +28,13 @@ const details = () => ({
tooltip: 'Delete the processed file after it has replaced the original.', tooltip: 'Delete the processed file after it has replaced the original.',
}, },
], ],
outputs: [{ number: 1, tooltip: 'Original file replaced — continue to next plugin' }], outputs: [
{ number: 1, tooltip: 'Original file replaced — continue to next plugin' },
],
}); });
const copyAndVerify = (source, destination) => {
fs.copyFileSync(source, destination);
const sourceSize = fs.statSync(source).size;
const destinationSize = fs.statSync(destination).size;
if (sourceSize !== destinationSize) {
throw new Error(
`Copy size mismatch: expected ${sourceSize} bytes, got ${destinationSize}`,
);
}
};
const plugin = async (args) => { const plugin = async (args) => {
const { inputFileObj, variables, jobLog } = args; const { inputFileObj, variables, jobLog, updateWorker } = args;
const source = inputFileObj._id; const source = inputFileObj._id;
const configuredDestination = variables.originalLibraryFile || args.originalLibraryFile; const configuredDestination = variables.originalLibraryFile || args.originalLibraryFile;
const destination = typeof configuredDestination === 'string' const destination = typeof configuredDestination === 'string'
@@ -69,14 +63,28 @@ const plugin = async (args) => {
let installed = false; let installed = false;
try { try {
if (fs.existsSync(temporary)) fs.unlinkSync(temporary); if (fs.existsSync(temporary)) {
copyAndVerify(source, temporary); fs.unlinkSync(temporary);
}
jobLog(`[replaceOriginalFile] Copying processed file to temporary destination: ${temporary}\n`);
await copyWithProgress({
source,
destination: temporary,
jobLog,
updateWorker,
updateProgress: args.updateProgress,
label: 'replaceOriginalFile',
});
if (fs.existsSync(destination)) { if (fs.existsSync(destination)) {
if (fs.existsSync(backup)) fs.unlinkSync(backup); if (fs.existsSync(backup)) {
fs.unlinkSync(backup);
}
fs.renameSync(destination, backup); fs.renameSync(destination, backup);
backupCreated = true; backupCreated = true;
} }
fs.renameSync(temporary, destination); fs.renameSync(temporary, destination);
installed = true; installed = true;
@@ -87,6 +95,7 @@ const plugin = async (args) => {
jobLog(`[replaceOriginalFile] WARNING: Could not remove backup ${backup}: ${error.message}\n`); jobLog(`[replaceOriginalFile] WARNING: Could not remove backup ${backup}: ${error.message}\n`);
} }
} }
if ((args.inputs.deleteSource || 'true') === 'true') { if ((args.inputs.deleteSource || 'true') === 'true') {
try { try {
fs.unlinkSync(source); fs.unlinkSync(source);
@@ -102,9 +111,11 @@ const plugin = async (args) => {
variables, variables,
}; };
} catch (error) { } catch (error) {
if (fs.existsSync(temporary)) fs.unlinkSync(temporary); if (fs.existsSync(temporary)) {
try { fs.unlinkSync(temporary); } catch (_) { /* ignore */ }
}
if (backupCreated && !installed && !fs.existsSync(destination)) { if (backupCreated && !installed && !fs.existsSync(destination)) {
fs.renameSync(backup, destination); try { fs.renameSync(backup, destination); } catch (_) { /* ignore */ }
} }
throw new Error(`[replaceOriginalFile] Replacement failed: ${error.message}`); throw new Error(`[replaceOriginalFile] Replacement failed: ${error.message}`);
} }
@@ -1,99 +0,0 @@
'use strict';
const path = require('path');
const fs = require('fs');
const { copyWithProgress } = require('../../../../FlowHelpers/1.0.0/copyWithProgress');
const details = () => ({
name: 'Replace Original File (Progress)',
description:
'Copies the processed file to the original library path with streamed progress, '
+ 'size verification, an atomic rename, and rollback if replacement fails.',
style: { borderColor: '#FF9800' },
tags: 'utility,replace,progress',
isStartPlugin: false,
pType: '',
requiresVersion: '2.11.01',
sidebarPosition: 1,
icon: 'faExchangeAlt',
inputs: [
{
label: 'Delete processed work file',
name: 'deleteSource',
type: 'boolean',
defaultValue: 'true',
inputUI: { type: 'dropdown', options: ['true', 'false'] },
tooltip: 'Delete the processed file after it has replaced the original.',
},
],
outputs: [{ number: 1, tooltip: 'Original file replaced — continue to next plugin' }],
});
const plugin = async (args) => {
const { inputFileObj, variables, jobLog } = args;
const source = inputFileObj._id;
const configuredDestination = variables.originalLibraryFile || args.originalLibraryFile;
const destination = typeof configuredDestination === 'string'
? configuredDestination
: configuredDestination && configuredDestination._id;
if (!destination || typeof destination !== 'string') {
throw new Error('[replaceOriginalFile] No original library path is available');
}
if (!fs.existsSync(source)) throw new Error(`[replaceOriginalFile] Processed file not found: ${source}`);
if (path.resolve(source) === path.resolve(destination)) {
jobLog('[replaceOriginalFile] Processed file is already at the original path — skipping.\n');
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
}
const destinationDir = path.dirname(destination);
if (!fs.existsSync(destinationDir)) {
throw new Error(`[replaceOriginalFile] Destination directory does not exist: ${destinationDir}`);
}
const temporary = `${destination}.tdarr.tmp`;
const backup = `${destination}.partial.old`;
let backupCreated = false;
let installed = false;
try {
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
await copyWithProgress({
source,
destination: temporary,
jobLog,
updateProgress: args.updateProgress,
label: 'replaceOriginalFile',
});
if (fs.existsSync(destination)) {
if (fs.existsSync(backup)) fs.unlinkSync(backup);
fs.renameSync(destination, backup);
backupCreated = true;
}
fs.renameSync(temporary, destination);
installed = true;
if (backupCreated) {
try {
fs.unlinkSync(backup);
} catch (error) {
jobLog(`[replaceOriginalFile] WARNING: Could not remove backup ${backup}: ${error.message}\n`);
}
}
if ((args.inputs.deleteSource || 'true') === 'true') {
try {
fs.unlinkSync(source);
} catch (error) {
jobLog(`[replaceOriginalFile] WARNING: Could not delete processed file ${source}: ${error.message}\n`);
}
}
jobLog(`[replaceOriginalFile] Replaced original file: ${destination}\n`);
return { outputNumber: 1, outputFileObj: { ...inputFileObj, _id: destination }, variables };
} catch (error) {
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
if (backupCreated && !installed && !fs.existsSync(destination)) fs.renameSync(backup, destination);
throw new Error(`[replaceOriginalFile] Replacement failed: ${error.message}`);
}
};
module.exports.details = details;
module.exports.plugin = plugin;
+13 -18
View File
@@ -1,4 +1,4 @@
# Tdarr Local Flow Plugins — Integration Guide # Tdarr Local Flow Plugins — Integration Guide
## Panoramica ## Panoramica
@@ -16,9 +16,8 @@ Questa guida descrive come assemblare i 7 plugin nel canvas Flow di Tdarr, confi
│ ├── setAnimationFlag/1.0.0/index.js │ ├── setAnimationFlag/1.0.0/index.js
│ ├── extractChaptersXml/1.0.0/index.js │ ├── extractChaptersXml/1.0.0/index.js
│ ├── finalizeMedia/1.0.0/index.js │ ├── finalizeMedia/1.0.0/index.js
│ ├── copyToWorkDir/2.0.0/index.js │ ├── copyToWorkDir/1.0.0/index.js
│ ├── replaceOriginalFile/1.0.0/index.js │ └── replaceOriginalFile/1.0.0/index.js
│ └── replaceOriginalFile/2.0.0/index.js
├── audio/ ├── audio/
│ ├── audioSelectorClamping/1.0.0/index.js │ ├── audioSelectorClamping/1.0.0/index.js
│ └── backupSourceAudio/1.0.0/index.js │ └── backupSourceAudio/1.0.0/index.js
@@ -72,8 +71,9 @@ Nella Web UI: **Settings → Nodes → [tuo worker Windows]**
> Il plugin `extractChaptersXml` deriva automaticamente il path di `mkvextract.exe` dalla stessa cartella di `mkvpropedit.exe`. > Il plugin `extractChaptersXml` deriva automaticamente il path di `mkvextract.exe` dalla stessa cartella di `mkvpropedit.exe`.
`copyToWorkDir` crea la copia temporanea con suffisso `_old` e conserva il `copyToWorkDir` crea la copia temporanea con suffisso `_old` e conserva il
percorso originale in `variables.originalLibraryFile`. La versione 2.0.0 percorso originale in `variables.originalLibraryFile`. Il plugin
trasmette il progresso della copia e verifica la dimensione finale. trasmette in tempo reale il progresso della copia (percentuale, velocità, ETA)
alla WebUI e ai log del worker, verificando la dimensione finale.
### 3.3 Configurazione PATH Windows ### 3.3 Configurazione PATH Windows
@@ -89,7 +89,7 @@ Aggiungi al PATH di sistema:
### 4.1 Ordine dei nodi ### 4.1 Ordine dei nodi
``` ```
[copyToWorkDir 2.0.0] (recommended for network libraries) [copyToWorkDir] (consigliato per librerie di rete)
▼ ▼
[mediaAnalyzerGOP] [mediaAnalyzerGOP]
│ │
@@ -114,20 +114,15 @@ Aggiungi al PATH di sistema:
▼ ▼
[transcode2PassX265] [transcode2PassX265]
▼ ▼
[replaceOriginalFile 2.0.0] [replaceOriginalFile]
▼ ▼
[finalizeMedia] [finalizeMedia]
``` ```
`copyToWorkDir` 2.0.0 is optional but recommended when the source library is on `copyToWorkDir` è facoltativo ma consigliato quando la libreria sorgente si trova su una condivisione di rete (NAS/SMB).
a network share. Place it before the first analysis/transcode node; it records Posizionalo prima dell'analisi/transcodifica: registra il percorso originale della libreria per `replaceOriginalFile`.
the library path for `replaceOriginalFile` 2.0.0. Entrambe le operazioni di copia/sostituzione trasmettono la percentuale di avanzamento alla WebUI di Tdarr tramite il worker callback,
Both progress-enabled copy operations verify the destination size and retry registrano velocità ed ETA, verificano l'integrità del file per dimensione e supportano il retry automatico.
failed writes twice after removing the partial destination. Their `Copy method` I plugin utilizzano `FlowHelpers/1.0.0/copyWithProgress.js`.
uses the operating system's native file-copy path, reports interval speed,
verifies the destination size, and retries failed writes.
Both progress-enabled plugins require
`FlowHelpers/1.0.0/copyWithProgress.js`; the installer downloads it
automatically.
### 4.2 Connessioni nel Canvas ### 4.2 Connessioni nel Canvas
+61 -20
View File
@@ -1,4 +1,4 @@
'use strict'; 'use strict';
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const fs = require('fs'); const fs = require('fs');
@@ -127,22 +127,53 @@ const AUDIO_INPUTS = {
eq(o.variables.audioItaOpusBitrate, '384', 'Series DTS 768k >= 384 -> Opus=384'); eq(o.variables.audioItaOpusBitrate, '384', 'Series DTS 768k >= 384 -> Opus=384');
cleanup(a); } cleanup(a); }
// Series AC3 320k < 384 threshold -> 256 // Series AC3 320k < 384 target -> 320 (strict no upcoding)
{ const streams = [ { const streams = [
makeStream({ index:0, codec_type:'video' }), makeStream({ index:0, codec_type:'video' }),
makeStream({ index:1, codec_type:'audio', codec_name:'ac3', bit_rate:'320000', channels:6, tags:{language:'ita'} }), makeStream({ index:1, codec_type:'audio', codec_name:'ac3', bit_rate:'320000', channels:6, tags:{language:'ita'} }),
]; ];
const a = makeArgs('/media/Sonarr/Show/ep.mkv', streams, AUDIO_INPUTS, { isMovie:'false' }); const a = makeArgs('/media/Sonarr/Show/ep.mkv', streams, AUDIO_INPUTS, { isMovie:'false', isAnimation:'false' });
const o = await audioPlugin(a); const o = await audioPlugin(a);
eq(o.variables.audioItaOpusBitrate, '256', 'Series AC3 320k < 384 -> Opus=256'); eq(o.variables.audioItaOpusBitrate, '320', 'Series AC3 320k -> min(384,320)=320 (no upcoding)');
cleanup(a); } cleanup(a); }
// NEW: Series bitrate unknown (0) -> safe default 384k // Series multichannel low bitrate 192k -> 192 (strict no upcoding to 256)
{ const streams = [
makeStream({ index:0, codec_type:'video' }),
makeStream({ index:1, codec_type:'audio', codec_name:'aac', bit_rate:'192000', channels:6, tags:{language:'ita'} }),
];
const a = makeArgs('/media/Sonarr/Show/ep.mkv', streams, AUDIO_INPUTS, { isMovie:'false', isAnimation:'false' });
const o = await audioPlugin(a);
eq(o.variables.audioItaOpusBitrate, '192', 'Series 192k multichannel -> stays 192k (never upcoded to 256)');
cleanup(a); }
// Series / Animation with 256k threshold -> capped at 256k
{ const streams = [
makeStream({ index:0, codec_type:'video' }),
makeStream({ index:1, codec_type:'audio', codec_name:'ac3', bit_rate:'448000', channels:6, tags:{language:'ita'} }),
];
const customInputs = Object.assign({}, AUDIO_INPUTS, { seriesMultiChannelThreshold: '256' });
const a = makeArgs('/media/Sonarr/Naruto/ep.mkv', streams, customInputs, { isMovie:'false' });
const o = await audioPlugin(a);
eq(o.variables.audioItaOpusBitrate, '256', 'Animation 448k multichannel with 256k threshold -> capped at 256k');
cleanup(a); }
// Detection via tags.BPS when stream.bit_rate is 0
{ const streams = [
makeStream({ index:0, codec_type:'video' }),
makeStream({ index:1, codec_type:'audio', codec_name:'ac3', bit_rate:'0', channels:6, tags:{language:'ita', BPS:'384000'} }),
];
const a = makeArgs('/media/Sonarr/Show/ep.mkv', streams, AUDIO_INPUTS, { isMovie:'false', isAnimation:'false' });
const o = await audioPlugin(a);
eq(o.variables.audioItaOpusBitrate, '384', 'Bitrate detected from tags.BPS -> 384k');
cleanup(a); }
// Series bitrate unknown (0) and no BPS tag -> safe default 384k
{ const streams = [ { const streams = [
makeStream({ index:0, codec_type:'video' }), makeStream({ index:0, codec_type:'video' }),
makeStream({ index:1, codec_type:'audio', codec_name:'dts', bit_rate:'0', channels:6, tags:{language:'ita'} }), makeStream({ index:1, codec_type:'audio', codec_name:'dts', bit_rate:'0', channels:6, tags:{language:'ita'} }),
]; ];
const a = makeArgs('/media/Sonarr/Show/ep.mkv', streams, AUDIO_INPUTS, { isMovie:'false' }); const a = makeArgs('/media/Sonarr/Show/ep.mkv', streams, AUDIO_INPUTS, { isMovie:'false', isAnimation:'false' });
const o = await audioPlugin(a); const o = await audioPlugin(a);
eq(o.variables.audioItaOpusBitrate, '384', 'Series unknown bitrate (0) -> safe default 384k'); eq(o.variables.audioItaOpusBitrate, '384', 'Series unknown bitrate (0) -> safe default 384k');
cleanup(a); } cleanup(a); }
@@ -208,25 +239,35 @@ const AUDIO_INPUTS = {
ok(!transcodeCode.includes("'-map_chapters -1'"), 'pass 1 separates -map_chapters from its value'); ok(!transcodeCode.includes("'-map_chapters -1'"), 'pass 1 separates -map_chapters from its value');
ok(transcodeCode.includes('const statsForX265 = path.basename(statsFile)'), 'x265 stats file is relative to workDir on Windows'); ok(transcodeCode.includes('const statsForX265 = path.basename(statsFile)'), 'x265 stats file is relative to workDir on Windows');
// ── copyToWorkDir & replaceOriginalFile v1.0.0 checks ─────────────────────
const copyPluginCode = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js'), 'utf8'); const copyPluginCode = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js'), 'utf8');
ok(copyPluginCode.includes('[copyToWorkDir] Progress:'), 'copyToWorkDir reports copy progress in the job log'); ok(copyPluginCode.includes('copyWithProgress'), 'copyToWorkDir v1 uses copyWithProgress');
const copyPluginV2Code = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/copyToWorkDir/2.0.0/index.js'), 'utf8'); ok(copyPluginCode.includes('updateWorker'), 'copyToWorkDir v1 forwards updateWorker callback');
ok(copyPluginV2Code.includes('updateProgress: args.updateProgress'), 'copyToWorkDir v2 forwards UI progress'); ok(!fs.existsSync(path.join(ROOT, 'LocalFlowPlugins/utility/copyToWorkDir/2.0.0')), 'copyToWorkDir v2.0.0 is deleted');
const replacePluginCode = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/replaceOriginalFile/2.0.0/index.js'), 'utf8');
ok(replacePluginCode.includes('.partial.old'), 'replaceOriginalFile v2 uses a rollback backup'); const replacePluginCode = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js'), 'utf8');
ok(replacePluginCode.includes('fs.renameSync(temporary, destination)'), 'replaceOriginalFile v2 atomically installs the verified copy'); ok(replacePluginCode.includes('copyWithProgress'), 'replaceOriginalFile v1 uses copyWithProgress');
const replacePluginV1Code = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js'), 'utf8'); ok(replacePluginCode.includes('updateWorker'), 'replaceOriginalFile v1 forwards updateWorker callback');
ok(replacePluginV1Code.includes('copyAndVerify'), 'replaceOriginalFile v1 verifies the copied file'); ok(replacePluginCode.includes('.partial.old'), 'replaceOriginalFile v1 uses a rollback backup');
ok(!replacePluginV1Code.includes('updateProgress'), 'replaceOriginalFile v1 has no progress callback'); ok(replacePluginCode.includes('fs.renameSync(temporary, destination)'), 'replaceOriginalFile v1 atomically installs the verified copy');
ok(!fs.existsSync(path.join(ROOT, 'LocalFlowPlugins/utility/replaceOriginalFile/2.0.0')), 'replaceOriginalFile v2.0.0 is deleted');
const copyHelperCode = require('fs').readFileSync(path.join(ROOT, 'FlowHelpers/1.0.0/copyWithProgress.js'), 'utf8'); const copyHelperCode = require('fs').readFileSync(path.join(ROOT, 'FlowHelpers/1.0.0/copyWithProgress.js'), 'utf8');
ok(copyHelperCode.includes('MAX_RETRIES = 2'), 'copy helper retries failed writes'); ok(copyHelperCode.includes('MAX_RETRIES = 2'), 'copy helper retries failed writes');
ok(copyHelperCode.includes('Copy size mismatch'), 'copy helper verifies destination size'); ok(copyHelperCode.includes('Copy size mismatch'), 'copy helper verifies destination size');
ok(copyHelperCode.includes('fs.unlinkSync(destination)'), 'copy helper removes partial copies before retrying'); ok(copyHelperCode.includes('fs.unlinkSync(destination)'), 'copy helper removes partial copies before retrying');
ok(copyHelperCode.includes('fs.promises.copyFile(source, destination)'), 'copy helper uses native file copying'); ok(copyHelperCode.includes('formatEta'), 'copy helper formats ETA');
ok(copyHelperCode.includes('PROGRESS_INTERVAL_MS = 250'), 'copy helper polls progress frequently'); ok(copyHelperCode.includes('updateWorker'), 'copy helper updates Tdarr worker progress');
ok(copyHelperCode.includes('intervalSpeed'), 'copy helper reports interval speed');
ok(!copyPluginV2Code.includes('copyMethod'), 'copyToWorkDir v2 has no copy method setting'); // ── Subtitle default naming checks ─────────────────────────────────────────
ok(!replacePluginCode.includes('bufferSizeMb'), 'replaceOriginalFile v2 has no buffer setting'); console.log('\n── Subtitle default naming rules ────────────────────');
const subPluginCode = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js'), 'utf8');
ok(subPluginCode.includes("lang === 'ita' && isForced"), 'extractSubtitlesAndOcr: only ita.forced gets default flag');
ok(!subPluginCode.includes("if (isForced || (stream.disposition && stream.disposition.default))"), 'extractSubtitlesAndOcr: old buggy default condition removed');
const finalizeCode = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js'), 'utf8');
ok(finalizeCode.includes("value === 'default'"), 'finalizeMedia: strips default before checking for ita.forced');
ok(finalizeCode.includes("isItaForced ? '.default' : ''"), 'finalizeMedia: expected subtitle sidecar only expects .default on ita.forced');
// ── Summary ─────────────────────────────────────────────────────────────── // ── Summary ───────────────────────────────────────────────────────────────
console.log(`\n${'='.repeat(55)}`); console.log(`\n${'='.repeat(55)}`);
+2 -1
View File
@@ -1,4 +1,4 @@
'use strict'; 'use strict';
/** /**
* run-tests.js * run-tests.js
@@ -17,6 +17,7 @@ const tests = [
{ name: 'Test 2 — Anime 25fps DTS ITA (Naruto)', file: './test-cases/test2-anime-25fps', heavy: false }, { name: 'Test 2 — Anime 25fps DTS ITA (Naruto)', file: './test-cases/test2-anime-25fps', heavy: false },
{ name: 'Test 3 — Ultrawide 3840x1600 Scale', file: './test-cases/test3-ultrawide', heavy: true }, { name: 'Test 3 — Ultrawide 3840x1600 Scale', file: './test-cases/test3-ultrawide', heavy: true },
{ name: 'Test 4 — Subtitle Extraction (forced + SDH)', file: './test-cases/test4-subtitles', heavy: false }, { name: 'Test 4 — Subtitle Extraction (forced + SDH)', file: './test-cases/test4-subtitles', heavy: false },
{ name: 'Test 5 — Copy/Replace Progress & Subtitle Rules', file: './test-cases/test-copy-and-subtitles', heavy: false },
]; ];
async function main() { async function main() {
+146
View File
@@ -0,0 +1,146 @@
'use strict';
const path = require('path');
const fs = require('fs');
const os = require('os');
const { copyWithProgress } = require('../../FlowHelpers/1.0.0/copyWithProgress');
const { plugin: copyPlugin } = require('../../LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index');
const { plugin: replacePlugin } = require('../../LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index');
const { plugin: finalizePlugin } = require('../../LocalFlowPlugins/utility/finalizeMedia/1.0.0/index');
const assert = (condition, message) => {
if (!condition) throw new Error(`FAILED: ${message}`);
console.log(` [OK] ${message}`);
};
async function testCopyAndSubtitles() {
console.log('\n=== Testing copyWithProgress, copyToWorkDir, replaceOriginalFile & subtitle naming ===\n');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tdarr-test-copy-'));
try {
// 1. Test copyWithProgress with mock worker updates
const srcFile = path.join(tmpDir, 'source.bin');
const dstFile = path.join(tmpDir, 'dest.bin');
const dummyData = Buffer.alloc(5 * 1024 * 1024, 0xAB); // 5 MB
fs.writeFileSync(srcFile, dummyData);
const workerUpdates = [];
const jobLogs = [];
await copyWithProgress({
source: srcFile,
destination: dstFile,
jobLog: (msg) => jobLogs.push(msg),
updateWorker: (data) => workerUpdates.push(data),
label: 'testCopy',
});
assert(fs.existsSync(dstFile), 'Destination file created');
assert(fs.statSync(dstFile).size === dummyData.length, 'Destination file size matches source');
assert(workerUpdates.length > 0, 'Worker updates were received');
assert(workerUpdates[workerUpdates.length - 1].percentage === 100, 'Final worker percentage is 100');
assert(jobLogs.some((l) => l.includes('Done:')), 'jobLog recorded completion');
// 2. Test copyToWorkDir plugin
const workDir = path.join(tmpDir, 'workdir');
fs.mkdirSync(workDir);
const copyArgs = {
inputFileObj: { _id: srcFile },
variables: {},
workDir,
inputs: { skipIfLocal: 'true' },
jobLog: () => {},
updateWorker: (data) => workerUpdates.push(data),
};
const copyResult = await copyPlugin(copyArgs);
assert(copyResult.outputNumber === 1, 'copyToWorkDir returned output 1');
assert(fs.existsSync(copyResult.outputFileObj._id), 'Local copy file exists in workDir');
assert(copyResult.variables.originalLibraryFile === srcFile, 'originalLibraryFile saved in variables');
assert(copyResult.variables.workDirCopyApplied === 'true', 'workDirCopyApplied marked true');
// 3. Test replaceOriginalFile plugin
const processedFile = path.join(workDir, 'source_processed.bin');
const processedData = Buffer.alloc(3 * 1024 * 1024, 0xCD); // 3 MB
fs.writeFileSync(processedFile, processedData);
const replaceArgs = {
inputFileObj: { _id: processedFile },
variables: { originalLibraryFile: srcFile },
workDir,
inputs: { deleteSource: 'true' },
jobLog: () => {},
updateWorker: (data) => workerUpdates.push(data),
};
const replaceResult = await replacePlugin(replaceArgs);
assert(replaceResult.outputNumber === 1, 'replaceOriginalFile returned output 1');
assert(fs.existsSync(srcFile), 'Original library file exists at destination');
assert(fs.statSync(srcFile).size === processedData.length, 'Original file successfully replaced with processed file');
assert(!fs.existsSync(processedFile), 'Processed work file deleted from workDir');
assert(!fs.existsSync(`${srcFile}.partial.old`), 'Temporary backup deleted');
assert(!fs.existsSync(`${srcFile}.tdarr.tmp`), 'Temporary file deleted');
// 4. Test finalizeMedia subtitle renaming & default stripping
const libraryDir = path.join(tmpDir, 'library');
fs.mkdirSync(libraryDir);
const mediaFile = path.join(libraryDir, 'Movie.mkv');
fs.writeFileSync(mediaFile, Buffer.alloc(100));
// Create a variety of subtitles:
// - eng.forced.default.srt (default should be stripped -> eng.forced.srt)
// - ita.forced.srt (default should be added -> ita.forced.default.srt)
// - ita.forced.default.srt (already correct -> stays ita.forced.default.srt)
// - ita.default.srt (default should be stripped -> ita.srt)
// - eng.default.srt (default should be stripped -> eng.srt)
// - eng.sdh.default.srt (default should be stripped -> eng.sdh.srt)
fs.writeFileSync(path.join(libraryDir, 'Movie.eng.forced.default.srt'), 'dummy 1');
fs.writeFileSync(path.join(libraryDir, 'Movie.ita.forced.srt'), 'dummy 2');
fs.writeFileSync(path.join(libraryDir, 'Movie.ita.default.srt'), 'dummy 3');
fs.writeFileSync(path.join(libraryDir, 'Movie.eng.default.srt'), 'dummy 4');
fs.writeFileSync(path.join(libraryDir, 'Movie.eng.sdh.default.srt'), 'dummy 5');
const finalizeArgs = {
inputFileObj: {
_id: mediaFile,
ffProbeData: { streams: [] },
},
variables: { originalLibraryDir: libraryDir },
inputs: { removeEmptySourceDir: 'false' },
jobLog: () => {},
};
await finalizePlugin(finalizeArgs);
assert(!fs.existsSync(path.join(libraryDir, 'Movie.eng.forced.default.srt')), 'eng.forced.default.srt was renamed');
assert(fs.existsSync(path.join(libraryDir, 'Movie.eng.forced.srt')), 'eng.forced.srt exists (default stripped)');
assert(!fs.existsSync(path.join(libraryDir, 'Movie.ita.forced.srt')), 'ita.forced.srt was normalized');
assert(fs.existsSync(path.join(libraryDir, 'Movie.ita.forced.default.srt')), 'ita.forced.default.srt exists');
assert(!fs.existsSync(path.join(libraryDir, 'Movie.ita.default.srt')), 'ita.default.srt was renamed');
assert(fs.existsSync(path.join(libraryDir, 'Movie.ita.srt')), 'ita.srt exists (default stripped)');
assert(!fs.existsSync(path.join(libraryDir, 'Movie.eng.default.srt')), 'eng.default.srt was renamed');
assert(fs.existsSync(path.join(libraryDir, 'Movie.eng.srt')), 'eng.srt exists (default stripped)');
assert(!fs.existsSync(path.join(libraryDir, 'Movie.eng.sdh.default.srt')), 'eng.sdh.default.srt was renamed');
assert(fs.existsSync(path.join(libraryDir, 'Movie.eng.sdh.srt')), 'eng.sdh.srt exists (default stripped)');
console.log('\n=== ALL COPY, MOVE & SUBTITLE TESTS PASSED ===\n');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
module.exports = testCopyAndSubtitles;
if (require.main === module) {
testCopyAndSubtitles().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});
}