diff --git a/compose.yaml b/compose.yaml index f0c15d0..9f0df2b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ services: fileflows: - image: revenz/fileflows:26.09 + image: revenz/fileflows:26.06 container_name: fileflows restart: always ports: diff --git a/fileflows/flow/flow_auto_movie_pipeline.json b/fileflows/flow/flow_auto_movie_pipeline.json index 3446d87..95bc959 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": 900, + "TimeOut": 300, "Color": "" } }, @@ -420,7 +420,7 @@ "ImportPath": "/tdarr/converted/serien", "UseUnmappedPath": false, "MoveMode": true, - "TimeOut": 900, + "TimeOut": 300, "Color": "" } }, diff --git a/fileflows/guard/README.md b/fileflows/guard/README.md deleted file mode 100644 index fd209bd..0000000 --- a/fileflows/guard/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# 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 deleted file mode 100755 index 0b38dd6..0000000 --- a/fileflows/guard/ff-apikey-guard.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/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 diff --git a/fileflows/scripts/05_Finalize_Move.js b/fileflows/scripts/05_Finalize_Move.js index 4e209ee..2b8b69e 100644 --- a/fileflows/scripts/05_Finalize_Move.js +++ b/fileflows/scripts/05_Finalize_Move.js @@ -4,10 +4,8 @@ * 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 3 + * @revision 2 * @output OK * @output Fehler */ @@ -60,7 +58,6 @@ function Script(SourceRoot, DestRoot) if (origPath !== destAbs) { Flow.Execute({ command: '/bin/rm', argumentList: ['-f', origPath] }); Logger.ILog('Deleted original: ' + origPath); - cleanupSourceDir(origPath); } // ======================================================================= @@ -90,52 +87,6 @@ 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/scripts/Radarr_-_Trigger_Manual_Import.js b/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js index 04cd807..b4c4844 100644 --- a/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js +++ b/fileflows/scripts/Radarr_-_Trigger_Manual_Import.js @@ -53,7 +53,6 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { 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) { diff --git a/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js b/fileflows/scripts/Sonarr_-_Trigger_Manual_Import.js index 610b6aa..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 13 + * @revision 10 * @output Import erfolgreich * @output Fehler * @param {string} URL Sonarr root URL and port @@ -31,16 +31,13 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { } // ── SKIP-GUARD: Nur API-Call machen, wenn der aktuelle ImportPath - // 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; + // 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; + } } const sonarr = new Sonarr(URL, ApiKey); @@ -57,22 +54,11 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) { if (!(seriesId && episodeIds)) { let trackedEpisode = null; for (const pattern of searchPatterns) { - trackedEpisode = findEpisodeFromQueue(pattern, sonarr, filePattern) - ?? findEpisodeFromGrabHistory(pattern, sonarr, filePattern); + trackedEpisode = findEpisodeFromQueue(pattern, sonarr) + ?? findEpisodeFromGrabHistory(pattern, sonarr); if (trackedEpisode) break; } - 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; - } - } + if (!trackedEpisode) trackedEpisode = parseSeries(searchPatterns[0] || '', sonarr); seriesId = trackedEpisode?.id ?? null; episodeIds = trackedEpisode?.episodeIds ?? null; } @@ -226,7 +212,7 @@ function sendManualImportCommand(sonarr, cmdBody) { /*────────────────────────────── helpers ──────────────────────────────────*/ -function findEpisodeFromQueue(searchPattern, sonarr, episodePattern) { +function findEpisodeFromQueue(searchPattern, sonarr) { if (!searchPattern) return null; try { const query = buildQueryParams({ page: 1, pageSize: 200, includeUnknownSeriesItems: true }); @@ -240,11 +226,7 @@ function findEpisodeFromQueue(searchPattern, sonarr, episodePattern) { 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) { - const resolved = resolvePackEpisode(matches, episodePattern, sonarr); - if (resolved) return resolved; - Logger.WLog(`Ambiguous Sonarr queue match (${matches.length} records).`); - } + if (matches.length > 1) Logger.WLog(`Ambiguous Sonarr queue match (${matches.length} records).`); return null; } catch (e) { Logger.ELog(`Error fetching Sonarr queue: ${e.message}`); @@ -252,7 +234,7 @@ function findEpisodeFromQueue(searchPattern, sonarr, episodePattern) { } } -function findEpisodeFromGrabHistory(searchPattern, sonarr, episodePattern) { +function findEpisodeFromGrabHistory(searchPattern, sonarr) { if (!searchPattern) return null; try { const query = buildQueryParams({ @@ -273,11 +255,7 @@ function findEpisodeFromGrabHistory(searchPattern, sonarr, episodePattern) { Logger.ILog(`Matched Sonarr grab history seriesId ${seriesId}, episodeId ${episodeId}.`); return { id: seriesId, episodeIds: [episodeId] }; } - if (keys.length > 1) { - const resolved = resolvePackEpisode(matches, episodePattern, sonarr); - if (resolved) return resolved; - Logger.WLog(`Ambiguous Sonarr grab history match (${keys.length} episodes).`); - } + if (keys.length > 1) Logger.WLog(`Ambiguous Sonarr grab history match (${keys.length} episodes).`); return null; } catch (e) { Logger.ELog(`Error fetching Sonarr history: ${e.message}`); @@ -285,61 +263,6 @@ function findEpisodeFromGrabHistory(searchPattern, sonarr, episodePattern) { } } -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) { - 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 => - 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, ''); } diff --git a/fileflows/tests/test_finalize_move_cleanup.py b/fileflows/tests/test_finalize_move_cleanup.py deleted file mode 100644 index 56dd29b..0000000 --- a/fileflows/tests/test_finalize_move_cleanup.py +++ /dev/null @@ -1,166 +0,0 @@ -"""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) diff --git a/fileflows/tests/test_sonarr_import_safety.py b/fileflows/tests/test_sonarr_import_safety.py index 047d4e0..7c45df6 100644 --- a/fileflows/tests/test_sonarr_import_safety.py +++ b/fileflows/tests/test_sonarr_import_safety.py @@ -22,8 +22,6 @@ 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 || []; return null; @@ -79,179 +77,6 @@ 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}, - "seriesResponse": {"id": 794, "title": "Lucky", "cleanTitle": "lucky"}, - "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_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" - 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/" - "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}, - "seriesResponse": {"id": 600, "title": "Marshals", "cleanTitle": "marshals"}, - "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."