32 Commits

Author SHA1 Message Date
8bec6908e5 move studio mode enabled validation into callback
patch bump
2026-01-24 02:34:45 +00:00
2c03b28fc6 fix type annotations 2026-01-12 21:13:16 +00:00
f1e29e0d4f fix settings position 2026-01-10 14:21:30 +00:00
c7b60ecaf9 patch bump 2026-01-10 14:18:24 +00:00
a05fce26f2 add media and settings aliases on the root typer 2026-01-10 14:18:04 +00:00
5355d29a31 keep it consistent 2026-01-10 14:04:27 +00:00
add9743b00 patch bump 2026-01-10 14:00:20 +00:00
8aa1fb2c09 add validate.timecode_format
add None checks for callbacks with optional values
2026-01-10 13:59:55 +00:00
5c7fc24839 patch bump 2026-01-09 23:24:54 +00:00
e4ab4ae630 remove unused monitor_exists() function 2026-01-09 23:22:14 +00:00
9cdbc657fa profile_exists validation log now callbacks 2026-01-09 23:19:49 +00:00
f74ec9cd93 scene_collection validation logic now in callbacks 2026-01-09 23:14:34 +00:00
329aec084c scene_in_scenes validation now a callback 2026-01-09 23:07:06 +00:00
3eaa3992a0 bump version in CHANGELOG 2026-01-09 19:53:18 +00:00
7c86aa8a8b minor version bump 2026-01-09 19:51:31 +00:00
09ca892fcb add Media to README 2026-01-09 19:51:11 +00:00
81fcb4e504 implement media command group 2026-01-09 19:51:03 +00:00
3f3b331363 bump version in CHANGELOG 2026-01-09 13:48:52 +00:00
2535fe85c5 minor bump 2026-01-09 13:47:18 +00:00
7d4485ec05 add Settings to README 2026-01-09 13:45:32 +00:00
2c2501e017 implement settings command group 2026-01-09 13:45:25 +00:00
356684e5d4 rename Settings to Config 2026-01-09 09:39:19 +00:00
f7e51f8488 add 0.22.0 to CHANGELOG 2026-01-09 09:31:28 +00:00
8da29ce90e upd typer dep version
obsws-cli minor version bump
2026-01-09 09:21:43 +00:00
72c6bcee49 upd README with new input subcommands 2026-01-09 09:21:06 +00:00
dceafba065 extend input command group 2026-01-09 09:20:45 +00:00
7a73ec35f6 remove lazyimports env 2025-09-29 20:51:17 +01:00
48e0f6cecd bump typer dependency.
release 0.17.0 fixes slow rich imports, see https://github.com/fastapi/typer/releases/tag/0.17.0

This is related to issue #2.

minor version bump
2025-09-29 04:21:26 +01:00
52e13922dc upd test delays to 500ms 2025-07-30 08:42:11 +01:00
f335d8ffd2 move the version flag 2025-07-29 08:48:30 +01:00
286cda8066 raise typer.Exit() on empty list queries 2025-07-29 08:17:52 +01:00
e851219ced tests should now pass from fresh install 2025-07-29 08:03:24 +01:00
45 changed files with 2373 additions and 1716 deletions

View File

