fix(finalizeMedia): scope subtitles to episode baseName and handle empty season/series folder cleanup
Build / Lint & Test (push) Successful in 18s
Build / Lint & Test (push) Successful in 18s
This commit is contained in:
1 parent
86448988ed
commit
30fb556af7
2 files changed
+163
-10
No files matched your search
@@ -25,12 +25,67 @@ const details = () => ({
|
|||||||
inputUI: { type: 'dropdown', options: ['true', 'false'] },
|
inputUI: { type: 'dropdown', options: ['true', 'false'] },
|
||||||
tooltip: 'Remove the original source directory only when it is empty.',
|
tooltip: 'Remove the original source directory only when it is empty.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Remove Empty Parent Series Directory',
|
||||||
|
name: 'removeEmptyParentSeriesDir',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: 'true',
|
||||||
|
inputUI: { type: 'dropdown', options: ['true', 'false'] },
|
||||||
|
tooltip: 'If the removed source directory was a season folder, also remove the parent series directory if it is empty.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
outputs: [{ number: 1, tooltip: 'Finalization complete' }],
|
outputs: [{ number: 1, tooltip: 'Finalization complete' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const LANGUAGE_CODES = { it: 'ita', en: 'eng' };
|
const LANGUAGE_CODES = { it: 'ita', en: 'eng' };
|
||||||
|
|
||||||
|
const IGNORED_JUNK_FILES = new Set([
|
||||||
|
'.ds_store',
|
||||||
|
'thumbs.db',
|
||||||
|
'desktop.ini',
|
||||||
|
'ehthumbs.db',
|
||||||
|
'ehthumbs_vista.db',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SEASON_DIR_REGEX = /^(season|stagione|staffel|saison|s)?\s*\d+$/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 isDirEmptyIgnoringJunk = (dirPath) => {
|
||||||
|
if (!fs.existsSync(dirPath)) return false;
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dirPath);
|
||||||
|
const significant = entries.filter((e) => !IGNORED_JUNK_FILES.has(e.toLowerCase()));
|
||||||
|
return significant.length === 0;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDirAndJunk = (dirPath, jobLog) => {
|
||||||
|
if (!fs.existsSync(dirPath)) return false;
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dirPath);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (IGNORED_JUNK_FILES.has(entry.toLowerCase())) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(path.join(dirPath, entry));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.rmdirSync(dirPath);
|
||||||
|
if (jobLog) jobLog(`[finalizeMedia] Removed empty directory: ${dirPath}\n`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (jobLog) jobLog(`[finalizeMedia] Could not remove directory ${dirPath}: ${err.message}\n`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeSubtitleParts = (parts) => {
|
const normalizeSubtitleParts = (parts) => {
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
@@ -65,6 +120,11 @@ const normalizeSubtitleName = (fileName) => {
|
|||||||
return `${prefix.concat(suffix).join('.')}${ext}`;
|
return `${prefix.concat(suffix).join('.')}${ext}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMediaSubtitle = (fileName, mediaBaseName) => {
|
||||||
|
if (!/\.srt$/i.test(fileName)) return false;
|
||||||
|
return fileName.toLowerCase().startsWith(`${mediaBaseName.toLowerCase()}.`);
|
||||||
|
};
|
||||||
|
|
||||||
const subtitleKey = (fileName) => {
|
const subtitleKey = (fileName) => {
|
||||||
const stem = fileName.slice(0, -path.extname(fileName).length);
|
const stem = fileName.slice(0, -path.extname(fileName).length);
|
||||||
return stem.replace(/_chapters(?=\.|$)/i, '').toLowerCase();
|
return stem.replace(/_chapters(?=\.|$)/i, '').toLowerCase();
|
||||||
@@ -116,8 +176,9 @@ const plugin = async (args) => {
|
|||||||
throw new Error(`[finalizeMedia] Final directory does not exist: ${finalDir}`);
|
throw new Error(`[finalizeMedia] Final directory does not exist: ${finalDir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter only subtitles that belong to this specific media/episode file
|
||||||
const files = fs.readdirSync(finalDir)
|
const files = fs.readdirSync(finalDir)
|
||||||
.filter((file) => /\.srt$/i.test(file))
|
.filter((file) => isMediaSubtitle(file, mediaBaseName))
|
||||||
.map((file) => ({ file, fullPath: path.join(finalDir, file) }));
|
.map((file) => ({ file, fullPath: path.join(finalDir, file) }));
|
||||||
|
|
||||||
for (const entry of files) {
|
for (const entry of files) {
|
||||||
@@ -137,9 +198,10 @@ const plugin = async (args) => {
|
|||||||
entry.fullPath = destination;
|
entry.fullPath = destination;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group and deduplicate only subtitles belonging to this specific media file
|
||||||
const grouped = new Map();
|
const grouped = new Map();
|
||||||
for (const entry of fs.readdirSync(finalDir)
|
for (const entry of fs.readdirSync(finalDir)
|
||||||
.filter((file) => /\.srt$/i.test(file))
|
.filter((file) => isMediaSubtitle(file, mediaBaseName))
|
||||||
.map((file) => ({ file, fullPath: path.join(finalDir, file) }))) {
|
.map((file) => ({ file, fullPath: path.join(finalDir, file) }))) {
|
||||||
const key = subtitleKey(entry.file);
|
const key = subtitleKey(entry.file);
|
||||||
if (!grouped.has(key)) grouped.set(key, []);
|
if (!grouped.has(key)) grouped.set(key, []);
|
||||||
@@ -191,15 +253,31 @@ const plugin = async (args) => {
|
|||||||
jobLog(`[finalizeMedia] Renamed chapter XML: ${entry.file} -> ${path.basename(canonicalChapter)}\n`);
|
jobLog(`[finalizeMedia] Renamed chapter XML: ${entry.file} -> ${path.basename(canonicalChapter)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((args.inputs.removeEmptySourceDir || 'true') === 'true'
|
// Remove source directory when empty (e.g. when transcoding from a staging/ingest directory).
|
||||||
&& sourceDir && path.resolve(sourceDir) !== path.resolve(finalDir)
|
// 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 removeEmptySourceDir = (args.inputs.removeEmptySourceDir || 'true') === 'true';
|
||||||
|
const removeEmptyParentSeriesDir = (args.inputs.removeEmptyParentSeriesDir || 'true') === 'true';
|
||||||
|
|
||||||
|
if (removeEmptySourceDir
|
||||||
|
&& sourceDir
|
||||||
|
&& path.resolve(sourceDir) !== path.resolve(finalDir)
|
||||||
&& fs.existsSync(sourceDir)) {
|
&& fs.existsSync(sourceDir)) {
|
||||||
const remaining = fs.readdirSync(sourceDir);
|
if (isDirEmptyIgnoringJunk(sourceDir)) {
|
||||||
if (remaining.length === 0) {
|
const isSeason = isSeasonDirectory(path.basename(sourceDir));
|
||||||
fs.rmdirSync(sourceDir);
|
const parentDir = path.dirname(sourceDir);
|
||||||
jobLog(`[finalizeMedia] Removed empty source directory: ${sourceDir}\n`);
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
jobLog(`[finalizeMedia] Source directory is not empty; keeping it: ${sourceDir}\n`);
|
jobLog(`[finalizeMedia] Source directory is not empty (contains other episodes/files); keeping it: ${sourceDir}\n`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,82 @@ 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.default.srt')), 'eng.sdh.default.srt was renamed');
|
||||||
assert(fs.existsSync(path.join(libraryDir, 'Movie.eng.sdh.srt')), 'eng.sdh.srt exists (default stripped)');
|
assert(fs.existsSync(path.join(libraryDir, 'Movie.eng.sdh.srt')), 'eng.sdh.srt exists (default stripped)');
|
||||||
|
|
||||||
console.log('\n=== ALL COPY, MOVE & SUBTITLE TESTS PASSED ===\n');
|
// 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');
|
||||||
|
fs.mkdirSync(seriesSourceDir, { recursive: true });
|
||||||
|
|
||||||
|
const finalSeasonDir = path.join(tmpDir, 'final_library', 'Halo', 'Season 01');
|
||||||
|
fs.mkdirSync(finalSeasonDir, { recursive: true });
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
|
||||||
|
// 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/>');
|
||||||
|
|
||||||
|
// 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/>');
|
||||||
|
|
||||||
|
// In source directory, simulate that Ep2 is still waiting in staging
|
||||||
|
fs.writeFileSync(path.join(seriesSourceDir, 'Halo - S01E02.mkv'), Buffer.alloc(100));
|
||||||
|
|
||||||
|
const finalizeEp1Args = {
|
||||||
|
inputFileObj: {
|
||||||
|
_id: ep1File,
|
||||||
|
ffProbeData: { streams: [] },
|
||||||
|
},
|
||||||
|
variables: { originalLibraryDir: seriesSourceDir },
|
||||||
|
inputs: { removeEmptySourceDir: 'true', removeEmptyParentSeriesDir: 'true' },
|
||||||
|
jobLog: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await finalizePlugin(finalizeEp1Args);
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
// Source season directory was NOT removed because Ep2 was still in staging
|
||||||
|
assert(fs.existsSync(seriesSourceDir), 'Source Season 01 directory kept while Ep2 remains');
|
||||||
|
|
||||||
|
// 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 finalizeEp2Args = {
|
||||||
|
inputFileObj: {
|
||||||
|
_id: ep2File,
|
||||||
|
ffProbeData: { streams: [] },
|
||||||
|
},
|
||||||
|
variables: { originalLibraryDir: seriesSourceDir },
|
||||||
|
inputs: { removeEmptySourceDir: 'true', removeEmptyParentSeriesDir: 'true' },
|
||||||
|
jobLog: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await finalizePlugin(finalizeEp2Args);
|
||||||
|
|
||||||
|
// 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, 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');
|
||||||
|
|
||||||
|
console.log('\n=== ALL COPY, MOVE, SUBTITLE & SERIES TESTS PASSED ===\n');
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user