#!/usr/bin/env python3 """HMS MediaEngine – Vollständig funktionierender Server. Startet: GStreamer-Renderer (echtes Video) + MJPEG-Preview + Web-UI + REST-API. Läuft mit System-Python (3.14) das GStreamer-Bindings hat. Usage: python3 run.py # Standardstart mit Testvideo python3 run.py video1.mp4 video2.mp4 # Eigene Videos python3 run.py --port 8080 # Anderer Web-UI Port python3 run.py --setup # Einstellungs-Dialog """ import argparse import json import os import signal import subprocess import sys import threading import time from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path # ============================================================ # GStreamer Renderer (echtes Video-Playback) # ============================================================ import gi gi.require_version("Gst", "1.0") from gi.repository import Gst Gst.init(None) class VideoRenderer: """Spielt echte Videos ab und liefert JPEG-Frames für den Browser-Preview.""" def __init__(self): self.pipeline = None self.current_jpeg = b"" self.video_paths = [] self.frame_count = 0 self.running = False self.last_error = None def start(self, video_paths: list[str] | None = None): """Startet Video-Playback mit Browser-Preview.""" self.stop() self.video_paths = video_paths or [] self.frame_count = 0 if not self.video_paths: # Testvideo generieren wenn keins gegeben test = self._make_test_video() if test: self.video_paths = [test] if not self.video_paths: # Fallback: Testpattern direkt streamen pipeline_str = ( "videotestsrc is-live=true pattern=0 ! " "video/x-raw,width=640,height=360,framerate=15/1 ! " "videoconvert ! jpegenc ! appsink name=preview emit-signals=true max-buffers=2 drop=true" ) elif len(self.video_paths) == 1: pipeline_str = self._single_video_pipeline(self.video_paths[0]) else: pipeline_str = self._multi_video_pipeline(self.video_paths) try: self.pipeline = Gst.parse_launch(pipeline_str) sink = self.pipeline.get_by_name("preview") if sink: sink.connect("new-sample", self._on_frame) self.pipeline.set_state(Gst.State.PLAYING) self.running = True print(f"[Renderer] Gestartet: {len(self.video_paths)} Video(s)") return True except Exception as e: self.last_error = str(e) print(f"[Renderer] FEHLER: {e}") return False def _single_video_pipeline(self, path: str) -> str: uri = f"file://{os.path.abspath(path)}" return ( f"uridecodebin uri={uri} ! " "queue ! videoconvert ! videoscale ! " "video/x-raw,width=640,height=360 ! " "jpegenc quality=80 ! " "appsink name=preview emit-signals=true max-buffers=2 drop=true" ) def _multi_video_pipeline(self, paths: list[str]) -> str: """Mischt mehrere Videos in ein 2x2 Grid.""" parts = [] n = min(len(paths), 4) w, h = 320, 180 for i in range(n): uri = f"file://{os.path.abspath(paths[i])}" x = (i % 2) * w y = (i // 2) * h parts.append( f"uridecodebin uri={uri} ! queue ! videoconvert ! videoscale ! " f"video/x-raw,width={w},height={h} ! " f"videomixer name=mix sink_{i}::xpos={x} sink_{i}::ypos={y} sink_{i}::zorder={i}" ) parts.append( f"mix. ! videoconvert ! videoscale ! " f"video/x-raw,width=640,height=360 ! " "jpegenc quality=80 ! " "appsink name=preview emit-signals=true max-buffers=2 drop=true" ) return " ".join(parts) def _make_test_video(self) -> str | None: """Erstellt ein Testvideo mit animiertem Ball.""" test_path = "/tmp/hms_test_video.mp4" if os.path.exists(test_path): return test_path try: gen = Gst.parse_launch( "videotestsrc pattern=ball num-buffers=900 ! " "video/x-raw,width=640,height=480,framerate=30/1 ! " "x264enc tune=zerolatency bitrate=2000 ! " "mp4mux ! " f"filesink location={test_path}" ) gen.set_state(Gst.State.PLAYING) bus = gen.get_bus() bus.timed_pop_filtered(Gst.CLOCK_TIME_NONE, Gst.MessageType.EOS) gen.set_state(Gst.State.NULL) print(f"[Renderer] Testvideo erstellt: {test_path}") return test_path except Exception as e: print(f"[Renderer] Testvideo-Fehler: {e}") return None def _on_frame(self, sink): """Empfängt JPEG-Frames.""" sample = sink.emit("pull-sample") if sample: buf = sample.get_buffer() self.current_jpeg = buf.extract_dup(0, buf.get_size()) self.frame_count += 1 return Gst.FlowReturn.OK def stop(self): if self.pipeline: self.pipeline.set_state(Gst.State.NULL) self.pipeline = None self.running = False def get_status(self) -> dict: return { "running": self.running, "videos": self.video_paths, "frames_rendered": self.frame_count, "error": self.last_error, } # ============================================================ # Kombinierter HTTP-Server (Web-UI + MJPEG-Stream + REST-API) # ============================================================ class MediaEngineServer: """HTTP-Server: liefert MJPEG-Stream + REST-API + einfache Web-UI.""" def __init__(self, renderer: VideoRenderer, port=8080, webui_dir=None): self.renderer = renderer self.port = port self.webui_dir = webui_dir self.server = None self.running = False def start(self): handler = self._make_handler() self.server = HTTPServer(("0.0.0.0", self.port), handler) self.running = True thread = threading.Thread(target=self.server.serve_forever, daemon=True) thread.start() print(f"[Server] Web-UI: http://0.0.0.0:{self.port}") print(f"[Server] Preview: http://0.0.0.0:{self.port}/stream.mjpg") print(f"[Server] API: http://0.0.0.0:{self.port}/api/status") def stop(self): self.running = False if self.server: self.server.shutdown() def _make_handler(self): renderer = self.renderer webui_dir = self.webui_dir server_ref = self class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/stream.mjpg": self._stream_mjpeg() elif self.path == "/api/status": self._json_response(renderer.get_status()) elif self.path == "/api/health": self._json_response({"status": "ok", "version": "0.1.0"}) elif self.path == "/": self._serve_ui() else: self.send_response(404) self.end_headers() def _stream_mjpeg(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_ref.running: if renderer.current_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(renderer.current_jpeg)}\r\n\r\n".encode("ascii") ) self.wfile.write(renderer.current_jpeg) self.wfile.write(b"\r\n") time.sleep(1.0 / 15) except (BrokenPipeError, ConnectionResetError): pass def _json_response(self, data): body = json.dumps(data).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _serve_ui(self): # Einfache HTML-UI mit Live-Preview html = b""" HMS MediaEngine

