diff options
| -rw-r--r-- | deckit/module.py | 16 | ||||
| -rw-r--r-- | deckit/modules/obs.py | 113 | ||||
| -rw-r--r-- | deckit/server.py | 1 | ||||
| -rw-r--r-- | tests/test_obs.py | 72 |
4 files changed, 202 insertions, 0 deletions
diff --git a/deckit/module.py b/deckit/module.py index fd5997c..f1a7fa1 100644 --- a/deckit/module.py +++ b/deckit/module.py @@ -37,6 +37,22 @@ class Registry: _get = get + def start_all(self) -> None: + for m in self._modules.values(): + if m.start: + try: + m.start() + except Exception: + pass + + def stop_all(self) -> None: + for m in self._modules.values(): + if m.stop: + try: + m.stop() + except Exception: + pass + def state(self, name: str) -> dict: return self._get(name).state() diff --git a/deckit/modules/obs.py b/deckit/modules/obs.py new file mode 100644 index 0000000..f4bf71a --- /dev/null +++ b/deckit/modules/obs.py @@ -0,0 +1,113 @@ +"""OBS stream-deck: recording, scenes, audio channels, transitions and sources.""" + +import threading + +from ..module import Module, ModuleError +from ..obsclient import ObsClient + +AUDIO_KINDS = { + "pulse_input_capture", "pulse_output_capture", "alsa_input_capture", "alsa_output_capture", + "wasapi_input_capture", "wasapi_output_capture", "coreaudio_input_capture", "coreaudio_output_capture", + "sndio_input_capture", "sndio_output_capture", "jack_input_capture", "jack_output_capture", +} + + +def make(cfg: dict) -> Module: + o = cfg.get("obs", {}) + client = cfg.get("client") or ObsClient( + host=o.get("host", "127.0.0.1"), port=o.get("port", 4455), password=o.get("password", "")) + lock = threading.Lock() + stop = threading.Event() + + def req(kind, data=None): + with lock: + return client.request(kind, data) + + def connect_loop(): + while not stop.is_set(): + if not getattr(client, "connected", False): + try: + client.connect() + except Exception: + pass + stop.wait(5) + + def state(): + if not getattr(client, "connected", False): + return {"connected": False} + try: + scenes = req("GetSceneList") + rec = req("GetRecordStatus") + stream = req("GetStreamStatus") + trans = req("GetCurrentSceneTransition") + kinds = req("GetTransitionKindList").get("transitionKinds", []) + current = scenes.get("currentProgramSceneName") + sources = [] + if current: + for item in req("GetSceneItemList", {"sceneName": current}).get("sceneItems", []): + sources.append({"id": item["sceneItemId"], "name": item["sourceName"], + "on": item["sceneItemEnabled"]}) + inputs = [] + for inp in req("GetInputList").get("inputs", []): + if inp.get("inputKind") not in AUDIO_KINDS: + continue + try: + vol = req("GetInputVolume", {"inputName": inp["inputName"]}) + muted = req("GetInputMute", {"inputName": inp["inputName"]}) + except ModuleError: + continue + inputs.append({"name": inp["inputName"], + "volume": vol.get("inputVolumeMul", 0), + "muted": muted.get("inputMuted", False)}) + except ModuleError: + return {"connected": False} + return { + "connected": True, + "scenes": [s["sceneName"] for s in scenes.get("scenes", [])], + "currentScene": current, + "recording": rec.get("outputActive", False), + "recordingTime": rec.get("outputDuration", 0) / 1000, + "streaming": stream.get("outputActive", False), + "transitions": kinds, + "transition": trans.get("transitionName"), + "sources": sources, + "inputs": inputs, + } + + def scene(body): + name = body.get("scene") + if not isinstance(name, str) or not name: + raise ModuleError("scene required") + req("SetCurrentProgramScene", {"sceneName": name}) + + def record(body): + req("StartRecord" if body.get("on") else "StopRecord") + + def stream(body): + req("StartStream" if body.get("on") else "StopStream") + + def mute(body): + req("SetInputMute", {"inputName": str(body.get("input")), "inputMuted": bool(body.get("muted"))}) + + def volume(body): + req("SetInputVolume", {"inputName": str(body.get("input")), + "inputVolumeMul": float(body.get("level", 0))}) + + def transition(body): + req("SetCurrentSceneTransition", {"transitionName": str(body.get("name"))}) + + def trigger(body): + req("TriggerStudioModeTransition") + + def source(body): + req("SetSceneItemEnabled", {"sceneName": str(body.get("scene")), + "sceneItemId": int(body.get("id")), + "sceneItemEnabled": bool(body.get("on"))}) + + return Module( + name="obs", title="OBS", state=state, + actions={"scene": scene, "record": record, "stream": stream, "mute": mute, + "volume": volume, "transition": transition, "trigger": trigger, "source": source}, + start=lambda: threading.Thread(target=connect_loop, daemon=True).start(), + stop=lambda: (stop.set(), client.close()), + ) diff --git a/deckit/server.py b/deckit/server.py index f51621e..bbd92cf 100644 --- a/deckit/server.py +++ b/deckit/server.py @@ -157,6 +157,7 @@ def serve(): TOKEN = load_token() REGISTRY = build(CFG) + REGISTRY.start_all() RUNDIR.mkdir(mode=0o700, parents=True, exist_ok=True) RUNDIR.chmod(0o700) SOCKET.unlink(missing_ok=True) diff --git a/tests/test_obs.py b/tests/test_obs.py new file mode 100644 index 0000000..876d48b --- /dev/null +++ b/tests/test_obs.py @@ -0,0 +1,72 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import threading +import time +import unittest + +import deckit.modules.obs as obs + + +class Fake: + connected = True + + def request(self, kind, data=None): + Fake.last = (kind, data) + return {"ok": True} + + +class Actions(unittest.TestCase): + def setUp(self): + self.m = obs.make({"obs": {"host": "h", "port": 4455, "password": ""}, "client": Fake()}) + + def test_scene_and_record(self): + self.m.actions["scene"]({"scene": "Live"}) + self.assertEqual(Fake.last[0], "SetCurrentProgramScene") + self.m.actions["record"]({"on": True}) + self.assertEqual(Fake.last[0], "StartRecord") + self.m.actions["record"]({"on": False}) + self.assertEqual(Fake.last[0], "StopRecord") + + def test_mute_and_source(self): + self.m.actions["mute"]({"input": "Mic", "muted": True}) + self.assertEqual(Fake.last[0], "SetInputMute") + self.m.actions["source"]({"scene": "S", "id": 2, "on": False}) + self.assertEqual(Fake.last[0], "SetSceneItemEnabled") + + def test_state_offline(self): + class Off: + connected = False + self.assertEqual(obs.make({"client": Off()}).state(), {"connected": False}) + + +class Serialized(unittest.TestCase): + def test_actions_do_not_overlap(self): + active = 0 + overlap = [] + + class Guard: + connected = True + + def request(self, kind, data=None): + nonlocal active + active += 1 + if active > 1: + overlap.append(kind) + time.sleep(0.02) + active -= 1 + return {} + + m = obs.make({"client": Guard()}) + threads = [threading.Thread(target=m.actions["record"], args=({"on": True},)) for _ in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + self.assertEqual(overlap, []) + + +if __name__ == "__main__": + unittest.main() |
