Compare commits

...
37 Commits
Author SHA1 Message Date
PickleRick 2ea83ae566 feat(analyzer): enhance anime detection with accent normalization and flexible matching
Build / Lint & Test (push) Successful in 19s
2026-10-06 09:34:19 +02:00
PickleRick c46e3dfdb3 fix(flow): embed copyWithProgress to make plugins self-contained and immune to Tdarr updates
Build / Lint & Test (push) Successful in 6m14s
2026-10-05 17:03:46 +02:00
PickleRick 5fdc5a6781 feat(copyWithProgress): report copy progress every second instead of every 1%
Build / Lint & Test (push) Successful in 21s
2026-10-01 00:00:08 +02:00
PickleRick 70f065a3a2 feat(transcode): scale 2-pass progress to 50% per pass for total 100% 2026-09-30 23:58:28 +02:00
PickleRick 2daa75df60 fix(transcode): restore official cliUtils and fix transcode progress and log flooding 2026-09-30 23:56:53 +02:00
PickleRick 88ecf9a847 feat(flow): support folder-to-folder conversion with sidecar migration and prune
Build / Lint & Test (push) Successful in 28s
2026-09-30 18:43:12 +02:00
PickleRick f2e483ca1f fix: optimize workDir copy cleanup and handle extension mismatch in replaceOriginalFile
Build / Lint & Test (push) Successful in 14s
2026-09-30 18:35:35 +02:00
PickleRick 07b730c81f fix(FlowHelpers): restore official fileUtils.js and only install custom copyWithProgress
Build / Lint & Test (push) Successful in 1m0s
2026-09-30 18:31:22 +02:00
PickleRick 3d809c6862 docs: update README to remove 2.0.0 references
Build / Lint & Test (push) Successful in 15s
2026-09-30 18:04:00 +02:00
PickleRick a05b84f046 fix(install.sh): remove references to deleted 2.0.0 versions and install all FlowHelpers
Build / Lint & Test (push) Successful in 15s
2026-09-30 18:03:23 +02:00
PickleRick 30fb556af7 fix(finalizeMedia): scope subtitles to episode baseName and handle empty season/series folder cleanup
Build / Lint & Test (push) Successful in 18s
2026-09-30 17:58:19 +02:00
PickleRick 86448988ed Fix copy progress, remove v2.0.0, fix subtitle default naming, enforce strict no-upcoding on audio
Build / Lint & Test (push) Successful in 3m15s
2026-09-30 17:26:19 +02:00
PickleRick 7ee5a5bc48 Save uncommitted changes
Build / Lint & Test (push) Successful in 28s
2026-09-21 23:38:34 +02:00
PickleRick b85759e27c Save uncommitted changes
Build / Lint & Test (push) Successful in 26s
2026-09-21 22:57:38 +02:00
PickleRick 68166ad7eb Save uncommitted changes
Build / Lint & Test (push) Successful in 36s
2026-09-21 22:49:46 +02:00
PickleRick 594889ea10 Save uncommitted changes
Build / Lint & Test (push) Successful in 19s
2026-09-21 22:48:11 +02:00
PickleRick febd68973b Save uncommitted changes
Build / Lint & Test (push) Successful in 43s
2026-09-21 22:33:52 +02:00
PickleRick 2cf22ef628 Save uncommitted changes
Build / Lint & Test (push) Successful in 15s
2026-09-21 12:46:44 +02:00
PickleRick 880ec5bc6d Save uncommitted changes
Build / Lint & Test (push) Successful in 14s
2026-09-21 12:38:13 +02:00
PickleRick 5d43ba6a26 Save uncommitted changes
Build / Lint & Test (push) Successful in 18s
2026-09-21 12:34:15 +02:00
PickleRick a8c4a81141 Save uncommitted changes
Build / Lint & Test (push) Successful in 22s
2026-09-21 10:42:08 +02:00
PickleRick 3f0b38d513 fix: update GOP parameters for mediaAnalyzerGOP and transcode2PassX265 to enhance encoding efficiency and documentation
Build / Lint & Test (push) Successful in 16s
2026-09-20 19:37:31 +02:00
PickleRick 36184fbf32 fix: enhance copyToWorkDir to log progress and improve video bitrate estimation in transcoding
Build / Lint & Test (push) Successful in 16s
2026-09-19 23:10:20 +02:00
PickleRick 0c8b713325 fix: separate -map_metadata and -map_chapters options from their values in x265 transcoding
Build / Lint & Test (push) Successful in 14s
2026-09-19 22:47:24 +02:00
PickleRick ee5fc99820 fix: enhance 2-pass x265 encoding to copy compatible HEVC video below specified bitrate limit
Build / Lint & Test (push) Successful in 15s
2026-09-19 22:29:00 +02:00
PickleRick 2a39da6d08 fix: disable metadata and chapter mapping in x265 transcoding to streamline output
Build / Lint & Test (push) Successful in 18s
2026-09-19 22:12:33 +02:00
PickleRick 9b0ed4677d fix: update extractChaptersXml to save XML file next to original source and reflect changes in documentation
Build / Lint & Test (push) Successful in 17s
2026-09-19 20:01:20 +02:00
PickleRick 95df925de7 fix: update x265 parameters for improved encoding efficiency in 2-pass process
Build / Lint & Test (push) Successful in 17s
2026-09-19 17:28:16 +02:00
PickleRick 1cdd771045 fix: optimize subtitle extraction with mkvextract batch mode, fix copyToWorkDir skip logic, target original library dir
Build / Lint & Test (push) Successful in 13s
2026-09-19 15:17:38 +02:00
PickleRick 27fee1ee95 fix: pass full args object to CLI constructors in all plugins to prevent cliUtils TypeError on variables/liveSizeCompare
Build / Lint & Test (push) Successful in 15s
2026-09-19 14:54:38 +02:00
PickleRick 5447c1cbc7 chore: add copyToWorkDir to install.sh plugin map
Build / Lint & Test (push) Successful in 11s
2026-09-19 14:31:12 +02:00
PickleRick c604acfe69 fix: pass updateWorker to all CLI constructors; add copyToWorkDir plugin
Build / Lint & Test (push) Successful in 13s
2026-09-19 14:28:54 +02:00
PickleRick 3b7f660058 feat: add install.sh for targeted plugin updates via raw file URLs
Build / Lint & Test (push) Successful in 11s
2026-09-19 14:15:35 +02:00
PickleRick 303c3b9fd3 ci: add build workflow for every commit (syntax check + logic tests)
Build / Lint & Test (push) Successful in 2m12s
2026-09-19 14:12:16 +02:00
PickleRick d70a931591 ci: trigger release only on v* tag push 2026-09-19 14:11:41 +02:00
PickleRick 7f90b366eb fix: rewrite release workflow to use Gitea REST API directly (drop softprops/action-gh-release)
Release Plugins / Package & Release (push) Successful in 10s
2026-09-19 14:07:35 +02:00
PickleRick 778d6e51ed fix: use Gitea git archive URLs in README (no release dependency)
Release Plugins / Package & Release (push) Failing after 12s
2026-09-19 14:03:56 +02:00
23 changed files with 3023 additions and 247 deletions

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
name: Build
on:
push:
branches:
- '**' # every branch
tags-ignore:
- 'v*' # skip tag pushes (handled by release.yml)
pull_request:
jobs:
build:
name: Lint & Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
# Syntax-check every plugin index.js with Node's parser (no install needed)
- name: Syntax check all plugins
run: |
echo "=== Checking plugin syntax ==="
ERRORS=0
for f in LocalFlowPlugins/*/*/1.0.0/index.js FlowHelpers/1.0.0/*.js; do
node --check "$f" \
&& echo " ✓ $f" \
|| { echo " ✗ SYNTAX ERROR: $f"; ERRORS=$((ERRORS+1)); }
done
[[ $ERRORS -eq 0 ]] || { echo ""; echo "❌ $ERRORS file(s) failed syntax check."; exit 1; }
echo ""
echo "✅ All files passed syntax check."
# Run the logic test suite (no FFmpeg needed)
- name: Run logic tests
run: node tests/quick-logic-test.js
+97 -48
View File
@@ -2,16 +2,8 @@ name: Release Plugins
on: on:
push: push:
branches:
- main
tags: tags:
- 'v*' - 'v*'
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g. v1.0.1). Leave blank to use the tag or "latest".'
required: false
default: ''
jobs: jobs:
release: release:
@@ -22,67 +14,124 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# ── Determine the release tag ─────────────────────────────────────────
- name: Determine release tag - name: Determine release tag
id: tag id: tag
run: | run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
elif [[ -n "${{ github.event.inputs.version }}" ]]; then
echo "tag=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
else
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi
# ── Build ZIP archives ────────────────────────────────────────────────
- name: Create plugin ZIPs - name: Create plugin ZIPs
run: | run: |
TAG="${{ steps.tag.outputs.tag }}"
mkdir -p dist mkdir -p dist
# Full suite ZIP (all LocalFlowPlugins + FlowHelpers) # Full suite ZIP (LocalFlowPlugins + FlowHelpers)
zip -r dist/Tdarr-plugins-${{ steps.tag.outputs.tag }}.zip \ zip -r "dist/Tdarr-plugins-${TAG}.zip" \
LocalFlowPlugins/ \ LocalFlowPlugins/ \
FlowHelpers/ \ FlowHelpers/ \
-x "*.git*" -x "*.git*"
# Individual plugin ZIPs # Individual plugin ZIPs (one per plugin folder)
for dir in LocalFlowPlugins/*/*/1.0.0; do for dir in LocalFlowPlugins/*/*/1.0.0; do
plugin_name=$(echo "$dir" | awk -F'/' '{print $(NF-1)}') plugin_name=$(echo "$dir" | awk -F'/' '{print $(NF-1)}')
zip -j "dist/${plugin_name}-${{ steps.tag.outputs.tag }}.zip" "$dir/index.js" zip -j "dist/${plugin_name}-${TAG}.zip" "$dir/index.js"
done done
echo "=== Built assets ==="
ls -lh dist/ ls -lh dist/
- name: Delete existing "latest" release (if re-releasing) # ── Gitea release via REST API ────────────────────────────────────────
if: steps.tag.outputs.tag == 'latest' # github.server_url can be empty on some self-hosted runners, so we
# derive it from GITHUB_SERVER_URL env var with a hardcoded fallback.
- name: Publish Gitea release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
gh release delete latest --yes --cleanup-tag 2>/dev/null || true TAG="${{ steps.tag.outputs.tag }}"
- name: Create GitHub Release # Resolve base URL: env var → context → hardcoded fallback
uses: softprops/action-gh-release@v2 BASE_URL="${GITHUB_SERVER_URL:-${{ github.server_url }}}"
with: BASE_URL="${BASE_URL:-https://git.cdtech.freeddns.org}"
tag_name: ${{ steps.tag.outputs.tag }} REPO="${GITHUB_REPOSITORY:-${{ github.repository }}}"
name: "Tdarr Plugins ${{ steps.tag.outputs.tag }}"
body: |
## Tdarr Local Flow Plugin Suite — ${{ steps.tag.outputs.tag }}
### Installation (curl) API="${BASE_URL}/api/v1/repos/${REPO}"
AUTH="Authorization: token ${GITEA_TOKEN}"
Download the full suite: IS_PRERELEASE="false"
```bash [[ "${TAG}" == "latest" ]] && IS_PRERELEASE="true"
curl -L https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}/Tdarr-plugins-${{ steps.tag.outputs.tag }}.zip -o plugins.zip
unzip plugins.zip -d /path/to/tdarr/plugins/
```
Or download an individual plugin (example: `transcode2PassX265`): RELEASE_BODY="## Tdarr Plugins ${TAG}\n\nInstall the full suite:\n\`\`\`bash\ncurl -fsSL ${BASE_URL}/${REPO}/archive/main.zip -o /tmp/tp.zip\nunzip -q -o /tmp/tp.zip -d /tmp/tpx\ncp -r /tmp/tpx/Tdarr-plugin/LocalFlowPlugins \${PLUGIN_DIR}/\ncp -r /tmp/tpx/Tdarr-plugin/FlowHelpers \${PLUGIN_DIR}/\nrm -rf /tmp/tp.zip /tmp/tpx\n\`\`\`\n\nSee [README](${BASE_URL}/${REPO}) for full docs."
```bash
curl -L https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}/transcode2PassX265-${{ steps.tag.outputs.tag }}.zip -o transcode2PassX265.zip
unzip transcode2PassX265.zip -d /path/to/tdarr/plugins/LocalFlowPlugins/video/transcode2PassX265/1.0.0/
```
See the [README](https://github.com/${{ github.repository }}#readme) for full installation instructions. echo "=== API endpoint: ${API} ==="
files: dist/*.zip echo "=== Repo: ${REPO} ==="
prerelease: ${{ steps.tag.outputs.tag == 'latest' }}
make_latest: ${{ steps.tag.outputs.tag != 'latest' }} # ── 1. Check for existing release with this tag ──────────────────
env: echo "Looking up existing release for tag '${TAG}'..."
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} EXISTING=$(curl -sf -H "$AUTH" "${API}/releases/tags/${TAG}" 2>/dev/null || echo "")
RELEASE_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
if [[ -n "$RELEASE_ID" ]]; then
echo "Found release id=${RELEASE_ID} — removing old assets..."
# Delete all existing assets
ASSETS=$(curl -sf -H "$AUTH" "${API}/releases/${RELEASE_ID}/assets" || echo "[]")
echo "$ASSETS" | python3 -c "
import sys, json
assets = json.load(sys.stdin)
for a in assets:
print(a['id'])
" 2>/dev/null | while read -r asset_id; do
echo " Deleting asset id=${asset_id}"
curl -sf -X DELETE -H "$AUTH" "${API}/releases/${RELEASE_ID}/assets/${asset_id}" \
&& echo " ✓ deleted" || echo " ✗ delete failed (ignored)"
done
# Patch release name/body
curl -sf -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
"${API}/releases/${RELEASE_ID}" \
-d "{\"name\":\"Tdarr Plugins ${TAG}\",\"body\":\"${RELEASE_BODY}\",\"prerelease\":${IS_PRERELEASE}}" \
> /dev/null && echo "Release updated."
else
echo "No existing release — creating one..."
# Ensure the lightweight tag exists on the repo
git tag "${TAG}" 2>/dev/null || echo "Tag already exists."
git push origin "${TAG}" 2>/dev/null || echo "Tag push skipped (may already exist)."
RESPONSE=$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
"${API}/releases" \
-d "{
\"tag_name\": \"${TAG}\",
\"name\": \"Tdarr Plugins ${TAG}\",
\"body\": \"${RELEASE_BODY}\",
\"prerelease\": ${IS_PRERELEASE},
\"draft\": false
}")
RELEASE_ID=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
echo "Created release id=${RELEASE_ID}"
fi
if [[ -z "$RELEASE_ID" ]]; then
echo "ERROR: Could not determine release ID. Aborting asset upload."
exit 1
fi
# ── 2. Upload all assets ─────────────────────────────────────────
echo ""
echo "Uploading assets to release id=${RELEASE_ID}..."
for asset in dist/*.zip; do
filename=$(basename "$asset")
echo " Uploading ${filename}..."
curl -sf -X POST \
-H "$AUTH" \
-F "attachment=@${asset};type=application/zip" \
"${API}/releases/${RELEASE_ID}/assets?name=${filename}" \
> /dev/null \
&& echo " ✓ ${filename}" \
|| { echo " ✗ Failed: ${filename}"; exit 1; }
done
echo ""
echo "✅ Release complete: ${BASE_URL}/${REPO}/releases/tag/${TAG}"
+68 -16
View File
@@ -1,41 +1,92 @@
'use strict'; 'use strict';
/** /**
* Local mock of Tdarr FlowHelpers/1.0.0/cliUtils.js * Local fallback/mock of Tdarr FlowHelpers/1.0.0/cliUtils.js
* Used only for local testing — on real Tdarr this file comes from the server. * Used for local testing and as safe fallback.
* *
* Matches the real Tdarr CLI constructor signature: * Matches the real Tdarr CLI constructor signature:
* new CLI({ cli, spawnArgs, WorkDir, jobLog, inputFileObj }) * new CLI({ cli, spawnArgs, spawnOpts, WorkDir, jobLog, inputFileObj, updateWorker, logFullCliOutput })
* inputFileObj is accepted but unused here (only used by real Tdarr for progress tracking).
*/ */
const { spawn } = require('child_process'); const { spawn } = require('child_process');
class CLI { class CLI {
constructor(config) { constructor(config) {
this.config = config; this.config = config || {};
} }
runCli() { runCli() {
return new Promise((resolve) => { return new Promise((resolve) => {
const { cli, spawnArgs, WorkDir, jobLog } = this.config; const {
cli,
spawnArgs = [],
spawnOpts = {},
WorkDir,
jobLog,
updateWorker,
logFullCliOutput = false,
} = this.config;
if (jobLog) jobLog(`[CLI] Running: ${cli} ${spawnArgs.join(' ')}\n`); const opts = {
cwd: spawnOpts.cwd || WorkDir || process.cwd(),
const proc = spawn(cli, spawnArgs, {
cwd: WorkDir || process.cwd(),
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
shell: false, shell: false,
}); ...spawnOpts,
};
proc.stdout.on('data', (d) => { if (jobLog) jobLog(d.toString()); }); if (jobLog) jobLog(`Running ${cli} ${spawnArgs.join(' ')}\n`);
proc.stderr.on('data', (d) => { if (jobLog) jobLog(d.toString()); });
const proc = spawn(cli, spawnArgs, opts);
const errorLogFull = [];
const handleData = (data) => {
const str = data.toString();
errorLogFull.push(str);
if (logFullCliOutput && jobLog) {
jobLog(str);
}
if (typeof updateWorker === 'function') {
// ffmpeg progress matching: time=HH:MM:SS
const fpsMatch = str.match(/fps=\s*(\d+(?:\.\d+)?)/);
if (fpsMatch) {
updateWorker({ fps: parseFloat(fpsMatch[1]) });
}
const timeMatch = str.match(/time=(\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)/);
if (timeMatch) {
const curSec = parseInt(timeMatch[1], 10) * 3600
+ parseInt(timeMatch[2], 10) * 60
+ parseFloat(timeMatch[3]);
const duration = this.config.inputFileObj?.meta?.Duration
|| parseFloat(this.config.inputFileObj?.ffProbeData?.format?.duration)
|| 0;
if (duration > 0) {
const perc = Math.min(100, Math.max(0, +((curSec / duration) * 100).toFixed(1)));
updateWorker({ percentage: perc });
}
} else if (str.includes('%')) {
// mkvextract progress matching (e.g. "Progress: 45%" or "Progresso: 45%")
const pMatch = str.match(/(?:Progress|Progresso):\s*(\d+)%/i);
if (pMatch) {
updateWorker({ percentage: parseInt(pMatch[1], 10) });
}
}
}
};
proc.stdout.on('data', handleData);
proc.stderr.on('data', handleData);
proc.on('close', (code) => { proc.on('close', (code) => {
resolve({ cliExitCode: code || 0 }); if (!logFullCliOutput && jobLog) {
jobLog(errorLogFull.slice(-1000).join(''));
}
resolve({ cliExitCode: code || 0, errorLogFull });
}); });
proc.on('error', (err) => { proc.on('error', (err) => {
if (jobLog) jobLog(`[CLI] Error: ${err.message}\n`); if (jobLog) jobLog(`Error executing binary: ${cli}: ${err.message}\n`);
resolve({ cliExitCode: 1 }); resolve({ cliExitCode: 1, errorLogFull });
}); });
}); });
} }
@@ -45,3 +96,4 @@ const getFFmpegVar = () => {};
module.exports = { CLI, getFFmpegVar }; module.exports = { CLI, getFFmpegVar };
+195
View File
@@ -0,0 +1,195 @@
'use strict';
const fs = require('fs');
const path = require('path');
const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 1000;
const PROGRESS_INTERVAL_MS = 1000;
const sleep = (milliseconds) => new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
const formatEta = (seconds) => {
if (!Number.isFinite(seconds) || seconds < 0) return '00:00:00';
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};
/**
* Copies a file from source to destination with real-time stream-based
* progress reporting, speed / ETA tracking, Tdarr worker updates, size verification,
* and automatic retry with cleanup.
*/
const copyWithProgress = async ({
source,
destination,
jobLog,
updateWorker,
updateProgress,
label = 'copy',
}) => {
if (!fs.existsSync(source)) {
throw new Error(`[${label}] Source file not found: ${source}`);
}
const sourceStat = fs.statSync(source);
const sourceSize = sourceStat.size;
const sourceSizeMb = sourceSize / 1024 / 1024;
const destDir = path.dirname(destination);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const report = (percent, message, eta) => {
if (typeof updateWorker === 'function') {
updateWorker({
percentage: Math.min(percent, 100),
ETA: eta || '00:00:00',
});
}
if (typeof updateProgress === 'function') {
updateProgress({
progress: Math.min(percent, 100),
jobLog: message,
});
}
if (message && typeof jobLog === 'function') {
jobLog(`${message}\n`);
}
};
let lastError;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
if (attempt > 0) {
report(0, `[${label}] Retrying copy (${attempt}/${MAX_RETRIES})...`);
await sleep(RETRY_DELAY_MS);
}
try {
if (fs.existsSync(destination)) {
fs.unlinkSync(destination);
}
// Special case: empty file (0 bytes)
if (sourceSize === 0) {
fs.writeFileSync(destination, Buffer.alloc(0));
report(100, `[${label}] Copied 0-byte file successfully.`, '00:00:00');
return;
}
const startedAt = Date.now();
let copiedBytes = 0;
let lastProgressTime = 0;
let intervalBytes = 0;
let intervalTime = startedAt;
await new Promise((resolve, reject) => {
const rd = fs.createReadStream(source, { highWaterMark: 1024 * 1024 });
const wr = fs.createWriteStream(destination, { highWaterMark: 1024 * 1024 });
rd.on('error', (err) => {
wr.destroy();
reject(err);
});
wr.on('error', (err) => {
rd.destroy();
reject(err);
});
wr.on('finish', resolve);
rd.on('data', (chunk) => {
copiedBytes += chunk.length;
intervalBytes += chunk.length;
const now = Date.now();
const percent = Math.min(Math.floor((copiedBytes / sourceSize) * 100), 99);
// Update UI and jobLog every second (PROGRESS_INTERVAL_MS) or on initial data chunk
const timeSinceLastProgress = now - lastProgressTime;
if (lastProgressTime === 0 || timeSinceLastProgress >= PROGRESS_INTERVAL_MS) {
const totalElapsedSec = Math.max((now - startedAt) / 1000, 0.001);
const avgSpeedBps = copiedBytes / totalElapsedSec;
const remainingBytes = Math.max(0, sourceSize - copiedBytes);
const remainingSec = avgSpeedBps > 0 ? remainingBytes / avgSpeedBps : 0;
const eta = formatEta(remainingSec);
if (typeof updateWorker === 'function') {
updateWorker({
percentage: percent,
ETA: eta,
});
}
if (typeof updateProgress === 'function') {
updateProgress({ progress: percent });
}
const intSec = Math.max((now - intervalTime) / 1000, 0.001);
const currentSpeedMb = (intervalBytes / 1024 / 1024 / intSec).toFixed(1);
const copiedMb = (copiedBytes / 1024 / 1024).toFixed(1);
if (typeof jobLog === 'function') {
jobLog(
`[${label}] Progress: ${percent}% `
+ `(${copiedMb}/${sourceSizeMb.toFixed(1)} MB, `
+ `${currentSpeedMb} MB/s, ETA: ${eta})\n`,
);
}
lastProgressTime = now;
intervalBytes = 0;
intervalTime = now;
}
});
rd.pipe(wr);
});
// Verify destination size
if (!fs.existsSync(destination)) {
throw new Error(`Destination file does not exist after copy: ${destination}`);
}
const destinationSize = fs.statSync(destination).size;
if (destinationSize !== sourceSize) {
throw new Error(
`Copy size mismatch: expected ${sourceSize} bytes, got ${destinationSize}`,
);
}
const totalElapsed = Math.max((Date.now() - startedAt) / 1000, 0.001);
const avgSpeed = (sourceSizeMb / totalElapsed).toFixed(1);
report(
100,
`[${label}] Done: ${sourceSizeMb.toFixed(1)} MB copied in `
+ `${totalElapsed.toFixed(1)}s (${avgSpeed} MB/s average)`,
'00:00:00',
);
return;
} catch (error) {
lastError = error;
try {
if (fs.existsSync(destination)) fs.unlinkSync(destination);
} catch (cleanupError) {
lastError = new Error(
`${error.message}; could not remove partial copy: ${cleanupError.message}`,
);
}
if (attempt < MAX_RETRIES && typeof jobLog === 'function') {
jobLog(`[${label}] Copy attempt ${attempt + 1} failed: ${lastError.message}\n`);
}
}
}
throw new Error(
`[${label}] Copy failed after ${MAX_RETRIES + 1} attempts: ${lastError.message}`,
);
};
module.exports = { copyWithProgress };
+269 -12
View File
@@ -1,12 +1,269 @@
'use strict'; "use strict";
/** var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
* Local mock of Tdarr FlowHelpers/1.0.0/fileUtils.js if (k2 === undefined) k2 = k;
*/ var desc = Object.getOwnPropertyDescriptor(m, k);
const fs = require('fs'); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
const path = require('path'); desc = { enumerable: true, get: function() { return m[k]; } };
}
const moveFile = (src, dest) => fs.renameSync(src, dest); Object.defineProperty(o, k2, desc);
const copyFile = (src, dest) => fs.copyFileSync(src, dest); }) : (function(o, m, k, k2) {
const getFileSize = (fp) => { try { return fs.statSync(fp).size; } catch(_) { return 0; } }; if (k2 === undefined) k2 = k;
o[k2] = m[k];
module.exports = { moveFile, copyFile, getFileSize }; }));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.hashFile = exports.getScanTypes = exports.getPluginWorkDir = exports.moveFileAndValidate = exports.getFileSize = exports.getSubStem = exports.getFfType = exports.getFileAbosluteDir = exports.getFileAbsoluteDir = exports.getFileName = exports.getContainer = exports.fileExists = void 0;
var fs_1 = __importStar(require("fs"));
var crypto_1 = __importDefault(require("crypto"));
var fileExists = function (path) { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, fs_1.promises.stat(path).catch(function () { return false; })];
case 1: return [2 /*return*/, !!(_a.sent())];
}
}); }); };
exports.fileExists = fileExists;
var getContainer = function (filePath) {
var parts = filePath.split('.');
return parts[parts.length - 1];
};
exports.getContainer = getContainer;
var getFileName = function (filePath) {
var parts = filePath.split('/');
var fileNameAndContainer = parts[parts.length - 1];
var parts2 = fileNameAndContainer.split('.');
parts2.pop();
return parts2.join('.');
};
exports.getFileName = getFileName;
var getFileAbsoluteDir = function (filePath) {
var parts = filePath.split('/');
parts.pop();
return parts.join('/');
};
exports.getFileAbsoluteDir = getFileAbsoluteDir;
// backwards compatibility for typo
exports.getFileAbosluteDir = exports.getFileAbsoluteDir;
var getFfType = function (codecType) { return (codecType === 'video' ? 'v' : 'a'); };
exports.getFfType = getFfType;
var getSubStem = function (_a) {
var inputPathStem = _a.inputPathStem, inputPath = _a.inputPath;
var subStem = inputPath.substring(inputPathStem.length);
var parts = subStem.split('/');
parts.pop();
return parts.join('/');
};
exports.getSubStem = getSubStem;
var getFileSize = function (file) { return __awaiter(void 0, void 0, void 0, function () {
var stats, size;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, fs_1.promises.stat(file)];
case 1:
stats = _a.sent();
size = stats.size;
return [2 /*return*/, size];
}
});
}); };
exports.getFileSize = getFileSize;
var moveFileAndValidate = function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) {
var inputSize, res1, outputSize, err_1, res2, errMessage;
var inputPath = _b.inputPath, outputPath = _b.outputPath, args = _b.args;
return __generator(this, function (_c) {
switch (_c.label) {
case 0: return [4 /*yield*/, (0, exports.getFileSize)(inputPath)];
case 1:
inputSize = _c.sent();
args.jobLog("Attempt 1: Moving file from ".concat(inputPath, " to ").concat(outputPath));
return [4 /*yield*/, new Promise(function (resolve) {
args.deps.gracefulfs.rename(inputPath, outputPath, function (err) {
if (err) {
args.jobLog("Failed to move file from ".concat(inputPath, " to ").concat(outputPath));
args.jobLog(JSON.stringify(err));
resolve(false);
}
else {
resolve(true);
}
});
})];
case 2:
res1 = _c.sent();
outputSize = 0;
_c.label = 3;
case 3:
_c.trys.push([3, 5, , 6]);
return [4 /*yield*/, (0, exports.getFileSize)(outputPath)];
case 4:
outputSize = _c.sent();
return [3 /*break*/, 6];
case 5:
err_1 = _c.sent();
args.jobLog(JSON.stringify(err_1));
return [3 /*break*/, 6];
case 6:
if (!(!res1 || inputSize !== outputSize)) return [3 /*break*/, 9];
if (inputSize !== outputSize) {
args.jobLog("File sizes do not match, input: ".concat(inputSize, " ")
+ "does not equal output: ".concat(outputSize));
}
args.jobLog("Attempt 1 failed: Moving file from ".concat(inputPath, " to ").concat(outputPath));
args.jobLog("Attempt 2: Moving file from ".concat(inputPath, " to ").concat(outputPath));
return [4 /*yield*/, new Promise(function (resolve) {
args.deps.mvdir(inputPath, outputPath, { overwrite: true })
.then(function () {
resolve(true);
}).catch(function (err) {
args.jobLog("Failed to move file from ".concat(inputPath, " to ").concat(outputPath));
args.jobLog(JSON.stringify(err));
resolve(false);
});
})];
case 7:
res2 = _c.sent();
return [4 /*yield*/, (0, exports.getFileSize)(outputPath)];
case 8:
outputSize = _c.sent();
if (!res2 || inputSize !== outputSize) {
if (inputSize !== outputSize) {
args.jobLog("File sizes do not match, input: ".concat(inputSize, " ")
+ "does not equal output: ".concat(outputSize));
}
errMessage = "Failed to move file from ".concat(inputPath, " to ").concat(outputPath, ", check errors above");
args.jobLog(errMessage);
throw new Error(errMessage);
}
_c.label = 9;
case 9: return [2 /*return*/];
}
});
}); };
exports.moveFileAndValidate = moveFileAndValidate;
var getPluginWorkDir = function (args) {
var pluginWorkDir = "".concat(args.workDir, "/").concat(new Date().getTime());
args.deps.fsextra.ensureDirSync(pluginWorkDir);
return pluginWorkDir;
};
exports.getPluginWorkDir = getPluginWorkDir;
var getScanTypes = function (pluginsTextRaw) {
var scanTypes = {
exifToolScan: true,
mediaInfoScan: false,
closedCaptionScan: false,
};
var scannerTypes = [
// needed for frame and duration data for ffmpeg
// {
// type: 'exifToolScan',
// terms: [
// 'meta',
// ],
// },
{
type: 'mediaInfoScan',
terms: [
'mediaInfo',
],
},
{
type: 'closedCaptionScan',
terms: [
'hasClosedCaptions',
],
},
];
var text = pluginsTextRaw.join('');
scannerTypes.forEach(function (scanner) {
scanner.terms.forEach(function (term) {
if (text.includes(term)) {
scanTypes[scanner.type] = true;
}
});
});
return scanTypes;
};
exports.getScanTypes = getScanTypes;
var hashFile = function (filePath, algorithm) {
if (algorithm === void 0) { algorithm = 'sha256'; }
return new Promise(function (resolve, reject) {
try {
var hash_1 = crypto_1.default.createHash(algorithm);
var stream = fs_1.default.createReadStream(filePath);
stream.on('data', function (data) {
hash_1.update(data);
});
stream.on('end', function () {
var hashStr = hash_1.digest('hex');
resolve(hashStr);
});
stream.on('error', function (error) {
reject(new Error("Error reading file for hashing: ".concat(error.message)));
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
catch (error) {
reject(new Error("Error setting up file hash: ".concat(error.message)));
}
});
};
exports.hashFile = hashFile;
@@ -1,4 +1,4 @@
'use strict'; 'use strict';
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
@@ -41,7 +41,20 @@ const getCodecWeight = (stream) => {
const getSourceBitrateKbps = (stream) => { const getSourceBitrateKbps = (stream) => {
const br = parseInt(stream.bit_rate || '0', 10); const br = parseInt(stream.bit_rate || '0', 10);
return br > 0 ? Math.round(br / 1000) : 0; if (br > 0) return Math.round(br / 1000);
// Check tags for BPS (common in Matroska containers)
const tags = stream.tags || {};
const bpsEntry = Object.entries(tags).find(([key]) => {
const k = String(key).toLowerCase();
return k === 'bps' || k === 'bps-eng' || k === 'bps-ita' || k.startsWith('bps');
});
if (bpsEntry) {
const bps = parseInt(bpsEntry[1], 10);
if (bps > 0) return Math.round(bps / 1000);
}
return 0;
}; };
const calcOpusBitrate = (stream, isMovie, inputs) => { const calcOpusBitrate = (stream, isMovie, inputs) => {
@@ -52,18 +65,18 @@ const calcOpusBitrate = (stream, isMovie, inputs) => {
const serThreshold = parseInt(inputs.seriesMultiChannelThreshold || '384', 10); const serThreshold = parseInt(inputs.seriesMultiChannelThreshold || '384', 10);
if (ch <= 2) { if (ch <= 2) {
// Stereo: min(max, source) — no upcoding // Stereo: min(max, source) — strict no upcoding
return srcKbps > 0 ? Math.min(stereoMax, srcKbps) : stereoMax; return srcKbps > 0 ? Math.min(stereoMax, srcKbps) : stereoMax;
} }
// Multichannel // Multichannel target cap
if (isMovie) { const maxTarget = isMovie ? filmTarget : serThreshold;
return srcKbps > 0 ? Math.min(filmTarget, srcKbps) : filmTarget;
// Strict no upcoding: if source bitrate is known, never exceed source bitrate
if (srcKbps > 0) {
return Math.min(maxTarget, srcKbps);
} }
// Series return maxTarget;
// If bitrate is unknown (0), assume high-quality source -> use 384k
const effectiveKbps = srcKbps > 0 ? srcKbps : serThreshold;
return effectiveKbps >= serThreshold ? 384 : 256;
}; };
const details = () => ({ const details = () => ({
@@ -118,7 +131,8 @@ const plugin = async (args) => {
jobLog(`[audioSelectorClamping] Processing: ${filePath}\n`); jobLog(`[audioSelectorClamping] Processing: ${filePath}\n`);
const isMovie = variables.isMovie === 'true'; const isMovie = variables.isMovie === 'true';
jobLog(`[audioSelectorClamping] isMovie=${isMovie}\n`); const isAnimation = variables.isAnimation === 'true';
jobLog(`[audioSelectorClamping] isMovie=${isMovie} isAnimation=${isAnimation}\n`);
const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || []; const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || [];
const audioStreams = streams.filter((s) => s.codec_type === 'audio'); const audioStreams = streams.filter((s) => s.codec_type === 'audio');
@@ -34,7 +34,7 @@ const details = () => ({
}); });
const plugin = async (args) => { const plugin = async (args) => {
const { jobLog, inputFileObj, variables, workDir } = args; const { jobLog, inputFileObj, variables, workDir, updateWorker } = args;
const inputFile = inputFileObj._id; const inputFile = inputFileObj._id;
jobLog(`[backupSourceAudio] Processing: ${inputFile}\n`); jobLog(`[backupSourceAudio] Processing: ${inputFile}\n`);
@@ -58,9 +58,9 @@ const plugin = async (args) => {
let ext = '.dts'; let ext = '.dts';
if (itaCodec.includes('truehd')) ext = '.thd'; if (itaCodec.includes('truehd')) ext = '.thd';
const sourceDir = path.dirname(inputFile); const targetDir = variables.originalLibraryDir || path.dirname(inputFile);
const baseName = path.basename(inputFile, path.extname(inputFile)); const baseName = path.basename(variables.originalLibraryFile || inputFile, path.extname(variables.originalLibraryFile || inputFile));
const outFile = path.join(sourceDir, `${baseName}.ita${ext}`); const outFile = path.join(targetDir, `${baseName}.ita${ext}`);
jobLog(`[backupSourceAudio] Backing up ITA ${itaCodec} -> ${outFile}\n`); jobLog(`[backupSourceAudio] Backing up ITA ${itaCodec} -> ${outFile}\n`);
@@ -74,9 +74,14 @@ const plugin = async (args) => {
'-c:a', 'copy', '-c:a', 'copy',
outFile, outFile,
], ],
spawnOpts: { cwd: workDir },
WorkDir: workDir, WorkDir: workDir,
jobLog, jobLog,
outputFilePath: outFile,
inputFileObj, inputFileObj,
updateWorker,
logFullCliOutput: args.logFullCliOutput || false,
args,
}); });
const { cliExitCode } = await cli.runCli(); const { cliExitCode } = await cli.runCli();
@@ -8,11 +8,13 @@ const fs = require('fs');
/** /**
* extractSubtitlesAndOcr * extractSubtitlesAndOcr
* Extracts subtitle streams for ITA and ENG languages: * Extracts subtitle streams for ITA and ENG languages:
* - Text-based (subrip, srt, ass, ssa, webvtt): direct FFmpeg extraction * - Text-based (subrip, srt, ass, ssa, webvtt):
* - Bitmap-based (hdmv_pgs_subtitle, dvd_subtitle): FFmpeg .sup extract * Fast extraction via mkvextract (all tracks in one pass, seconds instead of minutes),
* then SubtitleEdit CLI OCR -> .srt (with graceful fallback on failure) * 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: <baseName>.<lang>[.forced][.default][.sdh].srt * Naming: <baseName>.<lang>[.forced][.default][.sdh].srt
*/ */
@@ -22,8 +24,8 @@ const BITMAP_CODECS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'pgssub', '
const details = () => ({ const details = () => ({
name: 'Extract Subtitles & OCR Fallback', name: 'Extract Subtitles & OCR Fallback',
description: description:
'Extracts ITA/ENG subtitle streams to SRT files next to the source. ' 'Extracts ITA/ENG subtitle streams to SRT files in the library directory. '
+ 'Text subtitles are extracted directly via FFmpeg. ' + 'Fast multi-track extraction via mkvextract / FFmpeg. '
+ 'Bitmap subtitles (PGS/VOBSUB) are OCR-d via SubtitleEdit CLI. ' + 'Bitmap subtitles (PGS/VOBSUB) are OCR-d via SubtitleEdit CLI. '
+ 'OCR failures are logged as warnings without stopping the pipeline.', + 'OCR failures are logged as warnings without stopping the pipeline.',
style: { borderColor: '#00BCD4' }, style: { borderColor: '#00BCD4' },
@@ -70,19 +72,40 @@ 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');
// Only ita.forced can be default. Do not add default to eng, non-forced, or other tracks.
if (lang === 'ita' && isForced) parts.push('default');
if (stream.disposition && stream.disposition.hearing_impaired) parts.push('sdh'); if (stream.disposition && stream.disposition.hearing_impaired) parts.push('sdh');
// Also check title tag for SDH if (!parts.includes('sdh') && (title.includes('sdh') || title.includes('hearing impaired')
const title = ((stream.tags && stream.tags.title) || '').toLowerCase(); || title.includes('hearing_impaired'))) parts.push('sdh');
if (!parts.includes('sdh') && title.includes('sdh')) parts.push('sdh');
return parts.join('.'); return parts.join('.');
}; };
const runFFmpeg = async (spawnArgs, workDir, ffmpegPath, jobLog, inputFileObj) => { 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 } = require('../../../../FlowHelpers/1.0.0/cliUtils');
const cli = new CLI({ const cli = new CLI({
cli: ffmpegPath, spawnArgs, WorkDir: workDir, jobLog, inputFileObj, cli: cliBin,
spawnArgs,
spawnOpts: { cwd: args.workDir },
WorkDir: args.workDir,
jobLog: args.jobLog,
inputFileObj: args.inputFileObj,
updateWorker: args.updateWorker,
logFullCliOutput: args.logFullCliOutput || false,
args,
}); });
return cli.runCli(); return cli.runCli();
}; };
@@ -93,14 +116,17 @@ const plugin = async (args) => {
const { jobLog, inputFileObj, variables, workDir } = args; const { jobLog, inputFileObj, variables, workDir } = args;
const inputFile = inputFileObj._id; const inputFile = inputFileObj._id;
const sourceDir = path.dirname(inputFile); // Always save output SRTs in original library directory if known (e.g. from copyToWorkDir)
const baseName = path.basename(inputFile, path.extname(inputFile)); 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 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');
@@ -112,6 +138,10 @@ const plugin = async (args) => {
jobLog(`[extractSubtitlesAndOcr] Found ${subtitleStreams.length} subtitle stream(s).\n`); jobLog(`[extractSubtitlesAndOcr] Found ${subtitleStreams.length} subtitle stream(s).\n`);
const textTasks = [];
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) || '');
if (!targetLangs.includes(lang)) { if (!targetLangs.includes(lang)) {
@@ -121,75 +151,116 @@ const plugin = async (args) => {
const codec = (stream.codec_name || '').toLowerCase(); const codec = (stream.codec_name || '').toLowerCase();
const suffix = buildSuffix(lang, stream); 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`); 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`);
}
// ── Text-based subtitles ────────────────────────────────────────────── }
if (TEXT_CODECS.has(codec)) {
const { cliExitCode } = await runFFmpeg( for (const task of taskByOutput.values()) {
['-y', '-i', inputFile, '-map', `0:${stream.index}`, '-c:s', 'srt', outSrt], if (task.type === 'text') textTasks.push(task);
workDir, args.ffmpegPath, jobLog, inputFileObj, else bitmapTasks.push(task);
}
// ── 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) { if (cliExitCode !== 0) {
jobLog(`[extractSubtitlesAndOcr] WARNING: FFmpeg failed for stream #${stream.index} (exit ${cliExitCode}).\n`); jobLog(`[extractSubtitlesAndOcr] WARNING: .sup extraction failed for stream #${t.stream.index}.\n`);
} else {
jobLog(`[extractSubtitlesAndOcr] Extracted: ${path.basename(outSrt)}\n`);
}
continue; 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],
workDir, args.ffmpegPath, jobLog, inputFileObj,
);
if (extractExit !== 0) {
jobLog(`[extractSubtitlesAndOcr] WARNING: .sup extraction failed for stream #${stream.index}.\n`);
continue;
} }
// Step 2: OCR with SubtitleEdit // Run OCR with SubtitleEdit CLI
try { try {
const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils'); const seCli = await runCLI(
const seCli = new CLI({ subtitleEditPath,
cli: subtitleEditPath, ['/convert', tmpSup, 'subrip', '/track-number:0', `/outputfolder:${targetDir}`],
spawnArgs: [ args,
'/convert', tmpSup, 'subrip', );
'/track-number:0', if (seCli.cliExitCode !== 0) {
`/outputfolder:${sourceDir}`, jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit OCR failed (exit ${seCli.cliExitCode}) for stream #${t.stream.index}.\n`);
],
WorkDir: workDir,
jobLog,
inputFileObj,
});
const { cliExitCode: seExit } = await seCli.runCli();
if (seExit !== 0) {
jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit OCR failed (exit ${seExit}) for stream #${stream.index}.\n`);
} else { } else {
// SubtitleEdit names the output after the .sup filename const seOut = path.join(targetDir, path.basename(tmpSup, '.sup') + '.srt');
const seOut = path.join(sourceDir, path.basename(tmpSup, '.sup') + '.srt');
if (fs.existsSync(seOut)) { if (fs.existsSync(seOut)) {
fs.renameSync(seOut, outSrt); fs.renameSync(seOut, t.outSrt);
jobLog(`[extractSubtitlesAndOcr] OCR complete: ${path.basename(outSrt)}\n`); jobLog(`[extractSubtitlesAndOcr] OCR complete: ${path.basename(t.outSrt)}\n`);
} }
} }
} catch (err) { } catch (err) {
jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit not available or crashed: ${err.message}\n`); jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit not available: ${err.message}\n`);
} finally { } finally {
// Clean up temp .sup try { if (fs.existsSync(tmpSup)) fs.unlinkSync(tmpSup); } catch (_) { /* ignore */ }
try { fs.unlinkSync(tmpSup); } catch (_) { /* ignore */ }
} }
continue;
}
jobLog(`[extractSubtitlesAndOcr] Stream #${stream.index} codec "${codec}" not supported — skipping.\n`);
} }
jobLog('[extractSubtitlesAndOcr] Done.\n'); jobLog('[extractSubtitlesAndOcr] Done.\n');
@@ -0,0 +1,304 @@
'use strict';
const path = require('path');
const fs = require('fs');
/* eslint-disable no-param-reassign */
const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 1000;
const PROGRESS_INTERVAL_MS = 1000;
const sleep = (milliseconds) => new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
const formatEta = (seconds) => {
if (!Number.isFinite(seconds) || seconds < 0) return '00:00:00';
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};
/**
* Copies a file from source to destination with real-time stream-based
* progress reporting, speed / ETA tracking, Tdarr worker updates, size verification,
* and automatic retry with cleanup. Self-contained to avoid external helper dependencies.
*/
const copyWithProgress = async ({
source,
destination,
jobLog,
updateWorker,
updateProgress,
label = 'copy',
}) => {
if (!fs.existsSync(source)) {
throw new Error(`[${label}] Source file not found: ${source}`);
}
const sourceStat = fs.statSync(source);
const sourceSize = sourceStat.size;
const sourceSizeMb = sourceSize / 1024 / 1024;
const destDir = path.dirname(destination);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const displayFile = (source || '').replace(/\\/g, '/');
const report = (percent, message, eta) => {
if (typeof updateWorker === 'function') {
updateWorker({
file: displayFile,
percentage: Math.min(percent, 100),
ETA: eta || '00:00:00',
});
}
if (typeof updateProgress === 'function') {
updateProgress({
progress: Math.min(percent, 100),
jobLog: message,
});
}
if (message && typeof jobLog === 'function') {
jobLog(`${message}\n`);
}
};
let lastError;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
if (attempt > 0) {
report(0, `[${label}] Retrying copy (${attempt}/${MAX_RETRIES})...`);
await sleep(RETRY_DELAY_MS);
}
try {
if (fs.existsSync(destination)) {
fs.unlinkSync(destination);
}
// Special case: empty file (0 bytes)
if (sourceSize === 0) {
fs.writeFileSync(destination, Buffer.alloc(0));
report(100, `[${label}] Copied 0-byte file successfully.`, '00:00:00');
return;
}
const startedAt = Date.now();
let copiedBytes = 0;
let lastProgressTime = 0;
let intervalBytes = 0;
let intervalTime = startedAt;
await new Promise((resolve, reject) => {
const rd = fs.createReadStream(source, { highWaterMark: 1024 * 1024 });
const wr = fs.createWriteStream(destination, { highWaterMark: 1024 * 1024 });
rd.on('error', (err) => {
wr.destroy();
reject(err);
});
wr.on('error', (err) => {
rd.destroy();
reject(err);
});
wr.on('finish', resolve);
rd.on('data', (chunk) => {
copiedBytes += chunk.length;
intervalBytes += chunk.length;
const now = Date.now();
const percent = Math.min(Math.floor((copiedBytes / sourceSize) * 100), 99);
// Update UI and jobLog every second (PROGRESS_INTERVAL_MS) or on initial data chunk
const timeSinceLastProgress = now - lastProgressTime;
if (lastProgressTime === 0 || timeSinceLastProgress >= PROGRESS_INTERVAL_MS) {
const totalElapsedSec = Math.max((now - startedAt) / 1000, 0.001);
const avgSpeedBps = copiedBytes / totalElapsedSec;
const remainingBytes = Math.max(0, sourceSize - copiedBytes);
const remainingSec = avgSpeedBps > 0 ? remainingBytes / avgSpeedBps : 0;
const eta = formatEta(remainingSec);
if (typeof updateWorker === 'function') {
updateWorker({
file: displayFile,
percentage: percent,
ETA: eta,
});
}
if (typeof updateProgress === 'function') {
updateProgress({ progress: percent });
}
const intSec = Math.max((now - intervalTime) / 1000, 0.001);
const currentSpeedMb = (intervalBytes / 1024 / 1024 / intSec).toFixed(1);
const copiedMb = (copiedBytes / 1024 / 1024).toFixed(1);
if (typeof jobLog === 'function') {
jobLog(
`[${label}] Progress: ${percent}% `
+ `(${copiedMb}/${sourceSizeMb.toFixed(1)} MB, `
+ `${currentSpeedMb} MB/s, ETA: ${eta})\n`,
);
}
lastProgressTime = now;
intervalBytes = 0;
intervalTime = now;
}
});
rd.pipe(wr);
});
// Verify destination size
if (!fs.existsSync(destination)) {
throw new Error(`Destination file does not exist after copy: ${destination}`);
}
const destinationSize = fs.statSync(destination).size;
if (destinationSize !== sourceSize) {
throw new Error(
`Copy size mismatch: expected ${sourceSize} bytes, got ${destinationSize}`,
);
}
const totalElapsed = Math.max((Date.now() - startedAt) / 1000, 0.001);
const avgSpeed = (sourceSizeMb / totalElapsed).toFixed(1);
report(
100,
`[${label}] Done: ${sourceSizeMb.toFixed(1)} MB copied in `
+ `${totalElapsed.toFixed(1)}s (${avgSpeed} MB/s average)`,
'00:00:00',
);
return;
} catch (error) {
lastError = error;
try {
if (fs.existsSync(destination)) fs.unlinkSync(destination);
} catch (cleanupError) {
lastError = new Error(
`${error.message}; could not remove partial copy: ${cleanupError.message}`,
);
}
if (attempt < MAX_RETRIES && typeof jobLog === 'function') {
jobLog(`[${label}] Copy attempt ${attempt + 1} failed: ${lastError.message}\n`);
}
}
}
throw new Error(
`[${label}] Copy failed after ${MAX_RETRIES + 1} attempts: ${lastError.message}`,
);
};
/**
* copyToWorkDir
* Copies the source file into the worker's local workDir before processing.
*
* Use this plugin BEFORE any transcode/extract plugin when:
* - The source file lives on a network share / mapped drive
* - You want the worker to encode from a local copy to avoid I/O bottleneck
* - The network path is slow or unreliable during long encodes
*
* Flow:
* 1. Copies source -> workDir/<basename>_old<ext> (streamed with real-time progress)
* 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 via replaceOriginalFile
*/
const details = () => ({
name: 'Copy Source to Work Dir',
description:
'Copies the source file into the worker local workDir using a streamed, verified copy '
+ 'with real-time progress, speed, and ETA tracking. Use before transcode plugins '
+ 'when the source is on a network share to avoid I/O bottlenecks during encoding.',
style: { borderColor: '#607D8B' },
tags: 'utility,copy,progress',
isStartPlugin: false,
pType: '',
requiresVersion: '2.11.01',
sidebarPosition: 0,
icon: 'faCopy',
inputs: [
{
label: 'Skip if already local',
name: 'skipIfLocal',
type: 'boolean',
defaultValue: 'true',
inputUI: {
type: 'dropdown',
options: ['true', 'false'],
},
tooltip:
'If true, skip the copy when the source file is already inside workDir '
+ 'or on the same drive as workDir (avoids double-copy on mapped nodes).',
},
],
outputs: [
{ number: 1, tooltip: 'File is ready in workDir — continue to next plugin' },
],
});
const plugin = async (args) => {
const { jobLog, inputFileObj, variables, workDir, updateWorker } = args;
const inputFile = inputFileObj._id;
const ext = path.extname(inputFile);
const baseName = path.basename(inputFile, ext);
const localCopy = path.join(workDir, `${baseName}_old${ext}`).replace(/\\/g, '/');
const skipIfLocal = (args.inputs.skipIfLocal || 'true') === 'true';
jobLog(`[copyToWorkDir] Source : ${inputFile}\n`);
jobLog(`[copyToWorkDir] WorkDir: ${workDir}\n`);
// Skip if source file is already inside workDir
const relativeInput = path.relative(path.resolve(workDir), path.resolve(inputFile));
if (
skipIfLocal
&& relativeInput !== '..'
&& !relativeInput.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relativeInput)
) {
jobLog('[copyToWorkDir] Source file is already inside workDir — skipping copy.\n');
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
}
if (!fs.existsSync(inputFile)) {
throw new Error(`[copyToWorkDir] Source file not found: ${inputFile}`);
}
// Preserve original library paths for downstream plugins
if (!variables.originalLibraryFile) {
variables.originalLibraryFile = inputFile;
variables.originalLibraryDir = path.dirname(inputFile);
}
variables.workDirSourceBaseName = baseName;
variables.workDirCopyApplied = 'true';
jobLog(`[copyToWorkDir] Copying ${inputFile} to ${localCopy}\n`);
await copyWithProgress({
source: inputFile,
destination: localCopy,
jobLog,
updateWorker,
updateProgress: args.updateProgress,
label: 'copyToWorkDir',
});
return {
outputNumber: 1,
outputFileObj: { ...inputFileObj, _id: localCopy },
variables,
};
};
module.exports.details = details;
module.exports.plugin = plugin;
module.exports.copyWithProgress = copyWithProgress;
@@ -8,7 +8,7 @@ const fs = require('fs');
const details = () => ({ const details = () => ({
name: 'Extract Chapters to XML', name: 'Extract Chapters to XML',
description: description:
'Extracts MKV chapter markers to an XML file in workDir using mkvextract. ' 'Extracts MKV chapter markers to an XML file next to the original source using mkvextract. '
+ 'Stores the output path in args.variables.chaptersXmlPath. ' + 'Stores the output path in args.variables.chaptersXmlPath. '
+ 'Safe to run even if the source has no chapters.', + 'Safe to run even if the source has no chapters.',
style: { borderColor: '#9C27B0' }, style: { borderColor: '#9C27B0' },
@@ -25,11 +25,14 @@ const details = () => ({
}); });
const plugin = async (args) => { const plugin = async (args) => {
const { jobLog, inputFileObj, variables, workDir } = args; const { jobLog, inputFileObj, variables, workDir, updateWorker } = args;
const inputFile = inputFileObj._id; const inputFile = inputFileObj._id;
const baseName = path.basename(inputFile, path.extname(inputFile)); // Keep sidecar files in the library when a prior plugin copied the media to workDir.
const xmlPath = path.join(workDir, `${baseName}_chapters.xml`); const originalFile = variables.originalLibraryFile || inputFile;
const targetDir = variables.originalLibraryDir || path.dirname(originalFile);
const baseName = path.basename(originalFile, path.extname(originalFile));
const xmlPath = path.join(targetDir, `${baseName}_chapters.xml`);
jobLog(`[extractChaptersXml] Source: ${inputFile}\n`); jobLog(`[extractChaptersXml] Source: ${inputFile}\n`);
jobLog(`[extractChaptersXml] Output: ${xmlPath}\n`); jobLog(`[extractChaptersXml] Output: ${xmlPath}\n`);
@@ -47,9 +50,13 @@ const plugin = async (args) => {
const cli = new CLI({ const cli = new CLI({
cli: mkvExtract, cli: mkvExtract,
spawnArgs: [inputFile, 'chapters', xmlPath], spawnArgs: [inputFile, 'chapters', xmlPath],
spawnOpts: { cwd: workDir },
WorkDir: workDir, WorkDir: workDir,
jobLog, jobLog,
inputFileObj, inputFileObj,
updateWorker,
logFullCliOutput: args.logFullCliOutput || false,
args,
}); });
const { cliExitCode } = await cli.runCli(); const { cliExitCode } = await cli.runCli();
@@ -0,0 +1,345 @@
'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;
@@ -1,4 +1,4 @@
'use strict'; 'use strict';
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
@@ -6,7 +6,9 @@
* mediaAnalyzerGOP * mediaAnalyzerGOP
* Analyzes the video stream and populates args.variables with encoding * Analyzes the video stream and populates args.variables with encoding
* parameters consumed by downstream plugins: * parameters consumed by downstream plugins:
* gopKeyint / gopMinKeyint -> 2 x FPS (rounded) * gopKeyint -> 4 x FPS (rounded; maximum 4-second GOP)
* gopMinKeyint -> 1 x FPS (rounded; permits scene cuts)
* gopSceneCut -> x265 scene-cut threshold (40)
* isAnimation -> detected from animeSeries path list * isAnimation -> detected from animeSeries path list
* isMovie -> detected from moviePathPatterns * isMovie -> detected from moviePathPatterns
* vBitrate / vMaxRate / vBufSize * vBitrate / vMaxRate / vBufSize
@@ -17,7 +19,7 @@
const details = () => ({ const details = () => ({
name: 'Media Analyzer & GOP Calculator', name: 'Media Analyzer & GOP Calculator',
description: description:
'Analyzes video metadata, calculates GOP keyint (2xFPS), target bitrate ' 'Analyzes video metadata, calculates a streaming-friendly 4-second GOP, target bitrate '
+ 'and VBV parameters based on content type (film/series/anime). ' + 'and VBV parameters based on content type (film/series/anime). '
+ 'Exports all values to args.variables for downstream plugins.', + 'Exports all values to args.variables for downstream plugins.',
style: { borderColor: '#2196F3' }, style: { borderColor: '#2196F3' },
@@ -87,6 +89,37 @@ const details = () => ({
], ],
}); });
const normalizeAccents = (str) =>
String(str || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
const matchesAnime = (animeName, targetPath) => {
if (!animeName || !targetPath) return false;
const cleanTarget = normalizeAccents(targetPath).toLowerCase();
const cleanName = normalizeAccents(animeName).trim().toLowerCase();
if (!cleanName) return false;
// 1. Direct substring match (case/accent-insensitive)
if (cleanTarget.includes(cleanName)) return true;
// 2. Token / word boundary match allowing dots, dashes, underscores, spaces between words
// e.g. "Family Guy" matches "Family.Guy.1999" or "Family_Guy_S01E01"
const words = cleanName.split(/[^a-z0-9]+/).filter(Boolean);
if (words.length > 0) {
const escapedWords = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const flexiblePattern = new RegExp(`(^|[^a-z0-9])${escapedWords.join('[\\s._\\-]+')}([^a-z0-9]|$)`, 'i');
if (flexiblePattern.test(cleanTarget)) return true;
// 3. Match when punctuation/spaces/dots were completely collapsed (e.g. "bob's" -> "bobs")
const collapsedName = cleanName.replace(/[^a-z0-9]/g, '');
const collapsedTarget = cleanTarget.replace(/[^a-z0-9]/g, '');
if (collapsedName.length >= 3 && collapsedTarget.includes(collapsedName)) return true;
}
return false;
};
const parseRate = (rateStr) => { const parseRate = (rateStr) => {
if (!rateStr) return 0; if (!rateStr) return 0;
if (rateStr.includes('/')) { if (rateStr.includes('/')) {
@@ -123,17 +156,37 @@ const plugin = async (args) => {
} }
jobLog(`[mediaAnalyzerGOP] FPS: ${fps.toFixed(3)}\n`); jobLog(`[mediaAnalyzerGOP] FPS: ${fps.toFixed(3)}\n`);
const gopKeyint = String(Math.round(fps * 2)); // A four-second maximum GOP is a compromise for remote streaming: it
jobLog(`[mediaAnalyzerGOP] keyint=${gopKeyint}\n`); // reduces regular I-frame overhead while keeping seek recovery short.
// A one-second minimum leaves x265 free to insert an I-frame at a real cut.
const gopKeyint = String(Math.round(fps * 4));
const gopMinKeyint = String(Math.round(fps));
const gopSceneCut = '40';
jobLog(`[mediaAnalyzerGOP] keyint=${gopKeyint} min-keyint=${gopMinKeyint} scenecut=${gopSceneCut}\n`);
// Normalize to forward slashes for cross-platform path matching // Build candidate paths list to support workDir temporary copies
const normalizedPath = filePath.replace(/\\/g, '/'); const candidatePaths = [
const isMovie = moviePathPatterns.some((p) => normalizedPath.includes(p)); inputFileObj._id,
const matchedPat = moviePathPatterns.find((p) => normalizedPath.includes(p)); inputFileObj.file,
variables.originalLibraryFile,
variables.originalLibraryDir,
]
.filter(Boolean)
.map((p) => String(p).replace(/\\/g, '/'));
jobLog(`[mediaAnalyzerGOP] Candidate paths: ${candidatePaths.join(' | ')}\n`);
// Check if media matches movie path patterns
const matchedPat = moviePathPatterns.find((p) =>
candidatePaths.some((cPath) => cPath.toLowerCase().includes(p.toLowerCase())),
);
const isMovie = !!matchedPat;
jobLog(`[mediaAnalyzerGOP] isMovie=${isMovie}${matchedPat ? ` ("${matchedPat}")` : ''}\n`); jobLog(`[mediaAnalyzerGOP] isMovie=${isMovie}${matchedPat ? ` ("${matchedPat}")` : ''}\n`);
const lowerPath = normalizedPath.toLowerCase(); // Check if media matches anime series list
const matchedAnime = animeList.find((name) => lowerPath.includes(name.toLowerCase())); const matchedAnime = animeList.find((name) =>
candidatePaths.some((cPath) => matchesAnime(name, cPath)),
);
const isAnimation = !!matchedAnime; const isAnimation = !!matchedAnime;
jobLog(`[mediaAnalyzerGOP] isAnimation=${isAnimation}${matchedAnime ? ` ("${matchedAnime}")` : ''}\n`); jobLog(`[mediaAnalyzerGOP] isAnimation=${isAnimation}${matchedAnime ? ` ("${matchedAnime}")` : ''}\n`);
@@ -149,7 +202,8 @@ const plugin = async (args) => {
jobLog(`[mediaAnalyzerGOP] vBitrate=${vBitrate} vMaxRate=${vMaxRate} vBufSize=${vBufSize}\n`); jobLog(`[mediaAnalyzerGOP] vBitrate=${vBitrate} vMaxRate=${vMaxRate} vBufSize=${vBufSize}\n`);
variables.gopKeyint = gopKeyint; variables.gopKeyint = gopKeyint;
variables.gopMinKeyint = gopKeyint; variables.gopMinKeyint = gopMinKeyint;
variables.gopSceneCut = gopSceneCut;
variables.isAnimation = String(isAnimation); variables.isAnimation = String(isAnimation);
variables.isMovie = String(isMovie); variables.isMovie = String(isMovie);
variables.vBitrate = vBitrate; variables.vBitrate = vBitrate;
@@ -165,3 +219,6 @@ const plugin = async (args) => {
module.exports.details = details; module.exports.details = details;
module.exports.plugin = plugin; module.exports.plugin = plugin;
module.exports.matchesAnime = matchesAnime;
module.exports.normalizeAccents = normalizeAccents;
@@ -0,0 +1,390 @@
'use strict';
const path = require('path');
const fs = require('fs');
/* eslint-disable no-param-reassign */
const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 1000;
const PROGRESS_INTERVAL_MS = 1000;
const sleep = (milliseconds) => new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
const formatEta = (seconds) => {
if (!Number.isFinite(seconds) || seconds < 0) return '00:00:00';
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};
/**
* Copies a file from source to destination with real-time stream-based
* progress reporting, speed / ETA tracking, Tdarr worker updates, size verification,
* and automatic retry with cleanup. Self-contained to avoid external helper dependencies.
*/
const copyWithProgress = async ({
source,
destination,
jobLog,
updateWorker,
updateProgress,
label = 'copy',
}) => {
if (!fs.existsSync(source)) {
throw new Error(`[${label}] Source file not found: ${source}`);
}
const sourceStat = fs.statSync(source);
const sourceSize = sourceStat.size;
const sourceSizeMb = sourceSize / 1024 / 1024;
const destDir = path.dirname(destination);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const displayFile = (source || '').replace(/\\/g, '/');
const report = (percent, message, eta) => {
if (typeof updateWorker === 'function') {
updateWorker({
file: displayFile,
percentage: Math.min(percent, 100),
ETA: eta || '00:00:00',
});
}
if (typeof updateProgress === 'function') {
updateProgress({
progress: Math.min(percent, 100),
jobLog: message,
});
}
if (message && typeof jobLog === 'function') {
jobLog(`${message}\n`);
}
};
let lastError;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
if (attempt > 0) {
report(0, `[${label}] Retrying copy (${attempt}/${MAX_RETRIES})...`);
await sleep(RETRY_DELAY_MS);
}
try {
if (fs.existsSync(destination)) {
fs.unlinkSync(destination);
}
// Special case: empty file (0 bytes)
if (sourceSize === 0) {
fs.writeFileSync(destination, Buffer.alloc(0));
report(100, `[${label}] Copied 0-byte file successfully.`, '00:00:00');
return;
}
const startedAt = Date.now();
let copiedBytes = 0;
let lastProgressTime = 0;
let intervalBytes = 0;
let intervalTime = startedAt;
await new Promise((resolve, reject) => {
const rd = fs.createReadStream(source, { highWaterMark: 1024 * 1024 });
const wr = fs.createWriteStream(destination, { highWaterMark: 1024 * 1024 });
rd.on('error', (err) => {
wr.destroy();
reject(err);
});
wr.on('error', (err) => {
rd.destroy();
reject(err);
});
wr.on('finish', resolve);
rd.on('data', (chunk) => {
copiedBytes += chunk.length;
intervalBytes += chunk.length;
const now = Date.now();
const percent = Math.min(Math.floor((copiedBytes / sourceSize) * 100), 99);
// Update UI and jobLog every second (PROGRESS_INTERVAL_MS) or on initial data chunk
const timeSinceLastProgress = now - lastProgressTime;
if (lastProgressTime === 0 || timeSinceLastProgress >= PROGRESS_INTERVAL_MS) {
const totalElapsedSec = Math.max((now - startedAt) / 1000, 0.001);
const avgSpeedBps = copiedBytes / totalElapsedSec;
const remainingBytes = Math.max(0, sourceSize - copiedBytes);
const remainingSec = avgSpeedBps > 0 ? remainingBytes / avgSpeedBps : 0;
const eta = formatEta(remainingSec);
if (typeof updateWorker === 'function') {
updateWorker({
file: displayFile,
percentage: percent,
ETA: eta,
});
}
if (typeof updateProgress === 'function') {
updateProgress({ progress: percent });
}
const intSec = Math.max((now - intervalTime) / 1000, 0.001);
const currentSpeedMb = (intervalBytes / 1024 / 1024 / intSec).toFixed(1);
const copiedMb = (copiedBytes / 1024 / 1024).toFixed(1);
if (typeof jobLog === 'function') {
jobLog(
`[${label}] Progress: ${percent}% `
+ `(${copiedMb}/${sourceSizeMb.toFixed(1)} MB, `
+ `${currentSpeedMb} MB/s, ETA: ${eta})\n`,
);
}
lastProgressTime = now;
intervalBytes = 0;
intervalTime = now;
}
});
rd.pipe(wr);
});
// Verify destination size
if (!fs.existsSync(destination)) {
throw new Error(`Destination file does not exist after copy: ${destination}`);
}
const destinationSize = fs.statSync(destination).size;
if (destinationSize !== sourceSize) {
throw new Error(
`Copy size mismatch: expected ${sourceSize} bytes, got ${destinationSize}`,
);
}
const totalElapsed = Math.max((Date.now() - startedAt) / 1000, 0.001);
const avgSpeed = (sourceSizeMb / totalElapsed).toFixed(1);
report(
100,
`[${label}] Done: ${sourceSizeMb.toFixed(1)} MB copied in `
+ `${totalElapsed.toFixed(1)}s (${avgSpeed} MB/s average)`,
'00:00:00',
);
return;
} catch (error) {
lastError = error;
try {
if (fs.existsSync(destination)) fs.unlinkSync(destination);
} catch (cleanupError) {
lastError = new Error(
`${error.message}; could not remove partial copy: ${cleanupError.message}`,
);
}
if (attempt < MAX_RETRIES && typeof jobLog === 'function') {
jobLog(`[${label}] Copy attempt ${attempt + 1} failed: ${lastError.message}\n`);
}
}
}
throw new Error(
`[${label}] Copy failed after ${MAX_RETRIES + 1} attempts: ${lastError.message}`,
);
};
const details = () => ({
name: 'Replace Original File',
description:
'Copies the processed file to the original library path with streamed progress, '
+ 'size verification, an atomic rename, and rollback if replacement fails.',
style: { borderColor: '#FF9800' },
tags: 'utility,replace,progress',
isStartPlugin: false,
pType: '',
requiresVersion: '2.11.01',
sidebarPosition: 1,
icon: 'faExchangeAlt',
inputs: [
{
label: 'Delete processed work file',
name: 'deleteSource',
type: 'boolean',
defaultValue: 'true',
inputUI: { type: 'dropdown', options: ['true', 'false'] },
tooltip: 'Delete the processed file after it has replaced the original.',
},
{
label: 'Output Directory (Optional)',
name: 'outputDirectory',
type: 'string',
defaultValue: '',
inputUI: { type: 'text' },
tooltip: 'Target library directory for folder-to-folder conversion. If empty, automatically uses Library Output if configured, or replaces in-place. Example: T:/Media/Film or T:/Media/Serie',
},
],
outputs: [
{ number: 1, tooltip: 'Original file replaced — continue to next plugin' },
],
});
const plugin = async (args) => {
const { inputFileObj, variables, jobLog, updateWorker } = args;
const source = inputFileObj._id;
const configuredDestination = variables.originalLibraryFile || args.originalLibraryFile;
let destination = typeof configuredDestination === 'string'
? configuredDestination
: configuredDestination && configuredDestination._id;
if (!destination || typeof destination !== 'string') {
throw new Error('[replaceOriginalFile] No original library path is available');
}
if (!fs.existsSync(source)) {
throw new Error(`[replaceOriginalFile] Processed file not found: ${source}`);
}
const originalSourceFile = destination;
const originalSourceDir = path.dirname(originalSourceFile);
// Check for folder-to-folder target directory (from plugin input or Tdarr librarySettings.output)
const configuredOutput = ((args.inputs && args.inputs.outputDirectory) || '').trim()
|| ((args.librarySettings && args.librarySettings.output) || '').trim();
const sourceLibraryRoot = ((args.librarySettings && args.librarySettings.folder) || '').trim();
if (configuredOutput && path.resolve(configuredOutput) !== path.resolve(sourceLibraryRoot || originalSourceDir)) {
let relPath = '';
if (sourceLibraryRoot) {
try {
relPath = path.relative(path.resolve(sourceLibraryRoot), path.resolve(originalSourceFile));
} catch (_) { relPath = ''; }
}
if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
const base = path.basename(originalSourceFile);
const parent = path.basename(originalSourceDir);
const grandParent = path.basename(path.dirname(originalSourceDir));
if (grandParent && grandParent !== '.' && !grandParent.includes(':')) {
relPath = path.join(grandParent, parent, base);
} else {
relPath = path.join(parent, base);
}
}
destination = path.join(path.resolve(configuredOutput), relPath);
jobLog(`[replaceOriginalFile] Folder-to-folder mode: target is ${destination}\n`);
}
if (path.resolve(source) === path.resolve(destination)) {
jobLog('[replaceOriginalFile] Processed file is already at the original path — skipping.\n');
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
}
const destinationDir = path.dirname(destination);
if (!fs.existsSync(destinationDir)) {
fs.mkdirSync(destinationDir, { recursive: true });
jobLog(`[replaceOriginalFile] Created destination directory: ${destinationDir}\n`);
}
const sourceExt = path.extname(source);
const destExt = path.extname(destination);
const oldOriginalFile = destination;
if (sourceExt && destExt && sourceExt.toLowerCase() !== destExt.toLowerCase()) {
destination = path.join(destinationDir, `${path.basename(destination, destExt)}${sourceExt}`);
}
const temporary = `${destination}.tdarr.tmp`;
const backup = `${oldOriginalFile}.partial.old`;
let backupCreated = false;
let installed = false;
try {
if (fs.existsSync(temporary)) {
fs.unlinkSync(temporary);
}
jobLog(`[replaceOriginalFile] Copying processed file to temporary destination: ${temporary}\n`);
await copyWithProgress({
source,
destination: temporary,
jobLog,
updateWorker,
updateProgress: args.updateProgress,
label: 'replaceOriginalFile',
});
if (fs.existsSync(oldOriginalFile)) {
if (fs.existsSync(backup)) {
fs.unlinkSync(backup);
}
fs.renameSync(oldOriginalFile, backup);
backupCreated = true;
}
if (fs.existsSync(destination) && destination !== oldOriginalFile) {
try { fs.unlinkSync(destination); } catch (_) { /* ignore */ }
}
fs.renameSync(temporary, destination);
installed = true;
if (backupCreated) {
try {
fs.unlinkSync(backup);
} catch (error) {
jobLog(`[replaceOriginalFile] WARNING: Could not remove backup ${backup}: ${error.message}\n`);
}
}
if ((args.inputs.deleteSource || 'true') === 'true' && path.resolve(source) !== path.resolve(destination)) {
try {
fs.unlinkSync(source);
} catch (error) {
jobLog(`[replaceOriginalFile] WARNING: Could not delete processed file ${source}: ${error.message}\n`);
}
}
if (path.resolve(destination) !== path.resolve(originalSourceFile) && fs.existsSync(originalSourceFile)) {
try {
fs.unlinkSync(originalSourceFile);
jobLog(`[replaceOriginalFile] Removed source file from intake directory: ${originalSourceFile}\n`);
} catch (error) {
jobLog(`[replaceOriginalFile] WARNING: Could not delete intake source file ${originalSourceFile}: ${error.message}\n`);
}
}
// Clean up temporary workDir copy (_old) if still present
if (variables.workDirCopyApplied === 'true') {
const workDir = path.dirname(source);
const oldBase = variables.workDirSourceBaseName || path.basename(destination, sourceExt);
const oldCandidate = path.join(workDir, `${oldBase}_old${destExt}`);
if (fs.existsSync(oldCandidate)) {
try { fs.unlinkSync(oldCandidate); } catch (_) { /* ignore */ }
}
}
variables.finalFile = destination;
variables.finalDir = destinationDir;
jobLog(`[replaceOriginalFile] Replaced original file: ${destination}\n`);
return {
outputNumber: 1,
outputFileObj: { ...inputFileObj, _id: destination },
variables,
};
} catch (error) {
if (fs.existsSync(temporary)) {
try { fs.unlinkSync(temporary); } catch (_) { /* ignore */ }
}
if (backupCreated && !installed && !fs.existsSync(destination)) {
try { fs.renameSync(backup, destination); } catch (_) { /* ignore */ }
}
throw new Error(`[replaceOriginalFile] Replacement failed: ${error.message}`);
}
};
module.exports.details = details;
module.exports.plugin = plugin;
module.exports.copyWithProgress = copyWithProgress;
@@ -7,7 +7,8 @@ const fs = require('fs');
/** /**
* transcode2PassX265 * transcode2PassX265
* Performs 2-pass x265 encoding with: * Performs 2-pass x265 encoding, unless the input is already a compatible
* low-bitrate HEVC stream, with:
* - Bound-1080p Lanczos scaling (no letterbox, even dimensions) * - Bound-1080p Lanczos scaling (no letterbox, even dimensions)
* - VBV rate control from args.variables * - VBV rate control from args.variables
* - Dynamic Opus audio mapping (ITA default, ENG secondary) * - Dynamic Opus audio mapping (ITA default, ENG secondary)
@@ -19,8 +20,9 @@ const fs = require('fs');
const details = () => ({ const details = () => ({
name: '2-Pass x265 Transcode', name: '2-Pass x265 Transcode',
description: description:
'Performs 2-pass libx265 encoding using parameters from args.variables. ' 'Performs 2-pass libx265 encoding using parameters from args.variables, '
+ 'Applies bound-1080p Lanczos scaling, Opus audio re-encoding, ' + 'or copies compatible HEVC video below the configured bitrate limit. '
+ 'Applies bound-1080p Lanczos scaling when encoding, Opus audio re-encoding, '
+ 'and chapter preservation. Cross-platform (Linux server / Windows worker).', + 'and chapter preservation. Cross-platform (Linux server / Windows worker).',
style: { borderColor: '#E91E63' }, style: { borderColor: '#E91E63' },
tags: 'video,ffmpeg,x265', tags: 'video,ffmpeg,x265',
@@ -49,6 +51,16 @@ const details = () => ({
inputUI: { type: 'text' }, inputUI: { type: 'text' },
tooltip: 'Output file container extension (mkv recommended).', tooltip: 'Output file container extension (mkv recommended).',
}, },
{
label: 'Copy HEVC Below (kbps)',
name: 'copyHevcBelowKbps',
type: 'string',
defaultValue: '3000',
inputUI: { type: 'text' },
tooltip:
'Copy video instead of re-encoding when it is HEVC, is already 1080p or smaller, '
+ 'and its reported bitrate is strictly below this value. Set 0 to disable copying.',
},
], ],
outputs: [ outputs: [
{ number: 1, tooltip: 'Transcode complete — output file ready' }, { number: 1, tooltip: 'Transcode complete — output file ready' },
@@ -95,18 +107,79 @@ const buildAudioArgs = (variables) => {
return [...mapArgs, ...codecArgs]; return [...mapArgs, ...codecArgs];
}; };
const getStreamBitrateKbps = (stream) => {
const streamBitrate = parseInt(stream.bit_rate, 10);
if (streamBitrate > 0) return streamBitrate / 1000;
// Matroska files commonly expose the per-stream bitrate as BPS metadata
// rather than ffprobe's bit_rate field (for example: tags.BPS = "1800356").
const tags = stream.tags || {};
const bpsEntry = Object.entries(tags).find(([key]) => {
const normalizedKey = String(key).toLowerCase();
return normalizedKey === 'bps' || normalizedKey === 'bps-eng';
});
const metadataBitrate = bpsEntry ? parseInt(bpsEntry[1], 10) : 0;
return metadataBitrate > 0 ? metadataBitrate / 1000 : 0;
};
const getVideoBitrateKbps = (videoStream, streams, format) => {
const directBitrate = getStreamBitrateKbps(videoStream);
if (directBitrate > 0) return directBitrate;
// Last resort: derive video bitrate from the container bitrate after
// subtracting any known audio/data stream bitrates. This is deliberately
// conservative if a companion stream does not report a bitrate.
let containerBitrate = parseInt(format && format.bit_rate, 10) / 1000;
if (!(containerBitrate > 0)) {
const size = parseInt(format && format.size, 10);
const duration = parseFloat(format && format.duration);
containerBitrate = size > 0 && duration > 0 ? (size * 8 / duration) / 1000 : 0;
}
if (!(containerBitrate > 0)) return 0;
const otherStreamBitrate = (streams || [])
.filter((stream) => stream !== videoStream)
.reduce((total, stream) => total + getStreamBitrateKbps(stream), 0);
return Math.max(0, containerBitrate - otherStreamBitrate);
};
const shouldCopyVideo = (videoStream, copyHevcBelowKbps, streams, format) => {
if (!videoStream || String(videoStream.codec_name || '').toLowerCase() !== 'hevc') return false;
const width = parseInt(videoStream.width, 10) || 0;
const height = parseInt(videoStream.height, 10) || 0;
const bitrateKbps = getVideoBitrateKbps(videoStream, streams, format);
return copyHevcBelowKbps > 0
&& width > 0 && width <= 1920
&& height > 0 && height <= 1080
&& bitrateKbps > 0 && bitrateKbps < copyHevcBelowKbps;
};
const scalePassPercentage = (pass, percentage) => {
const p = Math.min(100, Math.max(0, Number(percentage) || 0));
if (pass === 1) {
return +(p * 0.5).toFixed(1);
}
if (pass === 2) {
return +(50 + p * 0.5).toFixed(1);
}
return p;
};
// ─── Plugin ─────────────────────────────────────────────────────────────────── // ─── Plugin ───────────────────────────────────────────────────────────────────
const plugin = async (args) => { const plugin = async (args) => {
const { jobLog, inputFileObj, variables, workDir } = args; const { jobLog, inputFileObj, variables, workDir, updateWorker } = args;
const inputFile = inputFileObj._id; const inputFile = inputFileObj._id;
jobLog(`[transcode2PassX265] Source: ${inputFile}\n`); jobLog(`[transcode2PassX265] Source: ${inputFile}\n`);
// ── Read variables ──────────────────────────────────────────────────────── // ── Read variables ────────────────────────────────────────────────────────
const { const {
gopKeyint = '48', gopKeyint = '96',
gopMinKeyint = '48', gopMinKeyint = '24',
gopSceneCut = '40',
vBitrate = '3000k', vBitrate = '3000k',
vMaxRate = '6000k', vMaxRate = '6000k',
vBufSize = '12000k', vBufSize = '12000k',
@@ -115,6 +188,12 @@ const plugin = async (args) => {
const x265Preset = args.inputs.x265Preset || 'slow'; const x265Preset = args.inputs.x265Preset || 'slow';
const container = args.inputs.outputContainer || 'mkv'; const container = args.inputs.outputContainer || 'mkv';
const copyHevcBelowKbps = Math.max(0, parseInt(args.inputs.copyHevcBelowKbps || '3000', 10) || 0);
const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || [];
const format = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.format) || {};
const videoStream = streams.find((stream) => stream.codec_type === 'video');
if (!videoStream) throw new Error('[transcode2PassX265] No video stream found.');
const copyVideo = shouldCopyVideo(videoStream, copyHevcBelowKbps, streams, format);
// Parse numeric VBV values (parseInt handles trailing 'k') // Parse numeric VBV values (parseInt handles trailing 'k')
const maxRateNum = parseInt(vMaxRate, 10); const maxRateNum = parseInt(vMaxRate, 10);
@@ -122,20 +201,31 @@ const plugin = async (args) => {
jobLog( jobLog(
`[transcode2PassX265] Params: bitrate=${vBitrate} maxrate=${maxRateNum}k ` `[transcode2PassX265] Params: bitrate=${vBitrate} maxrate=${maxRateNum}k `
+ `bufsize=${bufSizeNum}k keyint=${gopKeyint} tune=${x265Tune || 'none'} preset=${x265Preset}\n`, + `bufsize=${bufSizeNum}k keyint=${gopKeyint} min-keyint=${gopMinKeyint} scenecut=${gopSceneCut} `
+ `tune=${x265Tune || 'none'} preset=${x265Preset}\n`,
);
const sourceBitrateKbps = getVideoBitrateKbps(videoStream, streams, format).toFixed(0);
jobLog(
`[transcode2PassX265] Video: codec=${videoStream.codec_name || 'unknown'} `
+ `${videoStream.width || '?'}x${videoStream.height || '?'} bitrate=${sourceBitrateKbps}k `
+ `mode=${copyVideo ? 'copy' : '2-pass-x265'} (copy limit <${copyHevcBelowKbps}k)\n`,
); );
// ── 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}`).replace(/\\/g, '/');
const displayFile = (variables.originalLibraryFile || inputFile).replace(/\\/g, '/');
const statsFile = path.join(workDir, 'x265_2pass.log'); const statsFile = path.join(workDir, 'x265_2pass.log');
// x265 expects forward slashes in x265-params on all platforms // The CLI runs with workDir as its current directory. Keeping this relative
const statsForX265 = statsFile.replace(/\\/g, '/'); // avoids the Windows drive colon being parsed as an x265-params separator.
const statsForX265 = path.basename(statsFile);
const nullDev = process.platform === 'win32' ? 'NUL' : '/dev/null'; const nullDev = process.platform === 'win32' ? 'NUL' : '/dev/null';
jobLog(`[transcode2PassX265] Output: ${outFile}\n`); jobLog(`[transcode2PassX265] Output: ${outFile}\n`);
// ── Video filter ────────────────────────────────────────────────────────── // ── Video filter / args ───────────────────────────────────────────────────
const vf = [ const vf = [
"scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease", "scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease",
'scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=lanczos', 'scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=lanczos',
@@ -145,6 +235,8 @@ const plugin = async (args) => {
const x265ParamsBase = [ const x265ParamsBase = [
`keyint=${gopKeyint}`, `keyint=${gopKeyint}`,
`min-keyint=${gopMinKeyint}`, `min-keyint=${gopMinKeyint}`,
`scenecut=${gopSceneCut}`,
`open-gop=0`,
`vbv-maxrate=${maxRateNum}`, `vbv-maxrate=${maxRateNum}`,
`vbv-bufsize=${bufSizeNum}`, `vbv-bufsize=${bufSizeNum}`,
`stats=${statsForX265}`, `stats=${statsForX265}`,
@@ -152,7 +244,7 @@ const plugin = async (args) => {
// ── Common video args ───────────────────────────────────────────────────── // ── Common video args ─────────────────────────────────────────────────────
const tuneArgs = x265Tune ? ['-tune', x265Tune] : []; const tuneArgs = x265Tune ? ['-tune', x265Tune] : [];
const videoArgs = [ const encodeVideoArgs = [
'-vf', vf, '-vf', vf,
'-c:v', 'libx265', '-c:v', 'libx265',
'-b:v', vBitrate, '-b:v', vBitrate,
@@ -160,31 +252,173 @@ const plugin = async (args) => {
...tuneArgs, ...tuneArgs,
]; ];
const videoArgs = copyVideo ? ['-c:v', 'copy'] : encodeVideoArgs;
// A stream copy has no encoder pass. The normal pass-2 command below still
// remuxes the copied video while re-encoding the selected audio tracks.
if (copyVideo) {
const audioArgs = buildAudioArgs(variables);
if (audioArgs.length === 0) {
jobLog('[transcode2PassX265] WARNING: No audio streams mapped (no ITA/ENG found).\n');
}
jobLog('[transcode2PassX265] Copying compatible video and processing audio...\n');
if (typeof updateWorker === 'function') {
updateWorker({
file: displayFile,
CLIType: args.ffmpegPath,
preset: 'copy',
});
}
const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils'); const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils');
const copyCli = new CLI({
cli: args.ffmpegPath,
spawnArgs: [
'-y',
'-i', inputFile,
'-map', '0:v:0',
...audioArgs,
'-map_metadata', '-1',
'-map_chapters', '-1',
'-metadata', `title=${baseName}`,
'-disposition:v:0', 'default',
...videoArgs,
'-sn',
outFile,
],
spawnOpts: { cwd: workDir },
WorkDir: workDir,
jobLog,
outputFilePath: outFile,
inputFileObj,
updateWorker: (data) => updateWorker && updateWorker({ file: displayFile, ...data }),
logFullCliOutput: args.logFullCliOutput || false,
args,
});
const { cliExitCode } = await copyCli.runCli();
if (cliExitCode !== 0) {
throw new Error(`[transcode2PassX265] Video-copy/audio-transcode failed with exit code ${cliExitCode}`);
}
if (variables.workDirCopyApplied === 'true' && fs.existsSync(inputFile)) {
try {
fs.unlinkSync(inputFile);
jobLog(`[transcode2PassX265] Cleaned up temporary workDir copy: ${path.basename(inputFile)}\n`);
} catch (_) { /* ignore */ }
}
jobLog(`[transcode2PassX265] Done. Output: ${outFile}\n`);
return {
outputNumber: 1,
outputFileObj: { ...inputFileObj, _id: outFile },
variables,
};
}
const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils');
const { updateProgress } = args;
if (typeof updateWorker === 'function') {
updateWorker({
file: displayFile,
CLIType: args.ffmpegPath,
preset: `x265 2-pass (${vBitrate})`,
});
}
// Scale 2-pass progress so Pass 1 counts for 0-50% and Pass 2 counts for 50-100%
const pass1UpdateWorker = (progressData) => {
if (typeof updateWorker !== 'function') return;
if (progressData && typeof progressData.percentage === 'number') {
updateWorker({
file: displayFile,
...progressData,
percentage: scalePassPercentage(1, progressData.percentage),
});
} else {
updateWorker({
file: displayFile,
...progressData,
});
}
};
const pass1UpdateProgress = (progressData) => {
if (typeof updateProgress !== 'function') return;
if (progressData && typeof progressData.progress === 'number') {
updateProgress({
...progressData,
progress: scalePassPercentage(1, progressData.progress),
});
} else {
updateProgress(progressData);
}
};
const pass2UpdateWorker = (progressData) => {
if (typeof updateWorker !== 'function') return;
if (progressData && typeof progressData.percentage === 'number') {
updateWorker({
file: displayFile,
...progressData,
percentage: scalePassPercentage(2, progressData.percentage),
});
} else {
updateWorker({
file: displayFile,
...progressData,
});
}
};
const pass2UpdateProgress = (progressData) => {
if (typeof updateProgress !== 'function') return;
if (progressData && typeof progressData.progress === 'number') {
updateProgress({
...progressData,
progress: scalePassPercentage(2, progressData.progress),
});
} else {
updateProgress(progressData);
}
};
// ══ PASS 1 ════════════════════════════════════════════════════════════════ // ══ PASS 1 ════════════════════════════════════════════════════════════════
jobLog('[transcode2PassX265] Starting Pass 1...\n'); jobLog('[transcode2PassX265] Starting Pass 1...\n');
pass1UpdateWorker({ percentage: 0 });
pass1UpdateProgress({ progress: 0 });
const pass1Cli = new CLI({ const pass1Cli = new CLI({
cli: args.ffmpegPath, cli: args.ffmpegPath,
spawnArgs: [ spawnArgs: [
'-y', '-y',
'-i', inputFile, '-i', inputFile,
'-map_metadata', '-1',
'-map_chapters', '-1',
...videoArgs, ...videoArgs,
'-x265-params', `${x265ParamsBase}:pass=1`, '-x265-params', `no-slow-firstpass=1:pass=1:${x265ParamsBase}`,
'-an', '-sn', '-an', '-sn',
'-f', 'null', '-f', 'null',
nullDev, nullDev,
], ],
spawnOpts: { cwd: workDir },
WorkDir: workDir, WorkDir: workDir,
jobLog, jobLog,
inputFileObj, inputFileObj,
updateWorker: pass1UpdateWorker,
logFullCliOutput: args.logFullCliOutput || false,
args: { ...args, updateWorker: pass1UpdateWorker, updateProgress: pass1UpdateProgress },
}); });
const { cliExitCode: pass1Exit } = await pass1Cli.runCli(); const { cliExitCode: pass1Exit } = await pass1Cli.runCli();
if (pass1Exit !== 0) { if (pass1Exit !== 0) {
throw new Error(`[transcode2PassX265] Pass 1 failed with exit code ${pass1Exit}`); throw new Error(`[transcode2PassX265] Pass 1 failed with exit code ${pass1Exit}`);
} }
jobLog('[transcode2PassX265] Pass 1 complete.\n'); pass1UpdateWorker({ percentage: 100 });
pass1UpdateProgress({ progress: 100 });
jobLog('[transcode2PassX265] Pass 1 complete (50% total progress).\n');
// ══ PASS 2 ════════════════════════════════════════════════════════════════ // ══ PASS 2 ════════════════════════════════════════════════════════════════
const audioArgs = buildAudioArgs(variables); const audioArgs = buildAudioArgs(variables);
@@ -193,6 +427,9 @@ const plugin = async (args) => {
} }
jobLog('[transcode2PassX265] Starting Pass 2...\n'); jobLog('[transcode2PassX265] Starting Pass 2...\n');
pass2UpdateWorker({ percentage: 0 });
pass2UpdateProgress({ progress: 0 });
const pass2Cli = new CLI({ const pass2Cli = new CLI({
cli: args.ffmpegPath, cli: args.ffmpegPath,
spawnArgs: [ spawnArgs: [
@@ -200,28 +437,44 @@ const plugin = async (args) => {
'-i', inputFile, '-i', inputFile,
'-map', '0:v:0', '-map', '0:v:0',
...audioArgs, ...audioArgs,
'-map_chapters', '0', '-map_metadata', '-1',
'-map_chapters', '-1',
'-metadata', `title=${baseName}`,
'-disposition:v:0', 'default',
...videoArgs, ...videoArgs,
'-x265-params', `${x265ParamsBase}:pass=2`, '-x265-params', `pass=2:${x265ParamsBase}`,
'-sn', '-sn',
outFile, outFile,
], ],
spawnOpts: { cwd: workDir },
WorkDir: workDir, WorkDir: workDir,
jobLog, jobLog,
outputFilePath: outFile,
inputFileObj, inputFileObj,
updateWorker: pass2UpdateWorker,
logFullCliOutput: args.logFullCliOutput || false,
args: { ...args, updateWorker: pass2UpdateWorker, updateProgress: pass2UpdateProgress },
}); });
const { cliExitCode: pass2Exit } = await pass2Cli.runCli(); const { cliExitCode: pass2Exit } = await pass2Cli.runCli();
if (pass2Exit !== 0) { if (pass2Exit !== 0) {
throw new Error(`[transcode2PassX265] Pass 2 failed with exit code ${pass2Exit}`); throw new Error(`[transcode2PassX265] Pass 2 failed with exit code ${pass2Exit}`);
} }
jobLog('[transcode2PassX265] Pass 2 complete.\n'); pass2UpdateWorker({ percentage: 100 });
pass2UpdateProgress({ progress: 100 });
jobLog('[transcode2PassX265] Pass 2 complete (100% total progress).\n');
// ── Cleanup 2-pass log files ────────────────────────────────────────────── // ── Cleanup 2-pass log files and temporary input copy ────────────────────
[statsFile, `${statsFile}.mbtree`, `${statsFile}.cutree`].forEach((f) => { [statsFile, `${statsFile}.mbtree`, `${statsFile}.cutree`].forEach((f) => {
try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch (_) { /* ignore */ } try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch (_) { /* ignore */ }
}); });
jobLog('[transcode2PassX265] Temp log files cleaned up.\n'); if (variables.workDirCopyApplied === 'true' && fs.existsSync(inputFile)) {
try {
fs.unlinkSync(inputFile);
jobLog(`[transcode2PassX265] Cleaned up temporary workDir copy: ${path.basename(inputFile)}\n`);
} catch (_) { /* ignore */ }
}
jobLog('[transcode2PassX265] Temp files cleaned up.\n');
// ── Return transcoded file for Tdarr to manage ──────────────────────────── // ── Return transcoded file for Tdarr to manage ────────────────────────────
jobLog(`[transcode2PassX265] Done. Output: ${outFile}\n`); jobLog(`[transcode2PassX265] Done. Output: ${outFile}\n`);
@@ -234,3 +487,4 @@ const plugin = async (args) => {
module.exports.details = details; module.exports.details = details;
module.exports.plugin = plugin; module.exports.plugin = plugin;
module.exports.scalePassPercentage = scalePassPercentage;
+136 -31
View File
@@ -6,46 +6,106 @@ A collection of [Tdarr](https://tdarr.io/) Flow plugins for automated media tran
--- ---
## ⚡ Quick Install via curl ## ⚡ Quick Install / Update via curl
### Full Suite (all 7 plugins) ### Recommended — `install.sh` (no ZIP, no clone)
The repo includes an [`install.sh`](install.sh) script that downloads only the `index.js`
files you need directly from Gitea raw URLs.
**Update all plugins at once:**
```bash ```bash
# Replace vX.Y.Z with the desired release tag, or use "latest" curl -fsSL https://git.cdtech.freeddns.org/PickleRick/Tdarr-plugin/raw/branch/main/install.sh \
VERSION=latest | bash -s -- /your/tdarr/plugins/FlowPlugins
PLUGIN_DIR=/path/to/tdarr/plugins/FlowPlugins
curl -L https://git.cdtech.freeddns.org/PickleRick/Tdarr-plugin/releases/download/${VERSION}/Tdarr-plugins-${VERSION}.zip \
-o tdarr-plugins.zip
unzip -o tdarr-plugins.zip -d "${PLUGIN_DIR}"
rm tdarr-plugins.zip
``` ```
After extracting, restart the Tdarr server so it picks up the new plugins: By default, the installer downloads only the latest version of every plugin.
Use `--all` to download every version retained in the repository:
```bash
# Latest versions of all plugins
./install.sh /your/tdarr/plugins/FlowPlugins --latest
# All available versions of all plugins
./install.sh /your/tdarr/plugins/FlowPlugins --all
# All available versions of selected plugins
./install.sh /your/tdarr/plugins/FlowPlugins --all replaceOriginalFile copyToWorkDir
```
**Update a single plugin** (e.g. after a bug fix):
```bash
curl -fsSL https://git.cdtech.freeddns.org/PickleRick/Tdarr-plugin/raw/branch/main/install.sh \
| bash -s -- /your/tdarr/plugins/FlowPlugins transcode2PassX265
```
**Update two specific plugins:**
```bash
curl -fsSL https://git.cdtech.freeddns.org/PickleRick/Tdarr-plugin/raw/branch/main/install.sh \
| bash -s -- /your/tdarr/plugins/FlowPlugins transcode2PassX265 extractSubtitlesAndOcr
```
Then restart Tdarr:
```bash ```bash
docker restart tdarr-server docker restart tdarr-server
``` ```
### Individual Plugin ---
Each plugin is also released as its own ZIP (e.g. `transcode2PassX265-latest.zip`): ### Alternative — Full ZIP (first install)
```bash ```bash
PLUGIN=transcode2PassX265 PLUGIN_DIR=/path/to/tdarr/plugins/FlowPlugins
VERSION=latest
DEST=/path/to/tdarr/plugins/FlowPlugins/LocalFlowPlugins/video/${PLUGIN}/1.0.0
mkdir -p "${DEST}" curl -fsSL https://git.cdtech.freeddns.org/PickleRick/Tdarr-plugin/archive/main.zip \
curl -L https://git.cdtech.freeddns.org/PickleRick/Tdarr-plugin/releases/download/${VERSION}/${PLUGIN}-${VERSION}.zip \ -o /tmp/tdarr-plugins.zip
-o ${PLUGIN}.zip unzip -q -o /tmp/tdarr-plugins.zip -d /tmp/tdarr-extract
unzip -o ${PLUGIN}.zip -d "${DEST}" cp -r /tmp/tdarr-extract/Tdarr-plugin/LocalFlowPlugins "${PLUGIN_DIR}/"
rm ${PLUGIN}.zip cp -r /tmp/tdarr-extract/Tdarr-plugin/FlowHelpers "${PLUGIN_DIR}/"
rm -rf /tmp/tdarr-plugins.zip /tmp/tdarr-extract
docker restart tdarr-server
``` ```
Available plugin names: `mediaAnalyzerGOP`, `setAnimationFlag`, `extractChaptersXml`, ### Alternative — Single raw file
`audioSelectorClamping`, `backupSourceAudio`, `extractSubtitlesAndOcr`, `transcode2PassX265`
```bash
BASE=https://git.cdtech.freeddns.org/PickleRick/Tdarr-plugin/raw/branch/main
PLUGIN=transcode2PassX265
CATEGORY=video
DEST=/path/to/tdarr/plugins/FlowPlugins/LocalFlowPlugins/${CATEGORY}/${PLUGIN}/1.0.0
mkdir -p "${DEST}"
curl -fsSL "${BASE}/LocalFlowPlugins/${CATEGORY}/${PLUGIN}/1.0.0/index.js" -o "${DEST}/index.js"
```
The progress-enabled `copyToWorkDir/1.0.0` and `replaceOriginalFile/1.0.0`
plugins are self-contained and require no external helpers (immune to Tdarr updates).
| Plugin | Category |
|---|---|
| `mediaAnalyzerGOP` | `utility` |
| `setAnimationFlag` | `utility` |
| `extractChaptersXml` | `utility` |
| `finalizeMedia` | `utility` |
| `copyToWorkDir` | `utility` |
| `replaceOriginalFile` | `utility` |
| `audioSelectorClamping` | `audio` |
| `backupSourceAudio` | `audio` |
| `extractSubtitlesAndOcr` | `subtitles` |
| `transcode2PassX265` | `video` |
### Tagged Releases (versioned ZIPs)
Once a release is published by the CI workflow, versioned ZIPs are also available:
```bash
VERSION=v1.0.1
curl -L https://git.cdtech.freeddns.org/PickleRick/Tdarr-plugin/releases/download/${VERSION}/Tdarr-plugins-${VERSION}.zip \
-o tdarr-plugins.zip
unzip -o tdarr-plugins.zip -d "${PLUGIN_DIR}"
rm tdarr-plugins.zip
```
--- ---
@@ -56,7 +116,10 @@ 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
│ ├── copyToWorkDir/1.0.0/index.js
│ └── replaceOriginalFile/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
@@ -87,7 +150,7 @@ Reads video stream metadata and populates `args.variables` for all downstream pl
**Outputs**: `1` → continue **Outputs**: `1` → continue
**Variables set**: `gopKeyint`, `gopMinKeyint`, `isAnimation`, `isMovie`, `vBitrate`, `vMaxRate`, `vBufSize`, `x265Tune`, `videoHeight`, `videoFps` **Variables set**: `gopKeyint` (4 s max GOP), `gopMinKeyint` (1 s), `gopSceneCut` (40), `isAnimation`, `isMovie`, `vBitrate`, `vMaxRate`, `vBufSize`, `x265Tune`, `videoHeight`, `videoFps`
--- ---
@@ -110,7 +173,7 @@ Manually forces `isAnimation=true` and `x265Tune=animation` when a file was not
**Category**: `utility` · **Step**: before transcode **Category**: `utility` · **Step**: before transcode
Runs `mkvextract` to dump chapter markers to an XML file in `workDir`. Stores the path in `variables.chaptersXmlPath` for downstream use. Safe to run even when the source has no chapters (mkvextract exit 1 is treated as "no chapters"). Runs `mkvextract` to dump chapter markers to an XML file **next to the original source file**. Stores the path in `variables.chaptersXmlPath` for downstream use. Safe to run even when the source has no chapters (mkvextract exit 1 is treated as "no chapters").
**No inputs required.** **No inputs required.**
@@ -167,6 +230,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. |
@@ -180,11 +247,11 @@ 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`.
- **Video**: bound-1080p Lanczos scaling (no letterbox, even dimensions) - **Video**: HEVC already within 1080p and strictly below 3000 kbps is copied; all other video is bound-1080p Lanczos scaled and x265-encoded (no letterbox, even dimensions)
- **Audio**: Opus re-encode with per-track indexed bitrate (ITA default, ENG secondary) - **Audio**: Opus re-encode with per-track indexed bitrate (ITA default, ENG secondary)
- **Subtitles**: stripped (`-sn`) - **Subtitles**: stripped (`-sn`)
- **Chapters**: preserved (`-map_chapters 0`) - **Chapters**: preserved (`-map_chapters 0`)
@@ -194,17 +261,55 @@ Performs 2-pass libx265 encoding using VBV parameters from `args.variables`.
|---|---|---| |---|---|---|
| `x265Preset` | `slow` | x265 preset (`ultrafast` … `veryslow`). Slower = better quality. | | `x265Preset` | `slow` | x265 preset (`ultrafast` … `veryslow`). Slower = better quality. |
| `outputContainer` | `mkv` | Output file extension | | `outputContainer` | `mkv` | Output file extension |
| `copyHevcBelowKbps` | `3000` | Copy compatible HEVC video below this bitrate instead of re-encoding it. `0` disables copying. |
**Outputs**: `1` → transcode complete **Outputs**: `1` → transcode complete
**Reads from variables**: `gopKeyint`, `gopMinKeyint`, `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. `copyToWorkDir` — Copy Source to Work Dir (Progress)
**Category**: `utility` · **Step**: before transcode
Streams the source file to the worker's local `workDir`, verifies the byte count,
reports throttled copy progress, and retries failed writes twice after removing
the partial destination. Its `Copy method` uses the operating system's native file-copy path, reports interval speed,
verifies the destination size, and retries failed writes. It stores the
original library path in
`variables.originalLibraryFile` for `replaceOriginalFile`.
### 9. `replaceOriginalFile` — Replace Original File
**Category**: `utility` · **Step**: after transcode
The `1.0.0` version performs the same verified, atomic replacement as the
progress version but does not emit transfer progress callbacks or progress logs.
It is useful when the standard Tdarr replacement behavior is desired without
additional progress reporting.
### 10. `replaceOriginalFile` — Replace Original File (Progress)
**Category**: `utility` · **Step**: after transcode
Streams the processed file to a temporary file beside the original, verifies its
size, and retries failed or incomplete writes twice before giving up. It then
atomically replaces the original. An existing original is moved to `.partial.old`
during the operation and restored if replacement fails. The processed work file
is deleted after a successful replacement.
### 11. `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
``` ```
mediaAnalyzerGOP ──► gopKeyint, gopMinKeyint, vBitrate, vMaxRate, vBufSize mediaAnalyzerGOP ──► gopKeyint, gopMinKeyint, gopSceneCut, vBitrate, vMaxRate, vBufSize
──► isAnimation, isMovie, x265Tune, videoHeight, videoFps ──► isAnimation, isMovie, x265Tune, videoHeight, videoFps
│ │
(optional) setAnimationFlag ──► overrides vBitrate, vMaxRate, vBufSize, x265Tune (optional) setAnimationFlag ──► overrides vBitrate, vMaxRate, vBufSize, x265Tune
+32 -3
View File
@@ -1,4 +1,4 @@
# Tdarr Local Flow Plugins — Integration Guide # Tdarr Local Flow Plugins — Integration Guide
## Panoramica ## Panoramica
@@ -14,7 +14,10 @@ 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
│ ├── copyToWorkDir/1.0.0/index.js
│ └── replaceOriginalFile/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 +70,11 @@ 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` e conserva il
percorso originale in `variables.originalLibraryFile`. Il plugin
trasmette in tempo reale il progresso della copia (percentuale, velocità, ETA)
alla WebUI e ai log del worker, verificando la dimensione finale.
### 3.3 Configurazione PATH Windows ### 3.3 Configurazione PATH Windows
Aggiungi al PATH di sistema: Aggiungi al PATH di sistema:
@@ -81,6 +89,8 @@ Aggiungi al PATH di sistema:
### 4.1 Ordine dei nodi ### 4.1 Ordine dei nodi
``` ```
[copyToWorkDir] (consigliato per librerie di rete)
▼
[mediaAnalyzerGOP] [mediaAnalyzerGOP]
│ │
│ (Facoltativo — solo per anime non rilevati automaticamente) │ (Facoltativo — solo per anime non rilevati automaticamente)
@@ -104,8 +114,15 @@ Aggiungi al PATH di sistema:
▼ ▼
[transcode2PassX265] [transcode2PassX265]
▼ ▼
[Fine] [replaceOriginalFile]
▼
[finalizeMedia]
``` ```
`copyToWorkDir` è facoltativo ma consigliato quando la libreria sorgente si trova su una condivisione di rete (NAS/SMB).
Posizionalo prima dell'analisi/transcodifica: registra il percorso originale della libreria per `replaceOriginalFile`.
Entrambe le operazioni di copia/sostituzione trasmettono la percentuale di avanzamento alla WebUI di Tdarr tramite il worker callback,
I plugin integrano direttamente il motore di copia con progresso in tempo reale (`copyWithProgress`), risultando autonomi e immuni agli aggiornamenti di Tdarr.
### 4.2 Connessioni nel Canvas ### 4.2 Connessioni nel Canvas
@@ -155,6 +172,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)
@@ -165,6 +192,7 @@ Questa tabella mostra tutte le variabili che i plugin si passano a vicenda:
|---|---|---| |---|---|---|
| `gopKeyint` | mediaAnalyzerGOP | transcode2PassX265 | | `gopKeyint` | mediaAnalyzerGOP | transcode2PassX265 |
| `gopMinKeyint` | mediaAnalyzerGOP | transcode2PassX265 | | `gopMinKeyint` | mediaAnalyzerGOP | transcode2PassX265 |
| `gopSceneCut` | mediaAnalyzerGOP | transcode2PassX265 |
| `isAnimation` | mediaAnalyzerGOP / setAnimationFlag | audioSelectorClamping | | `isAnimation` | mediaAnalyzerGOP / setAnimationFlag | audioSelectorClamping |
| `isMovie` | mediaAnalyzerGOP | audioSelectorClamping | | `isMovie` | mediaAnalyzerGOP | audioSelectorClamping |
| `vBitrate` | mediaAnalyzerGOP / setAnimationFlag | transcode2PassX265 | | `vBitrate` | mediaAnalyzerGOP / setAnimationFlag | transcode2PassX265 |
@@ -198,6 +226,7 @@ Per ogni file sorgente `/media/toshiba/Radarr/Film (2023)/Film (2023).mkv`:
├── Film (2023).eng.srt ← estratto da extractSubtitlesAndOcr ├── Film (2023).eng.srt ← estratto da extractSubtitlesAndOcr
├── Film (2023).ita.forced.srt ← (se traccia forced presente) ├── Film (2023).ita.forced.srt ← (se traccia forced presente)
├── Film (2023).eng.sdh.srt ← (se traccia SDH presente) ├── Film (2023).eng.sdh.srt ← (se traccia SDH presente)
├── Film (2023)_chapters.xml ← estratto da extractChaptersXml
└── Film (2023).ita.dts ← backup (solo se codec DTS) └── Film (2023).ita.dts ← backup (solo se codec DTS)
o Film (2023).ita.thd ← backup (solo se codec TrueHD) o Film (2023).ita.thd ← backup (solo se codec TrueHD)
``` ```
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# install.sh — Update Tdarr plugins from Gitea raw file URLs.
#
# Usage:
# ./install.sh <PLUGIN_DIR> # latest version of ALL plugins
# ./install.sh <PLUGIN_DIR> --latest # latest version of ALL plugins
# ./install.sh <PLUGIN_DIR> --all # every available plugin version
# ./install.sh <PLUGIN_DIR> --all p1 p2 # every version of selected plugins
# ./install.sh <PLUGIN_DIR> --latest p1 p2 # latest version of selected plugins
#
# Or pipe directly from Gitea (no clone needed):
# curl -fsSL https://git.cdtech.freeddns.org/PickleRick/Tdarr-plugin/raw/branch/main/install.sh \
# | bash -s -- /your/tdarr/plugins/FlowPlugins
#
# Environment overrides:
# GITEA_URL — base URL of the Gitea instance (default below)
# GITEA_BRANCH — branch to pull from (default: main)
set -euo pipefail
# ── Config ────────────────────────────────────────────────────────────────────
GITEA_URL="${GITEA_URL:-https://git.cdtech.freeddns.org}"
GITEA_REPO="PickleRick/Tdarr-plugin"
BRANCH="${GITEA_BRANCH:-main}"
RAW="${GITEA_URL}/${GITEA_REPO}/raw/branch/${BRANCH}"
# Map: plugin-name → category
declare -A PLUGIN_CATEGORY=(
[mediaAnalyzerGOP]=utility
[setAnimationFlag]=utility
[extractChaptersXml]=utility
[finalizeMedia]=utility
[copyToWorkDir]=utility
[replaceOriginalFile]=utility
[audioSelectorClamping]=audio
[backupSourceAudio]=audio
[extractSubtitlesAndOcr]=subtitles
[transcode2PassX265]=video
)
# ── Args ──────────────────────────────────────────────────────────────────────
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <PLUGIN_DIR> [plugin1 plugin2 ...]"
echo ""
echo " PLUGIN_DIR — path that contains LocalFlowPlugins/"
echo " (e.g. /docker/tdarr/data/Tdarr/Plugins/FlowPlugins)"
exit 1
fi
PLUGIN_DIR="$1"
shift
# Version mode defaults to the latest version only.
VERSION_MODE="latest"
if [[ "${1:-}" == "--all" || "${1:-}" == "--latest" ]]; then
VERSION_MODE="${1#--}"
shift
fi
# If no specific plugins requested, update all.
if [[ $# -eq 0 ]]; then
TARGETS=("${!PLUGIN_CATEGORY[@]}")
else
TARGETS=("$@")
fi
# ── Download helper ───────────────────────────────────────────────────────────
download_plugin() {
local name="$1"
local category="${PLUGIN_CATEGORY[$name]:-}"
if [[ -z "$category" ]]; then
echo " ✗ Unknown plugin '${name}' — skipping."
return 1
fi
local versions="1.0.0"
for version in $versions; do
local dest="${PLUGIN_DIR}/LocalFlowPlugins/${category}/${name}/${version}"
local url="${RAW}/LocalFlowPlugins/${category}/${name}/${version}/index.js"
mkdir -p "$dest"
if curl -fsSL "$url" -o "${dest}/index.js"; then
echo " ✓ ${name} ${version} → ${dest}/index.js"
else
echo " ✗ Failed to download ${name} ${version} from ${url}"
return 1
fi
done
}
download_flow_helpers() {
local dest="${PLUGIN_DIR}/FlowHelpers/1.0.0"
mkdir -p "$dest"
# Restore official Tdarr fileUtils.js if it was previously overwritten by a mock
local official_fu="https://raw.githubusercontent.com/HaveAGitGat/Tdarr_Plugins/master/FlowPlugins/FlowHelpers/1.0.0/fileUtils.js"
if [[ -f "${dest}/fileUtils.js" ]]; then
if grep -q "Local mock of Tdarr" "${dest}/fileUtils.js" || ! grep -q "getFileAbsoluteDir" "${dest}/fileUtils.js"; then
echo " 🔄 Restoring official Tdarr fileUtils.js..."
curl -fsSL "$official_fu" -o "${dest}/fileUtils.js" || true
fi
else
curl -fsSL "$official_fu" -o "${dest}/fileUtils.js" || true
fi
# Restore official Tdarr cliUtils.js if it was previously overwritten by a mock
local official_cu="https://raw.githubusercontent.com/HaveAGitGat/Tdarr_Plugins/master/FlowPlugins/FlowHelpers/1.0.0/cliUtils.js"
if [[ -f "${dest}/cliUtils.js" ]]; then
if grep -q "Local mock of Tdarr" "${dest}/cliUtils.js" || ! grep -q "ffmpegParser" "${dest}/cliUtils.js"; then
echo " 🔄 Restoring official Tdarr cliUtils.js..."
curl -fsSL "$official_cu" -o "${dest}/cliUtils.js" || true
fi
else
curl -fsSL "$official_cu" -o "${dest}/cliUtils.js" || true
fi
# Download our custom copyWithProgress helper
local url="${RAW}/FlowHelpers/1.0.0/copyWithProgress.js"
if curl -fsSL "$url" -o "${dest}/copyWithProgress.js"; then
echo " ✓ FlowHelper copyWithProgress.js → ${dest}/copyWithProgress.js"
else
echo " ✗ Failed to download copyWithProgress.js from ${url}"
return 1
fi
}
clean_deprecated_plugins() {
for deprecated in copyToWorkDir replaceOriginalFile; do
local dep_dir="${PLUGIN_DIR}/LocalFlowPlugins/utility/${deprecated}/2.0.0"
if [[ -d "$dep_dir" ]]; then
rm -rf "$dep_dir"
echo " 🗑️ Removed deprecated ${deprecated} 2.0.0"
fi
done
}
# ── Run ───────────────────────────────────────────────────────────────────────
echo "=== Tdarr Plugin Updater ==="
echo " Source : ${GITEA_URL}/${GITEA_REPO} (branch: ${BRANCH})"
echo " Target : ${PLUGIN_DIR}"
echo ""
ERRORS=0
clean_deprecated_plugins
for plugin in "${TARGETS[@]}"; do
download_plugin "$plugin" || ERRORS=$((ERRORS + 1))
done
download_flow_helpers || ERRORS=$((ERRORS + 1))
echo ""
if [[ $ERRORS -eq 0 ]]; then
echo "✅ Done. Restart Tdarr server to pick up changes:"
echo " docker restart tdarr-server"
else
echo "⚠️ Done with ${ERRORS} error(s). Check output above."
exit 1
fi
+138 -9
View File
@@ -1,4 +1,4 @@
'use strict'; 'use strict';
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const fs = require('fs'); const fs = require('fs');
@@ -52,7 +52,9 @@ const AUDIO_INPUTS = {
{ const a = makeArgs('/media/toshiba/Radarr/Film (2023)/Film.mkv', { const a = makeArgs('/media/toshiba/Radarr/Film (2023)/Film.mkv',
[makeStream({ r_frame_rate:'24000/1001', height:1080 })], ANALYZER_INPUTS); [makeStream({ r_frame_rate:'24000/1001', height:1080 })], ANALYZER_INPUTS);
const o = await analyzerPlugin(a); const o = await analyzerPlugin(a);
eq(o.variables.gopKeyint, '48', '23.976fps -> keyint=48'); eq(o.variables.gopKeyint, '96', '23.976fps -> keyint=96 (4 seconds)');
eq(o.variables.gopMinKeyint, '24', '23.976fps -> min-keyint=24 (1 second)');
eq(o.variables.gopSceneCut, '40', 'scene-cut threshold is 40');
eq(o.variables.isMovie, 'true', '/Radarr -> isMovie=true'); eq(o.variables.isMovie, 'true', '/Radarr -> isMovie=true');
eq(o.variables.isAnimation, 'false', 'no anime -> isAnimation=false'); eq(o.variables.isAnimation, 'false', 'no anime -> isAnimation=false');
eq(o.variables.vBitrate, '3000k', 'film bitrate=3000k'); eq(o.variables.vBitrate, '3000k', 'film bitrate=3000k');
@@ -64,7 +66,7 @@ const AUDIO_INPUTS = {
{ const a = makeArgs('/media/toshiba/Sonarr/Naruto S01/S01E01.mkv', { const a = makeArgs('/media/toshiba/Sonarr/Naruto S01/S01E01.mkv',
[makeStream({ r_frame_rate:'25/1', height:720 })], ANALYZER_INPUTS); [makeStream({ r_frame_rate:'25/1', height:720 })], ANALYZER_INPUTS);
const o = await analyzerPlugin(a); const o = await analyzerPlugin(a);
eq(o.variables.gopKeyint, '50', '25fps -> keyint=50'); eq(o.variables.gopKeyint, '100', '25fps -> keyint=100 (4 seconds)');
eq(o.variables.isMovie, 'false', '/Sonarr -> isMovie=false'); eq(o.variables.isMovie, 'false', '/Sonarr -> isMovie=false');
eq(o.variables.isAnimation, 'true', 'Naruto -> isAnimation=true'); eq(o.variables.isAnimation, 'true', 'Naruto -> isAnimation=true');
eq(o.variables.x265Tune, 'animation', 'anime -> tune=animation'); eq(o.variables.x265Tune, 'animation', 'anime -> tune=animation');
@@ -74,7 +76,7 @@ const AUDIO_INPUTS = {
{ const a = makeArgs('/media/toshiba/Sonarr/Naruto S01/S01E01.mkv', { const a = makeArgs('/media/toshiba/Sonarr/Naruto S01/S01E01.mkv',
[makeStream({ r_frame_rate:'30000/1001', height:480 })], ANALYZER_INPUTS); [makeStream({ r_frame_rate:'30000/1001', height:480 })], ANALYZER_INPUTS);
const o = await analyzerPlugin(a); const o = await analyzerPlugin(a);
eq(o.variables.gopKeyint, '60', '29.97fps -> keyint=60'); eq(o.variables.gopKeyint, '120', '29.97fps -> keyint=120 (4 seconds)');
eq(o.variables.vBitrate, '1000k', 'anime SD 480p -> 1000k'); eq(o.variables.vBitrate, '1000k', 'anime SD 480p -> 1000k');
cleanup(a); } cleanup(a); }
@@ -85,6 +87,40 @@ const AUDIO_INPUTS = {
eq(o.variables.isMovie, 'true', 'Windows backslash path: /Radarr still matched'); eq(o.variables.isMovie, 'true', 'Windows backslash path: /Radarr still matched');
cleanup(a); } cleanup(a); }
// Family Guy (space in anime list, dots in folder & file name)
{ const a = makeArgs('/media/toshiba/Sonarr/Family.Guy.1999 [tvdbid-75978]/Family.Guy.S00/Family.Guy.S00E64.mkv',
[makeStream({ r_frame_rate:'24000/1001', height:1080 })],
Object.assign({}, ANALYZER_INPUTS, { animeSeries: 'Simpson|Griffin|Family Guy|South Park' }));
const o = await analyzerPlugin(a);
eq(o.variables.isAnimation, 'true', 'Family Guy (dots vs spaces in name): isAnimation=true');
eq(o.variables.x265Tune, 'animation', 'Family Guy: x265Tune=animation');
eq(o.variables.vBitrate, '2000k', 'Family Guy 1080p: anime HD bitrate=2000k');
cleanup(a); }
// Anime detected via variables.originalLibraryFile when workDir copy is input
{ const a = makeArgs('C:\\workDir\\Family.Guy.S00E64_old.mkv',
[makeStream({ r_frame_rate:'24000/1001', height:1080 })],
Object.assign({}, ANALYZER_INPUTS, { animeSeries: 'Family Guy' }),
{ originalLibraryFile: '/media/toshiba/Sonarr/Family.Guy.1999/Family.Guy.S00/Family.Guy.S00E64.mkv' });
const o = await analyzerPlugin(a);
eq(o.variables.isAnimation, 'true', 'Anime detected via originalLibraryFile in variables');
cleanup(a); }
// Accents and apostrophe handling in anime list
{ const a = makeArgs('/media/toshiba/Sonarr/Pokemon.S01E01.mkv',
[makeStream({ r_frame_rate:'24/1', height:720 })],
Object.assign({}, ANALYZER_INPUTS, { animeSeries: 'Pokémon' }));
const o = await analyzerPlugin(a);
eq(o.variables.isAnimation, 'true', 'Accented title "Pokémon" matches "Pokemon.S01E01.mkv"');
cleanup(a); }
{ const a = makeArgs('/media/toshiba/Sonarr/Bobs.Burgers.S01E01.mkv',
[makeStream({ r_frame_rate:'24/1', height:720 })],
Object.assign({}, ANALYZER_INPUTS, { animeSeries: "Bob's Burgers" }));
const o = await analyzerPlugin(a);
eq(o.variables.isAnimation, 'true', 'Apostrophe title "Bob\'s Burgers" matches "Bobs.Burgers"');
cleanup(a); }
// ── audioSelectorClamping ───────────────────────────────────────────────── // ── audioSelectorClamping ─────────────────────────────────────────────────
console.log('\n── audioSelectorClamping ────────────────────────────'); console.log('\n── audioSelectorClamping ────────────────────────────');
@@ -125,22 +161,53 @@ const AUDIO_INPUTS = {
eq(o.variables.audioItaOpusBitrate, '384', 'Series DTS 768k >= 384 -> Opus=384'); eq(o.variables.audioItaOpusBitrate, '384', 'Series DTS 768k >= 384 -> Opus=384');
cleanup(a); } cleanup(a); }
// Series AC3 320k < 384 threshold -> 256 // Series AC3 320k < 384 target -> 320 (strict no upcoding)
{ const streams = [ { const streams = [
makeStream({ index:0, codec_type:'video' }), makeStream({ index:0, codec_type:'video' }),
makeStream({ index:1, codec_type:'audio', codec_name:'ac3', bit_rate:'320000', channels:6, tags:{language:'ita'} }), makeStream({ index:1, codec_type:'audio', codec_name:'ac3', bit_rate:'320000', channels:6, tags:{language:'ita'} }),
]; ];
const a = makeArgs('/media/Sonarr/Show/ep.mkv', streams, AUDIO_INPUTS, { isMovie:'false' }); const a = makeArgs('/media/Sonarr/Show/ep.mkv', streams, AUDIO_INPUTS, { isMovie:'false', isAnimation:'false' });
const o = await audioPlugin(a); const o = await audioPlugin(a);
eq(o.variables.audioItaOpusBitrate, '256', 'Series AC3 320k < 384 -> Opus=256'); eq(o.variables.audioItaOpusBitrate, '320', 'Series AC3 320k -> min(384,320)=320 (no upcoding)');
cleanup(a); } cleanup(a); }
// NEW: Series bitrate unknown (0) -> safe default 384k // Series multichannel low bitrate 192k -> 192 (strict no upcoding to 256)
{ const streams = [
makeStream({ index:0, codec_type:'video' }),
makeStream({ index:1, codec_type:'audio', codec_name:'aac', bit_rate:'192000', channels:6, tags:{language:'ita'} }),
];
const a = makeArgs('/media/Sonarr/Show/ep.mkv', streams, AUDIO_INPUTS, { isMovie:'false', isAnimation:'false' });
const o = await audioPlugin(a);
eq(o.variables.audioItaOpusBitrate, '192', 'Series 192k multichannel -> stays 192k (never upcoded to 256)');
cleanup(a); }
// Series / Animation with 256k threshold -> capped at 256k
{ const streams = [
makeStream({ index:0, codec_type:'video' }),
makeStream({ index:1, codec_type:'audio', codec_name:'ac3', bit_rate:'448000', channels:6, tags:{language:'ita'} }),
];
const customInputs = Object.assign({}, AUDIO_INPUTS, { seriesMultiChannelThreshold: '256' });
const a = makeArgs('/media/Sonarr/Naruto/ep.mkv', streams, customInputs, { isMovie:'false' });
const o = await audioPlugin(a);
eq(o.variables.audioItaOpusBitrate, '256', 'Animation 448k multichannel with 256k threshold -> capped at 256k');
cleanup(a); }
// Detection via tags.BPS when stream.bit_rate is 0
{ const streams = [
makeStream({ index:0, codec_type:'video' }),
makeStream({ index:1, codec_type:'audio', codec_name:'ac3', bit_rate:'0', channels:6, tags:{language:'ita', BPS:'384000'} }),
];
const a = makeArgs('/media/Sonarr/Show/ep.mkv', streams, AUDIO_INPUTS, { isMovie:'false', isAnimation:'false' });
const o = await audioPlugin(a);
eq(o.variables.audioItaOpusBitrate, '384', 'Bitrate detected from tags.BPS -> 384k');
cleanup(a); }
// Series bitrate unknown (0) and no BPS tag -> safe default 384k
{ const streams = [ { const streams = [
makeStream({ index:0, codec_type:'video' }), makeStream({ index:0, codec_type:'video' }),
makeStream({ index:1, codec_type:'audio', codec_name:'dts', bit_rate:'0', channels:6, tags:{language:'ita'} }), makeStream({ index:1, codec_type:'audio', codec_name:'dts', bit_rate:'0', channels:6, tags:{language:'ita'} }),
]; ];
const a = makeArgs('/media/Sonarr/Show/ep.mkv', streams, AUDIO_INPUTS, { isMovie:'false' }); const a = makeArgs('/media/Sonarr/Show/ep.mkv', streams, AUDIO_INPUTS, { isMovie:'false', isAnimation:'false' });
const o = await audioPlugin(a); const o = await audioPlugin(a);
eq(o.variables.audioItaOpusBitrate, '384', 'Series unknown bitrate (0) -> safe default 384k'); eq(o.variables.audioItaOpusBitrate, '384', 'Series unknown bitrate (0) -> safe default 384k');
cleanup(a); } cleanup(a); }
@@ -197,6 +264,68 @@ const AUDIO_INPUTS = {
ok(transcodeCode.includes('`-b:a:${audioTrackOut}`'), 'buildAudioArgs uses indexed -b:a:N (not global -b:a)'); ok(transcodeCode.includes('`-b:a:${audioTrackOut}`'), 'buildAudioArgs uses indexed -b:a:N (not global -b:a)');
ok(transcodeCode.includes('[...mapArgs, ...codecArgs]'), 'buildAudioArgs: maps first, then codec settings'); ok(transcodeCode.includes('[...mapArgs, ...codecArgs]'), 'buildAudioArgs: maps first, then codec settings');
ok(!transcodeCode.includes("'-c:a', 'libopus'"), 'no unindexed -c:a in buildAudioArgs'); ok(!transcodeCode.includes("'-c:a', 'libopus'"), 'no unindexed -c:a in buildAudioArgs');
ok(transcodeCode.includes('bitrateKbps < copyHevcBelowKbps'), 'HEVC copy requires bitrate strictly below the limit');
ok(transcodeCode.includes("copyHevcBelowKbps || '3000'"), 'HEVC copy limit defaults to 3000 kbps');
ok(transcodeCode.includes("'-c:v', 'copy'"), 'compatible HEVC video uses stream copy');
ok(transcodeCode.includes("normalizedKey === 'bps' || normalizedKey === 'bps-eng'"), 'HEVC copy check falls back to Matroska BPS metadata');
ok(transcodeCode.includes('containerBitrate - otherStreamBitrate'), 'HEVC copy check estimates video bitrate from container and companion streams');
ok(!transcodeCode.includes("'-map_metadata -1'"), 'pass 1 separates -map_metadata from its value');
ok(transcodeCode.includes('const statsForX265 = path.basename(statsFile)'), 'x265 stats file is relative to workDir on Windows');
const { scalePassPercentage } = require(path.join(ROOT, 'LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js'));
eq(scalePassPercentage(1, 0), 0, 'pass 1 at 0% maps to 0%');
eq(scalePassPercentage(1, 50), 25, 'pass 1 at 50% maps to 25%');
eq(scalePassPercentage(1, 100), 50, 'pass 1 at 100% maps to 50%');
eq(scalePassPercentage(2, 0), 50, 'pass 2 at 0% maps to 50%');
eq(scalePassPercentage(2, 50), 75, 'pass 2 at 50% maps to 75%');
eq(scalePassPercentage(2, 100), 100, 'pass 2 at 100% maps to 100%');
ok(transcodeCode.includes('scalePassPercentage(1,'), 'pass 1 wraps updateWorker with 50% scale');
ok(transcodeCode.includes('scalePassPercentage(2,'), 'pass 2 wraps updateWorker with 50% scale');
ok(transcodeCode.includes("displayFile = (variables.originalLibraryFile || inputFile).replace(/\\\\/g, '/')"), 'transcode2PassX265 normalizes displayFile with forward slashes');
ok(transcodeCode.includes('file: displayFile'), 'transcode2PassX265 passes displayFile to updateWorker');
ok(transcodeCode.includes("'-metadata', `title=${baseName}`"), 'transcode2PassX265 restores metadata title tag in output MKV');
ok(transcodeCode.includes("outFile = path.join(workDir, `${baseName}.${container}`).replace(/\\\\/g, '/')"), 'transcode2PassX265 normalizes outFile with forward slashes');
// Verify WebUI worker title logic: `t.file ? (e = t.file).includes("/") ? e.split("/").pop() : "" : ""`
{
const sampleWorkerFile = 'C:/media/toshiba/Sonarr/Family.Guy.1999/Family.Guy.S00/Family.Guy.S00E64.mkv';
const webUiTitle = sampleWorkerFile ? (sampleWorkerFile.includes('/') ? sampleWorkerFile.split('/').pop() : '') : '';
eq(webUiTitle, 'Family.Guy.S00E64.mkv', 'WebUI title extract works with forward slashes');
const sampleWindowsFile = 'C:\\media\\toshiba\\Sonarr\\Family.Guy.1999\\Family.Guy.S00\\Family.Guy.S00E64.mkv';
const brokenWebUiTitle = sampleWindowsFile ? (sampleWindowsFile.includes('/') ? sampleWindowsFile.split('/').pop() : '') : '';
eq(brokenWebUiTitle, '', 'Confirms WebUI bug: Windows backslashes produce empty title');
}
// ── copyToWorkDir & replaceOriginalFile v1.0.0 checks ─────────────────────
const copyPluginCode = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js'), 'utf8');
ok(copyPluginCode.includes('copyWithProgress'), 'copyToWorkDir v1 uses copyWithProgress');
ok(copyPluginCode.includes('updateWorker'), 'copyToWorkDir v1 forwards updateWorker callback');
ok(!fs.existsSync(path.join(ROOT, 'LocalFlowPlugins/utility/copyToWorkDir/2.0.0')), 'copyToWorkDir v2.0.0 is deleted');
const replacePluginCode = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js'), 'utf8');
ok(replacePluginCode.includes('copyWithProgress'), 'replaceOriginalFile v1 uses copyWithProgress');
ok(replacePluginCode.includes('updateWorker'), 'replaceOriginalFile v1 forwards updateWorker callback');
ok(replacePluginCode.includes('.partial.old'), 'replaceOriginalFile v1 uses a rollback backup');
ok(replacePluginCode.includes('fs.renameSync(temporary, destination)'), 'replaceOriginalFile v1 atomically installs the verified copy');
ok(!fs.existsSync(path.join(ROOT, 'LocalFlowPlugins/utility/replaceOriginalFile/2.0.0')), 'replaceOriginalFile v2.0.0 is deleted');
const copyHelperCode = require('fs').readFileSync(path.join(ROOT, 'FlowHelpers/1.0.0/copyWithProgress.js'), 'utf8');
ok(copyHelperCode.includes('MAX_RETRIES = 2'), 'copy helper retries failed writes');
ok(copyHelperCode.includes('Copy size mismatch'), 'copy helper verifies destination size');
ok(copyHelperCode.includes('fs.unlinkSync(destination)'), 'copy helper removes partial copies before retrying');
ok(copyHelperCode.includes('formatEta'), 'copy helper formats ETA');
ok(copyHelperCode.includes('updateWorker'), 'copy helper updates Tdarr worker progress');
ok(copyHelperCode.includes('PROGRESS_INTERVAL_MS = 1000'), 'copy helper reports progress every 1 second (1000ms)');
// ── Subtitle default naming checks ─────────────────────────────────────────
console.log('\n── Subtitle default naming rules ────────────────────');
const subPluginCode = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js'), 'utf8');
ok(subPluginCode.includes("lang === 'ita' && isForced"), 'extractSubtitlesAndOcr: only ita.forced gets default flag');
ok(!subPluginCode.includes("if (isForced || (stream.disposition && stream.disposition.default))"), 'extractSubtitlesAndOcr: old buggy default condition removed');
const finalizeCode = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/finalizeMedia/1.0.0/index.js'), 'utf8');
ok(finalizeCode.includes("value === 'default'"), 'finalizeMedia: strips default before checking for ita.forced');
ok(finalizeCode.includes("isItaForced ? '.default' : ''"), 'finalizeMedia: expected subtitle sidecar only expects .default on ita.forced');
// ── Summary ─────────────────────────────────────────────────────────────── // ── Summary ───────────────────────────────────────────────────────────────
console.log(`\n${'='.repeat(55)}`); console.log(`\n${'='.repeat(55)}`);
+2 -1
View File
@@ -1,4 +1,4 @@
'use strict'; 'use strict';
/** /**
* run-tests.js * run-tests.js
@@ -17,6 +17,7 @@ const tests = [
{ name: 'Test 2 — Anime 25fps DTS ITA (Naruto)', file: './test-cases/test2-anime-25fps', heavy: false }, { name: 'Test 2 — Anime 25fps DTS ITA (Naruto)', file: './test-cases/test2-anime-25fps', heavy: false },
{ name: 'Test 3 — Ultrawide 3840x1600 Scale', file: './test-cases/test3-ultrawide', heavy: true }, { name: 'Test 3 — Ultrawide 3840x1600 Scale', file: './test-cases/test3-ultrawide', heavy: true },
{ name: 'Test 4 — Subtitle Extraction (forced + SDH)', file: './test-cases/test4-subtitles', heavy: false }, { name: 'Test 4 — Subtitle Extraction (forced + SDH)', file: './test-cases/test4-subtitles', heavy: false },
{ name: 'Test 5 — Copy/Replace Progress & Subtitle Rules', file: './test-cases/test-copy-and-subtitles', heavy: false },
]; ];
async function main() { async function main() {
+304
View File
@@ -0,0 +1,304 @@
'use strict';
const path = require('path');
const fs = require('fs');
const os = require('os');
const { copyWithProgress } = require('../../FlowHelpers/1.0.0/copyWithProgress');
const { plugin: copyPlugin } = require('../../LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index');
const { plugin: replacePlugin } = require('../../LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index');
const { plugin: finalizePlugin } = require('../../LocalFlowPlugins/utility/finalizeMedia/1.0.0/index');
const assert = (condition, message) => {
if (!condition) throw new Error(`FAILED: ${message}`);
console.log(` [OK] ${message}`);
};
async function testCopyAndSubtitles() {
console.log('\n=== Testing copyWithProgress, copyToWorkDir, replaceOriginalFile & subtitle naming ===\n');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tdarr-test-copy-'));
try {
// 1. Test copyWithProgress with mock worker updates
const srcFile = path.join(tmpDir, 'source.bin');
const dstFile = path.join(tmpDir, 'dest.bin');
const dummyData = Buffer.alloc(5 * 1024 * 1024, 0xAB); // 5 MB
fs.writeFileSync(srcFile, dummyData);
const workerUpdates = [];
const jobLogs = [];
await copyWithProgress({
source: srcFile,
destination: dstFile,
jobLog: (msg) => jobLogs.push(msg),
updateWorker: (data) => workerUpdates.push(data),
label: 'testCopy',
});
assert(fs.existsSync(dstFile), 'Destination file created');
assert(fs.statSync(dstFile).size === dummyData.length, 'Destination file size matches source');
assert(workerUpdates.length > 0, 'Worker updates were received');
assert(workerUpdates[workerUpdates.length - 1].percentage === 100, 'Final worker percentage is 100');
assert(jobLogs.some((l) => l.includes('Done:')), 'jobLog recorded completion');
// 2. Test copyToWorkDir plugin
const workDir = path.join(tmpDir, 'workdir');
fs.mkdirSync(workDir);
const copyArgs = {
inputFileObj: { _id: srcFile },
variables: {},
workDir,
inputs: { skipIfLocal: 'true' },
jobLog: () => {},
updateWorker: (data) => workerUpdates.push(data),
};
const copyResult = await copyPlugin(copyArgs);
assert(copyResult.outputNumber === 1, 'copyToWorkDir returned output 1');
assert(fs.existsSync(copyResult.outputFileObj._id), 'Local copy file exists in workDir');
assert(copyResult.variables.originalLibraryFile === srcFile, 'originalLibraryFile saved in variables');
assert(copyResult.variables.workDirCopyApplied === 'true', 'workDirCopyApplied marked true');
// 3. Test replaceOriginalFile plugin
const processedFile = path.join(workDir, 'source_processed.bin');
const processedData = Buffer.alloc(3 * 1024 * 1024, 0xCD); // 3 MB
fs.writeFileSync(processedFile, processedData);
const replaceArgs = {
inputFileObj: { _id: processedFile },
variables: { originalLibraryFile: srcFile },
workDir,
inputs: { deleteSource: 'true' },
jobLog: () => {},
updateWorker: (data) => workerUpdates.push(data),
};
const replaceResult = await replacePlugin(replaceArgs);
assert(replaceResult.outputNumber === 1, 'replaceOriginalFile returned output 1');
assert(fs.existsSync(srcFile), 'Original library file exists at destination');
assert(fs.statSync(srcFile).size === processedData.length, 'Original file successfully replaced with processed file');
assert(!fs.existsSync(processedFile), 'Processed work file deleted from workDir');
assert(!fs.existsSync(`${srcFile}.partial.old`), 'Temporary backup deleted');
assert(!fs.existsSync(`${srcFile}.tdarr.tmp`), 'Temporary file deleted');
// 4. Test finalizeMedia subtitle renaming & default stripping
const libraryDir = path.join(tmpDir, 'library');
fs.mkdirSync(libraryDir);
const mediaFile = path.join(libraryDir, 'Movie.mkv');
fs.writeFileSync(mediaFile, Buffer.alloc(100));
// Create a variety of subtitles:
// - eng.forced.default.srt (default should be stripped -> eng.forced.srt)
// - ita.forced.srt (default should be added -> ita.forced.default.srt)
// - ita.forced.default.srt (already correct -> stays ita.forced.default.srt)
// - ita.default.srt (default should be stripped -> ita.srt)
// - eng.default.srt (default should be stripped -> eng.srt)
// - eng.sdh.default.srt (default should be stripped -> eng.sdh.srt)
fs.writeFileSync(path.join(libraryDir, 'Movie.eng.forced.default.srt'), 'dummy 1');
fs.writeFileSync(path.join(libraryDir, 'Movie.ita.forced.srt'), 'dummy 2');
fs.writeFileSync(path.join(libraryDir, 'Movie.ita.default.srt'), 'dummy 3');
fs.writeFileSync(path.join(libraryDir, 'Movie.eng.default.srt'), 'dummy 4');
fs.writeFileSync(path.join(libraryDir, 'Movie.eng.sdh.default.srt'), 'dummy 5');
const finalizeArgs = {
inputFileObj: {
_id: mediaFile,
ffProbeData: { streams: [] },
},
variables: { originalLibraryDir: libraryDir },
inputs: { removeEmptySourceDir: 'false' },
jobLog: () => {},
};
await finalizePlugin(finalizeArgs);
assert(!fs.existsSync(path.join(libraryDir, 'Movie.eng.forced.default.srt')), 'eng.forced.default.srt was renamed');
assert(fs.existsSync(path.join(libraryDir, 'Movie.eng.forced.srt')), 'eng.forced.srt exists (default stripped)');
assert(!fs.existsSync(path.join(libraryDir, 'Movie.ita.forced.srt')), 'ita.forced.srt was normalized');
assert(fs.existsSync(path.join(libraryDir, 'Movie.ita.forced.default.srt')), 'ita.forced.default.srt exists');
assert(!fs.existsSync(path.join(libraryDir, 'Movie.ita.default.srt')), 'ita.default.srt was renamed');
assert(fs.existsSync(path.join(libraryDir, 'Movie.ita.srt')), 'ita.srt exists (default stripped)');
assert(!fs.existsSync(path.join(libraryDir, 'Movie.eng.default.srt')), 'eng.default.srt was renamed');
assert(fs.existsSync(path.join(libraryDir, 'Movie.eng.srt')), 'eng.srt exists (default stripped)');
assert(!fs.existsSync(path.join(libraryDir, 'Movie.eng.sdh.default.srt')), 'eng.sdh.default.srt was renamed');
assert(fs.existsSync(path.join(libraryDir, 'Movie.eng.sdh.srt')), 'eng.sdh.srt exists (default stripped)');
// 5. Test TV Series: Complete Folder-to-Folder Workflow (Sonarr -> Serie)
const sonarrRoot = path.join(tmpDir, 'Sonarr');
const seriesSourceDir = path.join(sonarrRoot, 'Halo.2022 [tvdbid-12345]', 'After.Life.S01');
fs.mkdirSync(seriesSourceDir, { recursive: true });
const serieOutputRoot = path.join(tmpDir, 'Serie');
// Create Episode 1 source files in Sonarr
const ep1SourceFile = path.join(seriesSourceDir, 'Halo.S01E01.mkv');
const ep1SubFile = path.join(seriesSourceDir, 'Halo.S01E01.ita.forced.srt');
const ep1ChapFile = path.join(seriesSourceDir, 'Halo.S01E01_chapters.xml');
fs.writeFileSync(ep1SourceFile, Buffer.alloc(100));
fs.writeFileSync(ep1SubFile, 'ep1 sub');
fs.writeFileSync(ep1ChapFile, '<chapters ep1/>');
// Create Episode 2 source files in Sonarr (still waiting to be processed)
const ep2SourceFile = path.join(seriesSourceDir, 'Halo.S01E02.mkv');
const ep2SubFile = path.join(seriesSourceDir, 'Halo.S01E02.eng.forced.default.srt');
const ep2ChapFile = path.join(seriesSourceDir, 'Halo.S01E02_chapters.xml');
fs.writeFileSync(ep2SourceFile, Buffer.alloc(100));
fs.writeFileSync(ep2SubFile, 'ep2 sub');
fs.writeFileSync(ep2ChapFile, '<chapters ep2/>');
// Ep1 encoded file in worker workDir
const ep1WorkFile = path.join(workDir, 'Halo.S01E01_encoded.mkv');
fs.writeFileSync(ep1WorkFile, Buffer.alloc(200));
// A. Run replaceOriginalFile for Ep1 with folder-to-folder
const ep1ReplaceResult = await replacePlugin({
inputFileObj: { _id: ep1WorkFile },
variables: { originalLibraryFile: ep1SourceFile, originalLibraryDir: seriesSourceDir },
workDir,
librarySettings: { folder: sonarrRoot, output: serieOutputRoot },
inputs: { deleteSource: 'true' },
jobLog: () => {},
updateWorker: () => {},
});
assert(fs.existsSync(ep1ReplaceResult.outputFileObj._id), 'Ep1 installed in Serie destination library');
assert(ep1ReplaceResult.outputFileObj._id.includes(path.join('Serie', 'Halo.2022 [tvdbid-12345]', 'After.Life.S01')), 'Ep1 relative season subfolder preserved');
assert(!fs.existsSync(ep1SourceFile), 'Ep1 source video unlinked from Sonarr intake');
assert(fs.existsSync(ep2SourceFile), 'Ep2 source video untouched in Sonarr intake');
assert(fs.existsSync(ep1SubFile), 'Ep1 subtitle sidecar still in Sonarr before finalize');
assert(fs.existsSync(ep2SubFile), 'Ep2 subtitle sidecar untouched in Sonarr');
// B. Run finalizeMedia for Ep1
const ep1FinalDir = path.dirname(ep1ReplaceResult.outputFileObj._id);
await finalizePlugin({
inputFileObj: {
_id: ep1ReplaceResult.outputFileObj._id,
ffProbeData: { streams: [] },
},
variables: ep1ReplaceResult.variables,
librarySettings: { folder: sonarrRoot, output: serieOutputRoot },
inputs: { removeEmptySourceDir: 'true', removeEmptyParentSeriesDir: 'true' },
jobLog: () => {},
});
// Check Ep1 sidecars migrated and normalized in Serie
assert(fs.existsSync(path.join(ep1FinalDir, 'Halo.S01E01.ita.forced.default.srt')), 'Ep1 subtitle moved to Serie and normalized with .default');
assert(fs.existsSync(path.join(ep1FinalDir, 'Halo.S01E01_chapters.xml')), 'Ep1 chapters moved to Serie');
assert(!fs.existsSync(ep1SubFile), 'Ep1 subtitle removed from Sonarr sourceDir');
assert(!fs.existsSync(ep1ChapFile), 'Ep1 chapters removed from Sonarr sourceDir');
// Check Ep2 sidecars in Sonarr were completely untouched
assert(fs.existsSync(ep2SubFile), 'Ep2 subtitle untouched in Sonarr sourceDir');
assert(fs.existsSync(ep2ChapFile), 'Ep2 chapters untouched in Sonarr sourceDir');
// Season folder in Sonarr preserved because Ep2 is still there
assert(fs.existsSync(seriesSourceDir), 'Sonarr season folder preserved while Ep2 remains');
// C. Now process Episode 2
const ep2WorkFile = path.join(workDir, 'Halo.S01E02_encoded.mkv');
fs.writeFileSync(ep2WorkFile, Buffer.alloc(200));
const ep2ReplaceResult = await replacePlugin({
inputFileObj: { _id: ep2WorkFile },
variables: { originalLibraryFile: ep2SourceFile, originalLibraryDir: seriesSourceDir },
workDir,
librarySettings: { folder: sonarrRoot, output: serieOutputRoot },
inputs: { deleteSource: 'true' },
jobLog: () => {},
updateWorker: () => {},
});
assert(fs.existsSync(ep2ReplaceResult.outputFileObj._id), 'Ep2 installed in Serie destination library');
assert(!fs.existsSync(ep2SourceFile), 'Ep2 source video unlinked from Sonarr');
const ep2FinalDir = path.dirname(ep2ReplaceResult.outputFileObj._id);
await finalizePlugin({
inputFileObj: {
_id: ep2ReplaceResult.outputFileObj._id,
ffProbeData: { streams: [] },
},
variables: ep2ReplaceResult.variables,
librarySettings: { folder: sonarrRoot, output: serieOutputRoot },
inputs: { removeEmptySourceDir: 'true', removeEmptyParentSeriesDir: 'true' },
jobLog: () => {},
});
// Ep2 sidecars normalized in Serie (default stripped for eng)
assert(fs.existsSync(path.join(ep2FinalDir, 'Halo.S01E02.eng.forced.srt')), 'Ep2 subtitle moved to Serie and default stripped');
assert(!fs.existsSync(path.join(ep2FinalDir, 'Halo.S01E02.eng.forced.default.srt')), 'Ep2 old subtitle removed');
assert(fs.existsSync(path.join(ep2FinalDir, 'Halo.S01E02_chapters.xml')), 'Ep2 chapters moved to Serie');
// Season 01 and parent Halo directory removed from Sonarr, but sonarrRoot preserved!
assert(!fs.existsSync(seriesSourceDir), 'Empty Season 01 directory removed from Sonarr');
assert(!fs.existsSync(path.join(sonarrRoot, 'Halo.2022 [tvdbid-12345]')), 'Empty Series parent directory removed from Sonarr');
assert(fs.existsSync(sonarrRoot), 'Sonarr library root preserved');
// 6. Test Movie: Complete Folder-to-Folder Workflow (Radarr -> Film)
const radarrRoot = path.join(tmpDir, 'Radarr');
const movieSourceDir = path.join(radarrRoot, 'Avatar.2009');
fs.mkdirSync(movieSourceDir, { recursive: true });
const filmOutputRoot = path.join(tmpDir, 'Film');
const movieSourceFile = path.join(movieSourceDir, 'Avatar.2009.mkv');
const movieSubFile = path.join(movieSourceDir, 'Avatar.2009.ita.forced.srt');
const movieChapFile = path.join(movieSourceDir, 'Avatar.2009_chapters.xml');
fs.writeFileSync(movieSourceFile, Buffer.alloc(100));
fs.writeFileSync(movieSubFile, 'movie sub');
fs.writeFileSync(movieChapFile, '<chapters movie/>');
const movieWorkFile = path.join(workDir, 'Avatar.2009_encoded.mkv');
fs.writeFileSync(movieWorkFile, Buffer.alloc(200));
const movieReplaceResult = await replacePlugin({
inputFileObj: { _id: movieWorkFile },
variables: { originalLibraryFile: movieSourceFile, originalLibraryDir: movieSourceDir },
workDir,
librarySettings: { folder: radarrRoot, output: filmOutputRoot },
inputs: { deleteSource: 'true' },
jobLog: () => {},
updateWorker: () => {},
});
assert(fs.existsSync(movieReplaceResult.outputFileObj._id), 'Movie installed in Film library');
assert(movieReplaceResult.outputFileObj._id.includes(path.join('Film', 'Avatar.2009')), 'Movie subfolder preserved in Film');
assert(!fs.existsSync(movieSourceFile), 'Movie source video unlinked from Radarr');
const movieFinalDir = path.dirname(movieReplaceResult.outputFileObj._id);
await finalizePlugin({
inputFileObj: {
_id: movieReplaceResult.outputFileObj._id,
ffProbeData: { streams: [] },
},
variables: movieReplaceResult.variables,
librarySettings: { folder: radarrRoot, output: filmOutputRoot },
inputs: { removeEmptySourceDir: 'true', removeEmptyParentSeriesDir: 'true' },
jobLog: () => {},
});
assert(fs.existsSync(path.join(movieFinalDir, 'Avatar.2009.ita.forced.default.srt')), 'Movie subtitle moved to Film and normalized');
assert(fs.existsSync(path.join(movieFinalDir, 'Avatar.2009_chapters.xml')), 'Movie chapters moved to Film');
assert(!fs.existsSync(movieSourceDir), 'Empty movie directory removed from Radarr');
assert(fs.existsSync(radarrRoot), 'Radarr library root preserved');
console.log('\n=== ALL COPY, MOVE, SUBTITLE & SERIES TESTS PASSED ===\n');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
module.exports = testCopyAndSubtitles;
if (require.main === module) {
testCopyAndSubtitles().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});
}
+5 -2
View File
@@ -6,7 +6,8 @@
* File: Radarr path, AC3 ITA 448k 5.1, EAC3 ENG 640k 5.1 * File: Radarr path, AC3 ITA 448k 5.1, EAC3 ENG 640k 5.1
* *
* Expected: * Expected:
* gopKeyint = "48" * gopKeyint = "96"
* gopMinKeyint = "24"
* isMovie = "true" * isMovie = "true"
* isAnimation = "false" * isAnimation = "false"
* vBitrate = "3000k" * vBitrate = "3000k"
@@ -44,7 +45,9 @@ async function run() {
const vars = out1.variables; const vars = out1.variables;
assertEqual(out1.outputNumber, 1, 'outputNumber'); assertEqual(out1.outputNumber, 1, 'outputNumber');
assertEqual(vars.gopKeyint, '48', 'gopKeyint (2 x 23.976 = 47.95 -> 48)'); assertEqual(vars.gopKeyint, '96', 'gopKeyint (4 x 23.976 = 95.90 -> 96)');
assertEqual(vars.gopMinKeyint,'24', 'gopMinKeyint (1 x 23.976 = 23.98 -> 24)');
assertEqual(vars.gopSceneCut, '40', 'gopSceneCut (x265 default threshold)');
assertEqual(vars.isMovie, 'true', 'isMovie (Radarr path)'); assertEqual(vars.isMovie, 'true', 'isMovie (Radarr path)');
assertEqual(vars.isAnimation, 'false', 'isAnimation (no anime match)'); assertEqual(vars.isAnimation, 'false', 'isAnimation (no anime match)');
assertEqual(vars.vBitrate, '3000k', 'vBitrate (film default)'); assertEqual(vars.vBitrate, '3000k', 'vBitrate (film default)');
+5 -2
View File
@@ -6,7 +6,8 @@
* Note: DTS detection logic is fully covered in unit tests (quick-logic-test.js). * Note: DTS detection logic is fully covered in unit tests (quick-logic-test.js).
* *
* Expected: * Expected:
* gopKeyint = "50" * gopKeyint = "100"
* gopMinKeyint = "25"
* isAnimation = "true" * isAnimation = "true"
* isMovie = "false" * isMovie = "false"
* x265Tune = "animation" * x265Tune = "animation"
@@ -40,7 +41,9 @@ async function run() {
}); });
const out1 = await analyzerPlugin(args1); const out1 = await analyzerPlugin(args1);
const vars = out1.variables; const vars = out1.variables;
assertEqual(vars.gopKeyint, '50', 'gopKeyint (2 x 25 = 50)'); assertEqual(vars.gopKeyint, '100', 'gopKeyint (4 x 25 = 100)');
assertEqual(vars.gopMinKeyint,'25', 'gopMinKeyint (1 x 25 = 25)');
assertEqual(vars.gopSceneCut, '40', 'gopSceneCut (x265 default threshold)');
assertEqual(vars.isMovie, 'false', 'isMovie=false (Sonarr path)'); assertEqual(vars.isMovie, 'false', 'isMovie=false (Sonarr path)');
assertEqual(vars.isAnimation, 'true', 'isAnimation=true (Naruto in path)'); assertEqual(vars.isAnimation, 'true', 'isAnimation=true (Naruto in path)');
assertEqual(vars.x265Tune, 'animation', 'x265Tune=animation'); assertEqual(vars.x265Tune, 'animation', 'x265Tune=animation');
+2 -2
View File
@@ -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));