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
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user