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
+253 -62

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,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 {