"""HTTP-Server: Web-UI, MJPEG-Stream, REST-API, Upload, Thumbnails, Settings.""" from __future__ import annotations import json import threading import time import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from . import config as cfgmod from .ui import HTML_UI class EngineServer: """Verbindet Engine, Art-Net, Medienbibliothek und Konfiguration mit HTTP.""" def __init__(self, engine, artnet, library, settings: dict, root): self.engine = engine self.artnet = artnet self.library = library self.settings = settings self.root = root self.httpd: ThreadingHTTPServer | None = None self.running = False def start(self, port=None) -> bool: port = port or int(self.settings["web"]["port"]) bind = str(self.settings["web"].get("bind", "0.0.0.0")) try: self.httpd = ThreadingHTTPServer((bind, port), self._make_handler()) except OSError as e: print(f"[Server] FEHLER: {bind}:{port} nicht bindbar: {e}") return False self.running = True threading.Thread(target=self.httpd.serve_forever, daemon=True).start() print(f"[Server] UI: http://localhost:{port}/") print(f"[Server] Preview: http://localhost:{port}/stream.mjpg") print(f"[Server] API: http://localhost:{port}/api/status") return True def stop(self): self.running = False if self.httpd is not None: self.httpd.shutdown() self.httpd = None def restart_artnet(self): self.artnet.restart() # ---------------- Handler ---------------- def _make_handler(self): engine = self.engine artnet = self.artnet library = self.library server = self settings = self.settings root = self.root class Handler(BaseHTTPRequestHandler): # ---------- GET ---------- def do_GET(self): parsed = urllib.parse.urlparse(self.path) path = parsed.path query = urllib.parse.parse_qs(parsed.query) if path == "/": self._send_html() elif path == "/stream.mjpg": self._stream() elif path == "/api/status": self._json(server._status()) elif path == "/api/health": self._json({"status": "ok"}) elif path == "/api/media": self._json({"items": library.scan(), "limits": settings["media"]}) elif path.startswith("/thumb/"): # Thumbnail heisst .jpg; Anfrage kommt mit # Originaldateinamen (z. B. /thumb/clip.mp4) name = urllib.parse.unquote(path[len("/thumb/"):]) self._send_file(library.thumb_path(name), "image/jpeg") elif path.startswith("/media/"): name = urllib.parse.unquote(path[len("/media/"):]) f = library.safe_file(name) ctype = ("image/png" if name.lower().endswith(".png") else "image/jpeg" if name.lower().endswith( (".jpg", ".jpeg")) else "image/bmp" if name.lower().endswith(".bmp") else "video/mp4") self._send_file(f, ctype, download_name=name) elif path == "/api/settings": self._json(settings) elif path == "/api/artnet": self._json(artnet.monitor()) else: self._send_error_json(404, "nicht gefunden") # ---------- POST ---------- def do_POST(self): parsed = urllib.parse.urlparse(self.path) path = parsed.path if path == "/api/media/upload": self._upload() elif path == "/api/media/delete": body = self._read_json() name = str(body.get("name", "")) ok = library.delete(name) # Layer mit dieser Datei entfernen if ok: for l in list(engine.layers): if l.name == name: engine.remove_layer(l.id) self._json({"deleted": ok}) elif path == "/api/layers/add": body = self._read_json() layer = engine.add_layer(str(body.get("name", ""))) if layer is None: self._send_error_json(400, engine.last_error or "?" ) else: self._json({"layer": layer}) elif path == "/api/layers/remove": body = self._read_json() ok = engine.remove_layer(int(body.get("id", -1))) self._json({"removed": ok}) elif path == "/api/layers/update": body = self._read_json() layer = engine.update_layer(int(body.get("id", -1)), body) if layer is None: self._send_error_json(404, "Layer nicht gefunden") else: self._json({"layer": engine._layer_dict(layer)}) elif path == "/api/master": body = self._read_json() engine.set_master(float(body.get("intensity", body.get("value", 1.0)))) self._json(server._status()) elif path == "/api/blackout": body = self._read_json() engine.set_blackout(bool(body.get("on", False))) self._json(server._status()) elif path == "/api/playback": body = self._read_json() engine.set_playback(bool(body.get("playing", True))) self._json(server._status()) elif path == "/api/retrigger": engine.retrigger() self._json({"ok": True}) elif path == "/api/settings": self._post_settings() else: self._send_error_json(404, "nicht gefunden") # ---------- Upload ---------- def _upload(self): length = int(self.headers.get("Content-Length", "0")) raw_name = (self.headers.get("X-Filename") or "upload" + ".bin") name = urllib.parse.unquote(raw_name) final, err = library.save_stream(name, self.rfile, length) if err is not None: self._send_error_json(400, err) return self._json({"saved": final}) # ---------- Settings ---------- def _post_settings(self): body = self._read_json() normalized, err = cfgmod.validate(body) if err is not None: self._send_error_json(400, err) return old_port = settings["web"]["port"] old_fs = settings["output"]["fullscreen"] # Struktur-Aenderungen anwenden settings.clear() settings.update(normalized) engine.library.settings = settings artnet.settings = settings cfgmod.save(root, settings) # Engine mit neuen Preview-Parametern neu bauen engine.rebuild() # Art-Net neu starten (Port/Universes evtl. geaendert) artnet.restart() restarted_web = settings["web"]["port"] != old_port fullscreen_changed = settings["output"]["fullscreen"] != old_fs self._json({"saved": True, "web_restarts": restarted_web, "fullscreen_changed": fullscreen_changed, "hint": ("Web-Port geaendert: Seite unter neuem " "Port neu oeffnen") if restarted_web else None}) # ---------- Ausgabe-Helfer ---------- def _read_json(self) -> dict: try: length = int(self.headers.get("Content-Length", "0")) return json.loads(self.rfile.read(length) or b"{}") except (ValueError, OSError): return {} def _send_error_json(self, code, message): self._json({"error": str(message)}, code=code) def _json(self, data, code=200): body = json.dumps(data).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _send_html(self): html = HTML_UI.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(html))) self.end_headers() self.wfile.write(html) def _send_file(self, f, ctype, download_name=None): if f is None or not f.is_file(): self._send_error_json(404, "Datei nicht gefunden") return try: data = f.read_bytes() except OSError: self._send_error_json(500, "Lesefehler") return self.send_response(200) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(data))) if download_name: self.send_header( "Content-Disposition", "inline; filename*=UTF-8''" + urllib.parse.quote(download_name)) self.end_headers() self.wfile.write(data) def _stream(self): self.send_response(200) self.send_header( "Content-Type", "multipart/x-mixed-replace; boundary=frame") self.send_header("Cache-Control", "no-cache") self.end_headers() try: while server.running: jpeg = engine.current_jpeg if jpeg: self.wfile.write(b"--frame\r\n") self.wfile.write( b"Content-Type: image/jpeg\r\n") self.wfile.write( (f"Content-Length: {len(jpeg)}\r\n\r\n") .encode("ascii")) self.wfile.write(jpeg) self.wfile.write(b"\r\n") time.sleep(1.0 / max(1, int( settings["preview"]["fps"]))) except (BrokenPipeError, ConnectionResetError): pass def log_message(self, fmt, *args): pass return Handler def _status(self) -> dict: st = self.engine.status() st["media_count"] = self.library.count() st["artnet"] = self.artnet.monitor() return st