diff --git a/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js b/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js index 6e06bcd..dac74c2 100644 --- a/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js +++ b/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js @@ -1,8 +1,8 @@ /** * @name Sonarr - Trigger Manual Import - * @description Safely trigger Sonarr ManualImport for exactly one processed episode file. + * @description Trigger Sonarr ManualImport or downloadedEpisodesScan with skip-guard. * @author FileFlows + Sascha - * @revision 9 + * @revision 8 * @output Import erfolgreich * @output Fehler * @param {string} URL Sonarr root URL and port @@ -18,22 +18,16 @@ import { Sonarr } from 'Shared/Sonarr'; function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { URL = (URL || Variables['Sonarr.Url'] || Variables['Sonarr.URI']).replace(/\/+$/g, ''); ApiKey = ApiKey || Variables['Sonarr.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 Sonarr 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). if (ImportPath && ImportPath.indexOf(URL) === -1) { + 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; @@ -47,25 +41,38 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { let seriesId = Variables['Sonarr.seriesId'] ?? Variables.TVShowInfo?.id ?? null; let episodeIds = Variables['Sonarr.episodeIds'] ?? Variables.TVShowInfo?.EpisodesInfo ?? null; - if (!(seriesId && episodeIds)) { - const trackedEpisode = findEpisodeFromQueue(searchPattern, sonarr) - ?? findEpisodeFromGrabHistory(searchPattern, sonarr) - ?? parseSeries(searchPattern, sonarr); - seriesId = trackedEpisode?.id ?? null; - episodeIds = trackedEpisode?.episodeIds ?? null; - } - Logger.ILog(`Sonarr URL: ${URL}`); - Logger.ILog(`Triggering Path: ${exactImportPath}`); + Logger.ILog(`Triggering Path: ${ImportPath}`); Logger.ILog(`Import Mode: ${importMode}`); if (seriesId && episodeIds) { Logger.ILog(`seriesId=${seriesId}, episodeIds=[${episodeIds.join(', ')}] → ManualImport`); - return manualImportWorkflow(sonarr, exactImportPath, importMode, seriesId, episodeIds, TimeOut); + } else { + Logger.ILog('No seriesId/episodeIds, Trying parsing from File/Folder name'); + const parsedSeries = parseSeries(searchPattern, sonarr) + seriesId = parsedSeries?.id ?? null + episodeIds = parsedSeries?.episodeIds ?? null + Logger.ILog( + seriesId && episodeIds + ? `seriesId=${seriesId}, episodeIds=[${episodeIds.join(', ')}] → ManualImport` + : 'No seriesId/episodeIds → downloadedEpisodesScan' + ); + } + + + /*─ Execute first workflow, then fail-over if needed ─*/ + let result; + if (seriesId && episodeIds) { + result = manualImportWorkflow(sonarr, ImportPath, importMode, seriesId, episodeIds, TimeOut); + if (result !== 1) { + Logger.WLog('ManualImport failed, falling back to downloadedEpisodesScan.'); + result = scanWorkflow(sonarr, ImportPath, importMode, TimeOut); + } + } else { + result = scanWorkflow(sonarr, ImportPath, importMode, TimeOut); } - Logger.ELog(`Unable to identify '${searchPattern}' uniquely; file remains for manual recovery.`); - return -1; + return result; } /*───────────────────────────── ManualImport ─────────────────────────────*/ @@ -87,14 +94,10 @@ function manualImportWorkflow(sonarr, path, mode, seriesId, episodeIds, timeout) return -1; } - /* accept only the candidate for the exact processed file */ - const exactPath = normalizeImportPath(path); - 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 '${path}'.`); + Logger.WLog('No ManualImport candidate contained quality information.'); return -1; } @@ -172,67 +175,47 @@ function sendManualImportCommand(sonarr, cmdBody) { } } +/*──────────────────────── downloadedEpisodesScan branch ─────────────────────*/ + +/** + * @description Perform the downloadedEpisodesScan flow (when seriesId is absent). + * @param {Sonarr} sonarr – Sonarr API instance + * @param {string} importPath – Folder/File path for downloadedEpisodesScan + * @param {string} mode – 'move' or 'copy' + * @param {number} timeout – Timeout in milliseconds + * @returns {number} 1 on success, −1 on failure + */ +function scanWorkflow(sonarr, importPath, mode, timeout) { + const cmdId = sendDownloadedEpisodesScan(sonarr, importPath, mode); + if (cmdId === null) { + Logger.WLog('Faild sending downloadedEpisodesScan command'); + return -1; + } + + return waitForCommand(sonarr, cmdId, timeout); +} + +/** + * @description Send the **downloadedEpisodesScan** command. + * @param {Sonarr} sonarr - Sonarr 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 sendDownloadedEpisodesScan(sonarr, importPath, mode) { + try { + const resp = sonarr.sendCommand('downloadedEpisodesScan', { path: importPath, importMode: mode }); + const cmdId = resp?.id; + Logger.ILog(cmdId ? `downloadedEpisodesScan queued (cmdId=${cmdId}).` : 'downloadedEpisodesScan failed.'); + return cmdId ?? null; + } catch (e) { + Logger.ELog(`Send command downloadedEpisodesScan failed: ${e.message}`); + return null; + } +} + /*────────────────────────────── helpers ──────────────────────────────────*/ -function findEpisodeFromQueue(searchPattern, sonarr) { - if (!searchPattern) return null; - try { - const query = buildQueryParams({ page: 1, pageSize: 200, includeUnknownSeriesItems: true }); - const response = sonarr.fetchJson('queue', query) || {}; - const records = Array.isArray(response) ? response : (response.records || []); - const wanted = normalizeReleaseTitle(searchPattern); - const matches = records.filter(item => - item?.seriesId && item?.episodeId && normalizeReleaseTitle(item.title || '') === wanted - ); - if (matches.length === 1) { - Logger.ILog(`Matched Sonarr queue seriesId ${matches[0].seriesId}, episodeId ${matches[0].episodeId}.`); - return { id: matches[0].seriesId, episodeIds: [matches[0].episodeId] }; - } - if (matches.length > 1) Logger.WLog(`Ambiguous Sonarr queue match (${matches.length} records).`); - return null; - } catch (e) { - Logger.ELog(`Error fetching Sonarr queue: ${e.message}`); - return null; - } -} - -function findEpisodeFromGrabHistory(searchPattern, sonarr) { - if (!searchPattern) return null; - try { - const query = buildQueryParams({ - page: 1, pageSize: 250, eventType: 1, - sortKey: 'date', sortDirection: 'descending' - }); - const response = sonarr.fetchJson('history', query) || {}; - const records = Array.isArray(response) ? response : (response.records || []); - const wanted = normalizeReleaseTitle(searchPattern); - const matches = records.filter(item => - item?.seriesId && item?.episodeId && normalizeReleaseTitle(item.sourceTitle || '') === wanted - ); - const keys = matches - .map(item => `${item.seriesId}:${item.episodeId}`) - .filter((key, index, values) => values.indexOf(key) === index); - if (keys.length === 1) { - const [seriesId, episodeId] = keys[0].split(':').map(Number); - Logger.ILog(`Matched Sonarr grab history seriesId ${seriesId}, episodeId ${episodeId}.`); - return { id: seriesId, episodeIds: [episodeId] }; - } - if (keys.length > 1) Logger.WLog(`Ambiguous Sonarr grab history match (${keys.length} episodes).`); - return null; - } catch (e) { - Logger.ELog(`Error fetching Sonarr 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 Poll Sonarr until the command id completes or timeout is reached. * @param {Sonarr} sonarr - Sonarr API instance diff --git a/fileflows/tests/test_sonarr_import_safety.py b/fileflows/tests/test_sonarr_import_safety.py deleted file mode 100644 index b05ee18..0000000 --- a/fileflows/tests/test_sonarr_import_safety.py +++ /dev/null @@ -1,124 +0,0 @@ -import json -import subprocess -import unittest -from pathlib import Path - - -SCRIPT = Path(__file__).parents[1] / "scripts" / "Sonarr_-_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 FakeSonarr { - 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 === 'history') return scenario.historyResponse || {records: []}; - if (endpoint === 'parse') return scenario.parseResponse || {}; - if (endpoint === 'manualimport') return scenario.candidates || []; - 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 = { - Sonarr: FakeSonarr, - 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}}}, - encodeURIComponent, - console -}; -vm.createContext(context); -vm.runInContext(source, context); -const result = context.__exports.Script( - 'http://sonarr-fhd:8990', - '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 SonarrImportSafetyTests(unittest.TestCase): - def test_percent_title_matches_queue_and_imports_exact_episode_file(self): - source_title = ( - "100%.Wolf.S01E01.Die.Aufnahmeprufung.German.1080p." - "RTLP.WEB-DL.AAC2.0.H.264-GLOTZE" - ) - queue_title = source_title.replace("100%.", "100.") - exact = f"/tdarr/converted/serien/{queue_title}/{source_title}.mkv" - result = run_script( - { - "releaseTitle": source_title, - "exactPath": exact, - "scopeRoot": "/tdarr/converted/serien", - "queueResponse": { - "records": [ - {"title": queue_title, "seriesId": 586, "episodeId": 42301} - ] - }, - "parseResponse": {}, - "candidates": [ - { - "path": exact, - "releaseGroup": "GLOTZE", - "quality": {"quality": {"id": 3, "name": "WEBDL-1080p"}}, - "languages": [{"id": 4, "name": "German"}], - } - ], - } - ) - - 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") - imported = commands[0]["body"]["files"][0] - self.assertEqual(imported["path"], exact) - self.assertEqual(imported["seriesId"], 586) - self.assertEqual(imported["episodeIds"], [42301]) - self.assertNotIn( - "downloadedEpisodesScan", - [call.get("name") for call in commands], - ) - - -if __name__ == "__main__": - unittest.main()