402 lines
No EOL
17 KiB
JavaScript
402 lines
No EOL
17 KiB
JavaScript
/**
|
||
* @name Sonarr - Trigger Manual Import
|
||
* @description Safely trigger Sonarr ManualImport for exactly one processed episode file.
|
||
* @author FileFlows + Sascha
|
||
* @revision 12
|
||
* @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) return null;
|
||
|
||
try {
|
||
const seriesId = seriesIds[0];
|
||
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('&');
|
||
} |