From 2ea83ae5663605c98b9bf0dcdeb3bb0a9d968660 Mon Sep 17 00:00:00 2001 From: PickleRick Date: Tue, 6 Oct 2026 09:34:19 +0200 Subject: [PATCH] feat(analyzer): enhance anime detection with accent normalization and flexible matching --- .../utility/copyToWorkDir/1.0.0/index.js | 5 +- .../utility/mediaAnalyzerGOP/1.0.0/index.js | 63 ++++++++++++++++--- .../replaceOriginalFile/1.0.0/index.js | 3 + .../video/transcode2PassX265/1.0.0/index.js | 35 +++++++++-- tests/quick-logic-test.js | 49 +++++++++++++++ 5 files changed, 143 insertions(+), 12 deletions(-) diff --git a/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js b/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js index 6268fcd..251c41a 100644 --- a/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js +++ b/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js @@ -47,9 +47,11 @@ const copyWithProgress = async ({ fs.mkdirSync(destDir, { recursive: true }); } + const displayFile = (source || '').replace(/\\/g, '/'); const report = (percent, message, eta) => { if (typeof updateWorker === 'function') { updateWorker({ + file: displayFile, percentage: Math.min(percent, 100), ETA: eta || '00:00:00', }); @@ -125,6 +127,7 @@ const copyWithProgress = async ({ if (typeof updateWorker === 'function') { updateWorker({ + file: displayFile, percentage: percent, ETA: eta, }); @@ -249,7 +252,7 @@ const plugin = async (args) => { const inputFile = inputFileObj._id; const ext = path.extname(inputFile); const baseName = path.basename(inputFile, ext); - const localCopy = path.join(workDir, `${baseName}_old${ext}`); + const localCopy = path.join(workDir, `${baseName}_old${ext}`).replace(/\\/g, '/'); const skipIfLocal = (args.inputs.skipIfLocal || 'true') === 'true'; jobLog(`[copyToWorkDir] Source : ${inputFile}\n`); diff --git a/LocalFlowPlugins/utility/mediaAnalyzerGOP/1.0.0/index.js b/LocalFlowPlugins/utility/mediaAnalyzerGOP/1.0.0/index.js index 5b7779e..3f55e84 100644 --- a/LocalFlowPlugins/utility/mediaAnalyzerGOP/1.0.0/index.js +++ b/LocalFlowPlugins/utility/mediaAnalyzerGOP/1.0.0/index.js @@ -1,4 +1,4 @@ -'use strict'; +'use strict'; /* eslint-disable no-param-reassign */ @@ -89,6 +89,37 @@ const details = () => ({ ], }); +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('/')) { @@ -133,14 +164,29 @@ const plugin = async (args) => { const gopSceneCut = '40'; jobLog(`[mediaAnalyzerGOP] keyint=${gopKeyint} min-keyint=${gopMinKeyint} scenecut=${gopSceneCut}\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)); + // 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`); - const lowerPath = normalizedPath.toLowerCase(); - const matchedAnime = animeList.find((name) => lowerPath.includes(name.toLowerCase())); + // 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`); @@ -173,3 +219,6 @@ const plugin = async (args) => { module.exports.details = details; module.exports.plugin = plugin; +module.exports.matchesAnime = matchesAnime; +module.exports.normalizeAccents = normalizeAccents; + diff --git a/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js b/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js index f344385..fc2c6f2 100644 --- a/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js +++ b/LocalFlowPlugins/utility/replaceOriginalFile/1.0.0/index.js @@ -47,9 +47,11 @@ const copyWithProgress = async ({ fs.mkdirSync(destDir, { recursive: true }); } + const displayFile = (source || '').replace(/\\/g, '/'); const report = (percent, message, eta) => { if (typeof updateWorker === 'function') { updateWorker({ + file: displayFile, percentage: Math.min(percent, 100), ETA: eta || '00:00:00', }); @@ -125,6 +127,7 @@ const copyWithProgress = async ({ if (typeof updateWorker === 'function') { updateWorker({ + file: displayFile, percentage: percent, ETA: eta, }); diff --git a/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js b/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js index d1f7bd9..1e86ef5 100644 --- a/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js +++ b/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js @@ -215,7 +215,8 @@ const plugin = async (args) => { const inputBaseName = path.basename(inputFile, path.extname(inputFile)); const baseName = variables.workDirSourceBaseName || (variables.workDirCopyApplied === 'true' ? inputBaseName.replace(/_old$/, '') : inputBaseName); - const outFile = path.join(workDir, `${baseName}.${container}`); + const outFile = path.join(workDir, `${baseName}.${container}`).replace(/\\/g, '/'); + const displayFile = (variables.originalLibraryFile || inputFile).replace(/\\/g, '/'); const statsFile = path.join(workDir, 'x265_2pass.log'); // The CLI runs with workDir as its current directory. Keeping this relative // avoids the Windows drive colon being parsed as an x265-params separator. @@ -262,6 +263,14 @@ const plugin = async (args) => { } jobLog('[transcode2PassX265] Copying compatible video and processing audio...\n'); + if (typeof updateWorker === 'function') { + updateWorker({ + file: displayFile, + CLIType: args.ffmpegPath, + preset: 'copy', + }); + } + const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils'); const copyCli = new CLI({ cli: args.ffmpegPath, @@ -272,6 +281,7 @@ const plugin = async (args) => { ...audioArgs, '-map_metadata', '-1', '-map_chapters', '-1', + '-metadata', `title=${baseName}`, '-disposition:v:0', 'default', ...videoArgs, '-sn', @@ -282,7 +292,7 @@ const plugin = async (args) => { jobLog, outputFilePath: outFile, inputFileObj, - updateWorker, + updateWorker: (data) => updateWorker && updateWorker({ file: displayFile, ...data }), logFullCliOutput: args.logFullCliOutput || false, args, }); @@ -310,16 +320,28 @@ const plugin = async (args) => { const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils'); const { updateProgress } = args; + if (typeof updateWorker === 'function') { + updateWorker({ + file: displayFile, + CLIType: args.ffmpegPath, + preset: `x265 2-pass (${vBitrate})`, + }); + } + // Scale 2-pass progress so Pass 1 counts for 0-50% and Pass 2 counts for 50-100% const pass1UpdateWorker = (progressData) => { if (typeof updateWorker !== 'function') return; if (progressData && typeof progressData.percentage === 'number') { updateWorker({ + file: displayFile, ...progressData, percentage: scalePassPercentage(1, progressData.percentage), }); } else { - updateWorker(progressData); + updateWorker({ + file: displayFile, + ...progressData, + }); } }; @@ -339,11 +361,15 @@ const plugin = async (args) => { if (typeof updateWorker !== 'function') return; if (progressData && typeof progressData.percentage === 'number') { updateWorker({ + file: displayFile, ...progressData, percentage: scalePassPercentage(2, progressData.percentage), }); } else { - updateWorker(progressData); + updateWorker({ + file: displayFile, + ...progressData, + }); } }; @@ -413,6 +439,7 @@ const plugin = async (args) => { ...audioArgs, '-map_metadata', '-1', '-map_chapters', '-1', + '-metadata', `title=${baseName}`, '-disposition:v:0', 'default', ...videoArgs, '-x265-params', `pass=2:${x265ParamsBase}`, diff --git a/tests/quick-logic-test.js b/tests/quick-logic-test.js index 98cab1a..b8e0dd2 100644 --- a/tests/quick-logic-test.js +++ b/tests/quick-logic-test.js @@ -87,6 +87,40 @@ const AUDIO_INPUTS = { eq(o.variables.isMovie, 'true', 'Windows backslash path: /Radarr still matched'); cleanup(a); } + // Family Guy (space in anime list, dots in folder & file name) + { const a = makeArgs('/media/toshiba/Sonarr/Family.Guy.1999 [tvdbid-75978]/Family.Guy.S00/Family.Guy.S00E64.mkv', + [makeStream({ r_frame_rate:'24000/1001', height:1080 })], + Object.assign({}, ANALYZER_INPUTS, { animeSeries: 'Simpson|Griffin|Family Guy|South Park' })); + const o = await analyzerPlugin(a); + eq(o.variables.isAnimation, 'true', 'Family Guy (dots vs spaces in name): isAnimation=true'); + eq(o.variables.x265Tune, 'animation', 'Family Guy: x265Tune=animation'); + eq(o.variables.vBitrate, '2000k', 'Family Guy 1080p: anime HD bitrate=2000k'); + cleanup(a); } + + // Anime detected via variables.originalLibraryFile when workDir copy is input + { const a = makeArgs('C:\\workDir\\Family.Guy.S00E64_old.mkv', + [makeStream({ r_frame_rate:'24000/1001', height:1080 })], + Object.assign({}, ANALYZER_INPUTS, { animeSeries: 'Family Guy' }), + { originalLibraryFile: '/media/toshiba/Sonarr/Family.Guy.1999/Family.Guy.S00/Family.Guy.S00E64.mkv' }); + const o = await analyzerPlugin(a); + eq(o.variables.isAnimation, 'true', 'Anime detected via originalLibraryFile in variables'); + cleanup(a); } + + // Accents and apostrophe handling in anime list + { const a = makeArgs('/media/toshiba/Sonarr/Pokemon.S01E01.mkv', + [makeStream({ r_frame_rate:'24/1', height:720 })], + Object.assign({}, ANALYZER_INPUTS, { animeSeries: 'Pokémon' })); + const o = await analyzerPlugin(a); + eq(o.variables.isAnimation, 'true', 'Accented title "Pokémon" matches "Pokemon.S01E01.mkv"'); + cleanup(a); } + + { const a = makeArgs('/media/toshiba/Sonarr/Bobs.Burgers.S01E01.mkv', + [makeStream({ r_frame_rate:'24/1', height:720 })], + Object.assign({}, ANALYZER_INPUTS, { animeSeries: "Bob's Burgers" })); + const o = await analyzerPlugin(a); + eq(o.variables.isAnimation, 'true', 'Apostrophe title "Bob\'s Burgers" matches "Bobs.Burgers"'); + cleanup(a); } + // ── audioSelectorClamping ───────────────────────────────────────────────── console.log('\n── audioSelectorClamping ────────────────────────────'); @@ -246,6 +280,21 @@ const AUDIO_INPUTS = { eq(scalePassPercentage(2, 100), 100, 'pass 2 at 100% maps to 100%'); ok(transcodeCode.includes('scalePassPercentage(1,'), 'pass 1 wraps updateWorker with 50% scale'); ok(transcodeCode.includes('scalePassPercentage(2,'), 'pass 2 wraps updateWorker with 50% scale'); + ok(transcodeCode.includes("displayFile = (variables.originalLibraryFile || inputFile).replace(/\\\\/g, '/')"), 'transcode2PassX265 normalizes displayFile with forward slashes'); + ok(transcodeCode.includes('file: displayFile'), 'transcode2PassX265 passes displayFile to updateWorker'); + ok(transcodeCode.includes("'-metadata', `title=${baseName}`"), 'transcode2PassX265 restores metadata title tag in output MKV'); + ok(transcodeCode.includes("outFile = path.join(workDir, `${baseName}.${container}`).replace(/\\\\/g, '/')"), 'transcode2PassX265 normalizes outFile with forward slashes'); + + // Verify WebUI worker title logic: `t.file ? (e = t.file).includes("/") ? e.split("/").pop() : "" : ""` + { + const sampleWorkerFile = 'C:/media/toshiba/Sonarr/Family.Guy.1999/Family.Guy.S00/Family.Guy.S00E64.mkv'; + const webUiTitle = sampleWorkerFile ? (sampleWorkerFile.includes('/') ? sampleWorkerFile.split('/').pop() : '') : ''; + eq(webUiTitle, 'Family.Guy.S00E64.mkv', 'WebUI title extract works with forward slashes'); + + const sampleWindowsFile = 'C:\\media\\toshiba\\Sonarr\\Family.Guy.1999\\Family.Guy.S00\\Family.Guy.S00E64.mkv'; + const brokenWebUiTitle = sampleWindowsFile ? (sampleWindowsFile.includes('/') ? sampleWindowsFile.split('/').pop() : '') : ''; + eq(brokenWebUiTitle, '', 'Confirms WebUI bug: Windows backslashes produce empty title'); + } // ── copyToWorkDir & replaceOriginalFile v1.0.0 checks ───────────────────── const copyPluginCode = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js'), 'utf8');