tdarr/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js

320 lines
No EOL
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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<object>} 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('&');
}