391 lines
13 KiB
JavaScript
391 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
/* eslint-disable no-param-reassign */
|
|
|
|
const MAX_RETRIES = 2;
|
|
const RETRY_DELAY_MS = 1000;
|
|
const PROGRESS_INTERVAL_MS = 1000;
|
|
|
|
const sleep = (milliseconds) => new Promise((resolve) => {
|
|
setTimeout(resolve, milliseconds);
|
|
});
|
|
|
|
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. Self-contained to avoid external helper dependencies.
|
|
*/
|
|
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 displayFile = (source || '').replace(/\\/g, '/');
|
|
const report = (percent, message, eta) => {
|
|
if (typeof updateWorker === 'function') {
|
|
updateWorker({
|
|
file: displayFile,
|
|
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})...`);
|
|
await sleep(RETRY_DELAY_MS);
|
|
}
|
|
|
|
try {
|
|
if (fs.existsSync(destination)) {
|
|
fs.unlinkSync(destination);
|
|
}
|
|
|
|
// 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 startedAt = Date.now();
|
|
let copiedBytes = 0;
|
|
let lastProgressTime = 0;
|
|
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 percent = Math.min(Math.floor((copiedBytes / sourceSize) * 100), 99);
|
|
|
|
// Update UI and jobLog every second (PROGRESS_INTERVAL_MS) or on initial data chunk
|
|
const timeSinceLastProgress = now - lastProgressTime;
|
|
if (lastProgressTime === 0 || timeSinceLastProgress >= PROGRESS_INTERVAL_MS) {
|
|
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);
|
|
|
|
if (typeof updateWorker === 'function') {
|
|
updateWorker({
|
|
file: displayFile,
|
|
percentage: percent,
|
|
ETA: eta,
|
|
});
|
|
}
|
|
if (typeof updateProgress === 'function') {
|
|
updateProgress({ progress: percent });
|
|
}
|
|
|
|
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`,
|
|
);
|
|
}
|
|
|
|
lastProgressTime = 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(
|
|
`Copy size mismatch: expected ${sourceSize} bytes, got ${destinationSize}`,
|
|
);
|
|
}
|
|
|
|
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 `
|
|
+ `${totalElapsed.toFixed(1)}s (${avgSpeed} MB/s average)`,
|
|
'00:00:00',
|
|
);
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
try {
|
|
if (fs.existsSync(destination)) fs.unlinkSync(destination);
|
|
} catch (cleanupError) {
|
|
lastError = new Error(
|
|
`${error.message}; could not remove partial copy: ${cleanupError.message}`,
|
|
);
|
|
}
|
|
if (attempt < MAX_RETRIES && typeof jobLog === 'function') {
|
|
jobLog(`[${label}] Copy attempt ${attempt + 1} failed: ${lastError.message}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`[${label}] Copy failed after ${MAX_RETRIES + 1} attempts: ${lastError.message}`,
|
|
);
|
|
};
|
|
|
|
const details = () => ({
|
|
name: 'Replace Original File',
|
|
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.',
|
|
},
|
|
{
|
|
label: 'Output Directory (Optional)',
|
|
name: 'outputDirectory',
|
|
type: 'string',
|
|
defaultValue: '',
|
|
inputUI: { type: 'text' },
|
|
tooltip: 'Target library directory for folder-to-folder conversion. If empty, automatically uses Library Output if configured, or replaces in-place. Example: T:/Media/Film or T:/Media/Serie',
|
|
},
|
|
],
|
|
outputs: [
|
|
{ number: 1, tooltip: 'Original file replaced — continue to next plugin' },
|
|
],
|
|
});
|
|
|
|
const plugin = async (args) => {
|
|
const { inputFileObj, variables, jobLog, updateWorker } = args;
|
|
const source = inputFileObj._id;
|
|
const configuredDestination = variables.originalLibraryFile || args.originalLibraryFile;
|
|
let 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}`);
|
|
}
|
|
|
|
const originalSourceFile = destination;
|
|
const originalSourceDir = path.dirname(originalSourceFile);
|
|
|
|
// Check for folder-to-folder target directory (from plugin input or Tdarr librarySettings.output)
|
|
const configuredOutput = ((args.inputs && args.inputs.outputDirectory) || '').trim()
|
|
|| ((args.librarySettings && args.librarySettings.output) || '').trim();
|
|
const sourceLibraryRoot = ((args.librarySettings && args.librarySettings.folder) || '').trim();
|
|
|
|
if (configuredOutput && path.resolve(configuredOutput) !== path.resolve(sourceLibraryRoot || originalSourceDir)) {
|
|
let relPath = '';
|
|
if (sourceLibraryRoot) {
|
|
try {
|
|
relPath = path.relative(path.resolve(sourceLibraryRoot), path.resolve(originalSourceFile));
|
|
} catch (_) { relPath = ''; }
|
|
}
|
|
if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
|
|
const base = path.basename(originalSourceFile);
|
|
const parent = path.basename(originalSourceDir);
|
|
const grandParent = path.basename(path.dirname(originalSourceDir));
|
|
if (grandParent && grandParent !== '.' && !grandParent.includes(':')) {
|
|
relPath = path.join(grandParent, parent, base);
|
|
} else {
|
|
relPath = path.join(parent, base);
|
|
}
|
|
}
|
|
destination = path.join(path.resolve(configuredOutput), relPath);
|
|
jobLog(`[replaceOriginalFile] Folder-to-folder mode: target is ${destination}\n`);
|
|
}
|
|
|
|
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)) {
|
|
fs.mkdirSync(destinationDir, { recursive: true });
|
|
jobLog(`[replaceOriginalFile] Created destination directory: ${destinationDir}\n`);
|
|
}
|
|
|
|
const sourceExt = path.extname(source);
|
|
const destExt = path.extname(destination);
|
|
const oldOriginalFile = destination;
|
|
if (sourceExt && destExt && sourceExt.toLowerCase() !== destExt.toLowerCase()) {
|
|
destination = path.join(destinationDir, `${path.basename(destination, destExt)}${sourceExt}`);
|
|
}
|
|
|
|
const temporary = `${destination}.tdarr.tmp`;
|
|
const backup = `${oldOriginalFile}.partial.old`;
|
|
let backupCreated = false;
|
|
let installed = false;
|
|
|
|
try {
|
|
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(oldOriginalFile)) {
|
|
if (fs.existsSync(backup)) {
|
|
fs.unlinkSync(backup);
|
|
}
|
|
fs.renameSync(oldOriginalFile, backup);
|
|
backupCreated = true;
|
|
}
|
|
|
|
if (fs.existsSync(destination) && destination !== oldOriginalFile) {
|
|
try { fs.unlinkSync(destination); } catch (_) { /* ignore */ }
|
|
}
|
|
|
|
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' && path.resolve(source) !== path.resolve(destination)) {
|
|
try {
|
|
fs.unlinkSync(source);
|
|
} catch (error) {
|
|
jobLog(`[replaceOriginalFile] WARNING: Could not delete processed file ${source}: ${error.message}\n`);
|
|
}
|
|
}
|
|
|
|
if (path.resolve(destination) !== path.resolve(originalSourceFile) && fs.existsSync(originalSourceFile)) {
|
|
try {
|
|
fs.unlinkSync(originalSourceFile);
|
|
jobLog(`[replaceOriginalFile] Removed source file from intake directory: ${originalSourceFile}\n`);
|
|
} catch (error) {
|
|
jobLog(`[replaceOriginalFile] WARNING: Could not delete intake source file ${originalSourceFile}: ${error.message}\n`);
|
|
}
|
|
}
|
|
|
|
// Clean up temporary workDir copy (_old) if still present
|
|
if (variables.workDirCopyApplied === 'true') {
|
|
const workDir = path.dirname(source);
|
|
const oldBase = variables.workDirSourceBaseName || path.basename(destination, sourceExt);
|
|
const oldCandidate = path.join(workDir, `${oldBase}_old${destExt}`);
|
|
if (fs.existsSync(oldCandidate)) {
|
|
try { fs.unlinkSync(oldCandidate); } catch (_) { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
variables.finalFile = destination;
|
|
variables.finalDir = destinationDir;
|
|
|
|
jobLog(`[replaceOriginalFile] Replaced original file: ${destination}\n`);
|
|
return {
|
|
outputNumber: 1,
|
|
outputFileObj: { ...inputFileObj, _id: destination },
|
|
variables,
|
|
};
|
|
} catch (error) {
|
|
if (fs.existsSync(temporary)) {
|
|
try { fs.unlinkSync(temporary); } catch (_) { /* ignore */ }
|
|
}
|
|
if (backupCreated && !installed && !fs.existsSync(destination)) {
|
|
try { fs.renameSync(backup, destination); } catch (_) { /* ignore */ }
|
|
}
|
|
throw new Error(`[replaceOriginalFile] Replacement failed: ${error.message}`);
|
|
}
|
|
};
|
|
|
|
module.exports.details = details;
|
|
module.exports.plugin = plugin;
|
|
module.exports.copyWithProgress = copyWithProgress;
|
|
|