diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..35e2130 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release Plugins + +on: + push: + branches: + - main + tags: + - '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: + release: + name: Package & Release + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine release tag + id: tag + run: | + 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 + + - name: Create plugin ZIPs + run: | + mkdir -p dist + + # Full suite ZIP (all LocalFlowPlugins + FlowHelpers) + zip -r dist/Tdarr-plugins-${{ steps.tag.outputs.tag }}.zip \ + LocalFlowPlugins/ \ + FlowHelpers/ \ + -x "*.git*" + + # Individual plugin ZIPs + for dir in LocalFlowPlugins/*/*/1.0.0; do + plugin_name=$(echo "$dir" | awk -F'/' '{print $(NF-1)}') + zip -j "dist/${plugin_name}-${{ steps.tag.outputs.tag }}.zip" "$dir/index.js" + done + + ls -lh dist/ + + - name: Delete existing "latest" release (if re-releasing) + if: steps.tag.outputs.tag == 'latest' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release delete latest --yes --cleanup-tag 2>/dev/null || true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: "Tdarr Plugins ${{ steps.tag.outputs.tag }}" + body: | + ## Tdarr Local Flow Plugin Suite — ${{ steps.tag.outputs.tag }} + + ### Installation (curl) + + Download the full suite: + ```bash + 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`): + ```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. + files: dist/*.zip + prerelease: ${{ steps.tag.outputs.tag == 'latest' }} + make_latest: ${{ steps.tag.outputs.tag != 'latest' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/FlowHelpers/1.0.0/cliUtils.js b/FlowHelpers/1.0.0/cliUtils.js index 9fecec6..80146a2 100644 --- a/FlowHelpers/1.0.0/cliUtils.js +++ b/FlowHelpers/1.0.0/cliUtils.js @@ -1,7 +1,11 @@ -'use strict'; +'use strict'; /** * Local mock of Tdarr FlowHelpers/1.0.0/cliUtils.js * Used only for local testing — on real Tdarr this file comes from the server. + * + * Matches the real Tdarr CLI constructor signature: + * new CLI({ cli, spawnArgs, WorkDir, jobLog, inputFileObj }) + * inputFileObj is accepted but unused here (only used by real Tdarr for progress tracking). */ const { spawn } = require('child_process'); @@ -40,3 +44,4 @@ class CLI { const getFFmpegVar = () => {}; module.exports = { CLI, getFFmpegVar }; + diff --git a/LocalFlowPlugins/audio/backupSourceAudio/1.0.0/index.js b/LocalFlowPlugins/audio/backupSourceAudio/1.0.0/index.js index 4ac0580..8eca5b3 100644 --- a/LocalFlowPlugins/audio/backupSourceAudio/1.0.0/index.js +++ b/LocalFlowPlugins/audio/backupSourceAudio/1.0.0/index.js @@ -1,4 +1,4 @@ -'use strict'; +'use strict'; const path = require('path'); const fs = require('fs'); @@ -76,6 +76,7 @@ const plugin = async (args) => { ], WorkDir: workDir, jobLog, + inputFileObj, }); const { cliExitCode } = await cli.runCli(); diff --git a/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js b/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js index 1ee4d2f..d384c29 100644 --- a/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js +++ b/LocalFlowPlugins/subtitles/extractSubtitlesAndOcr/1.0.0/index.js @@ -1,4 +1,4 @@ -'use strict'; +'use strict'; const path = require('path'); const fs = require('fs'); @@ -79,9 +79,11 @@ const buildSuffix = (lang, stream) => { return parts.join('.'); }; -const runFFmpeg = async (spawnArgs, workDir, ffmpegPath, jobLog) => { +const runFFmpeg = async (spawnArgs, workDir, ffmpegPath, jobLog, inputFileObj) => { const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils'); - const cli = new CLI({ cli: ffmpegPath, spawnArgs, WorkDir: workDir, jobLog }); + const cli = new CLI({ + cli: ffmpegPath, spawnArgs, WorkDir: workDir, jobLog, inputFileObj, + }); return cli.runCli(); }; @@ -127,7 +129,7 @@ const plugin = async (args) => { if (TEXT_CODECS.has(codec)) { const { cliExitCode } = await runFFmpeg( ['-y', '-i', inputFile, '-map', `0:${stream.index}`, '-c:s', 'srt', outSrt], - workDir, args.ffmpegPath, jobLog, + workDir, args.ffmpegPath, jobLog, inputFileObj, ); if (cliExitCode !== 0) { jobLog(`[extractSubtitlesAndOcr] WARNING: FFmpeg failed for stream #${stream.index} (exit ${cliExitCode}).\n`); @@ -144,7 +146,7 @@ const plugin = async (args) => { // Step 1: Extract to .sup const { cliExitCode: extractExit } = await runFFmpeg( ['-y', '-i', inputFile, '-map', `0:${stream.index}`, '-c:s', 'copy', tmpSup], - workDir, args.ffmpegPath, jobLog, + workDir, args.ffmpegPath, jobLog, inputFileObj, ); if (extractExit !== 0) { @@ -164,6 +166,7 @@ const plugin = async (args) => { ], WorkDir: workDir, jobLog, + inputFileObj, }); const { cliExitCode: seExit } = await seCli.runCli(); diff --git a/LocalFlowPlugins/utility/extractChaptersXml/1.0.0/index.js b/LocalFlowPlugins/utility/extractChaptersXml/1.0.0/index.js index 2e65350..f79a9e5 100644 --- a/LocalFlowPlugins/utility/extractChaptersXml/1.0.0/index.js +++ b/LocalFlowPlugins/utility/extractChaptersXml/1.0.0/index.js @@ -1,4 +1,4 @@ -'use strict'; +'use strict'; const path = require('path'); const fs = require('fs'); @@ -49,6 +49,7 @@ const plugin = async (args) => { spawnArgs: [inputFile, 'chapters', xmlPath], WorkDir: workDir, jobLog, + inputFileObj, }); const { cliExitCode } = await cli.runCli(); diff --git a/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js b/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js index 2c14c68..0e2747b 100644 --- a/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js +++ b/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js @@ -1,4 +1,4 @@ -'use strict'; +'use strict'; const path = require('path'); const fs = require('fs'); @@ -177,6 +177,7 @@ const plugin = async (args) => { ], WorkDir: workDir, jobLog, + inputFileObj, }); const { cliExitCode: pass1Exit } = await pass1Cli.runCli(); @@ -207,6 +208,7 @@ const plugin = async (args) => { ], WorkDir: workDir, jobLog, + inputFileObj, }); const { cliExitCode: pass2Exit } = await pass2Cli.runCli(); diff --git a/README.md b/README.md new file mode 100644 index 0000000..da10db2 --- /dev/null +++ b/README.md @@ -0,0 +1,282 @@ +# Tdarr Local Flow Plugin Suite + +A collection of [Tdarr](https://tdarr.io/) Flow plugins for automated media transcoding with **x265 2-pass encoding**, **audio track selection**, **subtitle extraction** and **OCR fallback**. + +> **Server**: Docker (Linux) **Workers**: Windows (mapped drives) + +--- + +## ⚡ Quick Install via curl + +### Full Suite (all 7 plugins) + +```bash +# Replace vX.Y.Z with the desired release tag, or use "latest" +VERSION=latest +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: + +```bash +docker restart tdarr-server +``` + +### Individual Plugin + +Each plugin is also released as its own ZIP (e.g. `transcode2PassX265-latest.zip`): + +```bash +PLUGIN=transcode2PassX265 +VERSION=latest +DEST=/path/to/tdarr/plugins/FlowPlugins/LocalFlowPlugins/video/${PLUGIN}/1.0.0 + +mkdir -p "${DEST}" +curl -L https://git.cdtech.freeddns.org/PickleRick/Tdarr-plugin/releases/download/${VERSION}/${PLUGIN}-${VERSION}.zip \ + -o ${PLUGIN}.zip +unzip -o ${PLUGIN}.zip -d "${DEST}" +rm ${PLUGIN}.zip +``` + +Available plugin names: `mediaAnalyzerGOP`, `setAnimationFlag`, `extractChaptersXml`, +`audioSelectorClamping`, `backupSourceAudio`, `extractSubtitlesAndOcr`, `transcode2PassX265` + +--- + +## 📁 Directory Layout + +``` +LocalFlowPlugins/ +├── utility/ +│ ├── mediaAnalyzerGOP/1.0.0/index.js +│ ├── setAnimationFlag/1.0.0/index.js +│ └── extractChaptersXml/1.0.0/index.js +├── audio/ +│ ├── audioSelectorClamping/1.0.0/index.js +│ └── backupSourceAudio/1.0.0/index.js +├── subtitles/ +│ └── extractSubtitlesAndOcr/1.0.0/index.js +└── video/ + └── transcode2PassX265/1.0.0/index.js +``` + +--- + +## 🔌 Plugin Reference + +### 1. `mediaAnalyzerGOP` — Media Analyzer & GOP Calculator + +**Category**: `utility` · **Step**: 1st in flow + +Reads video stream metadata and populates `args.variables` for all downstream plugins. + +| Input | Default | Description | +|---|---|---| +| `moviePathPatterns` | `/Radarr` | Pipe-separated path fragments that identify a Film library (e.g. `/Radarr\|/films`) | +| `animeSeries` | _(empty)_ | Pipe-separated anime series names to auto-detect (e.g. `Naruto\|One Piece`) | +| `filmBitrate` | `3000` | Target kbps for live-action films | +| `seriesBitrate` | `3000` | Target kbps for TV series | +| `animeBitrateHd` | `2000` | Target kbps for anime ≥720p | +| `animeBitrateSd` | `1000` | Target kbps for anime <720p | + +**Outputs**: `1` → continue + +**Variables set**: `gopKeyint`, `gopMinKeyint`, `isAnimation`, `isMovie`, `vBitrate`, `vMaxRate`, `vBufSize`, `x265Tune`, `videoHeight`, `videoFps` + +--- + +### 2. `setAnimationFlag` — Set Animation Flag + +**Category**: `utility` · **Step**: optional, after `mediaAnalyzerGOP` + +Manually forces `isAnimation=true` and `x265Tune=animation` when a file was not auto-detected as anime. Optionally recalculates bitrate targets. + +| Input | Default | Description | +|---|---|---| +| `animeBitrateHd` | `2000` | Bitrate (kbps) for anime ≥720p | +| `animeBitrateSd` | `1000` | Bitrate (kbps) for anime <720p | + +**Outputs**: `1` → continue + +--- + +### 3. `extractChaptersXml` — Extract Chapters to XML + +**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"). + +**No inputs required.** + +**Outputs**: `1` → continue + +> **Requires**: MKVToolNix (`mkvextract`) — automatically located from the `mkvpropeditPath` node setting. + +--- + +### 4. `audioSelectorClamping` — Audio Selector & Opus Bitrate Clamping + +**Category**: `audio` · **Step**: after `mediaAnalyzerGOP` + +Selects the best ITA and ENG audio tracks by codec+bitrate scoring, then calculates clamped Opus target bitrates. No upcoding — output bitrate never exceeds source. + +**Codec scoring** (higher = better): TrueHD 100 · DTS-HD MA 95 · FLAC 90 · EAC3 75 · DTS 60 · AC3 45 · AAC 35 · Opus 30 + +| Input | Default | Description | +|---|---|---| +| `filmMultiChannelBitrate` | `640` | Max kbps for 5.1+ film audio | +| `seriesMultiChannelThreshold` | `384` | kbps threshold: ≥threshold → 384k, else → 256k | +| `stereoBitrateMax` | `128` | Max kbps for stereo tracks | + +**Outputs**: `1` → ITA or ENG track found · `2` → no target tracks found + +**Variables set**: `audioHasIta`, `audioItaStreamIndex`, `audioItaOpusBitrate`, `audioItaChannels`, `audioItaCodecName`, `audioHasEng`, `audioEngStreamIndex`, `audioEngOpusBitrate`, `audioEngCodecName` + +--- + +### 5. `backupSourceAudio` — Backup Source Audio (DTS / TrueHD ITA) + +**Category**: `audio` · **Step**: after `audioSelectorClamping` + +Extracts the ITA audio track as a lossless bitstream copy (`-c:a copy`) **only when** the codec is DTS or TrueHD. Output is saved next to the source file. Silently skips if no backup is needed. + +**No inputs required.** + +**Outputs**: `1` → continue + +**Variables set**: `audioBackupPath` (path of the extracted file, if created) + +--- + +### 6. `extractSubtitlesAndOcr` — Extract Subtitles & OCR Fallback + +**Category**: `subtitles` · **Step**: after `audioSelectorClamping` + +Extracts ITA and ENG subtitle streams to SRT files **next to the source file**. + +- **Text-based** codecs (`subrip`, `srt`, `ass`, `ssa`, `webvtt`, `mov_text`): direct FFmpeg extraction +- **Bitmap-based** codecs (`hdmv_pgs_subtitle`, `dvd_subtitle`, `pgssub`, `dvbsub`): FFmpeg `.sup` extract → SubtitleEdit CLI OCR → `.srt` + +OCR failures are logged as **warnings** without stopping the pipeline. + +**Output naming**: `.[.forced][.default][.sdh].srt` + +| Input | Default | Description | +|---|---|---| +| `subtitleEditPath` | `SubtitleEdit` | Name or full path of SubtitleEdit CLI. Must be in PATH or provide absolute path. | +| `languages` | `ita,eng` | Comma-separated ISO 639-2 language codes to extract | + +**Outputs**: `1` → continue (warnings in log if OCR failed) + +> **Requires**: SubtitleEdit CLI for PGS/VOBSUB OCR (Windows workers only). + +--- + +### 7. `transcode2PassX265` — 2-Pass x265 Transcode + +**Category**: `video` · **Step**: last + +Performs 2-pass libx265 encoding using VBV parameters from `args.variables`. + +- **Video**: bound-1080p Lanczos scaling (no letterbox, even dimensions) +- **Audio**: Opus re-encode with per-track indexed bitrate (ITA default, ENG secondary) +- **Subtitles**: stripped (`-sn`) +- **Chapters**: preserved (`-map_chapters 0`) +- **2-pass logs**: automatically cleaned up after encode + +| Input | Default | Description | +|---|---|---| +| `x265Preset` | `slow` | x265 preset (`ultrafast` … `veryslow`). Slower = better quality. | +| `outputContainer` | `mkv` | Output file extension | + +**Outputs**: `1` → transcode complete + +**Reads from variables**: `gopKeyint`, `gopMinKeyint`, `vBitrate`, `vMaxRate`, `vBufSize`, `x265Tune`, `audioHasIta`, `audioItaStreamIndex`, `audioItaOpusBitrate`, `audioHasEng`, `audioEngStreamIndex`, `audioEngOpusBitrate` + +--- + +## 🔗 Flow Variable Map + +``` +mediaAnalyzerGOP ──► gopKeyint, gopMinKeyint, vBitrate, vMaxRate, vBufSize + ──► isAnimation, isMovie, x265Tune, videoHeight, videoFps + │ + (optional) setAnimationFlag ──► overrides vBitrate, vMaxRate, vBufSize, x265Tune + │ +audioSelectorClamping ──► audioHasIta, audioItaStreamIndex, audioItaOpusBitrate, audioItaCodecName + ──► audioHasEng, audioEngStreamIndex, audioEngOpusBitrate + │ +backupSourceAudio ──► audioBackupPath + │ +transcode2PassX265 ◄── all of the above +``` + +--- + +## 🛠 Requirements + +### Tdarr Server (Docker/Linux) +- Tdarr ≥ 2.11.01 +- Plugins folder mounted at the path configured in Tdarr settings + +### Tdarr Worker (Windows) +| Tool | Purpose | Download | +|---|---|---| +| FFmpeg (with libopus) | Video/audio/subtitle processing | https://ffmpeg.org/download.html | +| MKVToolNix | Chapter extraction (`mkvextract`) | https://mkvtoolnix.download/ | +| SubtitleEdit CLI | PGS/VOBSUB OCR to SRT | https://www.nikse.dk/subtitleedit | + +Add to Windows system PATH: `ffmpeg\bin`, `MKVToolNix`, `SubtitleEdit` + +Configure in Tdarr node settings: +- **FFmpeg Path**: `C:\Tools\ffmpeg\bin\ffmpeg.exe` +- **FFprobe Path**: `C:\Tools\ffmpeg\bin\ffprobe.exe` +- **MKVPropedit Path**: `C:\Program Files\MKVToolNix\mkvpropedit.exe` + +--- + +## 🧪 Local Testing + +```bash +# Prerequisites: Node.js, FFmpeg in PATH +cd /path/to/Tdarr-plugin + +# 1. Generate sample test files (requires FFmpeg) +node tests/generate-test-files.js + +# 2. Run logic tests (no real encode) +node tests/quick-logic-test.js + +# 3. Run full integration tests +node tests/run-tests.js + +# 4. Run integration tests skipping real x265 encode +node tests/run-tests.js --skip-transcode +``` + +--- + +## 📦 Release & Update + +Releases are created automatically via GitHub Actions on every push to `main` or on git tag `v*`. + +To trigger a manual release from the Actions tab, use **"Run workflow"** and specify the version (e.g. `v1.0.1`). + +To update plugins on the server to the latest release: + +```bash +VERSION=latest +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 +docker restart tdarr-server +```