Compare commits

...
Sign in to create a new pull request.

14 commits

Author SHA1 Message Date
468ca33bb3 Merge pull request 'Remove empty release dirs after finalize move' (#12) from fix/finalize-move-cleanup-20260916 into main 2026-09-16 11:45:57 +02:00
Trulla
650417d42b 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.
2026-09-16 11:45:33 +02:00
d32600aaa2 Merge pull request 'Add FileFlows ApiKey guard against [REDACTED] key corruption' (#11) from fix/fileflows-apikey-guard-20260916 into main 2026-09-16 11:41:12 +02:00
Trulla
18e123bdb2 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).
2026-09-16 11:34:22 +02:00
bb90021adc Merge pull request 'Fail closed on wrong-series season-pack releases' (#10) from fix/sonarr-pack-series-identity-20260916 into main 2026-09-16 07:19:22 +02:00
0acfe3194f Cover wrong-series season-pack release 2026-09-16 07:19:21 +02:00
63a48d541a Fail closed on tracked series title mismatch 2026-09-16 07:19:20 +02:00
e205763bba Merge pull request 'Fix Sonarr season-pack imports after container updates' (#9) from fix/sonarr-pack-queue-resolution-20260916 into main 2026-09-16 07:13:35 +02:00
a3a5f4e802 Increase Sonarr import wait timeout to 15 minutes 2026-09-16 07:13:33 +02:00
8881a5086e Add ambiguous season-pack regression tests 2026-09-16 07:13:33 +02:00
7abfb9e836 Resolve season-pack episode from exact filename 2026-09-16 07:13:32 +02:00
48f8719618 Merge pull request 'Pin FileFlows canary to 26.09' (#8) from chore/fileflows-26.09-canary-20260914 into main 2026-09-14 15:49:03 +02:00
c508582808 Pin FileFlows canary to 26.09 2026-09-14 15:49:01 +02:00
ac2272170f Merge pull request 'Fix FileFlows Sonarr season-pack path routing' (#7) from fix/sonarr-pack-path-scope-20260913 into main 2026-09-14 09:15:06 +02:00
8 changed files with 593 additions and 11 deletions

View file

@ -1,6 +1,6 @@
services: services:
fileflows: fileflows:
image: revenz/fileflows:26.06 image: revenz/fileflows:26.09
container_name: fileflows container_name: fileflows
restart: always restart: always
ports: ports:

View file

@ -386,7 +386,7 @@
"ImportPath": "/tdarr/converted/serien4k", "ImportPath": "/tdarr/converted/serien4k",
"UseUnmappedPath": false, "UseUnmappedPath": false,
"MoveMode": true, "MoveMode": true,
"TimeOut": 300, "TimeOut": 900,
"Color": "" "Color": ""
} }
}, },
@ -420,7 +420,7 @@
"ImportPath": "/tdarr/converted/serien", "ImportPath": "/tdarr/converted/serien",
"UseUnmappedPath": false, "UseUnmappedPath": false,
"MoveMode": true, "MoveMode": true,
"TimeOut": 300, "TimeOut": 900,
"Color": "" "Color": ""
} }
}, },

87
fileflows/guard/README.md Normal file
View file

