#!/usr/bin/env python3 """Webcam-Relay: holt den MJPEG-Stream der Kamera einmal ab und stellt ihn per HTTP für beliebig viele Zuschauer bereit (hinter Zoraxy / Reverse Proxy). Endpunkte: / Übersicht aller Kameras // Webseite mit Livebild einer Kamera //stream.mjpg Livestream (multipart/x-mixed-replace) //snapshot.jpg aktuelles Einzelbild /stream.mjpg Alias für die erste Kamera (Abwärtskompatibilität) /health Status aller Kameras Konfiguration über die Datei .env (neben relay.py) oder Umgebungsvariablen: CAMERA_= eine Zeile pro Kamera, z.B. CAMERA_cam1=http://... PORT (default 8000) AUTH_USER / AUTH_PASS optional: Basic-Auth (nur sinnvoll mit HTTPS in Zoraxy) """ import base64 import hmac import os import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import requests def load_env(path): """Liest KEY=VALUE-Zeilen aus .env; echte Umgebungsvariablen haben Vorrang.""" try: with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) os.environ.setdefault(k.strip(), v.strip().strip("\"'")) except FileNotFoundError: pass load_env(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) CAMERAS = {k[7:].lower(): v for k, v in os.environ.items() if k.startswith("CAMERA_") and v} if not CAMERAS: raise SystemExit("Keine Kameras konfiguriert: CAMERA_= in .env eintragen") PORT = int(os.environ.get("PORT", "8000")) AUTH_USER = os.environ.get("AUTH_USER", "") AUTH_PASS = os.environ.get("AUTH_PASS", "") BOUNDARY = "frame" HEAD = ("" "Webcam" "") def single_page(name): return (HEAD + f"{name}").encode() def overview_page(): cells = "".join(f"" for n in CAMERAS) return (HEAD + f"
{cells}
").encode() class Hub: """Hält das neueste Frame; Zuschauer warten auf das jeweils nächste.""" def __init__(self): self.cond = threading.Condition() self.frame = None self.seq = 0 self.last = 0.0 def publish(self, jpg): with self.cond: self.frame, self.seq, self.last = jpg, self.seq + 1, time.time() self.cond.notify_all() def wait(self, seq, timeout=10): with self.cond: self.cond.wait_for(lambda: self.seq != seq, timeout) return self.frame, self.seq hubs = {name: Hub() for name in CAMERAS} def pull_camera(name, url, hub): """Liest die Kamera dauerhaft; baut die Verbindung bei Fehlern neu auf.""" while True: try: with requests.get(url, stream=True, timeout=10) as r: r.raise_for_status() buf = b"" for chunk in r.iter_content(chunk_size=8192): buf += chunk while True: s = buf.find(b"\xff\xd8") if s < 0: buf = b"" break e = buf.find(b"\xff\xd9", s + 2) if e < 0: buf = buf[s:] break hub.publish(buf[s:e + 2]) buf = buf[e + 2:] except requests.RequestException as ex: print(f"{name}: Kamera nicht erreichbar: {ex} - neuer Versuch in 3s", flush=True) time.sleep(3) class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, fmt, *args): pass def authorized(self): if not AUTH_USER: return True h = self.headers.get("Authorization", "") want = "Basic " + base64.b64encode(f"{AUTH_USER}:{AUTH_PASS}".encode()).decode() return hmac.compare_digest(h, want) def send_bytes(self, code, ctype, body, extra=None): self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") for k, v in (extra or {}).items(): self.send_header(k, v) self.end_headers() self.wfile.write(body) def do_GET(self): parts = [p for p in self.path.split("?")[0].split("/") if p] if parts == ["health"]: now = time.time() lines = [f"{n}: {'ok' if h.last and now - h.last < 10 else 'keine Frames'}" for n, h in hubs.items()] ok = all(l.endswith(": ok") for l in lines) return self.send_bytes(200 if ok else 503, "text/plain", ("\n".join(lines) + "\n").encode()) if not self.authorized(): return self.send_bytes(401, "text/plain", b"Login erforderlich", {"WWW-Authenticate": 'Basic realm="Webcam"'}) if not parts: return self.send_bytes(200, "text/html; charset=utf-8", overview_page()) if parts == ["stream.mjpg"] or parts == ["snapshot.jpg"]: parts = [next(iter(CAMERAS))] + parts # Alias: erste Kamera name = parts[0] if name not in hubs or len(parts) > 2: return self.send_bytes(404, "text/plain", b"Nicht gefunden") hub = hubs[name] what = parts[1] if len(parts) == 2 else "" if what == "": if not self.path.split("?")[0].endswith("/"): return self.send_bytes(301, "text/plain", b"", {"Location": f"/{name}/"}) return self.send_bytes(200, "text/html; charset=utf-8", single_page(name)) if what == "snapshot.jpg": if hub.frame is None: return self.send_bytes(503, "text/plain", b"Noch kein Bild") return self.send_bytes(200, "image/jpeg", hub.frame) if what == "stream.mjpg": return self.stream(hub) self.send_bytes(404, "text/plain", b"Nicht gefunden") def stream(self, hub): self.send_response(200) self.send_header("Content-Type", f"multipart/x-mixed-replace; boundary={BOUNDARY}") self.send_header("Cache-Control", "no-store") self.send_header("X-Accel-Buffering", "no") # Proxy soll nicht puffern self.send_header("Connection", "close") self.end_headers() self.close_connection = True seq = 0 try: while True: frame, seq_new = hub.wait(seq) if frame is None or seq_new == seq: continue seq = seq_new self.wfile.write( f"--{BOUNDARY}\r\nContent-Type: image/jpeg\r\n" f"Content-Length: {len(frame)}\r\n\r\n".encode() + frame + b"\r\n") self.wfile.flush() except (BrokenPipeError, ConnectionResetError, TimeoutError): pass class Server(ThreadingHTTPServer): daemon_threads = True allow_reuse_address = True if __name__ == "__main__": for n, u in CAMERAS.items(): threading.Thread(target=pull_camera, args=(n, u, hubs[n]), daemon=True).start() print(f"Relay läuft auf Port {PORT}, Kameras: {', '.join(CAMERAS)}", flush=True) Server(("0.0.0.0", PORT), Handler).serve_forever()