/** * @name Radarr - Trigger Manual Import * @description Safely trigger Radarr ManualImport for exactly one processed file. * @author FileFlows + Sascha * @revision 10 * @output Import erfolgreich * @output Fehler * @param {string} URL Radarr root URL and port * @param {string} ApiKey Radarr 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 { 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; 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). if (ImportPath && ImportPath.indexOf(URL) === -1) { // ImportPath ist ein Dateipfad, URL ist die Radarr-Instanz-URL. // Pruefe ob der ImportPath unter dem erwarteten Verzeichnis liegt. // 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. if (currentFile && ImportPath && currentFile.indexOf(ImportPath) === -1) { Logger.ILog(`Skip: current file '${currentFile}' not under ImportPath '${ImportPath}' - not responsible`); return 1; } } const radarr = new Radarr(URL, ApiKey); /*── movieId detection ──────────────────────────────────*/ const searchPattern = Variables.file.Orig.FileNameNoExtension; let movieId = Variables['Radarr.movieId'] ?? findMovieIdFromQueue(searchPattern, radarr) ?? findMovieIdFromGrabHistory(searchPattern, radarr) ?? parseMovie(searchPattern, radarr) ?? null; Logger.ILog(`Radarr URL: ${URL}`); Logger.ILog(`Triggering Path: ${exactImportPath}`); Logger.ILog(`Import Mode: ${importMode}`); Logger.ILog(movieId ? `movieId: ${movieId} → ManualImport` : 'No unambiguous movieId → fail closed'); if (!movieId) { Logger.ELog(`Unable to identify '${searchPattern}' uniquely; file remains for manual recovery.`); return -1; } return manualImportWorkflow(radarr, exactImportPath, importMode, movieId, TimeOut); } /*───────────────────────────── ManualImport ─────────────────────────────*/ /** * @description Perform ManualImport flow (when movieId is present) and wait until it finishes (or times out). * @param {Radarr} radarr - Radarr API instance * @param {string} importPath - Folder/File path given to movie import * @param {string} mode - 'move' or 'copy' * @param {number} movieId - Radarr movieId to attach to the import * @param {number} timeout - Timeout in milliseconds * @returns {number} 1 on success, −1 on failure */ function manualImportWorkflow(radarr, importPath, mode, movieId, timeout) { const candidates = getManualImportCandidates(radarr, importPath); 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(importPath); 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 '${importPath}'.`); return -1; } const fileObj = buildManualImportFile(cand, movieId, importPath); const cmdBody = { name: 'ManualImport', files: [fileObj], importMode: mode }; const cmdId = sendManualImportCommand(radarr, cmdBody); if (cmdId === null) { Logger.WLog('Failed sending ManualImport command'); return -1; } return waitForCommand(radarr, cmdId, timeout); } /** * @description Retrieve candidate files from Radarr’s **Manual Import** analyser. * @param {Radarr} radarr – Radarr API instance * @param {string} importPath – Folder/File path to inspect * @returns {Array} Array of candidate objects (empty array on error) */ function getManualImportCandidates(radarr, importPath) { try { const query = buildQueryParams({ folder: importPath }); const resp = radarr.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 []; } } /** * @description Build the minimal file-object required by Radarr's ManualImport POST body. * @param {object} src – Single candidate object returned by ManualImport * @param {number} movieId – Radarr movieId to which the file is linked * @returns {object} File descriptor for ManualImport */ function buildManualImportFile(src, movieId, 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, movieId: movieId, releaseGroup: src.releaseGroup || 'Radarr', quality: src.quality, languages: src.languages || [ { id:0, name:'Unknown' } ], indexerFlags: src.indexerFlags ?? 0 }; } /** * @description Send the **ManualImport** command to Radarr and return command-id * @param {Radarr} radarr – Radarr API instance * @param {object} cmdBody – Complete payload for ManualImport command * @returns {number|null} Command id on success, null on failure */ function sendManualImportCommand(radarr, cmdBody) { try { const resp = radarr.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 ──────────────────────────────────*/ /** * @description Poll Radarr until the command id completes or timeout is reached. * @param {Radarr} radarr - Radarr 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(radarr, cmdId, timeoutMs) { const ok = radarr.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; } /** * 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) * @param {Object} radarr - Radarr API instance * @returns {Object|null} Parsed movie object, or null if none. */ function parseMovie(searchPattern, radarr) { let endpoint = 'parse' let sp = null; Logger.ILog(`Trying to Parse Movie name using Radarr Parsing`); if (!searchPattern) { Logger.WLog('No pattern passed in to find movie'); return null; } else { sp = searchPattern.toLowerCase(); } try { const queryParams = buildQueryParams({ title: sp }); const item = radarr.fetchJson(endpoint, queryParams); if (item?.movie?.title) { Logger.ILog(`Found Movie: ${item.movie.title}`); return item.movie.id; } Logger.WLog(`The ${endpoint} endpoint did not recognise this title.`); return null; } catch (e) { Logger.ELog(`Error fetching Radarr ${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('&'); }