diff --git a/servers/legion/k8s/media/fornax-coordinator.yaml b/servers/legion/k8s/media/fornax-coordinator.yaml index b12df99..0867ccb 100644 --- a/servers/legion/k8s/media/fornax-coordinator.yaml +++ b/servers/legion/k8s/media/fornax-coordinator.yaml @@ -12,19 +12,18 @@ data: coordinator.py: | #!/usr/bin/env python3 """ - Fornax coordinator — round-robin proxy for qBittorrent API. - - POST /api/v2/torrents/add → round-robins across all backends + Fornax coordinator — least-loaded proxy for qBittorrent API. + - POST /api/v2/torrents/add → routed to worker with fewest active torrents - Everything else → proxied to primary backend (qbittorrent) Manages its own SID sessions to each backend so auth is transparent. """ - import itertools, os, urllib.request, urllib.parse + import json, os, urllib.request, urllib.parse from http.server import HTTPServer, BaseHTTPRequestHandler BACKENDS = [b.strip() for b in os.environ.get("BACKENDS", "").split(",") if b.strip()] USER = os.environ.get("QBT_USER", "admin") PASS = os.environ.get("QBT_PASS", "adminadmin") PRIMARY = BACKENDS[0] if BACKENDS else "" - pool = itertools.cycle(BACKENDS) sids = {} def fetch_sid(backend): @@ -44,9 +43,26 @@ data: sids[backend] = fetch_sid(backend) return sids.get(backend, "") + def active_count(backend): + """Return number of downloading torrents on this backend, or large int on error.""" + try: + req = urllib.request.Request( + f"{backend}/api/v2/torrents/info?filter=downloading", + headers={"Cookie": f"SID={sid(backend)}"}, + ) + with urllib.request.urlopen(req, timeout=3) as r: + return len(json.loads(r.read())) + except Exception: + return 9999 + + def least_loaded(): + counts = {b: active_count(b) for b in BACKENDS} + chosen = min(counts, key=counts.get) + print(f" load: {counts} → routing to {chosen}") + return chosen + def forward(backend, method, path, body, content_type): - cookie = f"SID={sid(backend)}" - headers = {"Cookie": cookie} + headers = {"Cookie": f"SID={sid(backend)}"} if content_type: headers["Content-Type"] = content_type req = urllib.request.Request( @@ -57,8 +73,7 @@ data: with urllib.request.urlopen(req, timeout=30) as r: return r.status, r.read(), list(r.getheaders()) except urllib.error.HTTPError as e: - # SID expired — refresh once and retry - if e.code == 403: + if e.code == 403: # SID expired — refresh once and retry sids[backend] = fetch_sid(backend) headers["Cookie"] = f"SID={sids[backend]}" req2 = urllib.request.Request( @@ -76,24 +91,19 @@ data: class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): - print(f"{self.command} {self.path} → {args[1] if len(args) > 1 else ''}") + print(f"{self.command} {self.path}") def _proxy(self, method): length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length) if length else None ct = self.headers.get("Content-Type", "") - if method == "POST" and "/api/v2/torrents/add" in self.path: - backend = next(pool) - print(f" routing add → {backend}") - else: - backend = PRIMARY + backend = least_loaded() if (method == "POST" and "/api/v2/torrents/add" in self.path) else PRIMARY status, resp_body, resp_headers = forward(backend, method, self.path, body, ct) self.send_response(status) - skip = {"transfer-encoding", "connection"} for k, v in resp_headers: - if k.lower() not in skip: + if k.lower() not in {"transfer-encoding", "connection"}: self.send_header(k, v) self.end_headers() self.wfile.write(resp_body)