diff --git a/FlowHelpers/1.0.0/copyWithProgress.js b/FlowHelpers/1.0.0/copyWithProgress.js index 6160aa2..8ecb839 100644 --- a/FlowHelpers/1.0.0/copyWithProgress.js +++ b/FlowHelpers/1.0.0/copyWithProgress.js @@ -1,27 +1,70 @@ 'use strict'; -const fs = require('fs'); +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 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')}`; +}; - 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') { - 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) { 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); - - 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; - return; - } - const percent = sourceSize === 0 - ? 99 - : Math.min(Math.floor((copiedBytes / sourceSize) * 100), 99); - if (percent <= lastReportedPercent) return; - - 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); - - try { - await fs.promises.copyFile(source, destination); - } finally { - copying = false; - clearInterval(progressTimer); + 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 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 percent = Math.min(Math.floor((copiedBytes / sourceSize) * 100), 99); + + // 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) { diff --git a/LocalFlowPlugins/audio/audioSelectorClamping/1.0.0/index.js b/LocalFlowPlugins/audio/audioSelectorClamping/1.0.0/index.js index 2c3fd54..fb7ac24 100644 --- a/LocalFlowPlugins/audio/audioSelectorClamping/1.0.0/index.js +++ b/LocalFlowPlugins/audio/audioSelectorClamping/1.0.0/index.js @@ -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'); diff --git a/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js b/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js index 26277e7..2db2e35 100644 --- a/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js +++ b/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js @@ -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'); diff --git a/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js b/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js index 6423437..2c2fd25 100644 --- a/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js +++ b/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js @@ -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/_old (streams, no RAM load) + * 1. Copies source -> workDir/_old (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 }, diff --git a/LocalFlowPlugins/utility/copyToWorkDir/2.0.0/index.js b/LocalFlowPlugins/utility/copyToWorkDir/2.0.0/index.js deleted file mode 100644 index 3c90a43..0000000 --- a/LocalFlowPlugins/utility/copyToWorkDir/2.0.0/index.js +++ /dev/null @@ -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; diff --git a/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js b/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js index 660d9f4..4507784 100644 --- a/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js +++ b/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js @@ -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`); diff --git a/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js b/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js index 801da4a..0879c0d 100644 --- a/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js +++ b/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js @@ -1,15 +1,18 @@ 'use strict'; 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 = () => ({ 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}`); } diff --git a/LocalFlowPlugins/utility/replaceOriginalFile/2.0.0/index.js b/LocalFlowPlugins/utility/replaceOriginalFile/2.0.0/index.js deleted file mode 100644 index 305a75b..0000000 --- a/LocalFlowPlugins/utility/replaceOriginalFile/2.0.0/index.js +++ /dev/null @@ -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; diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 1bb31c4..8540bb0 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -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 diff --git a/tests/quick-logic-test.js b/tests/quick-logic-test.js index 2fe36cd..0d1cbf4 100644 --- a/tests/quick-logic-test.js +++ b/tests/quick-logic-test.js @@ -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)}`); diff --git a/tests/run-tests.js b/tests/run-tests.js index 0b00c5d..22a984c 100644 --- a/tests/run-tests.js +++ b/tests/run-tests.js @@ -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() { diff --git a/tests/test-cases/test-copy-and-subtitles.js b/tests/test-cases/test-copy-and-subtitles.js new file mode 100644 index 0000000..ab73502 --- /dev/null +++ b/tests/test-cases/test-copy-and-subtitles.js @@ -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); + }); +}