Files
hms-mediaengine/hms_app/media.py
T
HMS MediaEngine Agent 68ce87e5ac ECHTES PROGRAMM: Medienverwaltung + Upload + konfigurierbares Art-Net (14/14 E2E bestanden)
Vollständige Anwendung in hms_app/ (6 Module) + schlanker run.py:
- config.py: config.json-Persistenz, Defaults, strikte Validierung
  (Bereiche, Universes, Kanal-Mapping; ungültige Settings => 400)
- media.py: Upload-Streaming (chunkweise, Größenlimit, .part-Sicherung),
  Duplikat-Umbenennung, Thumbnail-Erzeugung per GStreamer,
  Metadaten-Cache (Dauer via query_duration), Löschen, Pfad-Schutz
- artnet.py: ArtDMX voll KONFIGURIERBAR (Port, Universes, Kanäle),
  Sequenz-/Duplikat-Filter, Signalverlust-Policy hold/fade_black,
  Watchdog, Live-Restart nach Settings-Änderung
- engine.py: dynamische Layer (Video + Bild via imagefreeze),
  atomarer Rebuild mit Rollback (alte Pipeline läuft bei Fehler weiter),
  Positions/Größen/Z-Order/Alpha je Layer, EOS-Loop, DMX-Mapping
- server.py: REST komplett (upload/delete, layers add/remove/update,
  master/blackout/playback, settings GET/POST mit Validierung),
  Thumbnails + Datei-Download, Traversal-Schutz
- ui.py: 4-Tabs-WebUI: Live (Preview+Slider+Diagnose), Medien
  (Drag&Drop-Upload mit Fortschritt, Thumbnails, Als-Layer/Delete),
  Layer-Editor (Alpha/Pos/Größe/Z), Einstellungen (Art-Net komplett
  umstellbar inkl. Port/Universes/Kanäle, Preview, Engine, Upload-Limit)
- setup.py: Bootstrap + setup_windows.ps1/setup_linux.sh Generierung
- run.py: Einstieg (--port, --setup, --bootstrap, --generate-setup,
  Datei-Import via CLI)

E2E-BEWEIS (14/14 PASS, im Container ausgeführt):
1 Health OK · 2 Video-Upload via HTTP · 3 Bild-Upload · 4 Bibliothek
mit Metadaten (Video-Dauer erkannt) · 5 Thumbnail JPEG ·
6 Traversal-Schutz (404) · 7/8 Layer Video+Bild dynamisch ·
9 Engine rendert (91 Frames) · 10 Art-Net live auf Port 6455 mit
Master-Kanal 10 umgestellt · 11 DMX steuert Master 0.251 auf NEUEM
Port/Kanal · 12 Layer-Alpha via DMX · 13 config.json persistiert ·
14 Layer-Remove ohne Absturz (258 Frames, atomar) ·
Ungültige Settings werden mit 400 + Feldname abgewiesen

Fixes: Bild-Caps kombiniert (parse-Fehler), Thumbnail-Namens-Mapping,
kind_of-Methode, atomarer Rebuild mit Rollback, DMX-accepted-Zähler
2026-09-11 11:05:01 +02:00

