Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ea83ae566 | ||
|
|
c46e3dfdb3 | ||
|
|
5fdc5a6781 | ||
|
|
70f065a3a2 | ||
|
|
2daa75df60 | ||
|
|
88ecf9a847 | ||
|
|
f2e483ca1f | ||
|
|
07b730c81f | ||
|
|
3d809c6862 | ||
|
|
a05b84f046 | ||
|
|
30fb556af7 | ||
|
|
86448988ed | ||
|
|
7ee5a5bc48 | ||
|
|
b85759e27c | ||
|
|
68166ad7eb | ||
|
|
594889ea10 | ||
|
|
febd68973b | ||
|
|
2cf22ef628 | ||
|
|
880ec5bc6d | ||
|
|
5d43ba6a26 | ||
|
|
a8c4a81141 | ||
|
|
3f0b38d513 | ||
|
|
36184fbf32 | ||
|
|
0c8b713325 | ||
|
|
ee5fc99820 | ||
|
|
2a39da6d08 | ||
|
|
9b0ed4677d | ||
|
|
95df925de7 | ||
|
|
1cdd771045 | ||
|
|
27fee1ee95 | ||
|
|
5447c1cbc7 | ||
|
|
c604acfe69 | ||
|
|
3b7f660058 | ||
|
|
303c3b9fd3 | ||
|
|
d70a931591 | ||
|
|
7f90b366eb | ||
|
|
778d6e51ed |
No files matched your search
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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 };
|
||||||
|
|
||||||
|
|
||||||
@@ -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
@@ -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,22 +8,24 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const TEXT_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', 'webvtt', 'mov_text']);
|
const TEXT_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', 'webvtt', 'mov_text']);
|
||||||
const BITMAP_CODECS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'pgssub', 'dvbsub']);
|
const BITMAP_CODECS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'pgssub', 'dvbsub']);
|
||||||
|
|
||||||
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);
|
||||||
// ── Text-based subtitles ──────────────────────────────────────────────
|
const isBitmap = BITMAP_CODECS.has(codec);
|
||||||
if (TEXT_CODECS.has(codec)) {
|
if (isText || isBitmap) {
|
||||||
const { cliExitCode } = await runFFmpeg(
|
const existing = taskByOutput.get(outSrt);
|
||||||
['-y', '-i', inputFile, '-map', `0:${stream.index}`, '-c:s', 'srt', outSrt],
|
if (!existing || (isText && existing.type === 'bitmap')) {
|
||||||
workDir, args.ffmpegPath, jobLog, inputFileObj,
|
taskByOutput.set(outSrt, { ...task, type: isText ? 'text' : 'bitmap' });
|
||||||
);
|
|
||||||
if (cliExitCode !== 0) {
|
|
||||||
jobLog(`[extractSubtitlesAndOcr] WARNING: FFmpeg failed for stream #${stream.index} (exit ${cliExitCode}).\n`);
|
|
||||||
} else {
|
|
||||||
jobLog(`[extractSubtitlesAndOcr] Extracted: ${path.basename(outSrt)}\n`);
|
|
||||||
}
|
}
|
||||||
continue;
|
} else {
|
||||||
|
jobLog(`[extractSubtitlesAndOcr] Stream #${stream.index} codec "${codec}" not supported — skipping.\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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
|
for (const task of taskByOutput.values()) {
|
||||||
const { cliExitCode: extractExit } = await runFFmpeg(
|
if (task.type === 'text') textTasks.push(task);
|
||||||
['-y', '-i', inputFile, '-map', `0:${stream.index}`, '-c:s', 'copy', tmpSup],
|
else bitmapTasks.push(task);
|
||||||
workDir, args.ffmpegPath, jobLog, inputFileObj,
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (extractExit !== 0) {
|
// ── 1. Batch extract text-based subtitles (mkvextract or single-pass FFmpeg)
|
||||||
jobLog(`[extractSubtitlesAndOcr] WARNING: .sup extraction failed for stream #${stream.index}.\n`);
|
if (textTasks.length > 0) {
|
||||||
continue;
|
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`);
|
||||||
|
});
|
||||||
|
|
||||||
// Step 2: OCR with SubtitleEdit
|
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 {
|
try {
|
||||||
const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils');
|
const mkvArgs = ['tracks', inputFile];
|
||||||
const seCli = new CLI({
|
textTasks.forEach((t) => mkvArgs.push(`${t.stream.index}:${t.outSrt}`));
|
||||||
cli: subtitleEditPath,
|
const { cliExitCode } = await runCLI(mkvExtract, mkvArgs, args);
|
||||||
spawnArgs: [
|
if (cliExitCode === 0) {
|
||||||
'/convert', tmpSup, 'subrip',
|
extracted = true;
|
||||||
'/track-number:0',
|
jobLog('[extractSubtitlesAndOcr] mkvextract completed successfully.\n');
|
||||||
`/outputfolder:${sourceDir}`,
|
|
||||||
],
|
|
||||||
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
|
jobLog(`[extractSubtitlesAndOcr] mkvextract exited with code ${cliExitCode}, falling back to FFmpeg.\n`);
|
||||||
const seOut = path.join(sourceDir, path.basename(tmpSup, '.sup') + '.srt');
|
|
||||||
if (fs.existsSync(seOut)) {
|
|
||||||
fs.renameSync(seOut, outSrt);
|
|
||||||
jobLog(`[extractSubtitlesAndOcr] OCR complete: ${path.basename(outSrt)}\n`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit not available or crashed: ${err.message}\n`);
|
jobLog(`[extractSubtitlesAndOcr] mkvextract error (${err.message}), falling back to FFmpeg.\n`);
|
||||||
} finally {
|
|
||||||
// Clean up temp .sup
|
|
||||||
try { fs.unlinkSync(tmpSup); } catch (_) { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jobLog(`[extractSubtitlesAndOcr] Stream #${stream.index} codec "${codec}" not supported — skipping.\n`);
|
// Fallback: single-pass FFmpeg (-vn -an for fast demuxing without video/audio decoding)
|
||||||
|
if (!extracted) {
|
||||||
|
const ffmpegArgs = ['-y', '-i', inputFile, '-vn', '-an'];
|
||||||
|
textTasks.forEach((t, i) => {
|
||||||
|
ffmpegArgs.push('-map', `0:${t.stream.index}`, `-c:s:${i}`, 'srt', t.outSrt);
|
||||||
|
});
|
||||||
|
const { cliExitCode } = await runCLI(args.ffmpegPath, ffmpegArgs, args);
|
||||||
|
if (cliExitCode === 0) {
|
||||||
|
jobLog('[extractSubtitlesAndOcr] FFmpeg text extraction completed successfully.\n');
|
||||||
|
} else {
|
||||||
|
jobLog(`[extractSubtitlesAndOcr] WARNING: FFmpeg extraction failed with exit code ${cliExitCode}.\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Process bitmap subtitles (PGS / VOBSUB) with SubtitleEdit OCR ─────
|
||||||
|
for (const t of bitmapTasks) {
|
||||||
|
const tmpSup = path.join(workDir, `${baseName}_s${t.stream.index}.sup`);
|
||||||
|
jobLog(`[extractSubtitlesAndOcr] Extracting bitmap stream #${t.stream.index} (${t.codec}) for OCR...\n`);
|
||||||
|
|
||||||
|
// Extract to .sup using mkvextract if mkv, else ffmpeg
|
||||||
|
let extractedSup = false;
|
||||||
|
const isMkv = inputFile.toLowerCase().endsWith('.mkv') || (inputFileObj.container === 'mkv');
|
||||||
|
if (isMkv) {
|
||||||
|
const mkvExtract = getMkvExtract(args);
|
||||||
|
const { cliExitCode } = await runCLI(mkvExtract, ['tracks', inputFile, `${t.stream.index}:${tmpSup}`], args);
|
||||||
|
if (cliExitCode === 0) extractedSup = true;
|
||||||
|
}
|
||||||
|
if (!extractedSup) {
|
||||||
|
const { cliExitCode } = await runCLI(
|
||||||
|
args.ffmpegPath,
|
||||||
|
['-y', '-i', inputFile, '-vn', '-an', '-map', `0:${t.stream.index}`, '-c:s', 'copy', tmpSup],
|
||||||
|
args,
|
||||||
|
);
|
||||||
|
if (cliExitCode !== 0) {
|
||||||
|
jobLog(`[extractSubtitlesAndOcr] WARNING: .sup extraction failed for stream #${t.stream.index}.\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run OCR with SubtitleEdit CLI
|
||||||
|
try {
|
||||||
|
const seCli = await runCLI(
|
||||||
|
subtitleEditPath,
|
||||||
|
['/convert', tmpSup, 'subrip', '/track-number:0', `/outputfolder:${targetDir}`],
|
||||||
|
args,
|
||||||
|
);
|
||||||
|
if (seCli.cliExitCode !== 0) {
|
||||||
|
jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit OCR failed (exit ${seCli.cliExitCode}) for stream #${t.stream.index}.\n`);
|
||||||
|
} else {
|
||||||
|
const seOut = path.join(targetDir, path.basename(tmpSup, '.sup') + '.srt');
|
||||||
|
if (fs.existsSync(seOut)) {
|
||||||
|
fs.renameSync(seOut, t.outSrt);
|
||||||
|
jobLog(`[extractSubtitlesAndOcr] OCR complete: ${path.basename(t.outSrt)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit not available: ${err.message}\n`);
|
||||||
|
} finally {
|
||||||
|
try { if (fs.existsSync(tmpSup)) fs.unlinkSync(tmpSup); } catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jobLog('[extractSubtitlesAndOcr] Done.\n');
|
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 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 { 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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -103,9 +113,16 @@ Aggiungi al PATH di sistema:
|
|||||||
[backupSourceAudio]
|
[backupSourceAudio]
|
||||||
▼
|
▼
|
||||||
[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
@@ -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
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* test4-subtitles.js
|
* test4-subtitles.js
|
||||||
* Validates: extractSubtitlesAndOcr produces correctly named SRT files.
|
* Validates: extractSubtitlesAndOcr produces correctly named SRT files.
|
||||||
* Source: MKV with 2 ITA SRT streams (forced + SDH).
|
* Source: MKV with 2 ITA SRT streams (forced + SDH).
|
||||||
* Expected: .ita.forced.srt and .ita.hearing_impaired.srt in source dir.
|
* Expected: .ita.forced.default.srt and .ita.sdh.srt in source dir.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -27,7 +27,7 @@ async function run() {
|
|||||||
await subPlugin(args);
|
await subPlugin(args);
|
||||||
|
|
||||||
// Check that forced SRT was created
|
// Check that forced SRT was created
|
||||||
const forcedSrt = path.join(SOURCE_DIR, `${BASE_NAME}.ita.forced.srt`);
|
const forcedSrt = path.join(SOURCE_DIR, `${BASE_NAME}.ita.forced.default.srt`);
|
||||||
const sdhSrt = path.join(SOURCE_DIR, `${BASE_NAME}.ita.sdh.srt`);
|
const sdhSrt = path.join(SOURCE_DIR, `${BASE_NAME}.ita.sdh.srt`);
|
||||||
|
|
||||||
console.log(' Checking for:', path.basename(forcedSrt));
|
console.log(' Checking for:', path.basename(forcedSrt));
|
||||||
|
|||||||
Reference in new issue
Block a user