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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user