1 parent
3f0b38d513
commit
a8c4a81141
8 files changed
+271
-19
No files matched your search
@@ -72,11 +72,14 @@ const normLang = (tag) => {
|
|||||||
|
|
||||||
const buildSuffix = (lang, stream) => {
|
const buildSuffix = (lang, stream) => {
|
||||||
const parts = [lang];
|
const parts = [lang];
|
||||||
if (stream.disposition && stream.disposition.forced) parts.push('forced');
|
const forced = stream.disposition && stream.disposition.forced;
|
||||||
if (stream.disposition && stream.disposition.default) parts.push('default');
|
const title = ((stream.tags && (stream.tags.title || stream.tags.TITLE)) || '').toLowerCase();
|
||||||
|
const isForced = Boolean(forced || title.includes('forced'));
|
||||||
|
if (isForced) parts.push('forced');
|
||||||
|
if (isForced || (stream.disposition && stream.disposition.default)) parts.push('default');
|
||||||
if (stream.disposition && stream.disposition.hearing_impaired) parts.push('sdh');
|
if (stream.disposition && stream.disposition.hearing_impaired) parts.push('sdh');
|
||||||
const title = ((stream.tags && stream.tags.title) || '').toLowerCase();
|
if (!parts.includes('sdh') && (title.includes('sdh') || title.includes('hearing impaired')
|
||||||
if (!parts.includes('sdh') && title.includes('sdh')) parts.push('sdh');
|
|| title.includes('hearing_impaired'))) parts.push('sdh');
|
||||||
return parts.join('.');
|
return parts.join('.');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,7 +123,7 @@ const plugin = async (args) => {
|
|||||||
|
|
||||||
const subtitleEditPath = args.inputs.subtitleEditPath || 'SubtitleEdit';
|
const subtitleEditPath = args.inputs.subtitleEditPath || 'SubtitleEdit';
|
||||||
const targetLangs = (args.inputs.languages || 'ita,eng')
|
const targetLangs = (args.inputs.languages || 'ita,eng')
|
||||||
.split(',').map((l) => l.trim().toLowerCase()).filter(Boolean);
|
.split(',').map((l) => normLang(l)).filter(Boolean);
|
||||||
|
|
||||||
const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || [];
|
const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || [];
|
||||||
const subtitleStreams = streams.filter((s) => s.codec_type === 'subtitle');
|
const subtitleStreams = streams.filter((s) => s.codec_type === 'subtitle');
|
||||||
@@ -134,6 +137,7 @@ const plugin = async (args) => {
|
|||||||
|
|
||||||
const textTasks = [];
|
const textTasks = [];
|
||||||
const bitmapTasks = [];
|
const bitmapTasks = [];
|
||||||
|
const taskByOutput = new Map();
|
||||||
|
|
||||||
for (const stream of subtitleStreams) {
|
for (const stream of subtitleStreams) {
|
||||||
const lang = normLang((stream.tags && stream.tags.language) || '');
|
const lang = normLang((stream.tags && stream.tags.language) || '');
|
||||||
@@ -146,13 +150,23 @@ const plugin = async (args) => {
|
|||||||
const suffix = buildSuffix(lang, stream);
|
const suffix = buildSuffix(lang, stream);
|
||||||
const outSrt = path.join(targetDir, `${baseName}.${suffix}.srt`);
|
const outSrt = path.join(targetDir, `${baseName}.${suffix}.srt`);
|
||||||
|
|
||||||
if (TEXT_CODECS.has(codec)) {
|
const task = { stream, lang, codec, outSrt };
|
||||||
textTasks.push({ stream, lang, codec, outSrt });
|
const isText = TEXT_CODECS.has(codec);
|
||||||
} else if (BITMAP_CODECS.has(codec)) {
|
const isBitmap = BITMAP_CODECS.has(codec);
|
||||||
bitmapTasks.push({ stream, lang, codec, outSrt });
|
if (isText || isBitmap) {
|
||||||
|
const existing = taskByOutput.get(outSrt);
|
||||||
|
if (!existing || (isText && existing.type === 'bitmap')) {
|
||||||
|
taskByOutput.set(outSrt, { ...task, type: isText ? 'text' : 'bitmap' });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
jobLog(`[extractSubtitlesAndOcr] Stream #${stream.index} codec "${codec}" not supported — skipping.\n`);
|
jobLog(`[extractSubtitlesAndOcr] Stream #${stream.index} codec "${codec}" not supported — skipping.\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of taskByOutput.values()) {
|
||||||
|
if (task.type === 'text') textTasks.push(task);
|
||||||
|
else bitmapTasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 1. Batch extract text-based subtitles (mkvextract or single-pass FFmpeg)
|
// ── 1. Batch extract text-based subtitles (mkvextract or single-pass FFmpeg)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const fs = require('fs');
|
|||||||
* - The network path is slow or unreliable during long encodes
|
* - The network path is slow or unreliable during long encodes
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. Copies source → workDir/<basename><ext> (streams, no RAM load)
|
* 1. Copies source → workDir/<basename>_old<ext> (streams, no RAM load)
|
||||||
* 2. Updates outputFileObj._id to the local copy path
|
* 2. Updates outputFileObj._id to the local copy path
|
||||||
* 3. Downstream plugins operate on the local copy
|
* 3. Downstream plugins operate on the local copy
|
||||||
* 4. Tdarr replaces the original with the final output as usual
|
* 4. Tdarr replaces the original with the final output as usual
|
||||||
@@ -63,7 +63,7 @@ const plugin = async (args) => {
|
|||||||
const inputFile = inputFileObj._id;
|
const inputFile = inputFileObj._id;
|
||||||
const ext = path.extname(inputFile);
|
const ext = path.extname(inputFile);
|
||||||
const baseName = path.basename(inputFile, ext);
|
const baseName = path.basename(inputFile, ext);
|
||||||
const localCopy = path.join(workDir, `${baseName}${ext}`);
|
const localCopy = path.join(workDir, `${baseName}_old${ext}`);
|
||||||
|
|
||||||
jobLog(`[copyToWorkDir] Source : ${inputFile}\n`);
|
jobLog(`[copyToWorkDir] Source : ${inputFile}\n`);
|
||||||
jobLog(`[copyToWorkDir] WorkDir: ${workDir}\n`);
|
jobLog(`[copyToWorkDir] WorkDir: ${workDir}\n`);
|
||||||
@@ -80,6 +80,8 @@ const plugin = async (args) => {
|
|||||||
variables.originalLibraryFile = inputFile;
|
variables.originalLibraryFile = inputFile;
|
||||||
variables.originalLibraryDir = path.dirname(inputFile);
|
variables.originalLibraryDir = path.dirname(inputFile);
|
||||||
}
|
}
|
||||||
|
variables.workDirSourceBaseName = baseName;
|
||||||
|
variables.workDirCopyApplied = 'true';
|
||||||
|
|
||||||
// ── Copy using streams (no RAM spike for large files) ────────────────────
|
// ── Copy using streams (no RAM spike for large files) ────────────────────
|
||||||
const sourceSize = fs.statSync(inputFile).size;
|
const sourceSize = fs.statSync(inputFile).size;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -201,8 +201,10 @@ const plugin = async (args) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── Paths ─────────────────────────────────────────────────────────────────
|
// ── Paths ─────────────────────────────────────────────────────────────────
|
||||||
const baseName = path.basename(inputFile, path.extname(inputFile));
|
const inputBaseName = path.basename(inputFile, path.extname(inputFile));
|
||||||
const outFile = path.join(workDir, `${baseName}_x265.${container}`);
|
const baseName = variables.workDirSourceBaseName
|
||||||
|
|| (variables.workDirCopyApplied === 'true' ? inputBaseName.replace(/_old$/, '') : inputBaseName);
|
||||||
|
const outFile = path.join(workDir, `${baseName}.${container}`);
|
||||||
const statsFile = path.join(workDir, 'x265_2pass.log');
|
const statsFile = path.join(workDir, 'x265_2pass.log');
|
||||||
// The CLI runs with workDir as its current directory. Keeping this relative
|
// The CLI runs with workDir as its current directory. Keeping this relative
|
||||||
// avoids the Windows drive colon being parsed as an x265-params separator.
|
// avoids the Windows drive colon being parsed as an x265-params separator.
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ curl -fsSL "${BASE}/LocalFlowPlugins/${CATEGORY}/${PLUGIN}/1.0.0/index.js" -o "$
|
|||||||
| `mediaAnalyzerGOP` | `utility` |
|
| `mediaAnalyzerGOP` | `utility` |
|
||||||
| `setAnimationFlag` | `utility` |
|
| `setAnimationFlag` | `utility` |
|
||||||
| `extractChaptersXml` | `utility` |
|
| `extractChaptersXml` | `utility` |
|
||||||
|
| `finalizeMedia` | `utility` |
|
||||||
| `audioSelectorClamping` | `audio` |
|
| `audioSelectorClamping` | `audio` |
|
||||||
| `backupSourceAudio` | `audio` |
|
| `backupSourceAudio` | `audio` |
|
||||||
| `extractSubtitlesAndOcr` | `subtitles` |
|
| `extractSubtitlesAndOcr` | `subtitles` |
|
||||||
@@ -95,7 +96,8 @@ LocalFlowPlugins/
|
|||||||
├── utility/
|
├── utility/
|
||||||
│ ├── mediaAnalyzerGOP/1.0.0/index.js
|
│ ├── mediaAnalyzerGOP/1.0.0/index.js
|
||||||
│ ├── setAnimationFlag/1.0.0/index.js
|
│ ├── setAnimationFlag/1.0.0/index.js
|
||||||
│ └── extractChaptersXml/1.0.0/index.js
|
│ ├── extractChaptersXml/1.0.0/index.js
|
||||||
|
│ └── finalizeMedia/1.0.0/index.js
|
||||||
├── audio/
|
├── audio/
|
||||||
│ ├── audioSelectorClamping/1.0.0/index.js
|
│ ├── audioSelectorClamping/1.0.0/index.js
|
||||||
│ └── backupSourceAudio/1.0.0/index.js
|
│ └── backupSourceAudio/1.0.0/index.js
|
||||||
@@ -206,6 +208,10 @@ OCR failures are logged as **warnings** without stopping the pipeline.
|
|||||||
|
|
||||||
**Output naming**: `<basename>.<lang>[.forced][.default][.sdh].srt`
|
**Output naming**: `<basename>.<lang>[.forced][.default][.sdh].srt`
|
||||||
|
|
||||||
|
Text tracks take precedence over bitmap tracks with the same language and flags.
|
||||||
|
Two-letter `it`/`en` tags are normalized to `ita`/`eng`; an Italian forced track
|
||||||
|
is always marked `default`.
|
||||||
|
|
||||||
| Input | Default | Description |
|
| Input | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `subtitleEditPath` | `SubtitleEdit` | Name or full path of SubtitleEdit CLI. Must be in PATH or provide absolute path. |
|
| `subtitleEditPath` | `SubtitleEdit` | Name or full path of SubtitleEdit CLI. Must be in PATH or provide absolute path. |
|
||||||
@@ -219,7 +225,7 @@ OCR failures are logged as **warnings** without stopping the pipeline.
|
|||||||
|
|
||||||
### 7. `transcode2PassX265` — 2-Pass x265 Transcode
|
### 7. `transcode2PassX265` — 2-Pass x265 Transcode
|
||||||
|
|
||||||
**Category**: `video` · **Step**: last
|
**Category**: `video` · **Step**: before `finalizeMedia`
|
||||||
|
|
||||||
Performs 2-pass libx265 encoding using VBV parameters from `args.variables`.
|
Performs 2-pass libx265 encoding using VBV parameters from `args.variables`.
|
||||||
|
|
||||||
@@ -239,6 +245,13 @@ Performs 2-pass libx265 encoding using VBV parameters from `args.variables`.
|
|||||||
|
|
||||||
**Reads from variables**: `gopKeyint`, `gopMinKeyint`, `gopSceneCut`, `vBitrate`, `vMaxRate`, `vBufSize`, `x265Tune`, `audioHasIta`, `audioItaStreamIndex`, `audioItaOpusBitrate`, `audioHasEng`, `audioEngStreamIndex`, `audioEngOpusBitrate`
|
**Reads from variables**: `gopKeyint`, `gopMinKeyint`, `gopSceneCut`, `vBitrate`, `vMaxRate`, `vBufSize`, `x265Tune`, `audioHasIta`, `audioItaStreamIndex`, `audioItaOpusBitrate`, `audioHasEng`, `audioEngStreamIndex`, `audioEngOpusBitrate`
|
||||||
|
|
||||||
|
### 8. `finalizeMedia` — Finalize Media & Sidecars
|
||||||
|
|
||||||
|
Run after `Move To Directory`. It normalizes subtitle language codes, adds
|
||||||
|
`.default` to `ita.forced`, removes duplicate `_chapters` sidecars, and removes
|
||||||
|
the original source directory only when it is empty. It also canonicalizes
|
||||||
|
chapter XML sidecars to `<media>_chapters.xml` and removes duplicate copies.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔗 Flow Variable Map
|
## 🔗 Flow Variable Map
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ Questa guida descrive come assemblare i 7 plugin nel canvas Flow di Tdarr, confi
|
|||||||
├── utility/
|
├── utility/
|
||||||
│ ├── mediaAnalyzerGOP/1.0.0/index.js
|
│ ├── mediaAnalyzerGOP/1.0.0/index.js
|
||||||
│ ├── setAnimationFlag/1.0.0/index.js
|
│ ├── setAnimationFlag/1.0.0/index.js
|
||||||
│ └── extractChaptersXml/1.0.0/index.js
|
│ ├── extractChaptersXml/1.0.0/index.js
|
||||||
|
│ └── finalizeMedia/1.0.0/index.js
|
||||||
├── audio/
|
├── audio/
|
||||||
│ ├── audioSelectorClamping/1.0.0/index.js
|
│ ├── audioSelectorClamping/1.0.0/index.js
|
||||||
│ └── backupSourceAudio/1.0.0/index.js
|
│ └── backupSourceAudio/1.0.0/index.js
|
||||||
@@ -67,6 +68,9 @@ Nella Web UI: **Settings → Nodes → [tuo worker Windows]**
|
|||||||
|
|
||||||
> Il plugin `extractChaptersXml` deriva automaticamente il path di `mkvextract.exe` dalla stessa cartella di `mkvpropedit.exe`.
|
> Il plugin `extractChaptersXml` deriva automaticamente il path di `mkvextract.exe` dalla stessa cartella di `mkvpropedit.exe`.
|
||||||
|
|
||||||
|
`copyToWorkDir` crea la copia temporanea con suffisso `_old`; il transcoder
|
||||||
|
produce così il nome originale senza `_x265`.
|
||||||
|
|
||||||
### 3.3 Configurazione PATH Windows
|
### 3.3 Configurazione PATH Windows
|
||||||
|
|
||||||
Aggiungi al PATH di sistema:
|
Aggiungi al PATH di sistema:
|
||||||
@@ -104,7 +108,11 @@ Aggiungi al PATH di sistema:
|
|||||||
▼
|
▼
|
||||||
[transcode2PassX265]
|
[transcode2PassX265]
|
||||||
▼
|
▼
|
||||||
[Fine]
|
[Copy/Move Folder Content]
|
||||||
|
▼
|
||||||
|
[Move To Directory]
|
||||||
|
▼
|
||||||
|
[finalizeMedia]
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 Connessioni nel Canvas
|
### 4.2 Connessioni nel Canvas
|
||||||
@@ -155,6 +163,16 @@ Aggiungi al PATH di sistema:
|
|||||||
| x265 Preset | `slow` |
|
| x265 Preset | `slow` |
|
||||||
| Output Container | `mkv` |
|
| Output Container | `mkv` |
|
||||||
|
|
||||||
|
### `finalizeMedia` — Valori raccomandati
|
||||||
|
|
||||||
|
| Input | Valore |
|
||||||
|
|---|---|
|
||||||
|
| Remove Empty Source Directory | `true` |
|
||||||
|
|
||||||
|
Collegare dopo `Move To Directory`. Il nodo normalizza i codici lingua dei
|
||||||
|
sottotitoli, controlla titoli/dispositions tramite i metadati ffprobe,
|
||||||
|
preferisce i text agli image e rimuove i duplicati SRT e XML dei capitoli.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Variabili di Flusso (args.variables)
|
## 6. Variabili di Flusso (args.variables)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ declare -A PLUGIN_CATEGORY=(
|
|||||||
[mediaAnalyzerGOP]=utility
|
[mediaAnalyzerGOP]=utility
|
||||||
[setAnimationFlag]=utility
|
[setAnimationFlag]=utility
|
||||||
[extractChaptersXml]=utility
|
[extractChaptersXml]=utility
|
||||||
|
[finalizeMedia]=utility
|
||||||
[copyToWorkDir]=utility
|
[copyToWorkDir]=utility
|
||||||
[audioSelectorClamping]=audio
|
[audioSelectorClamping]=audio
|
||||||
[backupSourceAudio]=audio
|
[backupSourceAudio]=audio
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* test4-subtitles.js
|
* test4-subtitles.js
|
||||||
* Validates: extractSubtitlesAndOcr produces correctly named SRT files.
|
* Validates: extractSubtitlesAndOcr produces correctly named SRT files.
|
||||||
* Source: MKV with 2 ITA SRT streams (forced + SDH).
|
* Source: MKV with 2 ITA SRT streams (forced + SDH).
|
||||||
* Expected: .ita.forced.srt and .ita.hearing_impaired.srt in source dir.
|
* Expected: .ita.forced.default.srt and .ita.sdh.srt in source dir.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -27,7 +27,7 @@ async function run() {
|
|||||||
await subPlugin(args);
|
await subPlugin(args);
|
||||||
|
|
||||||
// Check that forced SRT was created
|
// Check that forced SRT was created
|
||||||
const forcedSrt = path.join(SOURCE_DIR, `${BASE_NAME}.ita.forced.srt`);
|
const forcedSrt = path.join(SOURCE_DIR, `${BASE_NAME}.ita.forced.default.srt`);
|
||||||
const sdhSrt = path.join(SOURCE_DIR, `${BASE_NAME}.ita.sdh.srt`);
|
const sdhSrt = path.join(SOURCE_DIR, `${BASE_NAME}.ita.sdh.srt`);
|
||||||
|
|
||||||
console.log(' Checking for:', path.basename(forcedSrt));
|
console.log(' Checking for:', path.basename(forcedSrt));
|
||||||
|
|||||||
Reference in new issue
Block a user