346 lines
13 KiB
JavaScript
346 lines
13 KiB
JavaScript
'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+|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 {
|
|
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 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`;
|
|
};
|
|
|
|
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
|
|
|| (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))
|
|
.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 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));
|
|
const parentDir = path.dirname(sourceDir);
|
|
|
|
const removed = removeDirAndJunk(sourceDir, jobLog);
|
|
if (removed && isSeason && removeEmptyParentSeriesDir) {
|
|
if (parentDir && !isProtectedDir(parentDir) && isDirEmptyIgnoringJunk(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;
|