2026-09-11 11:05:01 +02:00
|
|
|
"""HTTP-Server: Web-UI, MJPEG-Stream, REST-API, Upload, Thumbnails, Settings."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-09-11 13:11:54 +02:00
|
|
|
import copy
|
2026-09-11 11:05:01 +02:00
|
|
|
import json
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
import urllib.parse
|
|
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
|
|
|
|
|
|
from . import config as cfgmod
|
2026-09-11 13:11:54 +02:00
|
|
|
from . import distribution
|
2026-09-11 12:55:50 +02:00
|
|
|
from .projects import ProjectStore
|
2026-09-11 11:05:01 +02:00
|
|
|
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
|
2026-09-11 12:55:50 +02:00
|
|
|
self.projects = ProjectStore(root)
|
2026-09-11 11:05:01 +02:00
|
|
|
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 <stem>.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())
|
2026-09-11 12:01:22 +02:00
|
|
|
elif path == "/api/fx":
|
|
|
|
|
self._json(engine.fx_catalog())
|
2026-09-11 13:11:54 +02:00
|
|
|
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})
|
2026-09-11 12:55:50 +02:00
|
|
|
elif path == "/api/projects":
|
|
|
|
|
self._json({"items": server.projects.list(),
|
|
|
|
|
"autostart": settings["engine"].get(
|
|
|
|
|
"autostart_project")})
|
2026-09-11 11:05:01 +02:00
|
|
|
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})
|
2026-09-11 13:11:54 +02:00
|
|
|
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))
|
2026-09-11 11:05:01 +02:00
|
|
|
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})
|
2026-09-11 12:55:50 +02:00
|
|
|
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"]})
|
2026-09-11 11:05:01 +02:00
|
|
|
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()
|
2026-09-11 13:11:54 +02:00
|
|
|
# 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)
|
2026-09-11 11:05:01 +02:00
|
|
|
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
|