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