From 7abfb9e836796ac577831aed9867033b6ee3cb29 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 16 Sep 2026 07:13:32 +0200 Subject: [PATCH 1/3] 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, ''); } -- 2.49.1 From 8881a5086e870916a7728ce513e773cf2966aa4e Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 16 Sep 2026 07:13:33 +0200 Subject: [PATCH 2/3] 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/" -- 2.49.1 From a3a5f4e802afb4eb3ffbdf5b915a49e69f3c7367 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 16 Sep 2026 07:13:33 +0200 Subject: [PATCH 3/3] 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": "" } }, -- 2.49.1