From ae57c7f661ec14e1707b94e3144ac6cfd3bd2e8f Mon Sep 17 00:00:00 2001 From: ginnoir Date: Mon, 8 Jun 2026 03:38:37 -0500 Subject: [PATCH] chore(romhacks): add extraction-cruft cleanup script (keeps archives, audits for loss) --- .../romhacks/orchestrator/clean_extracted.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 stacks/romhacks/orchestrator/clean_extracted.py diff --git a/stacks/romhacks/orchestrator/clean_extracted.py b/stacks/romhacks/orchestrator/clean_extracted.py new file mode 100644 index 0000000..a51f7dc --- /dev/null +++ b/stacks/romhacks/orchestrator/clean_extracted.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Remove JDownloader auto-extraction cruft from the romhacks library. + +For each library// folder, list every archive's contents (bsdtar) and +delete only the loose files whose path exactly matches an archive entry — i.e. +files that are reproducible by re-extracting an archive that is STILL PRESENT. +Archives themselves, art, guides/spreadsheets, and anything NOT contained in an +archive are always kept. If a folder has no archive, nothing is deleted (the +loose files are the only copy). + +Also audits for possible data loss: flags any folder that ends up with neither a +source archive nor a rom file. + +CLEAN_DRY=true (default) previews only. +""" +import os +import pathlib +import subprocess +import sys + +LIBRARY = pathlib.Path(os.environ.get("LIBRARY_DIR", "/library")) +DRY = os.environ.get("CLEAN_DRY", "true").lower() != "false" +ARCHIVE_EXTS = {".zip", ".rar", ".7z"} +ROM_EXTS = {".gba", ".gb", ".gbc", ".nds", ".3ds", ".cia", ".nsp", ".xci"} + + +def listing(path): + try: + out = subprocess.run(["bsdtar", "-tf", str(path)], capture_output=True, + text=True, timeout=300) + return [l for l in out.stdout.splitlines() if l.strip()] + except Exception: + return [] + + +def main(): + total_files = 0 + total_bytes = 0 + flagged = [] + for folder in sorted(p for p in LIBRARY.iterdir() if p.is_dir()): + archives = [p for p in folder.rglob("*") + if p.is_file() and p.suffix.lower() in ARCHIVE_EXTS] + # Resolve the set of paths each archive would extract to (relative to the + # archive's own directory) — these are the reproducible files. + extracted = set() + for a in archives: + for entry in listing(a): + extracted.add((a.parent / entry).resolve()) + + del_files = [p for p in folder.rglob("*") + if p.is_file() and p.suffix.lower() not in ARCHIVE_EXTS + and p.resolve() in extracted] + nbytes = sum(p.stat().st_size for p in del_files) + + # Audit: will the folder still hold a recoverable rom (an archive, or a + # loose rom we are NOT deleting)? + keeps = bool(archives) or any( + p.suffix.lower() in ROM_EXTS and p not in del_files + for p in folder.rglob("*") if p.is_file()) + if not keeps: + flagged.append(folder.name) + + if del_files: + verb = "would delete" if DRY else "deleting" + print(f"[clean] {folder.name}: {verb} {len(del_files)} file(s) " + f"{nbytes/1048576:.0f}MB (keeping {len(archives)} archive(s))") + total_files += len(del_files) + total_bytes += nbytes + if not DRY: + for p in del_files: + try: + p.unlink() + except Exception as e: + print(f" unlink fail {p}: {e}", file=sys.stderr) + if not DRY: + for d in sorted((p for p in folder.rglob("*") if p.is_dir()), + key=lambda x: len(x.parts), reverse=True): + try: + d.rmdir() + except OSError: + pass + + print(f"\n[clean] {'WOULD reclaim' if DRY else 'reclaimed'} " + f"{total_bytes/1073741824:.2f} GB across {total_files} files; DRY={DRY}") + if flagged: + print(f"[clean] WARNING — {len(flagged)} folder(s) with NO archive and " + f"NO rom (possible loss / check these):") + for f in flagged: + print(" ", f) + else: + print("[clean] audit OK — every folder retains an archive or rom.") + + +if __name__ == "__main__": + main()