From 88ecf9a8478b068b6cb56b2a9b1157ef35c2f584 Mon Sep 17 00:00:00 2001 From: PickleRick Date: Wed, 30 Sep 2026 18:43:12 +0200 Subject: [PATCH] feat(flow): support folder-to-folder conversion with sidecar migration and prune --- .../utility/finalizeMedia/1.0.0/index.js | 81 ++++++-- .../replaceOriginalFile/1.0.0/index.js | 55 +++++- tests/test-cases/test-copy-and-subtitles.js | 179 +++++++++++++----- 3 files changed, 253 insertions(+), 62 deletions(-) diff --git a/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js b/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js index 515dbe3..cf2ed64 100644 --- a/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js +++ b/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js @@ -47,14 +47,30 @@ const IGNORED_JUNK_FILES = new Set([ 'ehthumbs_vista.db', ]); -const SEASON_DIR_REGEX = /^(season|stagione|staffel|saison|s)?\s*\d+$/i; -const SPECIALS_DIR_REGEX = /^(specials?|extras?)$/i; +const SEASON_DIR_REGEX = /^(?:.*[ ._-])?(?:(?:season|stagione|staffel|saison)\s*[._-]?\s*\d+|s\d{1,2})$/i; +const SPECIALS_DIR_REGEX = /^(?:.*[ ._-])?(?:specials?|extras?)$/i; const isSeasonDirectory = (dirName) => { const trimmed = (dirName || '').trim(); return SEASON_DIR_REGEX.test(trimmed) || SPECIALS_DIR_REGEX.test(trimmed); }; +const moveFileSafe = (src, dest) => { + if (fs.existsSync(dest)) { + try { fs.unlinkSync(dest); } catch (_) { /* ignore */ } + } + try { + fs.renameSync(src, dest); + } catch (err) { + if (err.code === 'EXDEV' || err.code === 'EPERM') { + fs.copyFileSync(src, dest); + fs.unlinkSync(src); + } else { + throw err; + } + } +}; + const isDirEmptyIgnoringJunk = (dirPath) => { if (!fs.existsSync(dirPath)) return false; try { @@ -132,10 +148,11 @@ const subtitleKey = (fileName) => { const chapterFileName = (fileName, mediaBaseName) => { const expectedBase = mediaBaseName.toLowerCase(); - const stem = fileName.slice(0, -path.extname(fileName).length); - const normalizedStem = stem.replace(/_chapters$/i, ''); - if (path.extname(fileName).toLowerCase() !== '.xml' - || normalizedStem.toLowerCase() !== expectedBase) { + const ext = path.extname(fileName); + if (ext.toLowerCase() !== '.xml') return null; + const stem = fileName.slice(0, -ext.length); + const normalizedStem = stem.replace(/(_|\.)chapters$/i, ''); + if (normalizedStem.toLowerCase() !== expectedBase) { return null; } return `${mediaBaseName}_chapters.xml`; @@ -168,14 +185,47 @@ const plugin = async (args) => { const { jobLog, inputFileObj, variables } = args; const finalFile = inputFileObj._id; const finalDir = path.dirname(finalFile); - const sourceDir = variables.originalLibraryDir; + const sourceDir = variables.originalLibraryDir + || (variables.originalLibraryFile && path.dirname(variables.originalLibraryFile)) + || ''; const mediaBaseName = path.basename(finalFile, path.extname(finalFile)); + const origBaseName = variables.workDirSourceBaseName + || (variables.originalLibraryFile && path.basename(variables.originalLibraryFile, path.extname(variables.originalLibraryFile))) + || mediaBaseName; const metadata = subtitleMetadata(args); + const sourceLibraryRoot = ((args.librarySettings && args.librarySettings.folder) || '').trim(); + const outputLibraryRoot = ((args.inputs && args.inputs.outputDirectory) || '').trim() + || ((args.librarySettings && args.librarySettings.output) || '').trim(); if (!fs.existsSync(finalDir)) { throw new Error(`[finalizeMedia] Final directory does not exist: ${finalDir}`); } + // Move this media file's sidecars from sourceDir to finalDir if running folder-to-folder + if (sourceDir && path.resolve(sourceDir) !== path.resolve(finalDir) && fs.existsSync(sourceDir)) { + try { + const sourceEntries = fs.readdirSync(sourceDir); + for (const entry of sourceEntries) { + const isSub = isMediaSubtitle(entry, mediaBaseName) || isMediaSubtitle(entry, origBaseName); + const isChap = Boolean(chapterFileName(entry, mediaBaseName) || chapterFileName(entry, origBaseName)); + if (isSub || isChap) { + const srcPath = path.join(sourceDir, entry); + let targetName = entry; + if (origBaseName && origBaseName !== mediaBaseName && entry.toLowerCase().startsWith(`${origBaseName.toLowerCase()}.`)) { + targetName = `${mediaBaseName}${entry.slice(origBaseName.length)}`; + } else if (origBaseName && origBaseName !== mediaBaseName && (entry.toLowerCase().startsWith(`${origBaseName.toLowerCase()}_chapters`) || entry.toLowerCase().startsWith(`${origBaseName.toLowerCase()}.chapters`))) { + targetName = `${mediaBaseName}_chapters.xml`; + } + const destPath = path.join(finalDir, targetName); + moveFileSafe(srcPath, destPath); + jobLog(`[finalizeMedia] Moved sidecar to final directory: ${entry} -> ${targetName}\n`); + } + } + } catch (err) { + jobLog(`[finalizeMedia] WARNING: Error migrating sidecars from ${sourceDir}: ${err.message}\n`); + } + } + // Filter only subtitles that belong to this specific media/episode file const files = fs.readdirSync(finalDir) .filter((file) => isMediaSubtitle(file, mediaBaseName)) @@ -256,12 +306,22 @@ const plugin = async (args) => { // Remove source directory when empty (e.g. when transcoding from a staging/ingest directory). // For series (where sourceDir is e.g. Season 01), it will only be removed when the last episode is processed. // When the season folder is removed, if the parent Series directory is also empty, it is also cleaned up. + const isProtectedDir = (dirPath) => { + if (!dirPath) return true; + const resolved = path.resolve(dirPath); + if (resolved === path.resolve(path.parse(resolved).root)) return true; + if (sourceLibraryRoot && resolved === path.resolve(sourceLibraryRoot)) return true; + if (outputLibraryRoot && resolved === path.resolve(outputLibraryRoot)) return true; + return false; + }; + const removeEmptySourceDir = (args.inputs.removeEmptySourceDir || 'true') === 'true'; const removeEmptyParentSeriesDir = (args.inputs.removeEmptyParentSeriesDir || 'true') === 'true'; if (removeEmptySourceDir && sourceDir && path.resolve(sourceDir) !== path.resolve(finalDir) + && !isProtectedDir(sourceDir) && fs.existsSync(sourceDir)) { if (isDirEmptyIgnoringJunk(sourceDir)) { const isSeason = isSeasonDirectory(path.basename(sourceDir)); @@ -269,11 +329,8 @@ const plugin = async (args) => { const removed = removeDirAndJunk(sourceDir, jobLog); if (removed && isSeason && removeEmptyParentSeriesDir) { - if (parentDir && parentDir !== sourceDir && isDirEmptyIgnoringJunk(parentDir)) { - const grandParent = path.dirname(parentDir); - if (grandParent && grandParent !== parentDir) { - removeDirAndJunk(parentDir, jobLog); - } + if (parentDir && !isProtectedDir(parentDir) && isDirEmptyIgnoringJunk(parentDir)) { + removeDirAndJunk(parentDir, jobLog); } } } else { diff --git a/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js b/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js index 464a075..5f613db 100644 --- a/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js +++ b/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js @@ -27,6 +27,14 @@ const details = () => ({ 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' }, @@ -47,6 +55,36 @@ const plugin = async (args) => { 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 }; @@ -54,7 +92,8 @@ const plugin = async (args) => { const destinationDir = path.dirname(destination); if (!fs.existsSync(destinationDir)) { - throw new Error(`[replaceOriginalFile] Destination directory does not exist: ${destinationDir}`); + fs.mkdirSync(destinationDir, { recursive: true }); + jobLog(`[replaceOriginalFile] Created destination directory: ${destinationDir}\n`); } const sourceExt = path.extname(source); @@ -107,7 +146,7 @@ const plugin = async (args) => { } } - if ((args.inputs.deleteSource || 'true') === 'true') { + if ((args.inputs.deleteSource || 'true') === 'true' && path.resolve(source) !== path.resolve(destination)) { try { fs.unlinkSync(source); } catch (error) { @@ -115,6 +154,15 @@ const plugin = async (args) => { } } + 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); @@ -125,6 +173,9 @@ const plugin = async (args) => { } } + variables.finalFile = destination; + variables.finalDir = destinationDir; + jobLog(`[replaceOriginalFile] Replaced original file: ${destination}\n`); return { outputNumber: 1, diff --git a/tests/test-cases/test-copy-and-subtitles.js b/tests/test-cases/test-copy-and-subtitles.js index 8f7e04d..af3de5e 100644 --- a/tests/test-cases/test-copy-and-subtitles.js +++ b/tests/test-cases/test-copy-and-subtitles.js @@ -130,80 +130,163 @@ async function testCopyAndSubtitles() { 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)'); - // 5. Test TV Series: Multi-episode sidecar isolation & empty Season/Series directory cleanup - const stagingRoot = path.join(tmpDir, 'staging'); - const seriesSourceDir = path.join(stagingRoot, 'Halo', 'Season 01'); + // 5. Test TV Series: Complete Folder-to-Folder Workflow (Sonarr -> Serie) + const sonarrRoot = path.join(tmpDir, 'Sonarr'); + const seriesSourceDir = path.join(sonarrRoot, 'Halo.2022 [tvdbid-12345]', 'After.Life.S01'); fs.mkdirSync(seriesSourceDir, { recursive: true }); - const finalSeasonDir = path.join(tmpDir, 'final_library', 'Halo', 'Season 01'); - fs.mkdirSync(finalSeasonDir, { recursive: true }); + const serieOutputRoot = path.join(tmpDir, 'Serie'); - // In finalSeasonDir, put Episode 1 and Episode 2 with their sidecars - const ep1File = path.join(finalSeasonDir, 'Halo - S01E01.mkv'); - const ep2File = path.join(finalSeasonDir, 'Halo - S01E02.mkv'); - fs.writeFileSync(ep1File, Buffer.alloc(100)); - fs.writeFileSync(ep2File, Buffer.alloc(100)); + // Create Episode 1 source files in Sonarr + const ep1SourceFile = path.join(seriesSourceDir, 'Halo.S01E01.mkv'); + const ep1SubFile = path.join(seriesSourceDir, 'Halo.S01E01.ita.forced.srt'); + const ep1ChapFile = path.join(seriesSourceDir, 'Halo.S01E01_chapters.xml'); + fs.writeFileSync(ep1SourceFile, Buffer.alloc(100)); + fs.writeFileSync(ep1SubFile, 'ep1 sub'); + fs.writeFileSync(ep1ChapFile, ''); - // Sidecars for Ep1 (to be normalized when Ep1 is finalized) - fs.writeFileSync(path.join(finalSeasonDir, 'Halo - S01E01.ita.forced.srt'), 'ep1 sub'); - fs.writeFileSync(path.join(finalSeasonDir, 'Halo - S01E01_chapters.xml'), ''); + // Create Episode 2 source files in Sonarr (still waiting to be processed) + const ep2SourceFile = path.join(seriesSourceDir, 'Halo.S01E02.mkv'); + const ep2SubFile = path.join(seriesSourceDir, 'Halo.S01E02.eng.forced.default.srt'); + const ep2ChapFile = path.join(seriesSourceDir, 'Halo.S01E02_chapters.xml'); + fs.writeFileSync(ep2SourceFile, Buffer.alloc(100)); + fs.writeFileSync(ep2SubFile, 'ep2 sub'); + fs.writeFileSync(ep2ChapFile, ''); - // Sidecars for Ep2 (should NOT be touched when Ep1 is finalized!) - fs.writeFileSync(path.join(finalSeasonDir, 'Halo - S01E02.eng.forced.default.srt'), 'ep2 sub'); - fs.writeFileSync(path.join(finalSeasonDir, 'Halo - S01E02_chapters.xml'), ''); + // Ep1 encoded file in worker workDir + const ep1WorkFile = path.join(workDir, 'Halo.S01E01_encoded.mkv'); + fs.writeFileSync(ep1WorkFile, Buffer.alloc(200)); - // In source directory, simulate that Ep2 is still waiting in staging - fs.writeFileSync(path.join(seriesSourceDir, 'Halo - S01E02.mkv'), Buffer.alloc(100)); + // A. Run replaceOriginalFile for Ep1 with folder-to-folder + const ep1ReplaceResult = await replacePlugin({ + inputFileObj: { _id: ep1WorkFile }, + variables: { originalLibraryFile: ep1SourceFile, originalLibraryDir: seriesSourceDir }, + workDir, + librarySettings: { folder: sonarrRoot, output: serieOutputRoot }, + inputs: { deleteSource: 'true' }, + jobLog: () => {}, + updateWorker: () => {}, + }); - const finalizeEp1Args = { + assert(fs.existsSync(ep1ReplaceResult.outputFileObj._id), 'Ep1 installed in Serie destination library'); + assert(ep1ReplaceResult.outputFileObj._id.includes(path.join('Serie', 'Halo.2022 [tvdbid-12345]', 'After.Life.S01')), 'Ep1 relative season subfolder preserved'); + assert(!fs.existsSync(ep1SourceFile), 'Ep1 source video unlinked from Sonarr intake'); + assert(fs.existsSync(ep2SourceFile), 'Ep2 source video untouched in Sonarr intake'); + assert(fs.existsSync(ep1SubFile), 'Ep1 subtitle sidecar still in Sonarr before finalize'); + assert(fs.existsSync(ep2SubFile), 'Ep2 subtitle sidecar untouched in Sonarr'); + + // B. Run finalizeMedia for Ep1 + const ep1FinalDir = path.dirname(ep1ReplaceResult.outputFileObj._id); + await finalizePlugin({ inputFileObj: { - _id: ep1File, + _id: ep1ReplaceResult.outputFileObj._id, ffProbeData: { streams: [] }, }, - variables: { originalLibraryDir: seriesSourceDir }, + variables: ep1ReplaceResult.variables, + librarySettings: { folder: sonarrRoot, output: serieOutputRoot }, inputs: { removeEmptySourceDir: 'true', removeEmptyParentSeriesDir: 'true' }, jobLog: () => {}, - }; + }); - await finalizePlugin(finalizeEp1Args); + // Check Ep1 sidecars migrated and normalized in Serie + assert(fs.existsSync(path.join(ep1FinalDir, 'Halo.S01E01.ita.forced.default.srt')), 'Ep1 subtitle moved to Serie and normalized with .default'); + assert(fs.existsSync(path.join(ep1FinalDir, 'Halo.S01E01_chapters.xml')), 'Ep1 chapters moved to Serie'); + assert(!fs.existsSync(ep1SubFile), 'Ep1 subtitle removed from Sonarr sourceDir'); + assert(!fs.existsSync(ep1ChapFile), 'Ep1 chapters removed from Sonarr sourceDir'); - // Ep1 sidecars were processed - assert(fs.existsSync(path.join(finalSeasonDir, 'Halo - S01E01.ita.forced.default.srt')), 'Ep1 subtitle normalized to .default'); - assert(fs.existsSync(path.join(finalSeasonDir, 'Halo - S01E01_chapters.xml')), 'Ep1 chapters intact'); + // Check Ep2 sidecars in Sonarr were completely untouched + assert(fs.existsSync(ep2SubFile), 'Ep2 subtitle untouched in Sonarr sourceDir'); + assert(fs.existsSync(ep2ChapFile), 'Ep2 chapters untouched in Sonarr sourceDir'); - // Ep2 sidecars were COMPLETELY UNTOUCHED! - assert(fs.existsSync(path.join(finalSeasonDir, 'Halo - S01E02.eng.forced.default.srt')), 'Ep2 subtitle was untouched when Ep1 was finalized'); - assert(fs.existsSync(path.join(finalSeasonDir, 'Halo - S01E02_chapters.xml')), 'Ep2 chapters untouched'); + // Season folder in Sonarr preserved because Ep2 is still there + assert(fs.existsSync(seriesSourceDir), 'Sonarr season folder preserved while Ep2 remains'); - // Source season directory was NOT removed because Ep2 was still in staging - assert(fs.existsSync(seriesSourceDir), 'Source Season 01 directory kept while Ep2 remains'); + // C. Now process Episode 2 + const ep2WorkFile = path.join(workDir, 'Halo.S01E02_encoded.mkv'); + fs.writeFileSync(ep2WorkFile, Buffer.alloc(200)); - // Now simulate Ep2 finishing and removing from staging, leaving only a junk file (e.g. Thumbs.db) - fs.unlinkSync(path.join(seriesSourceDir, 'Halo - S01E02.mkv')); - fs.writeFileSync(path.join(seriesSourceDir, 'Thumbs.db'), 'junk'); - // Also put a junk file in parent series folder - fs.writeFileSync(path.join(stagingRoot, 'Halo', 'desktop.ini'), 'junk'); + const ep2ReplaceResult = await replacePlugin({ + inputFileObj: { _id: ep2WorkFile }, + variables: { originalLibraryFile: ep2SourceFile, originalLibraryDir: seriesSourceDir }, + workDir, + librarySettings: { folder: sonarrRoot, output: serieOutputRoot }, + inputs: { deleteSource: 'true' }, + jobLog: () => {}, + updateWorker: () => {}, + }); - const finalizeEp2Args = { + assert(fs.existsSync(ep2ReplaceResult.outputFileObj._id), 'Ep2 installed in Serie destination library'); + assert(!fs.existsSync(ep2SourceFile), 'Ep2 source video unlinked from Sonarr'); + + const ep2FinalDir = path.dirname(ep2ReplaceResult.outputFileObj._id); + await finalizePlugin({ inputFileObj: { - _id: ep2File, + _id: ep2ReplaceResult.outputFileObj._id, ffProbeData: { streams: [] }, }, - variables: { originalLibraryDir: seriesSourceDir }, + variables: ep2ReplaceResult.variables, + librarySettings: { folder: sonarrRoot, output: serieOutputRoot }, inputs: { removeEmptySourceDir: 'true', removeEmptyParentSeriesDir: 'true' }, jobLog: () => {}, - }; + }); - await finalizePlugin(finalizeEp2Args); + // Ep2 sidecars normalized in Serie (default stripped for eng) + assert(fs.existsSync(path.join(ep2FinalDir, 'Halo.S01E02.eng.forced.srt')), 'Ep2 subtitle moved to Serie and default stripped'); + assert(!fs.existsSync(path.join(ep2FinalDir, 'Halo.S01E02.eng.forced.default.srt')), 'Ep2 old subtitle removed'); + assert(fs.existsSync(path.join(ep2FinalDir, 'Halo.S01E02_chapters.xml')), 'Ep2 chapters moved to Serie'); - // Ep2 subtitle normalized now - assert(fs.existsSync(path.join(finalSeasonDir, 'Halo - S01E02.eng.forced.srt')), 'Ep2 subtitle normalized when Ep2 was finalized'); - assert(!fs.existsSync(path.join(finalSeasonDir, 'Halo - S01E02.eng.forced.default.srt')), 'Ep2 default stripped'); + // Season 01 and parent Halo directory removed from Sonarr, but sonarrRoot preserved! + assert(!fs.existsSync(seriesSourceDir), 'Empty Season 01 directory removed from Sonarr'); + assert(!fs.existsSync(path.join(sonarrRoot, 'Halo.2022 [tvdbid-12345]')), 'Empty Series parent directory removed from Sonarr'); + assert(fs.existsSync(sonarrRoot), 'Sonarr library root preserved'); - // Season 01 and parent Halo directory removed, but stagingRoot (library root) preserved - assert(!fs.existsSync(seriesSourceDir), 'Empty Season 01 directory removed'); - assert(!fs.existsSync(path.join(stagingRoot, 'Halo')), 'Empty Series directory removed'); - assert(fs.existsSync(stagingRoot), 'Staging root directory preserved'); + // 6. Test Movie: Complete Folder-to-Folder Workflow (Radarr -> Film) + const radarrRoot = path.join(tmpDir, 'Radarr'); + const movieSourceDir = path.join(radarrRoot, 'Avatar.2009'); + fs.mkdirSync(movieSourceDir, { recursive: true }); + + const filmOutputRoot = path.join(tmpDir, 'Film'); + + const movieSourceFile = path.join(movieSourceDir, 'Avatar.2009.mkv'); + const movieSubFile = path.join(movieSourceDir, 'Avatar.2009.ita.forced.srt'); + const movieChapFile = path.join(movieSourceDir, 'Avatar.2009_chapters.xml'); + fs.writeFileSync(movieSourceFile, Buffer.alloc(100)); + fs.writeFileSync(movieSubFile, 'movie sub'); + fs.writeFileSync(movieChapFile, ''); + + const movieWorkFile = path.join(workDir, 'Avatar.2009_encoded.mkv'); + fs.writeFileSync(movieWorkFile, Buffer.alloc(200)); + + const movieReplaceResult = await replacePlugin({ + inputFileObj: { _id: movieWorkFile }, + variables: { originalLibraryFile: movieSourceFile, originalLibraryDir: movieSourceDir }, + workDir, + librarySettings: { folder: radarrRoot, output: filmOutputRoot }, + inputs: { deleteSource: 'true' }, + jobLog: () => {}, + updateWorker: () => {}, + }); + + assert(fs.existsSync(movieReplaceResult.outputFileObj._id), 'Movie installed in Film library'); + assert(movieReplaceResult.outputFileObj._id.includes(path.join('Film', 'Avatar.2009')), 'Movie subfolder preserved in Film'); + assert(!fs.existsSync(movieSourceFile), 'Movie source video unlinked from Radarr'); + + const movieFinalDir = path.dirname(movieReplaceResult.outputFileObj._id); + await finalizePlugin({ + inputFileObj: { + _id: movieReplaceResult.outputFileObj._id, + ffProbeData: { streams: [] }, + }, + variables: movieReplaceResult.variables, + librarySettings: { folder: radarrRoot, output: filmOutputRoot }, + inputs: { removeEmptySourceDir: 'true', removeEmptyParentSeriesDir: 'true' }, + jobLog: () => {}, + }); + + assert(fs.existsSync(path.join(movieFinalDir, 'Avatar.2009.ita.forced.default.srt')), 'Movie subtitle moved to Film and normalized'); + assert(fs.existsSync(path.join(movieFinalDir, 'Avatar.2009_chapters.xml')), 'Movie chapters moved to Film'); + assert(!fs.existsSync(movieSourceDir), 'Empty movie directory removed from Radarr'); + assert(fs.existsSync(radarrRoot), 'Radarr library root preserved'); console.log('\n=== ALL COPY, MOVE, SUBTITLE & SERIES TESTS PASSED ===\n'); } finally {