450 lines
19 KiB
Python
450 lines
19 KiB
Python
#!/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 (Web-Frontend, frontend.html)
|
|
/<name>/ Webseite mit Livebild einer Kamera
|
|
/status.json Status aller Kameras (vom Frontend abgefragt)
|
|
/<name>/stream.mjpg Livestream (multipart/x-mixed-replace)
|
|
/<name>/snapshot.jpg aktuelles Einzelbild
|
|
POST /<name>/timelapse Timelapse einer Kamera steuern, Body: {"enabled": true, "interval": 300}
|
|
POST /<name>/setup ruft die in SETUP_<name> hinterlegte Kamera-URL auf
|
|
/<name>/timelapse.json Liste der Timelapse-Bilder je Tag
|
|
/<name>/timelapse/<tag>/<datei> einzelnes Timelapse-Bild
|
|
/<name>/timelapse/<tag>/video?fps=10 Tagesvideo (MP4 mit ffmpeg, sonst MJPEG-AVI)
|
|
/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://...
|
|
SETUP_<name>=<url> optional: Konfigurations-URL der Kamera (Button "Setup" im Frontend),
|
|
z.B. http://IP/control?var=framesize&val=11
|
|
TIMELAPSE_INTERVAL=<sek> Standard-Intervall der Timelapse (default 300); ein/aus und Intervall
|
|
stellt man pro Kamera im Frontend ein
|
|
TIMELAPSE_DIR=<pfad> Zielordner (default: timelapse neben relay.py)
|
|
TIMELAPSE_KEEP_DAYS=<n> Tage aufbewahren (0 = unbegrenzt, default)
|
|
PORT (default 8000)
|
|
AUTH_USER / AUTH_PASS optional: Basic-Auth (nur sinnvoll mit HTTPS in Zoraxy)
|
|
"""
|
|
import base64
|
|
import hmac
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import tempfile
|
|
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")
|
|
SETUPS = {k[6:].lower(): v for k, v in os.environ.items()
|
|
if k.startswith("SETUP_") and v and k[6:].lower() in CAMERAS}
|
|
PORT = int(os.environ.get("PORT", "8000"))
|
|
AUTH_USER = os.environ.get("AUTH_USER", "")
|
|
AUTH_PASS = os.environ.get("AUTH_PASS", "")
|
|
BOUNDARY = "frame"
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
TL_INTERVAL = int(os.environ.get("TIMELAPSE_INTERVAL", "300"))
|
|
TL_DIR = os.path.abspath(os.environ.get("TIMELAPSE_DIR") or os.path.join(BASE_DIR, "timelapse"))
|
|
TL_KEEP_DAYS = int(os.environ.get("TIMELAPSE_KEEP_DAYS", "0"))
|
|
DAY_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
FILE_RE = re.compile(r"^\d{6}\.jpg$")
|
|
|
|
FRONTEND = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend.html")
|
|
|
|
|
|
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:
|
|
"""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 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:
|
|
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)
|
|
|
|
|
|
TL_STATE_FILE = os.path.join(TL_DIR, "state.json")
|
|
TL_INTERVALS = (10, 30, 60, 300, 900, 1800, 3600)
|
|
tl_lock = threading.Lock()
|
|
|
|
|
|
def load_tl_state():
|
|
try:
|
|
with open(TL_STATE_FILE, encoding="utf-8") as f:
|
|
saved = json.load(f)
|
|
except (OSError, ValueError):
|
|
saved = {}
|
|
return {n: {"enabled": bool(saved.get(n, {}).get("enabled")),
|
|
"interval": int(saved.get(n, {}).get("interval", TL_INTERVAL))} for n in CAMERAS}
|
|
|
|
|
|
tl_state = load_tl_state()
|
|
|
|
|
|
def save_tl_state():
|
|
try:
|
|
os.makedirs(TL_DIR, exist_ok=True)
|
|
with open(TL_STATE_FILE + ".tmp", "w", encoding="utf-8") as f:
|
|
json.dump(tl_state, f)
|
|
os.replace(TL_STATE_FILE + ".tmp", TL_STATE_FILE)
|
|
except OSError as ex:
|
|
print(f"Timelapse-Zustand nicht gespeichert: {ex}", flush=True)
|
|
|
|
|
|
def timelapse(name, hub):
|
|
"""Speichert, solange für die Kamera aktiviert, im Intervall das aktuelle Bild
|
|
nach TL_DIR/<name>/<tag>/<HHMMSS>.jpg."""
|
|
last_seq, last_t = 0, 0.0
|
|
while True:
|
|
time.sleep(1)
|
|
st = tl_state[name]
|
|
if not st["enabled"] or time.time() - last_t < st["interval"]:
|
|
continue
|
|
frame, seq = hub.frame, hub.seq
|
|
if frame is None or seq == last_seq or not is_online(hub):
|
|
continue
|
|
last_seq, last_t = seq, time.time()
|
|
now = time.localtime()
|
|
folder = os.path.join(TL_DIR, name, time.strftime("%Y-%m-%d", now))
|
|
try:
|
|
os.makedirs(folder, exist_ok=True)
|
|
tmp = os.path.join(folder, ".tmp")
|
|
with open(tmp, "wb") as f:
|
|
f.write(frame)
|
|
os.replace(tmp, os.path.join(folder, time.strftime("%H%M%S", now) + ".jpg"))
|
|
if TL_KEEP_DAYS:
|
|
prune(os.path.join(TL_DIR, name))
|
|
except OSError as ex:
|
|
print(f"{name}: Timelapse-Bild nicht gespeichert: {ex}", flush=True)
|
|
|
|
|
|
def prune(cam_dir):
|
|
"""Löscht Tagesordner, die älter als TL_KEEP_DAYS sind."""
|
|
limit = time.strftime("%Y-%m-%d", time.localtime(time.time() - TL_KEEP_DAYS * 86400))
|
|
for day in os.listdir(cam_dir):
|
|
if DAY_RE.match(day) and day < limit:
|
|
shutil.rmtree(os.path.join(cam_dir, day), ignore_errors=True)
|
|
|
|
|
|
def jpeg_size(data):
|
|
"""Breite und Höhe aus dem SOF-Marker eines JPEG."""
|
|
i = 2
|
|
while i + 9 < len(data):
|
|
if data[i] != 0xFF:
|
|
i += 1
|
|
continue
|
|
m = data[i + 1]
|
|
if m in (0xC0, 0xC1, 0xC2):
|
|
h, w = struct.unpack(">HH", data[i + 5:i + 9])
|
|
return w, h
|
|
i += 2 + struct.unpack(">H", data[i + 2:i + 4])[0]
|
|
raise ValueError("kein JPEG")
|
|
|
|
|
|
def write_avi(path, files, fps):
|
|
"""Schreibt die JPEGs als MJPEG-AVI (ohne ffmpeg)."""
|
|
with open(files[0], "rb") as f:
|
|
w, h = jpeg_size(f.read())
|
|
sizes = [os.path.getsize(x) for x in files]
|
|
n, big = len(files), max(sizes)
|
|
pad = lambda v: v + (v & 1)
|
|
movi = 4 + sum(8 + pad(v) for v in sizes)
|
|
hdrl = (b"avih" + struct.pack("<I14I", 56, 1000000 // fps, big * fps, 0, 0x10, n, 0, 1, big, w, h, 0, 0, 0, 0)
|
|
+ b"LIST" + struct.pack("<I", 4 + 64 + 48) + b"strl"
|
|
+ b"strh" + struct.pack("<I4s4sIHHIIIIIIIIhhhh", 56, b"vids", b"MJPG", 0, 0, 0, 0, 1, fps, 0, n, big, 0xFFFFFFFF, 0, 0, 0, w, h)
|
|
+ b"strf" + struct.pack("<IIiiHH4sIIIII", 40, 40, w, h, 1, 24, b"MJPG", w * h * 3, 0, 0, 0, 0))
|
|
riff = 4 + 8 + 4 + len(hdrl) + 8 + movi + 8 + 16 * n
|
|
with open(path, "wb") as out:
|
|
out.write(b"RIFF" + struct.pack("<I", riff) + b"AVI ")
|
|
out.write(b"LIST" + struct.pack("<I", 4 + len(hdrl)) + b"hdrl" + hdrl)
|
|
out.write(b"LIST" + struct.pack("<I", movi) + b"movi")
|
|
idx, off = [], 4
|
|
for x, size in zip(files, sizes):
|
|
with open(x, "rb") as f:
|
|
data = f.read()
|
|
out.write(b"00dc" + struct.pack("<I", len(data)) + data + (b"\0" if len(data) & 1 else b""))
|
|
idx.append(b"00dc" + struct.pack("<III", 0x10, off, len(data)))
|
|
off += 8 + pad(len(data))
|
|
out.write(b"idx1" + struct.pack("<I", 16 * n) + b"".join(idx))
|
|
|
|
|
|
video_lock = threading.Lock()
|
|
|
|
|
|
def make_video(name, day, fps):
|
|
"""Erzeugt das Tagesvideo; liefert (Pfad, Dateiendung) oder None, wenn es keine Bilder gibt."""
|
|
folder = os.path.join(TL_DIR, name, day)
|
|
try:
|
|
files = sorted(os.path.join(folder, f) for f in os.listdir(folder) if FILE_RE.match(f))
|
|
except FileNotFoundError:
|
|
files = []
|
|
if not files:
|
|
return None
|
|
with video_lock:
|
|
if shutil.which("ffmpeg"):
|
|
fd, path = tempfile.mkstemp(suffix=".mp4")
|
|
os.close(fd)
|
|
r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(fps),
|
|
"-pattern_type", "glob", "-i", os.path.join(folder, "*.jpg"),
|
|
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-c:v", "libx264",
|
|
"-pix_fmt", "yuv420p", "-movflags", "+faststart", path],
|
|
capture_output=True, timeout=600)
|
|
if r.returncode == 0:
|
|
return path, "mp4"
|
|
print(f"{name}: ffmpeg fehlgeschlagen: {r.stderr.decode(errors='replace')[:300]}", flush=True)
|
|
os.unlink(path)
|
|
fd, path = tempfile.mkstemp(suffix=".avi")
|
|
os.close(fd)
|
|
write_avi(path, files, fps)
|
|
return path, "avi"
|
|
|
|
|
|
def timelapse_index(name):
|
|
"""{"interval": sek, "days": {tag: [dateien]}} aus dem Dateisystem."""
|
|
days = {}
|
|
cam_dir = os.path.join(TL_DIR, name)
|
|
try:
|
|
for day in sorted(os.listdir(cam_dir), reverse=True):
|
|
if DAY_RE.match(day):
|
|
days[day] = sorted(f for f in os.listdir(os.path.join(cam_dir, day)) if FILE_RE.match(f))
|
|
except FileNotFoundError:
|
|
pass
|
|
return {**tl_state[name], "days": days}
|
|
|
|
|
|
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)))
|
|
extra = extra or {}
|
|
if "Cache-Control" not in extra:
|
|
self.send_header("Cache-Control", "no-store")
|
|
for k, v in extra.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"]:
|
|
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", page("", ""))
|
|
if parts == ["status.json"]:
|
|
body = json.dumps({"cameras": [{"name": n, "online": is_online(h), "setup": n in SETUPS,
|
|
"timelapse": tl_state[n]}
|
|
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]
|
|
if name in hubs and len(parts) == 2 and parts[1] == "timelapse.json":
|
|
return self.send_bytes(200, "application/json", json.dumps(timelapse_index(name)).encode())
|
|
if name in hubs and len(parts) == 4 and parts[1:2] == ["timelapse"] and parts[3] == "video":
|
|
if not DAY_RE.match(parts[2]):
|
|
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
|
q = dict(kv.split("=", 1) for kv in self.path.partition("?")[2].split("&") if "=" in kv)
|
|
fps = int(q["fps"]) if q.get("fps", "").isdigit() and 1 <= int(q["fps"]) <= 60 else 10
|
|
try:
|
|
res = make_video(name, parts[2], fps)
|
|
except (OSError, ValueError, subprocess.SubprocessError) as ex:
|
|
print(f"{name}: Video fehlgeschlagen: {ex}", flush=True)
|
|
return self.send_bytes(500, "text/plain", b"Video konnte nicht erstellt werden")
|
|
if res is None:
|
|
return self.send_bytes(404, "text/plain", b"Keine Bilder")
|
|
path, ext = res
|
|
try:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "video/mp4" if ext == "mp4" else "video/x-msvideo")
|
|
self.send_header("Content-Length", str(os.path.getsize(path)))
|
|
self.send_header("Content-Disposition", f'attachment; filename="{name}-{parts[2]}.{ext}"')
|
|
self.end_headers()
|
|
with open(path, "rb") as f:
|
|
shutil.copyfileobj(f, self.wfile)
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
pass
|
|
finally:
|
|
os.unlink(path)
|
|
return
|
|
if name in hubs and len(parts) == 4 and parts[1] == "timelapse":
|
|
if not (DAY_RE.match(parts[2]) and FILE_RE.match(parts[3])):
|
|
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
|
try:
|
|
with open(os.path.join(TL_DIR, name, parts[2], parts[3]), "rb") as f:
|
|
body = f.read()
|
|
except FileNotFoundError:
|
|
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
|
return self.send_bytes(200, "image/jpeg", body, {"Cache-Control": "max-age=86400"})
|
|
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}/"}) # 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")
|
|
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 do_POST(self):
|
|
if not self.authorized():
|
|
return self.send_bytes(401, "text/plain", b"Login erforderlich",
|
|
{"WWW-Authenticate": 'Basic realm="Webcam"'})
|
|
parts = [p for p in self.path.split("?")[0].split("/") if p]
|
|
if len(parts) == 2 and parts[1] == "timelapse" and parts[0] in hubs:
|
|
try:
|
|
n = int(self.headers.get("Content-Length", 0))
|
|
req = json.loads(self.rfile.read(n) or b"{}")
|
|
with tl_lock:
|
|
st = tl_state[parts[0]]
|
|
if "enabled" in req:
|
|
st["enabled"] = bool(req["enabled"])
|
|
if int(req.get("interval", st["interval"])) in TL_INTERVALS:
|
|
st["interval"] = int(req.get("interval", st["interval"]))
|
|
save_tl_state()
|
|
except (ValueError, TypeError):
|
|
return self.send_bytes(400, "text/plain", b"Ungueltige Anfrage")
|
|
return self.send_bytes(200, "application/json", json.dumps(tl_state[parts[0]]).encode())
|
|
if len(parts) != 2 or parts[1] != "setup" or parts[0] not in SETUPS:
|
|
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
|
try:
|
|
r = requests.get(SETUPS[parts[0]], timeout=5)
|
|
r.raise_for_status()
|
|
except requests.RequestException as ex:
|
|
print(f"{parts[0]}: Setup fehlgeschlagen: {ex}", flush=True)
|
|
return self.send_bytes(502, "text/plain", b"Kamera nicht erreichbar")
|
|
self.send_bytes(200, "text/plain", b"ok")
|
|
|
|
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()
|
|
threading.Thread(target=timelapse, args=(n, hubs[n]), daemon=True).start()
|
|
print(f"Relay läuft auf Port {PORT}, Kameras: {', '.join(CAMERAS)}", flush=True)
|
|
print(f"Timelapse-Ordner: {TL_DIR}", flush=True)
|
|
Server(("0.0.0.0", PORT), Handler).serve_forever()
|