From 82c79ec485c1b5ea2cfbb57f54e58198787c7654 Mon Sep 17 00:00:00 2001 From: HMS MediaEngine Agent Date: Fri, 11 Sep 2026 03:13:43 +0200 Subject: [PATCH] =?UTF-8?q?Phase=205:=20Projekt-/Media-/Plugin-API=20im=20?= =?UTF-8?q?Control=20Core=20(=C2=A723.1,=20=C2=A713.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Projekte: CRUD + Activate (State-Store-Revision), §23.1/§24.2 - Medien: Import mit Duplikaterkennung (§13.3), Liste mit Missing-Markierung (§13.2) - Plugins: Listing nach Lifecycle-Status gruppiert (§14.5), Enable mit Lifecycle-Advancement, 404 bei unbekanntem Plugin - Art-Net-Status-Endpunkt (§23.1) - Diagnostics erweitert um State-/Projekt-Revisionen (§28.2) - Commands schreiben jetzt auch in den State-Store (§6.4) - SQLite check_same_thread=False für FastAPI async handlers - python-multipart für UploadFile hinzugefügt - Discovery-Fix: Builtin-Plugins nach Kategorie-Unterverzeichnissen (generators/, filters/) durchsuchen statt nur Direktkinder - 8 neue Integrationstests: Projekt-CRUD-Roundtrip, Activate, Media-Import mit Duplikat, Plugin-Listing (10+14 verifiziert), Enable-404, Diagnostics-Revisionen - Gesamtsuite 557 grün, Ruff grün --- apps/control_server/hms_control_server/app.py | 240 +++++++++++++++++- .../persistence/hms_persistence/__init__.py | 2 +- pyproject.toml | 1 + tests/integration/test_control_server.py | 2 +- tests/integration/test_project_api.py | 142 +++++++++++ uv.lock | 11 + 6 files changed, 384 insertions(+), 14 deletions(-) create mode 100644 tests/integration/test_project_api.py diff --git a/apps/control_server/hms_control_server/app.py b/apps/control_server/hms_control_server/app.py index 29b2e8b..7fb896f 100644 --- a/apps/control_server/hms_control_server/app.py +++ b/apps/control_server/hms_control_server/app.py @@ -9,6 +9,11 @@ Endpunkte: - POST /api/v1/commands/{command_id}/release - GET /api/v1/diagnostics - GET /api/v1/cluster/nodes +- GET /api/v1/projects, POST/GET/PUT/DELETE /api/v1/projects/{id} +- POST /api/v1/media/import +- GET /api/v1/media +- GET /api/v1/plugins +- GET /api/v1/artnet/status - WS /ws (State-Snapshot + Updates) Commands folgen §23.2: command_id, type, expected_revision, actor, payload. @@ -20,16 +25,23 @@ from __future__ import annotations import asyncio import uuid +from pathlib import Path -from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, HTTPException, UploadFile, WebSocket, WebSocketDisconnect from hms_capabilities.probe import CapabilityReport from hms_cluster.registry import NodeRegistry from hms_domain.identity import NodeIdentity, NodeRole +from hms_domain.model import ( + Project, +) +from hms_media import ImportStatus, MediaLibrary from hms_parameter.engine import ( ControlSource, ParameterEngine, RevisionConflict, ) +from hms_persistence import Database +from hms_persistence.state_store import ProjectStateStore from hms_protocol.idempotency import IdempotencyRegistry from pydantic import BaseModel, Field @@ -48,14 +60,51 @@ class ReleaseCommand(BaseModel): source: str = "web" +class CreateProjectBody(BaseModel): + name: str = "Neues Projekt" + + +def _builtin_plugin_dir() -> Path: + """Löst das Builtin-Plugin-Verzeichnis robust auf (§14.2). + + Sucht vom aktuellen Arbeitsverzeichnis aus über bekannte Kandidaten; + im Entwickungsbaum ist das Repo-Root die Basis. + """ + candidates = [ + Path.cwd() / "plugins" / "builtin", + Path(__file__).resolve().parents[3] / "plugins" / "builtin", + ] + for candidate in candidates: + if candidate.is_dir(): + return candidate + return candidates[0] + + +def _discover_builtin_plugins(mgr, builtin_dir: Path) -> list[str]: + """Entdeckt Plugins je Kategorie-Unterverzeichnis (§14.2).""" + errors: list[str] = [] + for category in sorted(builtin_dir.iterdir()): + if not category.is_dir(): + continue + errors.extend(mgr.discover(category)) + return errors + + class _State: - def __init__(self, identity: NodeIdentity) -> None: + def __init__(self, identity: NodeIdentity, data_dir: Path | None = None) -> None: self.identity = identity self.engine = ParameterEngine() self.registry = IdempotencyRegistry() self.report = CapabilityReport() self.nodes = NodeRegistry() self.subscribers: list[asyncio.Queue] = [] + # Persistenz und Projekt-Verwaltung (§24, §6.4) + self.data_dir = data_dir or Path("/tmp/hms-dev-data") + self.database = Database(self.data_dir / "database" / "hms.db") + self.database.open() + self.database.migrate(backup_dir=self.data_dir / "backups") + self.state_store = ProjectStateStore() + self.media_library = MediaLibrary(self.data_dir / "media") def create_app(identity: NodeIdentity | None = None) -> FastAPI: @@ -82,11 +131,13 @@ def create_app(identity: NodeIdentity | None = None) -> FastAPI: for queue in list(state.subscribers): queue.put_nowait(event) + # ---------- System (§23.1) ---------- + @app.get("/api/v1/system/health") async def health() -> dict: return { "status": "ok", - "phase": 1, + "phase": 5, "node_id": state.identity.node_id, "revision": state.engine.revision, } @@ -111,6 +162,19 @@ def create_app(identity: NodeIdentity | None = None) -> FastAPI: snap = state.engine.snapshot() return {"revision": snap.revision, "values": snap.as_dict()} + @app.get("/api/v1/diagnostics") + async def diagnostics() -> dict: + return { + "renderer": "not_connected", + "artnet": "not_started", + "revision": state.engine.revision, + "node_id": state.identity.node_id, + "state_revision": state.state_store.state_revision, + "project_revision": state.state_store.project_revision, + } + + # ---------- Commands (§23.2) ---------- + @app.post("/api/v1/commands") async def post_command(cmd: SetParameterCommand) -> dict: if cmd.type != "parameter.set": @@ -131,6 +195,7 @@ def create_app(identity: NodeIdentity | None = None) -> FastAPI: source=ControlSource.WEB, expected_revision=cmd.expected_revision, ) + state.state_store.set_value(path, float(value)) except RevisionConflict as exc: raise HTTPException( status_code=409, @@ -161,17 +226,9 @@ def create_app(identity: NodeIdentity | None = None) -> FastAPI: @app.post("/api/v1/commands/{command_id}/release") async def release_override(command_id: str) -> dict: - # Release nach §11.3; command_id referenziert den ursprünglichen Command. return {"status": "not_implemented_in_phase0"} - @app.get("/api/v1/diagnostics") - async def diagnostics() -> dict: - return { - "renderer": "not_connected", # IPC-Handshake folgt (ADR-0003) - "artnet": "not_started", - "revision": state.engine.revision, - "node_id": state.identity.node_id, - } + # ---------- Cluster (§23.1) ---------- @app.get("/api/v1/cluster/nodes") async def cluster_nodes() -> dict: @@ -190,6 +247,165 @@ def create_app(identity: NodeIdentity | None = None) -> FastAPI: ) return {"self": state.identity.node_id, "nodes": nodes} + # ---------- Projekte (§23.1, §24.2) ---------- + + @app.get("/api/v1/projects") + async def list_projects() -> dict: + return {"projects": state.database.list_projects()} + + @app.post("/api/v1/projects") + async def create_project(body: CreateProjectBody) -> dict: + project = Project(name=body.name) + state.database.save_project(project.model_dump(mode="json")) + return {"status": "ack", "project": project.model_dump(mode="json")} + + @app.get("/api/v1/projects/{project_id}") + async def get_project(project_id: str) -> dict: + data = state.database.load_project(project_id) + if data is None: + raise HTTPException(status_code=404, detail="Projekt nicht gefunden") + return data + + @app.put("/api/v1/projects/{project_id}") + async def update_project(project_id: str, body: dict) -> dict: + existing = state.database.load_project(project_id) + if existing is None: + raise HTTPException(status_code=404, detail="Projekt nicht gefunden") + for key, value in body.items(): + if key in existing: + existing[key] = value + updated = Project.model_validate(existing) + state.database.save_project(updated.model_dump(mode="json")) + state.state_store.bump_project_revision() + return {"status": "ack"} + + @app.delete("/api/v1/projects/{project_id}") + async def delete_project(project_id: str) -> dict: + state.database.delete_project(project_id) + return {"status": "ack"} + + @app.post("/api/v1/projects/{project_id}/activate") + async def activate_project(project_id: str) -> dict: + """Aktiviert ein Projekt als autoritative Basis (§6.4).""" + data = state.database.load_project(project_id) + if data is None: + raise HTTPException(status_code=404, detail="Projekt nicht gefunden") + project = Project.model_validate(data) + revision = state.state_store.activate_project(project) + return {"status": "ack", "state_revision": revision} + + # ---------- Medien (§13.3, §23.1) ---------- + + @app.post("/api/v1/media/import") + async def import_media(file: UploadFile) -> dict: + """Importiert eine Mediendatei mit Duplikaterkennung (§13.3). + + Import blockiert niemals den Renderthread (§13.3). + """ + media_root = state.data_dir / "media" + media_root.mkdir(parents=True, exist_ok=True) + dest = media_root / file.filename + content = await file.read() + dest.write_bytes(content) + outcome = state.media_library.import_file(dest) + if outcome.status is ImportStatus.IMPORTED and outcome.asset: + state.database.save_project({ + "id": str(uuid.uuid4()), + "name": file.filename, + "schema_version": 1, + "created_at": "2026-09-11T00:00:00+00:00", + "updated_at": "2026-09-11T00:00:00+00:00", + "data": outcome.asset.model_dump(mode="json"), + }) + return { + "status": outcome.status.value, + "asset": outcome.asset.model_dump(mode="json") if outcome.asset else None, + "duplicate_of": outcome.duplicate_of, + } + + @app.get("/api/v1/media") + async def list_media() -> dict: + """Medienliste mit Metadaten (§13.2).""" + missing = state.media_library.mark_missing() + assets = [] + for asset in state.media_library.all(): + assets.append({ + **asset.model_dump(mode="json"), + "missing": asset.id in state.media_library.missing_asset_ids, + }) + return {"media": assets, "missing_count": len(missing)} + + # ---------- Plugins (§23.1) ---------- + + @app.get("/api/v1/plugins") + async def list_plugins() -> dict: + """Installierte/verfügbare Plugins nach Status gruppiert (§14.5).""" + from hms_plugin_sdk import PluginLifecycleManager + + builtin_dir = _builtin_plugin_dir() + mgr = PluginLifecycleManager() + errors = _discover_builtin_plugins(mgr, builtin_dir) + plugins_by_state: dict[str, list[dict]] = {} + for record in mgr.all(): + plugins_by_state.setdefault(record.state.value, []).append( + { + "plugin_id": record.plugin_id, + "version": record.version, + "kind": record.manifest.get("kind", "unknown"), + "name": record.manifest.get("name", record.plugin_id), + "last_error": record.last_error, + } + ) + return { + "plugins_by_state": plugins_by_state, + "total": len(mgr.all()), + "discovery_errors": errors, + } + + @app.post("/api/v1/plugins/{plugin_id}/enable") + async def enable_plugin(plugin_id: str) -> dict: + """Aktiviert ein Plugin über den Lifecycle (§14.5).""" + from hms_plugin_sdk import ( + InvalidTransitionError, + LifecycleState, + PluginLifecycleManager, + ) + + builtin_dir = _builtin_plugin_dir() + mgr = PluginLifecycleManager() + _discover_builtin_plugins(mgr, builtin_dir) + if mgr.get(plugin_id) is None: + raise HTTPException(status_code=404, detail=f"unbekanntes Plugin {plugin_id!r}") + try: + for target in ( + LifecycleState.INSTALLED, + LifecycleState.ENABLED, + LifecycleState.COMPILED, + LifecycleState.ACTIVE, + ): + record = mgr.get(plugin_id) + if record is None: + break + if record.state is target: + break + mgr.advance(plugin_id, target) + return {"status": "ack", "plugin_id": plugin_id} + except InvalidTransitionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # ---------- Art-Net (§23.1) ---------- + + @app.get("/api/v1/artnet/status") + async def artnet_status() -> dict: + return { + "running": False, + "universes": [], + "telemetry": {}, + "note": "Art-Net-Receiver startet mit dem Launcher (Phase 1 vollstaendig nach Gate 1)", + } + + # ---------- WebSocket (§23.3) ---------- + @app.websocket("/ws") async def websocket_endpoint(ws: WebSocket) -> None: await ws.accept() diff --git a/packages/persistence/hms_persistence/__init__.py b/packages/persistence/hms_persistence/__init__.py index 6ec33c9..bdaf427 100644 --- a/packages/persistence/hms_persistence/__init__.py +++ b/packages/persistence/hms_persistence/__init__.py @@ -94,7 +94,7 @@ class Database: 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) + 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") diff --git a/pyproject.toml b/pyproject.toml index c0500e1..84291d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "uvicorn>=0.30", "pydantic>=2.7", "msgpack>=1.0", + "python-multipart>=0.0.32", ] [dependency-groups] diff --git a/tests/integration/test_control_server.py b/tests/integration/test_control_server.py index db6f417..6b8b6b2 100644 --- a/tests/integration/test_control_server.py +++ b/tests/integration/test_control_server.py @@ -45,7 +45,7 @@ def test_health(client: TestClient) -> None: r = client.get("/api/v1/system/health") assert r.status_code == 200 assert r.json()["status"] == "ok" - assert r.json()["phase"] == 1 + assert r.json()["phase"] == 5 assert r.json()["node_id"] # Identität in Health sichtbar diff --git a/tests/integration/test_project_api.py b/tests/integration/test_project_api.py new file mode 100644 index 0000000..f3e7c0b --- /dev/null +++ b/tests/integration/test_project_api.py @@ -0,0 +1,142 @@ +"""Integrationstests Projekt-/Media-/Plugin-API (PLAN.md §23.1, §13.3).""" + +from __future__ import annotations + +import io + +import pytest +from fastapi.testclient import TestClient +from hms_control_server import create_app + + +@pytest.fixture() +def client() -> TestClient: + return TestClient(create_app()) + + +# ---------- Projekte (§23.1, §24.2) ---------- + + +def test_project_crud_roundtrip(client: TestClient) -> None: + """Projekt anlegen → laden → aktualisieren → löschen (§23.1).""" + created = client.post("/api/v1/projects", json={"name": "Test Show"}) + assert created.status_code == 200 + project = created.json()["project"] + assert project["name"] == "Test Show" + assert project["schema_version"] == 1 + + listed = client.get("/api/v1/projects") + assert listed.status_code == 200 + assert any(p["name"] == "Test Show" for p in listed.json()["projects"]) + + fetched = client.get(f"/api/v1/projects/{project['id']}") + assert fetched.status_code == 200 + assert fetched.json()["name"] == "Test Show" + + updated = client.put(f"/api/v1/projects/{project['id']}", json={"name": "Umbenannt"}) + assert updated.status_code == 200 + refetched = client.get(f"/api/v1/projects/{project['id']}") + assert refetched.json()["name"] == "Umbenannt" + + deleted = client.delete(f"/api/v1/projects/{project['id']}") + assert deleted.status_code == 200 + assert client.get(f"/api/v1/projects/{project['id']}").status_code == 404 + + +def test_project_404_on_unknown(client: TestClient) -> None: + assert client.get("/api/v1/projects/00000000-0000-0000-0000-000000000000").status_code == 404 + assert client.put( + "/api/v1/projects/00000000-0000-0000-0000-000000000000", json={} + ).status_code == 404 + + +def test_project_activate(client: TestClient) -> None: + """Aktivieren setzt State-Revision im Store (§6.4).""" + created = client.post("/api/v1/projects", json={"name": "Show A"}) + project_id = created.json()["project"]["id"] + activated = client.post(f"/api/v1/projects/{project_id}/activate") + assert activated.status_code == 200 + assert activated.json()["state_revision"] >= 1 + # Projekt aktivieren mit unbekannter ID → 404 + unknown = "00000000-0000-0000-0000-000000000000" + assert client.post(f"/api/v1/projects/{unknown}/activate").status_code == 404 + + +# ---------- Medien (§13.3) ---------- + + +def test_media_import_and_list(client: TestClient) -> None: + """Upload mit Duplikaterkennung: erst importiert, dann duplicate (§13.3).""" + content = b"fake-video-bytes-for-testing-only" + # 1. Import + r1 = client.post( + "/api/v1/media/import", + files={"file": ("test.mp4", io.BytesIO(content), "video/mp4")}, + ) + assert r1.status_code == 200 + body1 = r1.json() + assert body1["status"] == "imported" + assert body1["asset"]["rel_path"] == "test.mp4" + assert body1["asset"]["content_hash"] + + # 2. Duplikat (gleicher Inhalt) + r2 = client.post( + "/api/v1/media/import", + files={"file": ("kopie.mp4", io.BytesIO(content), "video/mp4")}, + ) + assert r2.status_code == 200 + body2 = r2.json() + assert body2["status"] == "duplicate" + assert body2["duplicate_of"] == body1["asset"]["id"] + + # 3. Liste zeigt beide, markiert nichts fehlend + listed = client.get("/api/v1/media") + assert listed.status_code == 200 + assert len(listed.json()["media"]) >= 1 + assert listed.json()["missing_count"] == 0 + + +def test_media_list_empty(client: TestClient) -> None: + listed = client.get("/api/v1/media") + assert listed.status_code == 200 + assert "media" in listed.json() + + +# ---------- Plugins (§14.5) ---------- + + +def test_plugins_listed_by_state(client: TestClient) -> None: + """Plugins erscheinen nach Lifecycle-Status gruppiert (§14.5).""" + listed = client.get("/api/v1/plugins") + assert listed.status_code == 200 + body = listed.json() + assert body["total"] >= 24 # alle Builtin-Plugins gefunden + assert "validated" in body["plugins_by_state"] + generators = [ + p for p in body["plugins_by_state"].get("validated", []) + if p["kind"] == "generator" + ] + filters = [ + p for p in body["plugins_by_state"].get("validated", []) + if p["kind"] == "filter" + ] + assert len(generators) == 10 # §15.1 + assert len(filters) == 14 # §15.2 + + +def test_plugin_enable_unknown_returns_404(client: TestClient) -> None: + """Unbekanntes Plugin → 404, kein Absturz (§14.5).""" + r = client.post("/api/v1/plugins/com.gibts.nicht/enable") + assert r.status_code == 404 + + +# ---------- Diagnostics ---------- + + +def test_diagnostics_reports_revisions(client: TestClient) -> None: + """Diagnostics zeigt State- und Projekt-Revisionen (§28.2).""" + r = client.get("/api/v1/diagnostics") + assert r.status_code == 200 + body = r.json() + assert "state_revision" in body + assert "project_revision" in body diff --git a/uv.lock b/uv.lock index 1807a36..9329f17 100644 --- a/uv.lock +++ b/uv.lock @@ -93,6 +93,7 @@ dependencies = [ { name = "fastapi" }, { name = "msgpack" }, { name = "pydantic" }, + { name = "python-multipart" }, { name = "uvicorn" }, ] @@ -108,6 +109,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, { name = "msgpack", specifier = ">=1.0" }, { name = "pydantic", specifier = ">=2.7" }, + { name = "python-multipart", specifier = ">=0.0.32" }, { name = "uvicorn", specifier = ">=0.30" }, ] @@ -267,6 +269,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "ruff" version = "0.16.7"