diff --git a/fileflows/README.md b/fileflows/README.md index c31993a..f680e6f 100644 --- a/fileflows/README.md +++ b/fileflows/README.md @@ -15,27 +15,12 @@ Exportiert aus FileFlows am 17.07.2026. | Radarr_Trigger_Manual_Import | 0e522a46-... | Radarr Import trigger | | Sonarr_Trigger_Manual_Import | 8fe63a47-... | Sonarr Import trigger | -## Sicherheit beim Arr-Import - -- Radarr-Import arbeitet nur mit dem exakten `Variables['file.FullName']`. -- Die konfigurierte `ImportPath` dient ausschließlich als Zuständigkeits-Guard für UHD/FHD. -- Filmzuordnung erfolgt über einen eindeutigen Release-Match in Radarr Queue bzw. Grab-History; Radarr-Parsing ist nur Fallback. -- Ohne eindeutige Movie-ID oder exakten ManualImport-Kandidaten schlägt der Flow sicher fehl und lässt die Datei zur manuellen Wiederherstellung liegen. -- Ein Scan des gesamten `/tdarr/converted/video/`- oder `/video4k/`-Roots ist ausdrücklich verboten. - -Regressionstests: - -```bash -python3 -m unittest discover -s fileflows/tests -v -node --check fileflows/scripts/Radarr_-_Trigger_Manual_Import.js -``` - ## Bekannte Issues - QSV-Namen sind falsch, Code verwendet NVENC/cuda - AV1/HEVC Flow-Parameter passen nicht zur Script-Signatur - 05_Finalize_Move hat Datenverlust-Bug bei fehlgeschlagener SRT-Extraktion -- Der Flow ruft weiterhin alle vier Arr-Nodes seriell auf; deren Pfad-Guards verhindern unzuständige API-Imports. +- Arr-Import wird immer fuer alle 4 Instanzen ausgefuehrt ## Flow JSON diff --git a/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js b/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js index 5ebc556..5b24baf 100644 --- a/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js +++ b/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js @@ -1,8 +1,8 @@ /** * @name Radarr - Trigger Manual Import - * @description Safely trigger Radarr ManualImport for exactly one processed file. + * @description Trigger Radarr ManualImport or downloadedMoviesScan with skip-guard. * @author FileFlows + Sascha - * @revision 10 + * @revision 9 * @output Import erfolgreich * @output Fehler * @param {string} URL Radarr root URL and port @@ -18,18 +18,11 @@ import { Radarr } from 'Shared/Radarr'; function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { URL = (URL || Variables['Radarr.Url'] || Variables['Radarr.URI']).replace(/\/+$/g, ''); ApiKey = ApiKey || Variables['Radarr.ApiKey']; - const currentFile = Variables['file.FullName'] || Variables.file?.FullName || ''; - ImportPath = ImportPath || currentFile; + ImportPath = ImportPath || Variables.file.FullName; TimeOut = TimeOut ? Math.min(TimeOut, 3600) * 1000 : 60000; // ms ImportPath = UseUnmappedPath ? Flow.UnMapPath(ImportPath) : ImportPath; - const exactImportPath = UseUnmappedPath ? Flow.UnMapPath(currentFile) : currentFile; const importMode = MoveMode ? 'move' : 'copy'; - if (!exactImportPath) { - Logger.ELog('No exact current file path available; refusing broad Radarr scan.'); - return -1; - } - // ── SKIP-GUARD: Nur API-Call machen, wenn der aktuelle ImportPath // mit dem konfigurierten Pfad uebereinstimmt. Sonst sofort OK. // Das verhindert 3 unnoetige API-Calls pro Datei (4 Arr-Instanzen). @@ -39,6 +32,7 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { // Die Flow-Konfiguration setzt ImportPath z.B. auf /tdarr/converted/video4k/ // oder /tdarr/converted/video/. Wenn der aktuelle file.FullName nicht // darunter liegt, sind wir nicht zustaendig. + var currentFile = Variables['file.FullName'] || ''; if (currentFile && ImportPath && currentFile.indexOf(ImportPath) === -1) { Logger.ILog(`Skip: current file '${currentFile}' not under ImportPath '${ImportPath}' - not responsible`); return 1; @@ -49,23 +43,26 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { /*── movieId detection ──────────────────────────────────*/ const searchPattern = Variables.file.Orig.FileNameNoExtension; - let movieId = Variables['Radarr.movieId'] - ?? findMovieIdFromQueue(searchPattern, radarr) - ?? findMovieIdFromGrabHistory(searchPattern, radarr) - ?? parseMovie(searchPattern, radarr) - ?? null; + let movieId = Variables['Radarr.movieId'] ?? Variables.MovieInfo?.id ?? parseMovie(searchPattern, radarr) ?? null; Logger.ILog(`Radarr URL: ${URL}`); - Logger.ILog(`Triggering Path: ${exactImportPath}`); + Logger.ILog(`Triggering Path: ${ImportPath}`); Logger.ILog(`Import Mode: ${importMode}`); - Logger.ILog(movieId ? `movieId: ${movieId} → ManualImport` : 'No unambiguous movieId → fail closed'); + Logger.ILog(movieId ? `movieId: ${movieId} → ManualImport` : 'No movieId → downloadedMoviesScan'); - if (!movieId) { - Logger.ELog(`Unable to identify '${searchPattern}' uniquely; file remains for manual recovery.`); - return -1; + /*─ Execute first workflow, then fail-over if needed ─*/ + let result; + if (movieId) { + result = manualImportWorkflow(radarr, ImportPath, importMode, movieId, TimeOut); + if (result !== 1) { + Logger.WLog('ManualImport failed, falling back to downloadedMoviesScan.'); + result = scanWorkflow(radarr, ImportPath, importMode, TimeOut); + } + } else { + result = scanWorkflow(radarr, ImportPath, importMode, TimeOut); } - return manualImportWorkflow(radarr, exactImportPath, importMode, movieId, TimeOut); + return result; } @@ -87,14 +84,10 @@ function manualImportWorkflow(radarr, importPath, mode, movieId, timeout) { return -1; } - /* accept only the candidate for the exact processed file */ - const exactPath = normalizeImportPath(importPath); - const matchingCandidates = candidates.filter(c => - normalizeImportPath(c.path || '') === exactPath - ); - const cand = matchingCandidates.find(c => c.quality && c.quality.quality); + /* choose the first candidate that has quality info */ + const cand = candidates.find(c => c.quality && c.quality.quality); if (!cand) { - Logger.WLog(`No ManualImport candidate matched exact path '${importPath}'.`); + Logger.WLog('No ManualImport candidate contained quality information.'); return -1; } @@ -166,6 +159,45 @@ function sendManualImportCommand(radarr, cmdBody) { } } +/*──────────────────────────── downloadedMoviesScan ────────────────────────────*/ + +/** + * @description Perform the downloadedMoviesScan flow (when movieId is absent). + * @param {Radarr} radarr – Radarr API instance + * @param {string} importPath – Folder/File path for downloadedMoviesScan + * @param {string} mode – 'move' or 'copy' + * @param {number} timeout – Timeout in milliseconds + * @returns {number} 1 on success, −1 on failure + */ +function scanWorkflow(radarr, importPath, mode, timeout) { + const cmdId = sendDownloadedMoviesScan(radarr, importPath, mode); + if (cmdId === null) { + Logger.WLog('Faild sending downloadedMoviesScan command'); + return -1; + } + + return waitForCommand(radarr, cmdId, timeout); +} + +/** + * @description Send the **downloadedMoviesScan** command. + * @param {Radarr} radarr - Radarr API instance + * @param {string} importPath - Folder/File path for the scan + * @param {string} mode - 'move' or 'copy' + * @returns {number|null} command id or null + */ +function sendDownloadedMoviesScan(radarr, importPath, mode) { + try { + const resp = radarr.sendCommand('downloadedMoviesScan', { path: importPath, importMode: mode }); + const cmdId = resp?.id; + Logger.ILog(cmdId ? `downloadedMoviesScan queued (cmdId=${cmdId}).` : 'downloadedMoviesScan failed.'); + return cmdId ?? null; + } catch (e) { + Logger.ELog(`Send command downloadedMoviesScan failed: ${e.message}`); + return null; + } +} + /*────────────────────────────── helpers ──────────────────────────────────*/ /** @@ -186,93 +218,6 @@ function waitForCommand(radarr, cmdId, timeoutMs) { return -1; } -/** - * Resolve the movie from Radarr's active download queue. The queue title is - * the original release name and therefore survives FileFlows renaming. - * Only one exact normalized match is accepted. - */ -function findMovieIdFromQueue(searchPattern, radarr) { - if (!searchPattern) { - return null; - } - - try { - const query = buildQueryParams({ - page: 1, - pageSize: 200, - includeUnknownMovieItems: true - }); - const response = radarr.fetchJson('queue', query) || {}; - const records = Array.isArray(response) ? response : (response.records || []); - const wanted = normalizeReleaseTitle(searchPattern); - const matches = records.filter(item => - item?.movieId && normalizeReleaseTitle(item.title || '') === wanted - ); - - if (matches.length === 1) { - Logger.ILog(`Matched Radarr queue movieId ${matches[0].movieId}.`); - return matches[0].movieId; - } - if (matches.length > 1) { - Logger.WLog(`Ambiguous Radarr queue match (${matches.length} records).`); - } - return null; - } catch (e) { - Logger.ELog(`Error fetching Radarr queue: ${e.message}`); - return null; - } -} - -/** - * Resolve a release that has already left the active queue from Radarr's grab - * history. Repeated grabs are accepted only when every exact-title match points - * to the same movie. - */ -function findMovieIdFromGrabHistory(searchPattern, radarr) { - if (!searchPattern) { - return null; - } - - try { - const query = buildQueryParams({ - page: 1, - pageSize: 250, - eventType: 1, // Radarr HistoryEventType.Grabbed - sortKey: 'date', - sortDirection: 'descending' - }); - const response = radarr.fetchJson('history', query) || {}; - const records = Array.isArray(response) ? response : (response.records || []); - const wanted = normalizeReleaseTitle(searchPattern); - const movieIds = records - .filter(item => - item?.movieId && normalizeReleaseTitle(item.sourceTitle || '') === wanted - ) - .map(item => item.movieId) - .filter((id, index, values) => values.indexOf(id) === index); - - if (movieIds.length === 1) { - Logger.ILog(`Matched Radarr grab history movieId ${movieIds[0]}.`); - return movieIds[0]; - } - if (movieIds.length > 1) { - Logger.WLog(`Ambiguous Radarr grab history match (${movieIds.length} movies).`); - } - return null; - } catch (e) { - Logger.ELog(`Error fetching Radarr history: ${e.message}`); - return null; - } -} - -function normalizeReleaseTitle(value) { - return (value || '').toLowerCase().replace(/[^a-z0-9]+/g, ''); -} - -function normalizeImportPath(value) { - return (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); -} - /** * @description Parse the movie name using Radarr parsing based on the search pattern. * @param {string} searchPattern - The search string (file or folder name) diff --git a/fileflows/tests/test_radarr_import_safety.py b/fileflows/tests/test_radarr_import_safety.py deleted file mode 100644 index a895783..0000000 --- a/fileflows/tests/test_radarr_import_safety.py +++ /dev/null @@ -1,198 +0,0 @@ -import json -import subprocess -import unittest -from pathlib import Path - - -SCRIPT = Path(__file__).parents[1] / "scripts" / "Radarr_-_Trigger_Manual_Import.js" - -NODE_HARNESS = r""" -const fs = require('fs'); -const vm = require('vm'); -const path = require('path').posix; -const scenario = JSON.parse(process.argv[1]); -let source = fs.readFileSync(process.argv[2], 'utf8') - .replace(/^\/\*\*[\s\S]*?\*\/\s*/, '') - .replace(/^import .*$/m, ''); -source += '\n;globalThis.__exports = { Script };'; -const calls = []; -class FakeRadarr { - constructor(url, apiKey) { this.url = url; this.apiKey = apiKey; } - fetchJson(endpoint, query) { - calls.push({type: 'fetch', endpoint, query}); - if (endpoint === 'queue') return scenario.queueResponse || {records: []}; - if (endpoint === 'parse') return scenario.parseResponse || {}; - if (endpoint === 'manualimport') return scenario.candidates || []; - if (endpoint === 'history') return scenario.historyResponse || {records: []}; - return null; - } - sendCommand(name, body) { - calls.push({type: 'command', name, body}); - return {id: 42}; - } - waitForCompletion(id, timeout) { - calls.push({type: 'wait', id, timeout}); - return scenario.waitSuccess !== false; - } -} -const exactPath = scenario.exactPath; -const context = { - Radarr: FakeRadarr, - Variables: { - 'file.FullName': exactPath, - file: { - FullName: exactPath, - Orig: {FileNameNoExtension: scenario.releaseTitle} - }, - folder: {Name: path.basename(path.dirname(exactPath))} - }, - Flow: {UnMapPath: value => value}, - Logger: {ILog(){}, WLog(){}, ELog(){}, DLog(){}}, - System: {IO: {Path: {GetFileName: path.basename, GetDirectoryName: path.dirname}}}, - Sleep(){}, - encodeURIComponent, - console -}; -vm.createContext(context); -vm.runInContext(source, context); -const result = context.__exports.Script( - 'http://radarr-fhd:7879', - 'test-key', - scenario.scopeRoot, - false, - true, - 300 -); -process.stdout.write(JSON.stringify({result, calls})); -""" - - -def run_script(scenario): - completed = subprocess.run( - ["node", "-e", NODE_HARNESS, json.dumps(scenario), str(SCRIPT)], - check=True, - text=True, - capture_output=True, - ) - return json.loads(completed.stdout) - - -class RadarrImportSafetyTests(unittest.TestCase): - def test_queue_match_imports_only_the_exact_processed_file(self): - release = ( - "Operation.Kabul.-.13.Days.13.Nights.-.13.jours.13.nuits.2025." - "German.DTSHD.DL.1080p.BluRay.AVC.Remux-MAMA" - ) - exact = f"/tdarr/converted/video/{release}/{release}.mkv" - result = run_script( - { - "releaseTitle": release, - "exactPath": exact, - "scopeRoot": "/tdarr/converted/video/", - "queueResponse": { - "records": [ - {"title": release, "movieId": 6658} - ] - }, - "parseResponse": {}, - "candidates": [ - { - "path": exact, - "quality": {"quality": {"id": 7, "name": "Bluray-1080p"}}, - } - ], - } - ) - - self.assertEqual(result["result"], 1) - commands = [call for call in result["calls"] if call["type"] == "command"] - self.assertEqual(len(commands), 1) - self.assertEqual(commands[0]["name"], "ManualImport") - self.assertEqual(commands[0]["body"]["files"][0]["path"], exact) - self.assertEqual(commands[0]["body"]["files"][0]["movieId"], 6658) - self.assertNotIn( - "downloadedMoviesScan", - [call.get("name") for call in commands], - ) - - def test_unknown_movie_fails_closed_without_scanning_the_category_root(self): - release = "Unknown.Multilingual.Release.2026.1080p" - exact = f"/tdarr/converted/video/{release}/{release}.mkv" - result = run_script( - { - "releaseTitle": release, - "exactPath": exact, - "scopeRoot": "/tdarr/converted/video/", - "queueResponse": {"records": []}, - "parseResponse": {}, - "candidates": [], - } - ) - - self.assertEqual(result["result"], -1) - commands = [call for call in result["calls"] if call["type"] == "command"] - self.assertEqual(commands, []) - - def test_manual_import_rejects_a_candidate_for_a_different_file(self): - release = "Expected.Movie.2026.1080p" - exact = f"/tdarr/converted/video/{release}/{release}.mkv" - wrong = "/tdarr/converted/video/Other.Movie/Other.Movie.mkv" - result = run_script( - { - "releaseTitle": release, - "exactPath": exact, - "scopeRoot": "/tdarr/converted/video/", - "queueResponse": {"records": [{"title": release, "movieId": 7001}]}, - "candidates": [ - { - "path": wrong, - "quality": {"quality": {"id": 7, "name": "Bluray-1080p"}}, - }, - { - "path": exact, - "quality": {"quality": {"id": 7, "name": "Bluray-1080p"}}, - }, - ], - } - ) - - command = next(call for call in result["calls"] if call["type"] == "command") - self.assertEqual(command["body"]["files"][0]["path"], exact) - - def test_grab_history_match_recovers_when_download_is_no_longer_in_queue(self): - release = "Tracked.Movie.2026.German.1080p-TEST" - exact = f"/tdarr/converted/video/{release}/{release}.mkv" - result = run_script( - { - "releaseTitle": release, - "exactPath": exact, - "scopeRoot": "/tdarr/converted/video/", - "queueResponse": {"records": []}, - "historyResponse": { - "records": [ - {"sourceTitle": release, "movieId": 7111, "eventType": "grabbed"} - ] - }, - "parseResponse": {}, - "candidates": [ - { - "path": exact, - "quality": {"quality": {"id": 7, "name": "Bluray-1080p"}}, - } - ], - } - ) - - commands = [call for call in result["calls"] if call["type"] == "command"] - self.assertEqual(len(commands), 1) - self.assertEqual(commands[0]["body"]["files"][0]["movieId"], 7111) - history_fetch = next( - call - for call in result["calls"] - if call["type"] == "fetch" and call["endpoint"] == "history" - ) - self.assertIn("eventType=1", history_fetch["query"]) - - -if __name__ == "__main__": - unittest.main()