Soft per-user clip quotas: dumpd pauses/resumes Syncthing folders by folder size (CLIPS_QUOTA_GB, per-user overrides file); clips page shows usage

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 18:30:22 -05:00
parent c56a2997ba
commit 07ffae0746
5 changed files with 73 additions and 0 deletions

View File

@@ -100,6 +100,62 @@ ST_CONFIG = Path(os.environ.get("SYNCTHING_CONFIG", "/srv/media/.syncthing/confi
ST_CLIPS_PREFIX = os.environ.get("SYNCTHING_CLIPS_PREFIX", "/var/syncthing/clips") # path INSIDE the syncthing container
STIGNORE_SRC = Path(os.environ.get("STIGNORE_SRC", "/etc/vanguard/stignore"))
MIN_FREE_GB = int(os.environ.get("CLIPS_MIN_FREE_GB", "300"))
CLIPS_QUOTA_GB = float(os.environ.get("CLIPS_QUOTA_GB", "100")) # default per-user quota for clips/<user>
CLIPS_QUOTA_FILE = Path(os.environ.get("CLIPS_QUOTA_FILE", "/etc/vanguard/clip-quotas.json")) # {"user": GB, ...} overrides
QUOTA_CHECK_S = int(os.environ.get("CLIPS_QUOTA_CHECK_S", "300"))
QUOTA_STATE = Path(os.environ.get("CLIPS_QUOTA_STATE", "/srv/media/.markers/clip-quota-paused.json"))
def quota_for(user):
try:
q = json.loads(CLIPS_QUOTA_FILE.read_text()).get(user)
if q is not None: return float(q)
except (OSError, json.JSONDecodeError, ValueError):
pass
return CLIPS_QUOTA_GB
def folder_bytes(path):
total = 0
for root, dirs, files in os.walk(path):
for f in files:
try: total += os.lstat(os.path.join(root, f)).st_size
except OSError: pass
return total
def quota_state():
try: return json.loads(QUOTA_STATE.read_text())
except (OSError, json.JSONDecodeError): return {}
def quota_enforce_once():
"""Pause a user's Syncthing folder when clips/<user> exceeds their quota; resume when back under 90 %."""
paused = quota_state(); changed = False
try:
folders = st("GET", "/rest/config/folders")
except Exception as e:
print(f"{datetime.now(TZ):%F %T} quota: syncthing unreachable ({e})", flush=True); return
for f in folders:
if not f["id"].startswith("clips-"): continue
user = f["id"][len("clips-"):]
path = Path(ROOT) / "clips" / user
if not path.is_dir(): continue
used = folder_bytes(path); q = quota_for(user) * 1e9
over = used > q; under = used < q * 0.9
if over and not f.get("paused"):
try: st("POST", f"/rest/db/pause?folder={f['id']}"); paused[user] = {"at": datetime.now(TZ).isoformat(), "used_gb": round(used / 1e9, 1), "quota_gb": q / 1e9}; changed = True
except Exception as e: print(f"{datetime.now(TZ):%F %T} quota: pause {user} failed: {e}", flush=True); continue
print(f"{datetime.now(TZ):%F %T} quota: PAUSED clips-{user} ({used/1e9:.1f} GB > {q/1e9:.0f} GB)", flush=True)
elif under and f.get("paused") and user in paused:
try: st("POST", f"/rest/db/resume?folder={f['id']}"); paused.pop(user, None); changed = True
except Exception as e: print(f"{datetime.now(TZ):%F %T} quota: resume {user} failed: {e}", flush=True); continue
print(f"{datetime.now(TZ):%F %T} quota: resumed clips-{user} ({used/1e9:.1f} GB)", flush=True)
if changed:
try: QUOTA_STATE.parent.mkdir(exist_ok=True); QUOTA_STATE.write_text(json.dumps(paused, indent=2))
except OSError: pass
def quota_loop():
while True:
try: quota_enforce_once()
except Exception as e: print(f"{datetime.now(TZ):%F %T} quota loop error: {e}", flush=True)
_time.sleep(QUOTA_CHECK_S)
def st_apikey():
try:
@@ -146,6 +202,11 @@ def clips_status(user):
except Exception as e:
out["error"] = f"syncthing: {e}"
out["folder_exists"] = Path(out["server_path"]).is_dir()
out["quota_gb"] = quota_for(user)
out["used_gb"] = round(folder_bytes(out["server_path"]) / 1e9, 2) if out["folder_exists"] else 0.0
ps = quota_state().get(user)
out["paused_for_quota"] = bool(ps)
if ps: out["paused_since"] = ps.get("at")
return out
def clips_setup(user, device_id, device_name=None):
@@ -357,4 +418,5 @@ if __name__ == "__main__":
print(f"dumpd listening on {LISTEN[0]}:{LISTEN[1]} (dump, me) and {AUTH_LISTEN[0]}:{AUTH_LISTEN[1]} (mtx-auth) tz={TZ.key} night={NIGHT_START:%H:%M}-{NIGHT_END:%H:%M} token-secret={'ok' if token_secret() else 'MISSING'}", flush=True)
auth_srv = ThreadingHTTPServer(AUTH_LISTEN, H)
threading.Thread(target=auth_srv.serve_forever, daemon=True).start()
threading.Thread(target=quota_loop, daemon=True).start() # per-user clip quotas (pause/resume Syncthing folders)
ThreadingHTTPServer(LISTEN, H).serve_forever()