tdarr/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js

416 lines
No EOL
18 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 Sonarr - Trigger Manual Import
* @description Safely trigger Sonarr ManualImport for exactly one processed episode file.
* @author FileFlows + Sascha
* @revision 13
* @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 als vollständiges Pfadsegment
// übereinstimmt. Ein nacktes Prefix würde z. B. /serien4k
// fälschlich dem FHD-Root /serien zuordnen.
const normalizedCurrentPath = normalizeImportPath(exactImportPath);
const normalizedScopeRoot = normalizeImportPath(ImportPath);
const isInScope = normalizedCurrentPath === normalizedScopeRoot
|| normalizedCurrentPath.startsWith(normalizedScopeRoot + '/');
if (!isInScope) {
Logger.ILog(`Skip: current file '${currentFile}' not under ImportPath '${ImportPath}' - not responsible`);
return 1;
}
const sonarr = new Sonarr(URL, ApiKey);
/*── seriesId / episodeId detection ──────────────────────────────────*/
const filePattern = Variables.file.Orig.FileNameNoExtension;
const folderPattern = System.IO.Path.GetFileName(System.IO.Path.GetDirectoryName(exactImportPath));
const searchPatterns = [folderPattern, filePattern]
.filter(Boolean)
.filter((value, index, values) => values.indexOf(value) === index);
let seriesId = Variables['Sonarr.seriesId'] ?? Variables.TVShowInfo?.id ?? null;
let episodeIds = Variables['Sonarr.episodeIds'] ?? Variables.TVShowInfo?.EpisodesInfo ?? null;
if (!(seriesId && episodeIds)) {
let trackedEpisode = null;
for (const pattern of searchPatterns) {
trackedEpisode = findEpisodeFromQueue(pattern, sonarr, filePattern)
?? findEpisodeFromGrabHistory(pattern, sonarr, filePattern);
if (trackedEpisode) break;
}
if (!trackedEpisode) {
// Ein Staffelpaket-Ordner ist absichtlich mehrdeutig. Zuerst
// den exakten Episoden-Dateinamen parsen, danach erst den
// Ordnernamen als letzten Fallback versuchen.
const parsePatterns = [filePattern, folderPattern]
.filter(Boolean)
.filter((value, index, values) => values.indexOf(value) === index);
for (const pattern of parsePatterns) {
trackedEpisode = parseSeries(pattern, sonarr);
if (trackedEpisode?.id && trackedEpisode?.episodeIds?.length) break;
}
}
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) {
const releaseMetadata = findGrabMetadata(searchPatterns, sonarr, seriesId, episodeIds);
Logger.ILog(`seriesId=${seriesId}, episodeIds=[${episodeIds.join(', ')}] → ManualImport`);
return manualImportWorkflow(sonarr, exactImportPath, importMode, seriesId, episodeIds, releaseMetadata, TimeOut);
}
Logger.ELog(`Unable to identify '${searchPatterns.join("' or '")}' 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, releaseMetadata, 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, releaseMetadata);
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<object>} 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, releaseMetadata) {
const path = src.path || importPath;
const fallbackFolder = System.IO.Path.GetFileName(System.IO.Path.GetDirectoryName(path)) ?? Variables.folder.Name;
const metadata = releaseMetadata || {};
const metadataData = metadata.data || {};
return {
path: path,
folderName: src.folderName || src.name || fallbackFolder,
seriesId: seriesId,
episodeIds: episodeIds,
releaseGroup: metadataData.releaseGroup || metadata.releaseGroup || src.releaseGroup || 'Sonarr',
quality: metadata.quality || src.quality,
languages: metadata.languages || src.languages || [ { id:0, name:'Unknown' } ],
indexerFlags: Number(metadataData.indexerFlags ?? metadata.indexerFlags ?? src.indexerFlags ?? 0),
releaseType: metadataData.releaseType || metadata.releaseType || src.releaseType || 'unknown'
};
}
function findGrabMetadata(searchPatterns, sonarr, seriesId, episodeIds) {
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 = searchPatterns.map(normalizeReleaseTitle);
const matching = records.filter(item =>
item?.seriesId === seriesId
&& episodeIds.includes(item?.episodeId)
&& wanted.includes(normalizeReleaseTitle(item.sourceTitle || ''))
);
if (!matching.length) return null;
const targetKeys = matching
.map(item => `${item.seriesId}:${item.episodeId}`)
.filter((key, index, values) => values.indexOf(key) === index);
if (targetKeys.some(key => !episodeIds.includes(Number(key.split(':')[1])))) return null;
Logger.ILog(`Using metadata from exact Sonarr grab history match (${matching[0].sourceTitle}).`);
return matching[0];
} catch (e) {
Logger.WLog(`Unable to recover Sonarr grab metadata: ${e.message}`);
return null;
}
}
/**
* @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, episodePattern) {
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) {
const resolved = resolvePackEpisode(matches, episodePattern, sonarr);
if (resolved) return resolved;
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, episodePattern) {
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) {
const resolved = resolvePackEpisode(matches, episodePattern, sonarr);
if (resolved) return resolved;
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 resolvePackEpisode(matches, episodePattern, sonarr) {
const coordinates = extractEpisodeCoordinates(episodePattern);
if (!coordinates) return null;
const seriesIds = matches
.map(item => Number(item?.seriesId))
.filter(Boolean)
.filter((value, index, values) => values.indexOf(value) === index);
if (seriesIds.length !== 1) {
Logger.WLog('Tracked season-pack rows point to multiple series; refusing fallback parsing.');
return { conflict: true };
}
try {
const seriesId = seriesIds[0];
const trackedSeries = sonarr.fetchJson(`series/${seriesId}`) || {};
const cleanTitle = normalizeReleaseTitle(trackedSeries.cleanTitle || trackedSeries.title || '');
const normalizedPattern = normalizeReleaseTitle(episodePattern);
if (!cleanTitle || !normalizedPattern.startsWith(cleanTitle)) {
Logger.WLog(
`Tracked release title '${episodePattern}' does not match Sonarr series `
+ `'${trackedSeries.title || seriesId}'; refusing fallback parsing.`
);
return { conflict: true };
}
const query = buildQueryParams({ seriesId });
const episodes = sonarr.fetchJson('episode', query) || [];
const candidates = episodes.filter(item =>
Number(item?.seasonNumber) === coordinates.seasonNumber
&& Number(item?.episodeNumber) === coordinates.episodeNumber
);
if (candidates.length !== 1) return null;
const episodeId = Number(candidates[0]?.id);
const trackedIds = matches.map(item => Number(item?.episodeId)).filter(Boolean);
if (!episodeId || !trackedIds.includes(episodeId)) return null;
Logger.ILog(
`Resolved season-pack match via exact filename S${coordinates.seasonNumber}`
+ `E${coordinates.episodeNumber}: seriesId ${seriesId}, episodeId ${episodeId}.`
);
return { id: seriesId, episodeIds: [episodeId] };
} catch (e) {
Logger.WLog(`Unable to resolve season-pack episode: ${e.message}`);
return null;
}
}
function extractEpisodeCoordinates(value) {
const match = String(value || '').match(/(?:^|[^a-z0-9])s(\d{1,2})e(\d{1,3})(?:[^a-z0-9]|$)/i);
if (!match) return null;
return { seasonNumber: Number(match[1]), episodeNumber: Number(match[2]) };
}
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('&');
}