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 {