From 650417d42b944bb88e84b36680c3768c09d25c25 Mon Sep 17 00:00:00 2001 From: Trulla Date: Wed, 16 Sep 2026 11:45:33 +0200 Subject: [PATCH] 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)