From c46e3dfdb3728b2fefaf776fa50e08beb7741e00 Mon Sep 17 00:00:00 2001 From: PickleRick Date: Mon, 5 Oct 2026 17:03:46 +0200 Subject: [PATCH] fix(flow): embed copyWithProgress to make plugins self-contained and immune to Tdarr updates --- .../utility/copyToWorkDir/1.0.0/index.js | 191 ++++++++++++++++- .../replaceOriginalFile/1.0.0/index.js | 192 +++++++++++++++++- README.md | 7 +- docs/integration-guide.md | 4 +- 4 files changed, 384 insertions(+), 10 deletions(-) diff --git a/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js b/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js index 2c2fd25..6268fcd 100644 --- a/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js +++ b/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js @@ -2,10 +2,198 @@ const path = require('path'); const fs = require('fs'); -const { copyWithProgress } = require('../../../../FlowHelpers/1.0.0/copyWithProgress'); /* 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}`, + ); +}; + /** * copyToWorkDir * Copies the source file into the worker's local workDir before processing. @@ -110,3 +298,4 @@ const plugin = async (args) => { module.exports.details = details; module.exports.plugin = plugin; +module.exports.copyWithProgress = copyWithProgress; diff --git a/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js b/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js index 5f613db..f344385 100644 --- a/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js +++ b/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js @@ -2,10 +2,198 @@ const path = require('path'); const fs = require('fs'); -const { copyWithProgress } = require('../../../../FlowHelpers/1.0.0/copyWithProgress'); /* 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: @@ -195,3 +383,5 @@ const plugin = async (args) => { module.exports.details = details; module.exports.plugin = plugin; +module.exports.copyWithProgress = copyWithProgress; + diff --git a/README.md b/README.md index 11588e1..8dab4db 100644 --- a/README.md +++ b/README.md @@ -79,13 +79,8 @@ curl -fsSL "${BASE}/LocalFlowPlugins/${CATEGORY}/${PLUGIN}/1.0.0/index.js" -o "$ ``` The progress-enabled `copyToWorkDir/1.0.0` and `replaceOriginalFile/1.0.0` -plugins also require this shared helper: +plugins are self-contained and require no external helpers (immune to Tdarr updates). -```bash -mkdir -p /path/to/tdarr/plugins/FlowPlugins/FlowHelpers/1.0.0 -curl -fsSL "${BASE}/FlowHelpers/1.0.0/copyWithProgress.js" \ - -o /path/to/tdarr/plugins/FlowHelpers/1.0.0/copyWithProgress.js -``` | Plugin | Category | |---|---| diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 8540bb0..71da6fd 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -121,8 +121,8 @@ Aggiungi al PATH di sistema: `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`. +I plugin integrano direttamente il motore di copia con progresso in tempo reale (`copyWithProgress`), risultando autonomi e immuni agli aggiornamenti di Tdarr. + ### 4.2 Connessioni nel Canvas