from __future__ import annotations import importlib.util import sys from dataclasses import dataclass from pathlib import Path from typing import Any @dataclass class LoadedPlugin: id: str name: str desc: str version: str actions: list[dict[str, Any]] instance: Any | None enabled: bool error: str | None = None def public(self) -> dict[str, Any]: return { "id": self.id, "name": self.name, "desc": self.desc, "version": self.version, "actions": self.actions, "enabled": self.enabled, "error": self.error, } class PluginContext: def __init__(self, app: Any): self.app = app self.db = app.db self.broadcast = app.ws.broadcast class PluginManager: def __init__(self, root: Path): self.root = root self.plugins: dict[str, LoadedPlugin] = {} def load_all(self, ctx: PluginContext) -> None: self.root.mkdir(parents=True, exist_ok=True) self.plugins = {} for path in sorted(self.root.iterdir()): if path.name.startswith("_"): continue if path.is_file() and path.suffix == ".py": self._load_path(path.stem, path, ctx) elif path.is_dir() and (path / "__init__.py").exists(): self._load_path(path.name, path / "__init__.py", ctx) def public_plugins(self) -> list[dict[str, Any]]: return [plugin.public() for plugin in self.plugins.values()] async def on_event(self, ctx: PluginContext, event: dict[str, Any]) -> None: for plugin in self.plugins.values(): if not plugin.enabled or plugin.instance is None: continue hook = getattr(plugin.instance, "on_event", None) if not hook: continue try: result = hook(ctx, event) if hasattr(result, "__await__"): await result except Exception as exc: plugin.enabled = False plugin.error = f"on_event failed: {exc}" async def execute_action(self, ctx: PluginContext, plugin_id: str, action_id: str, config: dict[str, Any], event: dict[str, Any] | None) -> None: plugin = self.plugins.get(plugin_id) if not plugin or not plugin.enabled or plugin.instance is None: raise ValueError(f"Plugin '{plugin_id}' is not available.") hook = getattr(plugin.instance, "execute_action", None) if not hook: raise ValueError(f"Plugin '{plugin_id}' does not expose execute_action.") result = hook(ctx, action_id, config, event) if hasattr(result, "__await__"): await result def _load_path(self, plugin_id: str, path: Path, ctx: PluginContext) -> None: try: module_name = f"streamdeck_plugin_{plugin_id}" spec = importlib.util.spec_from_file_location(module_name, path) if spec is None or spec.loader is None: raise RuntimeError("Could not create import spec.") module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) instance = getattr(module, "PLUGIN") plugin = LoadedPlugin( id=plugin_id, name=str(getattr(instance, "name")), desc=str(getattr(instance, "desc", "")), version=str(getattr(instance, "version", "0.1.0")), actions=list(getattr(instance, "actions", [])), instance=instance, enabled=True, ) on_load = getattr(instance, "on_load", None) if on_load: on_load(ctx) self.plugins[plugin_id] = plugin except Exception as exc: self.plugins[plugin_id] = LoadedPlugin( id=plugin_id, name=plugin_id, desc="Plugin failed to load.", version="unknown", actions=[], instance=None, enabled=False, error=str(exc), )