feat(flow): support folder-to-folder conversion with sidecar migration and prune
Build / Lint & Test (push) Successful in 28s

This commit is contained in:
PickleRick committed 2026-09-30 18:43:12 +02:00
1 parent f2e483ca1f
commit 88ecf9a847
3 files changed
+252 -61

No files matched your search

@@ -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,13 +329,10 @@ 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) {
if (parentDir && !isProtectedDir(parentDir) && isDirEmptyIgnoringJunk(parentDir)) {
removeDirAndJunk(parentDir, jobLog);
}
}
}
} else {
jobLog(`[finalizeMedia] Source directory is not empty (contains other episodes/files); keeping it: ${sourceDir}\n`);
}
@@ -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,
+131 -48
View File
@@ -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, '<chapters ep1/>');
// 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'), '<chapters/>');
// 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, '<chapters ep2/>');
// 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'), '<chapters ep2/>');
// 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, '<chapters movie/>');
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 {