feat: Tdarr Local Flow Plugin Suite v1.0
7 atomic plugins for automated x265 transcoding pipeline: - utility/mediaAnalyzerGOP : GOP calc, FPS detection, film/series/anime routing - utility/setAnimationFlag : manual animation flag override - utility/extractChaptersXml : mkvextract chapters to XML - audio/audioSelectorClamping : codec scoring, clamped Opus bitrates, 2 outputs - audio/backupSourceAudio : lossless DTS/TrueHD ITA backup (-c:a copy) - subtitles/extractSubtitlesAndOcr : SRT extract + SubtitleEdit OCR for PGS - video/transcode2PassX265 : 2-pass x265, Lanczos 1080p bound, Opus mux Test suite: 34 unit tests + 3 integration tests (FFmpeg real files) FlowHelpers mock for local testing included Docs: integration-guide.md with canvas wiring and deploy instructions
This commit is contained in:
commit
c2074be826
19 files changed
+2134
No files matched your search
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
const details = () => ({
|
||||
name: 'Extract Chapters to XML',
|
||||
description:
|
||||
'Extracts MKV chapter markers to an XML file in workDir using mkvextract. '
|
||||
+ 'Stores the output path in args.variables.chaptersXmlPath. '
|
||||
+ 'Safe to run even if the source has no chapters.',
|
||||
style: { borderColor: '#9C27B0' },
|
||||
tags: 'utility,chapters,mkv',
|
||||
isStartPlugin: false,
|
||||
pType: '',
|
||||
requiresVersion: '2.11.01',
|
||||
sidebarPosition: -1,
|
||||
icon: 'faList',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ number: 1, tooltip: 'Continue to next plugin' },
|
||||
],
|
||||
});
|
||||
|
||||
const plugin = async (args) => {
|
||||
const { jobLog, inputFileObj, variables, workDir } = args;
|
||||
|
||||
const inputFile = inputFileObj._id;
|
||||
const baseName = path.basename(inputFile, path.extname(inputFile));
|
||||
const xmlPath = path.join(workDir, `${baseName}_chapters.xml`);
|
||||
|
||||
jobLog(`[extractChaptersXml] Source: ${inputFile}\n`);
|
||||
jobLog(`[extractChaptersXml] Output: ${xmlPath}\n`);
|
||||
|
||||
// Derive mkvextract from mkvpropeditPath (same MKVToolNix bin dir)
|
||||
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)) mkvExtract = candidate;
|
||||
}
|
||||
jobLog(`[extractChaptersXml] Using: ${mkvExtract}\n`);
|
||||
|
||||
const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils');
|
||||
const cli = new CLI({
|
||||
cli: mkvExtract,
|
||||
spawnArgs: [inputFile, 'chapters', xmlPath],
|
||||
WorkDir: workDir,
|
||||
jobLog,
|
||||
});
|
||||
|
||||
const { cliExitCode } = await cli.runCli();
|
||||
|
||||
// mkvextract: exit 0 = OK, exit 1 = no chapters (still OK), exit 2 = error
|
||||
if (cliExitCode > 1) {
|
||||
jobLog(`[extractChaptersXml] WARNING: mkvextract exit ${cliExitCode} — chapters may be missing.\n`);
|
||||
variables.chaptersXmlPath = '';
|
||||
} else if (fs.existsSync(xmlPath) && fs.statSync(xmlPath).size > 50) {
|
||||
variables.chaptersXmlPath = xmlPath;
|
||||
jobLog(`[extractChaptersXml] Chapters saved: ${xmlPath}\n`);
|
||||
} else {
|
||||
jobLog('[extractChaptersXml] No chapters found.\n');
|
||||
variables.chaptersXmlPath = '';
|
||||
}
|
||||
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
};
|
||||
|
||||
module.exports.details = details;
|
||||
module.exports.plugin = plugin;
|
||||
@@ -0,0 +1,167 @@
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
/**
|
||||
* mediaAnalyzerGOP
|
||||
* Analyzes the video stream and populates args.variables with encoding
|
||||
* parameters consumed by downstream plugins:
|
||||
* gopKeyint / gopMinKeyint -> 2 x FPS (rounded)
|
||||
* isAnimation -> detected from animeSeries path list
|
||||
* isMovie -> detected from moviePathPatterns
|
||||
* vBitrate / vMaxRate / vBufSize
|
||||
* x265Tune -> "animation" or ""
|
||||
* videoHeight / videoFps
|
||||
*/
|
||||
|
||||
const details = () => ({
|
||||
name: 'Media Analyzer & GOP Calculator',
|
||||
description:
|
||||
'Analyzes video metadata, calculates GOP keyint (2xFPS), target bitrate '
|
||||
+ 'and VBV parameters based on content type (film/series/anime). '
|
||||
+ 'Exports all values to args.variables for downstream plugins.',
|
||||
style: { borderColor: '#2196F3' },
|
||||
tags: 'video,utility',
|
||||
isStartPlugin: false,
|
||||
pType: '',
|
||||
requiresVersion: '2.11.01',
|
||||
sidebarPosition: 1,
|
||||
icon: 'faFilm',
|
||||
inputs: [
|
||||
{
|
||||
label: 'Movie Library Path Patterns',
|
||||
name: 'moviePathPatterns',
|
||||
type: 'string',
|
||||
defaultValue: '/Radarr',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip:
|
||||
'Pipe-separated path fragments that identify a Film library. '
|
||||
+ 'Example: /Radarr|/films|/movies',
|
||||
},
|
||||
{
|
||||
label: 'Anime Series Names',
|
||||
name: 'animeSeries',
|
||||
type: 'string',
|
||||
defaultValue: '',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip:
|
||||
'Pipe-separated list of anime series names. The plugin checks if any '
|
||||
+ 'token appears (case-insensitive) in the file path. '
|
||||
+ 'Example: Naruto|One Piece|Attack on Titan',
|
||||
},
|
||||
{
|
||||
label: 'Film Bitrate (kbps)',
|
||||
name: 'filmBitrate',
|
||||
type: 'string',
|
||||
defaultValue: '3000',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Target video bitrate in kbps for live-action films.',
|
||||
},
|
||||
{
|
||||
label: 'Series Bitrate (kbps)',
|
||||
name: 'seriesBitrate',
|
||||
type: 'string',
|
||||
defaultValue: '3000',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Target video bitrate in kbps for TV series.',
|
||||
},
|
||||
{
|
||||
label: 'Anime HD Bitrate (kbps, >=720p)',
|
||||
name: 'animeBitrateHd',
|
||||
type: 'string',
|
||||
defaultValue: '2000',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Target video bitrate in kbps for anime at 720p or higher.',
|
||||
},
|
||||
{
|
||||
label: 'Anime SD Bitrate (kbps, <720p)',
|
||||
name: 'animeBitrateSd',
|
||||
type: 'string',
|
||||
defaultValue: '1000',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Target video bitrate in kbps for anime below 720p.',
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ number: 1, tooltip: 'Continue to next plugin' },
|
||||
],
|
||||
});
|
||||
|
||||
const parseRate = (rateStr) => {
|
||||
if (!rateStr) return 0;
|
||||
if (rateStr.includes('/')) {
|
||||
const [n, d] = rateStr.split('/').map(Number);
|
||||
return d ? n / d : 0;
|
||||
}
|
||||
return parseFloat(rateStr) || 0;
|
||||
};
|
||||
|
||||
const plugin = async (args) => {
|
||||
const { jobLog, inputFileObj, variables } = args;
|
||||
|
||||
const filePath = inputFileObj._id;
|
||||
jobLog(`[mediaAnalyzerGOP] Processing: ${filePath}\n`);
|
||||
|
||||
const moviePathPatterns = (args.inputs.moviePathPatterns || '/Radarr')
|
||||
.split('|').filter(Boolean);
|
||||
const animeList = (args.inputs.animeSeries || '').split('|').filter(Boolean);
|
||||
const filmBitrate = Math.max(1, parseInt(args.inputs.filmBitrate || '3000', 10));
|
||||
const seriesBitrate = Math.max(1, parseInt(args.inputs.seriesBitrate || '3000', 10));
|
||||
const animeBitrateHd = Math.max(1, parseInt(args.inputs.animeBitrateHd || '2000', 10));
|
||||
const animeBitrateSd = Math.max(1, parseInt(args.inputs.animeBitrateSd || '1000', 10));
|
||||
|
||||
const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || [];
|
||||
const videoStream = streams.find((s) => s.codec_type === 'video');
|
||||
if (!videoStream) throw new Error('[mediaAnalyzerGOP] No video stream found.');
|
||||
|
||||
let fps = parseRate(videoStream.r_frame_rate);
|
||||
if (!fps || fps > 120) fps = parseRate(videoStream.avg_frame_rate);
|
||||
if (!fps || fps > 120) fps = parseFloat(videoStream.FrameRate || '0');
|
||||
if (!fps || fps <= 0) {
|
||||
jobLog('[mediaAnalyzerGOP] WARNING: Cannot determine FPS, defaulting to 24.\n');
|
||||
fps = 24;
|
||||
}
|
||||
jobLog(`[mediaAnalyzerGOP] FPS: ${fps.toFixed(3)}\n`);
|
||||
|
||||
const gopKeyint = String(Math.round(fps * 2));
|
||||
jobLog(`[mediaAnalyzerGOP] keyint=${gopKeyint}\n`);
|
||||
|
||||
// Normalize to forward slashes for cross-platform path matching
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const isMovie = moviePathPatterns.some((p) => normalizedPath.includes(p));
|
||||
const matchedPat = moviePathPatterns.find((p) => normalizedPath.includes(p));
|
||||
jobLog(`[mediaAnalyzerGOP] isMovie=${isMovie}${matchedPat ? ` ("${matchedPat}")` : ''}\n`);
|
||||
|
||||
const lowerPath = normalizedPath.toLowerCase();
|
||||
const matchedAnime = animeList.find((name) => lowerPath.includes(name.toLowerCase()));
|
||||
const isAnimation = !!matchedAnime;
|
||||
jobLog(`[mediaAnalyzerGOP] isAnimation=${isAnimation}${matchedAnime ? ` ("${matchedAnime}")` : ''}\n`);
|
||||
|
||||
const videoHeight = parseInt(videoStream.height || '0', 10);
|
||||
let targetKbps;
|
||||
if (isAnimation) targetKbps = videoHeight >= 720 ? animeBitrateHd : animeBitrateSd;
|
||||
else if (isMovie) targetKbps = filmBitrate;
|
||||
else targetKbps = seriesBitrate;
|
||||
|
||||
const vBitrate = `${targetKbps}k`;
|
||||
const vMaxRate = `${targetKbps * 2}k`;
|
||||
const vBufSize = `${targetKbps * 4}k`;
|
||||
jobLog(`[mediaAnalyzerGOP] vBitrate=${vBitrate} vMaxRate=${vMaxRate} vBufSize=${vBufSize}\n`);
|
||||
|
||||
variables.gopKeyint = gopKeyint;
|
||||
variables.gopMinKeyint = gopKeyint;
|
||||
variables.isAnimation = String(isAnimation);
|
||||
variables.isMovie = String(isMovie);
|
||||
variables.vBitrate = vBitrate;
|
||||
variables.vMaxRate = vMaxRate;
|
||||
variables.vBufSize = vBufSize;
|
||||
variables.x265Tune = isAnimation ? 'animation' : '';
|
||||
variables.videoHeight = String(videoHeight);
|
||||
variables.videoFps = fps.toFixed(3);
|
||||
|
||||
jobLog('[mediaAnalyzerGOP] Done.\n');
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
};
|
||||
|
||||
module.exports.details = details;
|
||||
module.exports.plugin = plugin;
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
const details = () => ({
|
||||
name: 'Set Animation Flag',
|
||||
description:
|
||||
'Forces isAnimation=true and x265Tune=animation in flow variables. '
|
||||
+ 'Use when a file is animation but was not detected automatically '
|
||||
+ 'by the animeSeries list in mediaAnalyzerGOP. '
|
||||
+ 'Optionally recalculates video bitrate targets.',
|
||||
style: { borderColor: '#FF9800' },
|
||||
tags: 'utility,animation',
|
||||
isStartPlugin: false,
|
||||
pType: '',
|
||||
requiresVersion: '2.11.01',
|
||||
sidebarPosition: -1,
|
||||
icon: 'faMagic',
|
||||
inputs: [
|
||||
{
|
||||
label: 'Anime HD Bitrate (kbps, >=720p)',
|
||||
name: 'animeBitrateHd',
|
||||
type: 'string',
|
||||
defaultValue: '2000',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Target bitrate for anime >=720p. Leave blank to skip recalculation.',
|
||||
},
|
||||
{
|
||||
label: 'Anime SD Bitrate (kbps, <720p)',
|
||||
name: 'animeBitrateSd',
|
||||
type: 'string',
|
||||
defaultValue: '1000',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Target bitrate for anime <720p.',
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ number: 1, tooltip: 'Continue with animation flag set' },
|
||||
],
|
||||
});
|
||||
|
||||
const plugin = async (args) => {
|
||||
const { jobLog, inputFileObj, variables } = args;
|
||||
jobLog('[setAnimationFlag] Forcing isAnimation=true\n');
|
||||
|
||||
variables.isAnimation = 'true';
|
||||
variables.x265Tune = 'animation';
|
||||
|
||||
const hdInput = args.inputs.animeBitrateHd;
|
||||
const sdInput = args.inputs.animeBitrateSd;
|
||||
|
||||
if (hdInput && sdInput) {
|
||||
const animeBitrateHd = Math.max(1, parseInt(hdInput, 10));
|
||||
const animeBitrateSd = Math.max(1, parseInt(sdInput, 10));
|
||||
const videoHeight = parseInt(variables.videoHeight || '0', 10);
|
||||
|
||||
if (videoHeight > 0) {
|
||||
const targetKbps = videoHeight >= 720 ? animeBitrateHd : animeBitrateSd;
|
||||
variables.vBitrate = `${targetKbps}k`;
|
||||
variables.vMaxRate = `${targetKbps * 2}k`;
|
||||
variables.vBufSize = `${targetKbps * 4}k`;
|
||||
jobLog(`[setAnimationFlag] Bitrate recalculated: ${variables.vBitrate} (height=${videoHeight}px)\n`);
|
||||
} else {
|
||||
jobLog('[setAnimationFlag] videoHeight not set — skipping bitrate recalculation.\n');
|
||||
}
|
||||
}
|
||||
|
||||
jobLog('[setAnimationFlag] Done.\n');
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
};
|
||||
|
||||
module.exports.details = details;
|
||||
module.exports.plugin = plugin;
|
||||
Reference in new issue
Block a user