From a8c4a811415ff32a2117a0c8434a60ca3fcd5bc1 Mon Sep 17 00:00:00 2001 From: PickleRick Date: Mon, 21 Sep 2026 10:42:08 +0200 Subject: [PATCH] Save uncommitted changes --- .../extractSubtitlesAndOcr/1.0.0/index.js | 32 ++- .../utility/copyToWorkDir/1.0.0/index.js | 6 +- .../utility/finalizeMedia/1.0.0/index.js | 202 ++++++++++++++++++ .../video/transcode2PassX265/1.0.0/index.js | 6 +- README.md | 17 +- docs/integration-guide.md | 22 +- install.sh | 1 + tests/test-cases/test4-subtitles.js | 4 +- 8 files changed, 271 insertions(+), 19 deletions(-) create mode 100644 LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js diff --git a/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js b/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js index c595715..26277e7 100644 --- a/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js +++ b/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js @@ -72,11 +72,14 @@ const normLang = (tag) => { const buildSuffix = (lang, stream) => { const parts = [lang]; - if (stream.disposition && stream.disposition.forced) parts.push('forced'); - if (stream.disposition && stream.disposition.default) parts.push('default'); + const forced = stream.disposition && stream.disposition.forced; + 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'); - const title = ((stream.tags && stream.tags.title) || '').toLowerCase(); - if (!parts.includes('sdh') && title.includes('sdh')) parts.push('sdh'); + if (!parts.includes('sdh') && (title.includes('sdh') || title.includes('hearing impaired') + || title.includes('hearing_impaired'))) parts.push('sdh'); return parts.join('.'); }; @@ -120,7 +123,7 @@ const plugin = async (args) => { const subtitleEditPath = args.inputs.subtitleEditPath || 'SubtitleEdit'; 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 subtitleStreams = streams.filter((s) => s.codec_type === 'subtitle'); @@ -134,6 +137,7 @@ const plugin = async (args) => { const textTasks = []; const bitmapTasks = []; + const taskByOutput = new Map(); for (const stream of subtitleStreams) { const lang = normLang((stream.tags && stream.tags.language) || ''); @@ -146,13 +150,23 @@ const plugin = async (args) => { const suffix = buildSuffix(lang, stream); const outSrt = path.join(targetDir, `${baseName}.${suffix}.srt`); - if (TEXT_CODECS.has(codec)) { - textTasks.push({ stream, lang, codec, outSrt }); - } else if (BITMAP_CODECS.has(codec)) { - bitmapTasks.push({ stream, lang, codec, outSrt }); + const task = { stream, lang, codec, outSrt }; + const isText = TEXT_CODECS.has(codec); + const isBitmap = BITMAP_CODECS.has(codec); + if (isText || isBitmap) { + const existing = taskByOutput.get(outSrt); + if (!existing || (isText && existing.type === 'bitmap')) { + taskByOutput.set(outSrt, { ...task, type: isText ? 'text' : 'bitmap' }); + } } else { 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) diff --git a/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js b/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js index 41eaf7b..6423437 100644 --- a/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js +++ b/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js @@ -15,7 +15,7 @@ const fs = require('fs'); * - The network path is slow or unreliable during long encodes * * Flow: - * 1. Copies source → workDir/ (streams, no RAM load) + * 1. Copies source → workDir/_old (streams, no RAM load) * 2. Updates outputFileObj._id to the local copy path * 3. Downstream plugins operate on the local copy * 4. Tdarr replaces the original with the final output as usual @@ -63,7 +63,7 @@ const plugin = async (args) => { const inputFile = inputFileObj._id; const ext = path.extname(inputFile); 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] WorkDir: ${workDir}\n`); @@ -80,6 +80,8 @@ const plugin = async (args) => { variables.originalLibraryFile = inputFile; variables.originalLibraryDir = path.dirname(inputFile); } + variables.workDirSourceBaseName = baseName; + variables.workDirCopyApplied = 'true'; // ── Copy using streams (no RAM spike for large files) ──────────────────── const sourceSize = fs.statSync(inputFile).size; diff --git a/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js b/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js new file mode 100644 index 0000000..660d9f4 --- /dev/null +++ b/LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js @@ -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; diff --git a/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js b/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js index 9005fd8..6f6eb41 100644 --- a/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js +++ b/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js @@ -201,8 +201,10 @@ const plugin = async (args) => { ); // ── Paths ───────────────────────────────────────────────────────────────── - const baseName = path.basename(inputFile, path.extname(inputFile)); - const outFile = path.join(workDir, `${baseName}_x265.${container}`); + const inputBaseName = path.basename(inputFile, path.extname(inputFile)); + 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'); // The CLI runs with workDir as its current directory. Keeping this relative // avoids the Windows drive colon being parsed as an x265-params separator. diff --git a/README.md b/README.md index d41d13f..913a2e6 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ curl -fsSL "${BASE}/LocalFlowPlugins/${CATEGORY}/${PLUGIN}/1.0.0/index.js" -o "$ | `mediaAnalyzerGOP` | `utility` | | `setAnimationFlag` | `utility` | | `extractChaptersXml` | `utility` | +| `finalizeMedia` | `utility` | | `audioSelectorClamping` | `audio` | | `backupSourceAudio` | `audio` | | `extractSubtitlesAndOcr` | `subtitles` | @@ -95,7 +96,8 @@ LocalFlowPlugins/ ├── utility/ │ ├── mediaAnalyzerGOP/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/ │ ├── audioSelectorClamping/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**: `.[.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 | |---|---|---| | `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 -**Category**: `video` · **Step**: last +**Category**: `video` · **Step**: before `finalizeMedia` 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` +### 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 `_chapters.xml` and removes duplicate copies. + --- ## 🔗 Flow Variable Map diff --git a/docs/integration-guide.md b/docs/integration-guide.md index d8c85e7..755ffac 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -14,7 +14,8 @@ Questa guida descrive come assemblare i 7 plugin nel canvas Flow di Tdarr, confi ├── utility/ │ ├── mediaAnalyzerGOP/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/ │ ├── audioSelectorClamping/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`. +`copyToWorkDir` crea la copia temporanea con suffisso `_old`; il transcoder +produce così il nome originale senza `_x265`. + ### 3.3 Configurazione PATH Windows Aggiungi al PATH di sistema: @@ -104,7 +108,11 @@ Aggiungi al PATH di sistema: ▼ [transcode2PassX265] ▼ - [Fine] + [Copy/Move Folder Content] + ▼ + [Move To Directory] + ▼ + [finalizeMedia] ``` ### 4.2 Connessioni nel Canvas @@ -155,6 +163,16 @@ Aggiungi al PATH di sistema: | x265 Preset | `slow` | | 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) diff --git a/install.sh b/install.sh index d01a373..24c1028 100644 --- a/install.sh +++ b/install.sh @@ -27,6 +27,7 @@ declare -A PLUGIN_CATEGORY=( [mediaAnalyzerGOP]=utility [setAnimationFlag]=utility [extractChaptersXml]=utility + [finalizeMedia]=utility [copyToWorkDir]=utility [audioSelectorClamping]=audio [backupSourceAudio]=audio diff --git a/tests/test-cases/test4-subtitles.js b/tests/test-cases/test4-subtitles.js index 96b558f..cd2207f 100644 --- a/tests/test-cases/test4-subtitles.js +++ b/tests/test-cases/test4-subtitles.js @@ -4,7 +4,7 @@ * test4-subtitles.js * Validates: extractSubtitlesAndOcr produces correctly named SRT files. * 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'); @@ -27,7 +27,7 @@ async function run() { await subPlugin(args); // 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`); console.log(' Checking for:', path.basename(forcedSrt));