]> skyeroc.xyz Git - obs-ctl/commitdiff
Initial
authorsbkelley <sb24kelley@gmail.com>
Fri, 28 Nov 2025 04:44:41 +0000 (23:44 -0500)
committersbkelley <sb24kelley@gmail.com>
Fri, 28 Nov 2025 04:44:41 +0000 (23:44 -0500)
.gitignore [new file with mode: 0644]
mockup.txt [new file with mode: 0644]
src/obs_ctl/__init__.py [new file with mode: 0644]
src/obs_ctl/__main__.py [new file with mode: 0644]
src/obs_ctl/const.py [new file with mode: 0644]
src/obs_ctl/data/layout.tcss [new file with mode: 0644]
src/obs_ctl/log.py [new file with mode: 0644]
src/obs_ctl/widget/__init__.py [new file with mode: 0644]
src/test/__init__.py [new file with mode: 0644]
src/test/main_test.py [new file with mode: 0644]
src/vscode_run_entrypoint.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..9eaf7a4
--- /dev/null
@@ -0,0 +1,3 @@
+.venv
+__pycache__
+.vscode
\ No newline at end of file
diff --git a/mockup.txt b/mockup.txt
new file mode 100644 (file)
index 0000000..2587a81
--- /dev/null
@@ -0,0 +1,38 @@
+messages:
+action_stop_stream
+action_start_stream
+action_stop_recording
+action_start_recording
+action_pause_recording
+action_unpause_recording
+
+labeled_number
+    h_labeled_number
+    v_labeled_number
+
+status_light
+-----------------------------------------
+|           Status | Controls           |
+|Stream:     [ ]   | Stop   Start       | req.get_record_status().output_active
+-----------------------------------------                        .output_paused
+|Recording:  [*]   | Stop   Start       | req.get_stream_status().output_active
+|                  | Pause  Unpause     |
+-----------------------------------------
+|            More Statistics            |
+-----------------------------------------
+|CPU Usage | Memory Usage |  FPS        | req.get_stats().cpu_usage in %
+|6.0%      | 298MB        |  30.00      |                .memory_usage in MB
+-----------------------------------------                .active_fps
+|Disk full in (approx): 500h                             .available_disk_space (/1024=GB) -- (req.get_stats().available_disk_space*1024*1024) / (a.output_bytes / (a.output_duration)) / 1000 / 60 / 60
+|Dropped Frames (Network): 0/24840 (0.0%) req.get_stream_status().output_skipped_frames/*_total_frames
+-----------------------------------------
+|           Frames missed               |
+-----------------------------------------
+|   Rendering Lag   |   Encoding Lag    |
+|   11/45607(0.0%)  |   0/44582 (0.0%)  |
+-----------------------------------------
+ req.get_stats().render_skipped_frames
+                .render_total_Frames
+                .output_* for encoding YES!
+
+
diff --git a/src/obs_ctl/__init__.py b/src/obs_ctl/__init__.py
new file mode 100644 (file)
index 0000000..c370fa3
--- /dev/null
@@ -0,0 +1,264 @@
+from dataclasses import dataclass
+import importlib.resources
+
+import obsws_python as obs
+import obsws_python.error as obs_error
+from textual.app import App
+from textual.containers import HorizontalGroup, VerticalGroup, Vertical, Horizontal, Container
+from textual.widgets import Footer, Header, Label, Placeholder
+
+from . import log, data
+from .widget import StreamControls, RecordControls, HorizontalLabels, VerticalLabels
+
+logger = log.getLogger(__name__)
+obs_req_client = obs.ReqClient()
+obs_evnt_client = obs.EventClient()
+
+@dataclass(frozen=True)
+class OBSStats:
+    record_output_active: bool
+    record_output_paused: bool
+    record_output_bytes: float
+    record_output_duration: float
+    stream_output_active: bool
+    stream_output_skipped_frames: float
+    stream_output_total_frames: float
+    stats_render_skipped_frames: float
+    stats_render_total_frames: float
+    stats_output_skipped_frames: float
+    stats_output_total_frames: float
+    cpu_usage_percent: float
+    memory_usage_mb: float
+    active_fps: float
+    available_disk_space: float
+
+    @property
+    def estimated_hours_remaining(self):
+        try:
+            output = (
+                (
+                    self.available_disk_space*1024*1024) / \
+                    (
+                        self.record_output_bytes / \
+                        self.record_output_duration
+                    )
+                ) / 1000 / 60 / 60
+        except:
+            output = "N/A"
+        
+        return output
+
+class OBSCtlApp(App):
+    logger.debug(importlib.resources.files(data))
+    CSS_PATH = importlib.resources.files(data).joinpath("layout.tcss")
+
+    def on_mount(self):
+        self.set_interval(1., self.process_obs_stats)
+
+
+    def compose(self):
+        yield Header()
+        yield Footer()
+        logger.debug("Composing OBSCtlApp")
+        yield Container(
+            Label("Controls", id="controls_label", classes="labels"),
+            StreamControls(id="stream_ctl", classes="controls"),
+            RecordControls(id="record_ctl", classes="controls"),
+            id="controls",
+            classes="blocks"
+        )
+        yield Container(
+            Label("Statistics", id="stats1_label", classes="labels"),
+            Horizontal(
+                VerticalLabels("CPU Usage", "-.-%", id="cpu_usage", classes="stats"),
+                VerticalLabels("Memory Usage", "-b", id="memory_usage", classes="stats"),
+                VerticalLabels("FPS", "--.--", id="fps", classes="stats")
+            ),
+            classes="blocks",
+        )
+        yield Container(
+            Label("More Statistics", id="stats2_label", classes="labels"),
+            HorizontalLabels("Disk full in (approx):", "--:--:--", id="disk_full", classes="stats"),
+            HorizontalLabels("Dropped frames (network):", "-/- (-.-%)", id="stream_dropped_frames", classes="stats"),
+            classes="blocks",
+        )
+        yield Container(
+            Label("Frames Missed", id="missed_label", classes="labels"),
+            Horizontal(
+                VerticalLabels("Rendering Lag", "-/- (-.-%)", id="rendering_lag", classes="stats"),
+                VerticalLabels("Encoding Lag", "-/- (-.-%)", id="encoding_lag", classes="stats"),
+            ),
+            classes="blocks"
+        )
+
+
+    def on_stream_controls_stopped(self, message):
+        logger.debug("Stream Stopped message rcv'd")
+        try:
+            obs_req_client.stop_stream()
+        except obs_error.OBSSDKRequestError as err:
+            if err.code == 501:
+                logger.debug("Stream already stopped.")
+            else:
+                raise
+
+
+    def on_stream_controls_started(self, message):
+        logger.debug("Stream Started message rcv'd")
+        try:
+            obs_req_client.start_stream()
+        except obs_error.OBSSDKRequestError as err:
+            if err.code == 500:
+                logger.debug("Stream already started.")
+            else:
+                raise
+
+
+    def on_record_controls_stopped(self, message):
+        logger.debug("Record Stopped message rcv'd")
+        try:
+            obs_req_client.stop_record()
+        except obs_error.OBSSDKRequestError as err:
+            if err.code == 501:
+                logger.debug("Record already stopped.")
+            else:
+                raise
+
+
+    def on_record_controls_started(self, message):
+        logger.debug("Record Started message rcv'd")
+        try:
+            obs_req_client.start_record()
+        except obs_error.OBSSDKRequestError as err:
+            if err.code == 500:
+                logger.debug("Record already started.")
+            else:
+                raise
+
+
+    def on_record_controls_paused(self, message):
+        logger.debug("Record Paused message rcv'd")
+        try:
+            obs_req_client.pause_record()
+        except obs_error.OBSSDKRequestError as err:
+            if err.code == 502:
+                logger.debug("Record already paused.")
+            else:
+                raise
+
+
+    def on_record_controls_unpaused(self, message):
+        logger.debug("Record Unpaused message rcv'd")
+        try:
+            obs_req_client.resume_record()
+        except obs_error.OBSSDKRequestError as err:
+            if err.code == 503:
+                logger.debug("Record already unpaused.")
+            else:
+                raise
+
+
+    @staticmethod
+    def get_obs_stats():
+        record_status = obs_req_client.get_record_status()
+        stream_status = obs_req_client.get_stream_status()
+        obs_stats = obs_req_client.get_stats()
+        # logger.debug(record_status)
+        # logger.debug(stream_status)
+        # logger.debug(obs_stats.attrs())
+        output = OBSStats(
+            record_output_active=record_status.output_active,
+            record_output_paused=record_status.output_paused,
+            record_output_bytes=record_status.output_bytes,
+            record_output_duration=record_status.output_duration,
+            stream_output_active=stream_status.output_active,
+            stream_output_skipped_frames=stream_status.output_skipped_frames,
+            stream_output_total_frames=stream_status.output_total_frames,
+            stats_render_skipped_frames=obs_stats.render_skipped_frames,
+            stats_render_total_frames=obs_stats.render_total_frames,
+            stats_output_skipped_frames=obs_stats.output_skipped_frames,
+            stats_output_total_frames=obs_stats.output_total_frames,
+            cpu_usage_percent=obs_stats.cpu_usage,
+            memory_usage_mb=obs_stats.memory_usage,
+            active_fps=obs_stats.active_fps,
+            available_disk_space=obs_stats.available_disk_space,
+        )
+
+        return output
+    
+
+    def update_monitors(self, obs_stats: OBSStats):
+        stream_ctl: StreamControls = self.query_exactly_one("#stream_ctl")
+        record_ctl: RecordControls = self.query_exactly_one("#record_ctl")
+        cpu_usage: VerticalLabels = self.query_exactly_one("#cpu_usage")
+        memory_usage: VerticalLabels = self.query_exactly_one("#memory_usage")
+        active_fp: VerticalLabels = self.query_exactly_one("#fps")
+        disk_full: HorizontalLabels = self.query_exactly_one("#disk_full")
+        stream_dropped_frames: HorizontalLabels = self.query_exactly_one("#stream_dropped_frames")
+        rendering_lag: VerticalLabels = self.query_exactly_one("#rendering_lag")
+        encoding_lag: VerticalLabels = self.query_exactly_one("#encoding_lag")
+
+        stream_ctl.set_light(obs_stats.stream_output_active)
+        record_ctl.set_light(obs_stats.record_output_active)
+        record_ctl.set_pause_light(obs_stats.record_output_paused)
+        cpu_usage.text = f"{obs_stats.cpu_usage_percent:0.5}%"
+        memory_usage.text = f"{obs_stats.memory_usage_mb:0.5}MB"
+        active_fp.text = f"{obs_stats.active_fps}"
+        disk_full.text = f"{obs_stats.estimated_hours_remaining:0.5} hours"
+        stream_dropped_frames.text = f"{obs_stats.stream_output_skipped_frames}/{obs_stats.stream_output_total_frames}"
+        rendering_lag.text = f"{obs_stats.stats_render_skipped_frames}/{obs_stats.stats_render_total_frames}"
+        encoding_lag.text = f"{obs_stats.stats_output_skipped_frames}/{obs_stats.stats_output_total_frames}"
+
+
+    def process_obs_stats(self):
+        logger.debug("Processing OBS stats")
+        stats = self.get_obs_stats()
+        self.update_monitors(stats)
+
+
+class OBSLayoutTest(App):
+    CSS_PATH = importlib.resources.files(data).joinpath("layout.tcss")
+    def compose(self):
+        yield Header()
+        yield Footer()
+        logger.debug("Composing OBSCtlApp")
+        yield Vertical(
+                Placeholder("Controls Label", id="controls_label", classes="labels"),
+                Placeholder("SteamControls", id="stream_ctl", classes="controls"),
+                Placeholder("RecordControls", id="record_ctl", classes="controls"),
+                id="controls",
+                classes="block"
+            )
+        yield Vertical(
+                Placeholder("Statistics Label", id="stats1_label", classes="labels"),
+                Horizontal(
+                    Placeholder("CPU Usage", id="cpu_usage", classes="stats"),
+                    Placeholder("Memory Usage", id="memory_usage", classes="stats"),
+                    Placeholder("FPS", id="fps", classes="stats"),
+                    classes="block",
+                )
+            )
+        yield Vertical(
+            Placeholder("More Statistics Label", id="stats2_label", classes="labels"),
+            Placeholder("Disk full in (approx):", id="disk_full", classes="stats"),
+            Placeholder("/Dropped frames (network):", id="stream_dropped_frames", classes="stats"),
+            classes="blocks",
+        )
+        yield Vertical(
+            Placeholder("Frames Missed Label", id="missed_label", classes="labels"),
+            Horizontal(
+                Placeholder("Rendering Lag", id="rendering_lag", classes="stats"),
+                Placeholder("Encoding Lag",  id="encoding_lag", classes="stats"),
+                classes="stats"
+            ),
+            classes="blocks"
+        )
+
+
+def main():
+    app = OBSCtlApp()
+    # app = OBSLayoutTest()
+    logger.debug("App entrypoint reached")
+    logger.debug(f"Request Client: {obs_req_client}")
+    logger.debug(f"Event Client: {obs_evnt_client}")
+    app.run()
\ No newline at end of file
diff --git a/src/obs_ctl/__main__.py b/src/obs_ctl/__main__.py
new file mode 100644 (file)
index 0000000..878197e
--- /dev/null
@@ -0,0 +1,4 @@
+from . import main
+
+if __name__ == "__main__":
+    main()
\ No newline at end of file
diff --git a/src/obs_ctl/const.py b/src/obs_ctl/const.py
new file mode 100644 (file)
index 0000000..a91231b
--- /dev/null
@@ -0,0 +1,2 @@
+APPNAME = "obs-ctl"
+APPAUTHOR = "skyebee"
\ No newline at end of file
diff --git a/src/obs_ctl/data/layout.tcss b/src/obs_ctl/data/layout.tcss
new file mode 100644 (file)
index 0000000..0d872bb
--- /dev/null
@@ -0,0 +1,27 @@
+OBSCtlApp {
+    layout: grid;
+    grid-size: 2 2;
+}
+
+StreamControls {
+    layout:horizontal;
+    max-height:1;
+    & Button {
+        width:1fr;
+    }
+}
+RecordControls {
+    layout:vertical;
+    max-height:2;
+    & Button {
+        width:1fr;
+    }
+    # & RadioButton {
+    #     max-width:20;
+    # }
+}
+
+VerticalLabels {
+    background:#ffffff;
+    max-width:5;
+}
\ No newline at end of file
diff --git a/src/obs_ctl/log.py b/src/obs_ctl/log.py
new file mode 100644 (file)
index 0000000..31d9d66
--- /dev/null
@@ -0,0 +1,32 @@
+import logging
+from pathlib import Path
+
+import platformdirs
+
+from .const import APPNAME, APPAUTHOR
+
+VERBOSE_LEVELS = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]
+
+dirs = platformdirs.PlatformDirs(APPNAME, APPAUTHOR, ensure_exists=True)
+logfile_fmt = logging.Formatter('%(asctime)s : %(name)s : %(levelname)s : %(message)s')
+logfile_hdlr = logging.FileHandler(Path(dirs.user_log_dir).joinpath("projectstruct.log"))
+logfile_hdlr.setFormatter(logfile_fmt)
+logfile_hdlr.setLevel("DEBUG")
+
+console_fmt = logging.Formatter('[%(levelname)s] %(message)s')
+console_hdlr = logging.StreamHandler()
+console_hdlr.setFormatter(console_fmt)
+console_hdlr.setLevel("ERROR")
+console_hdlr.set_name("console")
+
+logging.basicConfig(
+        handlers=[logfile_hdlr, console_hdlr],
+        level="DEBUG",
+)
+
+only_me_filter = logging.Filter("obs_ctl")
+console_hdlr.addFilter(only_me_filter)
+logfile_hdlr.addFilter(only_me_filter)
+
+def getLogger(name):
+    return logging.getLogger(name)
\ No newline at end of file
diff --git a/src/obs_ctl/widget/__init__.py b/src/obs_ctl/widget/__init__.py
new file mode 100644 (file)
index 0000000..d5750e4
--- /dev/null
@@ -0,0 +1,200 @@
+from textual.app import App, ComposeResult
+from textual.containers import Horizontal, Vertical, Grid, Container
+from textual.message import Message
+from textual.reactive import reactive
+from textual.widgets import Label, RadioButton, Button
+from textual.widget import Widget
+
+from .. import log
+
+logger = log.getLogger(__name__)
+
+
+class SRControls(Widget):
+    light_active = reactive(False)
+    class MsgBase(Message):
+        def __init__(self, srcontrols: "SRControls"):
+            logger.debug(f"{self} sent from {srcontrols}")
+            self.srcontrol = srcontrols
+            super().__init__()
+
+    def on_button_pressed(self, event: Button.Pressed):
+        # light: RadioButton = self.query_exactly_one("#light")
+        if event.button.id == "start":
+            logger.debug(f"Start pressed on {self}")
+            self.post_message(self.Started(self))
+            # light.value = True
+        elif event.button.id == "stop":
+            logger.debug(f"Stop pressed on {self}")
+            self.post_message(self.Stopped(self))
+            # light.value = False
+
+
+    def set_light(self, value: bool) -> None:
+        light: RadioButton = self.query_exactly_one("#light")
+        light.value = value
+
+
+class StreamControls(SRControls):
+    # DEFAULT_CSS = """
+    # StreamControls {
+    #     layout: grid;
+    #     grid-size:3 1;
+    #     height:1fr;
+    #     width:1fr;
+    #     # grid-columns:2fr 1fr 1fr;
+    #     # grid-rows:1fr;
+    # }
+    # """
+    class Started(SRControls.MsgBase): ...
+    class Stopped(SRControls.MsgBase): ...
+    def compose(self) -> ComposeResult:
+        logger.debug("Composing StreamControls")
+        yield RadioButton(
+            label="Streaming",
+            value=False,
+            button_first=False,
+            id="light",
+            compact=True,
+            disabled=True
+        )
+        yield Button(
+                label="Start",
+                id="start",
+                variant="success",
+                compact=True,
+        )
+        yield Button(
+                label="Stop",
+                id="stop",
+                variant="error",
+                compact=True,
+        )
+
+
+class RecordControls(SRControls):
+    # DEFAULT_CSS = """
+    # RecordControls {
+    #     layout: grid;
+    #     grid-size:3 2;
+    #     height:1fr;
+    #     width:1fr;
+    #     grid-columns: 2fr 1fr 1fr;
+    #     grid-rows: 1fr 1fr;
+    # }
+    # """
+
+    class Started(SRControls.MsgBase): ...
+    class Stopped(SRControls.MsgBase): ...
+    class Paused(SRControls.MsgBase): ...
+    class Unpaused(SRControls.MsgBase): ...
+
+
+    def compose(self):
+        logger.debug("Composing RecordControls")
+        with Horizontal():
+            yield RadioButton(
+                label="Recording",
+                value=False,
+                button_first=False,
+                compact=True,
+                disabled=True,
+                id="light",
+            )
+            yield Button(
+                    "Start",
+                    id="start",
+                    variant="success",
+                    compact = True,
+            )
+            yield Button(
+                    "Stop",
+                    id="stop",
+                    variant="error",
+                    compact=True,
+            )            
+        with Container():
+            yield RadioButton(
+                label="Paused",
+                value=False,
+                button_first=False,
+                id="pause_light",
+                compact=True,
+                disabled=True
+            )
+            yield Button(
+                    "Pause",
+                    id="pause_record",
+                    variant="error",
+                    compact=True,
+            )
+            yield Button(
+                    "Unpause",
+                    id="unpause_record",
+                    variant="success",
+                    compact=True,
+            )
+
+
+    def on_button_pressed(self, event: Button.Pressed):
+        if event.button.id == "pause_record":
+            logger.debug(f"Pause pressed on {self}")
+            self.post_message(self.Paused(self))
+        elif event.button.id == "unpause_record":
+            logger.debug(f"Unpause pressed on {self}")
+            self.post_message(self.Unpaused(self))
+
+
+    def set_pause_light(self, value):
+        light: RadioButton = self.query_exactly_one("#pause_light")
+        light.value = value
+
+
+class HorizontalLabels(Widget):
+    # DEFAULT_CSS = """
+    # HorizontalLabels {
+    #     layout: horizontal;
+    #     height:1fr;
+    #     width:1fr;
+    # }
+    # """
+    text = reactive("")
+    def __init__(self, label_one, label_two, *children, name = None, id = None, classes = None, disabled = False, markup = True):
+        self._label_one_param = label_one
+        self._label_two_param = label_two
+        super().__init__(*children, name=name, id=id, classes=classes, disabled=disabled, markup=markup)
+
+
+    def compose(self):
+        yield Label(self._label_one_param, id="constant")
+        yield Label(self._label_two_param, id="variable")
+    
+
+    def watch_text(self, text: str):
+        logger.debug(f"Setting label {self} text to {text}")
+        label: Label = self.query_exactly_one("#variable")
+        label.update(text)
+
+class VerticalLabels(Widget):
+    # DEFAULT_CSS = """
+    # VerticalLabels {
+    #     layout: vertical;
+    #     height: 1fr;
+    #     width:1fr;
+    # }
+    # """
+    text = reactive("")
+    def __init__(self, label_one, label_two, *children, name = None, id = None, classes = None, disabled = False, markup = True):
+        self._label_one_param = label_one
+        self._label_two_param = label_two
+        super().__init__(*children, name=name, id=id, classes=classes, disabled=disabled, markup=markup)
+
+    def compose(self):
+        yield Label(self._label_one_param, id="constant")
+        yield Label(self._label_two_param, id="variable")
+    
+
+    def watch_text(self, text: str):
+        logger.debug(f"Setting label {self} text to {text}")
+        label: Label = self.query_exactly_one("#variable")
+        label.update(text)
\ No newline at end of file
diff --git a/src/test/__init__.py b/src/test/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/test/main_test.py b/src/test/main_test.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/vscode_run_entrypoint.py b/src/vscode_run_entrypoint.py
new file mode 100644 (file)
index 0000000..e87038c
--- /dev/null
@@ -0,0 +1,3 @@
+import obs_ctl
+
+obs_ctl.main()
\ No newline at end of file