Fix copy progress, remove v2.0.0, fix subtitle default naming, enforce strict no-upcoding on audio
Build / Lint & Test (push) Successful in 3m15s
Build / Lint & Test (push) Successful in 3m15s
This commit is contained in:
1 parent
7ee5a5bc48
commit
86448988ed
12 files changed
+465
-343
No files matched your search
@@ -1,27 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
const PROGRESS_INTERVAL_MS = 250;
|
||||
const LOG_INTERVAL_MS = 3000;
|
||||
|
||||
const sleep = (milliseconds) => new Promise((resolve) => {
|
||||
setTimeout(resolve, milliseconds);
|
||||
});
|
||||
|
||||
const copyWithProgress = async ({ source, destination, jobLog, updateProgress, label }) => {
|
||||
const sourceSize = fs.statSync(source).size;
|
||||
const sourceSizeMb = sourceSize / 1024 / 1024;
|
||||
let lastError;
|
||||
|
||||
const report = (progress, message) => {
|
||||
if (typeof updateProgress === 'function') {
|
||||
updateProgress({ progress, jobLog: message });
|
||||
}
|
||||
if (typeof jobLog === 'function') jobLog(`${message}\n`);
|
||||
const formatEta = (seconds) => {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '00:00:00';
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
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')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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') {
|
||||
updateProgress({
|
||||
progress: Math.min(percent, 100),
|
||||
jobLog: message,
|
||||
});
|
||||
}
|
||||
if (message && typeof jobLog === 'function') {
|
||||
jobLog(`${message}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
|
||||
if (attempt > 0) {
|
||||
report(0, `[${label}] Retrying copy (${attempt}/${MAX_RETRIES})...`);
|
||||
@@ -29,49 +72,98 @@ const copyWithProgress = async ({ source, destination, jobLog, updateProgress, l
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(destination)) fs.unlinkSync(destination);
|
||||
if (fs.existsSync(destination)) {
|
||||
fs.unlinkSync(destination);
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
let lastReportedPercent = -1;
|
||||
let previousBytes = 0;
|
||||
let previousAt = startedAt;
|
||||
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;
|
||||
// Special case: empty file (0 bytes)
|
||||
if (sourceSize === 0) {
|
||||
fs.writeFileSync(destination, Buffer.alloc(0));
|
||||
report(100, `[${label}] Copied 0-byte file successfully.`, '00:00:00');
|
||||
return;
|
||||
}
|
||||
const percent = sourceSize === 0
|
||||
? 99
|
||||
: Math.min(Math.floor((copiedBytes / sourceSize) * 100), 99);
|
||||
if (percent <= lastReportedPercent) return;
|
||||
|
||||
const startedAt = Date.now();
|
||||
let copiedBytes = 0;
|
||||
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 intervalSeconds = Math.max((now - previousAt) / 1000, 0.001);
|
||||
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);
|
||||
const percent = Math.min(Math.floor((copiedBytes / sourceSize) * 100), 99);
|
||||
|
||||
try {
|
||||
await fs.promises.copyFile(source, destination);
|
||||
} finally {
|
||||
copying = false;
|
||||
clearInterval(progressTimer);
|
||||
// Calculate ETA and speed
|
||||
const totalElapsedSec = Math.max((now - startedAt) / 1000, 0.001);
|
||||
const avgSpeedBps = copiedBytes / totalElapsedSec;
|
||||
const remainingBytes = Math.max(0, sourceSize - copiedBytes);
|
||||
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;
|
||||
if (destinationSize !== sourceSize) {
|
||||
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 averageSpeed = sourceSize / 1024 / 1024 / elapsedSeconds;
|
||||
const totalElapsed = Math.max((Date.now() - startedAt) / 1000, 0.001);
|
||||
const avgSpeed = (sourceSizeMb / totalElapsed).toFixed(1);
|
||||
report(
|
||||
100,
|
||||
`[${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;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
'use strict';
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
@@ -41,7 +41,20 @@ const getCodecWeight = (stream) => {
|
||||
|
||||
const getSourceBitrateKbps = (stream) => {
|
||||
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) => {
|
||||
@@ -52,18 +65,18 @@ const calcOpusBitrate = (stream, isMovie, inputs) => {
|
||||
const serThreshold = parseInt(inputs.seriesMultiChannelThreshold || '384', 10);
|
||||
|
||||
if (ch <= 2) {
|
||||
// Stereo: min(max, source) — no upcoding
|
||||
// Stereo: min(max, source) — strict no upcoding
|
||||
return srcKbps > 0 ? Math.min(stereoMax, srcKbps) : stereoMax;
|
||||
}
|
||||
|
||||
// Multichannel
|
||||
if (isMovie) {
|
||||
return srcKbps > 0 ? Math.min(filmTarget, srcKbps) : filmTarget;
|
||||
// Multichannel target cap
|
||||
const maxTarget = isMovie ? filmTarget : serThreshold;
|
||||
|
||||
// Strict no upcoding: if source bitrate is known, never exceed source bitrate
|
||||
if (srcKbps > 0) {
|
||||
return Math.min(maxTarget, srcKbps);
|
||||
}
|
||||
// Series
|
||||
// If bitrate is unknown (0), assume high-quality source -> use 384k
|
||||
const effectiveKbps = srcKbps > 0 ? srcKbps : serThreshold;
|
||||
return effectiveKbps >= serThreshold ? 384 : 256;
|
||||
return maxTarget;
|
||||
};
|
||||
|
||||
const details = () => ({
|
||||
@@ -118,7 +131,8 @@ const plugin = async (args) => {
|
||||
jobLog(`[audioSelectorClamping] Processing: ${filePath}\n`);
|
||||
|
||||
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 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 isForced = Boolean(forced || title.includes('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 (!parts.includes('sdh') && (title.includes('sdh') || title.includes('hearing impaired')
|
||||
|| title.includes('hearing_impaired'))) parts.push('sdh');
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { copyWithProgress } = require('../../../../FlowHelpers/1.0.0/copyWithProgress');
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
/**
|
||||
* 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:
|
||||
* - 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
|
||||
*
|
||||
* 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
|
||||
* 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 = () => ({
|
||||
name: 'Copy Source to Work Dir',
|
||||
description:
|
||||
'Copies the source file into the worker local workDir before processing. '
|
||||
+ 'Use before transcode plugins when the source is on a slow network share '
|
||||
+ 'to avoid I/O bottleneck during encode. '
|
||||
+ 'The local copy is used by all subsequent plugins in this flow.',
|
||||
'Copies the source file into the worker local workDir using a streamed, verified copy '
|
||||
+ 'with real-time progress, speed, and ETA tracking. Use before transcode plugins '
|
||||
+ 'when the source is on a network share to avoid I/O bottlenecks during encoding.',
|
||||
style: { borderColor: '#607D8B' },
|
||||
tags: 'utility',
|
||||
tags: 'utility,copy,progress',
|
||||
isStartPlugin: false,
|
||||
pType: '',
|
||||
requiresVersion: '2.11.01',
|
||||
@@ -55,27 +55,35 @@ const details = () => ({
|
||||
],
|
||||
});
|
||||
|
||||
// ─── Plugin ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const plugin = async (args) => {
|
||||
const { jobLog, inputFileObj, variables, workDir } = args;
|
||||
const { jobLog, inputFileObj, variables, workDir, updateWorker } = 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';
|
||||
|
||||
jobLog(`[copyToWorkDir] Source : ${inputFile}\n`);
|
||||
jobLog(`[copyToWorkDir] WorkDir: ${workDir}\n`);
|
||||
|
||||
// ── Skip only if file is already located inside workDir ──────────────────
|
||||
const skipIfLocal = (args.inputs.skipIfLocal || 'true') === 'true';
|
||||
if (skipIfLocal && inputFile.startsWith(workDir)) {
|
||||
// Skip if source file is already inside workDir
|
||||
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 };
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
variables.originalLibraryFile = inputFile;
|
||||
variables.originalLibraryDir = path.dirname(inputFile);
|
||||
@@ -83,43 +91,16 @@ const plugin = async (args) => {
|
||||
variables.workDirSourceBaseName = baseName;
|
||||
variables.workDirCopyApplied = 'true';
|
||||
|
||||
// ── Copy using streams (no RAM spike for large files) ────────────────────
|
||||
const sourceSize = fs.statSync(inputFile).size;
|
||||
const sourceSizeMb = (sourceSize / 1024 / 1024).toFixed(1);
|
||||
jobLog(`[copyToWorkDir] Copying ${sourceSizeMb} MB to: ${localCopy}\n`);
|
||||
|
||||
const startMs = Date.now();
|
||||
let copiedBytes = 0;
|
||||
let nextProgressPercent = 10;
|
||||
|
||||
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);
|
||||
jobLog(`[copyToWorkDir] Copying ${inputFile} to ${localCopy}\n`);
|
||||
await copyWithProgress({
|
||||
source: inputFile,
|
||||
destination: localCopy,
|
||||
jobLog,
|
||||
updateWorker,
|
||||
updateProgress: args.updateProgress,
|
||||
label: 'copyToWorkDir',
|
||||
});
|
||||
|
||||
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 {
|
||||
outputNumber: 1,
|
||||
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 = [];
|
||||
for (const part of parts) {
|
||||
const value = part.toLowerCase();
|
||||
if (LANGUAGE_CODES[value]) normalized.push(LANGUAGE_CODES[value]);
|
||||
else if (!normalized.includes(value)) normalized.push(value);
|
||||
if (LANGUAGE_CODES[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')
|
||||
&& !normalized.includes('default')) {
|
||||
normalized.splice(normalized.indexOf('forced') + 1, 0, 'default');
|
||||
@@ -151,9 +158,10 @@ const plugin = async (args) => {
|
||||
}
|
||||
|
||||
for (const item of metadata) {
|
||||
const isItaForced = item.language === 'ita' && item.forced;
|
||||
const expected = `${mediaBaseName}.${item.language}`
|
||||
+ (item.forced ? '.forced' : '')
|
||||
+ (item.forced || item.default ? '.default' : '')
|
||||
+ (isItaForced ? '.default' : '')
|
||||
+ (item.sdh ? '.sdh' : '') + '.srt';
|
||||
if (!fs.existsSync(path.join(finalDir, expected))) {
|
||||
jobLog(`[finalizeMedia] WARNING: Expected subtitle sidecar is missing: ${expected}\n`);
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { copyWithProgress } = require('../../../../FlowHelpers/1.0.0/copyWithProgress');
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
const details = () => ({
|
||||
name: 'Replace Original File',
|
||||
description:
|
||||
'Copies the processed file to the original library path, verifies the copy, '
|
||||
+ 'and atomically replaces the original with rollback if replacement fails.',
|
||||
'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',
|
||||
tags: 'utility,replace,progress',
|
||||
isStartPlugin: false,
|
||||
pType: '',
|
||||
requiresVersion: '2.11.01',
|
||||
@@ -25,22 +28,13 @@ const details = () => ({
|
||||
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 { inputFileObj, variables, jobLog } = args;
|
||||
const { inputFileObj, variables, jobLog, updateWorker } = args;
|
||||
const source = inputFileObj._id;
|
||||
const configuredDestination = variables.originalLibraryFile || args.originalLibraryFile;
|
||||
const destination = typeof configuredDestination === 'string'
|
||||
@@ -69,14 +63,28 @@ const plugin = async (args) => {
|
||||
let installed = false;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
||||
copyAndVerify(source, temporary);
|
||||
if (fs.existsSync(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(backup)) fs.unlinkSync(backup);
|
||||
if (fs.existsSync(backup)) {
|
||||
fs.unlinkSync(backup);
|
||||
}
|
||||
fs.renameSync(destination, backup);
|
||||
backupCreated = true;
|
||||
}
|
||||
|
||||
fs.renameSync(temporary, destination);
|
||||
installed = true;
|
||||
|
||||
@@ -87,6 +95,7 @@ const plugin = async (args) => {
|
||||
jobLog(`[replaceOriginalFile] WARNING: Could not remove backup ${backup}: ${error.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if ((args.inputs.deleteSource || 'true') === 'true') {
|
||||
try {
|
||||
fs.unlinkSync(source);
|
||||
@@ -102,9 +111,11 @@ const plugin = async (args) => {
|
||||
variables,
|
||||
};
|
||||
} 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)) {
|
||||
fs.renameSync(backup, destination);
|
||||
try { fs.renameSync(backup, destination); } catch (_) { /* ignore */ }
|
||||
}
|
||||
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
@@ -1,4 +1,4 @@
|
||||
# Tdarr Local Flow Plugins — Integration Guide
|
||||
# Tdarr Local Flow Plugins — Integration Guide
|
||||
|
||||
## 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
|
||||
│ ├── extractChaptersXml/1.0.0/index.js
|
||||
│ ├── finalizeMedia/1.0.0/index.js
|
||||
│ ├── copyToWorkDir/2.0.0/index.js
|
||||
│ ├── replaceOriginalFile/1.0.0/index.js
|
||||
│ └── replaceOriginalFile/2.0.0/index.js
|
||||
│ ├── copyToWorkDir/1.0.0/index.js
|
||||
│ └── replaceOriginalFile/1.0.0/index.js
|
||||
├── audio/
|
||||
│ ├── audioSelectorClamping/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`.
|
||||
|
||||
`copyToWorkDir` crea la copia temporanea con suffisso `_old` e conserva il
|
||||
percorso originale in `variables.originalLibraryFile`. La versione 2.0.0
|
||||
trasmette il progresso della copia e verifica la dimensione finale.
|
||||
percorso originale in `variables.originalLibraryFile`. Il plugin
|
||||
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
|
||||
|
||||
@@ -89,7 +89,7 @@ Aggiungi al PATH di sistema:
|
||||
### 4.1 Ordine dei nodi
|
||||
|
||||
```
|
||||
[copyToWorkDir 2.0.0] (recommended for network libraries)
|
||||
[copyToWorkDir] (consigliato per librerie di rete)
|
||||
▼
|
||||
[mediaAnalyzerGOP]
|
||||
│
|
||||
@@ -114,20 +114,15 @@ Aggiungi al PATH di sistema:
|
||||
▼
|
||||
[transcode2PassX265]
|
||||
▼
|
||||
[replaceOriginalFile 2.0.0]
|
||||
[replaceOriginalFile]
|
||||
▼
|
||||
[finalizeMedia]
|
||||
```
|
||||
`copyToWorkDir` 2.0.0 is optional but recommended when the source library is on
|
||||
a network share. Place it before the first analysis/transcode node; it records
|
||||
the library path for `replaceOriginalFile` 2.0.0.
|
||||
Both progress-enabled copy operations verify the destination size and retry
|
||||
failed writes twice after removing the partial destination. Their `Copy method`
|
||||
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.
|
||||
`copyToWorkDir` è facoltativo ma consigliato quando la libreria sorgente si trova su una condivisione di rete (NAS/SMB).
|
||||
Posizionalo prima dell'analisi/transcodifica: registra il percorso originale della libreria per `replaceOriginalFile`.
|
||||
Entrambe le operazioni di copia/sostituzione trasmettono la percentuale di avanzamento alla WebUI di Tdarr tramite il worker callback,
|
||||
registrano velocità ed ETA, verificano l'integrità del file per dimensione e supportano il retry automatico.
|
||||
I plugin utilizzano `FlowHelpers/1.0.0/copyWithProgress.js`.
|
||||
|
||||
### 4.2 Connessioni nel Canvas
|
||||
|
||||
|
||||
+61
-20
@@ -1,4 +1,4 @@
|
||||
'use strict';
|
||||
'use strict';
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
@@ -127,22 +127,53 @@ const AUDIO_INPUTS = {
|
||||
eq(o.variables.audioItaOpusBitrate, '384', 'Series DTS 768k >= 384 -> Opus=384');
|
||||
cleanup(a); }
|
||||
|
||||
// Series AC3 320k < 384 threshold -> 256
|
||||
// Series AC3 320k < 384 target -> 320 (strict no upcoding)
|
||||
{ const streams = [
|
||||
makeStream({ index:0, codec_type:'video' }),
|
||||
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);
|
||||
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); }
|
||||
|
||||
// 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 = [
|
||||
makeStream({ index:0, codec_type:'video' }),
|
||||
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);
|
||||
eq(o.variables.audioItaOpusBitrate, '384', 'Series unknown bitrate (0) -> safe default 384k');
|
||||
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('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');
|
||||
ok(copyPluginCode.includes('[copyToWorkDir] Progress:'), 'copyToWorkDir reports copy progress in the job log');
|
||||
const copyPluginV2Code = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/copyToWorkDir/2.0.0/index.js'), 'utf8');
|
||||
ok(copyPluginV2Code.includes('updateProgress: args.updateProgress'), 'copyToWorkDir v2 forwards UI progress');
|
||||
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');
|
||||
ok(replacePluginCode.includes('fs.renameSync(temporary, destination)'), 'replaceOriginalFile v2 atomically installs the verified copy');
|
||||
const replacePluginV1Code = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js'), 'utf8');
|
||||
ok(replacePluginV1Code.includes('copyAndVerify'), 'replaceOriginalFile v1 verifies the copied file');
|
||||
ok(!replacePluginV1Code.includes('updateProgress'), 'replaceOriginalFile v1 has no progress callback');
|
||||
ok(copyPluginCode.includes('copyWithProgress'), 'copyToWorkDir v1 uses copyWithProgress');
|
||||
ok(copyPluginCode.includes('updateWorker'), 'copyToWorkDir v1 forwards updateWorker callback');
|
||||
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/1.0.0/index.js'), 'utf8');
|
||||
ok(replacePluginCode.includes('copyWithProgress'), 'replaceOriginalFile v1 uses copyWithProgress');
|
||||
ok(replacePluginCode.includes('updateWorker'), 'replaceOriginalFile v1 forwards updateWorker callback');
|
||||
ok(replacePluginCode.includes('.partial.old'), 'replaceOriginalFile v1 uses a rollback backup');
|
||||
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');
|
||||
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('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('PROGRESS_INTERVAL_MS = 250'), 'copy helper polls progress frequently');
|
||||
ok(copyHelperCode.includes('intervalSpeed'), 'copy helper reports interval speed');
|
||||
ok(!copyPluginV2Code.includes('copyMethod'), 'copyToWorkDir v2 has no copy method setting');
|
||||
ok(!replacePluginCode.includes('bufferSizeMb'), 'replaceOriginalFile v2 has no buffer setting');
|
||||
ok(copyHelperCode.includes('formatEta'), 'copy helper formats ETA');
|
||||
ok(copyHelperCode.includes('updateWorker'), 'copy helper updates Tdarr worker progress');
|
||||
|
||||
// ── Subtitle default naming checks ─────────────────────────────────────────
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
console.log(`\n${'='.repeat(55)}`);
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
'use strict';
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 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 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 5 — Copy/Replace Progress & Subtitle Rules', file: './test-cases/test-copy-and-subtitles', heavy: false },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user