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.
This commit is contained in:
Trulla 2026-09-16 11:45:33 +02:00
parent d32600aaa2
commit 650417d42b
2 changed files with 216 additions and 1 deletions

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)