274 lines
9.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Medienbibliothek: Upload/Import, Thumbnails, Metadaten, Löschen."""
from __future__ import annotations
import json
import re
import shutil
import time
import urllib.parse
from pathlib import Path
import gi
gi.require_version("Gst", "1.0")
from gi.repository import Gst
Gst.init(None)
VIDEO_EXT = {".mp4", ".m4v", ".avi", ".mkv", ".mov", ".webm"}
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".bmp"}
def kind_of(name: str) -> str | None:
ext = Path(name).suffix.lower()
if ext in VIDEO_EXT:
return "video"
if ext in IMAGE_EXT:
return "image"
return None
def sanitize(name: str) -> str:
"""Dateiname portabel und sicher machen (keine Pfade/Sonderzeichen)."""
base = Path(name).name
base = re.sub(r"[^A-Za-z0-9._-]", "_", base).strip("._")
return base or "file"
class MediaLibrary:
"""Dateibasierte Bibliothek inkl. Thumbnail- und Metadaten-Cache."""
def __init__(self, settings: dict, root: Path):
self.settings = settings
self.root = root
self.dir = (root / settings["media"]["dir"]).resolve()
self.thumbs = (root / settings["media"]["thumbs_dir"]).resolve()
# ---------- Pfade ----------
def ensure_dirs(self) -> None:
self.dir.mkdir(parents=True, exist_ok=True)
self.thumbs.mkdir(parents=True, exist_ok=True)
def kind_of(self, name: str) -> str | None:
"""Medientyp einer Datei (video/image) Instanz-Zugriff für Engine."""
return kind_of(name)
def file_path(self, name: str) -> Path:
return self.dir / sanitize(name)
def thumb_path(self, name: str) -> Path:
return self.thumbs / (Path(name).stem + ".jpg")
def _safe(self, base: Path, raw: str) -> Path | None:
name = urllib.parse.unquote(raw or "")
if not name or "/" in name or "\\" in name or name in (".", ".."):
return None
p = base / name
try:
if p.resolve().parent != base.resolve():
return None
except OSError:
return None
return p
def safe_file(self, raw: str) -> Path | None:
return self._safe(self.dir, raw)
def safe_thumb(self, raw: str) -> Path | None:
return self._safe(self.thumbs, raw)
def count(self) -> int:
try:
return sum(1 for f in self.dir.iterdir()
if f.is_file() and kind_of(f.name))
except OSError:
return 0
# ---------- Metadaten-Cache ----------
def _meta_path(self) -> Path:
return self.thumbs / "meta.json"
def _load_meta(self) -> dict:
try:
return json.loads(self._meta_path().read_text("utf-8"))
except (OSError, ValueError):
return {}
def _save_meta(self, meta: dict) -> None:
try:
self._meta_path().write_text(
json.dumps(meta, ensure_ascii=False, indent=1), "utf-8")
except OSError as e:
print(f"[Media] Meta-Cache schreiben fehlgeschlagen: {e}")
def _duration(self, f: Path) -> float | None:
try:
p = Gst.parse_launch(
f"uridecodebin uri={f.resolve().as_uri()} ! fakesink")
p.set_state(Gst.State.PAUSED)
p.get_state(3 * Gst.SECOND)
ok, dur = p.query_duration(Gst.Format.TIME)
p.set_state(Gst.State.NULL)
if ok and dur > 0:
return round(dur / Gst.SECOND, 2)
except Exception: # noqa: BLE001
pass
return None
def make_thumb(self, name: str) -> Path | None:
src = self.file_path(name)
if not src.exists():
return None
out = self.thumb_path(name)
if out.exists():
return out
got: dict = {}
try:
pipeline = Gst.parse_launch(
f"uridecodebin uri={src.resolve().as_uri()} ! "
"videoconvert ! videoscale ! "
"video/x-raw,width=192,height=108 ! "
"jpegenc quality=75 ! "
"appsink name=thumb emit-signals=true "
"max-buffers=1 drop=true")
sink = pipeline.get_by_name("thumb")
def on_sample(s):
sample = s.emit("pull-sample")
if sample is not None:
buf = sample.get_buffer()
got["data"] = buf.extract_dup(0, buf.get_size())
return Gst.FlowReturn.OK
sink.connect("new-sample", on_sample)
bus = pipeline.get_bus()
pipeline.set_state(Gst.State.PLAYING)
deadline = time.monotonic() + 6.0
while "data" not in got and time.monotonic() < deadline:
msg = bus.timed_pop_filtered(
100 * Gst.MSECOND,
Gst.MessageType.EOS | Gst.MessageType.ERROR)
if msg is not None:
break
time.sleep(0.02)
pipeline.set_state(Gst.State.NULL)
except Exception as e: # noqa: BLE001
print(f"[Media] Thumbnail-Fehler ({name}): {e}")
return None
if "data" not in got:
return None
try:
out.write_bytes(got["data"])
except OSError as e:
print(f"[Media] Thumbnail schreiben fehlgeschlagen: {e}")
return None
return out
# ---------- Öffentliche Operationen ----------
def scan(self) -> list[dict]:
self.ensure_dirs()
meta = self._load_meta()
items: list[dict] = []
dirty = False
for f in sorted(self.dir.iterdir()):
if not f.is_file() or f.name.endswith(".part"):
continue
kind = kind_of(f.name)
if kind is None:
continue
st = f.stat()
ent = meta.get(f.name)
if (not isinstance(ent, dict)
or ent.get("size") != st.st_size
or ent.get("mtime") != int(st.st_mtime)):
dur = self._duration(f) if kind == "video" else None
self.make_thumb(f.name)
ent = {"size": st.st_size, "mtime": int(st.st_mtime),
"duration_s": dur}
meta[f.name] = ent
dirty = True
items.append({
"name": f.name, "size": st.st_size, "type": kind,
"mtime": ent["mtime"], "duration_s": ent.get("duration_s"),
})
if dirty:
self._save_meta(meta)
return items
def _unique(self, base: str) -> str:
final = base
n = 1
while (self.dir / final).exists():
final = f"{Path(base).stem}-{n}{Path(base).suffix}"
n += 1
return final
def save_stream(self, name: str, src, length: int
) -> tuple[str | None, str | None]:
"""Speichert einen Upload-Stream chunkweise (final_name, error)."""
base = sanitize(name)
if kind_of(base) is None:
return None, "Dateityp nicht unterstützt"
if length <= 0:
return None, "Content-Length fehlt"
limit_mb = int(self.settings["media"].get("max_upload_mb", 2048))
limit = limit_mb * 1024 * 1024
if length > limit:
return None, f"Datei zu groß (max {limit_mb} MB)"
final = self._unique(base)
tmp = self.dir / (final + ".part")
written = 0
try:
with open(tmp, "wb") as out:
while written < length:
chunk = src.read(min(1024 * 1024, length - written))
if not chunk:
break
out.write(chunk)
written += len(chunk)
except OSError as e:
tmp.unlink(missing_ok=True)
return None, f"IO-Fehler: {e}"
if written != length:
tmp.unlink(missing_ok=True)
return None, f"Upload unvollständig ({written}/{length} Bytes)"
tmp.rename(self.dir / final)
try:
self.make_thumb(final)
except Exception: # noqa: BLE001
pass
return final, None
def import_copy(self, src: Path) -> str | None:
"""Importiert eine lokale Datei (CLI-Argument) in die Bibliothek."""
if not src.exists() or kind_of(src.name) is None:
return None
if src.resolve().parent == self.dir:
return src.name
final = self._unique(sanitize(src.name))
try:
shutil.copy2(src, self.dir / final)
except OSError as e:
print(f"[Media] Import fehlgeschlagen ({src}): {e}")
return None
self.make_thumb(final)
return final
def delete(self, name: str) -> bool:
p = self.safe_file(name)
if p is None or not p.exists():
return False
try:
p.unlink()
except OSError:
return False
self.thumb_path(p.name).unlink(missing_ok=True)
meta = self._load_meta()
if p.name in meta:
meta.pop(p.name)
self._save_meta(meta)
return True