Compare commits

..

No commits in common. "ab71414d2750070079ff38ca468172cf663c56db" and "51a4a60aa619c1d5c869df60ff1581da593d863b" have entirely different histories.

14 changed files with 92 additions and 378 deletions

View File

@ -5,18 +5,6 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
# [0.16.1] - 2025-06-04
### Added
- screenshot save command, see [Screenshot](https://github.com/onyx-and-iris/obsws-cli/tree/main?tab=readme-ov-file#screenshot)
### Changed
- filter list:
- source_name arg is now optional, it defaults to the current scene.
- default values are printed if unmodified.
# [0.15.0] - 2025-06-02
### Added

View File

@ -519,10 +519,7 @@ obsws-cli hotkey trigger-sequence OBS_KEY_F1 --shift --ctrl
#### Filter
- list: List filters for a source.
*optional*
- args: <source_name>
- defaults to current scene
```console
obsws-cli filter list "Mic/Aux"
@ -583,24 +580,6 @@ obsws-cli projector open --monitor-index=1 "test_scene"
obsws-cli projector open --monitor-index=1 "test_group"
```
#### Screenshot
- save: Take a screenshot and save it to a file.
- flags:
*optional*
- --width:
- defaults to 1920
- --height:
- defaults to 1080
- --quality:
- defaults to -1
- args: <source_name> <output_path>
```console
obsws-cli screenshot save --width=2560 --height=1440 "Scene" "C:\Users\me\Videos\screenshot.png"
```
## License

View File

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

View File

@ -1,6 +1,5 @@
"""Command line interface for the OBS WebSocket API."""
import importlib
from typing import Annotated
import obsws_python as obsws
@ -9,29 +8,43 @@ from rich.console import Console
from obsws_cli.__about__ import __version__ as obsws_cli_version
from . import settings
from . import (
filter,
group,
hotkey,
input,
profile,
projector,
record,
replaybuffer,
scene,
scenecollection,
sceneitem,
settings,
stream,
studiomode,
virtualcam,
)
from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
for sub_typer in (
'filter',
'group',
'hotkey',
'input',
'profile',
'projector',
'record',
'replaybuffer',
'scene',
'scenecollection',
'sceneitem',
'screenshot',
'stream',
'studiomode',
'virtualcam',
for module in (
filter,
group,
hotkey,
input,
projector,
profile,
record,
replaybuffer,
scene,
scenecollection,
sceneitem,
stream,
studiomode,
virtualcam,
):
module = importlib.import_module(f'.{sub_typer}', package=__package__)
app.add_typer(module.app, name=sub_typer)
app.add_typer(module.app, name=module.__name__.split('.')[-1])
out_console = Console()
err_console = Console(stderr=True)

View File

@ -1,7 +1,5 @@
"""module containing commands for manipulating filters in scenes."""
from typing import Annotated, Optional
import obsws_python as obsws
import typer
from rich.console import Console
@ -21,20 +19,8 @@ def main():
@app.command('list | ls')
def list_(
ctx: typer.Context,
source_name: Annotated[
Optional[str],
typer.Argument(
show_default='The current scene',
help='The source to list filters for',
),
] = None,
):
def list(ctx: typer.Context, source_name: str):
"""List filters for a source."""
if not source_name:
source_name = ctx.obj.get_current_program_scene().scene_name
try:
resp = ctx.obj.get_source_filter_list(source_name)
except obsws.error.OBSSDKRequestError as e:
@ -58,9 +44,6 @@ def list_(
)
for filter in resp.filters:
resp = ctx.obj.get_source_filter_default_settings(filter['filterKind'])
settings = resp.default_filter_settings | filter['filterSettings']
table.add_row(
filter['filterName'],
util.snakecase_to_titlecase(filter['filterKind']),
@ -68,7 +51,7 @@ def list_(
'\n'.join(
[
f'{util.snakecase_to_titlecase(k):<20} {v:>10}'
for k, v in settings.items()
for k, v in filter['filterSettings'].items()
]
),
)
@ -85,18 +68,8 @@ def _get_filter_enabled(ctx: typer.Context, source_name: str, filter_name: str):
@app.command('enable | on')
def enable(
ctx: typer.Context,
source_name: Annotated[
str,
typer.Argument(
..., 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'
),
],
source_name: str = typer.Argument(..., help='The source to enable the filter for'),
filter_name: str = typer.Argument(..., help='The name of the filter to enable'),
):
"""Enable a filter for a source."""
if _get_filter_enabled(ctx, source_name, filter_name):
@ -112,18 +85,8 @@ def enable(
@app.command('disable | off')
def disable(
ctx: typer.Context,
source_name: Annotated[
str,
typer.Argument(
..., 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'
),
],
source_name: str = typer.Argument(..., help='The source to disable the filter for'),
filter_name: str = typer.Argument(..., help='The name of the filter to disable'),
):
"""Disable a filter for a source."""
if not _get_filter_enabled(ctx, source_name, filter_name):
@ -139,18 +102,8 @@ def disable(
@app.command('toggle | tg')
def toggle(
ctx: typer.Context,
source_name: Annotated[
str,
typer.Argument(
..., 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'
),
],
source_name: str = typer.Argument(..., help='The source to toggle the filter for'),
filter_name: str = typer.Argument(..., help='The name of the filter to toggle'),
):
"""Toggle a filter for a source."""
is_enabled = _get_filter_enabled(ctx, source_name, filter_name)
@ -166,18 +119,12 @@ def toggle(
@app.command('status | ss')
def status(
ctx: typer.Context,
source_name: Annotated[
str,
typer.Argument(
..., 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'
),
],
source_name: str = typer.Argument(
..., help='The source to get the filter status for'
),
filter_name: str = typer.Argument(
..., help='The name of the filter to get the status for'
),
):
"""Get the status of a filter for a source."""
is_enabled = _get_filter_enabled(ctx, source_name, filter_name)

View File

@ -1,7 +1,5 @@
"""module containing commands for manipulating groups in scenes."""
from typing import Annotated, Optional
import typer
from rich.console import Console
from rich.table import Table
@ -21,15 +19,11 @@ def main():
@app.command('list | ls')
def list_(
def list(
ctx: typer.Context,
scene_name: Annotated[
Optional[str],
typer.Argument(
show_default='The current scene',
help='Scene name to list groups for',
),
] = None,
scene_name: str = typer.Argument(
None, help='Scene name (optional, defaults to current scene)'
),
):
"""List groups in a scene."""
if not scene_name:
@ -81,16 +75,7 @@ def _get_group(group_name: str, resp: DataclassProtocol) -> dict | None:
@app.command('show | sh')
def show(
ctx: typer.Context,
scene_name: Annotated[
str,
typer.Argument(..., show_default=False, help='Scene name the group is in'),
],
group_name: Annotated[
str, typer.Argument(..., show_default=False, help='Group name to show')
],
):
def show(ctx: typer.Context, scene_name: str, group_name: str):
"""Show a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")
@ -111,15 +96,7 @@ def show(
@app.command('hide | h')
def hide(
ctx: typer.Context,
scene_name: Annotated[
str, typer.Argument(..., show_default=False, help='Scene name the group is in')
],
group_name: Annotated[
str, typer.Argument(..., show_default=False, help='Group name to hide')
],
):
def hide(ctx: typer.Context, scene_name: str, group_name: str):
"""Hide a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")
@ -140,15 +117,7 @@ def hide(
@app.command('toggle | tg')
def toggle(
ctx: typer.Context,
scene_name: Annotated[
str, typer.Argument(..., show_default=False, help='Scene name the group is in')
],
group_name: Annotated[
str, typer.Argument(..., show_default=False, help='Group name to toggle')
],
):
def toggle(ctx: typer.Context, scene_name: str, group_name: str):
"""Toggle a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")
@ -173,15 +142,7 @@ def toggle(
@app.command('status | ss')
def status(
ctx: typer.Context,
scene_name: Annotated[
str, typer.Argument(..., show_default=False, help='Scene name the group is in')
],
group_name: Annotated[
str, typer.Argument(..., show_default=False, help='Group name to check status')
],
):
def status(ctx: typer.Context, scene_name: str, group_name: str):
"""Get the status of a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")

View File

@ -1,7 +1,5 @@
"""module containing commands for hotkey management."""
from typing import Annotated
import typer
from rich.console import Console
from rich.table import Table
@ -19,7 +17,7 @@ def main():
@app.command('list | ls')
def list_(
def list(
ctx: typer.Context,
):
"""List all hotkeys."""
@ -37,9 +35,7 @@ def list_(
@app.command('trigger | tr')
def trigger(
ctx: typer.Context,
hotkey: Annotated[
str, typer.Argument(..., show_default=False, help='The hotkey to trigger')
],
hotkey: str = typer.Argument(..., help='The hotkey to trigger'),
):
"""Trigger a hotkey by name."""
ctx.obj.trigger_hotkey_by_name(hotkey)
@ -48,26 +44,14 @@ def trigger(
@app.command('trigger-sequence | trs')
def trigger_sequence(
ctx: typer.Context,
key_id: Annotated[
str,
typer.Argument(
...,
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',
),
],
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,
shift: bool = typer.Option(False, help='Press shift when triggering the hotkey'),
ctrl: bool = typer.Option(False, help='Press control when triggering the hotkey'),
alt: bool = typer.Option(False, help='Press alt when triggering the hotkey'),
cmd: bool = typer.Option(False, help='Press cmd when triggering the hotkey'),
key_id: str = typer.Argument(
...,
help='The OBS key ID to trigger, see https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#hotkey for more info',
),
):
"""Trigger a hotkey by sequence."""
ctx.obj.trigger_hotkey_by_key_sequence(key_id, shift, ctrl, alt, cmd)

View File

@ -20,7 +20,7 @@ def main():
@app.command('list | ls')
def list_(
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,
@ -67,12 +67,7 @@ def list_(
@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.')
],
):
def mute(ctx: typer.Context, input_name: str):
"""Mute an input."""
if not validate.input_in_inputs(ctx, input_name):
err_console.print(f"Input '{input_name}' not found.")
@ -87,13 +82,7 @@ def mute(
@app.command('unmute | um')
def unmute(
ctx: typer.Context,
input_name: Annotated[
str,
typer.Argument(..., show_default=False, help='Name of the input to unmute.'),
],
):
def unmute(ctx: typer.Context, input_name: str):
"""Unmute an input."""
if not validate.input_in_inputs(ctx, input_name):
err_console.print(f"Input '{input_name}' not found.")
@ -108,13 +97,7 @@ def unmute(
@app.command('toggle | tg')
def toggle(
ctx: typer.Context,
input_name: Annotated[
str,
typer.Argument(..., show_default=False, help='Name of the input to toggle.'),
],
):
def toggle(ctx: typer.Context, input_name: str):
"""Toggle an input."""
if not validate.input_in_inputs(ctx, input_name):
err_console.print(f"Input '{input_name}' not found.")

View File

@ -1,7 +1,5 @@
"""module containing commands for manipulating profiles in OBS."""
from typing import Annotated
import typer
from rich.console import Console
from rich.table import Table
@ -20,7 +18,7 @@ def main():
@app.command('list | ls')
def list_(ctx: typer.Context):
def list(ctx: typer.Context):
"""List profiles."""
resp = ctx.obj.get_profile_list()
@ -49,15 +47,7 @@ def current(ctx: typer.Context):
@app.command('switch | set')
def switch(
ctx: typer.Context,
profile_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='Name of the profile to switch to'
),
],
):
def switch(ctx: typer.Context, profile_name: str):
"""Switch to a profile."""
if not validate.profile_exists(ctx, profile_name):
err_console.print(f"Profile '{profile_name}' not found.")
@ -73,13 +63,7 @@ def switch(
@app.command('create | new')
def create(
ctx: typer.Context,
profile_name: Annotated[
str,
typer.Argument(..., show_default=False, help='Name of the profile to create.'),
],
):
def create(ctx: typer.Context, profile_name: str):
"""Create a new profile."""
if validate.profile_exists(ctx, profile_name):
err_console.print(f"Profile '{profile_name}' already exists.")
@ -90,13 +74,7 @@ def create(
@app.command('remove | rm')
def remove(
ctx: typer.Context,
profile_name: Annotated[
str,
typer.Argument(..., show_default=False, help='Name of the profile to remove.'),
],
):
def remove(ctx: typer.Context, profile_name: str):
"""Remove a profile."""
if not validate.profile_exists(ctx, profile_name):
err_console.print(f"Profile '{profile_name}' not found.")

View File

@ -52,8 +52,7 @@ def open(
source_name: Annotated[
str,
typer.Argument(
show_default='The current scene',
help='Name of the source to project.',
help='Name of the source to project. (optional, defaults to current scene)'
),
] = '',
):

View File

@ -20,7 +20,7 @@ def main():
@app.command('list | ls')
def list_(ctx: typer.Context):
def list(ctx: typer.Context):
"""List all scenes."""
resp = ctx.obj.get_scene_list()
scenes = (
@ -64,9 +64,7 @@ def current(
@app.command('switch | set')
def switch(
ctx: typer.Context,
scene_name: Annotated[
str, typer.Argument(..., help='Name of the scene to switch to')
],
scene_name: str,
preview: Annotated[
bool,
typer.Option(help='Switch to the preview scene instead of the program scene'),

View File

@ -1,7 +1,5 @@
"""module containing commands for manipulating scene collections."""
from typing import Annotated
import typer
from rich.console import Console
from rich.table import Table
@ -20,7 +18,7 @@ def main():
@app.command('list | ls')
def list_(ctx: typer.Context):
def list(ctx: typer.Context):
"""List all scene collections."""
resp = ctx.obj.get_scene_collection_list()
@ -41,12 +39,7 @@ def current(ctx: typer.Context):
@app.command('switch | set')
def switch(
ctx: typer.Context,
scene_collection_name: Annotated[
str, typer.Argument(..., help='Name of the scene collection to switch to')
],
):
def switch(ctx: typer.Context, scene_collection_name: str):
"""Switch to a scene collection."""
if not validate.scene_collection_in_scene_collections(ctx, scene_collection_name):
err_console.print(f"Scene collection '{scene_collection_name}' not found.")
@ -66,12 +59,7 @@ def switch(
@app.command('create | new')
def create(
ctx: typer.Context,
scene_collection_name: Annotated[
str, typer.Argument(..., help='Name of the scene collection to create')
],
):
def create(ctx: typer.Context, scene_collection_name: str):
"""Create a new scene collection."""
if validate.scene_collection_in_scene_collections(ctx, scene_collection_name):
err_console.print(f"Scene collection '{scene_collection_name}' already exists.")

View File

@ -22,15 +22,11 @@ def main():
@app.command('list | ls')
def list_(
def list(
ctx: typer.Context,
scene_name: Annotated[
Optional[str],
typer.Argument(
show_default='The current scene',
help='Scene name to list items for',
),
] = None,
scene_name: str = typer.Argument(
None, help='Scene name (optional, defaults to current scene)'
),
):
"""List all items in a scene."""
if not scene_name:
@ -167,10 +163,8 @@ def _get_scene_name_and_item_id(
@app.command('show | sh')
def show(
ctx: typer.Context,
scene_name: Annotated[str, typer.Argument(..., help='Scene name the item is in')],
item_name: Annotated[
str, typer.Argument(..., help='Item name to show in the scene')
],
scene_name: str,
item_name: str,
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Show an item in a scene."""
@ -200,10 +194,8 @@ def show(
@app.command('hide | h')
def hide(
ctx: typer.Context,
scene_name: Annotated[str, typer.Argument(..., help='Scene name the item is in')],
item_name: Annotated[
str, typer.Argument(..., help='Item name to hide in the scene')
],
scene_name: str,
item_name: str,
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Hide an item in a scene."""
@ -235,10 +227,8 @@ def hide(
@app.command('toggle | tg')
def toggle(
ctx: typer.Context,
scene_name: Annotated[str, typer.Argument(..., help='Scene name the item is in')],
item_name: Annotated[
str, typer.Argument(..., help='Item name to toggle in the scene')
],
scene_name: str,
item_name: str,
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Toggle an item in a scene."""
@ -301,10 +291,8 @@ def toggle(
@app.command('visible | v')
def visible(
ctx: typer.Context,
scene_name: Annotated[str, typer.Argument(..., help='Scene name the item is in')],
item_name: Annotated[
str, typer.Argument(..., help='Item name to check visibility in the scene')
],
scene_name: str,
item_name: str,
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Check if an item in a scene is visible."""

View File

@ -1,92 +0,0 @@
"""module for taking screenshots using OBS WebSocket API."""
from pathlib import Path
from typing import Annotated
import obsws_python as obsws
import typer
from rich.console import Console
from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(
stderr=True,
)
@app.callback()
def main():
"""Take screenshots using OBS."""
@app.command('save | sv')
def save(
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,
# we won't validate the path here.
typer.Argument(
...,
show_default=False,
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[
float,
typer.Option(
min=-1,
max=100,
help='Quality of the screenshot.',
),
] = -1,
):
"""Take a screenshot and save it to a file."""
try:
ctx.obj.save_source_screenshot(
name=source_name,
img_format=output_path.suffix.lstrip('.').lower(),
file_path=str(output_path),
width=width,
height=height,
quality=quality,
)
except obsws.error.OBSSDKRequestError as e:
match e.code:
case 403:
err_console.print(
'The image format (file extension) must be included in the file name '
"for example: '/path/to/screenshot.png'.",
)
raise typer.Exit(1)
case 600:
err_console.print(f"No source was found by the name of '{source_name}'")
raise typer.Exit(1)
case _:
raise
out_console.print(f"Screenshot saved to [bold]'{output_path}'[/bold].")