From 2b953ecd073d48a8aa32a10943eda1574775242d Mon Sep 17 00:00:00 2001 From: sascha Date: Mon, 7 Sep 2026 10:51:07 +0200 Subject: [PATCH] Fix Sonarr exact episode import for percent titles --- .../scripts/Sonarr_-_Trigger_Manual_Import.js | 149 ++++++++++-------- 1 file changed, 83 insertions(+), 66 deletions(-) diff --git a/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js b/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js index dac74c2..6e06bcd 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 Trigger Sonarr ManualImport or downloadedEpisodesScan with skip-guard. + * @description Safely trigger Sonarr ManualImport for exactly one processed episode file. * @author FileFlows + Sascha - * @revision 8 + * @revision 9 * @output Import erfolgreich * @output Fehler * @param {string} URL Sonarr root URL and port @@ -18,16 +18,22 @@ 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']; - ImportPath = ImportPath || Variables.file.FullName; + const currentFile = Variables['file.FullName'] || Variables.file?.FullName || ''; + ImportPath = ImportPath || currentFile; 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; @@ -41,38 +47,25 @@ 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: ${ImportPath}`); + Logger.ILog(`Triggering Path: ${exactImportPath}`); Logger.ILog(`Import Mode: ${importMode}`); if (seriesId && episodeIds) { Logger.ILog(`seriesId=${seriesId}, episodeIds=[${episodeIds.join(', ')}] → ManualImport`); - } 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); + return manualImportWorkflow(sonarr, exactImportPath, importMode, seriesId, episodeIds, TimeOut); } - return result; + Logger.ELog(`Unable to identify '${searchPattern}' uniquely; file remains for manual recovery.`); + return -1; } /*───────────────────────────── ManualImport ─────────────────────────────*/ @@ -94,10 +87,14 @@ function manualImportWorkflow(sonarr, path, mode, seriesId, episodeIds, timeout) return -1; } - /* choose the first candidate that has quality info */ - const cand = candidates.find(c => c.quality && c.quality.quality); + /* 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); if (!cand) { - Logger.WLog('No ManualImport candidate contained quality information.'); + Logger.WLog(`No ManualImport candidate matched exact path '${path}'.`); return -1; } @@ -175,46 +172,66 @@ function sendManualImportCommand(sonarr, cmdBody) { } } -/*──────────────────────── downloadedEpisodesScan branch ─────────────────────*/ +/*────────────────────────────── helpers ──────────────────────────────────*/ -/** - * @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) { +function findEpisodeFromQueue(searchPattern, sonarr) { + if (!searchPattern) return null; 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; + 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(`Send command downloadedEpisodesScan failed: ${e.message}`); + Logger.ELog(`Error fetching Sonarr queue: ${e.message}`); return null; } } -/*────────────────────────────── helpers ──────────────────────────────────*/ +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.