Files
HMS MediaEngine Agent 362e089be0 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).
2026-09-11 23:44:06 +02:00

264 lines
9.0 KiB
Python

"""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
}