HMS MediaEngine v0.1.0

Renderer: LIVE
Live Preview
""" 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 log_message(self, format, *args): pass return Handler # ============================================================ # Setup-Dialog (tkinter, ohne Browser) # ============================================================ def run_setup(): """Öffnet einen nativen Einstellungs-Dialog.""" try: import tkinter as tk from tkinter import ttk, filedialog, messagebox except ImportError: print("tkinter nicht verfügbar. Installiere: apt install python3-tk") return class SetupDialog: def __init__(self): self.root = tk.Tk() self.root.title("HMS MediaEngine – Einstellungen") self.root.geometry("480x520") self.root.configure(bg="#16181d") self.root.resizable(False, False) # Dark Theme style = ttk.Style() style.theme_use("clam") style.configure("TLabel", background="#16181d", foreground="#c8ccd4", font=("System", 10)) style.configure("TButton", background="#262a33", foreground="#c8ccd4") style.configure("TEntry", fieldbackground="#0d0f12", foreground="#e8eaee") self.build_ui() def build_ui(self): main = ttk.Frame(self.root, padding=20) main.pack(fill="both", expand=True) # Titel ttk.Label(main, text="HMS MediaEngine", font=("System", 18, "bold")).pack(pady=(0, 4)) ttk.Label(main, text="Ersteinrichtung", foreground="#8a8f9a").pack(pady=(0, 20)) # Video-Auswahl video_frame = ttk.LabelFrame(main, text=" Video-Dateien ", padding=10) video_frame.pack(fill="x", pady=(0, 10)) self.video_list = tk.Listbox(video_frame, height=4, bg="#0d0f12", fg="#c8ccd4", selectbackground="#4a90d9") self.video_list.pack(fill="x") btn_frame = ttk.Frame(video_frame) btn_frame.pack(fill="x", pady=5) ttk.Button(btn_frame, text="Video hinzufügen…", command=self.add_video).pack(side="left", padx=(0, 5)) ttk.Button(btn_frame, text="Entfernen", command=self.remove_video).pack(side="left") # Port port_frame = ttk.Frame(main) port_frame.pack(fill="x", pady=(0, 10)) ttk.Label(port_frame, text="Web-UI Port:").pack(side="left") self.port_var = tk.StringVar(value="8080") ttk.Entry(port_frame, textvariable=self.port_var, width=8).pack(side="left", padx=8) # Netzwerk ttk.Label(main, text="Netzwerk-Modus:").pack(anchor="w") self.network_var = tk.StringVar(value="local") net_frame = ttk.Frame(main) net_frame.pack(fill="x", pady=(0, 15)) ttk.Radiobutton(net_frame, text="Nur dieser Rechner", variable=self.network_var, value="local").pack(side="left") ttk.Radiobutton(net_frame, text="Show-LAN (alle Geräte)", variable=self.network_var, value="lan").pack(side="left", padx=10) # Start-Button self.start_btn = ttk.Button(main, text="▶ MediaEngine starten", command=self.start_engine) self.start_btn.pack(fill="x", pady=(10, 5), ipady=6) self.status_label = ttk.Label(main, text="", foreground="#3fa34d") self.status_label.pack() def add_video(self): files = filedialog.askopenfilenames( title="Video-Dateien wählen", filetypes=[("Video", "*.mp4 *.avi *.mkv *.mov *.webm"), ("Alle", "*.*")] ) for f in files: self.video_list.insert("end", f) def remove_video(self): sel = self.video_list.curselection() if sel: self.video_list.delete(sel[0]) def start_engine(self): videos = list(self.video_list.get(0, "end")) port = int(self.port_var.get() or "8080") self.root.destroy() self.result = {"videos": videos, "port": port} dialog = SetupDialog() dialog.root.mainloop() return getattr(dialog, "result", {"videos": [], "port": 8080}) # ============================================================ # Bootstrap: Prüft und installiert Abhängigkeiten # ============================================================ def check_and_install_dependencies() -> bool: """Prüft ob GStreamer und Python verfügbar sind; installiert falls nötig.""" print("[Bootstrap] Prüfe Abhängigkeiten...") # GStreamer prüfen try: import gi gi.require_version("Gst", "1.0") from gi.repository import Gst Gst.init(None) print(f"[Bootstrap] GStreamer {Gst.version_string()} ✓") return True except ImportError: pass # Installieren je nach OS if sys.platform == "linux": print("[Bootstrap] Installiere GStreamer (sudo erforderlich)...") pkgs = [ "gstreamer1.0-tools", "gstreamer1.0-plugins-base", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-plugins-ugly", "gstreamer1.0-libav", "python3-gst-1.0", "gir1.2-gst-1.0", ] cmd = ["sudo", "apt-get", "install", "-y"] + pkgs try: subprocess.run(cmd, check=True, timeout=300) print("[Bootstrap] GStreamer installiert ✓") return True except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): print("[Bootstrap] MANUELLE INSTALLATION NÖTIG:") print(f" sudo apt-get install {' '.join(pkgs)}") return False elif sys.platform == "win32": print("[Bootstrap] Windows: GStreamer muss manuell installiert werden.") print("[Bootstrap] Lade Installer herunter...") url = "https://gstreamer.freedesktop.org/data/pkg/windows/1.28.4/msvc/gstreamer-1.0-msvc-x86_64-1.28.4.msi" try: import urllib.request msi_path = Path.home() / "Downloads" / "gstreamer-1.0-msvc-x86_64-1.28.4.msi" msi_path.parent.mkdir(exist_ok=True) if not msi_path.exists(): print(f"[Bootstrap] Lade {url}...") urllib.request.urlretrieve(url, msi_path) print(f"[Bootstrap] GStreamer-Installer: {msi_path}") subprocess.run(["msiexec", "/i", str(msi_path), "/quiet"], check=False) print("[Bootstrap] GStreamer-MSI gestartet. Bitte Installation abschließen.") return False # Nach Installation neu starten except Exception as e: print(f"[Bootstrap] Download-Fehler: {e}") print(f"[Bootstrap] Bitte manuell installieren: {url}") return False return False # ============================================================ # Windows Setup-Skript Generator # ============================================================ def generate_windows_setup_script(output_dir: str = "."): """Erstellt setup_windows.ps1 für die Windows-Installation.""" script = r'''# HMS MediaEngine – Windows Setup # Führt EINMALIG die komplette Installation durch. # Danach startet hms-mediaengine.exe direkt. $ErrorActionPreference = "Stop" Write-Host "=== HMS MediaEngine Windows Setup ===" -ForegroundColor Cyan # 1. Prüfe Admin-Rechte $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin) { Write-Host "FEHLER: Als Administrator ausführen (Rechtsklick -> Als Administrator ausführen)" -ForegroundColor Red exit 1 } # 2. Python 3.13 installieren (wenn nicht vorhanden) Write-Host "[1/5] Prüfe Python..." -ForegroundColor Yellow $pythonVersion = python --version 2>$null if ($pythonVersion -notmatch "3.13") { Write-Host " Installiere Python 3.13..." $pythonUrl = "https://www.python.org/ftp/python/3.13.2/python-3.13.2-amd64.exe" $pythonExe = "$env:TEMP\python-installer.exe" Invoke-WebRequest -Uri $pythonUrl -OutFile $pythonExe Start-Process -FilePath $pythonExe -ArgumentList "/quiet InstallAllUsers=1 PrependPath=1" -Wait Write-Host " Python 3.13 installiert ✓" } else { Write-Host " Python bereits vorhanden ✓" } # 3. GStreamer installieren Write-Host "[2/5] Prüfe GStreamer..." -ForegroundColor Yellow $gstRoot = [Environment]::GetEnvironmentVariable("GSTREAMER_1_0_ROOT_MSVC_X86_64", "Machine") if (-not $gstRoot) { Write-Host " Installiere GStreamer 1.28.4 MSVC..." $gstUrl = "https://gstreamer.freedesktop.org/data/pkg/windows/1.28.4/msvc/gstreamer-1.0-msvc-x86_64-1.28.4.msi" $gstMsi = "$env:TEMP\gstreamer.msi" Invoke-WebRequest -Uri $gstUrl -OutFile $gstMsi Start-Process -FilePath "msiexec.exe" -ArgumentList "/i `"$gstMsi`" /quiet ADDLOCAL=ALL" -Wait Write-Host " GStreamer installiert ✓" # Umgebungsvariable aktualisieren $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";$env:Path" } else { Write-Host " GStreamer bereits vorhanden ✓" } # 4. uv (Python-Paketmanager) installieren Write-Host "[3/5] Installiere uv..." -ForegroundColor Yellow $uvInstalled = Get-Command uv -ErrorAction SilentlyContinue if (-not $uvInstalled) { irm https://astral.sh/uv/install.ps1 | iex $env:Path = "$env:USERPROFILE\.local\bin;$env:Path" Write-Host " uv installiert ✓" } else { Write-Host " uv bereits vorhanden ✓" } # 5. Projekt-Abhängigkeiten Write-Host "[4/5] Installiere Projekt-Abhängigkeiten..." -ForegroundColor Yellow $repoDir = Split-Path -Parent $MyInvocation.MyCommand.Path Set-Location $repoDir uv sync Write-Host " Abhängigkeiten installiert ✓" # 6. Frontend bauen Write-Host "[5/5] Baue Web-Frontend..." -ForegroundColor Yellow Push-Location apps\web corepack enable pnpm 2>$null pnpm install --frozen-lockfile 2>$null pnpm build Pop-Location Write-Host " Frontend gebaut ✓" Write-Host "" Write-Host "=== Installation abgeschlossen ===" -ForegroundColor Green Write-Host "" Write-Host "Starten mit:" Write-Host " .\run.py" Write-Host "" Write-Host "Oder Einstellungen öffnen:" Write-Host " .\run.py --setup" Write-Host "" ''' path = Path(output_dir) / "setup_windows.ps1" path.write_text(script, encoding="utf-8") print(f"Windows-Setup-Skript: {path}") # ============================================================ # Linux Setup-Skript Generator # ============================================================ def generate_linux_setup_script(output_dir: str = "."): """Erstellt setup_linux.sh für die Linux-Installation.""" script = r'''#!/bin/bash # HMS MediaEngine – Linux Setup set -e echo "=== HMS MediaEngine Linux Setup ===" # 1. System-Pakete echo "[1/4] Installiere System-Pakete..." if command -v apt-get &>/dev/null; then sudo apt-get update -qq sudo apt-get install -y -qq \ gstreamer1.0-tools gstreamer1.0-plugins-base \ gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \ gstreamer1.0-plugins-ugly gstreamer1.0-libav \ python3-gst-1.0 gir1.2-gst-1.0 \ python3-tk xvfb elif command -v dnf &>/dev/null; then sudo dnf install -y gstreamer1 gstreamer1-plugins-* python3-gstreamer python3-tkinter fi echo " System-Pakete installiert ✓" # 2. uv installieren echo "[2/4] Installiere uv..." if ! command -v uv &>/dev/null; then curl -LsSf https://astral.sh/uv/install.sh | sh export PATH="$HOME/.local/bin:$PATH" fi echo " uv ✓" # 3. Projekt-Abhängigkeiten echo "[3/4] Installiere Python-Abhängigkeiten..." REPO_DIR="$(dirname "$(realpath "$0")")" cd "$REPO_DIR" uv sync echo " Abhängigkeiten ✓" # 4. Frontend echo "[4/4] Baue Frontend..." if command -v pnpm &>/dev/null || command -v npx &>/dev/null; then cd apps/web (pnpm install --frozen-lockfile 2>/dev/null || npx pnpm install) && \ (pnpm build 2>/dev/null || npx pnpm build) cd ../.. echo " Frontend ✓" else echo " WARNUNG: pnpm nicht gefunden. Frontend wird nicht gebaut." echo " Installiere mit: npm install -g pnpm" fi echo "" echo "=== Installation abgeschlossen ===" echo "Starten: ./run.py" echo "Einstellungen: ./run.py --setup" echo "" ''' path = Path(output_dir) / "setup_linux.sh" path.write_text(script, encoding="utf-8") path.chmod(0o755) print(f"Linux-Setup-Skript: {path}") # ============================================================ # MAIN # ============================================================ def main(): parser = argparse.ArgumentParser(description="HMS MediaEngine") parser.add_argument("videos", nargs="*", help="Video-Dateien zum Abspielen") parser.add_argument("--port", type=int, default=8080, help="Web-UI Port (default: 8080)") parser.add_argument("--setup", action="store_true", help="Einstellungs-Dialog öffnen") parser.add_argument("--bootstrap", action="store_true", help="Abhängigkeiten prüfen/installieren") parser.add_argument("--generate-setup", action="store_true", help="Setup-Skripte für Windows/Linux generieren") args = parser.parse_args() if args.generate_setup: generate_windows_setup_script() generate_linux_setup_script() print("Setup-Skripte generiert.") return 0 if args.bootstrap: if not check_and_install_dependencies(): print("\nAbhängigkeiten nicht vollständig. Setup-Skript ausführen:") if sys.platform == "win32": print(" .\\setup_windows.ps1") else: print(" ./setup_linux.sh") return 1 if args.setup: config = run_setup() args.videos = config.get("videos", args.videos) args.port = config.get("port", args.port) # Renderer starten renderer = VideoRenderer() video_paths = args.videos if args.videos else None if not renderer.start(video_paths): print("Renderer konnte nicht gestartet werden.") return 1 # HTTP-Server starten server = MediaEngineServer(renderer, port=args.port) server.start() # Signal-Handler running = True def signal_handler(sig, frame): nonlocal running running = False signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) print("\n" + "=" * 60) print(" HMS MediaEngine läuft!") print(f" Web-UI: http://localhost:{args.port}") print(f" Preview: http://localhost:{args.port}/stream.mjpg") print(f" API: http://localhost:{args.port}/api/status") print(f" Videos: {len(renderer.video_paths)} aktiv") print(f" Frames: {renderer.frame_count}") print(" Strg+C zum Beenden") print("=" * 60 + "\n") try: while running: time.sleep(1) if renderer.frame_count % 150 == 0 and renderer.frame_count > 0: pass # Status-Update alle 10 Sekunden bei 15fps except KeyboardInterrupt: pass finally: print("\nBeende...") renderer.stop() server.stop() print("Beendet.") return 0 if __name__ == "__main__": sys.exit(main())