Files
custom-streamdeck/backend/services/plugins.py
2026-05-10 12:46:33 +02:00

120 lines
4.1 KiB
Python

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),
)