'use strict'; const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const details = () => ({ name: 'Finalize Media & Sidecars', description: 'Normalizes subtitle language codes and forced/default flags, removes duplicate ' + 'chapter XML sidecars, and removes the original directory only when it is empty.', style: { borderColor: '#4CAF50' }, tags: 'utility,finalize,subtitles', isStartPlugin: false, pType: '', requiresVersion: '2.11.01', sidebarPosition: 5, icon: 'faCheck', inputs: [ { label: 'Remove Empty Source Directory', name: 'removeEmptySourceDir', type: 'boolean', defaultValue: 'true', 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) { const value = part.toLowerCase(); if (LANGUAGE_CODES[value]) { normalized.push(LANGUAGE_CODES[value]); } else if (value === 'default') { // Discard 'default'; it will only be re-added below if it is ita + forced continue; } else if (!normalized.includes(value)) { normalized.push(value); } } // Only ita.forced can be marked as default; default must be removed from all other subtitles if (normalized.includes('ita') && normalized.includes('forced') && !normalized.includes('default')) { normalized.splice(normalized.indexOf('forced') + 1, 0, 'default'); } return normalized; }; const normalizeSubtitleName = (fileName) => { if (!/\.srt$/i.test(fileName)) return fileName; const ext = path.extname(fileName); const stem = fileName.slice(0, -ext.length); const parts = stem.split('.'); const languageIndex = parts.findIndex((part) => LANGUAGE_CODES[part.toLowerCase()] || ['ita', 'eng'].includes(part.toLowerCase())); if (languageIndex < 0) return fileName; const prefix = parts.slice(0, languageIndex); const suffix = normalizeSubtitleParts(parts.slice(languageIndex)); 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(); }; 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) { return null; } return `${mediaBaseName}_chapters.xml`; }; const hashFile = (fileName) => { const hash = crypto.createHash('sha1'); hash.update(fs.readFileSync(fileName)); return hash.digest('hex'); }; const subtitleMetadata = (args) => { const streams = (args.inputFileObj.ffProbeData && args.inputFileObj.ffProbeData.streams) || []; return streams.filter((stream) => stream.codec_type === 'subtitle').map((stream) => { const tags = stream.tags || {}; const title = String(tags.title || tags.TITLE || '').toLowerCase(); const language = String(tags.language || '').toLowerCase(); return { language: LANGUAGE_CODES[language] || language, forced: Boolean(stream.disposition && stream.disposition.forced) || title.includes('forced'), default: Boolean(stream.disposition && stream.disposition.default), sdh: Boolean(stream.disposition && stream.disposition.hearing_impaired) || title.includes('sdh') || title.includes('hearing impaired') || title.includes('hearing_impaired'), }; }); }; const plugin = async (args) => { const { jobLog, inputFileObj, variables } = args; const finalFile = inputFileObj._id; const finalDir = path.dirname(finalFile); const sourceDir = variables.originalLibraryDir; const mediaBaseName = path.basename(finalFile, path.extname(finalFile)); const metadata = subtitleMetadata(args); if (!fs.existsSync(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) .filter((file) => isMediaSubtitle(file, mediaBaseName)) .map((file) => ({ file, fullPath: path.join(finalDir, file) })); for (const entry of files) { const normalized = normalizeSubtitleName(entry.file); if (normalized === entry.file) continue; const destination = path.join(finalDir, normalized); if (fs.existsSync(destination)) { if (hashFile(entry.fullPath) === hashFile(destination)) { fs.unlinkSync(entry.fullPath); jobLog(`[finalizeMedia] Removed duplicate subtitle: ${entry.file}\n`); } continue; } fs.renameSync(entry.fullPath, destination); entry.file = normalized; 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) => isMediaSubtitle(file, mediaBaseName)) .map((file) => ({ file, fullPath: path.join(finalDir, file) }))) { const key = subtitleKey(entry.file); if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(entry); } for (const entries of grouped.values()) { if (entries.length < 2) continue; const preferred = entries.find((entry) => !/_chapters(?=\.|$)/i.test(entry.file)) || entries[0]; for (const entry of entries) { if (entry.fullPath === preferred.fullPath) continue; fs.unlinkSync(entry.fullPath); jobLog(`[finalizeMedia] Removed duplicate subtitle: ${entry.file}\n`); } } for (const item of metadata) { const isItaForced = item.language === 'ita' && item.forced; const expected = `${mediaBaseName}.${item.language}` + (item.forced ? '.forced' : '') + (isItaForced ? '.default' : '') + (item.sdh ? '.sdh' : '') + '.srt'; if (!fs.existsSync(path.join(finalDir, expected))) { jobLog(`[finalizeMedia] WARNING: Expected subtitle sidecar is missing: ${expected}\n`); } } const chapterEntries = fs.readdirSync(finalDir) .map((file) => ({ file, fullPath: path.join(finalDir, file), canonical: chapterFileName(file, mediaBaseName), })) .filter((entry) => entry.canonical); const canonicalChapter = path.join(finalDir, `${mediaBaseName}_chapters.xml`); for (const entry of chapterEntries) { if (entry.fullPath === canonicalChapter) continue; if (fs.existsSync(canonicalChapter)) { if (hashFile(entry.fullPath) === hashFile(canonicalChapter)) { fs.unlinkSync(entry.fullPath); jobLog(`[finalizeMedia] Removed duplicate chapter XML: ${entry.file}\n`); } else { jobLog(`[finalizeMedia] WARNING: Different chapter XML retained: ${entry.file}\n`); } continue; } fs.renameSync(entry.fullPath, canonicalChapter); jobLog(`[finalizeMedia] Renamed chapter XML: ${entry.file} -> ${path.basename(canonicalChapter)}\n`); } // 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)) { 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 (contains other episodes/files); keeping it: ${sourceDir}\n`); } } return { outputNumber: 1, outputFileObj: inputFileObj, variables }; }; module.exports.details = details; module.exports.plugin = plugin;