AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell. Jetzt ist sauber getrennt: ROOT (was der Nutzer sieht und braucht): - run.py = das Programm - hms_app/ = der Anwendungscode - HMS MediaEngine.app = macOS Doppelklick-Starter - HMS-Start.vbs = Windows Doppelklick-Starter - HMS-Install.vbs = Windows Erst-Installation - HMS-Mac-Install.command = macOS Homebrew-Installation - HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix) - installer_gui.py = grafischer Installer - launcher.pyw + launcher_core.py = interne Start-Logik - LIESMICH.txt = 10-Zeilen-Kurzanleitung - .gitignore _entwicklung/ (alles andere, NICHT benoetigt): - packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/ build/ fixture_profiles/ - PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md - pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann _entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne. Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
"""SQLite-Persistenz mit Migrationen (PLAN.md §24).
|
||||
|
||||
Regeln (§24.1, §24.4):
|
||||
- WAL-Modus, Foreign Keys aktiv, kurze Transaktionen
|
||||
- keine Datenbankoperation im Renderthread (nur Control Core nutzt sie)
|
||||
- automatisches Backup vor Migration
|
||||
- Integritätscheck beim Start nach unsauberem Shutdown
|
||||
- jede Schemaänderung: Vorwärtsmigration, Test mit Altdaten, Backup,
|
||||
dokumentierte Nicht-Rückwärtskompatibilität, neue schema_version
|
||||
|
||||
Projektdateien liegen als JSON-Dateien (§24.2: große Medien als Dateien,
|
||||
SQLite speichert Index, Einstellungen, Pluginstatus, Projektmetadaten).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
_MIGRATIONS: dict[int, str] = {
|
||||
1: """
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
schema_version INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS plugin_status (
|
||||
plugin_id TEXT PRIMARY KEY,
|
||||
version TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
state TEXT NOT NULL DEFAULT 'discovered',
|
||||
package_hash TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationResult:
|
||||
"""Ergebnis einer Migration (für TEST_REPORT und Gate-Doku)."""
|
||||
|
||||
from_version: int
|
||||
to_version: int
|
||||
backup_path: Path | None
|
||||
integrity_ok: bool
|
||||
|
||||
|
||||
class Database:
|
||||
"""SQLite-Wrapper für den Control Core (nicht im Renderthread!).
|
||||
|
||||
- open(): öffnet mit WAL, FK, Integritätscheck
|
||||
- migrate(): führt fehlende Migrationen aus, Backup vorher
|
||||
- save_project/load_project/list_projects: Projektmetadaten + JSON-Daten
|
||||
- set_setting/get_setting: App-Einstellungen
|
||||
- upsert_plugin_status/get_plugin_status: Pluginverwaltung (§14.5)
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self._path = Path(path)
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
|
||||
@property
|
||||
def connection(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError("database not opened")
|
||||
return self._conn
|
||||
|
||||
@property
|
||||
def schema_version(self) -> int:
|
||||
row = self.connection.execute(
|
||||
"SELECT value FROM meta WHERE key='schema_version'"
|
||||
).fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
def open(self, integrity_check: bool = True) -> None:
|
||||
"""Öffnet die DB: WAL, Foreign Keys, Busy-Timeout, Integritätscheck."""
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = sqlite3.connect(self._path, timeout=5.0, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL") # §24.1
|
||||
self._conn.execute("PRAGMA foreign_keys=ON") # §24.1
|
||||
self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
if integrity_check: # nach unsauberem Shutdown (§24.1)
|
||||
row = self._conn.execute("PRAGMA integrity_check").fetchone()
|
||||
if row and row[0] != "ok":
|
||||
raise sqlite3.DatabaseError(f"integrity check failed: {row[0]}")
|
||||
|
||||
def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
def migrate(self, backup_dir: Path | None = None) -> MigrationResult:
|
||||
"""Führt Migrationen bis SCHEMA_VERSION aus; Backup vorher (§24.4).
|
||||
|
||||
Migrationen sind reine Vorwärtsmigrationen; jede Änderung erhöht
|
||||
schema_version. Altdaten werden beim Backup erhalten.
|
||||
"""
|
||||
conn = self.connection
|
||||
current = 0
|
||||
try:
|
||||
current = self.schema_version
|
||||
except sqlite3.OperationalError:
|
||||
pass # meta-Tabelle existiert noch nicht → Version 0
|
||||
if current >= SCHEMA_VERSION:
|
||||
return MigrationResult(current, current, None, True)
|
||||
|
||||
backup_path: Path | None = None
|
||||
if self._path.exists() and backup_dir is not None:
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = backup_dir / f"{self._path.name}.pre-migration-{stamp}.bak"
|
||||
shutil.copy2(self._path, backup_path) # §24.4: Backup vor Migration
|
||||
|
||||
with conn: # kurze Transaktion je Version (§24.1)
|
||||
for version in range(current + 1, SCHEMA_VERSION + 1):
|
||||
sql = _MIGRATIONS.get(version)
|
||||
if sql is None:
|
||||
raise RuntimeError(f"missing migration for version {version}")
|
||||
conn.executescript(sql)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)",
|
||||
(str(SCHEMA_VERSION),),
|
||||
)
|
||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
||||
ok = bool(row and row[0] == "ok")
|
||||
return MigrationResult(current, SCHEMA_VERSION, backup_path, ok)
|
||||
|
||||
# ---------- Projekte (§24.2) ----------
|
||||
|
||||
def save_project(self, project: dict) -> None:
|
||||
"""Speichert Projektmetadaten + JSON-Daten transaktionell."""
|
||||
required = ("id", "name", "schema_version", "created_at", "updated_at")
|
||||
for key in required:
|
||||
if key not in project:
|
||||
raise ValueError(f"project missing field {key!r}")
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"""INSERT OR REPLACE INTO projects
|
||||
(id, name, schema_version, created_at, updated_at, data)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
project["id"],
|
||||
project["name"],
|
||||
int(project["schema_version"]),
|
||||
project["created_at"],
|
||||
project["updated_at"],
|
||||
json.dumps(project, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
|
||||
def load_project(self, project_id: str) -> dict | None:
|
||||
row = self.connection.execute(
|
||||
"SELECT data FROM projects WHERE id=?", (project_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return json.loads(row[0])
|
||||
|
||||
def list_projects(self) -> list[dict]:
|
||||
rows = self.connection.execute(
|
||||
"SELECT id, name, schema_version, updated_at FROM projects"
|
||||
" ORDER BY updated_at DESC"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"name": r[1],
|
||||
"schema_version": r[2],
|
||||
"updated_at": r[3],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def delete_project(self, project_id: str) -> None:
|
||||
with self.connection:
|
||||
self.connection.execute("DELETE FROM projects WHERE id=?", (project_id,))
|
||||
|
||||
# ---------- Einstellungen ----------
|
||||
|
||||
def set_setting(self, key: str, value: str) -> None:
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
|
||||
(key, value, time.strftime("%Y-%m-%dT%H:%M:%S%z")),
|
||||
)
|
||||
|
||||
def get_setting(self, key: str, default: str | None = None) -> str | None:
|
||||
row = self.connection.execute(
|
||||
"SELECT value FROM settings WHERE key=?", (key,)
|
||||
).fetchone()
|
||||
return row[0] if row else default
|
||||
|
||||
# ---------- Plugin-Status (§14.5) ----------
|
||||
|
||||
def upsert_plugin_status(
|
||||
self,
|
||||
plugin_id: str,
|
||||
version: str,
|
||||
enabled: bool,
|
||||
state: str,
|
||||
package_hash: str | None = None,
|
||||
) -> None:
|
||||
if state not in {
|
||||
"discovered",
|
||||
"validated",
|
||||
"installed",
|
||||
"enabled",
|
||||
"compiled",
|
||||
"active",
|
||||
"quarantined",
|
||||
"incompatible",
|
||||
"disabled",
|
||||
}:
|
||||
raise ValueError(f"invalid plugin state {state!r}")
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"""INSERT OR REPLACE INTO plugin_status
|
||||
(plugin_id, version, enabled, state, package_hash, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
plugin_id,
|
||||
version,
|
||||
int(enabled),
|
||||
state,
|
||||
package_hash,
|
||||
time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
),
|
||||
)
|
||||
|
||||
def get_plugin_status(self) -> dict[str, dict]:
|
||||
rows = self.connection.execute(
|
||||
"SELECT plugin_id, version, enabled, state, package_hash, updated_at"
|
||||
" FROM plugin_status"
|
||||
).fetchall()
|
||||
return {
|
||||
r[0]: {
|
||||
"version": r[1],
|
||||
"enabled": bool(r[2]),
|
||||
"state": r[3],
|
||||
"package_hash": r[4],
|
||||
"updated_at": r[5],
|
||||
}
|
||||
for r in rows
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Autoritativer Projekt- und Showzustand (PLAN.md §6.4 State Sync, §24.2).
|
||||
|
||||
- Vollständiger Snapshot nach Verbindung; danach inkrementelle Deltas
|
||||
- monotone Revisionen: kein halber Zustand (§11.4, §6.4)
|
||||
- Livezustand und dauerhafter Projektzustand sind getrennt (§24.2)
|
||||
- Szenenaktivierung erzeugt eine neue State-Revision
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from hms_domain.model import PresetScene, Project
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateDelta:
|
||||
"""Inkrementelle Änderung mit monotoner Revision (§6.4).
|
||||
|
||||
- project_revision: Projekt-Inhaltsrevision (Manifest-Ebene)
|
||||
- state_revision: monotone Showzustands-Revision
|
||||
- changes: Parameterpfad → Wert; gelöschte Pfade als None markiert
|
||||
"""
|
||||
|
||||
state_revision: int
|
||||
project_revision: int
|
||||
changes: dict[str, float | None]
|
||||
monotonic_ns: int
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"state_revision": self.state_revision,
|
||||
"project_revision": self.project_revision,
|
||||
"changes": self.changes,
|
||||
"monotonic_ns": self.monotonic_ns,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectStateStore:
|
||||
"""Autoritative Instanz im Control Core (§6.1B, §6.4).
|
||||
|
||||
Trennung (§24.2):
|
||||
- project: dauerhafter Projektzustand (Domänenmodell, persistiert)
|
||||
- live: Show-Livezustand (Parameterwerte je Pfad, nicht persistent)
|
||||
|
||||
Revisions:
|
||||
- state_revision steigt bei jeder Livezustandsänderung monoton
|
||||
- project_revision steigt bei Projektinhalts-Änderungen (z. B. neue
|
||||
Szenen, Medien-Revision) – Grundlage für Preflight (§6.5)
|
||||
"""
|
||||
|
||||
_state_revision: int = 0
|
||||
_project_revision: int = 0
|
||||
_live: dict[str, float] = field(default_factory=dict)
|
||||
_project: Project | None = None
|
||||
_project_name: str = ""
|
||||
|
||||
# ---------- Projekt ----------
|
||||
|
||||
def activate_project(self, project: Project) -> int:
|
||||
"""Aktiviert ein Projekt als autoritative Basis; erhöht die
|
||||
Projektrevision. Livezustand wird zurückgesetzt (kein Mischzustand)."""
|
||||
self._project = project
|
||||
self._project_name = project.name
|
||||
self._project_revision += 1
|
||||
self._live.clear()
|
||||
self._state_revision += 1 # neuer Zustand nach Projektwechsel
|
||||
return self._state_revision
|
||||
|
||||
@property
|
||||
def project(self) -> Project | None:
|
||||
return self._project
|
||||
|
||||
@property
|
||||
def project_revision(self) -> int:
|
||||
return self._project_revision
|
||||
|
||||
@property
|
||||
def state_revision(self) -> int:
|
||||
return self._state_revision
|
||||
|
||||
# ---------- Livezustand ----------
|
||||
|
||||
def set_value(self, path: str, value: float) -> int:
|
||||
"""Setzt einen Liveparameter; gibt die neue State-Revision zurück."""
|
||||
self._live[path] = float(value)
|
||||
self._state_revision += 1
|
||||
return self._state_revision
|
||||
|
||||
def clear_value(self, path: str) -> int:
|
||||
"""Entfernt einen Liveparameter (z. B. Release); neue Revision."""
|
||||
self._live.pop(path, None)
|
||||
self._state_revision += 1
|
||||
return self._state_revision
|
||||
|
||||
def get_value(self, path: str) -> float | None:
|
||||
return self._live.get(path)
|
||||
|
||||
# ---------- Snapshot / Delta (§6.4) ----------
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
"""Vollständiger Zustand nach Verbindungsaufbau (§6.2, §6.4)."""
|
||||
return {
|
||||
"state_revision": self._state_revision,
|
||||
"project_revision": self._project_revision,
|
||||
"project_name": self._project_name,
|
||||
"values": dict(self._live),
|
||||
"monotonic_ns": time.monotonic_ns(),
|
||||
}
|
||||
|
||||
def delta_since(self, last_seen_revision: int, pending: dict[str, float]) -> StateDelta | None:
|
||||
"""Delta seit einer gesehenen Revision; None, wenn nichts Neues.
|
||||
|
||||
pending: letzter BEKANNTER Zustand des Empfängers (Pfad → Wert,
|
||||
wie er bei last_seen_revision beim Client stand). Das Delta enthält:
|
||||
- neue Pfade (in live, nicht in pending) mit ihrem Wert
|
||||
- geänderte Pfade mit dem neuen Wert
|
||||
- gelöschte Pfade als None
|
||||
Ein vollständiger Re-Sync (neuer Snapshot) ist Aufgabe des
|
||||
Transports, wenn last_seen_revision zu alt ist (§6.2).
|
||||
"""
|
||||
if last_seen_revision > self._state_revision:
|
||||
raise ValueError(
|
||||
f"gesehene Revision {last_seen_revision} liegt in der Zukunft"
|
||||
)
|
||||
if last_seen_revision == self._state_revision:
|
||||
return None # nichts Neues
|
||||
changes: dict[str, float | None] = {}
|
||||
for path, value in self._live.items():
|
||||
if path not in pending:
|
||||
changes[path] = value # neu seit last_seen
|
||||
elif pending[path] != value:
|
||||
changes[path] = value # geändert
|
||||
for path in pending:
|
||||
if path not in self._live:
|
||||
changes[path] = None # gelöscht
|
||||
return StateDelta(
|
||||
state_revision=self._state_revision,
|
||||
project_revision=self._project_revision,
|
||||
changes=changes,
|
||||
monotonic_ns=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
# ---------- Szenen (§18) ----------
|
||||
|
||||
def apply_scene(self, scene: PresetScene) -> int:
|
||||
"""Aktiviert eine Szene direkt (§18.1): ÜBERNAHME der Snapshot-Werte
|
||||
in den Livezustand als neue State-Revision. Übergänge (Crossfade
|
||||
etc.) berechnet der Renderer aus vorher/nachher – hier entsteht nur
|
||||
der Zielzustand (§18.1: Diff zur Laufzeit).
|
||||
"""
|
||||
snapshot_values = scene.composition_snapshot.get("values", {})
|
||||
if not isinstance(snapshot_values, dict):
|
||||
raise ValueError("Szene enthält keine Werte")
|
||||
for path, value in snapshot_values.items():
|
||||
self._live[str(path)] = float(value)
|
||||
self._state_revision += 1
|
||||
return self._state_revision
|
||||
|
||||
def bump_project_revision(self) -> int:
|
||||
"""Projektinhalt geändert (Medien/Plugins/Szenen) → neue Revision."""
|
||||
self._project_revision += 1
|
||||
return self._project_revision
|
||||
Reference in New Issue
Block a user