Compare commits

..

3 commits

2 changed files with 207 additions and 66 deletions

View file

@ -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'
);
}
/*─ Execute first workflow, then fail-over if needed ─*/
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; Logger.ELog(`Unable to identify '${searchPattern}' uniquely; file remains for manual recovery.`);
return -1;
} }
/*───────────────────────────── 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.

View file

@ -0,0 +1,124 @@
import json
import subprocess
import unittest
from pathlib import Path
SCRIPT = Path(__file__).parents[1] / "scripts" / "Sonarr_-_Trigger_Manual_Import.js"
NODE_HARNESS = r"""
const fs = require('fs');
const vm = require('vm');
const path = require('path').posix;
const scenario = JSON.parse(process.argv[1]);
let source = fs.readFileSync(process.argv[2], 'utf8')
.replace(/^\/\*\*[\s\S]*?\*\/\s*/, '')
.replace(/^import .*$/m, '');
source += '\n;globalThis.__exports = { Script };';
const calls = [];
class FakeSonarr {
constructor(url, apiKey) { this.url = url; this.apiKey = apiKey; }
fetchJson(endpoint, query) {
calls.push({type: 'fetch', endpoint, query});
if (endpoint === 'queue') return scenario.queueResponse || {records: []};
if (endpoint === 'history') return scenario.historyResponse || {records: []};
if (endpoint === 'parse') return scenario.parseResponse || {};
if (endpoint === 'manualimport') return scenario.candidates || [];
return null;
}
sendCommand(name, body) {
calls.push({type: 'command', name, body});
return {id: 42};
}
waitForCompletion(id, timeout) {
calls.push({type: 'wait', id, timeout});
return scenario.waitSuccess !== false;
}
}
const exactPath = scenario.exactPath;
const context = {
Sonarr: FakeSonarr,
Variables: {
'file.FullName': exactPath,
file: {
FullName: exactPath,
Orig: {FileNameNoExtension: scenario.releaseTitle}
},
folder: {Name: path.basename(path.dirname(exactPath))}
},
Flow: {UnMapPath: value => value},
Logger: {ILog(){}, WLog(){}, ELog(){}, DLog(){}},
System: {IO: {Path: {GetFileName: path.basename, GetDirectoryName: path.dirname}}},
encodeURIComponent,
console
};
vm.createContext(context);
vm.runInContext(source, context);
const result = context.__exports.Script(
'http://sonarr-fhd:8990',
'test-key',
scenario.scopeRoot,
false,
true,
300
);
process.stdout.write(JSON.stringify({result, calls}));
"""
def run_script(scenario):
completed = subprocess.run(
["node", "-e", NODE_HARNESS, json.dumps(scenario), str(SCRIPT)],
check=True,
text=True,
capture_output=True,
)
return json.loads(completed.stdout)
class SonarrImportSafetyTests(unittest.TestCase):
def test_percent_title_matches_queue_and_imports_exact_episode_file(self):
source_title = (
"100%.Wolf.S01E01.Die.Aufnahmeprufung.German.1080p."
"RTLP.WEB-DL.AAC2.0.H.264-GLOTZE"
)
queue_title = source_title.replace("100%.", "100.")
exact = f"/tdarr/converted/serien/{queue_title}/{source_title}.mkv"
result = run_script(
{
"releaseTitle": source_title,
"exactPath": exact,
"scopeRoot": "/tdarr/converted/serien",
"queueResponse": {
"records": [
{"title": queue_title, "seriesId": 586, "episodeId": 42301}
]
},
"parseResponse": {},
"candidates": [
{
"path": exact,
"releaseGroup": "GLOTZE",
"quality": {"quality": {"id": 3, "name": "WEBDL-1080p"}},
"languages": [{"id": 4, "name": "German"}],
}
],
}
)
self.assertEqual(result["result"], 1)
commands = [call for call in result["calls"] if call["type"] == "command"]
self.assertEqual(len(commands), 1)
self.assertEqual(commands[0]["name"], "ManualImport")
imported = commands[0]["body"]["files"][0]
self.assertEqual(imported["path"], exact)
self.assertEqual(imported["seriesId"], 586)
self.assertEqual(imported["episodeIds"], [42301])
self.assertNotIn(
"downloadedEpisodesScan",
[call.get("name") for call in commands],
)
if __name__ == "__main__":
unittest.main()