1 parent
3f0b38d513
commit
a8c4a81141
8 files changed
+271
-19
No files matched your search
@@ -0,0 +1,202 @@
|
||||
'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.',
|
||||
},
|
||||
],
|
||||
outputs: [{ number: 1, tooltip: 'Finalization complete' }],
|
||||
});
|
||||
|
||||
const LANGUAGE_CODES = { it: 'ita', en: 'eng' };
|
||||
|
||||
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 (!normalized.includes(value)) normalized.push(value);
|
||||
}
|
||||
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 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}`);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(finalDir)
|
||||
.filter((file) => /\.srt$/i.test(file))
|
||||
.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;
|
||||
}
|
||||
|
||||
const grouped = new Map();
|
||||
for (const entry of fs.readdirSync(finalDir)
|
||||
.filter((file) => /\.srt$/i.test(file))
|
||||
.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 expected = `${mediaBaseName}.${item.language}`
|
||||
+ (item.forced ? '.forced' : '')
|
||||
+ (item.forced || item.default ? '.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`);
|
||||
}
|
||||
|
||||
if ((args.inputs.removeEmptySourceDir || 'true') === 'true'
|
||||
&& 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`);
|
||||
} else {
|
||||
jobLog(`[finalizeMedia] Source directory is not empty; keeping it: ${sourceDir}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
};
|
||||
|
||||
module.exports.details = details;
|
||||
module.exports.plugin = plugin;
|
||||
Reference in new issue
Block a user