'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 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})...`); 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({ 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;