@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
# [0.24.0] - 2026-01-09
### Added
- new subcommands added to input, see [Input](https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#input)
- settings command group, see [Settings](https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#settings)
- media command group, see [Media](https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#media)
# [0.20.0] - 2025-07-14 # [0.20.0] - 2025-07-14
### Added ### Added

139
README.md
View File

@@ -298,6 +298,20 @@ obsws-cli group status START "test_group"
#### Input #### Input
- create: Create a new input.
- args: <input_name> <input_kind>
```console
obsws-cli input create 'stream mix' 'wasapi_input_capture'
```
- remove: Remove an input.
- args: <input_name>
```console
obsws-cli input remove 'stream mix'
```
- list: List all inputs. - list: List all inputs.
- flags: - flags:
@@ -315,6 +329,12 @@ obsws-cli input list
obsws-cli input list --input --colour obsws-cli input list --input --colour
``` ```
- list-kinds: List all input kinds.
```console
obsws-cli input list-kinds
```
- mute: Mute an input. - mute: Mute an input.
- args: <input_name> - args: <input_name>
@@ -335,6 +355,32 @@ obsws-cli input unmute "Mic/Aux"
obsws-cli input toggle "Mic/Aux" obsws-cli input toggle "Mic/Aux"
``` ```
- volume: Set the volume of an input.
- args: <input_name> <volume>
```console
obsws-cli input volume -- 'Desktop Audio' -38.9
```
- show: Show information for an input in the current scene.
- args: <input_name>
- flags:
*optional*
- --verbose: List all available input devices.
```console
obsws-cli input show 'Mic/Aux' --verbose
```
- update: Name of the input to update.
- args: <input_name> <device_name>
```console
obsws-cli input update 'Mic/Aux' 'Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)'
```
#### Text #### Text
- current: Get the current text for a text input. - current: Get the current text for a text input.
@@ -676,6 +722,99 @@ obsws-cli projector open --monitor-index=1 "test_group"
obsws-cli screenshot save --width=2560 --height=1440 "Scene" "C:\Users\me\Videos\screenshot.png" obsws-cli screenshot save --width=2560 --height=1440 "Scene" "C:\Users\me\Videos\screenshot.png"
``` ```
#### Settings
- show: Show current OBS settings.
- flags:
*optional*
- --video: Show video settings.
- --record: Show recording settings.
- --profile: Show profile settings.
```console
obsws-cli settings show --video --record
```
- profile: Get/set OBS profile settings.
- args: <category> <name> <value>
```console
obsws-cli settings profile SimpleOutput VBitrate
obsws-cli settings profile SimpleOutput VBitrate 6000
```
- stream-service: Get/set OBS stream service settings.
- flags:
- --key: Stream key.
- --server: Stream server URL.
*optional*
- args: <type>
```console
obsws-cli settings stream-service
obsws-cli settings stream-service --key='live_xyzxyzxyzxyz' rtmp_common
```
- video: Get/set OBS video settings.
- flags:
*optional*
- --base-width: Base (canvas) width.
- --base-height: Base (canvas) height.
- --output-width: Output (scaled) width.
- --output-height: Output (scaled) height.
- --fps-num: Frames per second numerator.
- --fps-den: Frames per second denominator.
```console
obsws-cli settings video
obsws-cli settings video --base-width=1920 --base-height=1080
```
#### Media
- cursor: Get/set the cursor position of a media input.
- args: InputName
*optional*
- TimeString
```console
obsws-cli media cursor "Media"
obsws-cli media cursor "Media" "00:08:30"
```
- play: Plays a media input.
```console
obsws-cli media play "Media"
```
- pause: Pauses a media input.
```console
obsws-cli media pause "Media"
```
- stop: Stops a media input.
```console
obsws-cli media stop "Media"
```
- restart: Restarts a media input.
```console
obsws-cli media restart "Media"
```
## License ## License
`obsws-cli` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. `obsws-cli` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.

View File

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

View File

@@ -2,6 +2,6 @@
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
from .app import run from .app import app
__all__ = ['run'] __all__ = ['app']

77
obsws_cli/alias.py Normal file
View File

@@ -0,0 +1,77 @@
"""module defining a custom group class for handling command name aliases."""
import re
import typer
class RootTyperAliasGroup(typer.core.TyperGroup):
"""A custom group class to handle command name aliases for the root typer."""
def __init__(self, *args, **kwargs):
"""Initialize the AliasGroup."""
super().__init__(*args, **kwargs)
self.no_args_is_help = True
def get_command(self, ctx, cmd_name):
"""Get a command by name."""
match cmd_name:
case 'f':
cmd_name = 'filter'
case 'g':
cmd_name = 'group'
case 'hk':
cmd_name = 'hotkey'
case 'i':
cmd_name = 'input'
case 'm':
cmd_name = 'media'
case 'prf':
cmd_name = 'profile'
case 'prj':
cmd_name = 'projector'
case 'rc':
cmd_name = 'record'
case 'rb':
cmd_name = 'replaybuffer'
case 'sc':
cmd_name = 'scene'
case 'scc':
cmd_name = 'scenecollection'
case 'si':
cmd_name = 'sceneitem'
case 'ss':
cmd_name = 'screenshot'
case 'set':
cmd_name = 'settings'
case 'st':
cmd_name = 'stream'
case 'sm':
cmd_name = 'studiomode'
case 't':
cmd_name = 'text'
case 'vc':
cmd_name = 'virtualcam'
return super().get_command(ctx, cmd_name)
class SubTyperAliasGroup(typer.core.TyperGroup):
"""A custom group class to handle command name aliases for sub typers."""
_CMD_SPLIT_P = re.compile(r' ?[,|] ?')
def __init__(self, *args, **kwargs):
"""Initialize the AliasGroup."""
super().__init__(*args, **kwargs)
self.no_args_is_help = True
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():
if cmd.name and default_name in self._CMD_SPLIT_P.split(cmd.name):
return cmd.name
return default_name

View File

@@ -2,31 +2,23 @@
import importlib import importlib
import logging import logging
from dataclasses import dataclass from typing import Annotated
from typing import Annotated, Any
import obsws_python as obsws import obsws_python as obsws
from cyclopts import App, Group, Parameter, config import typer
from obsws_cli.__about__ import __version__ as version from obsws_cli.__about__ import __version__ as version
from . import console, styles from . import config, console, styles
from .context import Context from .alias import RootTyperAliasGroup
from .error import OBSWSCLIError
app = App( app = typer.Typer(cls=RootTyperAliasGroup)
config=config.Env( for sub_typer in (
'OBS_'
), # Environment variable prefix for configuration parameters
version=version,
usage='[bold][yellow]Usage:[/yellow] [white]obsws-cli [OPTIONS] COMMAND [ARGS]...[/white][/bold]',
)
app.meta.group_parameters = Group('Options', sort_key=0)
for sub_app in (
'filter', 'filter',
'group', 'group',
'hotkey', 'hotkey',
'input', 'input',
'media',
'profile', 'profile',
'projector', 'projector',
'record', 'record',
@@ -35,103 +27,143 @@ for sub_app in (
'scenecollection', 'scenecollection',
'sceneitem', 'sceneitem',
'screenshot', 'screenshot',
'settings',
'stream', 'stream',
'studiomode', 'studiomode',
'text', 'text',
'virtualcam', 'virtualcam',
): ):
module = importlib.import_module(f'.{sub_app}', package=__package__) module = importlib.import_module(f'.{sub_typer}', package=__package__)
app.command(module.app) app.add_typer(module.app, name=sub_typer)
@Parameter(name='*') def version_callback(value: bool):
@dataclass """Show the version of the CLI."""
class OBSConfig: if value:
"""Dataclass to hold OBS connection parameters. console.out.print(f'obsws-cli version: {version}')
raise typer.Exit()
Attributes:
host (str): The hostname or IP address of the OBS WebSocket server.
port (int): The port number of the OBS WebSocket server.
password (str): The password for the OBS WebSocket server, if required.
"""
host: str = 'localhost'
port: int = 4455
password: str = ''
@dataclass def setup_logging(debug: bool):
class StyleConfig:
"""Dataclass to hold style parameters.
Attributes:
name (str): The name of the style to use for console output.
no_border (bool): Whether to style the borders in the console output.
"""
name: str = 'disabled'
no_border: bool = False
def setup_logging(type_, value: Any):
"""Set up logging for the application.""" """Set up logging for the application."""
log_level = logging.DEBUG if value else logging.CRITICAL log_level = logging.DEBUG if debug else logging.CRITICAL
logging.basicConfig( logging.basicConfig(
level=log_level, level=log_level,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
) )
@app.meta.default def validate_style(value: str):
def launcher( """Validate and return the style."""
*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], if value not in styles.registry:
obs_config: OBSConfig, raise typer.BadParameter(
style_config: StyleConfig, f'Invalid style: {value}. Available styles: {", ".join(styles.registry.keys())}'
)
return value
@app.callback()
def main(
ctx: typer.Context,
host: Annotated[
str,
typer.Option(
'--host',
'-H',
envvar='OBS_HOST',
help='WebSocket host',
show_default='localhost',
),
] = config.get('host'),
port: Annotated[
int,
typer.Option(
'--port',
'-P',
envvar='OBS_PORT',
help='WebSocket port',
show_default=4455,
),
] = config.get('port'),
password: Annotated[
str,
typer.Option(
'--password',
'-p',
envvar='OBS_PASSWORD',
help='WebSocket password',
show_default=False,
),
] = config.get('password'),
timeout: Annotated[
int,
typer.Option(
'--timeout',
'-T',
envvar='OBS_TIMEOUT',
help='WebSocket timeout',
show_default=5,
),
] = config.get('timeout'),
style: Annotated[
str,
typer.Option(
'--style',
'-s',
envvar='OBS_STYLE',
help='Set the style for the CLI output',
show_default='disabled',
callback=validate_style,
),
] = config.get('style'),
no_border: Annotated[
bool,
typer.Option(
'--no-border',
'-b',
envvar='OBS_STYLE_NO_BORDER',
help='Disable table border styling in the CLI output',
show_default=False,
),
] = config.get('style_no_border'),
version: Annotated[
bool,
typer.Option(
'--version',
'-v',
is_eager=True,
help='Show the CLI version and exit',
show_default=False,
callback=version_callback,
),
] = False,
debug: Annotated[ debug: Annotated[
bool, bool,
Parameter(validator=setup_logging, help='Enable debug logging'), typer.Option(
] = False, '--debug',
'-d',
envvar='OBS_DEBUG',
is_eager=True,
help='Enable debug logging',
show_default=False,
callback=setup_logging,
hidden=True,
),
] = config.get('debug'),
): ):
"""Command line interface for the OBS WebSocket API.""" """obsws_cli is a command line interface for the OBS WebSocket API."""
with obsws.ReqClient( ctx.ensure_object(dict)
host=obs_config.host, ctx.obj['obsws'] = ctx.with_resource(
port=obs_config.port, obsws.ReqClient(host=host, port=port, password=password, timeout=timeout)
password=obs_config.password,
) as client:
additional_kwargs = {}
command, bound, ignored = app.parse_args(tokens)
if 'ctx' in ignored:
# If 'ctx' is in ignored, it means it was not passed as an argument
# and we need to add it to the bound arguments.
additional_kwargs['ctx'] = ignored['ctx'](
client,
styles.request_style_obj(style_config.name, style_config.no_border),
) )
return command(*bound.args, **bound.kwargs, **additional_kwargs) ctx.obj['style'] = styles.request_style_obj(style, no_border)
@app.command @app.command()
def obs_version( def obs_version(ctx: typer.Context):
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get the OBS Client and WebSocket versions.""" """Get the OBS Client and WebSocket versions."""
resp = ctx.client.get_version() resp = ctx.obj['obsws'].get_version()
console.out.print( console.out.print(
f'OBS Client version: {console.highlight(ctx, resp.obs_version)}' f'OBS Client version: {console.highlight(ctx, resp.obs_version)}'
f' with WebSocket version: {console.highlight(ctx, resp.obs_web_socket_version)}' f' with WebSocket version: {console.highlight(ctx, resp.obs_web_socket_version)}'
) )
def run():
"""Run the OBS WebSocket CLI application.
Handles exceptions and prints error messages to the console.
"""
try:
app.meta()
except OBSWSCLIError as e:
console.err.print(f'Error: {e}')
return e.code

80
obsws_cli/config.py Normal file
View File

@@ -0,0 +1,80 @@
"""module for settings management for obsws-cli."""
from collections import UserDict
from pathlib import Path
from dotenv import dotenv_values
ConfigValue = str | int
class Config(UserDict):
"""A class to manage config for obsws-cli.
This class extends UserDict to provide a dictionary-like interface for config.
It loads config from environment variables and .env files.
The config values are expected to be in uppercase and should start with 'OBS_'.
Example:
-------
config = Config()
host = config['OBS_HOST']
config['OBS_PORT'] = 4455
"""
PREFIX = 'OBS_'
def __init__(self, *args, **kwargs):
"""Initialize the Settings object."""
kwargs.update(
{
**dotenv_values('.env'),
**dotenv_values(Path.home() / '.config' / 'obsws-cli' / 'obsws.env'),
}
)
super().__init__(*args, **kwargs)
def __getitem__(self, key: str) -> ConfigValue:
"""Get a setting value by key."""
key = key.upper()
if not key.startswith(Config.PREFIX):
key = f'{Config.PREFIX}{key}'
return self.data[key]
def __setitem__(self, key: str, value: ConfigValue):
"""Set a setting value by key."""
key = key.upper()
if not key.startswith(Config.PREFIX):
key = f'{Config.PREFIX}{key}'
self.data[key] = value
_config = Config(
OBS_HOST='localhost',
OBS_PORT=4455,
OBS_PASSWORD='',
OBS_TIMEOUT=5,
OBS_DEBUG=False,
OBS_STYLE='disabled',
OBS_STYLE_NO_BORDER=False,
)
def get(key: str) -> ConfigValue:
"""Get a setting value by key.
Args:
----
key (str): The key of the config to retrieve.
Returns:
-------
The value of the config.
Raises:
------
KeyError: If the key does not exist in the config.
"""
return _config[key]

View File

@@ -1,13 +1,12 @@
"""module for console output handling in obsws_cli.""" """module for console output handling in obsws_cli."""
import typer
from rich.console import Console from rich.console import Console
from .context import Context
out = Console() out = Console()
err = Console(stderr=True, style='bold red') err = Console(stderr=True, style='bold red')
def highlight(ctx: Context, text: str) -> str: def highlight(ctx: typer.Context, text: str) -> str:
"""Highlight text using the current context's style.""" """Highlight text using the current context's style."""
return f'[{ctx.style.highlight}]{text}[/{ctx.style.highlight}]' return f'[{ctx.obj["style"].highlight}]{text}[/{ctx.obj["style"].highlight}]'

View File

@@ -1,15 +0,0 @@
"""module for managing the application context."""
from dataclasses import dataclass
import obsws_python as obsws
from . import styles
@dataclass
class Context:
"""Context for the application, holding OBS and style configurations."""
client: obsws.ReqClient
style: styles.Style

View File

@@ -1,10 +0,0 @@
"""module for exit codes used in the application."""
from enum import IntEnum, auto
class ExitCode(IntEnum):
"""Exit codes for the application."""
SUCCESS = 0
ERROR = auto()

View File

@@ -1,11 +0,0 @@
"""module containing error handling for OBS WebSocket CLI."""
class OBSWSCLIError(Exception):
"""Base class for OBS WebSocket CLI errors."""
def __init__(self, message: str, code: int = 1):
"""Initialize the error with a message and an optional code."""
super().__init__(message)
self.message = message
self.code = code

View File

@@ -3,46 +3,44 @@
from typing import Annotated, Optional from typing import Annotated, Optional
import obsws_python as obsws import obsws_python as obsws
from cyclopts import App, Parameter import typer
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from . import console, util from . import console, util
from .context import Context from .alias import SubTyperAliasGroup
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='filter', help='Commands for managing filters in OBS sources') app = typer.Typer(cls=SubTyperAliasGroup)
@app.command(name=['list', 'ls']) @app.callback()
def main():
"""Control filters in OBS scenes."""
@app.command('list | ls')
def list_( def list_(
source_name: Optional[str] = None, ctx: typer.Context,
/, source_name: Annotated[
*, Optional[str],
ctx: Annotated[Context, Parameter(parse=False)], typer.Argument(
show_default='The current scene',
help='The source to list filters for',
),
] = None,
): ):
"""List filters for a source. """List filters for a source."""
Parameters
----------
source_name : str, optional
The name of the source to list filters for. If not provided, the current program scene's source will be used.
ctx : Context
The context containing the OBS client and other settings.
"""
if not source_name: if not source_name:
source_name = ctx.client.get_current_program_scene().scene_name source_name = ctx.obj['obsws'].get_current_program_scene().scene_name
try: try:
resp = ctx.client.get_source_filter_list(source_name) resp = ctx.obj['obsws'].get_source_filter_list(source_name)
except obsws.error.OBSSDKRequestError as e: except obsws.error.OBSSDKRequestError as e:
if e.code == 600: if e.code == 600:
raise OBSWSCLIError( console.err.print(
f'No source found by the name of [yellow]{source_name}[/yellow].', f'No source was found by the name of [yellow]{source_name}[/yellow].'
code=ExitCode.ERROR,
) )
raise typer.Exit(1)
else: else:
raise raise
@@ -50,25 +48,25 @@ def list_(
console.out.print( console.out.print(
f'No filters found for source {console.highlight(ctx, source_name)}' f'No filters found for source {console.highlight(ctx, source_name)}'
) )
return raise typer.Exit()
table = Table( table = Table(
title=f'Filters for Source: {source_name}', title=f'Filters for Source: {source_name}',
padding=(0, 2), padding=(0, 2),
border_style=ctx.style.border, border_style=ctx.obj['style'].border,
) )
columns = [ columns = [
(Text('Filter Name', justify='center'), 'left', ctx.style.column), (Text('Filter Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Kind', justify='center'), 'left', ctx.style.column), (Text('Kind', justify='center'), 'left', ctx.obj['style'].column),
(Text('Enabled', justify='center'), 'center', None), (Text('Enabled', justify='center'), 'center', None),
(Text('Settings', justify='center'), 'center', ctx.style.column), (Text('Settings', justify='center'), 'center', ctx.obj['style'].column),
] ]
for heading, justify, style in columns: for heading, justify, style in columns:
table.add_column(heading, justify=justify, style=style) table.add_column(heading, justify=justify, style=style)
for filter in resp.filters: for filter in resp.filters:
resp = ctx.client.get_source_filter_default_settings(filter['filterKind']) resp = ctx.obj['obsws'].get_source_filter_default_settings(filter['filterKind'])
settings = resp.default_filter_settings | filter['filterSettings'] settings = resp.default_filter_settings | filter['filterSettings']
table.add_row( table.add_row(
@@ -86,100 +84,93 @@ def list_(
console.out.print(table) console.out.print(table)
def _get_filter_enabled(ctx: Context, source_name: str, filter_name: str): def _get_filter_enabled(ctx: typer.Context, source_name: str, filter_name: str):
"""Get the status of a filter for a source.""" """Get the status of a filter for a source."""
resp = ctx.client.get_source_filter(source_name, filter_name) resp = ctx.obj['obsws'].get_source_filter(source_name, filter_name)
return resp.filter_enabled return resp.filter_enabled
@app.command(name=['enable', 'on']) @app.command('enable | on')
def enable( def enable(
source_name: str, ctx: typer.Context,
filter_name: str, source_name: Annotated[
/, str,
*, typer.Argument(
ctx: Annotated[Context, Parameter(parse=False)], ..., show_default=False, help='The source to enable the filter for'
),
],
filter_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='The name of the filter to enable'
),
],
): ):
"""Enable a filter for a source. """Enable a filter for a source."""
Parameters
----------
source_name : str
The name of the source to enable the filter for.
filter_name : str
The name of the filter to enable.
ctx : Context
The context containing the OBS client and other settings.
"""
if _get_filter_enabled(ctx, source_name, filter_name): if _get_filter_enabled(ctx, source_name, filter_name):
raise OBSWSCLIError( console.err.print(
f'Filter [yellow]{filter_name}[/yellow] is already enabled for source [yellow]{source_name}[/yellow]', f'Filter [yellow]{filter_name}[/yellow] is already enabled for source [yellow]{source_name}[/yellow]'
code=ExitCode.ERROR,
) )
raise typer.Exit(1)
ctx.client.set_source_filter_enabled(source_name, filter_name, enabled=True) ctx.obj['obsws'].set_source_filter_enabled(source_name, filter_name, enabled=True)
console.out.print( console.out.print(
f'Enabled filter {console.highlight(ctx, filter_name)} for source {console.highlight(ctx, source_name)}' f'Enabled filter {console.highlight(ctx, filter_name)} for source {console.highlight(ctx, source_name)}'
) )
@app.command(name=['disable', 'off']) @app.command('disable | off')
def disable( def disable(
source_name: str, ctx: typer.Context,
filter_name: str, source_name: Annotated[
/, str,
*, typer.Argument(
ctx: Annotated[Context, Parameter(parse=False)], ..., show_default=False, help='The source to disable the filter for'
),
],
filter_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='The name of the filter to disable'
),
],
): ):
"""Disable a filter for a source. """Disable a filter for a source."""
Parameters
----------
source_name : str
The name of the source to disable the filter for.
filter_name : str
The name of the filter to disable.
ctx : Context
The context containing the OBS client and other settings.
"""
if not _get_filter_enabled(ctx, source_name, filter_name): if not _get_filter_enabled(ctx, source_name, filter_name):
raise OBSWSCLIError( console.err.print(
f'Filter [yellow]{filter_name}[/yellow] is already disabled for source [yellow]{source_name}[/yellow]', f'Filter [yellow]{filter_name}[/yellow] is already disabled for source [yellow]{source_name}[/yellow]'
code=ExitCode.ERROR,
) )
raise typer.Exit(1)
ctx.client.set_source_filter_enabled(source_name, filter_name, enabled=False) ctx.obj['obsws'].set_source_filter_enabled(source_name, filter_name, enabled=False)
console.out.print( console.out.print(
f'Disabled filter {console.highlight(ctx, filter_name)} for source {console.highlight(ctx, source_name)}' f'Disabled filter {console.highlight(ctx, filter_name)} for source {console.highlight(ctx, source_name)}'
) )
@app.command(name=['toggle', 'tg']) @app.command('toggle | tg')
def toggle( def toggle(
source_name: str, ctx: typer.Context,
filter_name: str, source_name: Annotated[
/, str,
*, typer.Argument(
ctx: Annotated[Context, Parameter(parse=False)], ..., show_default=False, help='The source to toggle the filter for'
),
],
filter_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='The name of the filter to toggle'
),
],
): ):
"""Toggle a filter for a source. """Toggle a filter for a source."""
Parameters
----------
source_name : str
The name of the source to toggle the filter for.
filter_name : str
The name of the filter to toggle.
ctx : Context
The context containing the OBS client and other settings.
"""
is_enabled = _get_filter_enabled(ctx, source_name, filter_name) is_enabled = _get_filter_enabled(ctx, source_name, filter_name)
new_state = not is_enabled new_state = not is_enabled
ctx.client.set_source_filter_enabled(source_name, filter_name, enabled=new_state) ctx.obj['obsws'].set_source_filter_enabled(
source_name, filter_name, enabled=new_state
)
if new_state: if new_state:
console.out.print( console.out.print(
f'Enabled filter {console.highlight(ctx, filter_name)} for source {console.highlight(ctx, source_name)}' f'Enabled filter {console.highlight(ctx, filter_name)} for source {console.highlight(ctx, source_name)}'
@@ -190,26 +181,23 @@ def toggle(
) )
@app.command(name=['status', 'ss']) @app.command('status | ss')
def status( def status(
source_name: str, ctx: typer.Context,
filter_name: str, source_name: Annotated[
/, str,
*, typer.Argument(
ctx: Annotated[Context, Parameter(parse=False)], ..., show_default=False, help='The source to get the filter status for'
),
],
filter_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='The name of the filter to get the status for'
),
],
): ):
"""Get the status of a filter for a source. """Get the status of a filter for a source."""
Parameters
----------
source_name : str
The name of the source to check the filter status for.
filter_name : str
The name of the filter to check the status for.
ctx : Context
The context containing the OBS client and other settings.
"""
is_enabled = _get_filter_enabled(ctx, source_name, filter_name) is_enabled = _get_filter_enabled(ctx, source_name, filter_name)
if is_enabled: if is_enabled:
console.out.print( console.out.print(

View File

@@ -2,47 +2,39 @@
from typing import Annotated, Optional from typing import Annotated, Optional
from cyclopts import App, Parameter import typer
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from . import console, util, validate from . import console, util, validate
from .context import Context from .alias import SubTyperAliasGroup
from .enum import ExitCode
from .error import OBSWSCLIError
from .protocols import DataclassProtocol from .protocols import DataclassProtocol
app = App(name='group', help='Commands for managing groups in OBS scenes') app = typer.Typer(cls=SubTyperAliasGroup)
@app.command(name=['list', 'ls']) @app.callback()
def main():
"""Control groups in OBS scenes."""
@app.command('list | ls')
def list_( def list_(
scene_name: Optional[str] = None, ctx: typer.Context,
/, scene_name: Annotated[
*, Optional[str],
ctx: Annotated[Context, Parameter(parse=False)], typer.Argument(
show_default='The current scene',
help='Scene name to list groups for',
callback=validate.scene_in_scenes,
),
] = None,
): ):
"""List groups in a scene. """List groups in a scene."""
if scene_name is None:
scene_name = ctx.obj['obsws'].get_current_program_scene().scene_name
Parameters resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
----------
scene_name : str, optional
The name of the scene to list groups for. If not provided, the current program scene
will be used.
ctx : Context
The context containing the OBS client and other settings.
"""
if not scene_name:
scene_name = ctx.client.get_current_program_scene().scene_name
if not validate.scene_in_scenes(ctx, scene_name):
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
resp = ctx.client.get_scene_item_list(scene_name)
groups = [ groups = [
(item.get('sceneItemId'), item.get('sourceName'), item.get('sceneItemEnabled')) (item.get('sceneItemId'), item.get('sourceName'), item.get('sceneItemEnabled'))
for item in resp.scene_items for item in resp.scene_items
@@ -53,17 +45,17 @@ def list_(
console.out.print( console.out.print(
f'No groups found in scene {console.highlight(ctx, scene_name)}.' f'No groups found in scene {console.highlight(ctx, scene_name)}.'
) )
return raise typer.Exit()
table = Table( table = Table(
title=f'Groups in Scene: {scene_name}', title=f'Groups in Scene: {scene_name}',
padding=(0, 2), padding=(0, 2),
border_style=ctx.style.border, border_style=ctx.obj['style'].border,
) )
columns = [ columns = [
(Text('ID', justify='center'), 'center', ctx.style.column), (Text('ID', justify='center'), 'center', ctx.obj['style'].column),
(Text('Group Name', justify='center'), 'left', ctx.style.column), (Text('Group Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Enabled', justify='center'), 'center', None), (Text('Enabled', justify='center'), 'center', None),
] ]
for heading, justify, style in columns: for heading, justify, style in columns:
@@ -92,40 +84,31 @@ def _get_group(group_name: str, resp: DataclassProtocol) -> dict | None:
return group return group
@app.command(name=['show', 'sh']) @app.command('show | sh')
def show( def show(
scene_name: str, ctx: typer.Context,
group_name: str, scene_name: Annotated[
/, str,
*, typer.Argument(
ctx: Annotated[Context, Parameter(parse=False)], ...,
show_default=False,
help='Scene name the group is in',
callback=validate.scene_in_scenes,
),
],
group_name: Annotated[
str, typer.Argument(..., show_default=False, help='Group name to show')
],
): ):
"""Show a group in a scene. """Show a group in a scene."""
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
Parameters
----------
scene_name : str
The name of the scene where the group is located.
group_name : str
The name of the group to show.
ctx : Context
The context containing the OBS client and other settings.
"""
if not validate.scene_in_scenes(ctx, scene_name):
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
resp = ctx.client.get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None: if (group := _get_group(group_name, resp)) is None:
raise OBSWSCLIError( console.err.print(
f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].', f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].'
code=ExitCode.ERROR,
) )
raise typer.Exit(1)
ctx.client.set_scene_item_enabled( ctx.obj['obsws'].set_scene_item_enabled(
scene_name=scene_name, scene_name=scene_name,
item_id=int(group.get('sceneItemId')), item_id=int(group.get('sceneItemId')),
enabled=True, enabled=True,
@@ -134,40 +117,31 @@ def show(
console.out.print(f'Group {console.highlight(ctx, group_name)} is now visible.') console.out.print(f'Group {console.highlight(ctx, group_name)} is now visible.')
@app.command(name=['hide', 'h']) @app.command('hide | h')
def hide( def hide(
scene_name: str, ctx: typer.Context,
group_name: str, scene_name: Annotated[
/, str,
*, typer.Argument(
ctx: Annotated[Context, Parameter(parse=False)], ...,
show_default=False,
help='Scene name the group is in',
callback=validate.scene_in_scenes,
),
],
group_name: Annotated[
str, typer.Argument(..., show_default=False, help='Group name to hide')
],
): ):
"""Hide a group in a scene. """Hide a group in a scene."""
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
Parameters
----------
scene_name : str
The name of the scene where the group is located.
group_name : str
The name of the group to hide.
ctx : Context
The context containing the OBS client and other settings.
"""
if not validate.scene_in_scenes(ctx, scene_name):
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
resp = ctx.client.get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None: if (group := _get_group(group_name, resp)) is None:
raise OBSWSCLIError( console.err.print(
f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].', f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].'
code=ExitCode.ERROR,
) )
raise typer.Exit(1)
ctx.client.set_scene_item_enabled( ctx.obj['obsws'].set_scene_item_enabled(
scene_name=scene_name, scene_name=scene_name,
item_id=int(group.get('sceneItemId')), item_id=int(group.get('sceneItemId')),
enabled=False, enabled=False,
@@ -176,41 +150,32 @@ def hide(
console.out.print(f'Group {console.highlight(ctx, group_name)} is now hidden.') console.out.print(f'Group {console.highlight(ctx, group_name)} is now hidden.')
@app.command(name=['toggle', 'tg']) @app.command('toggle | tg')
def toggle( def toggle(
scene_name: str, ctx: typer.Context,
group_name: str, scene_name: Annotated[
/, str,
*, typer.Argument(
ctx: Annotated[Context, Parameter(parse=False)], ...,
show_default=False,
help='Scene name the group is in',
callback=validate.scene_in_scenes,
),
],
group_name: Annotated[
str, typer.Argument(..., show_default=False, help='Group name to toggle')
],
): ):
"""Toggle a group in a scene. """Toggle a group in a scene."""
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
Parameters
----------
scene_name : str
The name of the scene where the group is located.
group_name : str
The name of the group to toggle.
ctx : Context
The context containing the OBS client and other settings.
"""
if not validate.scene_in_scenes(ctx, scene_name):
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
resp = ctx.client.get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None: if (group := _get_group(group_name, resp)) is None:
raise OBSWSCLIError( console.err.print(
f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].', f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].'
code=ExitCode.ERROR,
) )
raise typer.Exit(1)
new_state = not group.get('sceneItemEnabled') new_state = not group.get('sceneItemEnabled')
ctx.client.set_scene_item_enabled( ctx.obj['obsws'].set_scene_item_enabled(
scene_name=scene_name, scene_name=scene_name,
item_id=int(group.get('sceneItemId')), item_id=int(group.get('sceneItemId')),
enabled=new_state, enabled=new_state,
@@ -222,40 +187,31 @@ def toggle(
console.out.print(f'Group {console.highlight(ctx, group_name)} is now hidden.') console.out.print(f'Group {console.highlight(ctx, group_name)} is now hidden.')
@app.command(name=['status', 'ss']) @app.command('status | ss')
def status( def status(
scene_name: str, ctx: typer.Context,
group_name: str, scene_name: Annotated[
/, str,
*, typer.Argument(
ctx: Annotated[Context, Parameter(parse=False)], ...,
show_default=False,
help='Scene name the group is in',
callback=validate.scene_in_scenes,
),
],
group_name: Annotated[
str, typer.Argument(..., show_default=False, help='Group name to check status')
],
): ):
"""Get the status of a group in a scene. """Get the status of a group in a scene."""
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
Parameters
----------
scene_name : str
The name of the scene where the group is located.
group_name : str
The name of the group to check.
ctx : Context
The context containing the OBS client and other settings.
"""
if not validate.scene_in_scenes(ctx, scene_name):
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
resp = ctx.client.get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None: if (group := _get_group(group_name, resp)) is None:
raise OBSWSCLIError( console.err.print(
f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].', f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].'
code=ExitCode.ERROR,
) )
raise typer.Exit(1)
enabled = ctx.client.get_scene_item_enabled( enabled = ctx.obj['obsws'].get_scene_item_enabled(
scene_name=scene_name, scene_name=scene_name,
item_id=int(group.get('sceneItemId')), item_id=int(group.get('sceneItemId')),
) )

View File

@@ -2,40 +2,41 @@
from typing import Annotated from typing import Annotated
from cyclopts import App, Parameter import typer
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from . import console from . import console
from .context import Context from .alias import SubTyperAliasGroup
app = App(name='hotkey', help='Commands for managing hotkeys in OBS') app = typer.Typer(cls=SubTyperAliasGroup)
@app.command(name=['list', 'ls']) @app.callback()
def main():
"""Control hotkeys in OBS."""
@app.command('list | ls')
def list_( def list_(
*, ctx: typer.Context,
ctx: Annotated[Context, Parameter(parse=False)],
): ):
"""List all hotkeys. """List all hotkeys."""
resp = ctx.obj['obsws'].get_hotkey_list()
Parameters if not resp.hotkeys:
---------- console.out.print('No hotkeys found.')
ctx : Context raise typer.Exit()
The context containing the OBS client to interact with.
"""
resp = ctx.client.get_hotkey_list()
table = Table( table = Table(
title='Hotkeys', title='Hotkeys',
padding=(0, 2), padding=(0, 2),
border_style=ctx.style.border, border_style=ctx.obj['style'].border,
) )
table.add_column( table.add_column(
Text('Hotkey Name', justify='center'), Text('Hotkey Name', justify='center'),
justify='left', justify='left',
style=ctx.style.column, style=ctx.obj['style'].column,
) )
for i, hotkey in enumerate(resp.hotkeys): for i, hotkey in enumerate(resp.hotkeys):
@@ -44,53 +45,40 @@ def list_(
console.out.print(table) console.out.print(table)
@app.command(name=['trigger', 'tr']) @app.command('trigger | tr')
def trigger( def trigger(
hotkey: str, ctx: typer.Context,
/, hotkey: Annotated[
*, str, typer.Argument(..., show_default=False, help='The hotkey to trigger')
ctx: Annotated[Context, Parameter(parse=False)], ],
): ):
"""Trigger a hotkey by name. """Trigger a hotkey by name."""
ctx.obj['obsws'].trigger_hotkey_by_name(hotkey)
Parameters
----------
hotkey : str
The name of the hotkey to trigger.
ctx : Context
The context containing the OBS client to interact with.
"""
ctx.client.trigger_hotkey_by_name(hotkey)
@app.command(name=['trigger-sequence', 'trs']) @app.command('trigger-sequence | trs')
def trigger_sequence( def trigger_sequence(
key_id: str, ctx: typer.Context,
/, key_id: Annotated[
shift: bool = False, str,
ctrl: bool = False, typer.Argument(
alt: bool = False, ...,
cmd: bool = False, show_default=False,
*, help='The OBS key ID to trigger, see https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#hotkey for more info',
ctx: Annotated[Context, Parameter(parse=False)], ),
],
shift: Annotated[
bool, typer.Option(..., help='Press shift when triggering the hotkey')
] = False,
ctrl: Annotated[
bool, typer.Option(..., help='Press control when triggering the hotkey')
] = False,
alt: Annotated[
bool, typer.Option(..., help='Press alt when triggering the hotkey')
] = False,
cmd: Annotated[
bool, typer.Option(..., help='Press cmd when triggering the hotkey')
] = False,
): ):
"""Trigger a hotkey by sequence. """Trigger a hotkey by sequence."""
ctx.obj['obsws'].trigger_hotkey_by_key_sequence(key_id, shift, ctrl, alt, cmd)
Parameters
----------
key_id : str
The OBS key ID to trigger, see https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#hotkey for more info
shift : bool, optional
Press shift when triggering the hotkey (default is False)
ctrl : bool, optional
Press control when triggering the hotkey (default is False)
alt : bool, optional
Press alt when triggering the hotkey (default is False)
cmd : bool, optional
Press cmd when triggering the hotkey (default is False)
ctx : Context
The context containing the OBS client to interact with.
"""
ctx.client.trigger_hotkey_by_key_sequence(key_id, shift, ctrl, alt, cmd)

View File

@@ -3,50 +3,96 @@
from typing import Annotated from typing import Annotated
import obsws_python as obsws import obsws_python as obsws
from cyclopts import App, Parameter import typer
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from . import console, util, validate from . import console, util, validate
from .context import Context from .alias import SubTyperAliasGroup
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='input', help='Commands for managing inputs in OBS') app = typer.Typer(cls=SubTyperAliasGroup)
@app.command(name=['list', 'ls']) @app.callback()
def list_( def main():
input: bool = False, """Control inputs in OBS."""
output: bool = False,
colour: bool = False,
ffmpeg: bool = False, @app.command('create | add')
vlc: bool = False, def create(
uuid: bool = False, ctx: typer.Context,
*, input_name: Annotated[
ctx: Annotated[Context, Parameter(parse=False)], str,
typer.Argument(
...,
show_default=False,
help='Name of the input to create.',
callback=validate.input_not_in_inputs,
),
],
input_kind: Annotated[
str,
typer.Argument(
...,
show_default=False,
help='Kind of the input to create.',
callback=validate.kind_in_input_kinds,
),
],
): ):
"""List all inputs. """Create a new input."""
current_scene = (
ctx.obj['obsws'].get_current_program_scene().current_program_scene_name
)
try:
ctx.obj['obsws'].create_input(
inputName=input_name,
inputKind=input_kind,
sceneItemEnabled=True,
sceneName=current_scene,
inputSettings={},
)
except obsws.error.OBSSDKRequestError as e:
console.err.print(f'Failed to create input: [yellow]{e}[/yellow]')
raise typer.Exit(1)
Parameters console.out.print(
---------- f'Input {console.highlight(ctx, input_name)} of kind '
input: f'{console.highlight(ctx, input_kind)} created.',
Filter by input type. )
output:
Filter by output type.
colour:
Filter by colour source type.
ffmpeg:
Filter by ffmpeg source type.
vlc:
Filter by VLC source type.
uuid:
Show UUIDs of inputs.
ctx:
The context containing the client and style.
"""
resp = ctx.client.get_input_list() @app.command('remove | rm')
def remove(
ctx: typer.Context,
input_name: Annotated[
str,
typer.Argument(
...,
show_default=False,
help='Name of the input to remove.',
callback=validate.input_in_inputs,
),
],
):
"""Remove an input."""
ctx.obj['obsws'].remove_input(name=input_name)
console.out.print(f'Input {console.highlight(ctx, input_name)} removed.')
@app.command('list | ls')
def list_(
ctx: typer.Context,
input: Annotated[bool, typer.Option(help='Filter by input type.')] = False,
output: Annotated[bool, typer.Option(help='Filter by output type.')] = False,
colour: Annotated[bool, typer.Option(help='Filter by colour source type.')] = False,
ffmpeg: Annotated[bool, typer.Option(help='Filter by ffmpeg source type.')] = False,
vlc: Annotated[bool, typer.Option(help='Filter by VLC source type.')] = False,
uuid: Annotated[bool, typer.Option(help='Show UUIDs of inputs.')] = False,
):
"""List all inputs."""
resp = ctx.obj['obsws'].get_input_list()
kinds = [] kinds = []
if input: if input:
@@ -60,7 +106,7 @@ def list_(
if vlc: if vlc:
kinds.append('vlc') kinds.append('vlc')
if not any([input, output, colour, ffmpeg, vlc]): if not any([input, output, colour, ffmpeg, vlc]):
kinds = ctx.client.get_input_kind_list(False).input_kinds kinds = ctx.obj['obsws'].get_input_kind_list(False).input_kinds
inputs = sorted( inputs = sorted(
( (
@@ -74,21 +120,21 @@ def list_(
) )
if not inputs: if not inputs:
console.out.print('No inputs found matching the specified filters.') console.out.print('No inputs found.')
return raise typer.Exit()
table = Table(title='Inputs', padding=(0, 2), border_style=ctx.style.border) table = Table(title='Inputs', padding=(0, 2), border_style=ctx.obj['style'].border)
if uuid: if uuid:
columns = [ columns = [
(Text('Input Name', justify='center'), 'left', ctx.style.column), (Text('Input Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Kind', justify='center'), 'center', ctx.style.column), (Text('Kind', justify='center'), 'center', ctx.obj['style'].column),
(Text('Muted', justify='center'), 'center', None), (Text('Muted', justify='center'), 'center', None),
(Text('UUID', justify='center'), 'left', ctx.style.column), (Text('UUID', justify='center'), 'left', ctx.obj['style'].column),
] ]
else: else:
columns = [ columns = [
(Text('Input Name', justify='center'), 'left', ctx.style.column), (Text('Input Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Kind', justify='center'), 'center', ctx.style.column), (Text('Kind', justify='center'), 'center', ctx.obj['style'].column),
(Text('Muted', justify='center'), 'center', None), (Text('Muted', justify='center'), 'center', None),
] ]
for heading, justify, style in columns: for heading, justify, style in columns:
@@ -97,7 +143,7 @@ def list_(
for input_name, input_kind, input_uuid in inputs: for input_name, input_kind, input_uuid in inputs:
input_mark = '' input_mark = ''
try: try:
input_muted = ctx.client.get_input_mute(name=input_name).input_muted input_muted = ctx.obj['obsws'].get_input_mute(name=input_name).input_muted
input_mark = util.check_mark(input_muted) input_mark = util.check_mark(input_muted)
except obsws.error.OBSSDKRequestError as e: except obsws.error.OBSSDKRequestError as e:
if e.code == 604: # Input does not support audio if e.code == 604: # Input does not support audio
@@ -122,30 +168,48 @@ def list_(
console.out.print(table) console.out.print(table)
@app.command(name=['mute', 'm']) @app.command('list-kinds | ls-k')
def mute( def list_kinds(
input_name: str, ctx: typer.Context,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
): ):
"""Mute an input. """List all input kinds."""
resp = ctx.obj['obsws'].get_input_kind_list(False)
kinds = sorted(resp.input_kinds)
Parameters if not kinds:
---------- console.out.print('No input kinds found.')
input_name: str raise typer.Exit()
Name of the input to mute.
ctx: Context
The context containing the client and style.
""" table = Table(
if not validate.input_in_inputs(ctx, input_name): title='Input Kinds', padding=(0, 2), border_style=ctx.obj['style'].border
raise OBSWSCLIError( )
f'Input [yellow]{input_name}[/yellow] not found.', table.add_column(
code=ExitCode.ERROR, Text('Input Kind', justify='center'),
justify='left',
style=ctx.obj['style'].column,
) )
ctx.client.set_input_mute( for kind in kinds:
table.add_row(util.snakecase_to_titlecase(kind))
console.out.print(table)
@app.command('mute | m')
def mute(
ctx: typer.Context,
input_name: Annotated[
str,
typer.Argument(
...,
show_default=False,
help='Name of the input to mute.',
callback=validate.input_in_inputs,
),
],
):
"""Mute an input."""
ctx.obj['obsws'].set_input_mute(
name=input_name, name=input_name,
muted=True, muted=True,
) )
@@ -153,30 +217,21 @@ def mute(
console.out.print(f'Input {console.highlight(ctx, input_name)} muted.') console.out.print(f'Input {console.highlight(ctx, input_name)} muted.')
@app.command(name=['unmute', 'um']) @app.command('unmute | um')
def unmute( def unmute(
input_name: str, ctx: typer.Context,
/, input_name: Annotated[
*, str,
ctx: Annotated[Context, Parameter(parse=False)], typer.Argument(
...,
show_default=False,
help='Name of the input to unmute.',
callback=validate.input_in_inputs,
),
],
): ):
"""Unmute an input. """Unmute an input."""
ctx.obj['obsws'].set_input_mute(
Parameters
----------
input_name: str
Name of the input to unmute.
ctx: Context
The context containing the client and style.
"""
if not validate.input_in_inputs(ctx, input_name):
raise OBSWSCLIError(
f'Input [yellow]{input_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
ctx.client.set_input_mute(
name=input_name, name=input_name,
muted=False, muted=False,
) )
@@ -184,33 +239,24 @@ def unmute(
console.out.print(f'Input {console.highlight(ctx, input_name)} unmuted.') console.out.print(f'Input {console.highlight(ctx, input_name)} unmuted.')
@app.command(name=['toggle', 'tg']) @app.command('toggle | tg')
def toggle( def toggle(
input_name: str, ctx: typer.Context,
/, input_name: Annotated[
*, str,
ctx: Annotated[Context, Parameter(parse=False)], typer.Argument(
...,
show_default=False,
help='Name of the input to toggle.',
callback=validate.input_in_inputs,
),
],
): ):
"""Toggle an input. """Toggle an input."""
resp = ctx.obj['obsws'].get_input_mute(name=input_name)
Parameters
----------
input_name: str
Name of the input to toggle.
ctx: Context
The context containing the client and style.
"""
if not validate.input_in_inputs(ctx, input_name):
raise OBSWSCLIError(
f'Input [yellow]{input_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
resp = ctx.client.get_input_mute(name=input_name)
new_state = not resp.input_muted new_state = not resp.input_muted
ctx.client.set_input_mute( ctx.obj['obsws'].set_input_mute(
name=input_name, name=input_name,
muted=new_state, muted=new_state,
) )
@@ -223,3 +269,188 @@ def toggle(
console.out.print( console.out.print(
f'Input {console.highlight(ctx, input_name)} unmuted.', f'Input {console.highlight(ctx, input_name)} unmuted.',
) )
@app.command('volume | vol')
def volume(
ctx: typer.Context,
input_name: Annotated[
str,
typer.Argument(
...,
show_default=False,
help='Name of the input to set volume for.',
callback=validate.input_in_inputs,
),
],
volume: Annotated[
float,
typer.Argument(
...,
show_default=False,
help='Volume level to set (-90 to 0).',
min=-90,
max=0,
),
],
):
"""Set the volume of an input."""
ctx.obj['obsws'].set_input_volume(
name=input_name,
vol_db=volume,
)
console.out.print(
f'Input {console.highlight(ctx, input_name)} volume set to {console.highlight(ctx, volume)}.',
)
@app.command('show | s')
def show(
ctx: typer.Context,
input_name: Annotated[
str,
typer.Argument(
...,
show_default=False,
help='Name of the input to show.',
callback=validate.input_in_inputs,
),
],
verbose: Annotated[
bool, typer.Option(help='List all available input devices.')
] = False,
):
"""Show information for an input in the current scene."""
input_list = ctx.obj['obsws'].get_input_list()
for input_ in input_list.inputs:
if input_['inputName'] == input_name:
input_kind = input_['inputKind']
break
for prop in ['device', 'device_id']:
try:
device_id = (
ctx.obj['obsws']
.get_input_settings(
name=input_name,
)
.input_settings.get(prop)
)
if device_id:
break
except obsws.error.OBSSDKRequestError:
continue
else:
device_id = '(N/A)'
for device in (
ctx.obj['obsws']
.get_input_properties_list_property_items(
input_name=input_name,
prop_name=prop,
)
.property_items
):
if device.get('itemValue') == device_id:
device_id = device.get('itemName')
break
table = Table(
title='Input Information', padding=(0, 2), border_style=ctx.obj['style'].border
)
columns = [
(Text('Input Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Kind', justify='center'), 'left', ctx.obj['style'].column),
(Text('Device', justify='center'), 'left', ctx.obj['style'].column),
]
for heading, justify, style in columns:
table.add_column(heading, justify=justify, style=style)
table.add_row(
input_name,
util.snakecase_to_titlecase(input_kind),
device_id,
)
console.out.print(table)
if verbose:
resp = ctx.obj['obsws'].get_input_properties_list_property_items(
input_name=input_name,
prop_name=prop,
)
table = Table(
title='Devices',
padding=(0, 2),
border_style=ctx.obj['style'].border,
)
columns = [
(Text('Name', justify='center'), 'left', ctx.obj['style'].column),
]
for heading, justify, style in columns:
table.add_column(heading, justify=justify, style=style)
for i, item in enumerate(resp.property_items):
table.add_row(
item.get('itemName'),
style='' if i % 2 == 0 else 'dim',
)
console.out.print(table)
@app.command('update | upd')
def update(
ctx: typer.Context,
input_name: Annotated[
str,
typer.Argument(
...,
show_default=False,
help='Name of the input to update.',
callback=validate.input_in_inputs,
),
],
device_name: Annotated[
str,
typer.Argument(
...,
show_default=False,
help='Name of the device to set for the input.',
),
],
):
"""Update a setting for an input."""
device_id = None
for prop in ['device', 'device_id']:
try:
for device in (
ctx.obj['obsws']
.get_input_properties_list_property_items(
input_name=input_name,
prop_name=prop,
)
.property_items
):
if device.get('itemName') == device_name:
device_id = device.get('itemValue')
break
except obsws.error.OBSSDKRequestError:
continue
if device_id:
break
if not device_id:
console.err.print(
f'Failed to find device ID for device '
f'{console.highlight(ctx, device_name)}.',
)
raise typer.Exit(1)
ctx.obj['obsws'].set_input_settings(
name=input_name, settings={prop: device_id}, overlay=True
)
console.out.print(
f'Input {console.highlight(ctx, input_name)} updated to use device '
f'{console.highlight(ctx, device_name)}.',
)

101
obsws_cli/media.py Normal file
View File

@@ -0,0 +1,101 @@
"""module containing commands for media inputs."""
from typing import Annotated, Optional
import typer
from . import console, util, validate
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
def main():
"""Commands for media inputs."""
@app.command('cursor | c')
def cursor(
ctx: typer.Context,
input_name: Annotated[
str, typer.Argument(..., help='The name of the media input.')
],
timecode: Annotated[
Optional[str],
typer.Argument(
...,
help='The timecode to set the cursor to (format: HH:MM:SS).',
callback=validate.timecode_format,
),
] = None,
):
"""Get/set the cursor position of a media input."""
if timecode is None:
resp = ctx.obj['obsws'].get_media_input_status(input_name)
console.out.print(
f'Cursor for {console.highlight(ctx, input_name)} is at {util.milliseconds_to_timecode(resp.media_cursor)}.'
)
return
cursor_position = util.timecode_to_milliseconds(timecode)
ctx.obj['obsws'].set_media_input_cursor(input_name, cursor_position)
console.out.print(
f'Cursor for {console.highlight(ctx, input_name)} set to {timecode}.'
)
@app.command('play | p')
def play(
ctx: typer.Context,
input_name: Annotated[
str, typer.Argument(..., help='The name of the media input.')
],
):
"""Get/set the playing status of a media input."""
ctx.obj['obsws'].trigger_media_input_action(
input_name, 'OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PLAY'
)
console.out.print(f'Playing media input {console.highlight(ctx, input_name)}.')
@app.command('pause | pa')
def pause(
ctx: typer.Context,
input_name: Annotated[
str, typer.Argument(..., help='The name of the media input.')
],
):
"""Pause a media input."""
ctx.obj['obsws'].trigger_media_input_action(
input_name, 'OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PAUSE'
)
console.out.print(f'Paused media input {console.highlight(ctx, input_name)}.')
@app.command('stop | s')
def stop(
ctx: typer.Context,
input_name: Annotated[
str, typer.Argument(..., help='The name of the media input.')
],
):
"""Stop a media input."""
ctx.obj['obsws'].trigger_media_input_action(
input_name, 'OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP'
)
console.out.print(f'Stopped media input {console.highlight(ctx, input_name)}.')
@app.command('restart | r')
def restart(
ctx: typer.Context,
input_name: Annotated[
str, typer.Argument(..., help='The name of the media input.')
],
):
"""Restart a media input."""
ctx.obj['obsws'].trigger_media_input_action(
input_name, 'OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART'
)
console.out.print(f'Restarted media input {console.highlight(ctx, input_name)}.')

View File

@@ -2,45 +2,40 @@
from typing import Annotated from typing import Annotated
from cyclopts import App, Parameter import typer
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from . import console, util, validate from . import console, util, validate
from .context import Context from .alias import SubTyperAliasGroup
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='profile', help='Commands for managing profiles in OBS') app = typer.Typer(cls=SubTyperAliasGroup)
@app.command(name=['list', 'ls']) @app.callback()
def list_( def main():
*, """Control profiles in OBS."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""List profiles.
Parameters
----------
ctx: Context
The context containing the client and style.
""" @app.command('list | ls')
resp = ctx.client.get_profile_list() def list_(ctx: typer.Context):
"""List profiles."""
resp = ctx.obj['obsws'].get_profile_list()
table = Table(title='Profiles', padding=(0, 2), border_style=ctx.style.border) if not resp.profiles:
console.out.print('No profiles found.')
raise typer.Exit()
table = Table(
title='Profiles', padding=(0, 2), border_style=ctx.obj['style'].border
)
columns = [ columns = [
(Text('Profile Name', justify='center'), 'left', ctx.style.column), (Text('Profile Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Current', justify='center'), 'center', None), (Text('Current', justify='center'), 'center', None),
] ]
for heading, justify, style in columns: for heading, justify, style in columns:
table.add_column(heading, justify=justify, style=style) table.add_column(heading, justify=justify, style=style)
if not resp.profiles:
console.out.print('No profiles found.')
return
for profile in resp.profiles: for profile in resp.profiles:
table.add_row( table.add_row(
profile, profile,
@@ -52,110 +47,71 @@ def list_(
console.out.print(table) console.out.print(table)
@app.command(name=['current', 'get']) @app.command('current | get')
def current( def current(ctx: typer.Context):
*, """Get the current profile."""
ctx: Annotated[Context, Parameter(parse=False)], resp = ctx.obj['obsws'].get_profile_list()
):
"""Get the current profile.
Parameters
----------
ctx: Context
The context containing the client and style.
"""
resp = ctx.client.get_profile_list()
console.out.print( console.out.print(
f'Current profile: {console.highlight(ctx, resp.current_profile_name)}' f'Current profile: {console.highlight(ctx, resp.current_profile_name)}'
) )
@app.command(name=['switch', 'set']) @app.command('switch | set')
def switch( def switch(
profile_name: str, ctx: typer.Context,
/, profile_name: Annotated[
*, str,
ctx: Annotated[Context, Parameter(parse=False)], typer.Argument(
...,
show_default=False,
help='Name of the profile to switch to',
callback=validate.profile_exists,
),
],
): ):
"""Switch to a profile. """Switch to a profile."""
resp = ctx.obj['obsws'].get_profile_list()
Parameters
----------
profile_name: str
Name of the profile to switch to.
ctx: Context
The context containing the client and style.
"""
if not validate.profile_exists(ctx, profile_name):
console.err.print(f'Profile [yellow]{profile_name}[/yellow] not found.')
raise OBSWSCLIError(
f'Profile [yellow]{profile_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
resp = ctx.client.get_profile_list()
if resp.current_profile_name == profile_name: if resp.current_profile_name == profile_name:
raise OBSWSCLIError( console.err.print(
f'Profile [yellow]{profile_name}[/yellow] is already the current profile.', f'Profile [yellow]{profile_name}[/yellow] is already the current profile.'
code=ExitCode.ERROR,
) )
raise typer.Exit(1)
ctx.client.set_current_profile(profile_name) ctx.obj['obsws'].set_current_profile(profile_name)
console.out.print(f'Switched to profile {console.highlight(ctx, profile_name)}.') console.out.print(f'Switched to profile {console.highlight(ctx, profile_name)}.')
@app.command(name=['create', 'new']) @app.command('create | new')
def create( def create(
profile_name: str, ctx: typer.Context,
/, profile_name: Annotated[
*, str,
ctx: Annotated[Context, Parameter(parse=False)], typer.Argument(
...,
show_default=False,
help='Name of the profile to create.',
callback=validate.profile_not_exists,
),
],
): ):
"""Create a new profile. """Create a new profile."""
ctx.obj['obsws'].create_profile(profile_name)
Parameters
----------
profile_name: str
Name of the profile to create.
ctx: Context
The context containing the client and style.
"""
if validate.profile_exists(ctx, profile_name):
raise OBSWSCLIError(
f'Profile [yellow]{profile_name}[/yellow] already exists.',
code=ExitCode.ERROR,
)
ctx.client.create_profile(profile_name)
console.out.print(f'Created profile {console.highlight(ctx, profile_name)}.') console.out.print(f'Created profile {console.highlight(ctx, profile_name)}.')
@app.command(name=['remove', 'rm']) @app.command('remove | rm')
def remove( def remove(
profile_name: str, ctx: typer.Context,
/, profile_name: Annotated[
*, str,
ctx: Annotated[Context, Parameter(parse=False)], typer.Argument(
...,
show_default=False,
help='Name of the profile to remove.',
callback=validate.profile_exists,
),
],
): ):
"""Remove a profile. """Remove a profile."""
ctx.obj['obsws'].remove_profile(profile_name)
Parameters
----------
profile_name: str
Name of the profile to remove.
ctx: Context
The context containing the client and style.
"""
if not validate.profile_exists(ctx, profile_name):
console.err.print(f'Profile [yellow]{profile_name}[/yellow] not found.')
raise OBSWSCLIError(
f'Profile [yellow]{profile_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
ctx.client.remove_profile(profile_name)
console.out.print(f'Removed profile {console.highlight(ctx, profile_name)}.') console.out.print(f'Removed profile {console.highlight(ctx, profile_name)}.')

View File

@@ -1,57 +1,45 @@
"""module containing commands for manipulating projectors in OBS.""" """module containing commands for manipulating projectors in OBS."""
from typing import Annotated, Optional from typing import Annotated
from cyclopts import App, Parameter, validators import typer
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from . import console from . import console
from .context import Context from .alias import SubTyperAliasGroup
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='projector', help='Commands for managing projectors in OBS') app = typer.Typer(cls=SubTyperAliasGroup)
@app.command(name=['list-monitors', 'ls-m']) @app.callback()
def list_monitors( def main():
*, """Control projectors in OBS."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""List available monitors.
Parameters
----------
ctx : Context
The context containing the OBS client and configuration.
"""
resp = ctx.client.get_monitor_list()
if not resp.monitors:
console.out.print('No monitors found.')
return
@app.command('list-monitors | ls-m')
def list_monitors(ctx: typer.Context):
"""List available monitors."""
resp = ctx.obj['obsws'].get_monitor_list()
monitors = sorted( monitors = sorted(
((m['monitorIndex'], m['monitorName']) for m in resp.monitors), ((m['monitorIndex'], m['monitorName']) for m in resp.monitors),
key=lambda m: m[0], key=lambda m: m[0],
) )
if not monitors: if not monitors:
console.out.print('No monitors available.') console.out.print('No monitors found.')
return raise typer.Exit()
table = Table( table = Table(
title='Available Monitors', title='Available Monitors',
padding=(0, 2), padding=(0, 2),
border_style=ctx.style.border, border_style=ctx.obj['style'].border,
) )
table.add_column( table.add_column(
Text('Index', justify='center'), justify='center', style=ctx.style.column Text('Index', justify='center'), justify='center', style=ctx.obj['style'].column
) )
table.add_column( table.add_column(
Text('Name', justify='center'), justify='left', style=ctx.style.column Text('Name', justify='center'), justify='left', style=ctx.obj['style'].column
) )
for index, monitor in monitors: for index, monitor in monitors:
@@ -60,33 +48,29 @@ def list_monitors(
console.out.print(table) console.out.print(table)
@app.command(name=['open', 'o']) @app.command('open | o')
def open( def open(
source_name: Optional[str] = None, ctx: typer.Context,
/, monitor_index: Annotated[
monitor_index: Annotated[int, Parameter(validator=validators.Number(gte=0))] = 0, int,
*, typer.Option(help='Index of the monitor to open the projector on.'),
ctx: Annotated[Context, Parameter(parse=False)], ] = 0,
source_name: Annotated[
str,
typer.Argument(
show_default='The current scene',
help='Name of the source to project.',
),
] = '',
): ):
"""Open a fullscreen projector for a source on a specific monitor. """Open a fullscreen projector for a source on a specific monitor."""
Parameters
----------
source_name : str, optional
The name of the source to project. If not provided, the current program scene will be used.
monitor_index : int, optional
The index of the monitor to open the projector on. Defaults to 0 (the primary monitor).
ctx : Context
The context containing the OBS client and configuration.
"""
if not source_name: if not source_name:
source_name = ctx.client.get_current_program_scene().scene_name source_name = ctx.obj['obsws'].get_current_program_scene().scene_name
monitors = ctx.client.get_monitor_list().monitors monitors = ctx.obj['obsws'].get_monitor_list().monitors
for monitor in monitors: for monitor in monitors:
if monitor['monitorIndex'] == monitor_index: if monitor['monitorIndex'] == monitor_index:
ctx.client.open_source_projector( ctx.obj['obsws'].open_source_projector(
source_name=source_name, source_name=source_name,
monitor_index=monitor_index, monitor_index=monitor_index,
) )
@@ -97,8 +81,8 @@ def open(
break break
else: else:
raise OBSWSCLIError( console.err.print(
f'Monitor with index [yellow]{monitor_index}[/yellow] not found. ' f'Monitor with index [yellow]{monitor_index}[/yellow] not found. '
f'Use [yellow]obsws-cli projector ls-m[/yellow] to see available monitors.', f'Use [yellow]obsws-cli projector ls-m[/yellow] to see available monitors.'
ExitCode.ERROR,
) )
raise typer.Exit(code=1)

View File

@@ -3,104 +3,68 @@
from pathlib import Path from pathlib import Path
from typing import Annotated, Optional from typing import Annotated, Optional
from cyclopts import App, Parameter import typer
from . import console from . import console
from .context import Context from .alias import SubTyperAliasGroup
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='record', help='Commands for controlling OBS recording functionality.') app = typer.Typer(cls=SubTyperAliasGroup)
def _get_recording_status(ctx: Context) -> tuple: @app.callback()
def main():
"""Control OBS recording functionality."""
def _get_recording_status(ctx: typer.Context) -> tuple:
"""Get recording status.""" """Get recording status."""
resp = ctx.client.get_record_status() resp = ctx.obj['obsws'].get_record_status()
return resp.output_active, resp.output_paused return resp.output_active, resp.output_paused
@app.command(name=['start', 's']) @app.command('start | s')
def start( def start(ctx: typer.Context):
*, """Start recording."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Start recording.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
active, paused = _get_recording_status(ctx) active, paused = _get_recording_status(ctx)
if active: if active:
err_msg = 'Recording is already in progress, cannot start.' err_msg = 'Recording is already in progress, cannot start.'
if paused: if paused:
err_msg += ' Try resuming it.' err_msg += ' Try resuming it.'
raise OBSWSCLIError(err_msg, ExitCode.ERROR)
ctx.client.start_record() console.err.print(err_msg)
raise typer.Exit(1)
ctx.obj['obsws'].start_record()
console.out.print('Recording started successfully.') console.out.print('Recording started successfully.')
@app.command(name=['stop', 'st']) @app.command('stop | st')
def stop( def stop(ctx: typer.Context):
*, """Stop recording."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Stop recording.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
active, _ = _get_recording_status(ctx) active, _ = _get_recording_status(ctx)
if not active: if not active:
raise OBSWSCLIError( console.err.print('Recording is not in progress, cannot stop.')
'Recording is not in progress, cannot stop.', ExitCode.ERROR raise typer.Exit(1)
)
resp = ctx.client.stop_record() resp = ctx.obj['obsws'].stop_record()
console.out.print( console.out.print(
f'Recording stopped successfully. Saved to: {console.highlight(ctx, resp.output_path)}' f'Recording stopped successfully. Saved to: {console.highlight(ctx, resp.output_path)}'
) )
@app.command(name=['toggle', 'tg']) @app.command('toggle | tg')
def toggle( def toggle(ctx: typer.Context):
*, """Toggle recording."""
ctx: Annotated[Context, Parameter(parse=False)], resp = ctx.obj['obsws'].toggle_record()
):
"""Toggle recording.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
resp = ctx.client.toggle_record()
if resp.output_active: if resp.output_active:
console.out.print('Recording started successfully.') console.out.print('Recording started successfully.')
else: else:
console.out.print('Recording stopped successfully.') console.out.print('Recording stopped successfully.')
@app.command(name=['status', 'ss']) @app.command('status | ss')
def status( def status(ctx: typer.Context):
*, """Get recording status."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get recording status.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
active, paused = _get_recording_status(ctx) active, paused = _get_recording_status(ctx)
if active: if active:
if paused: if paused:
@@ -111,143 +75,98 @@ def status(
console.out.print('Recording is not in progress.') console.out.print('Recording is not in progress.')
@app.command(name=['resume', 'r']) @app.command('resume | r')
def resume( def resume(ctx: typer.Context):
*, """Resume recording."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Resume recording.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
active, paused = _get_recording_status(ctx) active, paused = _get_recording_status(ctx)
if not active: if not active:
raise OBSWSCLIError( console.err.print('Recording is not in progress, cannot resume.')
'Recording is not in progress, cannot resume.', ExitCode.ERROR raise typer.Exit(1)
)
if not paused: if not paused:
raise OBSWSCLIError( console.err.print('Recording is in progress but not paused, cannot resume.')
'Recording is in progress but not paused, cannot resume.', ExitCode.ERROR raise typer.Exit(1)
)
ctx.client.resume_record() ctx.obj['obsws'].resume_record()
console.out.print('Recording resumed successfully.') console.out.print('Recording resumed successfully.')
@app.command(name=['pause', 'p']) @app.command('pause | p')
def pause( def pause(ctx: typer.Context):
*, """Pause recording."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Pause recording.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
active, paused = _get_recording_status(ctx) active, paused = _get_recording_status(ctx)
if not active: if not active:
raise OBSWSCLIError( console.err.print('Recording is not in progress, cannot pause.')
'Recording is not in progress, cannot pause.', ExitCode.ERROR raise typer.Exit(1)
)
if paused: if paused:
raise OBSWSCLIError( console.err.print('Recording is in progress but already paused, cannot pause.')
'Recording is in progress but already paused, cannot pause.', ExitCode.ERROR raise typer.Exit(1)
)
ctx.client.pause_record() ctx.obj['obsws'].pause_record()
console.out.print('Recording paused successfully.') console.out.print('Recording paused successfully.')
@app.command(name=['directory', 'd']) @app.command('directory | d')
def directory( def directory(
ctx: typer.Context,
record_directory: Annotated[
Optional[Path],
# Since the CLI and OBS may be running on different platforms, # Since the CLI and OBS may be running on different platforms,
# we won't validate the path here. # we won't validate the path here.
record_directory: Optional[Path] = None, typer.Argument(
*, file_okay=False,
ctx: Annotated[Context, Parameter(parse=False)], dir_okay=True,
help='Directory to set for recording.',
),
] = None,
): ):
"""Get or set the recording directory. """Get or set the recording directory."""
Parameters
----------
record_directory: Optional[Path]
The directory to set for recording. If not provided, the current recording directory is displayed.
ctx: Context
The context containing the OBS client and other settings.
"""
if record_directory is not None: if record_directory is not None:
ctx.client.set_record_directory(str(record_directory)) ctx.obj['obsws'].set_record_directory(str(record_directory))
console.out.print( console.out.print(
f'Recording directory updated to: {console.highlight(ctx, record_directory)}' f'Recording directory updated to: {console.highlight(ctx, record_directory)}'
) )
else: else:
resp = ctx.client.get_record_directory() resp = ctx.obj['obsws'].get_record_directory()
console.out.print( console.out.print(
f'Recording directory: {console.highlight(ctx, resp.record_directory)}' f'Recording directory: {console.highlight(ctx, resp.record_directory)}'
) )
@app.command(name=['split', 'sp']) @app.command('split | sp')
def split( def split(ctx: typer.Context):
*, """Split the current recording."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Split the current recording.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
active, paused = _get_recording_status(ctx) active, paused = _get_recording_status(ctx)
if not active: if not active:
console.err.print('Recording is not in progress, cannot split.') console.err.print('Recording is not in progress, cannot split.')
raise OBSWSCLIError( raise typer.Exit(1)
'Recording is not in progress, cannot split.', ExitCode.ERROR
)
if paused: if paused:
raise OBSWSCLIError('Recording is paused, cannot split.', ExitCode.ERROR) console.err.print('Recording is paused, cannot split.')
raise typer.Exit(1)
ctx.client.split_record_file() ctx.obj['obsws'].split_record_file()
console.out.print('Recording split successfully.') console.out.print('Recording split successfully.')
@app.command(name=['chapter', 'ch']) @app.command('chapter | ch')
def chapter( def chapter(
chapter_name: Optional[str] = None, ctx: typer.Context,
*, chapter_name: Annotated[
ctx: Annotated[Context, Parameter(parse=False)], Optional[str],
typer.Argument(
help='Name of the chapter to create.',
),
] = None,
): ):
"""Create a chapter in the current recording. """Create a chapter in the current recording."""
Parameters
----------
chapter_name: Optional[str]
The name of the chapter to create. If not provided, an unnamed chapter is created.
ctx: Context
The context containing the OBS client and other settings.
"""
active, paused = _get_recording_status(ctx) active, paused = _get_recording_status(ctx)
if not active: if not active:
raise OBSWSCLIError( console.err.print('Recording is not in progress, cannot create chapter.')
'Recording is not in progress, cannot create chapter.', ExitCode.ERROR raise typer.Exit(1)
)
if paused: if paused:
raise OBSWSCLIError( console.err.print('Recording is paused, cannot create chapter.')
'Recording is paused, cannot create chapter.', ExitCode.ERROR raise typer.Exit(1)
)
ctx.client.create_record_chapter(chapter_name) ctx.obj['obsws'].create_record_chapter(chapter_name)
console.out.print( console.out.print(
f'Chapter {console.highlight(ctx, chapter_name or "unnamed")} created successfully.' f'Chapter {console.highlight(ctx, chapter_name or "unnamed")} created successfully.'
) )

View File

@@ -1,113 +1,64 @@
"""module containing commands for manipulating the replay buffer in OBS.""" """module containing commands for manipulating the replay buffer in OBS."""
from typing import Annotated import typer
from cyclopts import App, Parameter
from . import console from . import console
from .context import Context from .alias import SubTyperAliasGroup
from .enum import ExitCode
from .error import OBSWSCLIError
app = App( app = typer.Typer(cls=SubTyperAliasGroup)
name='replaybuffer', help='Commands for controlling the replay buffer in OBS.'
)
@app.command(name=['start', 's']) @app.callback()
def start( def main():
*, """Control profiles in OBS."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Start the replay buffer.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
""" @app.command('start | s')
resp = ctx.client.get_replay_buffer_status() def start(ctx: typer.Context):
"""Start the replay buffer."""
resp = ctx.obj['obsws'].get_replay_buffer_status()
if resp.output_active: if resp.output_active:
raise OBSWSCLIError('Replay buffer is already active.', ExitCode.ERROR) console.err.print('Replay buffer is already active.')
raise typer.Exit(1)
ctx.client.start_replay_buffer() ctx.obj['obsws'].start_replay_buffer()
console.out.print('Replay buffer started.') console.out.print('Replay buffer started.')
@app.command(name=['stop', 'st']) @app.command('stop | st')
def stop( def stop(ctx: typer.Context):
*, """Stop the replay buffer."""
ctx: Annotated[Context, Parameter(parse=False)], resp = ctx.obj['obsws'].get_replay_buffer_status()
):
"""Stop the replay buffer.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
resp = ctx.client.get_replay_buffer_status()
if not resp.output_active: if not resp.output_active:
raise OBSWSCLIError('Replay buffer is not active.', ExitCode.ERROR) console.err.print('Replay buffer is not active.')
raise typer.Exit(1)
ctx.client.stop_replay_buffer() ctx.obj['obsws'].stop_replay_buffer()
console.out.print('Replay buffer stopped.') console.out.print('Replay buffer stopped.')
@app.command(name=['toggle', 'tg']) @app.command('toggle | tg')
def toggle( def toggle(ctx: typer.Context):
*, """Toggle the replay buffer."""
ctx: Annotated[Context, Parameter(parse=False)], resp = ctx.obj['obsws'].toggle_replay_buffer()
):
"""Toggle the replay buffer.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
resp = ctx.client.toggle_replay_buffer()
if resp.output_active: if resp.output_active:
console.out.print('Replay buffer is active.') console.out.print('Replay buffer is active.')
else: else:
console.out.print('Replay buffer is not active.') console.out.print('Replay buffer is not active.')
@app.command(name=['status', 'ss']) @app.command('status | ss')
def status( def status(ctx: typer.Context):
*, """Get the status of the replay buffer."""
ctx: Annotated[Context, Parameter(parse=False)], resp = ctx.obj['obsws'].get_replay_buffer_status()
):
"""Get the status of the replay buffer.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
resp = ctx.client.get_replay_buffer_status()
if resp.output_active: if resp.output_active:
console.out.print('Replay buffer is active.') console.out.print('Replay buffer is active.')
else: else:
console.out.print('Replay buffer is not active.') console.out.print('Replay buffer is not active.')
@app.command(name=['save', 'sv']) @app.command('save | sv')
def save( def save(ctx: typer.Context):
*, """Save the replay buffer."""
ctx: Annotated[Context, Parameter(parse=False)], ctx.obj['obsws'].save_replay_buffer()
):
"""Save the replay buffer.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
ctx.client.save_replay_buffer()
console.out.print('Replay buffer saved.') console.out.print('Replay buffer saved.')

View File

@@ -2,35 +2,28 @@
from typing import Annotated from typing import Annotated
from cyclopts import App, Parameter import typer
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from . import console, util, validate from . import console, util, validate
from .context import Context from .alias import SubTyperAliasGroup
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='scene', help='Commands for managing OBS scenes') app = typer.Typer(cls=SubTyperAliasGroup)
@app.command(name=['list', 'ls']) @app.callback()
def main():
"""Control OBS scenes."""
@app.command('list | ls')
def list_( def list_(
uuid: bool = False, ctx: typer.Context,
*, uuid: Annotated[bool, typer.Option(help='Show UUIDs of scenes')] = False,
ctx: Annotated[Context, Parameter(parse=False)],
): ):
"""List all scenes. """List all scenes."""
resp = ctx.obj['obsws'].get_scene_list()
Parameters
----------
uuid : bool
Show UUIDs of scenes.
ctx : Context
The context containing the OBS client and configuration.
"""
resp = ctx.client.get_scene_list()
scenes = ( scenes = (
(scene.get('sceneName'), scene.get('sceneUuid')) (scene.get('sceneName'), scene.get('sceneUuid'))
for scene in reversed(resp.scenes) for scene in reversed(resp.scenes)
@@ -38,20 +31,20 @@ def list_(
if not scenes: if not scenes:
console.out.print('No scenes found.') console.out.print('No scenes found.')
return raise typer.Exit()
active_scene = ctx.client.get_current_program_scene().scene_name active_scene = ctx.obj['obsws'].get_current_program_scene().scene_name
table = Table(title='Scenes', padding=(0, 2), border_style=ctx.style.border) table = Table(title='Scenes', padding=(0, 2), border_style=ctx.obj['style'].border)
if uuid: if uuid:
columns = [ columns = [
(Text('Scene Name', justify='center'), 'left', ctx.style.column), (Text('Scene Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Active', justify='center'), 'center', None), (Text('Active', justify='center'), 'center', None),
(Text('UUID', justify='center'), 'left', ctx.style.column), (Text('UUID', justify='center'), 'left', ctx.obj['style'].column),
] ]
else: else:
columns = [ columns = [
(Text('Scene Name', justify='center'), 'left', ctx.style.column), (Text('Scene Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Active', justify='center'), 'center', None), (Text('Active', justify='center'), 'center', None),
] ]
for heading, justify, style in columns: for heading, justify, style in columns:
@@ -73,79 +66,57 @@ def list_(
console.out.print(table) console.out.print(table)
@app.command(name=['current', 'get']) @app.command('current | get')
def current( def current(
preview: bool = False, ctx: typer.Context,
*, preview: Annotated[
ctx: Annotated[Context, Parameter(parse=False)], bool,
typer.Option(
help='Get the preview scene instead of the program scene',
callback=validate.studio_mode_enabled,
),
] = False,
): ):
"""Get the current program scene or preview scene. """Get the current program scene or preview scene."""
Parameters
----------
preview : bool
If True, get the preview scene instead of the program scene.
ctx : Context
The context containing the OBS client and configuration.
"""
if preview and not validate.studio_mode_enabled(ctx):
raise OBSWSCLIError(
'Studio mode is not enabled, cannot get preview scene.',
code=ExitCode.ERROR,
)
if preview: if preview:
resp = ctx.client.get_current_preview_scene() resp = ctx.obj['obsws'].get_current_preview_scene()
console.out.print( console.out.print(
f'Current Preview Scene: {console.highlight(ctx, resp.current_preview_scene_name)}' f'Current Preview Scene: {console.highlight(ctx, resp.current_preview_scene_name)}'
) )
else: else:
resp = ctx.client.get_current_program_scene() resp = ctx.obj['obsws'].get_current_program_scene()
console.out.print( console.out.print(
f'Current Program Scene: {console.highlight(ctx, resp.current_program_scene_name)}' f'Current Program Scene: {console.highlight(ctx, resp.current_program_scene_name)}'
) )
@app.command(name=['switch', 'set']) @app.command('switch | set')
def switch( def switch(
scene_name: str, ctx: typer.Context,
/, scene_name: Annotated[
preview: bool = False, str,
*, typer.Argument(
ctx: Annotated[Context, Parameter(parse=False)], ...,
help='Name of the scene to switch to',
callback=validate.scene_in_scenes,
),
],
preview: Annotated[
bool,
typer.Option(
help='Switch to the preview scene instead of the program scene',
callback=validate.studio_mode_enabled,
),
] = False,
): ):
"""Switch to a scene. """Switch to a scene."""
Parameters
----------
scene_name : str
The name of the scene to switch to.
preview : bool
If True, switch to the preview scene instead of the program scene.
ctx : Context
The context containing the OBS client and configuration.
"""
if preview and not validate.studio_mode_enabled(ctx):
raise OBSWSCLIError(
'Studio mode is not enabled, cannot switch to preview scene.',
code=ExitCode.ERROR,
)
if not validate.scene_in_scenes(ctx, scene_name):
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
if preview: if preview:
ctx.client.set_current_preview_scene(scene_name) ctx.obj['obsws'].set_current_preview_scene(scene_name)
console.out.print( console.out.print(
f'Switched to preview scene: {console.highlight(ctx, scene_name)}' f'Switched to preview scene: {console.highlight(ctx, scene_name)}'
) )
else: else:
ctx.client.set_current_program_scene(scene_name) ctx.obj['obsws'].set_current_program_scene(scene_name)
console.out.print( console.out.print(
f'Switched to program scene: {console.highlight(ctx, scene_name)}' f'Switched to program scene: {console.highlight(ctx, scene_name)}'
) )

View File

@@ -2,44 +2,37 @@
from typing import Annotated from typing import Annotated
from cyclopts import App, Parameter import typer
from rich.table import Table from rich.table import Table
from . import console, validate from . import console, validate
from .context import Context from .alias import SubTyperAliasGroup
from .enum import ExitCode
from .error import OBSWSCLIError
app = App( app = typer.Typer(cls=SubTyperAliasGroup)
name='scenecollection', help='Commands for controlling scene collections in OBS.'
)
@app.command(name=['list', 'ls']) @app.callback()
def list_( def main():
*, """Control scene collections in OBS."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""List all scene collections.
Parameters
----------
ctx : Context
The context containing the OBS client and configuration.
""" @app.command('list | ls')
resp = ctx.client.get_scene_collection_list() def list_(ctx: typer.Context):
"""List all scene collections."""
resp = ctx.obj['obsws'].get_scene_collection_list()
if not resp.scene_collections:
console.out.print('No scene collections found.')
raise typer.Exit()
table = Table( table = Table(
title='Scene Collections', title='Scene Collections',
padding=(0, 2), padding=(0, 2),
border_style=ctx.style.border, border_style=ctx.obj['style'].border,
)
table.add_column(
'Scene Collection Name', justify='left', style=ctx.obj['style'].column
) )
table.add_column('Scene Collection Name', justify='left', style=ctx.style.column)
if not resp.scene_collections:
console.out.print('No scene collections found.')
return
for scene_collection_name in resp.scene_collections: for scene_collection_name in resp.scene_collections:
table.add_row(scene_collection_name) table.add_row(scene_collection_name)
@@ -47,87 +40,57 @@ def list_(
console.out.print(table) console.out.print(table)
@app.command(name=['current', 'get']) @app.command('current | get')
def current( def current(ctx: typer.Context):
*, """Get the current scene collection."""
ctx: Annotated[Context, Parameter(parse=False)], resp = ctx.obj['obsws'].get_scene_collection_list()
):
"""Get the current scene collection.
Parameters
----------
ctx : Context
The context containing the OBS client and configuration.
"""
resp = ctx.client.get_scene_collection_list()
console.out.print( console.out.print(
f'Current scene collection: {console.highlight(ctx, resp.current_scene_collection_name)}' f'Current scene collection: {console.highlight(ctx, resp.current_scene_collection_name)}'
) )
@app.command(name=['switch', 'set']) @app.command('switch | set')
def switch( def switch(
scene_collection_name: str, ctx: typer.Context,
/, scene_collection_name: Annotated[
*, str,
ctx: Annotated[Context, Parameter(parse=False)], typer.Argument(
...,
help='Name of the scene collection to switch to',
callback=validate.scene_collection_in_scene_collections,
),
],
): ):
"""Switch to a scene collection. """Switch to a scene collection."""
Parameters
----------
scene_collection_name : str
The name of the scene collection to switch to.
ctx : Context
The context containing the OBS client and configuration.
"""
if not validate.scene_collection_in_scene_collections(ctx, scene_collection_name):
raise OBSWSCLIError(
f'Scene collection [yellow]{scene_collection_name}[/yellow] not found.',
exit_code=ExitCode.ERROR,
)
current_scene_collection = ( current_scene_collection = (
ctx.client.get_scene_collection_list().current_scene_collection_name ctx.obj['obsws'].get_scene_collection_list().current_scene_collection_name
) )
if scene_collection_name == current_scene_collection: if scene_collection_name == current_scene_collection:
raise OBSWSCLIError( console.err.print(
f'Scene collection [yellow]{scene_collection_name}[/yellow] is already active.', f'Scene collection [yellow]{scene_collection_name}[/yellow] is already active.'
exit_code=ExitCode.ERROR,
) )
raise typer.Exit(1)
ctx.client.set_current_scene_collection(scene_collection_name) ctx.obj['obsws'].set_current_scene_collection(scene_collection_name)
console.out.print( console.out.print(
f'Switched to scene collection {console.highlight(ctx, scene_collection_name)}.' f'Switched to scene collection {console.highlight(ctx, scene_collection_name)}.'
) )
@app.command(name=['create', 'new']) @app.command('create | new')
def create( def create(
scene_collection_name: str, ctx: typer.Context,
/, scene_collection_name: Annotated[
*, str,
ctx: Annotated[Context, Parameter(parse=False)], typer.Argument(
...,
help='Name of the scene collection to create',
callback=validate.scene_collection_not_in_scene_collections,
),
],
): ):
"""Create a new scene collection. """Create a new scene collection."""
ctx.obj['obsws'].create_scene_collection(scene_collection_name)
Parameters
----------
scene_collection_name : str
The name of the scene collection to create.
ctx : Context
The context containing the OBS client and configuration.
"""
if validate.scene_collection_in_scene_collections(ctx, scene_collection_name):
raise OBSWSCLIError(
f'Scene collection [yellow]{scene_collection_name}[/yellow] already exists.',
exit_code=ExitCode.ERROR,
)
ctx.client.create_scene_collection(scene_collection_name)
console.out.print( console.out.print(
f'Created scene collection {console.highlight(ctx, scene_collection_name)}.' f'Created scene collection {console.highlight(ctx, scene_collection_name)}.'
) )

View File

@@ -2,49 +2,38 @@
from typing import Annotated, Optional from typing import Annotated, Optional
from cyclopts import App, Parameter import typer
from rich.table import Table from rich.table import Table
from . import console, util, validate from . import console, util, validate
from .context import Context from .alias import SubTyperAliasGroup
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='sceneitem', help='Commands for controlling scene items in OBS.') app = typer.Typer(cls=SubTyperAliasGroup)
@app.command(name=['list', 'ls']) @app.callback()
def main():
"""Control items in OBS scenes."""
@app.command('list | ls')
def list_( def list_(
scene_name: Optional[str] = None, ctx: typer.Context,
/, scene_name: Annotated[
uuid: bool = False, Optional[str],
*, typer.Argument(
ctx: Annotated[Context, Parameter(parse=False)], show_default='The current scene',
help='Scene name to list items for',
callback=validate.scene_in_scenes,
),
] = None,
uuid: Annotated[bool, typer.Option(help='Show UUIDs of scene items')] = False,
): ):
"""List all items in a scene. """List all items in a scene."""
if scene_name is None:
scene_name = ctx.obj['obsws'].get_current_program_scene().scene_name
Parameters resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
----------
scene_name : str, optional
The name of the scene to list items for. If not provided, the current program scene
will be used.
uuid : bool
Show UUIDs of scene items.
ctx : Context
The context containing the OBS client and configuration.
"""
if not scene_name:
scene_name = ctx.client.get_current_program_scene().scene_name
if not validate.scene_in_scenes(ctx, scene_name):
console.err.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
exit_code=ExitCode.ERROR,
)
resp = ctx.client.get_scene_item_list(scene_name)
items = sorted( items = sorted(
( (
( (
@@ -63,26 +52,26 @@ def list_(
console.out.print( console.out.print(
f'No items found in scene {console.highlight(ctx, scene_name)}.' f'No items found in scene {console.highlight(ctx, scene_name)}.'
) )
return raise typer.Exit()
table = Table( table = Table(
title=f'Items in Scene: {scene_name}', title=f'Items in Scene: {scene_name}',
padding=(0, 2), padding=(0, 2),
border_style=ctx.style.border, border_style=ctx.obj['style'].border,
) )
if uuid: if uuid:
columns = [ columns = [
('Item ID', 'center', ctx.style.column), ('Item ID', 'center', ctx.obj['style'].column),
('Item Name', 'left', ctx.style.column), ('Item Name', 'left', ctx.obj['style'].column),
('In Group', 'left', ctx.style.column), ('In Group', 'left', ctx.obj['style'].column),
('Enabled', 'center', None), ('Enabled', 'center', None),
('UUID', 'left', ctx.style.column), ('UUID', 'left', ctx.obj['style'].column),
] ]
else: else:
columns = [ columns = [
('Item ID', 'center', ctx.style.column), ('Item ID', 'center', ctx.obj['style'].column),
('Item Name', 'left', ctx.style.column), ('Item Name', 'left', ctx.obj['style'].column),
('In Group', 'left', ctx.style.column), ('In Group', 'left', ctx.obj['style'].column),
('Enabled', 'center', None), ('Enabled', 'center', None),
] ]
# Add columns to the table # Add columns to the table
@@ -91,7 +80,7 @@ def list_(
for item_id, item_name, is_group, is_enabled, source_uuid in items: for item_id, item_name, is_group, is_enabled, source_uuid in items:
if is_group: if is_group:
resp = ctx.client.get_group_scene_item_list(item_name) resp = ctx.obj['obsws'].get_group_scene_item_list(item_name)
group_items = sorted( group_items = sorted(
( (
( (
@@ -146,91 +135,79 @@ def list_(
def _validate_sources( def _validate_sources(
ctx: Context, ctx: typer.Context,
scene_name: str, scene_name: str,
item_name: str, item_name: str,
group: Optional[str] = None, group: Optional[str] = None,
): ) -> bool:
"""Validate the scene name and item name.""" """Validate the scene name and item name."""
if not validate.scene_in_scenes(ctx, scene_name): if not validate.scene_in_scenes(ctx, scene_name):
raise OBSWSCLIError( console.err.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
f'Scene [yellow]{scene_name}[/yellow] not found.', return False
exit_code=ExitCode.ERROR,
)
if group: if group:
if not validate.item_in_scene_item_list(ctx, scene_name, group): if not validate.item_in_scene_item_list(ctx, scene_name, group):
raise OBSWSCLIError( console.err.print(
f'Group [yellow]{group}[/yellow] not found in scene [yellow]{scene_name}[/yellow].', f'Group [yellow]{group}[/yellow] not found in scene [yellow]{scene_name}[/yellow].'
exit_code=ExitCode.ERROR,
) )
return False
else: else:
if not validate.item_in_scene_item_list(ctx, scene_name, item_name): if not validate.item_in_scene_item_list(ctx, scene_name, item_name):
raise OBSWSCLIError( console.err.print(
f'Item [yellow]{item_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow]. Is the item in a group? ' f'Item [yellow]{item_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow]. Is the item in a group? '
f'If so use the [yellow]--group[/yellow] option to specify the parent group.\n' f'If so use the [yellow]--group[/yellow] option to specify the parent group.\n'
'Use [yellow]obsws-cli sceneitem ls[/yellow] for a list of items in the scene.', 'Use [yellow]obsws-cli sceneitem ls[/yellow] for a list of items in the scene.'
exit_code=ExitCode.ERROR,
) )
return False
return True
def _get_scene_name_and_item_id( def _get_scene_name_and_item_id(
ctx: Context, ctx: typer.Context, scene_name: str, item_name: str, group: Optional[str] = None
scene_name: str,
item_name: str,
group: Optional[str] = None,
): ):
"""Get the scene name and item ID for the given scene and item name.""" """Get the scene name and item ID for the given scene and item name."""
if group: if group:
resp = ctx.client.get_group_scene_item_list(group) resp = ctx.obj['obsws'].get_group_scene_item_list(group)
for item in resp.scene_items: for item in resp.scene_items:
if item.get('sourceName') == item_name: if item.get('sourceName') == item_name:
scene_name = group scene_name = group
scene_item_id = item.get('sceneItemId') scene_item_id = item.get('sceneItemId')
break break
else: else:
raise OBSWSCLIError( console.err.print(
f'Item [yellow]{item_name}[/yellow] not found in group [yellow]{group}[/yellow].', f'Item [yellow]{item_name}[/yellow] not found in group [yellow]{group}[/yellow].'
exit_code=ExitCode.ERROR,
) )
raise typer.Exit(1)
else: else:
resp = ctx.client.get_scene_item_id(scene_name, item_name) resp = ctx.obj['obsws'].get_scene_item_id(scene_name, item_name)
scene_item_id = resp.scene_item_id scene_item_id = resp.scene_item_id
return scene_name, scene_item_id return scene_name, scene_item_id
@app.command(name=['show', 'sh']) @app.command('show | sh')
def show( def show(
scene_name: str, ctx: typer.Context,
item_name: str, scene_name: Annotated[
/, str, typer.Argument(..., show_default=False, help='Scene name the item is in')
group: Optional[str] = None, ],
*, item_name: Annotated[
ctx: Annotated[Context, Parameter(parse=False)], str,
typer.Argument(..., show_default=False, help='Item name to show in the scene'),
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
): ):
"""Show an item in a scene. """Show an item in a scene."""
if not _validate_sources(ctx, scene_name, item_name, group):
Parameters raise typer.Exit(1)
----------
scene_name : str
The name of the scene the item is in.
item_name : str
The name of the item to show in the scene.
group : str, optional
The name of the parent group the item is in, if applicable.
ctx : Context
The context containing the OBS client and configuration.
"""
_validate_sources(ctx, scene_name, item_name, group)
old_scene_name = scene_name old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id( scene_name, scene_item_id = _get_scene_name_and_item_id(
ctx, scene_name, item_name, group ctx, scene_name, item_name, group
) )
ctx.client.set_scene_item_enabled( ctx.obj['obsws'].set_scene_item_enabled(
scene_name=scene_name, scene_name=scene_name,
item_id=int(scene_item_id), item_id=int(scene_item_id),
enabled=True, enabled=True,
@@ -251,37 +228,28 @@ def show(
) )
@app.command(name=['hide', 'h']) @app.command('hide | h')
def hide( def hide(
scene_name: str, ctx: typer.Context,
item_name: str, scene_name: Annotated[
/, str, typer.Argument(..., show_default=False, help='Scene name the item is in')
group: Optional[str] = None, ],
*, item_name: Annotated[
ctx: Annotated[Context, Parameter(parse=False)], str,
typer.Argument(..., show_default=False, help='Item name to hide in the scene'),
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
): ):
"""Hide an item in a scene. """Hide an item in a scene."""
if not _validate_sources(ctx, scene_name, item_name, group):
Parameters raise typer.Exit(1)
----------
scene_name : str
The name of the scene the item is in.
item_name : str
The name of the item to hide in the scene.
group : str, optional
The name of the parent group the item is in, if applicable.
ctx : Context
The context containing the OBS client and configuration.
"""
_validate_sources(ctx, scene_name, item_name, group)
old_scene_name = scene_name old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id( scene_name, scene_item_id = _get_scene_name_and_item_id(
ctx, scene_name, item_name, group ctx, scene_name, item_name, group
) )
ctx.client.set_scene_item_enabled( ctx.obj['obsws'].set_scene_item_enabled(
scene_name=scene_name, scene_name=scene_name,
item_id=int(scene_item_id), item_id=int(scene_item_id),
enabled=False, enabled=False,
@@ -301,43 +269,36 @@ def hide(
) )
@app.command(name=['toggle', 'tg']) @app.command('toggle | tg')
def toggle( def toggle(
scene_name: str, ctx: typer.Context,
item_name: str, scene_name: Annotated[
/, str, typer.Argument(..., show_default=False, help='Scene name the item is in')
group: Optional[str] = None, ],
*, item_name: Annotated[
ctx: Annotated[Context, Parameter(parse=False)], str,
typer.Argument(
..., show_default=False, help='Item name to toggle in the scene'
),
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
): ):
"""Toggle an item in a scene. """Toggle an item in a scene."""
if not _validate_sources(ctx, scene_name, item_name, group):
Parameters raise typer.Exit(1)
----------
scene_name : str
The name of the scene the item is in.
item_name : str
The name of the item to toggle in the scene.
group : str, optional
The name of the parent group the item is in, if applicable.
ctx : Context
The context containing the OBS client and configuration.
"""
_validate_sources(ctx, scene_name, item_name, group)
old_scene_name = scene_name old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id( scene_name, scene_item_id = _get_scene_name_and_item_id(
ctx, scene_name, item_name, group ctx, scene_name, item_name, group
) )
enabled = ctx.client.get_scene_item_enabled( enabled = ctx.obj['obsws'].get_scene_item_enabled(
scene_name=scene_name, scene_name=scene_name,
item_id=int(scene_item_id), item_id=int(scene_item_id),
) )
new_state = not enabled.scene_item_enabled new_state = not enabled.scene_item_enabled
ctx.client.set_scene_item_enabled( ctx.obj['obsws'].set_scene_item_enabled(
scene_name=scene_name, scene_name=scene_name,
item_id=int(scene_item_id), item_id=int(scene_item_id),
enabled=new_state, enabled=new_state,
@@ -369,37 +330,30 @@ def toggle(
) )
@app.command(name=['visible', 'v']) @app.command('visible | v')
def visible( def visible(
scene_name: str, ctx: typer.Context,
item_name: str, scene_name: Annotated[
/, str, typer.Argument(..., show_default=False, help='Scene name the item is in')
group: Optional[str] = None, ],
*, item_name: Annotated[
ctx: Annotated[Context, Parameter(parse=False)], str,
typer.Argument(
..., show_default=False, help='Item name to check visibility in the scene'
),
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
): ):
"""Check if an item in a scene is visible. """Check if an item in a scene is visible."""
if not _validate_sources(ctx, scene_name, item_name, group):
Parameters raise typer.Exit(1)
----------
scene_name : str
The name of the scene the item is in.
item_name : str
The name of the item to check visibility in the scene.
group : str, optional
The name of the parent group the item is in, if applicable.
ctx : Context
The context containing the OBS client and configuration.
"""
_validate_sources(ctx, scene_name, item_name, group)
old_scene_name = scene_name old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id( scene_name, scene_item_id = _get_scene_name_and_item_id(
ctx, scene_name, item_name, group ctx, scene_name, item_name, group
) )
enabled = ctx.client.get_scene_item_enabled( enabled = ctx.obj['obsws'].get_scene_item_enabled(
scene_name=scene_name, scene_name=scene_name,
item_id=int(scene_item_id), item_id=int(scene_item_id),
) )
@@ -420,75 +374,68 @@ def visible(
) )
@app.command(name=['transform', 't']) @app.command('transform | t')
def transform( def transform(
scene_name: str, ctx: typer.Context,
item_name: str, scene_name: Annotated[
/, str, typer.Argument(..., show_default=False, help='Scene name the item is in')
group: Optional[str] = None, ],
alignment: Optional[int] = None, item_name: Annotated[
bounds_alignment: Optional[int] = None, str,
bounds_height: Optional[float] = None, typer.Argument(
bounds_type: Optional[str] = None, ..., show_default=False, help='Item name to transform in the scene'
bounds_width: Optional[float] = None, ),
crop_to_bounds: Optional[bool] = None, ],
crop_bottom: Optional[float] = None, group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
crop_left: Optional[float] = None, alignment: Annotated[
crop_right: Optional[float] = None, Optional[int], typer.Option(help='Alignment of the item in the scene')
crop_top: Optional[float] = None, ] = None,
position_x: Optional[float] = None, bounds_alignment: Annotated[
position_y: Optional[float] = None, Optional[int], typer.Option(help='Bounds alignment of the item in the scene')
rotation: Optional[float] = None, ] = None,
scale_x: Optional[float] = None, bounds_height: Annotated[
scale_y: Optional[float] = None, Optional[float], typer.Option(help='Height of the item in the scene')
*, ] = None,
ctx: Annotated[Context, Parameter(parse=False)], bounds_type: Annotated[
Optional[str], typer.Option(help='Type of bounds for the item in the scene')
] = None,
bounds_width: Annotated[
Optional[float], typer.Option(help='Width of the item in the scene')
] = None,
crop_to_bounds: Annotated[
Optional[bool], typer.Option(help='Crop the item to the bounds')
] = None,
crop_bottom: Annotated[
Optional[float], typer.Option(help='Bottom crop of the item in the scene')
] = None,
crop_left: Annotated[
Optional[float], typer.Option(help='Left crop of the item in the scene')
] = None,
crop_right: Annotated[
Optional[float], typer.Option(help='Right crop of the item in the scene')
] = None,
crop_top: Annotated[
Optional[float], typer.Option(help='Top crop of the item in the scene')
] = None,
position_x: Annotated[
Optional[float], typer.Option(help='X position of the item in the scene')
] = None,
position_y: Annotated[
Optional[float], typer.Option(help='Y position of the item in the scene')
] = None,
rotation: Annotated[
Optional[float], typer.Option(help='Rotation of the item in the scene')
] = None,
scale_x: Annotated[
Optional[float], typer.Option(help='X scale of the item in the scene')
] = None,
scale_y: Annotated[
Optional[float], typer.Option(help='Y scale of the item in the scene')
] = None,
): ):
"""Set the transform of an item in a scene. """Set the transform of an item in a scene."""
if not _validate_sources(ctx, scene_name, item_name, group):
Parameters raise typer.Exit(1)
----------
scene_name : str
The name of the scene the item is in.
item_name : str
The name of the item to transform in the scene.
group : str, optional
The name of the parent group the item is in, if applicable.
alignment : int, optional
Alignment of the item in the scene.
bounds_alignment : int, optional
Bounds alignment of the item in the scene.
bounds_height : float, optional
Height of the item in the scene.
bounds_type : str, optional
Type of bounds for the item in the scene.
bounds_width : float, optional
Width of the item in the scene.
crop_to_bounds : bool, optional
Crop the item to the bounds.
crop_bottom : float, optional
Bottom crop of the item in the scene.
crop_left : float, optional
Left crop of the item in the scene.
crop_right : float, optional
Right crop of the item in the scene.
crop_top : float, optional
Top crop of the item in the scene.
position_x : float, optional
X position of the item in the scene.
position_y : float, optional
Y position of the item in the scene.
rotation : float, optional
Rotation of the item in the scene.
scale_x : float, optional
X scale of the item in the scene.
scale_y : float, optional
Y scale of the item in the scene.
ctx : Context
The context containing the OBS client and configuration.
"""
_validate_sources(ctx, scene_name, item_name, group)
old_scene_name = scene_name old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id( scene_name, scene_item_id = _get_scene_name_and_item_id(
@@ -528,12 +475,10 @@ def transform(
transform['scaleY'] = scale_y transform['scaleY'] = scale_y
if not transform: if not transform:
raise OBSWSCLIError( console.err.print('No transform options provided.')
'No transform options provided. Use at least one of the transform options.', raise typer.Exit(1)
exit_code=ExitCode.ERROR,
)
transform = ctx.client.set_scene_item_transform( transform = ctx.obj['obsws'].set_scene_item_transform(
scene_name=scene_name, scene_name=scene_name,
item_id=int(scene_item_id), item_id=int(scene_item_id),
transform=transform, transform=transform,

View File

@@ -4,51 +4,66 @@ from pathlib import Path
from typing import Annotated from typing import Annotated
import obsws_python as obsws import obsws_python as obsws
from cyclopts import App, Parameter, validators import typer
from . import console from . import console
from .context import Context from .alias import SubTyperAliasGroup
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='screenshot', help='Commands for taking screenshots using OBS.') app = typer.Typer(cls=SubTyperAliasGroup)
@app.command(name=['save', 'sv']) @app.callback()
def main():
"""Take screenshots using OBS."""
@app.command('save | sv')
def save( def save(
source_name: str, ctx: typer.Context,
source_name: Annotated[
str,
typer.Argument(
...,
show_default=False,
help='Name of the source to take a screenshot of.',
),
],
output_path: Annotated[
Path,
# Since the CLI and OBS may be running on different platforms, # Since the CLI and OBS may be running on different platforms,
# we won't validate the path here. # we won't validate the path here.
output_path: Path, typer.Argument(
/, ...,
width: float = 1920, show_default=False,
height: float = 1080, file_okay=True,
dir_okay=False,
help='Path to save the screenshot (must include file name and extension).',
),
],
width: Annotated[
float,
typer.Option(
help='Width of the screenshot.',
),
] = 1920,
height: Annotated[
float,
typer.Option(
help='Height of the screenshot.',
),
] = 1080,
quality: Annotated[ quality: Annotated[
float, Parameter(validator=validators.Number(gte=-1, lte=100)) float,
] = -1.0, typer.Option(
*, min=-1,
ctx: Annotated[Context, Parameter(parse=False)], max=100,
help='Quality of the screenshot.',
),
] = -1,
): ):
"""Take a screenshot and save it to a file. """Take a screenshot and save it to a file."""
Parameters
----------
source_name : str
Name of the source to take a screenshot of.
output_path : Path
Path to save the screenshot (must include file name and extension).
width : float
Width of the screenshot.
height : float
Height of the screenshot.
quality : float
Quality of the screenshot. A value of -1 uses the default quality.
ctx : Context
Context containing the OBS WebSocket client instance.
"""
try: try:
ctx.client.save_source_screenshot( ctx.obj['obsws'].save_source_screenshot(
name=source_name, name=source_name,
img_format=output_path.suffix.lstrip('.').lower(), img_format=output_path.suffix.lstrip('.').lower(),
file_path=str(output_path), file_path=str(output_path),
@@ -59,16 +74,16 @@ def save(
except obsws.error.OBSSDKRequestError as e: except obsws.error.OBSSDKRequestError as e:
match e.code: match e.code:
case 403: case 403:
raise OBSWSCLIError( console.err.print(
'The [yellow]image format[/yellow] (file extension) must be included in the file name, ' 'The [yellow]image format[/yellow] (file extension) must be included in the file name, '
"for example: '/path/to/screenshot.png'.", "for example: '/path/to/screenshot.png'.",
code=ExitCode.ERROR,
) )
raise typer.Exit(1)
case 600: case 600:
raise OBSWSCLIError( console.err.print(
'No source was found by the name of [yellow]{source_name}[/yellow]', f'No source was found by the name of [yellow]{source_name}[/yellow]'
code=ExitCode.ERROR,
) )
raise typer.Exit(1)
case _: case _:
raise raise

337
obsws_cli/settings.py Normal file
View File

@@ -0,0 +1,337 @@
"""module for settings management."""
from typing import Annotated, Optional
import typer
from rich.table import Table
from rich.text import Text
from . import console, util
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
def main():
"""Manage OBS settings."""
@app.command('show | sh')
def show(
ctx: typer.Context,
video: Annotated[
bool, typer.Option('--video', '-v', help='Show video settings.')
] = False,
record: Annotated[
bool, typer.Option('--record', '-r', help='Show recording settings.')
] = False,
profile: Annotated[
bool, typer.Option('--profile', '-p', help='Show profile settings.')
] = False,
):
"""Show current OBS settings."""
if not any([video, record, profile]):
video = True
record = True
profile = True
resp = ctx.obj['obsws'].get_video_settings()
video_table = Table(
title='Video Settings',
padding=(0, 2),
border_style=ctx.obj['style'].border,
)
video_columns = (
('Setting', 'left', ctx.obj['style'].column),
('Value', 'left', ctx.obj['style'].column),
)
for header_text, justify, style in video_columns:
video_table.add_column(
Text(header_text, justify='center'),
justify=justify,
style=style,
)
for setting in resp.attrs():
video_table.add_row(
util.snakecase_to_titlecase(setting),
str(getattr(resp, setting)),
style='' if video_table.row_count % 2 == 0 else 'dim',
)
if video:
console.out.print(video_table)
resp = ctx.obj['obsws'].get_record_directory()
record_table = Table(
title='Recording Settings',
padding=(0, 2),
border_style=ctx.obj['style'].border,
)
record_columns = (
('Setting', 'left', ctx.obj['style'].column),
('Value', 'left', ctx.obj['style'].column),
)
for header_text, justify, style in record_columns:
record_table.add_column(
Text(header_text, justify='center'),
justify=justify,
style=style,
)
record_table.add_row(
'Directory',
resp.record_directory,
style='' if record_table.row_count % 2 == 0 else 'dim',
)
if record:
console.out.print(record_table)
profile_table = Table(
title='Profile Settings',
padding=(0, 2),
border_style=ctx.obj['style'].border,
)
profile_columns = (
('Setting', 'left', ctx.obj['style'].column),
('Value', 'left', ctx.obj['style'].column),
)
for header_text, justify, style in profile_columns:
profile_table.add_column(
Text(header_text, justify='center'),
justify=justify,
style=style,
)
params = [
('Output', 'Mode', 'Output Mode'),
('SimpleOutput', 'StreamEncoder', 'Simple Streaming Encoder'),
('SimpleOutput', 'RecEncoder', 'Simple Recording Encoder'),
('SimpleOutput', 'RecFormat2', 'Simple Recording Video Format'),
('SimpleOutput', 'RecAudioEncoder', 'Simple Recording Audio Format'),
('SimpleOutput', 'RecQuality', 'Simple Recording Quality'),
('AdvOut', 'Encoder', 'Advanced Streaming Encoder'),
('AdvOut', 'RecEncoder', 'Advanced Recording Encoder'),
('AdvOut', 'RecType', 'Advanced Recording Type'),
('AdvOut', 'RecFormat2', 'Advanced Recording Video Format'),
('AdvOut', 'RecAudioEncoder', 'Advanced Recording Audio Format'),
]
for category, name, display_name in params:
resp = ctx.obj['obsws'].get_profile_parameter(
category=category,
name=name,
)
if resp.parameter_value is not None:
profile_table.add_row(
display_name,
str(resp.parameter_value),
style='' if profile_table.row_count % 2 == 0 else 'dim',
)
if profile:
console.out.print(profile_table)
@app.command('profile | pr')
def profile(
ctx: typer.Context,
category: Annotated[
str,
typer.Argument(
...,
help='Profile parameter category (e.g., SimpleOutput, AdvOut).',
),
],
name: Annotated[
str,
typer.Argument(
...,
help='Profile parameter name (e.g., StreamEncoder, RecFormat2).',
),
],
value: Annotated[
Optional[str],
typer.Argument(
...,
help='Value to set for the profile parameter. If omitted, the current value is retrieved.',
),
] = None,
):
"""Get/set OBS profile settings."""
if value is None:
resp = ctx.obj['obsws'].get_profile_parameter(
category=category,
name=name,
)
console.out.print(
f'Parameter Value for [bold]{name}[/bold]: '
f'[green]{resp.parameter_value}[/green]'
)
else:
ctx.obj['obsws'].set_profile_parameter(
category=category,
name=name,
value=value,
)
console.out.print(
f'Set Parameter [bold]{name}[/bold] to [green]{value}[/green]'
)
@app.command('stream-service | ss')
def stream_service(
ctx: typer.Context,
type_: Annotated[
Optional[str],
typer.Argument(
...,
help='Stream service type (e.g., Twitch, YouTube). If omitted, current settings are retrieved.',
),
] = None,
key: Annotated[
Optional[str],
typer.Option('--key', '-k', help='Stream key to set. Optional.'),
] = None,
server: Annotated[
Optional[str],
typer.Option('--server', '-s', help='Stream server to set. Optional.'),
] = None,
):
"""Get/set OBS stream service settings."""
if type_ is None:
resp = ctx.obj['obsws'].get_stream_service_settings()
table = Table(
title='Stream Service Settings',
padding=(0, 2),
border_style=ctx.obj['style'].border,
)
columns = (
('Setting', 'left', ctx.obj['style'].column),
('Value', 'left', ctx.obj['style'].column),
)
for header_text, justify, style in columns:
table.add_column(
Text(header_text, justify='center'),
justify=justify,
style=style,
)
table.add_row(
'Type',
resp.stream_service_type,
style='' if table.row_count % 2 == 0 else 'dim',
)
table.add_row(
'Server',
resp.stream_service_settings.get('server', ''),
style='' if table.row_count % 2 == 0 else 'dim',
)
table.add_row(
'Key',
resp.stream_service_settings.get('key', ''),
style='' if table.row_count % 2 == 0 else 'dim',
)
console.out.print(table)
else:
current_settings = ctx.obj['obsws'].get_stream_service_settings()
if key is None:
key = current_settings.stream_service_settings.get('key', '')
if server is None:
server = current_settings.stream_service_settings.get('server', '')
ctx.obj['obsws'].set_stream_service_settings(
ss_type=type_,
ss_settings={'key': key, 'server': server},
)
console.out.print('Stream service settings updated.')
@app.command('video | vi')
def video(
ctx: typer.Context,
base_width: Annotated[
Optional[int],
typer.Option('--base-width', '-bw', help='Set base (canvas) width.'),
] = None,
base_height: Annotated[
Optional[int],
typer.Option('--base-height', '-bh', help='Set base (canvas) height.'),
] = None,
output_width: Annotated[
Optional[int],
typer.Option('--output-width', '-ow', help='Set output (scaled) width.'),
] = None,
output_height: Annotated[
Optional[int],
typer.Option('--output-height', '-oh', help='Set output (scaled) height.'),
] = None,
fps_num: Annotated[
Optional[int],
typer.Option('--fps-num', '-fn', help='Set FPS numerator.'),
] = None,
fps_den: Annotated[
Optional[int],
typer.Option('--fps-den', '-fd', help='Set FPS denominator.'),
] = None,
):
"""Get/set OBS video settings."""
if not any(
[
base_width,
base_height,
output_width,
output_height,
fps_num,
fps_den,
]
):
resp = ctx.obj['obsws'].get_video_settings()
table = Table(
title='Video Settings',
padding=(0, 2),
border_style=ctx.obj['style'].border,
)
columns = (
('Setting', 'left', ctx.obj['style'].column),
('Value', 'left', ctx.obj['style'].column),
)
for header_text, justify, style in columns:
table.add_column(
Text(header_text, justify='center'),
justify=justify,
style=style,
)
for setting in resp.attrs():
table.add_row(
util.snakecase_to_titlecase(setting),
str(getattr(resp, setting)),
style='' if table.row_count % 2 == 0 else 'dim',
)
console.out.print(table)
else:
current_settings = ctx.obj['obsws'].get_video_settings()
if base_width is None:
base_width = current_settings.base_width
if base_height is None:
base_height = current_settings.base_height
if output_width is None:
output_width = current_settings.output_width
if output_height is None:
output_height = current_settings.output_height
if fps_num is None:
fps_num = current_settings.fps_num
if fps_den is None:
fps_den = current_settings.fps_den
ctx.obj['obsws'].set_video_settings(
base_width=base_width,
base_height=base_height,
out_width=output_width,
out_height=output_height,
numerator=fps_num,
denominator=fps_den,
)
console.out.print('Video settings updated.')

View File

@@ -1,104 +1,61 @@
"""module for controlling OBS stream functionality.""" """module for controlling OBS stream functionality."""
from typing import Annotated import typer
from cyclopts import App, Parameter
from . import console from . import console
from .context import Context from .alias import SubTyperAliasGroup
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='stream', help='Commands for controlling OBS stream functionality.') app = typer.Typer(cls=SubTyperAliasGroup)
def _get_streaming_status(ctx: Context) -> tuple: @app.callback()
def main():
"""Control OBS stream functionality."""
def _get_streaming_status(ctx: typer.Context) -> tuple:
"""Get streaming status.""" """Get streaming status."""
resp = ctx.client.get_stream_status() resp = ctx.obj['obsws'].get_stream_status()
return resp.output_active, resp.output_duration return resp.output_active, resp.output_duration
@app.command(name=['start', 's']) @app.command('start | s')
def start( def start(ctx: typer.Context):
*, """Start streaming."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Start streaming.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
active, _ = _get_streaming_status(ctx) active, _ = _get_streaming_status(ctx)
if active: if active:
raise OBSWSCLIError( console.err.print('Streaming is already in progress, cannot start.')
'Streaming is already in progress, cannot start.', raise typer.Exit(1)
code=ExitCode.ERROR,
)
ctx.client.start_stream() ctx.obj['obsws'].start_stream()
console.out.print('Streaming started successfully.') console.out.print('Streaming started successfully.')
@app.command(name=['stop', 'st']) @app.command('stop | st')
def stop( def stop(ctx: typer.Context):
*, """Stop streaming."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Stop streaming.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
active, _ = _get_streaming_status(ctx) active, _ = _get_streaming_status(ctx)
if not active: if not active:
raise OBSWSCLIError( console.err.print('Streaming is not in progress, cannot stop.')
'Streaming is not in progress, cannot stop.', raise typer.Exit(1)
code=ExitCode.ERROR,
)
ctx.client.stop_stream() ctx.obj['obsws'].stop_stream()
console.out.print('Streaming stopped successfully.') console.out.print('Streaming stopped successfully.')
@app.command(name=['toggle', 'tg']) @app.command('toggle | tg')
def toggle( def toggle(ctx: typer.Context):
*, """Toggle streaming."""
ctx: Annotated[Context, Parameter(parse=False)], resp = ctx.obj['obsws'].toggle_stream()
):
"""Toggle streaming.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
resp = ctx.client.toggle_stream()
if resp.output_active: if resp.output_active:
console.out.print('Streaming started successfully.') console.out.print('Streaming started successfully.')
else: else:
console.out.print('Streaming stopped successfully.') console.out.print('Streaming stopped successfully.')
@app.command(name=['status', 'ss']) @app.command('status | ss')
def status( def status(ctx: typer.Context):
*, """Get streaming status."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get streaming status.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
active, duration = _get_streaming_status(ctx) active, duration = _get_streaming_status(ctx)
if active: if active:
if duration > 0: if duration > 0:

View File

@@ -1,85 +1,48 @@
"""module containing commands for manipulating studio mode in OBS.""" """module containing commands for manipulating studio mode in OBS."""
from typing import Annotated import typer
from cyclopts import App, Parameter
from . import console from . import console
from .context import Context from .alias import SubTyperAliasGroup
app = App(name='studiomode', help='Commands for controlling studio mode in OBS.') app = typer.Typer(cls=SubTyperAliasGroup)
@app.command(name=['enable', 'on']) @app.callback()
def enable( def main():
*, """Control studio mode in OBS."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Enable studio mode.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
""" @app.command('enable | on')
ctx.client.set_studio_mode_enabled(True) def enable(ctx: typer.Context):
"""Enable studio mode."""
ctx.obj['obsws'].set_studio_mode_enabled(True)
console.out.print('Studio mode has been enabled.') console.out.print('Studio mode has been enabled.')
@app.command(name=['disable', 'off']) @app.command('disable | off')
def disable( def disable(ctx: typer.Context):
*, """Disable studio mode."""
ctx: Annotated[Context, Parameter(parse=False)], ctx.obj['obsws'].set_studio_mode_enabled(False)
):
"""Disable studio mode.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
ctx.client.set_studio_mode_enabled(False)
console.out.print('Studio mode has been disabled.') console.out.print('Studio mode has been disabled.')
@app.command(name=['toggle', 'tg']) @app.command('toggle | tg')
def toggle( def toggle(ctx: typer.Context):
*, """Toggle studio mode."""
ctx: Annotated[Context, Parameter(parse=False)], resp = ctx.obj['obsws'].get_studio_mode_enabled()
):
"""Toggle studio mode.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
resp = ctx.client.get_studio_mode_enabled()
if resp.studio_mode_enabled: if resp.studio_mode_enabled:
ctx.client.set_studio_mode_enabled(False) ctx.obj['obsws'].set_studio_mode_enabled(False)
console.out.print('Studio mode is now disabled.') console.out.print('Studio mode is now disabled.')
else: else:
ctx.client.set_studio_mode_enabled(True) ctx.obj['obsws'].set_studio_mode_enabled(True)
console.out.print('Studio mode is now enabled.') console.out.print('Studio mode is now enabled.')
@app.command(name=['status', 'ss']) @app.command('status | ss')
def status( def status(ctx: typer.Context):
*, """Get the status of studio mode."""
ctx: Annotated[Context, Parameter(parse=False)], resp = ctx.obj['obsws'].get_studio_mode_enabled()
):
"""Get the status of studio mode.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
resp = ctx.client.get_studio_mode_enabled()
if resp.studio_mode_enabled: if resp.studio_mode_enabled:
console.out.print('Studio mode is enabled.') console.out.print('Studio mode is enabled.')
else: else:

View File

@@ -2,43 +2,36 @@
from typing import Annotated, Optional from typing import Annotated, Optional
from cyclopts import App, Parameter import typer
from . import console, validate from . import console, validate
from .context import Context from .alias import SubTyperAliasGroup
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='text', help='Commands for controlling text inputs in OBS.') app = typer.Typer(cls=SubTyperAliasGroup)
@app.command(name=['current', 'get']) @app.callback()
def main():
"""Control text inputs in OBS."""
@app.command('current | get')
def current( def current(
input_name: str, ctx: typer.Context,
*, input_name: Annotated[
ctx: Annotated[Context, Parameter(parse=False)], str,
typer.Argument(
help='Name of the text input to get.', callback=validate.input_in_inputs
),
],
): ):
"""Get the current text for a text input. """Get the current text for a text input."""
resp = ctx.obj['obsws'].get_input_settings(name=input_name)
Parameters
----------
input_name : str
The name of the text input to retrieve the current text from.
ctx : Context
The context containing the OBS client and other settings.
"""
if not validate.input_in_inputs(ctx, input_name):
raise OBSWSCLIError(
f'Input [yellow]{input_name}[/yellow] not found.', code=ExitCode.ERROR
)
resp = ctx.client.get_input_settings(name=input_name)
if not resp.input_kind.startswith('text_'): if not resp.input_kind.startswith('text_'):
raise OBSWSCLIError( console.err.print(
f'Input [yellow]{input_name}[/yellow] is not a text input.', f'Input [yellow]{input_name}[/yellow] is not a text input.',
code=ExitCode.ERROR,
) )
raise typer.Exit(1)
current_text = resp.input_settings.get('text', '') current_text = resp.input_settings.get('text', '')
if not current_text: if not current_text:
@@ -48,40 +41,31 @@ def current(
) )
@app.command(name=['update', 'set']) @app.command('update | set')
def update( def update(
input_name: str, ctx: typer.Context,
new_text: Optional[str] = None, input_name: Annotated[
/, str,
*, typer.Argument(
ctx: Annotated[Context, Parameter(parse=False)], help='Name of the text input to update.', callback=validate.input_in_inputs
),
],
new_text: Annotated[
Optional[str],
typer.Argument(
help='The new text to set for the input.',
),
] = None,
): ):
"""Update the text of a text input. """Update the text of a text input."""
resp = ctx.obj['obsws'].get_input_settings(name=input_name)
Parameters
----------
input_name : str
The name of the text input to update.
new_text : Optional[str]
The new text to set for the input. If not provided, the text will be cleared
(set to an empty string).
ctx : Context
The context containing the OBS client and other settings.
"""
if not validate.input_in_inputs(ctx, input_name):
raise OBSWSCLIError(
f'Input [yellow]{input_name}[/yellow] not found.', code=ExitCode.ERROR
)
resp = ctx.client.get_input_settings(name=input_name)
if not resp.input_kind.startswith('text_'): if not resp.input_kind.startswith('text_'):
raise OBSWSCLIError( console.err.print(
f'Input [yellow]{input_name}[/yellow] is not a text input.', f'Input [yellow]{input_name}[/yellow] is not a text input.',
code=ExitCode.ERROR,
) )
raise typer.Exit(1)
ctx.client.set_input_settings( ctx.obj['obsws'].set_input_settings(
name=input_name, name=input_name,
settings={'text': new_text}, settings={'text': new_text},
overlay=True, overlay=True,

View File

@@ -20,3 +20,28 @@ def check_mark(value: bool, empty_if_false: bool = False) -> str:
if os.getenv('NO_COLOR', '') != '': if os.getenv('NO_COLOR', '') != '':
return '' if value else '' return '' if value else ''
return '' if value else '' return '' if value else ''
def timecode_to_milliseconds(timecode: str) -> int:
"""Convert a timecode string (HH:MM:SS) to total milliseconds."""
match timecode.split(':'):
case [mm, ss]:
hours = 0
minutes = int(mm)
seconds = int(ss)
case [hh, mm, ss]:
hours = int(hh)
minutes = int(mm)
seconds = int(ss)
return (hours * 3600 + minutes * 60 + seconds) * 1000
def milliseconds_to_timecode(milliseconds: int) -> str:
"""Convert total milliseconds to a timecode string (HH:MM:SS)."""
total_seconds = milliseconds // 1000
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
if hours == 0:
return f'{minutes:02}:{seconds:02}'
return f'{hours:02}:{minutes:02}:{seconds:02}'

View File

@@ -1,49 +1,142 @@
"""module containing validation functions.""" """module containing validation functions."""
from .context import Context from typing import Optional
import typer
from . import console
# type alias for an option that is skipped when the command is run
skipped_option = typer.Option(parser=lambda _: _, hidden=True, expose_value=False)
def input_in_inputs(ctx: Context, input_name: str) -> bool: def input_in_inputs(ctx: typer.Context, input_name: str) -> str:
"""Check if an input is in the input list.""" """Ensure the given input exists in the list of inputs."""
inputs = ctx.client.get_input_list().inputs resp = ctx.obj['obsws'].get_input_list()
return any(input_.get('inputName') == input_name for input_ in inputs) if not any(input.get('inputName') == input_name for input in resp.inputs):
console.err.print(f'Input [yellow]{input_name}[/yellow] does not exist.')
raise typer.Exit(1)
return input_name
def scene_in_scenes(ctx: Context, scene_name: str) -> bool: def input_not_in_inputs(ctx: typer.Context, input_name: str) -> str:
"""Ensure an input does not already exist in the list of inputs."""
resp = ctx.obj['obsws'].get_input_list()
if any(input.get('inputName') == input_name for input in resp.inputs):
console.err.print(f'Input [yellow]{input_name}[/yellow] already exists.')
raise typer.Exit(1)
return input_name
def scene_in_scenes(ctx: typer.Context, scene_name: Optional[str]) -> str | None:
"""Check if a scene exists in the list of scenes.""" """Check if a scene exists in the list of scenes."""
resp = ctx.client.get_scene_list() if scene_name is None:
return any(scene.get('sceneName') == scene_name for scene in resp.scenes) return
resp = ctx.obj['obsws'].get_scene_list()
if not any(scene.get('sceneName') == scene_name for scene in resp.scenes):
console.err.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
raise typer.Exit(1)
return scene_name
def studio_mode_enabled(ctx: Context) -> bool: def studio_mode_enabled(ctx: typer.Context, preview: bool) -> bool:
"""Check if studio mode is enabled.""" """Ensure studio mode is enabled if preview option is used."""
resp = ctx.client.get_studio_mode_enabled() resp = ctx.obj['obsws'].get_studio_mode_enabled()
return resp.studio_mode_enabled if preview and not resp.studio_mode_enabled:
console.err.print(
'Studio mode is disabled. This action requires it to be enabled.'
)
raise typer.Exit(1)
return preview
def scene_collection_in_scene_collections( def scene_collection_in_scene_collections(
ctx: Context, scene_collection_name: str ctx: typer.Context, scene_collection_name: str
) -> bool: ) -> str:
"""Check if a scene collection exists.""" """Ensure a scene collection exists in the list of scene collections."""
resp = ctx.client.get_scene_collection_list() resp = ctx.obj['obsws'].get_scene_collection_list()
return any( if not any(
collection == scene_collection_name for collection in resp.scene_collections collection == scene_collection_name for collection in resp.scene_collections
):
console.err.print(
f'Scene collection [yellow]{scene_collection_name}[/yellow] not found.'
) )
raise typer.Exit(1)
return scene_collection_name
def item_in_scene_item_list(ctx: Context, scene_name: str, item_name: str) -> bool: def scene_collection_not_in_scene_collections(
ctx: typer.Context, scene_collection_name: str
) -> str:
"""Ensure a scene collection does not already exist in the list of scene collections."""
resp = ctx.obj['obsws'].get_scene_collection_list()
if any(
collection == scene_collection_name for collection in resp.scene_collections
):
console.err.print(
f'Scene collection [yellow]{scene_collection_name}[/yellow] already exists.'
)
raise typer.Exit(1)
return scene_collection_name
def item_in_scene_item_list(
ctx: typer.Context, scene_name: str, item_name: str
) -> bool:
"""Check if an item exists in a scene.""" """Check if an item exists in a scene."""
resp = ctx.client.get_scene_item_list(scene_name) resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
return any(item.get('sourceName') == item_name for item in resp.scene_items) return any(item.get('sourceName') == item_name for item in resp.scene_items)
def profile_exists(ctx: Context, profile_name: str) -> bool: def profile_exists(ctx: typer.Context, profile_name: str) -> str:
"""Check if a profile exists.""" """Ensure a profile exists."""
resp = ctx.client.get_profile_list() resp = ctx.obj['obsws'].get_profile_list()
return any(profile == profile_name for profile in resp.profiles) if not any(profile == profile_name for profile in resp.profiles):
console.err.print(f'Profile [yellow]{profile_name}[/yellow] not found.')
raise typer.Exit(1)
return profile_name
def monitor_exists(ctx: Context, monitor_index: int) -> bool: def profile_not_exists(ctx: typer.Context, profile_name: str) -> str:
"""Check if a monitor exists.""" """Ensure a profile does not exist."""
resp = ctx.client.get_monitor_list() resp = ctx.obj['obsws'].get_profile_list()
return any(monitor['monitorIndex'] == monitor_index for monitor in resp.monitors) if any(profile == profile_name for profile in resp.profiles):
console.err.print(f'Profile [yellow]{profile_name}[/yellow] already exists.')
raise typer.Exit(1)
return profile_name
def kind_in_input_kinds(ctx: typer.Context, input_kind: str) -> str:
"""Check if an input kind is valid."""
resp = ctx.obj['obsws'].get_input_kind_list(False)
if not any(kind == input_kind for kind in resp.input_kinds):
console.err.print(f'Input kind [yellow]{input_kind}[/yellow] not found.')
raise typer.Exit(1)
return input_kind
def timecode_format(ctx: typer.Context, timecode: Optional[str]) -> str | None:
"""Validate that a timecode is in HH:MM:SS or MM:SS format."""
if timecode is None:
return
match timecode.split(':'):
case [mm, ss]:
if not (mm.isdigit() and ss.isdigit()):
console.err.print(
f'Timecode [yellow]{timecode}[/yellow] is not valid. Use MM:SS or HH:MM:SS format.'
)
raise typer.Exit(1)
case [hh, mm, ss]:
if not (hh.isdigit() and mm.isdigit() and ss.isdigit()):
console.err.print(
f'Timecode [yellow]{timecode}[/yellow] is not valid. Use MM:SS or HH:MM:SS format.'
)
raise typer.Exit(1)
case _:
console.err.print(
f'Timecode [yellow]{timecode}[/yellow] is not valid. Use MM:SS or HH:MM:SS format.'
)
raise typer.Exit(1)
return timecode

View File

@@ -1,83 +1,46 @@
"""module containing commands for manipulating virtual camera in OBS.""" """module containing commands for manipulating virtual camera in OBS."""
from typing import Annotated import typer
from cyclopts import App, Parameter
from . import console from . import console
from .context import Context from .alias import SubTyperAliasGroup
app = App(name='virtualcam', help='Commands for controlling the virtual camera in OBS.') app = typer.Typer(cls=SubTyperAliasGroup)
@app.command(name=['start', 's']) @app.callback()
def start( def main():
*, """Control virtual camera in OBS."""
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Start the virtual camera.
Parameters
----------
ctx : Context
The context containing the OBS client and other settings.
""" @app.command('start | s')
ctx.client.start_virtual_cam() def start(ctx: typer.Context):
"""Start the virtual camera."""
ctx.obj['obsws'].start_virtual_cam()
console.out.print('Virtual camera started.') console.out.print('Virtual camera started.')
@app.command(name=['stop', 'p']) @app.command('stop | p')
def stop( def stop(ctx: typer.Context):
*, """Stop the virtual camera."""
ctx: Annotated[Context, Parameter(parse=False)], ctx.obj['obsws'].stop_virtual_cam()
):
"""Stop the virtual camera.
Parameters
----------
ctx : Context
The context containing the OBS client and other settings.
"""
ctx.client.stop_virtual_cam()
console.out.print('Virtual camera stopped.') console.out.print('Virtual camera stopped.')
@app.command(name=['toggle', 'tg']) @app.command('toggle | tg')
def toggle( def toggle(ctx: typer.Context):
*, """Toggle the virtual camera."""
ctx: Annotated[Context, Parameter(parse=False)], resp = ctx.obj['obsws'].toggle_virtual_cam()
):
"""Toggle the virtual camera.
Parameters
----------
ctx : Context
The context containing the OBS client and other settings.
"""
resp = ctx.client.toggle_virtual_cam()
if resp.output_active: if resp.output_active:
console.out.print('Virtual camera is enabled.') console.out.print('Virtual camera is enabled.')
else: else:
console.out.print('Virtual camera is disabled.') console.out.print('Virtual camera is disabled.')
@app.command(name=['status', 'ss']) @app.command('status | ss')
def status( def status(ctx: typer.Context):
*, """Get the status of the virtual camera."""
ctx: Annotated[Context, Parameter(parse=False)], resp = ctx.obj['obsws'].get_virtual_cam_status()
):
"""Get the status of the virtual camera.
Parameters
----------
ctx : Context
The context containing the OBS client and other settings.
"""
resp = ctx.client.get_virtual_cam_status()
if resp.output_active: if resp.output_active:
console.out.print('Virtual camera is enabled.') console.out.print('Virtual camera is enabled.')
else: else:

View File

@@ -21,7 +21,7 @@ classifiers = [
"Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: PyPy",
] ]
dependencies = ["cyclopts>=3.22.2", "obsws-python>=1.8.0"] dependencies = ["typer>=0.21.1", "obsws-python>=1.8.0", "python-dotenv>=1.1.0"]
[project.urls] [project.urls]
@@ -30,7 +30,7 @@ Issues = "https://github.com/onyx-and-iris/obsws-cli/issues"
Source = "https://github.com/onyx-and-iris/obsws-cli" Source = "https://github.com/onyx-and-iris/obsws-cli"
[project.scripts] [project.scripts]
obsws-cli = "obsws_cli:run" obsws-cli = "obsws_cli:app"
[tool.hatch.version] [tool.hatch.version]
path = "obsws_cli/__about__.py" path = "obsws_cli/__about__.py"
@@ -42,9 +42,6 @@ dependencies = ["click-man>=0.5.1"]
cli = "obsws-cli {args:}" cli = "obsws-cli {args:}"
man = "python man/generate.py --output=./man" man = "python man/generate.py --output=./man"
[tool.hatch.envs.lazyimports.scripts]
cli = "obsws-cli {args:}"
[tool.hatch.envs.hatch-test] [tool.hatch.envs.hatch-test]
randomize = true randomize = true

View File

@@ -1,6 +1,7 @@
"""pytest configuration file.""" """pytest configuration file."""
import os import os
import time
import obsws_python as obsws import obsws_python as obsws
from dotenv import find_dotenv, load_dotenv from dotenv import find_dotenv, load_dotenv
@@ -44,9 +45,54 @@ def pytest_sessionstart(session):
}, },
) )
session.obsws.set_current_scene_collection('test-collection') session.obsws.create_profile('pytest_profile')
time.sleep(0.1) # Wait for the profile to be created
session.obsws.set_profile_parameter(
'SimpleOutput',
'RecRB',
'true',
)
# hack to ensure the replay buffer is enabled
session.obsws.set_current_profile('Untitled')
session.obsws.set_current_profile('pytest_profile')
session.obsws.create_scene('pytest_scene') session.obsws.create_scene('pytest_scene')
# Ensure Desktop Audio is created.
desktop_audio_kinds = {
'windows': 'wasapi_output_capture',
'linux': 'pulse_output_capture',
'darwin': 'coreaudio_output_capture',
}
platform = os.environ.get('OBS_TESTS_PLATFORM', os.uname().sysname.lower())
try:
session.obsws.create_input(
sceneName='pytest_scene',
inputName='Desktop Audio',
inputKind=desktop_audio_kinds[platform],
inputSettings={'device_id': 'default'},
sceneItemEnabled=True,
)
except obsws.error.OBSSDKRequestError as e:
if e.code == 601:
"""input already exists, continue."""
# Ensure Mic/Aux is created.
mic_kinds = {
'windows': 'wasapi_input_capture',
'linux': 'pulse_input_capture',
'darwin': 'coreaudio_input_capture',
}
try:
session.obsws.create_input(
sceneName='pytest_scene',
inputName='Mic/Aux',
inputKind=mic_kinds[platform],
inputSettings={'device_id': 'default'},
sceneItemEnabled=True,
)
except obsws.error.OBSSDKRequestError as e:
if e.code == 601:
"""input already exists, continue."""
session.obsws.create_input( session.obsws.create_input(
sceneName='pytest_scene', sceneName='pytest_scene',
inputName='pytest_input', inputName='pytest_input',
@@ -131,7 +177,7 @@ def pytest_sessionfinish(session, exitstatus):
session.obsws.remove_scene('pytest_scene') session.obsws.remove_scene('pytest_scene')
session.obsws.set_current_scene_collection('default') session.obsws.set_current_scene_collection('Untitled')
resp = session.obsws.get_stream_status() resp = session.obsws.get_stream_status()
if resp.output_active: if resp.output_active:
@@ -149,6 +195,8 @@ def pytest_sessionfinish(session, exitstatus):
if resp.studio_mode_enabled: if resp.studio_mode_enabled:
session.obsws.set_studio_mode_enabled(False) session.obsws.set_studio_mode_enabled(False)
session.obsws.remove_profile('pytest_profile')
# Close the OBS WebSocket client connection # Close the OBS WebSocket client connection
session.obsws.disconnect() session.obsws.disconnect()

View File

@@ -4,7 +4,7 @@ from typer.testing import CliRunner
from obsws_cli.app import app from obsws_cli.app import app
runner = CliRunner(mix_stderr=False) runner = CliRunner()
def test_filter_list(): def test_filter_list():

View File

@@ -1,10 +1,18 @@
"""Unit tests for the group command in the OBS WebSocket CLI.""" """Unit tests for the group command in the OBS WebSocket CLI."""
import os
import pytest
from typer.testing import CliRunner from typer.testing import CliRunner
from obsws_cli.app import app from obsws_cli.app import app
runner = CliRunner(mix_stderr=False) runner = CliRunner()
if os.environ.get('OBS_TESTS_SKIP_GROUP_TESTS'):
pytest.skip(
'Skipping group tests as per environment variable', allow_module_level=True
)
def test_group_list(): def test_group_list():

View File

@@ -4,7 +4,7 @@ from typer.testing import CliRunner
from obsws_cli.app import app from obsws_cli.app import app
runner = CliRunner(mix_stderr=False) runner = CliRunner()
def test_hotkey_list(): def test_hotkey_list():

View File

@@ -4,7 +4,7 @@ from typer.testing import CliRunner
from obsws_cli.app import app from obsws_cli.app import app
runner = CliRunner(mix_stderr=False) runner = CliRunner()
def test_input_list(): def test_input_list():
@@ -13,10 +13,7 @@ def test_input_list():
assert result.exit_code == 0 assert result.exit_code == 0
assert 'Desktop Audio' in result.stdout assert 'Desktop Audio' in result.stdout
assert 'Mic/Aux' in result.stdout assert 'Mic/Aux' in result.stdout
assert all( assert all(item in result.stdout for item in ('pytest_input', 'pytest_input_2'))
item in result.stdout
for item in ('Colour Source', 'Colour Source 2', 'Colour Source 3')
)
def test_input_list_filter_input(): def test_input_list_filter_input():
@@ -39,9 +36,6 @@ def test_input_list_filter_colour():
"""Test the input list command with colour filter.""" """Test the input list command with colour filter."""
result = runner.invoke(app, ['input', 'list', '--colour']) result = runner.invoke(app, ['input', 'list', '--colour'])
assert result.exit_code == 0 assert result.exit_code == 0
assert all( assert all(item in result.stdout for item in ('pytest_input', 'pytest_input_2'))
item in result.stdout
for item in ('Colour Source', 'Colour Source 2', 'Colour Source 3')
)
assert 'Desktop Audio' not in result.stdout assert 'Desktop Audio' not in result.stdout
assert 'Mic/Aux' not in result.stdout assert 'Mic/Aux' not in result.stdout

View File

@@ -6,7 +6,7 @@ from typer.testing import CliRunner
from obsws_cli.app import app from obsws_cli.app import app
runner = CliRunner(mix_stderr=False) runner = CliRunner()
def test_record_start(): def test_record_start():
@@ -49,7 +49,9 @@ def test_record_toggle():
result = runner.invoke(app, ['record', 'toggle']) result = runner.invoke(app, ['record', 'toggle'])
assert result.exit_code == 0 assert result.exit_code == 0
time.sleep(0.5) # Wait for the recording to toggle time.sleep(0.5) # Wait for the recording to toggle
if active: if active:
assert 'Recording stopped successfully.' in result.stdout assert 'Recording stopped successfully.' in result.stdout
else: else:

View File

@@ -1,10 +1,20 @@
"""Unit tests for the replaybuffer command in the OBS WebSocket CLI.""" """Unit tests for the replaybuffer command in the OBS WebSocket CLI."""
import os
import time
import pytest
from typer.testing import CliRunner from typer.testing import CliRunner
from obsws_cli.app import app from obsws_cli.app import app
runner = CliRunner(mix_stderr=False) runner = CliRunner()
if os.environ.get('OBS_TESTS_SKIP_REPLAYBUFFER_TESTS'):
pytest.skip(
'Skipping replaybuffer tests as per environment variable',
allow_module_level=True,
)
def test_replaybuffer_start(): def test_replaybuffer_start():
@@ -14,6 +24,9 @@ def test_replaybuffer_start():
active = 'Replay buffer is active.' in resp.stdout active = 'Replay buffer is active.' in resp.stdout
resp = runner.invoke(app, ['replaybuffer', 'start']) resp = runner.invoke(app, ['replaybuffer', 'start'])
time.sleep(0.5) # Wait for the replay buffer to start
if active: if active:
assert resp.exit_code != 0 assert resp.exit_code != 0
assert 'Replay buffer is already active.' in resp.stderr assert 'Replay buffer is already active.' in resp.stderr
@@ -29,6 +42,9 @@ def test_replaybuffer_stop():
active = 'Replay buffer is active.' in resp.stdout active = 'Replay buffer is active.' in resp.stdout
resp = runner.invoke(app, ['replaybuffer', 'stop']) resp = runner.invoke(app, ['replaybuffer', 'stop'])
time.sleep(0.5) # Wait for the replay buffer to stop
if not active: if not active:
assert resp.exit_code != 0 assert resp.exit_code != 0
assert 'Replay buffer is not active.' in resp.stderr assert 'Replay buffer is not active.' in resp.stderr
@@ -44,9 +60,11 @@ def test_replaybuffer_toggle():
active = 'Replay buffer is active.' in resp.stdout active = 'Replay buffer is active.' in resp.stdout
resp = runner.invoke(app, ['replaybuffer', 'toggle']) resp = runner.invoke(app, ['replaybuffer', 'toggle'])
if active:
assert resp.exit_code == 0 assert resp.exit_code == 0
time.sleep(0.5) # Wait for the replay buffer to toggle
if active:
assert 'Replay buffer is not active.' in resp.stdout assert 'Replay buffer is not active.' in resp.stdout
else: else:
assert resp.exit_code == 0
assert 'Replay buffer is active.' in resp.stdout assert 'Replay buffer is active.' in resp.stdout

View File

@@ -4,7 +4,7 @@ from typer.testing import CliRunner
from obsws_cli.app import app from obsws_cli.app import app
runner = CliRunner(mix_stderr=False) runner = CliRunner()
def test_scene_list(): def test_scene_list():

View File

@@ -4,7 +4,7 @@ from typer.testing import CliRunner
from obsws_cli.app import app from obsws_cli.app import app
runner = CliRunner(mix_stderr=False) runner = CliRunner()
def test_sceneitem_list(): def test_sceneitem_list():

View File

@@ -6,7 +6,7 @@ from typer.testing import CliRunner
from obsws_cli.app import app from obsws_cli.app import app
runner = CliRunner(mix_stderr=False) runner = CliRunner()
def test_stream_start(): def test_stream_start():
@@ -23,7 +23,7 @@ def test_stream_start():
else: else:
assert result.exit_code == 0 assert result.exit_code == 0
assert 'Streaming started successfully.' in result.stdout assert 'Streaming started successfully.' in result.stdout
time.sleep(1) # Wait for the streaming to start time.sleep(0.5) # Wait for the streaming to start
def test_stream_stop(): def test_stream_stop():
@@ -37,7 +37,7 @@ def test_stream_stop():
if active: if active:
assert result.exit_code == 0 assert result.exit_code == 0
assert 'Streaming stopped successfully.' in result.stdout assert 'Streaming stopped successfully.' in result.stdout
time.sleep(1) # Wait for the streaming to stop time.sleep(0.5) # Wait for the streaming to stop
else: else:
assert result.exit_code != 0 assert result.exit_code != 0
assert 'Streaming is not in progress, cannot stop.' in result.stderr assert 'Streaming is not in progress, cannot stop.' in result.stderr
@@ -52,7 +52,7 @@ def test_stream_toggle():
result = runner.invoke(app, ['stream', 'toggle']) result = runner.invoke(app, ['stream', 'toggle'])
assert result.exit_code == 0 assert result.exit_code == 0
time.sleep(1) # Wait for the stream to toggle time.sleep(0.5) # Wait for the stream to toggle
if active: if active:
assert 'Streaming stopped successfully.' in result.stdout assert 'Streaming stopped successfully.' in result.stdout

View File

@@ -4,7 +4,7 @@ from typer.testing import CliRunner
from obsws_cli.app import app from obsws_cli.app import app
runner = CliRunner(mix_stderr=False) runner = CliRunner()
def test_studio_enable(): def test_studio_enable():

View File

@@ -4,7 +4,7 @@ from typer.testing import CliRunner
from obsws_cli.app import app from obsws_cli.app import app
runner = CliRunner(mix_stderr=False) runner = CliRunner()
def test_text_update(): def test_text_update():

View File

@@ -4,7 +4,7 @@ from typer.testing import CliRunner
from obsws_cli.app import app from obsws_cli.app import app
runner = CliRunner(mix_stderr=False) runner = CliRunner()
def test_version(): def test_version():