[object Object]

← back to Wallco Ai

Add sweep-orphan-rooms.py: remove unreferenced room PNGs (dry-run default, --delete/--verify), reclaimed ~1.7GB on prod

6ef7cefcd2bca2b7dfa81115b22594387f1c4e5f · 2026-05-29 08:43:51 -0700 · Steve

Files touched

Diff

commit 6ef7cefcd2bca2b7dfa81115b22594387f1c4e5f
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri May 29 08:43:51 2026 -0700

    Add sweep-orphan-rooms.py: remove unreferenced room PNGs (dry-run default, --delete/--verify), reclaimed ~1.7GB on prod
---
 scripts/sweep-orphan-rooms.py | 96 +++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 96 insertions(+)

diff --git a/scripts/sweep-orphan-rooms.py b/scripts/sweep-orphan-rooms.py
new file mode 100644
index 0000000..fe459af
--- /dev/null
+++ b/scripts/sweep-orphan-rooms.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+"""Sweep orphan room-mockup PNGs from data/rooms.
+
+An orphan is any data/rooms/design_<id>_<room>[_hero].png whose (id, room) pair
+is NOT referenced by that design's room_mockups in designs.json (or whose id is
+absent from the snapshot entirely). The server only ever serves rooms listed in
+room_mockups, so orphans are unreachable dead weight on disk.
+
+_hero variants are tied to their base room: design_<id>_<room>_hero.png is kept
+iff (id, room) is referenced — so a referenced hero is never swept.
+
+Default is a DRY RUN (reports scope, writes the orphan list, deletes nothing).
+Pass --delete to actually remove. Pass --verify to re-audit broken refs after.
+
+Usage:
+  sweep-orphan-rooms.py                 # dry run
+  sweep-orphan-rooms.py --delete --verify
+"""
+import json, os, re, glob, argparse
+from collections import defaultdict
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))  # served project dir
+ROOMS_DIR = os.path.join(ROOT, 'data', 'rooms')
+DESIGNS = os.path.join(ROOT, 'data', 'designs.json')
+PAT = re.compile(r'design_(\d+)_([a-z_]+?)(_hero)?\.png$')
+
+
+def rooms_of(x):
+    rm = x.get('room_mockups') or []
+    if isinstance(rm, dict):
+        return set(rm.keys())
+    if isinstance(rm, list):
+        return set(r for r in rm if isinstance(r, str))
+    return set()
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--delete', action='store_true', help='actually remove orphans (default: dry run)')
+    ap.add_argument('--verify', action='store_true', help='re-audit broken refs after')
+    ap.add_argument('--list-out', default='/tmp/orphan_rooms.txt')
+    a = ap.parse_args()
+
+    d = json.load(open(DESIGNS))
+    ids_in_snap = set(x['id'] for x in d)
+    ref = {x['id']: rooms_of(x) for x in d}
+
+    files = glob.glob(ROOMS_DIR + '/design_*.png')
+    orphans = []
+    reason = defaultdict(int)
+    total_sz = orphan_sz = 0
+    kept = 0
+    for f in files:
+        sz = os.path.getsize(f); total_sz += sz
+        m = PAT.search(os.path.basename(f))
+        if not m:
+            orphans.append(f); orphan_sz += sz; reason['unparseable'] += 1; continue
+        did = int(m.group(1)); room = m.group(2)
+        if did not in ids_in_snap:
+            orphans.append(f); orphan_sz += sz; reason['id_not_in_snapshot'] += 1
+        elif room not in ref.get(did, set()):
+            orphans.append(f); orphan_sz += sz; reason['room_not_in_mockups'] += 1
+        else:
+            kept += 1
+
+    print('total room PNGs: %d (%.1f MB)' % (len(files), total_sz / 1e6))
+    print('KEEP (referenced): %d' % kept)
+    print('ORPHANS: %d (%.1f MB reclaimable)' % (len(orphans), orphan_sz / 1e6))
+    print('orphan reasons:', dict(reason))
+    open(a.list_out, 'w').write('\n'.join(orphans))
+    print('orphan list -> %s' % a.list_out)
+
+    if a.delete:
+        n = 0
+        for f in orphans:
+            try:
+                os.remove(f); n += 1
+            except FileNotFoundError:
+                pass
+        print('[deleted] %d files' % n)
+    else:
+        print('[dry run] nothing deleted — pass --delete to act')
+
+    if a.verify:
+        broken = defaultdict(int)
+        for x in d:
+            if not x.get('is_published'):
+                continue
+            for r in rooms_of(x):
+                if not os.path.exists(ROOMS_DIR + '/design_%d_%s.png' % (x['id'], r)):
+                    broken[r] += 1
+        print('post-sweep broken refs (published):', dict(broken) or 'NONE — 0 broken')
+
+
+if __name__ == '__main__':
+    main()

← 9b7f1eb feat(designs-grid): Contact sheet button on admin bulk-selec  ·  back to Wallco Ai  ·  fix(cactus-curator): batch bulk-delete + drop removed SKUs f 3778fde →