#!/usr/bin/env python3 """E2E: Generatoren, Remote-Server-Medienverteilung, Autostart-Merge. Startet ZWEI echte Server: A (lokal) und B (Quelle). Server A registriert B und zieht Medien von B ins lokale Archiv. """ import json import os import shutil import subprocess import sys import time import urllib.error import urllib.parse import urllib.request ROOT = "/a0/usr/workdir/hms-mediaengine" DATA_B = "/tmp/hms_server_b_data" PORT_A = 8095 PORT_B = 8096 BASE_A = "http://localhost:%d" % PORT_A BASE_B = "http://localhost:%d" % PORT_B procs = {} def start_server(tag: str, base: str, port: int, data_root: str | None = None) -> bool: cmd = ["/usr/bin/python3", ROOT + "/run.py", "--port", str(port)] if data_root: cmd += ["--data-root", data_root] procs[tag] = subprocess.Popen( cmd, cwd=ROOT, stdout=open("/tmp/hms_e2e_%s.log" % tag, "ab"), stderr=subprocess.STDOUT) deadline = time.time() + 25 while time.time() < deadline: try: with urllib.request.urlopen(base + "/api/health", timeout=2) as r: if (json.loads(r.read().decode()) .get("status") == "ok"): return True except Exception: # noqa: BLE001 time.sleep(0.3) return False def stop_all(): for p in procs.values(): p.terminate() for p in procs.values(): try: p.wait(timeout=6) except subprocess.TimeoutExpired: p.kill() procs.clear() time.sleep(0.5) def get(base, path): with urllib.request.urlopen(base + path, timeout=15) as r: return json.loads(r.read().decode()) def post(base, path, body=None, raw=None, headers=None): data = raw if raw is not None else json.dumps(body or {}).encode() req = urllib.request.Request(base + path, data=data, method="POST") req.add_header("Content-Type", "application/json" if raw is None else "application/octet-stream") for k, v in (headers or {}).items(): req.add_header(k, v) try: with urllib.request.urlopen(req, timeout=180) as r: return json.loads(r.read().decode()) except urllib.error.HTTPError as e: return json.loads(e.read().decode()) def check(label, ok, detail=""): print(("PASS" if ok else "FAIL") + " " + label + " " + str(detail)) if not ok: sys.exit(1) # ---------- Reset ---------- for d in ("media", "thumbs", "projects"): shutil.rmtree(os.path.join(ROOT, d), ignore_errors=True) try: os.remove(os.path.join(ROOT, "config.json")) except FileNotFoundError: pass try: # ---------- Server B (Quelle) ---------- shutil.rmtree(DATA_B, ignore_errors=True) check("Server B startet (eigener Datenordner)", start_server("B", BASE_B, PORT_B, data_root=DATA_B)) # Testvideo auf B erzeugen + hochladen subprocess.run([ "/usr/bin/python3", "-c", "import gi; gi.require_version('Gst','1.0')\n" "from gi.repository import Gst; Gst.init(None)\n" "p = Gst.parse_launch('videotestsrc num-buffers=60 pattern=18 ! " "video/x-raw,width=320,height=180 ! x264enc tune=zerolatency ! " "mp4mux ! filesink location=/tmp/e2e_dist.mp4')\n" "p.set_state(Gst.State.PLAYING)\n" "p.get_bus().timed_pop_filtered(Gst.CLOCK_TIME_NONE, " "Gst.MessageType.EOS)\n" "p.set_state(Gst.State.NULL)\n", ], check=True) data = open("/tmp/e2e_dist.mp4", "rb").read() r = post(BASE_B, "/api/media/upload", raw=data, headers={"X-Filename": urllib.parse.quote("clip_vom_server_B.mp4")}) check("B: Video hochgeladen", bool(r.get("saved")), r) # ---------- Server A (Hauptserver) ---------- check("Server A startet", start_server("A", BASE_A, PORT_A)) # 1) Generatoren: Katalog + Layer + FX gens = get(BASE_A, "/api/generators")["items"] check("Generator-Katalog 15", len(gens) == 15, "%d" % len(gens)) gen_ids = [g["id"] for g in gens] check("Ball + Snow vorhanden", "ball" in gen_ids and "snow" in gen_ids) r = post(BASE_A, "/api/layers/add-generator", {"generator": "ball"}) check("Generator-Layer ball", r.get("layer", {}).get("kind") == "generator", r) gid = r.get("layer", {}).get("id") r = post(BASE_A, "/api/layers/update", {"id": gid, "fx1": "blur", "fx1_intensity": 0.6}) check("Generator + FX kombiniert", r.get("layer", {}).get("fx1") == "blur", r) time.sleep(2) st = get(BASE_A, "/api/status") check("Engine rendert Generator+FX", st["frames_rendered"] > 0 and st["error"] is None, "frames=%d err=%s" % (st["frames_rendered"], st["error"])) # Generator wechseln (Pattern-Tausch ohne neuen Layer) r = post(BASE_A, "/api/layers/update", {"id": gid, "name": "snow"}) check("Generator-Wechsel ball->snow", r.get("layer", {}).get("name") == "snow", r) time.sleep(1.5) st = get(BASE_A, "/api/status") check("Engine nach Generator-Wechsel", st["running"] is True, "err=%s" % st["error"]) # Ungueltiger Generator abgewiesen r = post(BASE_A, "/api/layers/add-generator", {"generator": "keins"}) check("Ungueltiger Generator 400", "error" in r, r) # 2) Remote-Server-Verteilung: A registriert B r = post(BASE_A, "/api/servers/add", {"name": "Node-B", "url": BASE_B}) check("Server B registriert", r.get("added") is True, r) # Duplikat-Name abgewiesen r = post(BASE_A, "/api/servers/add", {"name": "Node-B", "url": BASE_B}) check("Duplikat-Name abgewiesen", "error" in r, r) # Ungueltige URL abgewiesen r = post(BASE_A, "/api/servers/add", {"name": "X", "url": "ftp://kein-http"}) check("Nicht-HTTP abgewiesen", "error" in r, r) # Server-Liste: B ist online und liefert Medien srv = get(BASE_A, "/api/servers")["servers"] check("Server-Liste 1 Eintrag", len(srv) == 1, srv) check("Node-B online", srv[0]["online"] is True, srv[0]["name"]) check("B-Medien sichtbar", any(m["name"] == "clip_vom_server_B.mp4" for m in srv[0]["items"]), [m["name"] for m in srv[0]["items"]]) # Medium von B nach A ziehen r = post(BASE_A, "/api/servers/import", {"server": "Node-B", "media": "clip_vom_server_B.mp4"}) check("Import von B nach A (Originalname, getrennte Ordner)", r.get("saved") == "clip_vom_server_B.mp4", r) # Datei-Gleichheit pruefen (SHA von Quelle und Kopie) import hashlib def sha(path): return hashlib.sha256(open(path, "rb").read()).hexdigest()[:16] src = os.path.join(DATA_B, "media", "clip_vom_server_B.mp4") dst = os.path.join(ROOT, "media", "clip_vom_server_B.mp4") check("Importierte Datei byte-identisch", sha(src) == sha(dst), "sha=%s" % sha(dst)) lib_a = get(BASE_A, "/api/media")["items"] check("Datei liegt in A-Archiv", any(m["name"] == "clip_vom_server_B.mp4" for m in lib_a), [m["name"] for m in lib_a]) # Import in A als Layer nutzbar r = post(BASE_A, "/api/layers/add", {"name": "clip_vom_server_B.mp4"}) check("Importiertes als Layer", "layer" in r, r) time.sleep(1.5) st = get(BASE_A, "/api/status") check("Engine rendert mit Import", st["frames_rendered"] > 0 and st["error"] is None, "frames=%d" % st["frames_rendered"]) # Import von unregistriertem Namen -> 404 r = post(BASE_A, "/api/servers/import", {"server": "gibts_nicht", "media": "x.mp4"}) check("Unbekannter Server 404", "error" in r, r) # Offline-Server: B stoppen, Liste muss offline zeigen procs["B"].terminate() procs["B"].wait(timeout=6) time.sleep(0.5) srv = get(BASE_A, "/api/servers")["servers"] check("B nach Stop offline", srv[0]["online"] is False) r = post(BASE_A, "/api/servers/remove", {"name": "Node-B"}) check("Server entfernt", r.get("removed") is True, r) cfg = json.load(open(os.path.join(ROOT, "config.json"))) check("config.json Server entfernt", cfg.get("servers") == []) # 3) Autostart + Settings-Merge: Autostart darf beim # Speichern der Einstellungen NICHT verloren gehen post(BASE_A, "/api/projects/save", {"name": "Autostart Test"}) r = post(BASE_A, "/api/projects/autostart", {"name": "Autostart Test"}) check("Autostart gesetzt", r.get("autostart") == "Autostart Test", r) # Einstellungen OHNE autostart_project speichern (UI-Merge-Fall) r = post(BASE_A, "/api/settings", {"preview": {"fps": 20}}) check("Settings gespeichert (Merge)", bool(r.get("saved")), r) cfg = json.load(open(os.path.join(ROOT, "config.json"))) check("Autostart UEBERLEBT Settings-Save", cfg["engine"]["autostart_project"] == "Autostart Test", cfg["engine"]["autostart_project"]) check("FPS uebernommen", cfg["preview"]["fps"] == 20) st = get(BASE_A, "/api/status") check("Server laeuft weiter", st["running"] is True) # Autostart via Settings-Endpunkt abwaehlen (NULL akzeptiert) r = post(BASE_A, "/api/settings", {"engine": {"autostart_project": None}}) check("Autostart via Settings abgewaehlt", bool(r.get("saved")) and json.load(open(os.path.join( ROOT, "config.json")))["engine"]["autostart_project"] is None) # Generatoren in Projekten: speichern + laden post(BASE_A, "/api/layers/add-generator", {"generator": "gradient"}) r = post(BASE_A, "/api/projects/save", {"name": "Mit Generator"}) check("Projekt mit Generator gespeichert", r.get("saved") == "Mit Generator", r) r = post(BASE_A, "/api/projects/load", {"name": "Mit Generator"}) check("Projekt mit Generator geladen", r.get("loaded") is True, r) st = get(BASE_A, "/api/status") check("Generator nach Projekt-Load aktiv", any(l["kind"] == "generator" and l["name"] == "gradient" for l in st["layers"]), [(l["kind"], l["name"]) for l in st["layers"]]) print("") print("ALLE VERTEILUNGS/GENERATOR-E2E-TESTS BESTANDEN") finally: stop_all()