Erster Commit: MJPEG-Relay, Stream-Tool und README

This commit is contained in:
Joachim Hummel committed 2026-10-17 20:22:40 +02:00
commit 2de90feafc
5 files changed
+388

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
#!/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
/<name>/ Webseite mit Livebild einer Kamera
/<name>/stream.mjpg Livestream (multipart/x-mixed-replace)
/<name>/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_<name>=<url> 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_<name>=<url> 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 = ("<!doctype html><html lang=de><head><meta charset=utf-8>"
"<meta name=viewport content='width=device-width,initial-scale=1'><title>Webcam</title>"
"<style>html,body{margin:0;background:#111;color:#eee;font-family:sans-serif}"
"img{display:block;width:100%}a{color:#8cf}"
".grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:8px;padding:8px}"
"h3{margin:4px 0}</style></head><body>")
def single_page(name):
return (HEAD + f"<img src=stream.mjpg alt='{name}'></body></html>").encode()
def overview_page():
cells = "".join(f"<div><h3><a href='{n}/'>{n}</a></h3><img src='{n}/stream.mjpg' alt='{n}'></div>"
for n in CAMERAS)
return (HEAD + f"<div class=grid>{cells}</div></body></html>").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()