From 81a01d5880e8e865710ace88e4bdc3a4eca3b0e3 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 12:03:07 +0200 Subject: [PATCH 01/30] fix: preserve Arr metadata and fail closed --- .../scripts/Sonarr_-_Trigger_Manual_Import.js | 68 +++++++++++++++---- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js b/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js index 6e06bcd..48dc729 100644 --- a/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js +++ b/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js @@ -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 From 3c714618e6a649989fe3c289f426d873994dcdf7 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 12:03:08 +0200 Subject: [PATCH 02/30] fix: preserve Arr metadata and fail closed --- .../scripts/Radarr_-_Trigger_Manual_Import.js | 69 ++++++++++++++----- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js b/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js index 5ebc556..b4c4844 100644 --- a/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js +++ b/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js @@ -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 From c0913a29ae3448a3724a23968ed712a03e3ca7dd Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 12:03:09 +0200 Subject: [PATCH 03/30] fix: preserve Arr metadata and fail closed --- fileflows/tests/test_sonarr_import_safety.py | 92 ++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/fileflows/tests/test_sonarr_import_safety.py b/fileflows/tests/test_sonarr_import_safety.py index b05ee18..7c45df6 100644 --- a/fileflows/tests/test_sonarr_import_safety.py +++ b/fileflows/tests/test_sonarr_import_safety.py @@ -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() From 82b14a07320fadcc0ff4e3b4cf8afe8afa6a4c2d Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 12:03:09 +0200 Subject: [PATCH 04/30] fix: preserve Arr metadata and fail closed --- fileflows/tests/test_radarr_import_safety.py | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/fileflows/tests/test_radarr_import_safety.py b/fileflows/tests/test_radarr_import_safety.py index a895783..d8f9864 100644 --- a/fileflows/tests/test_radarr_import_safety.py +++ b/fileflows/tests/test_radarr_import_safety.py @@ -193,6 +193,51 @@ 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) + if __name__ == "__main__": unittest.main() From e23374e24b62daa8dfd62dbb00a738dda3b0bb31 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 12:03:10 +0200 Subject: [PATCH 05/30] fix: preserve Arr metadata and fail closed --- .../tests/test_flow_import_failure_routing.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 fileflows/tests/test_flow_import_failure_routing.py diff --git a/fileflows/tests/test_flow_import_failure_routing.py b/fileflows/tests/test_flow_import_failure_routing.py new file mode 100644 index 0000000..bdb370c --- /dev/null +++ b/fileflows/tests/test_flow_import_failure_routing.py @@ -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() From d3488078e1de04f5e19129fb2d30d897ec640612 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 12:03:11 +0200 Subject: [PATCH 06/30] fix: preserve Arr metadata and fail closed --- fileflows/flow/flow_auto_movie_pipeline.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/fileflows/flow/flow_auto_movie_pipeline.json b/fileflows/flow/flow_auto_movie_pipeline.json index 35498dc..91cffd0 100644 --- a/fileflows/flow/flow_auto_movie_pipeline.json +++ b/fileflows/flow/flow_auto_movie_pipeline.json @@ -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": { From 506be2234bdbe14e64fa7693870ed9d15dd81a73 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 12:08:00 +0200 Subject: [PATCH 07/30] test: cover shortened Radarr filenames From f817088de5909d1ac224dc38a929bc3e113f4c12 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 12:08:00 +0200 Subject: [PATCH 08/30] test: cover shortened Radarr filenames From c62a18beff3ca18cbf6c771ebaff9eeb4572cea0 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 12:08:01 +0200 Subject: [PATCH 09/30] test: cover shortened Radarr filenames From 188164c3e582d00acdeaa8abcb0571d7d6b24b2e Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 12:08:02 +0200 Subject: [PATCH 10/30] test: cover shortened Radarr filenames --- fileflows/tests/test_radarr_import_safety.py | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/fileflows/tests/test_radarr_import_safety.py b/fileflows/tests/test_radarr_import_safety.py index d8f9864..b281ec2 100644 --- a/fileflows/tests/test_radarr_import_safety.py +++ b/fileflows/tests/test_radarr_import_safety.py @@ -238,6 +238,31 @@ class RadarrImportSafetyTests(unittest.TestCase): 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() From cb094beae3f6b235498fb1f24e7789c677421b33 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 12:08:02 +0200 Subject: [PATCH 11/30] test: cover shortened Radarr filenames From b4bb09343d8265ef8187a999b4264b2d82d59710 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 12:08:03 +0200 Subject: [PATCH 12/30] test: cover shortened Radarr filenames From 648972ab56e97b99a7faba9c76ddfd61cffd7eb5 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 19:52:47 +0200 Subject: [PATCH 13/30] fix: allow 15 minutes for FHD Radarr import --- fileflows/flow/flow_auto_movie_pipeline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fileflows/flow/flow_auto_movie_pipeline.json b/fileflows/flow/flow_auto_movie_pipeline.json index 91cffd0..95bc959 100644 --- a/fileflows/flow/flow_auto_movie_pipeline.json +++ b/fileflows/flow/flow_auto_movie_pipeline.json @@ -4,7 +4,7 @@ "DateCreated": "2026-05-13T06:59:27Z", "DateModified": "2026-07-15T18:19:38Z", "Enabled": true, - "Revision": 14, + "Revision": 15, "Type": 0, "ReadOnly": false, "Default": false, @@ -352,7 +352,7 @@ "ImportPath": "/tdarr/converted/video/", "UseUnmappedPath": false, "MoveMode": true, - "TimeOut": 300, + "TimeOut": 900, "Color": "" } }, From f1b83fcdc23c89b80b4797c4da6b5f34b87b191b Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 19:52:48 +0200 Subject: [PATCH 14/30] test: cover large NAS import timeout --- fileflows/tests/test_flow_import_failure_routing.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/fileflows/tests/test_flow_import_failure_routing.py b/fileflows/tests/test_flow_import_failure_routing.py index bdb370c..28ff16d 100644 --- a/fileflows/tests/test_flow_import_failure_routing.py +++ b/fileflows/tests/test_flow_import_failure_routing.py @@ -26,6 +26,16 @@ class FlowImportFailureRoutingTests(unittest.TestCase): with self.subTest(node=node["Uid"]): self.assertEqual(node["ErrorConnection"]["InputNode"], fail_node["Uid"]) + def test_fhd_radarr_import_timeout_handles_large_nas_copy(self): + flow = json.loads(FLOW.read_text()) + fhd_radarr = next( + part + for part in flow["Parts"] + if part.get("Name") == "Radarr - Trigger Manual Import" + and part.get("Model", {}).get("ImportPath") == "/tdarr/converted/video/" + ) + self.assertGreaterEqual(fhd_radarr["Model"]["TimeOut"], 900) + if __name__ == "__main__": unittest.main() From 2929014b5c5a5133cb857c5949993a62dd6060e7 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 20:53:48 +0200 Subject: [PATCH 15/30] test: hold one Radarr FHD release for native scan PoC --- fileflows/scripts/Radarr_-_Trigger_Manual_Import.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js b/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js index b4c4844..79da0e2 100644 --- a/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js +++ b/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js @@ -2,7 +2,7 @@ * @name Radarr - Trigger Manual Import * @description Safely trigger Radarr ManualImport for exactly one processed file. * @author FileFlows + Sascha - * @revision 11 + * @revision 12 * @output Import erfolgreich * @output Fehler * @param {string} URL Radarr root URL and port @@ -11,11 +11,12 @@ * @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) + * @param {string} PocHoldRelease Exact release title to leave for an external native-scan proof */ import { Radarr } from 'Shared/Radarr'; -function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { +function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut, PocHoldRelease) { URL = (URL || Variables['Radarr.Url'] || Variables['Radarr.URI']).replace(/\/+$/g, ''); ApiKey = ApiKey || Variables['Radarr.ApiKey']; const currentFile = Variables['file.FullName'] || Variables.file?.FullName || ''; @@ -53,6 +54,11 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { const searchPatterns = [folderPattern, filePattern] .filter(Boolean) .filter((value, index, values) => values.indexOf(value) === index); + const pocHold = normalizeReleaseTitle(PocHoldRelease || ''); + if (pocHold && searchPatterns.some(pattern => normalizeReleaseTitle(pattern) === pocHold)) { + Logger.ILog(`PoC hold: leaving '${exactImportPath}' on NVMe for external native Arr scan.`); + return 1; + } let movieId = Variables['Radarr.movieId'] ?? null; for (const pattern of searchPatterns) { if (!movieId) { From 8c36977d631e8829a4d4b38cc6777be57a0b1aad Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 20:53:49 +0200 Subject: [PATCH 16/30] test: hold one Radarr FHD release for native scan PoC --- fileflows/tests/test_radarr_import_safety.py | 30 +++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/fileflows/tests/test_radarr_import_safety.py b/fileflows/tests/test_radarr_import_safety.py index b281ec2..7216a2f 100644 --- a/fileflows/tests/test_radarr_import_safety.py +++ b/fileflows/tests/test_radarr_import_safety.py @@ -61,7 +61,8 @@ const result = context.__exports.Script( scenario.scopeRoot, false, true, - 300 + 300, + scenario.pocHoldRelease || '' ); process.stdout.write(JSON.stringify({result, calls})); """ @@ -262,6 +263,33 @@ class RadarrImportSafetyTests(unittest.TestCase): 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) + def test_poc_hold_leaves_exact_release_for_external_native_scan(self): + release = "The.Ritual.2017.German.DL.1080p.WEB.x264.iNTERNAL-BiGiNT" + exact = f"/tdarr/converted/video/{release}/{release}.mkv" + result = run_script( + { + "releaseTitle": release, + "exactPath": exact, + "scopeRoot": "/tdarr/converted/video/", + "pocHoldRelease": release, + "queueResponse": {"records": [{"title": release, "movieId": 6676}]}, + "historyResponse": { + "records": [ + { + "sourceTitle": release, + "movieId": 6676, + "downloadId": "poc-nzo-id", + "eventType": "grabbed", + } + ] + }, + "candidates": [], + } + ) + + self.assertEqual(result["result"], 1) + commands = [call for call in result["calls"] if call["type"] == "command"] + self.assertEqual(commands, []) if __name__ == "__main__": From ec8080aa5cf2e4244a21e813ea650babd357dc19 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 20:53:49 +0200 Subject: [PATCH 17/30] test: hold one Radarr FHD release for native scan PoC --- fileflows/flow/flow_auto_movie_pipeline.json | 1 + 1 file changed, 1 insertion(+) diff --git a/fileflows/flow/flow_auto_movie_pipeline.json b/fileflows/flow/flow_auto_movie_pipeline.json index 95bc959..e0acd9d 100644 --- a/fileflows/flow/flow_auto_movie_pipeline.json +++ b/fileflows/flow/flow_auto_movie_pipeline.json @@ -353,6 +353,7 @@ "UseUnmappedPath": false, "MoveMode": true, "TimeOut": 900, + "PocHoldRelease": "The.Ritual.2017.German.DL.1080p.WEB.x264.iNTERNAL-BiGiNT", "Color": "" } }, From a8f303c941f520de6e3c8c7af9d6ec3c1af48a24 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 21:44:42 +0200 Subject: [PATCH 18/30] Rollback temporary native-scan PoC hold --- fileflows/scripts/Radarr_-_Trigger_Manual_Import.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js b/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js index 79da0e2..04cd807 100644 --- a/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js +++ b/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js @@ -2,7 +2,7 @@ * @name Radarr - Trigger Manual Import * @description Safely trigger Radarr ManualImport for exactly one processed file. * @author FileFlows + Sascha - * @revision 12 + * @revision 11 * @output Import erfolgreich * @output Fehler * @param {string} URL Radarr root URL and port @@ -11,12 +11,11 @@ * @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) - * @param {string} PocHoldRelease Exact release title to leave for an external native-scan proof */ import { Radarr } from 'Shared/Radarr'; -function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut, PocHoldRelease) { +function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { URL = (URL || Variables['Radarr.Url'] || Variables['Radarr.URI']).replace(/\/+$/g, ''); ApiKey = ApiKey || Variables['Radarr.ApiKey']; const currentFile = Variables['file.FullName'] || Variables.file?.FullName || ''; @@ -54,11 +53,7 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut, Poc const searchPatterns = [folderPattern, filePattern] .filter(Boolean) .filter((value, index, values) => values.indexOf(value) === index); - const pocHold = normalizeReleaseTitle(PocHoldRelease || ''); - if (pocHold && searchPatterns.some(pattern => normalizeReleaseTitle(pattern) === pocHold)) { - Logger.ILog(`PoC hold: leaving '${exactImportPath}' on NVMe for external native Arr scan.`); - return 1; - } + let movieId = Variables['Radarr.movieId'] ?? null; for (const pattern of searchPatterns) { if (!movieId) { From 5960a263b9e66e7d394577e320089ad47eb9e452 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 21:44:43 +0200 Subject: [PATCH 19/30] Rollback temporary native-scan PoC hold --- fileflows/flow/flow_auto_movie_pipeline.json | 1 - 1 file changed, 1 deletion(-) diff --git a/fileflows/flow/flow_auto_movie_pipeline.json b/fileflows/flow/flow_auto_movie_pipeline.json index e0acd9d..95bc959 100644 --- a/fileflows/flow/flow_auto_movie_pipeline.json +++ b/fileflows/flow/flow_auto_movie_pipeline.json @@ -353,7 +353,6 @@ "UseUnmappedPath": false, "MoveMode": true, "TimeOut": 900, - "PocHoldRelease": "The.Ritual.2017.German.DL.1080p.WEB.x264.iNTERNAL-BiGiNT", "Color": "" } }, From fb1271ba8b76a83f0358d40c31cd123fccd26aef Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 12 Sep 2026 21:44:43 +0200 Subject: [PATCH 20/30] Rollback temporary native-scan PoC hold --- fileflows/tests/test_radarr_import_safety.py | 30 +------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/fileflows/tests/test_radarr_import_safety.py b/fileflows/tests/test_radarr_import_safety.py index 7216a2f..b281ec2 100644 --- a/fileflows/tests/test_radarr_import_safety.py +++ b/fileflows/tests/test_radarr_import_safety.py @@ -61,8 +61,7 @@ const result = context.__exports.Script( scenario.scopeRoot, false, true, - 300, - scenario.pocHoldRelease || '' + 300 ); process.stdout.write(JSON.stringify({result, calls})); """ @@ -263,33 +262,6 @@ class RadarrImportSafetyTests(unittest.TestCase): 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) - def test_poc_hold_leaves_exact_release_for_external_native_scan(self): - release = "The.Ritual.2017.German.DL.1080p.WEB.x264.iNTERNAL-BiGiNT" - exact = f"/tdarr/converted/video/{release}/{release}.mkv" - result = run_script( - { - "releaseTitle": release, - "exactPath": exact, - "scopeRoot": "/tdarr/converted/video/", - "pocHoldRelease": release, - "queueResponse": {"records": [{"title": release, "movieId": 6676}]}, - "historyResponse": { - "records": [ - { - "sourceTitle": release, - "movieId": 6676, - "downloadId": "poc-nzo-id", - "eventType": "grabbed", - } - ] - }, - "candidates": [], - } - ) - - self.assertEqual(result["result"], 1) - commands = [call for call in result["calls"] if call["type"] == "command"] - self.assertEqual(commands, []) if __name__ == "__main__": From cb054cbac70cbce2b8552a86d1bc3dfdcd28c44a Mon Sep 17 00:00:00 2001 From: sascha Date: Mon, 14 Sep 2026 09:15:04 +0200 Subject: [PATCH 21/30] Fix Sonarr path boundary and exact episode parsing --- .../scripts/Sonarr_-_Trigger_Manual_Import.js | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js b/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js index 48dc729..1adfe02 100644 --- a/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js +++ b/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js @@ -2,7 +2,7 @@ * @name Sonarr - Trigger Manual Import * @description Safely trigger Sonarr ManualImport for exactly one processed episode file. * @author FileFlows + Sascha - * @revision 10 + * @revision 11 * @output Import erfolgreich * @output Fehler * @param {string} URL Sonarr root URL and port @@ -31,13 +31,16 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { } // ── SKIP-GUARD: Nur API-Call machen, wenn der aktuelle ImportPath - // mit dem konfigurierten Pfad uebereinstimmt. Sonst sofort OK. - // Das verhindert 3 unnoetige API-Calls pro Datei (4 Arr-Instanzen). - if (ImportPath && ImportPath.indexOf(URL) === -1) { - if (currentFile && ImportPath && currentFile.indexOf(ImportPath) === -1) { - Logger.ILog(`Skip: current file '${currentFile}' not under ImportPath '${ImportPath}' - not responsible`); - return 1; - } + // 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); @@ -58,7 +61,18 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { ?? findEpisodeFromGrabHistory(pattern, sonarr); if (trackedEpisode) break; } - if (!trackedEpisode) trackedEpisode = parseSeries(searchPatterns[0] || '', sonarr); + 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; } From 3153e66cc8d7d3c4b1cdd82e07ab08a5cfb2c7fd Mon Sep 17 00:00:00 2001 From: sascha Date: Mon, 14 Sep 2026 09:15:05 +0200 Subject: [PATCH 22/30] Add season-pack and sibling-prefix regression tests --- fileflows/tests/test_sonarr_import_safety.py | 63 ++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/fileflows/tests/test_sonarr_import_safety.py b/fileflows/tests/test_sonarr_import_safety.py index 7c45df6..26f6308 100644 --- a/fileflows/tests/test_sonarr_import_safety.py +++ b/fileflows/tests/test_sonarr_import_safety.py @@ -77,6 +77,69 @@ def run_script(scenario): class SonarrImportSafetyTests(unittest.TestCase): + def test_fhd_scope_does_not_match_uhd_sibling_prefix(self): + exact = ( + "/tdarr/converted/serien4k/Marshals.2026.S01.GERMAN.DUBBED/" + "Marshals.2026.S01E05.GERMAN.DUBBED.mkv" + ) + result = run_script( + { + "releaseTitle": "Marshals.2026.S01E05.GERMAN.DUBBED", + "exactPath": exact, + "scopeRoot": "/tdarr/converted/serien", + } + ) + + self.assertEqual(result["result"], 1) + self.assertEqual(result["calls"], []) + + def test_season_pack_history_uses_exact_episode_filename_for_parse(self): + pack = "Marshals.2026.S01.GERMAN.DUBBED.DL.2160p.WEB.H265-TSCC" + episode = "Marshals.2026.S01E05.GERMAN.DUBBED.DL.2160p.WEB.H265-TSCC" + exact = f"/tdarr/converted/serien4k/{pack}/{episode}.mkv" + queue_records = [ + {"title": pack, "seriesId": 600, "episodeId": 37500 + number} + for number in range(1, 14) + ] + history_records = [ + { + "sourceTitle": pack, + "seriesId": 600, + "episodeId": 37500 + number, + "eventType": "grabbed", + } + for number in range(1, 14) + ] + result = run_script( + { + "releaseTitle": episode, + "exactPath": exact, + "scopeRoot": "/tdarr/converted/serien4k", + "queueResponse": {"records": queue_records}, + "historyResponse": {"records": history_records}, + "parseResponse": { + "series": {"id": 600, "title": "Marshals"}, + "episodes": [{"id": 37505}], + }, + "candidates": [ + { + "path": exact, + "quality": {"quality": {"id": 19, "name": "WEBDL-2160p"}}, + "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]["episodeIds"], [37505]) + parse_call = next( + call for call in result["calls"] + if call["type"] == "fetch" and call["endpoint"] == "parse" + ) + self.assertIn("s01e05", parse_call["query"].lower()) + def test_percent_title_matches_queue_and_imports_exact_episode_file(self): source_title = ( "100%.Wolf.S01E01.Die.Aufnahmeprufung.German.1080p." From c5085828083d52cfe33bd6ec58b283266e299707 Mon Sep 17 00:00:00 2001 From: sascha Date: Mon, 14 Sep 2026 15:49:01 +0200 Subject: [PATCH 23/30] Pin FileFlows canary to 26.09 --- compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose.yaml b/compose.yaml index 9f0df2b..f0c15d0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ services: fileflows: - image: revenz/fileflows:26.06 + image: revenz/fileflows:26.09 container_name: fileflows restart: always ports: From 7abfb9e836796ac577831aed9867033b6ee3cb29 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 16 Sep 2026 07:13:32 +0200 Subject: [PATCH 24/30] Resolve season-pack episode from exact filename --- .../scripts/Sonarr_-_Trigger_Manual_Import.js | 63 ++++++++++++++++--- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js b/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js index 1adfe02..7dd870b 100644 --- a/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js +++ b/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js @@ -2,7 +2,7 @@ * @name Sonarr - Trigger Manual Import * @description Safely trigger Sonarr ManualImport for exactly one processed episode file. * @author FileFlows + Sascha - * @revision 11 + * @revision 12 * @output Import erfolgreich * @output Fehler * @param {string} URL Sonarr root URL and port @@ -57,8 +57,8 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { if (!(seriesId && episodeIds)) { let trackedEpisode = null; for (const pattern of searchPatterns) { - trackedEpisode = findEpisodeFromQueue(pattern, sonarr) - ?? findEpisodeFromGrabHistory(pattern, sonarr); + trackedEpisode = findEpisodeFromQueue(pattern, sonarr, filePattern) + ?? findEpisodeFromGrabHistory(pattern, sonarr, filePattern); if (trackedEpisode) break; } if (!trackedEpisode) { @@ -226,7 +226,7 @@ function sendManualImportCommand(sonarr, cmdBody) { /*────────────────────────────── helpers ──────────────────────────────────*/ -function findEpisodeFromQueue(searchPattern, sonarr) { +function findEpisodeFromQueue(searchPattern, sonarr, episodePattern) { if (!searchPattern) return null; try { const query = buildQueryParams({ page: 1, pageSize: 200, includeUnknownSeriesItems: true }); @@ -240,7 +240,11 @@ function findEpisodeFromQueue(searchPattern, sonarr) { 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).`); + 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}`); @@ -248,7 +252,7 @@ function findEpisodeFromQueue(searchPattern, sonarr) { } } -function findEpisodeFromGrabHistory(searchPattern, sonarr) { +function findEpisodeFromGrabHistory(searchPattern, sonarr, episodePattern) { if (!searchPattern) return null; try { const query = buildQueryParams({ @@ -269,7 +273,11 @@ function findEpisodeFromGrabHistory(searchPattern, sonarr) { 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).`); + 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}`); @@ -277,6 +285,47 @@ function findEpisodeFromGrabHistory(searchPattern, sonarr) { } } +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, ''); } From 8881a5086e870916a7728ce513e773cf2966aa4e Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 16 Sep 2026 07:13:33 +0200 Subject: [PATCH 25/30] Add ambiguous season-pack regression tests --- fileflows/tests/test_sonarr_import_safety.py | 77 ++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/fileflows/tests/test_sonarr_import_safety.py b/fileflows/tests/test_sonarr_import_safety.py index 26f6308..9512215 100644 --- a/fileflows/tests/test_sonarr_import_safety.py +++ b/fileflows/tests/test_sonarr_import_safety.py @@ -22,6 +22,7 @@ class FakeSonarr { calls.push({type: 'fetch', endpoint, query}); if (endpoint === 'queue') return scenario.queueResponse || {records: []}; if (endpoint === 'history') return scenario.historyResponse || {records: []}; + if (endpoint === 'episode') return scenario.episodeResponse || []; if (endpoint === 'parse') return scenario.parseResponse || {}; if (endpoint === 'manualimport') return scenario.candidates || []; return null; @@ -77,6 +78,82 @@ def run_script(scenario): class SonarrImportSafetyTests(unittest.TestCase): + def test_pack_with_ambiguous_queue_resolves_exact_episode_from_filename(self): + pack = "Lucky.S01.Complete.2026.GERMAN.aac.720p.WEB.h265-FritzBox" + episode = "Lucky.S01E05.German.Title.2026.GERMAN.aac.720p.WEB.h265-FritzBox" + exact = f"/tdarr/converted/serien/{pack}/{episode}.mp4" + queue_records = [ + {"title": pack, "seriesId": 794, "episodeId": 64000 + number} + for number in range(1, 8) + ] + history_records = [ + { + "sourceTitle": pack, + "seriesId": 794, + "episodeId": 64000 + number, + "eventType": "grabbed", + "quality": {"quality": {"id": 6, "name": "WEBDL-720p"}}, + "languages": [{"id": 4, "name": "German"}], + "data": {"releaseGroup": "FritzBox", "releaseType": "SeasonPack"}, + } + for number in range(1, 8) + ] + result = run_script( + { + "releaseTitle": episode, + "exactPath": exact, + "scopeRoot": "/tdarr/converted/serien", + "queueResponse": {"records": queue_records}, + "historyResponse": {"records": history_records}, + "episodeResponse": [ + {"id": 64000 + number, "seasonNumber": 1, "episodeNumber": number} + for number in range(1, 8) + ], + "parseResponse": {}, + "candidates": [ + { + "path": exact, + "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["seriesId"], 794) + self.assertEqual(imported["episodeIds"], [64005]) + self.assertEqual(imported["releaseGroup"], "FritzBox") + + def test_pack_resolution_fails_closed_when_series_ids_differ(self): + pack = "Lucky.S01.Complete.2026.GERMAN.aac.720p.WEB.h265-FritzBox" + episode = "Lucky.S01E05.German.Title.2026.GERMAN.aac.720p.WEB.h265-FritzBox" + exact = f"/tdarr/converted/serien/{pack}/{episode}.mp4" + result = run_script( + { + "releaseTitle": episode, + "exactPath": exact, + "scopeRoot": "/tdarr/converted/serien", + "queueResponse": { + "records": [ + {"title": pack, "seriesId": 794, "episodeId": 64005}, + {"title": pack, "seriesId": 999, "episodeId": 99005}, + ] + }, + "historyResponse": {"records": []}, + "episodeResponse": [ + {"id": 64005, "seasonNumber": 1, "episodeNumber": 5} + ], + "parseResponse": {}, + "candidates": [], + } + ) + + self.assertEqual(result["result"], -1) + self.assertFalse(any(call["type"] == "command" for call in result["calls"])) + def test_fhd_scope_does_not_match_uhd_sibling_prefix(self): exact = ( "/tdarr/converted/serien4k/Marshals.2026.S01.GERMAN.DUBBED/" From a3a5f4e802afb4eb3ffbdf5b915a49e69f3c7367 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 16 Sep 2026 07:13:33 +0200 Subject: [PATCH 26/30] Increase Sonarr import wait timeout to 15 minutes --- fileflows/flow/flow_auto_movie_pipeline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fileflows/flow/flow_auto_movie_pipeline.json b/fileflows/flow/flow_auto_movie_pipeline.json index 95bc959..3446d87 100644 --- a/fileflows/flow/flow_auto_movie_pipeline.json +++ b/fileflows/flow/flow_auto_movie_pipeline.json @@ -386,7 +386,7 @@ "ImportPath": "/tdarr/converted/serien4k", "UseUnmappedPath": false, "MoveMode": true, - "TimeOut": 300, + "TimeOut": 900, "Color": "" } }, @@ -420,7 +420,7 @@ "ImportPath": "/tdarr/converted/serien", "UseUnmappedPath": false, "MoveMode": true, - "TimeOut": 300, + "TimeOut": 900, "Color": "" } }, From 63a48d541a394db5dd5ce88192e0f75c2d7e2188 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 16 Sep 2026 07:19:20 +0200 Subject: [PATCH 27/30] Fail closed on tracked series title mismatch --- .../scripts/Sonarr_-_Trigger_Manual_Import.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js b/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js index 7dd870b..610b6aa 100644 --- a/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js +++ b/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js @@ -2,7 +2,7 @@ * @name Sonarr - Trigger Manual Import * @description Safely trigger Sonarr ManualImport for exactly one processed episode file. * @author FileFlows + Sascha - * @revision 12 + * @revision 13 * @output Import erfolgreich * @output Fehler * @param {string} URL Sonarr root URL and port @@ -293,10 +293,24 @@ function resolvePackEpisode(matches, episodePattern, sonarr) { .map(item => Number(item?.seriesId)) .filter(Boolean) .filter((value, index, values) => values.indexOf(value) === index); - if (seriesIds.length !== 1) return null; + 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 => From 0acfe3194fb749b9e6658e233fd0c531226852b5 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 16 Sep 2026 07:19:21 +0200 Subject: [PATCH 28/30] Cover wrong-series season-pack release --- fileflows/tests/test_sonarr_import_safety.py | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/fileflows/tests/test_sonarr_import_safety.py b/fileflows/tests/test_sonarr_import_safety.py index 9512215..047d4e0 100644 --- a/fileflows/tests/test_sonarr_import_safety.py +++ b/fileflows/tests/test_sonarr_import_safety.py @@ -22,6 +22,7 @@ class FakeSonarr { calls.push({type: 'fetch', endpoint, query}); if (endpoint === 'queue') return scenario.queueResponse || {records: []}; if (endpoint === 'history') return scenario.historyResponse || {records: []}; + if (endpoint.startsWith('series/')) return scenario.seriesResponse || {}; if (endpoint === 'episode') return scenario.episodeResponse || []; if (endpoint === 'parse') return scenario.parseResponse || {}; if (endpoint === 'manualimport') return scenario.candidates || []; @@ -105,6 +106,7 @@ class SonarrImportSafetyTests(unittest.TestCase): "scopeRoot": "/tdarr/converted/serien", "queueResponse": {"records": queue_records}, "historyResponse": {"records": history_records}, + "seriesResponse": {"id": 794, "title": "Lucky", "cleanTitle": "lucky"}, "episodeResponse": [ {"id": 64000 + number, "seasonNumber": 1, "episodeNumber": number} for number in range(1, 8) @@ -127,6 +129,38 @@ class SonarrImportSafetyTests(unittest.TestCase): self.assertEqual(imported["episodeIds"], [64005]) self.assertEqual(imported["releaseGroup"], "FritzBox") + def test_pack_resolution_fails_closed_when_tracked_series_title_differs(self): + pack = "Lucky.S01.Complete.2026.GERMAN.aac.720p.WEB.h265-FritzBox" + episode = "Lucky.S01E05.Are.We.Bad.People.2026.GERMAN.aac.720p.WEB.h265-FritzBox" + exact = f"/tdarr/converted/serien/{pack}/{episode}.mp4" + result = run_script( + { + "releaseTitle": episode, + "exactPath": exact, + "scopeRoot": "/tdarr/converted/serien", + "queueResponse": { + "records": [ + {"title": pack, "seriesId": 794, "episodeId": 64000 + number} + for number in range(1, 8) + ] + }, + "historyResponse": {"records": []}, + "seriesResponse": {"id": 794, "title": "Dutton Ranch", "cleanTitle": "duttonranch"}, + "episodeResponse": [ + {"id": 64005, "seasonNumber": 1, "episodeNumber": 5} + ], + "parseResponse": { + "series": {"id": 821, "title": "Lucky"}, + "episodes": [{"id": 61648}], + }, + "candidates": [{"path": exact, "quality": {"quality": {"id": 6}}}], + } + ) + + self.assertEqual(result["result"], -1) + self.assertFalse(any(call["type"] == "command" for call in result["calls"])) + self.assertFalse(any(call.get("endpoint") == "parse" for call in result["calls"])) + def test_pack_resolution_fails_closed_when_series_ids_differ(self): pack = "Lucky.S01.Complete.2026.GERMAN.aac.720p.WEB.h265-FritzBox" episode = "Lucky.S01E05.German.Title.2026.GERMAN.aac.720p.WEB.h265-FritzBox" @@ -194,6 +228,7 @@ class SonarrImportSafetyTests(unittest.TestCase): "scopeRoot": "/tdarr/converted/serien4k", "queueResponse": {"records": queue_records}, "historyResponse": {"records": history_records}, + "seriesResponse": {"id": 600, "title": "Marshals", "cleanTitle": "marshals"}, "parseResponse": { "series": {"id": 600, "title": "Marshals"}, "episodes": [{"id": 37505}], From 18e123bdb2e8567eadd353d27222c50b255659ab Mon Sep 17 00:00:00 2001 From: Trulla Date: Wed, 16 Sep 2026 11:34:22 +0200 Subject: [PATCH 29/30] Add FileFlows ApiKey guard against [REDACTED] key corruption Die FileFlows-API liefert ApiKey-Felder im GET als [REDACTED]. Ein Save aus der Web-UI (oder GET->PUT-Roundtrip) schreibt diesen Platzhalter in die DB, wodurch alle vier Arr-Manual-Import-Nodes still mit 401 fehlschlagen. Der n8n-Handoff bleibt auf processing, movetdarr.sh pollt weiter und der SAB-Job haengt bis zum 24h-Timeout in der Queue. Negativtest bestaetigt: ein sabotierter Key korrumpiert alle vier, weil der GET alle vier als [REDACTED] ausliefert. Guard prueft die FileFlows-SQLite auf ApiKeys != 32 Zeichen und schreibt die echten Keys via Butler-Proxy zurueck. Laeuft als systemd-Timer alle 15min auf tdarr (cron dort inaktiv). --- fileflows/guard/README.md | 87 ++++++++++++++++++++++++ fileflows/guard/ff-apikey-guard.sh | 105 +++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 fileflows/guard/README.md create mode 100755 fileflows/guard/ff-apikey-guard.sh diff --git a/fileflows/guard/README.md b/fileflows/guard/README.md new file mode 100644 index 0000000..fd209bd --- /dev/null +++ b/fileflows/guard/README.md @@ -0,0 +1,87 @@ +# FileFlows ApiKey Guard + +## Problem + +Die FileFlows-API liefert `ApiKey`-Felder in Flow-Parts beim `GET` grundsätzlich +als Literal `[REDACTED]` aus. Wird ein Flow danach gespeichert — aus der Web-UI +oder über einen `GET → PUT`-Roundtrip (Scripts, Automationen) — landet dieser +Platzhalter in der Datenbank. + +Folge: die Nodes `Sonarr - Trigger Manual Import` und +`Radarr - Trigger Manual Import` authentisieren sich mit `[REDACTED]`, die Arr +antwortet **401**, der Node bricht **still** ab. + +Fehlerbild in der Kette: + +``` +SABnzbd (movetdarr.sh) → FileFlows → Arr Manual Import + ↑ 401, still +``` + +- n8n Media-Handoff bleibt auf `processing` stehen +- `movetdarr.sh` pollt den Handoff-Lease weiter → SAB-Job hängt in der Queue + bis zum 24-h-Timeout +- Symptom beim Nutzer: "hängt seit Stunden in der Queue" + +**Ein einziger UI-Save zerschießt alle vier Keys gleichzeitig**, weil der GET +alle vier als `[REDACTED]` ausliefert (verifiziert per Negativtest: 1 Key +sabotiert → 4 Keys in der DB defekt). + +## Lösung + +`ff-apikey-guard.sh` prüft alle Flows in der FileFlows-SQLite auf ApiKeys mit +Länge != 32 und schreibt die echten Keys via Butler-Proxy +(`PUT /fileflows/api/flow`) zurück. + +Keys kommen aus einer lokalen Map, nie aus Git. + +## Deployment (tdarr, 10.2.1.104) + +```bash +sudo install -m 750 fileflows/guard/ff-apikey-guard.sh \ + /app-config/ff-guard/ff-apikey-guard.sh + +# Butler-Token +printf '%s' '' | sudo tee /app-config/ff-guard/butler.token +sudo chmod 600 /app-config/ff-guard/butler.token + +# Key-Map: " " pro Zeile, Keys aus den Arr-config.xml auf 10.2.1.100 +# 8989 sonarrUHD / 8990 sonarrFHD / 7878 radarrUHD / 7879 radarrFHD +sudo chmod 600 /app-config/ff-guard/arr-keys.map +``` + +Läuft als systemd-Timer alle 15 Minuten (`cron` ist auf tdarr inaktiv): + +``` +/etc/systemd/system/ff-apikey-guard.service Type=oneshot +/etc/systemd/system/ff-apikey-guard.timer OnUnitActiveSec=15min +``` + +```bash +sudo systemctl enable --now ff-apikey-guard.timer +``` + +## Betrieb + +```bash +# Log +sudo tail /app-config/ff-guard/guard.log + +# Manuell prüfen/reparieren +sudo systemctl start ff-apikey-guard.service + +# Timer-Status +systemctl list-timers ff-apikey-guard.timer +``` + +Logzeilen (Datum TT.MM.JJJJ): + +- `OK: alle Flow-ApiKeys 32 Zeichen` — nichts zu tun +- `WARNUNG: defekte ApiKeys in Flow — repariere` +- `Flow repariert: N Key(s) zurueckgeschrieben (HTTP 200)` + +## Nach einem UI-Save immer prüfen + +Wer einen Flow in der FileFlows-UI speichert, sollte danach den Guard anstoßen +oder maximal 15 Minuten auf den Timer warten. Vorher schlagen alle +Arr-Importe still fehl. diff --git a/fileflows/guard/ff-apikey-guard.sh b/fileflows/guard/ff-apikey-guard.sh new file mode 100755 index 0000000..0b38dd6 --- /dev/null +++ b/fileflows/guard/ff-apikey-guard.sh @@ -0,0 +1,105 @@ +#!/bin/bash +# ff-apikey-guard.sh — repariert kaputte Arr-ApiKeys in FileFlows-Flows +# +# URSACHE: Die FileFlows-API liefert ApiKey-Felder im GET als "[REDACTED]". +# Wird ein Flow aus der Web-UI (oder per GET->PUT-Roundtrip) gespeichert, +# landet dieser Platzhalter in der DB. Die "Trigger Manual Import"-Nodes +# schlagen dann still fehl (Arr antwortet 401), der n8n-Handoff bleibt auf +# "processing" und der SAB-Job haengt bis zum 24h-Timeout in der Queue. +# +# Dieses Skript erkennt ApiKeys != 32 Zeichen und schreibt die echten Keys +# aus /app-config/ff-guard/arr-keys.map zurueck (Format: " "). +set -u + +BUTLER="${BUTLER:-http://10.5.85.2:8888}" +GUARD_DIR="${GUARD_DIR:-/app-config/ff-guard}" +TOKEN_FILE="$GUARD_DIR/butler.token" +KEYMAP="$GUARD_DIR/arr-keys.map" +LOG="$GUARD_DIR/guard.log" +DB="${DB:-/app-config/fileflows-data/Data/FileFlows.sqlite}" + +mkdir -p "$GUARD_DIR" +log() { echo "$(date '+%d.%m.%Y %H:%M:%S'): $1" >> "$LOG"; } + +for f in "$TOKEN_FILE" "$KEYMAP"; do + [ -r "$f" ] || { log "FEHLER: Datei fehlt oder nicht lesbar: $f"; exit 1; } +done +[ -r "$DB" ] || { log "FEHLER: DB nicht lesbar: $DB"; exit 1; } + +TOKEN="$(tr -d '\n\r' < "$TOKEN_FILE")" + +# Flows mit defekten Keys ermitteln +FLOWUIDS="$(python3 - "$DB" <<'PY' +import sqlite3, json, sys +con = sqlite3.connect("file:%s?mode=ro" % sys.argv[1], uri=True) +for uid, name, typ, d in con.execute( + "select Uid,Name,Type,Data from DbObject where Type like '%Flow%'"): + try: + o = json.loads(d) + except Exception: + continue + for p in o.get("Parts", []): + k = (p.get("Model") or {}).get("ApiKey") + if k is not None and len(str(k)) != 32: + print(uid) + break +PY +)" + +if [ -z "$FLOWUIDS" ]; then + log "OK: alle Flow-ApiKeys 32 Zeichen" + exit 0 +fi + +RC=0 +for uid in $FLOWUIDS; do + log "WARNUNG: defekte ApiKeys in Flow $uid — repariere" + OUT="$(mktemp)"; PATCHED="$(mktemp)" + + if ! curl -sf -m 60 -H "Authorization: Bearer $TOKEN" \ + "$BUTLER/fileflows/api/flow/$uid" -o "$OUT"; then + log "FEHLER: GET flow $uid fehlgeschlagen" + rm -f "$OUT" "$PATCHED"; RC=1; continue + fi + + CHANGED="$(python3 - "$OUT" "$KEYMAP" "$PATCHED" <<'PY' +import json, sys +flow = json.load(open(sys.argv[1])) +keys = {} +for line in open(sys.argv[2]): + parts = line.split() + if len(parts) == 2 and len(parts[1]) == 32: + keys[parts[0]] = parts[1] +n = 0 +for p in flow.get("Parts", []): + mod = p.get("Model") or {} + if "ApiKey" not in mod: + continue + url = str(mod.get("URL") or mod.get("ApiUrl") or "") + port = url.rstrip("/").rsplit(":", 1)[-1] + if port in keys and str(mod["ApiKey"]) != keys[port]: + mod["ApiKey"] = keys[port] + n += 1 +json.dump(flow, open(sys.argv[3], "w")) +print(n) +PY +)" + + if [ "${CHANGED:-0}" = "0" ]; then + log "WARNUNG: Flow $uid — kein Key konnte gemappt werden (Ports pruefen)" + rm -f "$OUT" "$PATCHED"; RC=1; continue + fi + + HTTP="$(curl -s -m 60 -o /dev/null -w '%{http_code}' -X PUT \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + "$BUTLER/fileflows/api/flow" --data @"$PATCHED")" + + if [ "$HTTP" = "200" ]; then + log "Flow $uid repariert: $CHANGED Key(s) zurueckgeschrieben (HTTP 200)" + else + log "FEHLER: PUT flow $uid -> HTTP $HTTP" + RC=1 + fi + rm -f "$OUT" "$PATCHED" +done +exit $RC From 650417d42b944bb88e84b36680c3768c09d25c25 Mon Sep 17 00:00:00 2001 From: Trulla Date: Wed, 16 Sep 2026 11:45:33 +0200 Subject: [PATCH 30/30] Remove empty release dirs after finalize move 05_Finalize_Move loeschte nur die Original-Videodatei, nicht das Release-Verzeichnis. Zurueck blieben leere Ordner mit .nfo-Resten unter /tdarr/complete/, die GET /media/handoff/diagnostics als target_directories auflistet und dadurch wie haengende Jobs aussehen (6 Leichen gefunden). cleanupSourceDir loescht das Verzeichnis nur wenn: - es unter SourceRoot liegt und nicht SourceRoot selbst ist - find erfolgreich war (fail safe bei Fehler) - keine Mediendatei mehr darin liegt (Season-Pack-Schutz) 6 neue Tests im bestehenden Node-VM-Harness, 22/22 gruen. --- fileflows/scripts/05_Finalize_Move.js | 51 +++++- fileflows/tests/test_finalize_move_cleanup.py | 166 ++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 fileflows/tests/test_finalize_move_cleanup.py diff --git a/fileflows/scripts/05_Finalize_Move.js b/fileflows/scripts/05_Finalize_Move.js index 2b8b69e..4e209ee 100644 --- a/fileflows/scripts/05_Finalize_Move.js +++ b/fileflows/scripts/05_Finalize_Move.js @@ -4,8 +4,10 @@ * behandelt Untertitel (Text-Subs als .srt-Sidecar, Bild-Subs Disposition neutralisieren). * SICHERHEITS-FIX 17.07.2026: atomare Sidecar-Erzeugung, 0-Byte-Pruefung, * nur erfolgreich extrahierte Textspuren entfernen, Remux-Verifikation. + * CLEANUP-FIX 16.09.2026: leeres Release-Verzeichnis unter SourceRoot + * entfernen (nur wenn keine Mediendatei mehr drin liegt). * @author Sascha + Trulla - * @revision 2 + * @revision 3 * @output OK * @output Fehler */ @@ -58,6 +60,7 @@ function Script(SourceRoot, DestRoot) if (origPath !== destAbs) { Flow.Execute({ command: '/bin/rm', argumentList: ['-f', origPath] }); Logger.ILog('Deleted original: ' + origPath); + cleanupSourceDir(origPath); } // ======================================================================= @@ -87,6 +90,52 @@ function Script(SourceRoot, DestRoot) return 1; // -- Helfer -------------------------------------------------------------- + + /** + * Loescht das Release-Verzeichnis des Originals, wenn darin keine + * Mediendatei mehr liegt. Ohne das bleiben nach jedem Import leere + * Ordner mit .nfo/.sfv-Resten unter /tdarr/complete/ liegen, die den + * Handoff-Diagnostics-Endpoint als "target_directories" verschmutzen + * und faelschlich wie haengende Jobs aussehen. + * Sicherheit: nur unter SourceRoot, nur wenn keine Mediendatei mehr da. + */ + function cleanupSourceDir(originalFile) { + var dir = originalFile.substring(0, originalFile.lastIndexOf('/')); + + if (!dir || dir.indexOf(SourceRoot) !== 0 || dir === SourceRoot || + dir === SourceRoot.replace(/\/$/, '')) { + Logger.ILog('Cleanup: "' + dir + '" nicht unter ' + SourceRoot + ' - uebersprungen'); + return; + } + + var mediaExts = ['mkv', 'mp4', 'avi', 'ts', 'm2ts', 'mov', 'wmv', 'mpg', 'mpeg', 'm4v', 'iso']; + var findArgs = [dir, '-type', 'f', '(']; + mediaExts.forEach(function (ext, i) { + if (i > 0) findArgs.push('-o'); + findArgs.push('-iname', '*.' + ext); + }); + findArgs.push(')'); + + var found = Flow.Execute({ command: '/usr/bin/find', argumentList: findArgs }); + if (!found || found.exitCode !== 0) { + Logger.WLog('Cleanup: find fehlgeschlagen - Verzeichnis bleibt: ' + dir); + return; + } + + var remaining = ('' + (found.standardOutput || '')).trim(); + if (remaining.length > 0) { + Logger.ILog('Cleanup: Mediendateien noch vorhanden - Verzeichnis bleibt: ' + dir); + return; + } + + var del = Flow.Execute({ command: '/bin/rm', argumentList: ['-rf', dir] }); + if (del && del.exitCode === 0) { + Logger.ILog('Cleanup: Release-Verzeichnis entfernt: ' + dir); + } else { + Logger.WLog('Cleanup: rm fehlgeschlagen (exit=' + (del ? del.exitCode : '?') + '): ' + dir); + } + } + function handleSubtitles(file) { var ffprobe = Flow.GetToolPath('ffprobe') || Variables['ffprobe'] || 'ffprobe'; var ffmpeg = Flow.GetToolPath('ffmpeg') || Variables['ffmpeg'] || 'ffmpeg'; diff --git a/fileflows/tests/test_finalize_move_cleanup.py b/fileflows/tests/test_finalize_move_cleanup.py new file mode 100644 index 0000000..56dd29b --- /dev/null +++ b/fileflows/tests/test_finalize_move_cleanup.py @@ -0,0 +1,166 @@ +"""Tests fuer cleanupSourceDir in 05_Finalize_Move.js. + +Hintergrund (16.09.2026): Das Script loeschte nur die Original-Videodatei, nicht +das Release-Verzeichnis. Dadurch blieben unter /tdarr/complete/ leere Ordner mit +.nfo-Resten liegen. Die tauchten im Butler-Endpoint +GET /media/handoff/diagnostics als "target_directories" auf und sahen wie +haengende Jobs aus, obwohl der Import laengst durch war. + +Diese Tests fahren das echte Script in einem Node-VM-Sandkasten und pruefen, +dass rm -rf NUR bei leeren Release-Verzeichnissen unter SourceRoot laeuft. +""" + +import json +import subprocess +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).parents[1] / "scripts" / "05_Finalize_Move.js" + +NODE_HARNESS = r""" +const fs = require('fs'); +const vm = require('vm'); +const scenario = JSON.parse(process.argv[2]); +let source = fs.readFileSync(process.argv[3], 'utf8') + .replace(/^\/\*\*[\s\S]*?\*\/\s*/, ''); +source += '\n;globalThis.__exports = { Script };'; + +const executed = []; + +function fakeExecute(spec) { + const cmd = spec.command; + const args = spec.argumentList || []; + executed.push({command: cmd, args: args}); + + if (cmd === '/usr/bin/find') { + const dir = args[0]; + const hits = (scenario.mediaFiles || []).filter(f => f.startsWith(dir)); + return {exitCode: scenario.findExitCode === undefined ? 0 : scenario.findExitCode, + standardOutput: hits.join('\n')}; + } + if (cmd === '/usr/bin/test') { + return {exitCode: 1}; // Ziel existiert nicht + } + if (cmd === '/bin/mv') { + return {exitCode: scenario.mvExitCode === undefined ? 0 : scenario.mvExitCode}; + } + if (cmd === '/bin/rm') { + return {exitCode: scenario.rmExitCode === undefined ? 0 : scenario.rmExitCode}; + } + if (cmd === '/bin/mkdir') { + return {exitCode: 0}; + } + if (String(cmd).indexOf('ffprobe') !== -1) { + // keine Subtitle-Streams -> handleSubtitles steigt sofort aus + return {exitCode: 0, standardOutput: JSON.stringify({streams: []})}; + } + return {exitCode: 0, standardOutput: ''}; +} + +const context = { + Variables: { + 'file.Orig.FullName': scenario.origPath, + 'file.FullName': scenario.workingFile + }, + Flow: { + Execute: fakeExecute, + SetWorkingFile() {}, + GetToolPath: name => '/usr/bin/' + name, + TempPath: '/temp', + NewGuid: () => 'guid-0000' + }, + Logger: {ILog(){}, WLog(){}, ELog(){}, DLog(){}}, + JSON: JSON, + console +}; +vm.createContext(context); +vm.runInContext(source, context); + +const rc = context.__exports.Script( + scenario.sourceRoot || '/tdarr/complete/', + scenario.destRoot || '/tdarr/converted/' +); +console.log(JSON.stringify({rc: rc, executed: executed})); +""" + + +def run_script(scenario): + harness = Path("/tmp/ff_finalize_harness.js") + harness.write_text(NODE_HARNESS) + proc = subprocess.run( + ["node", str(harness), json.dumps(scenario), str(SCRIPT)], + capture_output=True, text=True, timeout=60, + ) + if proc.returncode != 0: + raise AssertionError(f"harness failed: {proc.stderr[-2000:]}") + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def rm_rf_targets(result): + return [e["args"][1] for e in result["executed"] + if e["command"] == "/bin/rm" and e["args"][:1] == ["-rf"]] + + +class CleanupSourceDirTests(unittest.TestCase): + + BASE = { + "origPath": "/tdarr/complete/serien4k/Slow.Horses.S06E01-W4K/slow.horses.s06e01-w4k.mkv", + "workingFile": "/temp/Runner-1/slow.horses.s06e01-w4k.mkv", + } + + def test_leeres_release_verzeichnis_wird_entfernt(self): + """Nur .nfo uebrig -> Verzeichnis muss weg.""" + result = run_script({**self.BASE, "mediaFiles": []}) + self.assertEqual(result["rc"], 1) + self.assertEqual( + rm_rf_targets(result), + ["/tdarr/complete/serien4k/Slow.Horses.S06E01-W4K"], + ) + + def test_verzeichnis_mit_restlicher_mediendatei_bleibt(self): + """Season-Pack: zweite Episode noch da -> nicht loeschen.""" + result = run_script({ + **self.BASE, + "mediaFiles": [ + "/tdarr/complete/serien4k/Slow.Horses.S06E01-W4K/slow.horses.s06e02-w4k.mkv" + ], + }) + self.assertEqual(result["rc"], 1) + self.assertEqual(rm_rf_targets(result), []) + + def test_find_fehler_laesst_verzeichnis_stehen(self): + """find schlaegt fehl -> fail safe, nichts loeschen.""" + result = run_script({**self.BASE, "mediaFiles": [], "findExitCode": 1}) + self.assertEqual(result["rc"], 1) + self.assertEqual(rm_rf_targets(result), []) + + def test_sourceroot_selbst_wird_nie_geloescht(self): + """Datei direkt in SourceRoot -> rm -rf /tdarr/complete darf NICHT passieren.""" + result = run_script({ + "origPath": "/tdarr/complete/lose.datei.mkv", + "workingFile": "/temp/Runner-1/lose.datei.mkv", + "mediaFiles": [], + }) + self.assertEqual(rm_rf_targets(result), []) + + def test_pfad_ausserhalb_sourceroot_wird_abgelehnt(self): + """Original nicht unter SourceRoot -> Script bricht mit 2 ab, kein rm -rf.""" + result = run_script({ + "origPath": "/mnt/anderswo/film/film.mkv", + "workingFile": "/temp/Runner-1/film.mkv", + "mediaFiles": [], + }) + self.assertEqual(result["rc"], 2) + self.assertEqual(rm_rf_targets(result), []) + + def test_originaldatei_wird_weiterhin_geloescht(self): + """Regression: rm -f auf das Original bleibt erhalten.""" + result = run_script({**self.BASE, "mediaFiles": []}) + rm_f = [e["args"][1] for e in result["executed"] + if e["command"] == "/bin/rm" and e["args"][:1] == ["-f"]] + self.assertIn(self.BASE["origPath"], rm_f) + + +if __name__ == "__main__": + unittest.main(verbosity=2)