225 lines
8.3 KiB
JavaScript
225 lines
8.3 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 -> 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
|
|
* isMovie -> detected from moviePathPatterns
|
|
* vBitrate / vMaxRate / vBufSize
|
|
* x265Tune -> "animation" or ""
|
|
* videoHeight / videoFps
|
|
*/
|
|
|
|
const details = () => ({
|
|
name: 'Media Analyzer & GOP Calculator',
|
|
description:
|
|
'Analyzes video metadata, calculates a streaming-friendly 4-second GOP, 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 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) => {
|
|
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`);
|
|
|
|
// A four-second maximum GOP is a compromise for remote streaming: it
|
|
// 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`);
|
|
|
|
// Build candidate paths list to support workDir temporary copies
|
|
const candidatePaths = [
|
|
inputFileObj._id,
|
|
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`);
|
|
|
|
// Check if media matches anime series list
|
|
const matchedAnime = animeList.find((name) =>
|
|
candidatePaths.some((cPath) => matchesAnime(name, cPath)),
|
|
);
|
|
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 = gopMinKeyint;
|
|
variables.gopSceneCut = gopSceneCut;
|
|
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;
|
|
module.exports.matchesAnime = matchesAnime;
|
|
module.exports.normalizeAccents = normalizeAccents;
|
|
|