diff --git a/README.md b/README.md index bafd5ca..cc1d68f 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,25 @@ journalctl -u webcam-relay -f | Pfad | Beschreibung | |---|---| -| `/` | Übersicht aller Kameras | -| `//` | Webseite mit Livebild einer Kamera | +| `/` | Web-Frontend: Übersicht aller Kameras | +| `//` | Web-Frontend: große Ansicht einer Kamera, die anderen als Vorschau-Leiste | +| `/status.json` | Online-Status aller Kameras (wird vom Frontend alle 5 s abgefragt) | | `//stream.mjpg` | Livestream (`multipart/x-mixed-replace`) | | `//snapshot.jpg` | Aktuelles Einzelbild | | `/stream.mjpg`, `/snapshot.jpg` | Alias für die erste Kamera (Abwärtskompatibilität) | | `/health` | Status aller Kameras (`200` wenn alle liefern, sonst `503`); ohne Authentifizierung erreichbar | +### Web-Frontend + +Das Frontend liegt in `frontend.html` (reines HTML/CSS/JS, keine Abhängigkeiten) und wird vom Relay ausgeliefert. Änderungen an der Datei wirken sofort, ohne Neustart. + +- Raster mit Live-Streams aller Kameras, Klick auf den Namen öffnet die große Ansicht +- Statuspunkt und Hinweis „Kamera offline“ pro Kamera, automatisches Neuverbinden +- Buttons für Foto (Download) und Vollbild +- Hell/Dunkel passend zum System +- Streams werden getrennt, solange der Tab im Hintergrund ist +- Alle Links sind relativ, daher funktioniert es auch unter einem Proxy-Prefix wie `/webcam/` + ### Funktionsweise - Pro Kamera läuft ein Hintergrund-Thread, der den Stream liest und die JPEG-Frames anhand der Marker `FFD8`/`FFD9` ausschneidet. diff --git a/frontend.html b/frontend.html new file mode 100644 index 0000000..451e85e --- /dev/null +++ b/frontend.html @@ -0,0 +1,166 @@ + + + + + +Webcams + + + +
+

Webcams

+ + +
+ +
+ + + diff --git a/relay.py b/relay.py index 20cb985..c2fa08a 100644 --- a/relay.py +++ b/relay.py @@ -3,8 +3,9 @@ per HTTP für beliebig viele Zuschauer bereit (hinter Zoraxy / Reverse Proxy). Endpunkte: - / Übersicht aller Kameras + / Übersicht aller Kameras (Web-Frontend, frontend.html) // Webseite mit Livebild einer Kamera + /status.json Status aller Kameras (vom Frontend abgefragt) //stream.mjpg Livestream (multipart/x-mixed-replace) //snapshot.jpg aktuelles Einzelbild /stream.mjpg Alias für die erste Kamera (Abwärtskompatibilität) @@ -17,6 +18,7 @@ Konfiguration über die Datei .env (neben relay.py) oder Umgebungsvariablen: """ import base64 import hmac +import json import os import threading import time @@ -48,22 +50,15 @@ AUTH_USER = os.environ.get("AUTH_USER", "") AUTH_PASS = os.environ.get("AUTH_PASS", "") BOUNDARY = "frame" -HEAD = ("" - "Webcam" - "") +FRONTEND = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend.html") -def single_page(name): - return (HEAD + f"{name}").encode() - - -def overview_page(): - cells = "".join(f"" - for n in CAMERAS) - return (HEAD + f"
{cells}
").encode() +def page(root, current): + """Frontend ausliefern; Links sind relativ, damit es auch unter einem Proxy-Prefix läuft.""" + with open(FRONTEND, encoding="utf-8") as f: + html = f.read() + return (html.replace("__ROOT__", json.dumps(root)) + .replace("__CURRENT__", json.dumps(current))).encode() class Hub: @@ -89,6 +84,10 @@ class Hub: hubs = {name: Hub() for name in CAMERAS} +def is_online(hub): + return bool(hub.last) and time.time() - hub.last < 10 + + def pull_camera(name, url, hub): """Liest die Kamera dauerhaft; baut die Verbindung bei Fehlern neu auf.""" while True: @@ -140,16 +139,18 @@ class Handler(BaseHTTPRequestHandler): 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()] + lines = [f"{n}: {'ok' if is_online(h) 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()) + return self.send_bytes(200, "text/html; charset=utf-8", page("", "")) + if parts == ["status.json"]: + body = json.dumps({"cameras": [{"name": n, "online": is_online(h)} + for n, h in hubs.items()]}).encode() + return self.send_bytes(200, "application/json", body) if parts == ["stream.mjpg"] or parts == ["snapshot.jpg"]: parts = [next(iter(CAMERAS))] + parts # Alias: erste Kamera name = parts[0] @@ -159,8 +160,8 @@ class Handler(BaseHTTPRequestHandler): 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)) + return self.send_bytes(301, "text/plain", b"", {"Location": f"{name}/"}) # relativ: Proxy-Prefix bleibt erhalten + return self.send_bytes(200, "text/html; charset=utf-8", page("../", name)) if what == "snapshot.jpg": if hub.frame is None: return self.send_bytes(503, "text/plain", b"Noch kein Bild")