feat(flow): support folder-to-folder conversion with sidecar migration and prune
Build / Lint & Test (push) Successful in 28s
Build / Lint & Test (push) Successful in 28s
This commit is contained in:
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 {
|
||||
|
||||
Reference in new issue
Block a user