From 1cdd771045ecaa9b0fd4f8089f3892ec3a1d9c4e Mon Sep 17 00:00:00 2001 From: PickleRick Date: Sat, 19 Sep 2026 15:17:38 +0200 Subject: [PATCH] fix: optimize subtitle extraction with mkvextract batch mode, fix copyToWorkDir skip logic, target original library dir --- .../audio/backupSourceAudio/1.0.0/index.js | 6 +- .../extractSubtitlesAndOcr/1.0.0/index.js | 199 +++++++++++------- .../utility/copyToWorkDir/1.0.0/index.js | 22 +- 3 files changed, 133 insertions(+), 94 deletions(-) diff --git a/LocalFlowPlugins/audio/backupSourceAudio/1.0.0/index.js b/LocalFlowPlugins/audio/backupSourceAudio/1.0.0/index.js index 47af082..4f436ec 100644 --- a/LocalFlowPlugins/audio/backupSourceAudio/1.0.0/index.js +++ b/LocalFlowPlugins/audio/backupSourceAudio/1.0.0/index.js @@ -58,9 +58,9 @@ const plugin = async (args) => { let ext = '.dts'; if (itaCodec.includes('truehd')) ext = '.thd'; - const sourceDir = path.dirname(inputFile); - const baseName = path.basename(inputFile, path.extname(inputFile)); - const outFile = path.join(sourceDir, `${baseName}.ita${ext}`); + const targetDir = variables.originalLibraryDir || path.dirname(inputFile); + const baseName = path.basename(variables.originalLibraryFile || inputFile, path.extname(variables.originalLibraryFile || inputFile)); + const outFile = path.join(targetDir, `${baseName}.ita${ext}`); jobLog(`[backupSourceAudio] Backing up ITA ${itaCodec} -> ${outFile}\n`); diff --git a/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js b/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js index dc705cd..c595715 100644 --- a/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js +++ b/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js @@ -8,11 +8,13 @@ const fs = require('fs'); /** * extractSubtitlesAndOcr * Extracts subtitle streams for ITA and ENG languages: - * - Text-based (subrip, srt, ass, ssa, webvtt): direct FFmpeg extraction - * - Bitmap-based (hdmv_pgs_subtitle, dvd_subtitle): FFmpeg .sup extract - * then SubtitleEdit CLI OCR -> .srt (with graceful fallback on failure) + * - Text-based (subrip, srt, ass, ssa, webvtt): + * Fast extraction via mkvextract (all tracks in one pass, seconds instead of minutes), + * with single-pass FFmpeg (-vn -an) fallback. + * - Bitmap-based (hdmv_pgs_subtitle, dvd_subtitle): .sup extract + * then SubtitleEdit CLI OCR -> .srt (with graceful fallback on failure). * - * Output files go next to the source file. + * Output files go to target directory (original library directory or next to source). * Naming: .[.forced][.default][.sdh].srt */ @@ -22,8 +24,8 @@ const BITMAP_CODECS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'pgssub', 'd const details = () => ({ name: 'Extract Subtitles & OCR Fallback', description: - 'Extracts ITA/ENG subtitle streams to SRT files next to the source. ' - + 'Text subtitles are extracted directly via FFmpeg. ' + 'Extracts ITA/ENG subtitle streams to SRT files in the library directory. ' + + 'Fast multi-track extraction via mkvextract / FFmpeg. ' + 'Bitmap subtitles (PGS/VOBSUB) are OCR-d via SubtitleEdit CLI. ' + 'OCR failures are logged as warnings without stopping the pipeline.', style: { borderColor: '#00BCD4' }, @@ -73,21 +75,25 @@ const buildSuffix = (lang, stream) => { if (stream.disposition && stream.disposition.forced) parts.push('forced'); if (stream.disposition && stream.disposition.default) parts.push('default'); if (stream.disposition && stream.disposition.hearing_impaired) parts.push('sdh'); - // Also check title tag for SDH const title = ((stream.tags && stream.tags.title) || '').toLowerCase(); if (!parts.includes('sdh') && title.includes('sdh')) parts.push('sdh'); return parts.join('.'); }; -/** - * Run FFmpeg via Tdarr's CLI helper. - * Passes the full args object so cliUtils can access any field it needs - * (inputFileObj, updateWorker, variables, etc.) without us having to predict them. - */ -const runFFmpeg = async (spawnArgs, args) => { +const getMkvExtract = (args) => { + let mkvExtract = 'mkvextract'; + if (args.mkvpropeditPath) { + const dir = path.dirname(args.mkvpropeditPath); + const candidate = path.join(dir, process.platform === 'win32' ? 'mkvextract.exe' : 'mkvextract'); + if (fs.existsSync(candidate)) return candidate; + } + return mkvExtract; +}; + +const runCLI = async (cliBin, spawnArgs, args) => { const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils'); const cli = new CLI({ - cli: args.ffmpegPath, + cli: cliBin, spawnArgs, WorkDir: args.workDir, jobLog: args.jobLog, @@ -104,10 +110,13 @@ const plugin = async (args) => { const { jobLog, inputFileObj, variables, workDir } = args; const inputFile = inputFileObj._id; - const sourceDir = path.dirname(inputFile); - const baseName = path.basename(inputFile, path.extname(inputFile)); + // Always save output SRTs in original library directory if known (e.g. from copyToWorkDir) + const targetDir = variables.originalLibraryDir || path.dirname(inputFile); + const origFile = variables.originalLibraryFile || inputFile; + const baseName = path.basename(origFile, path.extname(origFile)); - jobLog(`[extractSubtitlesAndOcr] Source: ${inputFile}\n`); + jobLog(`[extractSubtitlesAndOcr] Input: ${inputFile}\n`); + jobLog(`[extractSubtitlesAndOcr] Output Directory: ${targetDir}\n`); const subtitleEditPath = args.inputs.subtitleEditPath || 'SubtitleEdit'; const targetLangs = (args.inputs.languages || 'ita,eng') @@ -123,6 +132,9 @@ const plugin = async (args) => { jobLog(`[extractSubtitlesAndOcr] Found ${subtitleStreams.length} subtitle stream(s).\n`); + const textTasks = []; + const bitmapTasks = []; + for (const stream of subtitleStreams) { const lang = normLang((stream.tags && stream.tags.language) || ''); if (!targetLangs.includes(lang)) { @@ -132,77 +144,106 @@ const plugin = async (args) => { const codec = (stream.codec_name || '').toLowerCase(); const suffix = buildSuffix(lang, stream); - const outSrt = path.join(sourceDir, `${baseName}.${suffix}.srt`); + const outSrt = path.join(targetDir, `${baseName}.${suffix}.srt`); - jobLog(`[extractSubtitlesAndOcr] Stream #${stream.index} lang=${lang} codec=${codec} -> ${path.basename(outSrt)}\n`); - - // ── Text-based subtitles ────────────────────────────────────────────── if (TEXT_CODECS.has(codec)) { - const { cliExitCode } = await runFFmpeg( - ['-y', '-i', inputFile, '-map', `0:${stream.index}`, '-c:s', 'srt', outSrt], + textTasks.push({ stream, lang, codec, outSrt }); + } else if (BITMAP_CODECS.has(codec)) { + bitmapTasks.push({ stream, lang, codec, outSrt }); + } else { + jobLog(`[extractSubtitlesAndOcr] Stream #${stream.index} codec "${codec}" not supported — skipping.\n`); + } + } + + // ── 1. Batch extract text-based subtitles (mkvextract or single-pass FFmpeg) + if (textTasks.length > 0) { + jobLog(`[extractSubtitlesAndOcr] Extracting ${textTasks.length} text subtitle track(s)...\n`); + textTasks.forEach((t) => { + jobLog(` -> Stream #${t.stream.index} (${t.lang}, ${t.codec}) to ${path.basename(t.outSrt)}\n`); + }); + + let extracted = false; + const isMkv = inputFile.toLowerCase().endsWith('.mkv') || (inputFileObj.container === 'mkv'); + const mkvExtract = getMkvExtract(args); + + // Try mkvextract first (instantaneous on MKV) + if (isMkv) { + try { + const mkvArgs = ['tracks', inputFile]; + textTasks.forEach((t) => mkvArgs.push(`${t.stream.index}:${t.outSrt}`)); + const { cliExitCode } = await runCLI(mkvExtract, mkvArgs, args); + if (cliExitCode === 0) { + extracted = true; + jobLog('[extractSubtitlesAndOcr] mkvextract completed successfully.\n'); + } else { + jobLog(`[extractSubtitlesAndOcr] mkvextract exited with code ${cliExitCode}, falling back to FFmpeg.\n`); + } + } catch (err) { + jobLog(`[extractSubtitlesAndOcr] mkvextract error (${err.message}), falling back to FFmpeg.\n`); + } + } + + // Fallback: single-pass FFmpeg (-vn -an for fast demuxing without video/audio decoding) + if (!extracted) { + const ffmpegArgs = ['-y', '-i', inputFile, '-vn', '-an']; + textTasks.forEach((t, i) => { + ffmpegArgs.push('-map', `0:${t.stream.index}`, `-c:s:${i}`, 'srt', t.outSrt); + }); + const { cliExitCode } = await runCLI(args.ffmpegPath, ffmpegArgs, args); + if (cliExitCode === 0) { + jobLog('[extractSubtitlesAndOcr] FFmpeg text extraction completed successfully.\n'); + } else { + jobLog(`[extractSubtitlesAndOcr] WARNING: FFmpeg extraction failed with exit code ${cliExitCode}.\n`); + } + } + } + + // ── 2. Process bitmap subtitles (PGS / VOBSUB) with SubtitleEdit OCR ───── + for (const t of bitmapTasks) { + const tmpSup = path.join(workDir, `${baseName}_s${t.stream.index}.sup`); + jobLog(`[extractSubtitlesAndOcr] Extracting bitmap stream #${t.stream.index} (${t.codec}) for OCR...\n`); + + // Extract to .sup using mkvextract if mkv, else ffmpeg + let extractedSup = false; + const isMkv = inputFile.toLowerCase().endsWith('.mkv') || (inputFileObj.container === 'mkv'); + if (isMkv) { + const mkvExtract = getMkvExtract(args); + const { cliExitCode } = await runCLI(mkvExtract, ['tracks', inputFile, `${t.stream.index}:${tmpSup}`], args); + if (cliExitCode === 0) extractedSup = true; + } + if (!extractedSup) { + const { cliExitCode } = await runCLI( + args.ffmpegPath, + ['-y', '-i', inputFile, '-vn', '-an', '-map', `0:${t.stream.index}`, '-c:s', 'copy', tmpSup], args, ); if (cliExitCode !== 0) { - jobLog(`[extractSubtitlesAndOcr] WARNING: FFmpeg failed for stream #${stream.index} (exit ${cliExitCode}).\n`); - } else { - jobLog(`[extractSubtitlesAndOcr] Extracted: ${path.basename(outSrt)}\n`); - } - continue; - } - - // ── Bitmap-based subtitles (PGS / VOBSUB) ──────────────────────────── - if (BITMAP_CODECS.has(codec)) { - const tmpSup = path.join(workDir, `${baseName}_s${stream.index}.sup`); - - // Step 1: Extract to .sup - const { cliExitCode: extractExit } = await runFFmpeg( - ['-y', '-i', inputFile, '-map', `0:${stream.index}`, '-c:s', 'copy', tmpSup], - args, - ); - - if (extractExit !== 0) { - jobLog(`[extractSubtitlesAndOcr] WARNING: .sup extraction failed for stream #${stream.index}.\n`); + jobLog(`[extractSubtitlesAndOcr] WARNING: .sup extraction failed for stream #${t.stream.index}.\n`); continue; } - - // Step 2: OCR with SubtitleEdit - try { - const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils'); - const seCli = new CLI({ - cli: subtitleEditPath, - spawnArgs: [ - '/convert', tmpSup, 'subrip', - '/track-number:0', - `/outputfolder:${sourceDir}`, - ], - WorkDir: workDir, - jobLog, - inputFileObj, - updateWorker: args.updateWorker, - args, - }); - const { cliExitCode: seExit } = await seCli.runCli(); - - if (seExit !== 0) { - jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit OCR failed (exit ${seExit}) for stream #${stream.index}.\n`); - } else { - // SubtitleEdit names the output after the .sup filename - const seOut = path.join(sourceDir, path.basename(tmpSup, '.sup') + '.srt'); - if (fs.existsSync(seOut)) { - fs.renameSync(seOut, outSrt); - jobLog(`[extractSubtitlesAndOcr] OCR complete: ${path.basename(outSrt)}\n`); - } - } - } catch (err) { - jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit not available or crashed: ${err.message}\n`); - } finally { - // Clean up temp .sup - try { fs.unlinkSync(tmpSup); } catch (_) { /* ignore */ } - } - continue; } - jobLog(`[extractSubtitlesAndOcr] Stream #${stream.index} codec "${codec}" not supported — skipping.\n`); + // Run OCR with SubtitleEdit CLI + try { + const seCli = await runCLI( + subtitleEditPath, + ['/convert', tmpSup, 'subrip', '/track-number:0', `/outputfolder:${targetDir}`], + args, + ); + if (seCli.cliExitCode !== 0) { + jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit OCR failed (exit ${seCli.cliExitCode}) for stream #${t.stream.index}.\n`); + } else { + const seOut = path.join(targetDir, path.basename(tmpSup, '.sup') + '.srt'); + if (fs.existsSync(seOut)) { + fs.renameSync(seOut, t.outSrt); + jobLog(`[extractSubtitlesAndOcr] OCR complete: ${path.basename(t.outSrt)}\n`); + } + } + } catch (err) { + jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit not available: ${err.message}\n`); + } finally { + try { if (fs.existsSync(tmpSup)) fs.unlinkSync(tmpSup); } catch (_) { /* ignore */ } + } } jobLog('[extractSubtitlesAndOcr] Done.\n'); diff --git a/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js b/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js index 08640db..b4d53e3 100644 --- a/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js +++ b/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js @@ -68,19 +68,17 @@ const plugin = async (args) => { jobLog(`[copyToWorkDir] Source : ${inputFile}\n`); jobLog(`[copyToWorkDir] WorkDir: ${workDir}\n`); - // ── Optional: skip if source is already local ───────────────────────────── + // ── Skip only if file is already located inside workDir ────────────────── const skipIfLocal = (args.inputs.skipIfLocal || 'true') === 'true'; - if (skipIfLocal) { - const srcDrive = inputFile.split(/[/\\]/)[0].toUpperCase(); // e.g. "T:" - const workDrive = workDir.split(/[/\\]/)[0].toUpperCase(); // e.g. "T:" - const alreadyLocal = ( - inputFile.startsWith(workDir) - || (srcDrive && workDrive && srcDrive === workDrive) - ); - if (alreadyLocal) { - jobLog(`[copyToWorkDir] Source is already local (same drive: ${srcDrive}) — skipping copy.\n`); - return { outputNumber: 1, outputFileObj: inputFileObj, variables }; - } + if (skipIfLocal && inputFile.startsWith(workDir)) { + jobLog('[copyToWorkDir] Source file is already inside workDir — skipping copy.\n'); + return { outputNumber: 1, outputFileObj: inputFileObj, variables }; + } + + // ── Preserve original library paths for downstream plugins ────────────── + if (!variables.originalLibraryFile) { + variables.originalLibraryFile = inputFile; + variables.originalLibraryDir = path.dirname(inputFile); } // ── Copy using streams (no RAM spike for large files) ────────────────────