Fix Sonarr import for percent titles #2
1 changed files with 83 additions and 66 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* @name Sonarr - Trigger Manual Import
|
* @name Sonarr - Trigger Manual Import
|
||||||
* @description Trigger Sonarr ManualImport or downloadedEpisodesScan with skip-guard.
|
* @description Safely trigger Sonarr ManualImport for exactly one processed episode file.
|
||||||
* @author FileFlows + Sascha
|
* @author FileFlows + Sascha
|
||||||
* @revision 8
|
* @revision 9
|
||||||
* @output Import erfolgreich
|
* @output Import erfolgreich
|
||||||
* @output Fehler
|
* @output Fehler
|
||||||
* @param {string} URL Sonarr root URL and port
|
* @param {string} URL Sonarr root URL and port
|
||||||
|
|
@ -18,16 +18,22 @@ import { Sonarr } from 'Shared/Sonarr';
|
||||||
function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
|
function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
|
||||||
URL = (URL || Variables['Sonarr.Url'] || Variables['Sonarr.URI']).replace(/\/+$/g, '');
|
URL = (URL || Variables['Sonarr.Url'] || Variables['Sonarr.URI']).replace(/\/+$/g, '');
|
||||||
ApiKey = ApiKey || Variables['Sonarr.ApiKey'];
|
ApiKey = ApiKey || Variables['Sonarr.ApiKey'];
|
||||||
ImportPath = ImportPath || Variables.file.FullName;
|
const currentFile = Variables['file.FullName'] || Variables.file?.FullName || '';
|
||||||
|
ImportPath = ImportPath || currentFile;
|
||||||
TimeOut = TimeOut ? Math.min(TimeOut, 3600) * 1000 : 60000; // ms
|
TimeOut = TimeOut ? Math.min(TimeOut, 3600) * 1000 : 60000; // ms
|
||||||
ImportPath = UseUnmappedPath ? Flow.UnMapPath(ImportPath) : ImportPath;
|
ImportPath = UseUnmappedPath ? Flow.UnMapPath(ImportPath) : ImportPath;
|
||||||
|
const exactImportPath = UseUnmappedPath ? Flow.UnMapPath(currentFile) : currentFile;
|
||||||
const importMode = MoveMode ? 'move' : 'copy';
|
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
|
// ── SKIP-GUARD: Nur API-Call machen, wenn der aktuelle ImportPath
|
||||||
// mit dem konfigurierten Pfad uebereinstimmt. Sonst sofort OK.
|
// mit dem konfigurierten Pfad uebereinstimmt. Sonst sofort OK.
|
||||||
// Das verhindert 3 unnoetige API-Calls pro Datei (4 Arr-Instanzen).
|
// Das verhindert 3 unnoetige API-Calls pro Datei (4 Arr-Instanzen).
|
||||||
if (ImportPath && ImportPath.indexOf(URL) === -1) {
|
if (ImportPath && ImportPath.indexOf(URL) === -1) {
|
||||||
var currentFile = Variables['file.FullName'] || '';
|
|
||||||
if (currentFile && ImportPath && currentFile.indexOf(ImportPath) === -1) {
|
if (currentFile && ImportPath && currentFile.indexOf(ImportPath) === -1) {
|
||||||
Logger.ILog(`Skip: current file '${currentFile}' not under ImportPath '${ImportPath}' - not responsible`);
|
Logger.ILog(`Skip: current file '${currentFile}' not under ImportPath '${ImportPath}' - not responsible`);
|
||||||
return 1;
|
return 1;
|
||||||
|
|
@ -41,38 +47,25 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
|
||||||
let seriesId = Variables['Sonarr.seriesId'] ?? Variables.TVShowInfo?.id ?? null;
|
let seriesId = Variables['Sonarr.seriesId'] ?? Variables.TVShowInfo?.id ?? null;
|
||||||
let episodeIds = Variables['Sonarr.episodeIds'] ?? Variables.TVShowInfo?.EpisodesInfo ?? 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(`Sonarr URL: ${URL}`);
|
||||||
Logger.ILog(`Triggering Path: ${ImportPath}`);
|
Logger.ILog(`Triggering Path: ${exactImportPath}`);
|
||||||
Logger.ILog(`Import Mode: ${importMode}`);
|
Logger.ILog(`Import Mode: ${importMode}`);
|
||||||
|
|
||||||
if (seriesId && episodeIds) {
|
if (seriesId && episodeIds) {
|
||||||
Logger.ILog(`seriesId=${seriesId}, episodeIds=[${episodeIds.join(', ')}] → ManualImport`);
|
Logger.ILog(`seriesId=${seriesId}, episodeIds=[${episodeIds.join(', ')}] → ManualImport`);
|
||||||
} else {
|
return manualImportWorkflow(sonarr, exactImportPath, importMode, seriesId, episodeIds, TimeOut);
|
||||||
Logger.ILog('No seriesId/episodeIds, Trying parsing from File/Folder name');
|
|
||||||
const parsedSeries = parseSeries(searchPattern, sonarr)
|
|
||||||
seriesId = parsedSeries?.id ?? null
|
|
||||||
episodeIds = parsedSeries?.episodeIds ?? null
|
|
||||||
Logger.ILog(
|
|
||||||
seriesId && episodeIds
|
|
||||||
? `seriesId=${seriesId}, episodeIds=[${episodeIds.join(', ')}] → ManualImport`
|
|
||||||
: 'No seriesId/episodeIds → downloadedEpisodesScan'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.ELog(`Unable to identify '${searchPattern}' uniquely; file remains for manual recovery.`);
|
||||||
/*─ Execute first workflow, then fail-over if needed ─*/
|
return -1;
|
||||||
let result;
|
|
||||||
if (seriesId && episodeIds) {
|
|
||||||
result = manualImportWorkflow(sonarr, ImportPath, importMode, seriesId, episodeIds, TimeOut);
|
|
||||||
if (result !== 1) {
|
|
||||||
Logger.WLog('ManualImport failed, falling back to downloadedEpisodesScan.');
|
|
||||||
result = scanWorkflow(sonarr, ImportPath, importMode, TimeOut);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result = scanWorkflow(sonarr, ImportPath, importMode, TimeOut);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*───────────────────────────── ManualImport ─────────────────────────────*/
|
/*───────────────────────────── ManualImport ─────────────────────────────*/
|
||||||
|
|
@ -94,10 +87,14 @@ function manualImportWorkflow(sonarr, path, mode, seriesId, episodeIds, timeout)
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* choose the first candidate that has quality info */
|
/* accept only the candidate for the exact processed file */
|
||||||
const cand = candidates.find(c => c.quality && c.quality.quality);
|
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) {
|
if (!cand) {
|
||||||
Logger.WLog('No ManualImport candidate contained quality information.');
|
Logger.WLog(`No ManualImport candidate matched exact path '${path}'.`);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,46 +172,66 @@ function sendManualImportCommand(sonarr, cmdBody) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*──────────────────────── downloadedEpisodesScan branch ─────────────────────*/
|
/*────────────────────────────── helpers ──────────────────────────────────*/
|
||||||
|
|
||||||
/**
|
function findEpisodeFromQueue(searchPattern, sonarr) {
|
||||||
* @description Perform the downloadedEpisodesScan flow (when seriesId is absent).
|
if (!searchPattern) return null;
|
||||||
* @param {Sonarr} sonarr – Sonarr API instance
|
|
||||||
* @param {string} importPath – Folder/File path for downloadedEpisodesScan
|
|
||||||
* @param {string} mode – 'move' or 'copy'
|
|
||||||
* @param {number} timeout – Timeout in milliseconds
|
|
||||||
* @returns {number} 1 on success, −1 on failure
|
|
||||||
*/
|
|
||||||
function scanWorkflow(sonarr, importPath, mode, timeout) {
|
|
||||||
const cmdId = sendDownloadedEpisodesScan(sonarr, importPath, mode);
|
|
||||||
if (cmdId === null) {
|
|
||||||
Logger.WLog('Faild sending downloadedEpisodesScan command');
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return waitForCommand(sonarr, cmdId, timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Send the **downloadedEpisodesScan** command.
|
|
||||||
* @param {Sonarr} sonarr - Sonarr API instance
|
|
||||||
* @param {string} importPath - Folder/File path for the scan
|
|
||||||
* @param {string} mode - 'move' or 'copy'
|
|
||||||
* @returns {number|null} command id or null
|
|
||||||
*/
|
|
||||||
function sendDownloadedEpisodesScan(sonarr, importPath, mode) {
|
|
||||||
try {
|
try {
|
||||||
const resp = sonarr.sendCommand('downloadedEpisodesScan', { path: importPath, importMode: mode });
|
const query = buildQueryParams({ page: 1, pageSize: 200, includeUnknownSeriesItems: true });
|
||||||
const cmdId = resp?.id;
|
const response = sonarr.fetchJson('queue', query) || {};
|
||||||
Logger.ILog(cmdId ? `downloadedEpisodesScan queued (cmdId=${cmdId}).` : 'downloadedEpisodesScan failed.');
|
const records = Array.isArray(response) ? response : (response.records || []);
|
||||||
return cmdId ?? null;
|
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) {
|
} catch (e) {
|
||||||
Logger.ELog(`Send command downloadedEpisodesScan failed: ${e.message}`);
|
Logger.ELog(`Error fetching Sonarr queue: ${e.message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*────────────────────────────── helpers ──────────────────────────────────*/
|
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.
|
* @description Poll Sonarr until the command id completes or timeout is reached.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue