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:
62
bin/dumpd.py
62
bin/dumpd.py
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user