"""HTTP-Server: Web-UI, MJPEG-Stream, REST-API, Upload, Thumbnails, Settings.""" from __future__ import annotations import copy import json import threading import time import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from . import config as cfgmod from . import distribution from .projects import ProjectStore 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.projects = ProjectStore(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()) elif path == "/api/fx": self._json(engine.fx_catalog()) elif path == "/api/generators": self._json({"items": engine.generators()}) elif path == "/api/servers": out = [] for s_ in settings.get("servers", []): entry = {"name": s_.get("name"), "url": s_.get("url"), "online": False, "items": []} try: data = distribution.list_media( str(s_.get("url", ""))) entry["online"] = True entry["items"] = data.get("items", []) except Exception: # noqa: BLE001 pass out.append(entry) self._json({"servers": out}) elif path == "/api/projects": self._json({"items": server.projects.list(), "autostart": settings["engine"].get( "autostart_project")}) 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/add-generator": body = self._read_json() layer = engine.add_generator( str(body.get("generator", ""))) if layer is None: self._send_error_json( 400, engine.last_error or "unbekannter Generator") else: self._json({"layer": layer}) elif path == "/api/servers/add": body = self._read_json() name = str(body.get("name", "")).strip() url = str(body.get("url", "")).strip() if (not name or not url.startswith( ("http://", "https://"))): self._send_error_json( 400, "Name und http(s)-URL erforderlich") elif any(s_.get("name") == name for s_ in settings.get("servers", [])): self._send_error_json(400, "Name bereits vergeben") elif not distribution.check(url): self._json({"added": False, "error": "Server nicht erreichbar " "oder kein HMS MediaEngine"}) else: settings.setdefault("servers", []).append( {"name": name, "url": url}) cfgmod.save(root, settings) self._json({"added": True, "name": name}) elif path == "/api/servers/remove": body = self._read_json() name = str(body.get("name", "")) before = len(settings.get("servers", [])) settings["servers"] = [ s_ for s_ in settings.get("servers", []) if s_.get("name") != name] if len(settings["servers"]) == before: self._send_error_json(404, "Server nicht gefunden") else: cfgmod.save(root, settings) self._json({"removed": True}) elif path == "/api/servers/import": body = self._read_json() srv_name = str(body.get("server", "")) media_name = str(body.get("media", "")) url = None for s_ in settings.get("servers", []): if s_.get("name") == srv_name: url = str(s_.get("url", "")) break if url is None: self._send_error_json(404, "Server nicht gefunden") elif not media_name: self._send_error_json(400, "Medienname fehlt") else: ok, result = distribution.import_media( url, media_name, library) if ok: self._json({"saved": result}) else: self._send_error_json(400, str(result)) 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/projects/save": body = self._read_json() name = str(body.get("name", "")).strip() if not name: self._send_error_json(400, "Name fehlt") else: info = server.projects.save( name, engine.export_state()) if info is None: self._send_error_json(500, "Speichern fehlgeschlagen") else: self._json(info) elif path == "/api/projects/load": body = self._read_json() name = str(body.get("name", "")) doc = server.projects.load(name) if doc is None: self._send_error_json(404, "Projekt nicht gefunden") else: ok = engine.load_state(doc.get("state") or {}) self._json({"loaded": bool(ok), "name": doc.get("name"), "error": None if ok else engine.last_error}) elif path == "/api/projects/delete": body = self._read_json() ok = server.projects.delete(str(body.get("name", ""))) if (settings["engine"].get("autostart_project") == str(body.get("name", ""))): settings["engine"]["autostart_project"] = None cfgmod.save(root, settings) self._json({"deleted": bool(ok)}) elif path == "/api/projects/autostart": body = self._read_json() name = body.get("name") if name and not server.projects.exists(str(name)): self._send_error_json(404, "Projekt nicht gefunden") else: settings["engine"]["autostart_project"] = ( str(name) if name else None) cfgmod.save(root, settings) self._json({"autostart": settings["engine"][ "autostart_project"]}) 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() # WICHTIG: auf aktuelle Settings mergen, damit nicht # gesendete Felder (autostart_project, servers) erhalten # bleiben und nicht auf Defaults zurueckfallen. merged = cfgmod.deep_merge(copy.deepcopy(settings), body) normalized, err = cfgmod.validate(merged) 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