Files
Tdarr-plugin/LocalFlowPlugins/utility/mediaAnalyzerGOP/1.0.0/index.js
T
PickleRick c2074be826 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
2026-09-18 23:23:05 +02:00

168 lines
6.0 KiB
JavaScript

'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;