Merge pull request 'FileFlows Arr-Handoff robust gegen Duplicate-Loops' (#3) from fix/fileflows-arr-handoff-robustness-20260912 into main

This commit is contained in:
sascha 2026-09-12 12:10:56 +02:00
commit 7f33195ad3
6 changed files with 303 additions and 35 deletions

View file

@ -310,7 +310,7 @@
"ErrorConnection": {
"Input": 1,
"Output": -1,
"InputNode": "64a5eb5c-13d3-47d7-b67a-a013fad58145"
"InputNode": "6bf4e65f-5376-4b52-a754-bc0972620be4"
},
"Type": 9,
"Model": {
@ -343,7 +343,7 @@
"ErrorConnection": {
"Input": 1,
"Output": -1,
"InputNode": "64a5eb5c-13d3-47d7-b67a-a013fad58145"
"InputNode": "6bf4e65f-5376-4b52-a754-bc0972620be4"
},
"Type": 9,
"Model": {
@ -377,7 +377,7 @@
"ErrorConnection": {
"Input": 1,
"Output": -1,
"InputNode": "64a5eb5c-13d3-47d7-b67a-a013fad58145"
"InputNode": "6bf4e65f-5376-4b52-a754-bc0972620be4"
},
"Type": 9,
"Model": {
@ -411,7 +411,7 @@
"ErrorConnection": {
"Input": 1,
"Output": -1,
"InputNode": "64a5eb5c-13d3-47d7-b67a-a013fad58145"
"InputNode": "6bf4e65f-5376-4b52-a754-bc0972620be4"
},
"Type": 9,
"Model": {

View file

@ -2,7 +2,7 @@
* @name Radarr - Trigger Manual Import
* @description Safely trigger Radarr ManualImport for exactly one processed file.
* @author FileFlows + Sascha
* @revision 10
* @revision 11
* @output Import erfolgreich
* @output Fehler
* @param {string} URL Radarr root URL and port
@ -48,12 +48,20 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
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;
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 movieId = Variables['Radarr.movieId'] ?? null;
for (const pattern of searchPatterns) {
if (!movieId) {
movieId = findMovieIdFromQueue(pattern, radarr)
?? findMovieIdFromGrabHistory(pattern, radarr);
}
if (movieId) break;
}
if (!movieId) movieId = parseMovie(searchPatterns[0] || '', radarr);
Logger.ILog(`Radarr URL: ${URL}`);
Logger.ILog(`Triggering Path: ${exactImportPath}`);
@ -61,11 +69,12 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
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.`);
Logger.ELog(`Unable to identify '${searchPatterns.join("' or '")}' uniquely; file remains for manual recovery.`);
return -1;
}
return manualImportWorkflow(radarr, exactImportPath, importMode, movieId, TimeOut);
const releaseMetadata = findGrabMetadata(searchPatterns, radarr, movieId);
return manualImportWorkflow(radarr, exactImportPath, importMode, movieId, releaseMetadata, TimeOut);
}
@ -80,7 +89,7 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
* @param {number} timeout - Timeout in milliseconds
* @returns {number} 1 on success, −1 on failure
*/
function manualImportWorkflow(radarr, importPath, mode, movieId, timeout) {
function manualImportWorkflow(radarr, importPath, mode, movieId, releaseMetadata, timeout) {
const candidates = getManualImportCandidates(radarr, importPath);
if (!candidates.length) {
Logger.WLog('No candidates returned by fetching ManualImport');
@ -98,7 +107,7 @@ function manualImportWorkflow(radarr, importPath, mode, movieId, timeout) {
return -1;
}
const fileObj = buildManualImportFile(cand, movieId, importPath);
const fileObj = buildManualImportFile(cand, movieId, importPath, releaseMetadata);
const cmdBody = { name: 'ManualImport', files: [fileObj], importMode: mode };
const cmdId = sendManualImportCommand(radarr, cmdBody);
if (cmdId === null) {
@ -133,21 +142,49 @@ function getManualImportCandidates(radarr, importPath) {
* @param {number} movieId – Radarr movieId to which the file is linked
* @returns {object} File descriptor for ManualImport
*/
function buildManualImportFile(src, movieId, importPath) {
function buildManualImportFile(src, movieId, 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,
movieId: movieId,
releaseGroup: src.releaseGroup || 'Radarr',
quality: src.quality,
languages: src.languages || [ { id:0, name:'Unknown' } ],
indexerFlags: src.indexerFlags ?? 0
releaseGroup: metadataData.releaseGroup || metadata.releaseGroup || src.releaseGroup || 'Radarr',
quality: metadata.quality || src.quality,
languages: metadata.languages || src.languages || [ { id:0, name:'Unknown' } ],
indexerFlags: Number(metadataData.indexerFlags ?? metadata.indexerFlags ?? src.indexerFlags ?? 0)
};
}
function findGrabMetadata(searchPatterns, radarr, movieId) {
try {
const query = buildQueryParams({
page: 1, pageSize: 250, eventType: 1,
sortKey: 'date', sortDirection: 'descending'
});
const response = radarr.fetchJson('history', query) || {};
const records = Array.isArray(response) ? response : (response.records || []);
const wanted = searchPatterns.map(normalizeReleaseTitle);
const matching = records.filter(item =>
item?.movieId === movieId
&& wanted.includes(normalizeReleaseTitle(item.sourceTitle || ''))
);
if (!matching.length) return null;
const movieIds = matching
.map(item => item.movieId)
.filter((id, index, values) => values.indexOf(id) === index);
if (movieIds.length !== 1) return null;
Logger.ILog(`Using metadata from exact Radarr grab history match (${matching[0].sourceTitle}).`);
return matching[0];
} catch (e) {
Logger.WLog(`Unable to recover Radarr grab metadata: ${e.message}`);
return null;
}
}
/**
* @description Send the **ManualImport** command to Radarr and return command-id
* @param {Radarr} radarr – Radarr API instance

View file

@ -2,7 +2,7 @@
* @name Sonarr - Trigger Manual Import
* @description Safely trigger Sonarr ManualImport for exactly one processed episode file.
* @author FileFlows + Sascha
* @revision 9
* @revision 10
* @output Import erfolgreich
* @output Fehler
* @param {string} URL Sonarr root URL and port
@ -43,14 +43,22 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
const sonarr = new Sonarr(URL, ApiKey);
/*── seriesId / episodeId detection ──────────────────────────────────*/
const searchPattern = Variables.file.Orig.FileNameNoExtension;
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)) {
const trackedEpisode = findEpisodeFromQueue(searchPattern, sonarr)
?? findEpisodeFromGrabHistory(searchPattern, sonarr)
?? parseSeries(searchPattern, sonarr);
let trackedEpisode = null;
for (const pattern of searchPatterns) {
trackedEpisode = findEpisodeFromQueue(pattern, sonarr)
?? findEpisodeFromGrabHistory(pattern, sonarr);
if (trackedEpisode) break;
}
if (!trackedEpisode) trackedEpisode = parseSeries(searchPatterns[0] || '', sonarr);
seriesId = trackedEpisode?.id ?? null;
episodeIds = trackedEpisode?.episodeIds ?? null;
}
@ -60,11 +68,12 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
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, TimeOut);
return manualImportWorkflow(sonarr, exactImportPath, importMode, seriesId, episodeIds, releaseMetadata, TimeOut);
}
Logger.ELog(`Unable to identify '${searchPattern}' uniquely; file remains for manual recovery.`);
Logger.ELog(`Unable to identify '${searchPatterns.join("' or '")}' uniquely; file remains for manual recovery.`);
return -1;
}
@ -80,7 +89,7 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
* @param {number} timeout - Timeout in milliseconds
* @returns {number} 1 on success, −1 on failure
*/
function manualImportWorkflow(sonarr, path, mode, seriesId, episodeIds, timeout) {
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');
@ -98,7 +107,7 @@ function manualImportWorkflow(sonarr, path, mode, seriesId, episodeIds, timeout)
return -1;
}
const fileObj = buildManualImportFile(cand, seriesId, episodeIds, path);
const fileObj = buildManualImportFile(cand, seriesId, episodeIds, path, releaseMetadata);
const cmdBody = { name: 'ManualImport', files: [fileObj], importMode: mode };
const cmdId = sendManualImportCommand(sonarr, cmdBody);
if (cmdId === null) {
@ -137,23 +146,52 @@ function getManualImportCandidates(sonarr, importPath) {
* @param {number} episodeIds – Sonarr episodeIds to which the file is linked
* @returns {object} File descriptor for ManualImport
*/
function buildManualImportFile(src, seriesId, episodeIds, importPath) {
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: src.releaseGroup || 'Sonarr',
quality: src.quality,
languages: src.languages || [ { id:0, name:'Unknown' } ],
indexerFlags: src.indexerFlags ?? 0,
releaseType: src.releaseType ?? 'unknown'
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

View file

@ -0,0 +1,31 @@
import json
import unittest
from pathlib import Path
FLOW = Path(__file__).parents[1] / "flow" / "flow_auto_movie_pipeline.json"
class FlowImportFailureRoutingTests(unittest.TestCase):
def test_arr_import_failures_route_to_fail_flow(self):
flow = json.loads(FLOW.read_text())
fail_node = next(
part
for part in flow["Parts"]
if part.get("FlowElementUid") == "FileFlows.BasicNodes.Functions.FailFlow"
)
import_nodes = [
part
for part in flow["Parts"]
if part.get("Name")
in {"Sonarr - Trigger Manual Import", "Radarr - Trigger Manual Import"}
]
self.assertEqual(len(import_nodes), 4)
for node in import_nodes:
with self.subTest(node=node["Uid"]):
self.assertEqual(node["ErrorConnection"]["InputNode"], fail_node["Uid"])
if __name__ == "__main__":
unittest.main()

View file

@ -193,6 +193,76 @@ class RadarrImportSafetyTests(unittest.TestCase):
)
self.assertIn("eventType=1", history_fetch["query"])
def test_grab_metadata_overrides_unknown_manualimport_metadata(self):
release = "American.Sweatshop.2025.German.DTSHD.DL.1080p.BluRay.AVC.Remux-MAMA"
exact = f"/tdarr/converted/video/{release}/{release}.mkv"
grabbed_quality = {"quality": {"id": 30, "name": "Remux-1080p"}}
grabbed_languages = [
{"id": 1, "name": "English"},
{"id": 4, "name": "German"},
]
result = run_script(
{
"releaseTitle": release,
"exactPath": exact,
"scopeRoot": "/tdarr/converted/video/",
"queueResponse": {"records": [{"title": release, "movieId": 6425}]},
"historyResponse": {
"records": [
{
"sourceTitle": release,
"movieId": 6425,
"eventType": "grabbed",
"quality": grabbed_quality,
"languages": grabbed_languages,
"data": {"releaseGroup": "MAMA", "indexerFlags": "4"},
}
]
},
"candidates": [
{
"path": exact,
"releaseGroup": "Radarr",
"quality": {"quality": {"id": 0, "name": "Unknown"}},
"languages": [{"id": 0, "name": "Unknown"}],
}
],
}
)
self.assertEqual(result["result"], 1)
command = next(call for call in result["calls"] if call["type"] == "command")
imported = command["body"]["files"][0]
self.assertEqual(imported["quality"], grabbed_quality)
self.assertEqual(imported["languages"], grabbed_languages)
self.assertEqual(imported["releaseGroup"], "MAMA")
self.assertEqual(imported["indexerFlags"], 4)
def test_release_folder_matches_when_movie_filename_is_shortened(self):
release = "Example.Movie.2026.German.DL.1080p.WEB.H264-GROUP"
exact = f"/tdarr/converted/video/{release}/movie-final.mkv"
result = run_script(
{
"releaseTitle": "movie-final",
"exactPath": exact,
"scopeRoot": "/tdarr/converted/video/",
"queueResponse": {"records": [{"title": release, "movieId": 7331}]},
"historyResponse": {"records": []},
"parseResponse": {},
"candidates": [
{
"path": exact,
"quality": {"quality": {"id": 3, "name": "WEBDL-1080p"}},
"languages": [{"id": 4, "name": "German"}],
}
],
}
)
self.assertEqual(result["result"], 1)
command = next(call for call in result["calls"] if call["type"] == "command")
self.assertEqual(command["body"]["files"][0]["movieId"], 7331)
if __name__ == "__main__":
unittest.main()

View file

@ -119,6 +119,98 @@ class SonarrImportSafetyTests(unittest.TestCase):
[call.get("name") for call in commands],
)
def test_release_folder_matches_when_episode_filename_is_scene_shortened(self):
release = (
"Angie.Tribeca.S01E06.Frettchen.Royale.GERMAN.DL."
"1080p.HDTV.x264-SYNCTV"
)
exact = f"/tdarr/converted/serien/{release}/synctv-angie.s01e06.1080p.mkv"
result = run_script(
{
"releaseTitle": "synctv-angie.s01e06.1080p",
"exactPath": exact,
"scopeRoot": "/tdarr/converted/serien",
"queueResponse": {
"records": [
{"title": release, "seriesId": 853, "episodeId": 63336}
]
},
"historyResponse": {"records": []},
"parseResponse": {},
"candidates": [
{
"path": exact,
"quality": {"quality": {"id": 4, "name": "HDTV-1080p"}},
"languages": [{"id": 4, "name": "German"}],
}
],
}
)
self.assertEqual(result["result"], 1)
command = next(call for call in result["calls"] if call["type"] == "command")
imported = command["body"]["files"][0]
self.assertEqual(imported["seriesId"], 853)
self.assertEqual(imported["episodeIds"], [63336])
def test_grab_metadata_overrides_unknown_manualimport_metadata(self):
release = (
"The.Night.Agent.2025.S02E04.Desperate.Measures.2160p.NF."
"WEB-DL.MULTi.DDP5.1.Atmos.H.265-FUZEER"
)
exact = f"/tdarr/converted/serien4k/{release}/{release}.mkv"
grabbed_quality = {"quality": {"id": 18, "name": "WEBDL-2160p"}}
grabbed_languages = [
{"id": 1, "name": "English"},
{"id": 4, "name": "German"},
]
result = run_script(
{
"releaseTitle": release,
"exactPath": exact,
"scopeRoot": "/tdarr/converted/serien4k",
"queueResponse": {
"records": [
{"title": release, "seriesId": 602, "episodeId": 37528}
]
},
"historyResponse": {
"records": [
{
"sourceTitle": release,
"seriesId": 602,
"episodeId": 37528,
"eventType": "grabbed",
"quality": grabbed_quality,
"languages": grabbed_languages,
"data": {
"releaseGroup": "FUZEER",
"indexerFlags": "8",
"releaseType": "SingleEpisode",
},
}
]
},
"candidates": [
{
"path": exact,
"releaseGroup": "Sonarr",
"quality": {"quality": {"id": 0, "name": "Unknown"}},
"languages": [{"id": 0, "name": "Unknown"}],
}
],
}
)
self.assertEqual(result["result"], 1)
command = next(call for call in result["calls"] if call["type"] == "command")
imported = command["body"]["files"][0]
self.assertEqual(imported["quality"], grabbed_quality)
self.assertEqual(imported["languages"], grabbed_languages)
self.assertEqual(imported["releaseGroup"], "FUZEER")
self.assertEqual(imported["indexerFlags"], 8)
self.assertEqual(imported["releaseType"], "SingleEpisode")
if __name__ == "__main__":
unittest.main()