/** * @name Sonarr - Trigger Manual Import * @description Safely trigger Sonarr ManualImport for exactly one processed episode file. * @author FileFlows + Sascha * @revision 9 * @output Import erfolgreich * @output Fehler * @param {string} URL Sonarr root URL and port * @param {string} ApiKey Sonarr API Key * @param {string} ImportPath Output path for import triggering * @param {bool} UseUnmappedPath Whether to Unmap the path * @param {bool} MoveMode Import mode: true=move, false=copy * @param {int} TimeOut Timeout in seconds (max 3600) */ 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; 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) { if (currentFile && ImportPath && currentFile.indexOf(ImportPath) === -1) { Logger.ILog(`Skip: current file '${currentFile}' not under ImportPath '${ImportPath}' - not responsible`); return 1; } } const sonarr = new Sonarr(URL, ApiKey); /*── seriesId / episodeId detection ──────────────────────────────────*/ const searchPattern = Variables.file.Orig.FileNameNoExtension; 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(`Import Mode: ${importMode}`); if (seriesId && episodeIds) { Logger.ILog(`seriesId=${seriesId}, episodeIds=[${episodeIds.join(', ')}] → ManualImport`); return manualImportWorkflow(sonarr, exactImportPath, importMode, seriesId, episodeIds, TimeOut); } Logger.ELog(`Unable to identify '${searchPattern}' uniquely; file remains for manual recovery.`); return -1; } /*───────────────────────────── ManualImport ─────────────────────────────*/ /** * @description Perform ManualImport flow and wait until it finishes (or times out). * @param {Sonarr} sonarr - Sonarr API instance * @param {string} importPath - Folder/File path given to seires import * @param {string} mode - 'move' or 'copy' * @param {number} seriesId - Sonarr seriesId to attach to the import * @param {number} episodeIds - Sonarr episodeIds to attach to the import * @param {number} timeout - Timeout in milliseconds * @returns {number} 1 on success, −1 on failure */ function manualImportWorkflow(sonarr, path, mode, seriesId, episodeIds, timeout) { const candidates = getManualImportCandidates(sonarr, path); if (!candidates.length) { Logger.WLog('No candidates returned by fetching ManualImport'); 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); if (!cand) { Logger.WLog(`No ManualImport candidate matched exact path '${path}'.`); return -1; } const fileObj = buildManualImportFile(cand, seriesId, episodeIds, path); const cmdBody = { name: 'ManualImport', files: [fileObj], importMode: mode }; const cmdId = sendManualImportCommand(sonarr, cmdBody); if (cmdId === null) { Logger.WLog('Failed sending ManualImport command'); return -1; } return waitForCommand(sonarr, cmdId, timeout); } /** * @description Retrieve candidate files from Sonarr’s **Manual Import** analyser. * @param {Sonarr} sonarr - Sonarr API instance * @param {string} importPath - Folder/File path to inspect * @returns {Array} Array of candidate objects (empty array on error) */ function getManualImportCandidates(sonarr, importPath) { try { const query = buildQueryParams({ folder: importPath }); const resp = sonarr.fetchJson('manualimport', query) || []; Logger.ILog(`ManualImport returned ${resp.length} candidate(s).`); return resp; } catch (e) { Logger.ELog(`Fetch candidates from ManualImport failed: ${e.message}`); return []; } } /** * Build a fully-populated file object for Sonarr ManualImport. */ /** * @description Build the minimal file-object required by Sonarr's ManualImport POST body. * @param {object} src – Single candidate object returned by ManualImport * @param {number} seriesId – Sonarr seriesId to which the file is linked * @param {number} episodeIds – Sonarr episodeIds to which the file is linked * @returns {object} File descriptor for ManualImport */ function buildManualImportFile(src, seriesId, episodeIds, importPath) { const path = src.path || importPath; const fallbackFolder = System.IO.Path.GetFileName(System.IO.Path.GetDirectoryName(path)) ?? Variables.folder.Name; return { path: path, folderName: src.folderName || src.name || fallbackFolder, seriesId: seriesId, episodeIds: episodeIds, releaseGroup: src.releaseGroup || 'Sonarr', quality: src.quality, languages: src.languages || [ { id:0, name:'Unknown' } ], indexerFlags: src.indexerFlags ?? 0, releaseType: src.releaseType ?? 'unknown' }; } /** * @description Send the **ManualImport** command to Sonarr and return command-id * @param {Sonarr} sonarr – Sonarr API instance * @param {object} cmdBody – Complete payload for ManualImport command * @returns {number|null} Command id on success, null on failure */ function sendManualImportCommand(sonarr, cmdBody) { try { const resp = sonarr.sendCommand('ManualImport', cmdBody); const cmdId = resp?.id; Logger.ILog(cmdId ? `ManualImport queued (cmdId=${cmdId}).` : 'ManualImport failed.'); return cmdId ?? null; } catch (e) { Logger.ELog(`Send command ManualImport 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 * @param {number} cmdId - Command id returned by sendCommand * @param {number} timeoutMs - Maximum time to wait in milliseconds * @returns {number} 1 on success, −1 on timeout */ function waitForCommand(sonarr, cmdId, timeoutMs) { const ok = sonarr.waitForCompletion(cmdId, timeoutMs); if (ok) { Logger.DLog(`Command ${cmdId} completed.`); Logger.ILog('Import completed successfully'); return 1; } Logger.WLog('Import timed out or failed.'); return -1; } /** * @description Parse the series name using Sonarr parsing based on the search pattern. * @param {string} searchPattern - The search string (file or folder name) * @param {Object} sonarr - Sonarr API instance * @returns {Object|null} Parsed Series object, or null if none. */ function parseSeries(searchPattern, sonarr) { let endpoint = 'parse' let sp = null; Logger.ILog(`Trying to Parse Series name using Sonarr Parsing`);; if (!searchPattern) { Logger.WLog('No pattern passed in to find series'); return null; } else { sp = searchPattern.toLowerCase(); } try { const queryParams = buildQueryParams({ title: sp }); const item = sonarr.fetchJson(endpoint, queryParams); if (item?.series?.title) { if (item.episodes) { item.series.episodeIds = item.episodes.map(ep => ep.id); } return item.series; } Logger.WLog(`The ${endpoint} endpoint did not recognise this title.`); return null; } catch (e) { Logger.ELog(`Error fetching Sonarr ${endpoint} endpoint: ${e.message}`); return null; } } /** * @description Constructs a query string from the given parameters * @param {Object} params - Key-value pairs to be converted into a query string * @returns {string} The constructed query string */ function buildQueryParams(obj) { return Object.entries(obj) .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) .join('&'); }