commit 8ce9a80eedfc32e38d9cdf413002efd3bf3fc57a Author: onyx-and-iris Date: Sat Apr 19 20:15:26 2025 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc6c141 --- /dev/null +++ b/.gitignore @@ -0,0 +1,178 @@ +# Auto-generated .gitignore by gignore: github.com/onyx-and-iris/gignore + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.hatch + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of gignore: github.com/onyx-and-iris/gignore + +test-*.py \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..711c8a7 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025-present onyx-and-iris + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..823d94c --- /dev/null +++ b/README.md @@ -0,0 +1,233 @@ +# obsws-cli + +[![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) + +----- + +## Table of Contents + +- [Installation](#installation) +- [Configuration](#configuration) +- [License](#license) + +## Requirements + +- Python 3.10 or greater +- [OBS Studio 28+][obs-studio] + +## Installation + +##### *with uv* + +```console +uv tool install . +``` + +##### *with pipx* + +```console +pipx install . +``` + +The CLI should now be discoverable as `obsws-cli` + +## Configuration + +#### Flags + +Pass `--host`, `--port` and `--password` as flags to the root command, for example: + +```console +obsws-cli --host=localhost --port=4455 --password= --help +``` + +#### Environment Variables + +Store and load environment variables from: +- A `.env` file in the cwd +- `user home directory / .config / obsws-cli / obsws.env` + +```env +OBSWS_HOST=localhost +OBSWS_PORT=4455 +OBSWS_PASSWORD= +``` + +Flags can be used to override environment variables. + +## Root Typer + +- version: Get the OBS Client and WebSocket versions. + +```console +obsws-cli version +``` + +## Sub Typers + +#### Scene + +- list: List all scenes. + +```console +obsws-cli scene list +``` + +- current: Get the current program scene. + +```console +obsws-cli scene current +``` + +- switch: Switch to a scene. + - args: + +```console +obsws-cli scene switch LIVE +``` + +#### Item + +- show: Show an item in a scene. + - args: + +```console +obsws-cli item show START "Colour Source" +``` + +- hide: Hide an item in a scene. + - args: + +```console +obsws-cli item hide START "Colour Source" +``` + +- list: List all items in a scene. + - args: + +```console +obsws-cli item list LIVE +``` + +#### Group + +- show: Show a group in a scene. + - args: + +```console +obsws-cli group show START "test_group" +``` + +- hide: Hide a group in a scene. + - args: + +```console +obsws-cli group hide START "test_group" +``` + +- list: List groups in a scene. + - args: + +```console +obsws-cli group list START +``` + +#### Input + +- list: List all inputs. + +```console +obsws-cli input list +``` + +- mute: Mute an input. + - args: + +```console +obsws-cli input mute "Mic/Aux" +``` + +- unmute: Unmute an input. + - args: + +```console +obsws-cli input unmute "Mic/Aux" +``` + +- toggle: Toggle an input. + +```console +obsws-cli input toggle "Mic/Aux" +``` + +#### Record + +- start: Start recording. + +```console +obsws-cli record start +``` + +- stop: Stop recording. + +```console +obsws-cli record stop +``` + +- status: Get recording status. + +```console +obsws-cli record status +``` + +- toggle: Toggle recording. + +```console +obsws-cli record toggle +``` + +- resume: Resume recording. + +```console +obsws-cli record resume +``` + +- pause: Pause recording. + +```console +obsws-cli record pause +``` + +#### Stream + +- start: Start streaming. + +```console +obsws-cli stream start +``` + +- stop: Stop streaming. + +```console +obsws-cli stream stop +``` + +- status: Get streaming status. + +```console +obsws-cli stream status +``` + +- toggle: Toggle streaming. + +```console +obsws-cli stream toggle +``` + +## License + +`obsws-cli` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. + + +[obs-studio]: https://obsproject.com/ \ No newline at end of file diff --git a/obsws_cli/__about__.py b/obsws_cli/__about__.py new file mode 100644 index 0000000..081c3ae --- /dev/null +++ b/obsws_cli/__about__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2025-present onyx-and-iris +# +# SPDX-License-Identifier: MIT +__version__ = "0.1.0" diff --git a/obsws_cli/__init__.py b/obsws_cli/__init__.py new file mode 100644 index 0000000..c412b50 --- /dev/null +++ b/obsws_cli/__init__.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2025-present onyx-and-iris +# +# SPDX-License-Identifier: MIT + +from .app import app + +__all__ = ["app"] diff --git a/obsws_cli/alias.py b/obsws_cli/alias.py new file mode 100644 index 0000000..e3e13e6 --- /dev/null +++ b/obsws_cli/alias.py @@ -0,0 +1,23 @@ +"""module defining a custom group class for handling command name aliases.""" + +import re + +import typer + + +class AliasGroup(typer.core.TyperGroup): + """A custom group class to handle command name aliases.""" + + _CMD_SPLIT_P = re.compile(r' ?[,|] ?') + + def get_command(self, ctx, cmd_name): + """Get a command by name.""" + cmd_name = self._group_cmd_name(cmd_name) + return super().get_command(ctx, cmd_name) + + def _group_cmd_name(self, default_name): + for cmd in self.commands.values(): + name = cmd.name + if name and default_name in self._CMD_SPLIT_P.split(name): + return name + return default_name diff --git a/obsws_cli/app.py b/obsws_cli/app.py new file mode 100644 index 0000000..8aef374 --- /dev/null +++ b/obsws_cli/app.py @@ -0,0 +1,78 @@ +"""Command line interface for the OBS WebSocket API.""" + +from pathlib import Path +from typing import Annotated + +import obsws_python as obsws +import typer +from pydantic import ConfigDict +from pydantic_settings import BaseSettings + +from . import group, input, item, record, scene, stream + + +class Settings(BaseSettings): + """Settings for the OBS WebSocket client.""" + + model_config = ConfigDict( + env_file=( + '.env', + Path.home() / '.config' / 'obsws-cli' / 'obsws.env', + ), + env_file_encoding='utf-8', + env_prefix='OBSWS_', + ) + + HOST: str = 'localhost' + PORT: int = 4455 + PASSWORD: str = '' # No password by default + TIMEOUT: int = 5 # Timeout for requests in seconds + + +app = typer.Typer() +app.add_typer(scene.app, name='scene') +app.add_typer(item.app, name='item') +app.add_typer(group.app, name='group') +app.add_typer(input.app, name='input') +app.add_typer(record.app, name='record') +app.add_typer(stream.app, name='stream') + + +@app.command() +def version(ctx: typer.Context): + """Get the OBS Client and WebSocket versions.""" + resp = ctx.obj['obsws'].get_version() + typer.echo( + f'OBS Client version: {resp.obs_version} with WebSocket version: {resp.obs_web_socket_version}' + ) + + +@app.callback() +def main( + ctx: typer.Context, + host: Annotated[str, typer.Option(help='WebSocket host')] = None, + port: Annotated[int, typer.Option(help='WebSocket port')] = None, + password: Annotated[str, typer.Option(help='WebSocket password')] = None, + timeout: Annotated[int, typer.Option(help='WebSocket timeout')] = None, +): + """obsws_cli is a command line interface for the OBS WebSocket API.""" + settings = Settings() + # Allow overriding settings with command line options + if host: + settings.HOST = host + if port: + settings.PORT = port + if password: + settings.PASSWORD = password + if timeout: + settings.TIMEOUT = timeout + + ctx.obj = ctx.ensure_object(dict) + ctx.obj['obsws'] = ctx.with_resource( + obsws.ReqClient( + host=settings.HOST, + port=settings.PORT, + password=settings.PASSWORD, + timeout=settings.TIMEOUT, + ) + ) diff --git a/obsws_cli/errors.py b/obsws_cli/errors.py new file mode 100644 index 0000000..08525ff --- /dev/null +++ b/obsws_cli/errors.py @@ -0,0 +1,18 @@ +"""Exceptions for obsws_cli.""" + + +class ObswsCliError(Exception): + """Base class for all exceptions raised by obsws_cli.""" + + def __init__(self, message: str): + """Initialize the exception with a message.""" + message = ( + message.split('With message: ')[1] + if 'With message: ' in message + else message + ) + super().__init__(message) + + +class ObswsCliBadParameter(ObswsCliError): + """Exception raised when a bad parameter is passed to a command.""" diff --git a/obsws_cli/group.py b/obsws_cli/group.py new file mode 100644 index 0000000..f3ff7e6 --- /dev/null +++ b/obsws_cli/group.py @@ -0,0 +1,81 @@ +"""module containing commands for manipulating groups in scenes.""" + +import obsws_python as obsws +import typer + +from .alias import AliasGroup +from .errors import ObswsCliBadParameter +from .protocols import DataclassProtocol + +app = typer.Typer(cls=AliasGroup) + + +@app.callback() +def main(): + """Control groups in OBS scenes.""" + + +def _get_group(group_name: str, resp: DataclassProtocol) -> dict | None: + """Get a group from the scene item list response.""" + group = next( + ( + item + for item in resp.scene_items + if item.get('sourceName') == group_name and item.get('isGroup') + ), + None, + ) + return group + + +@app.command() +def show(ctx: typer.Context, scene_name: str, group_name: str): + """Show a group in a scene.""" + try: + resp = ctx.obj['obsws'].get_scene_item_list(scene_name) + if (group := _get_group(group_name, resp)) is None: + raise ObswsCliBadParameter(f"Group '{group_name}' not found in scene.") + + ctx.obj['obsws'].set_scene_item_enabled( + scene_name=scene_name, + item_id=int(group.get('sceneItemId')), + enabled=True, + ) + except obsws.error.OBSSDKRequestError as e: + if e.code == 600: + raise ObswsCliBadParameter(str(e)) from e + raise + + +@app.command() +def hide(ctx: typer.Context, scene_name: str, group_name: str): + """Hide a group in a scene.""" + try: + resp = ctx.obj['obsws'].get_scene_item_list(scene_name) + if (group := _get_group(group_name, resp)) is None: + raise ObswsCliBadParameter(f"Group '{group_name}' not found in scene.") + + ctx.obj['obsws'].set_scene_item_enabled( + scene_name=scene_name, + item_id=int(group.get('sceneItemId')), + enabled=False, + ) + except obsws.error.OBSSDKRequestError as e: + if e.code == 600: + raise ObswsCliBadParameter(str(e)) from e + raise + + +@app.command('list | ls') +def list(ctx: typer.Context, scene_name: str): + """List groups in a scene.""" + try: + resp = ctx.obj['obsws'].get_scene_item_list(scene_name) + groups = ( + item.get('sourceName') for item in resp.scene_items if item.get('isGroup') + ) + typer.echo('\n'.join(groups)) + except obsws.error.OBSSDKRequestError as e: + if e.code == 600: + raise ObswsCliBadParameter(str(e)) from e + raise diff --git a/obsws_cli/input.py b/obsws_cli/input.py new file mode 100644 index 0000000..55fae5a --- /dev/null +++ b/obsws_cli/input.py @@ -0,0 +1,89 @@ +"""module containing commands for manipulating inputs.""" + +import obsws_python as obsws +import typer + +from .alias import AliasGroup +from .errors import ObswsCliBadParameter +from .protocols import DataclassProtocol + +app = typer.Typer(cls=AliasGroup) + + +@app.callback() +def main(): + """Control inputs in OBS.""" + + +@app.command('ls') +def list(ctx: typer.Context): + """List all inputs.""" + resp = ctx.obj['obsws'].get_input_list() + inputs = (input.get('inputName') for input in resp.inputs) + typer.echo('\n'.join(inputs)) + + +def _get_input(input_name: str, resp: DataclassProtocol) -> dict | None: + """Get an input from the input list response.""" + input_ = next( + (input_ for input_ in resp.inputs if input_.get('inputName') == input_name), + None, + ) + + return input_ + + +@app.command() +def mute(ctx: typer.Context, input_name: str): + """Mute an input.""" + try: + resp = ctx.obj['obsws'].get_input_list() + if (input_ := _get_input(input_name, resp)) is None: + raise ObswsCliBadParameter(f"Input '{input_name}' not found.") + + ctx.obj['obsws'].set_input_mute( + name=input_.get('inputName'), + muted=True, + ) + except obsws.error.OBSSDKRequestError as e: + if e.code == 600: + raise ObswsCliBadParameter(str(e)) from e + raise + + +@app.command() +def unmute(ctx: typer.Context, input_name: str): + """Unmute an input.""" + try: + resp = ctx.obj['obsws'].get_input_list() + if (input_ := _get_input(input_name, resp)) is None: + raise ObswsCliBadParameter(f"Input '{input_name}' not found.") + + ctx.obj['obsws'].set_input_mute( + name=input_.get('inputName'), + muted=False, + ) + except obsws.error.OBSSDKRequestError as e: + if e.code == 600: + raise ObswsCliBadParameter(str(e)) from e + raise + + +@app.command() +def toggle(ctx: typer.Context, input_name: str): + """Toggle an input.""" + try: + resp = ctx.obj['obsws'].get_input_list() + if (input_ := _get_input(input_name, resp)) is None: + raise ObswsCliBadParameter(f"Input '{input_name}' not found.") + + resp = ctx.obj['obsws'].get_input_mute(name=input_.get('inputName')) + + ctx.obj['obsws'].set_input_mute( + name=input_.get('inputName'), + muted=not resp.input_muted, + ) + except obsws.error.OBSSDKRequestError as e: + if e.code == 600: + raise ObswsCliBadParameter(str(e)) from e + raise diff --git a/obsws_cli/item.py b/obsws_cli/item.py new file mode 100644 index 0000000..d57f8ae --- /dev/null +++ b/obsws_cli/item.py @@ -0,0 +1,61 @@ +"""module containing commands for manipulating items in scenes.""" + +import obsws_python as obsws +import typer + +from .alias import AliasGroup +from .errors import ObswsCliBadParameter + +app = typer.Typer(cls=AliasGroup) + + +@app.callback() +def main(): + """Control items in OBS scenes.""" + + +@app.command() +def show(ctx: typer.Context, scene_name: str, item_name: str): + """Show an item in a scene.""" + try: + resp = ctx.obj['obsws'].get_scene_item_id(scene_name, item_name) + + ctx.obj['obsws'].set_scene_item_enabled( + scene_name=scene_name, + item_id=int(resp.scene_item_id), + enabled=True, + ) + except obsws.error.OBSSDKRequestError as e: + if e.code == 600: + raise ObswsCliBadParameter(str(e)) from e + raise + + +@app.command() +def hide(ctx: typer.Context, scene_name: str, item_name: str): + """Hide an item in a scene.""" + try: + resp = ctx.obj['obsws'].get_scene_item_id(scene_name, item_name) + + ctx.obj['obsws'].set_scene_item_enabled( + scene_name=scene_name, + item_id=int(resp.scene_item_id), + enabled=False, + ) + except obsws.error.OBSSDKRequestError as e: + if e.code == 600: + raise ObswsCliBadParameter(str(e)) from e + raise + + +@app.command('list | ls') +def list(ctx: typer.Context, scene_name: str): + """List all items in a scene.""" + try: + resp = ctx.obj['obsws'].get_scene_item_list(scene_name) + items = (item.get('sourceName') for item in resp.scene_items) + typer.echo('\n'.join(items)) + except obsws.error.OBSSDKRequestError as e: + if e.code == 600: + raise ObswsCliBadParameter(str(e)) from e + raise diff --git a/obsws_cli/protocols.py b/obsws_cli/protocols.py new file mode 100644 index 0000000..cc08327 --- /dev/null +++ b/obsws_cli/protocols.py @@ -0,0 +1,10 @@ +"""module defining protocols for type hinting.""" + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + + +@runtime_checkable +@dataclass +class DataclassProtocol(Protocol): + """A protocol for dataclass-like structures.""" diff --git a/obsws_cli/record.py b/obsws_cli/record.py new file mode 100644 index 0000000..125089f --- /dev/null +++ b/obsws_cli/record.py @@ -0,0 +1,110 @@ +"""module for controlling OBS recording functionality.""" + +import obsws_python as obsws +import typer + +from .errors import ObswsCliError + +app = typer.Typer() + + +@app.callback() +def main(): + """Control OBS recording functionality.""" + + +@app.command() +def start(ctx: typer.Context): + """Start recording.""" + try: + ctx.obj['obsws'].start_record() + typer.echo('Recording started successfully.') + except obsws.error.OBSSDKRequestError as e: + if e.code == 500: + raise ObswsCliError( + 'Recording is already in progress, cannot start.' + ) from e + raise + + +@app.command() +def stop(ctx: typer.Context): + """Stop recording.""" + try: + ctx.obj['obsws'].stop_record() + typer.echo('Recording stopped successfully.') + except obsws.error.OBSSDKRequestError as e: + if e.code == 501: + raise ObswsCliError('Recording is not in progress, cannot stop.') from e + raise + + +def _get_recording_status(ctx: typer.Context) -> tuple: + """Get recording status.""" + resp = ctx.obj['obsws'].get_record_status() + return resp.output_active, resp.output_paused + + +@app.command() +def status(ctx: typer.Context): + """Get recording status.""" + active, paused = _get_recording_status(ctx) + if active: + if paused: + typer.echo('Recording is in progress and paused.') + else: + typer.echo('Recording is in progress.') + else: + typer.echo('Recording is not in progress.') + + +@app.command() +def toggle(ctx: typer.Context): + """Toggle recording.""" + active, _ = _get_recording_status(ctx) + if active: + try: + ctx.obj['obsws'].stop_record() + typer.echo('Recording stopped successfully.') + except obsws.error.OBSSDKRequestError as e: + raise ObswsCliError(str(e)) from e + else: + try: + ctx.obj['obsws'].start_record() + typer.echo('Recording started successfully.') + except obsws.error.OBSSDKRequestError as e: + raise ObswsCliError(str(e)) from e + + +@app.command() +def resume(ctx: typer.Context): + """Resume recording.""" + active, paused = _get_recording_status(ctx) + if not active: + raise ObswsCliError('Recording is not in progress, cannot resume.') + if not paused: + raise ObswsCliError('Recording is in progress but not paused, cannot resume.') + + try: + ctx.obj['obsws'].resume_record() + typer.echo('Recording resumed successfully.') + except obsws.error.OBSSDKRequestError as e: + raise ObswsCliError(str(e)) from e + + +@app.command() +def pause(ctx: typer.Context): + """Pause recording.""" + active, paused = _get_recording_status(ctx) + if not active: + raise ObswsCliError('Recording is not in progress, cannot pause.') + if paused: + raise ObswsCliError( + 'Recording is in progress but already paused, cannot pause.' + ) + + try: + ctx.obj['obsws'].pause_record() + typer.echo('Recording paused successfully.') + except obsws.error.OBSSDKRequestError as e: + raise ObswsCliError(str(e)) from e diff --git a/obsws_cli/scene.py b/obsws_cli/scene.py new file mode 100644 index 0000000..077c41a --- /dev/null +++ b/obsws_cli/scene.py @@ -0,0 +1,40 @@ +"""module containing commands for controlling OBS scenes.""" + +import obsws_python as obsws +import typer + +from .alias import AliasGroup +from .errors import ObswsCliBadParameter + +app = typer.Typer(cls=AliasGroup) + + +@app.callback() +def main(): + """Control OBS scenes.""" + + +@app.command('list | ls') +def list(ctx: typer.Context): + """List all scenes.""" + resp = ctx.obj['obsws'].get_scene_list() + scenes = (scene.get('sceneName') for scene in reversed(resp.scenes)) + typer.echo('\n'.join(scenes)) + + +@app.command('current | get') +def current(ctx: typer.Context): + """Get the current program scene.""" + resp = ctx.obj['obsws'].get_current_program_scene() + typer.echo(resp.current_program_scene_name) + + +@app.command('switch | set') +def switch(ctx: typer.Context, scene_name: str): + """Switch to a scene.""" + try: + ctx.obj['obsws'].set_current_program_scene(scene_name) + except obsws.error.OBSSDKRequestError as e: + if e.code == 600: + raise ObswsCliBadParameter(f"Scene '{scene_name}' not found.") + raise diff --git a/obsws_cli/stream.py b/obsws_cli/stream.py new file mode 100644 index 0000000..6ecad62 --- /dev/null +++ b/obsws_cli/stream.py @@ -0,0 +1,87 @@ +"""module for controlling OBS stream functionality.""" + +import obsws_python as obsws +import typer + +from .errors import ObswsCliError + +app = typer.Typer() + + +@app.callback() +def main(): + """Control OBS stream functionality.""" + + +@app.command() +def start(ctx: typer.Context): + """Start streaming.""" + try: + ctx.obj['obsws'].start_stream() + typer.echo('Streaming started successfully.') + except obsws.error.OBSSDKRequestError as e: + if e.code == 500: + raise ObswsCliError( + 'Streaming is already in progress, cannot start.' + ) from e + raise + + +@app.command() +def stop(ctx: typer.Context): + """Stop streaming.""" + try: + ctx.obj['obsws'].stop_stream() + typer.echo('Streaming stopped successfully.') + except obsws.error.OBSSDKRequestError as e: + if e.code == 501: + raise ObswsCliError('Streaming is not in progress, cannot stop.') from e + raise + + +def _get_streaming_status(ctx: typer.Context) -> tuple: + """Get streaming status.""" + resp = ctx.obj['obsws'].get_stream_status() + return resp.output_active, resp.output_duration + + +@app.command() +def status(ctx: typer.Context): + """Get streaming status.""" + active, duration = _get_streaming_status(ctx) + if active: + if duration > 0: + seconds = duration / 1000 + minutes = int(seconds // 60) + seconds = int(seconds % 60) + if minutes > 0: + typer.echo( + f'Streaming is in progress for {minutes} minutes and {seconds} seconds.' + ) + else: + if seconds > 0: + typer.echo(f'Streaming is in progress for {seconds} seconds.') + else: + typer.echo('Streaming is in progress for less than a second.') + else: + typer.echo('Streaming is in progress.') + else: + typer.echo('Streaming is not in progress.') + + +@app.command() +def toggle(ctx: typer.Context): + """Toggle streaming.""" + active, _ = _get_streaming_status(ctx) + if active: + try: + ctx.obj['obsws'].stop_stream() + typer.echo('Streaming stopped successfully.') + except obsws.error.OBSSDKRequestError as e: + raise ObswsCliError(str(e)) from e + else: + try: + ctx.obj['obsws'].start_stream() + typer.echo('Streaming started successfully.') + except obsws.error.OBSSDKRequestError as e: + raise ObswsCliError(str(e)) from e diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..469db82 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "obsws-cli" +dynamic = ["version"] +description = 'A command line interface for the OBS WebSocket API.' +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +keywords = ["obs", "obs-websocket", "obs-websocket-api", "cli", "command-line"] +authors = [{ name = "onyx-and-iris", email = "code@onyxandiris.online" }] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [ + "typer>=0.15.2", + "obsws-python>=1.7.1", + "pydantic-settings>=2.9.1", +] + +[project.urls] +Documentation = "https://github.com/onyx-and-iris/obsws-cli#readme" +Issues = "https://github.com/onyx-and-iris/obsws-cli/issues" +Source = "https://github.com/onyx-and-iris/obsws-cli" + +[project.scripts] +obsws-cli = "obsws_cli:app" + +[tool.hatch.version] +path = "obsws_cli/__about__.py" + +[tool.hatch.envs.default.scripts] +test = "pytest {args:obsws_cli tests}" + +[tool.hatch.envs.hatch-test] +dependencies = ["pytest>=8.3.5", "pytest-dotenv"] + +[tool.hatch.envs.types] +extra-dependencies = ["mypy>=1.0.0"] +[tool.hatch.envs.types.scripts] +check = "mypy --install-types --non-interactive {args:obsws_cli tests}" diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..a9a41b4 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,78 @@ +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] + +# Same as Black. +line-length = 88 +indent-width = 4 + +# Assume Python 3.9 +target-version = "py39" + +[lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or +# McCabe complexity (`C901`) by default. +# Enable pydocstyle (`D`) codes by default. +select = ["E4", "E7", "E9", "F", "D"] +ignore = [] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[format] +# Unlike Black, use single quotes for strings. +quote-style = "single" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" + +# Enable auto-formatting of code examples in docstrings. Markdown, +# reStructuredText code/literal blocks and doctests are all supported. +# +# This is currently disabled by default, but it is planned for this +# to be opt-out in the future. +docstring-code-format = false + +# Set the line length limit used when formatting code snippets in +# docstrings. +# +# This only has an effect when the `docstring-code-format` setting is +# enabled. +docstring-code-line-length = "dynamic" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3ae73e9 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present onyx-and-iris +# +# SPDX-License-Identifier: MIT diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6c3ec8e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,74 @@ +"""pytest configuration file.""" + +import os + +import obsws_python as obsws + + +def pytest_configure(config): + """Call after command line options are parsed. + + All plugins and initial conftest files are loaded. + """ + + +def pytest_sessionstart(session): + """Call after the Session object is created. + + Before performing collection and entering the run test loop. + """ + # Initialize the OBS WebSocket client + session.obsws = obsws.ReqClient( + host=os.environ['OBSWS_HOST'], + port=os.environ['OBSWS_PORT'], + password=os.environ['OBSWS_PASSWORD'], + timeout=5, + ) + resp = session.obsws.get_version() + + out = ( + 'Running tests with:', + f'OBS Client version: {resp.obs_version} with WebSocket version: {resp.obs_web_socket_version}', + ) + print(' '.join(out)) + + session.obsws.create_scene('pytest') + session.obsws.create_input( + sceneName='pytest', + inputName='pytest_input', + inputKind='color_source_v3', + inputSettings={ + 'color': 3279460728, + 'width': 1920, + 'height': 1080, + 'visible': True, + }, + sceneItemEnabled=True, + ) + session.obsws.create_input( + sceneName='pytest', + inputName='pytest_input_2', + inputKind='color_source_v3', + inputSettings={ + 'color': 1789347616, + 'width': 720, + 'height': 480, + 'visible': True, + }, + sceneItemEnabled=True, + ) + + +def pytest_sessionfinish(session, exitstatus): + """Call after the whole test run finishes. + + Return the exit status to the system. + """ + session.obsws.remove_scene('pytest') + + # Close the OBS WebSocket client connection + session.obsws.disconnect() + + +def pytest_unconfigure(config): + """Call before test process is exited.""" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a134033 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,15 @@ +"""Unit tests for the root command in the OBS WebSocket CLI.""" + +from typer.testing import CliRunner + +from obsws_cli.app import app + +runner = CliRunner() + + +def test_version(): + """Test the version command.""" + result = runner.invoke(app, ['version']) + assert result.exit_code == 0 + assert 'OBS Client version' in result.stdout + assert 'WebSocket version' in result.stdout diff --git a/tests/test_item.py b/tests/test_item.py new file mode 100644 index 0000000..42f7495 --- /dev/null +++ b/tests/test_item.py @@ -0,0 +1,15 @@ +"""Unit tests for the item command in the OBS WebSocket CLI.""" + +from typer.testing import CliRunner + +from obsws_cli.app import app + +runner = CliRunner() + + +def test_item_list(): + """Test the item list command.""" + result = runner.invoke(app, ['item', 'list', 'pytest']) + assert result.exit_code == 0 + assert 'pytest_input' in result.stdout + assert 'pytest_input_2' in result.stdout diff --git a/tests/test_record.py b/tests/test_record.py new file mode 100644 index 0000000..48d3988 --- /dev/null +++ b/tests/test_record.py @@ -0,0 +1,32 @@ +"""Unit tests for the record command in the OBS WebSocket CLI.""" + +import time + +from typer.testing import CliRunner + +from obsws_cli.app import app + +runner = CliRunner() + + +def test_record_start_status_stop(): + """Test the record start command.""" + result = runner.invoke(app, ['record', 'start']) + assert result.exit_code == 0 + assert 'Recording started successfully.' in result.stdout + + time.sleep(0.5) # Wait for the recording to start + + result = runner.invoke(app, ['record', 'status']) + assert result.exit_code == 0 + assert 'Recording is in progress.' in result.stdout + + result = runner.invoke(app, ['record', 'stop']) + assert result.exit_code == 0 + assert 'Recording stopped successfully.' in result.stdout + + time.sleep(0.5) # Wait for the recording to stop + + result = runner.invoke(app, ['record', 'status']) + assert result.exit_code == 0 + assert 'Recording is not in progress.' in result.stdout diff --git a/tests/test_scene.py b/tests/test_scene.py new file mode 100644 index 0000000..d8eab3e --- /dev/null +++ b/tests/test_scene.py @@ -0,0 +1,22 @@ +"""Unit tests for the scene commands in the OBS WebSocket CLI.""" + +from typer.testing import CliRunner + +from obsws_cli.app import app + +runner = CliRunner() + + +def test_scene_list(): + """Test the scene list command.""" + result = runner.invoke(app, ['scene', 'list']) + assert result.exit_code == 0 + assert 'pytest' in result.stdout + + +def test_scene_current(): + """Test the scene current command.""" + runner.invoke(app, ['scene', 'switch', 'pytest']) + result = runner.invoke(app, ['scene', 'current']) + assert result.exit_code == 0 + assert 'pytest' in result.stdout