@ -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' '<butler-token>' | sudo tee /app-config/ff-guard/butler.token
sudo chmod 600 /app-config/ff-guard/butler.token
# Key-Map: "<port> <apikey>" 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 <uid> — repariere`
- `Flow <uid> 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.

View file

@ -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: "<port> <key>").
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

View file

@ -4,8 +4,10 @@
* behandelt Untertitel (Text-Subs als .srt-Sidecar, Bild-Subs Disposition neutralisieren). * behandelt Untertitel (Text-Subs als .srt-Sidecar, Bild-Subs Disposition neutralisieren).
* SICHERHEITS-FIX 17.07.2026: atomare Sidecar-Erzeugung, 0-Byte-Pruefung, * SICHERHEITS-FIX 17.07.2026: atomare Sidecar-Erzeugung, 0-Byte-Pruefung,
* nur erfolgreich extrahierte Textspuren entfernen, Remux-Verifikation. * 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 * @author Sascha + Trulla
* @revision 2 * @revision 3
* @output OK * @output OK
* @output Fehler * @output Fehler
*/ */
@ -58,6 +60,7 @@ function Script(SourceRoot, DestRoot)
if (origPath !== destAbs) { if (origPath !== destAbs) {
Flow.Execute({ command: '/bin/rm', argumentList: ['-f', origPath] }); Flow.Execute({ command: '/bin/rm', argumentList: ['-f', origPath] });
Logger.ILog('Deleted original: ' + origPath); Logger.ILog('Deleted original: ' + origPath);
cleanupSourceDir(origPath);
} }
// ======================================================================= // =======================================================================
@ -87,6 +90,52 @@ function Script(SourceRoot, DestRoot)
return 1; return 1;
// -- Helfer -------------------------------------------------------------- // -- 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) { function handleSubtitles(file) {
var ffprobe = Flow.GetToolPath('ffprobe') || Variables['ffprobe'] || 'ffprobe'; var ffprobe = Flow.GetToolPath('ffprobe') || Variables['ffprobe'] || 'ffprobe';
var ffmpeg = Flow.GetToolPath('ffmpeg') || Variables['ffmpeg'] || 'ffmpeg'; var ffmpeg = Flow.GetToolPath('ffmpeg') || Variables['ffmpeg'] || 'ffmpeg';

View file

@ -2,7 +2,7 @@
* @name Sonarr - Trigger Manual Import * @name Sonarr - Trigger Manual Import
* @description Safely trigger Sonarr ManualImport for exactly one processed episode file. * @description Safely trigger Sonarr ManualImport for exactly one processed episode file.
* @author FileFlows + Sascha * @author FileFlows + Sascha
* @revision 11 * @revision 13
* @output Import erfolgreich * @output Import erfolgreich
* @output Fehler * @output Fehler
* @param {string} URL Sonarr root URL and port * @param {string} URL Sonarr root URL and port
@ -57,8 +57,8 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
if (!(seriesId && episodeIds)) { if (!(seriesId && episodeIds)) {
let trackedEpisode = null; let trackedEpisode = null;
for (const pattern of searchPatterns) { for (const pattern of searchPatterns) {
trackedEpisode = findEpisodeFromQueue(pattern, sonarr) trackedEpisode = findEpisodeFromQueue(pattern, sonarr, filePattern)
?? findEpisodeFromGrabHistory(pattern, sonarr); ?? findEpisodeFromGrabHistory(pattern, sonarr, filePattern);
if (trackedEpisode) break; if (trackedEpisode) break;
} }
if (!trackedEpisode) { if (!trackedEpisode) {
@ -226,7 +226,7 @@ function sendManualImportCommand(sonarr, cmdBody) {
/*────────────────────────────── helpers ──────────────────────────────────*/ /*────────────────────────────── helpers ──────────────────────────────────*/
function findEpisodeFromQueue(searchPattern, sonarr) { function findEpisodeFromQueue(searchPattern, sonarr, episodePattern) {
if (!searchPattern) return null; if (!searchPattern) return null;
try { try {
const query = buildQueryParams({ page: 1, pageSize: 200, includeUnknownSeriesItems: true }); 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}.`); Logger.ILog(`Matched Sonarr queue seriesId ${matches[0].seriesId}, episodeId ${matches[0].episodeId}.`);
return { id: matches[0].seriesId, episodeIds: [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; return null;
} catch (e) { } catch (e) {
Logger.ELog(`Error fetching Sonarr queue: ${e.message}`); 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; if (!searchPattern) return null;
try { try {
const query = buildQueryParams({ const query = buildQueryParams({
@ -269,7 +273,11 @@ function findEpisodeFromGrabHistory(searchPattern, sonarr) {
Logger.ILog(`Matched Sonarr grab history seriesId ${seriesId}, episodeId ${episodeId}.`); Logger.ILog(`Matched Sonarr grab history seriesId ${seriesId}, episodeId ${episodeId}.`);
return { id: seriesId, episodeIds: [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; return null;
} catch (e) { } catch (e) {
Logger.ELog(`Error fetching Sonarr history: ${e.message}`); Logger.ELog(`Error fetching Sonarr history: ${e.message}`);
@ -277,6 +285,61 @@ 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) {
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) { function normalizeReleaseTitle(value) {
return (value || '').toLowerCase().replace(/[^a-z0-9]+/g, ''); return (value || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
} }

View file

@ -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)

View file

@ -22,6 +22,8 @@ class FakeSonarr {
calls.push({type: 'fetch', endpoint, query}); calls.push({type: 'fetch', endpoint, query});
if (endpoint === 'queue') return scenario.queueResponse || {records: []}; if (endpoint === 'queue') return scenario.queueResponse || {records: []};
if (endpoint === 'history') return scenario.historyResponse || {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 === 'parse') return scenario.parseResponse || {};
if (endpoint === 'manualimport') return scenario.candidates || []; if (endpoint === 'manualimport') return scenario.candidates || [];
return null; return null;
@ -77,6 +79,115 @@ def run_script(scenario):
class SonarrImportSafetyTests(unittest.TestCase): 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): def test_fhd_scope_does_not_match_uhd_sibling_prefix(self):
exact = ( exact = (
"/tdarr/converted/serien4k/Marshals.2026.S01.GERMAN.DUBBED/" "/tdarr/converted/serien4k/Marshals.2026.S01.GERMAN.DUBBED/"
@ -117,6 +228,7 @@ class SonarrImportSafetyTests(unittest.TestCase):
"scopeRoot": "/tdarr/converted/serien4k", "scopeRoot": "/tdarr/converted/serien4k",
"queueResponse": {"records": queue_records}, "queueResponse": {"records": queue_records},
"historyResponse": {"records": history_records}, "historyResponse": {"records": history_records},
"seriesResponse": {"id": 600, "title": "Marshals", "cleanTitle": "marshals"},
"parseResponse": { "parseResponse": {
"series": {"id": 600, "title": "Marshals"}, "series": {"id": 600, "title": "Marshals"},
"episodes": [{"id": 37505}], "episodes": [{"id": 37505}],