diff --git a/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js b/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js index 4507784..515dbe3 100644 --- a/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js +++ b/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js @@ -25,12 +25,67 @@ const details = () => ({ inputUI: { type: 'dropdown', options: ['true', 'false'] }, 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' }], }); 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 normalized = []; for (const part of parts) { @@ -65,6 +120,11 @@ const normalizeSubtitleName = (fileName) => { 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 stem = fileName.slice(0, -path.extname(fileName).length); return stem.replace(/_chapters(?=\.|$)/i, '').toLowerCase(); @@ -116,8 +176,9 @@ const plugin = async (args) => { 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) - .filter((file) => /\.srt$/i.test(file)) + .filter((file) => isMediaSubtitle(file, mediaBaseName)) .map((file) => ({ file, fullPath: path.join(finalDir, file) })); for (const entry of files) { @@ -137,9 +198,10 @@ const plugin = async (args) => { entry.fullPath = destination; } + // Group and deduplicate only subtitles belonging to this specific media file const grouped = new Map(); 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) }))) { const key = subtitleKey(entry.file); 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`); } - if ((args.inputs.removeEmptySourceDir || 'true') === 'true' - && sourceDir && path.resolve(sourceDir) !== path.resolve(finalDir) + // 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 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)) { - const remaining = fs.readdirSync(sourceDir); - if (remaining.length === 0) { - fs.rmdirSync(sourceDir); - jobLog(`[finalizeMedia] Removed empty source directory: ${sourceDir}\n`); + if (isDirEmptyIgnoringJunk(sourceDir)) { + const isSeason = isSeasonDirectory(path.basename(sourceDir)); + const parentDir = path.dirname(sourceDir); + + 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 { - 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`); } } diff --git a/tests/test-cases/test-copy-and-subtitles.js b/tests/test-cases/test-copy-and-subtitles.js index ab73502..8f7e04d 100644 --- a/tests/test-cases/test-copy-and-subtitles.js +++ b/tests/test-cases/test-copy-and-subtitles.js @@ -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.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'), ''); + + // 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'), ''); + + // 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 { fs.rmSync(tmpDir, { recursive: true, force: true }); }