first commit

This commit is contained in:
onyx-and-iris 2025-04-19 20:15:26 +01:00
commit 8ce9a80eed
23 changed files with 1317 additions and 0 deletions

178
.gitignore vendored Normal file
View File

@ -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

9
LICENSE.txt Normal file
View File

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2025-present onyx-and-iris <code@onyxandiris.online>
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.

233
README.md Normal file
View File

@ -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=<websocket 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=<websocket 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: <scene_name>
```console
obsws-cli scene switch LIVE
```
#### Item
- show: Show an item in a scene.
- args: <scene_name> <item_name>
```console
obsws-cli item show START "Colour Source"
```
- hide: Hide an item in a scene.
- args: <scene_name> <item_name>
```console
obsws-cli item hide START "Colour Source"
```
- list: List all items in a scene.
- args: <scene_name>
```console
obsws-cli item list LIVE
```
#### Group
- show: Show a group in a scene.
- args: <scene_name> <group_name>
```console
obsws-cli group show START "test_group"
```
- hide: Hide a group in a scene.
- args: <scene_name> <group_name>
```console
obsws-cli group hide START "test_group"
```
- list: List groups in a scene.
- args: <scene_name>
```console
obsws-cli group list START
```
#### Input
- list: List all inputs.
```console
obsws-cli input list
```
- mute: Mute an input.
- args: <input_name>
```console
obsws-cli input mute "Mic/Aux"
```
- unmute: Unmute an input.
- args: <input_name>
```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/

4
obsws_cli/__about__.py Normal file
View File

@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: 2025-present onyx-and-iris <code@onyxandiris.online>
#
# SPDX-License-Identifier: MIT
__version__ = "0.1.0"

7
obsws_cli/__init__.py Normal file
View File

@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2025-present onyx-and-iris <code@onyxandiris.online>
#
# SPDX-License-Identifier: MIT
from .app import app
__all__ = ["app"]

23
obsws_cli/alias.py Normal file
View File

@ -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

78
obsws_cli/app.py Normal file
View File

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

18
obsws_cli/errors.py Normal file
View File

@ -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."""

81
obsws_cli/group.py Normal file
View File

@ -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

89
obsws_cli/input.py Normal file
View File

@ -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

61
obsws_cli/item.py Normal file
View File

@ -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

10
obsws_cli/protocols.py Normal file
View File

@ -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."""

110
obsws_cli/record.py Normal file
View File

@ -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

40
obsws_cli/scene.py Normal file
View File

@ -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

87
obsws_cli/stream.py Normal file
View File

@ -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

50
pyproject.toml Normal file
View File

@ -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}"

78
ruff.toml Normal file
View File

@ -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"

3
tests/__init__.py Normal file
View File

@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2025-present onyx-and-iris <code@onyxandiris.online>
#
# SPDX-License-Identifier: MIT

74
tests/conftest.py Normal file
View File

@ -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."""

15
tests/test_cli.py Normal file
View File

@ -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

15
tests/test_item.py Normal file
View File

@ -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

32
tests/test_record.py Normal file
View File

@ -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

22
tests/test_scene.py Normal file
View File

@ -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