Compare commits

...

19 Commits

Author SHA1 Message Date
ab71414d27 no need to create list here 2025-06-04 18:03:29 +01:00
ab0679174b patch bump 2025-06-04 17:34:59 +01:00
37781f6de7 clean up defaults in help messages 2025-06-04 17:34:45 +01:00
5e84becc57 wrap annotations with Annotated 2025-06-04 16:46:29 +01:00
b8dd94ccbc wrap annotations with Annotated 2025-06-04 16:31:43 +01:00
657fa84ea3 wrap scene switch annotations with Annotated 2025-06-04 16:26:27 +01:00
59f52417cd wrap annotations with Annotated 2025-06-04 15:52:35 +01:00
2d351e00b5 wrap annotations with Annotated 2025-06-04 15:49:44 +01:00
5f606b42d0 wrap annotations with Annotated 2025-06-04 15:46:52 +01:00
ae4ec542aa wrap annotations with Annotated 2025-06-04 15:39:53 +01:00
6ac63aa5e8 patch bump 2025-06-04 15:27:03 +01:00
df90614352 add Changed filter list to 0.16.1 2025-06-04 15:25:14 +01:00
d8e89285cc upd Filter section in readme 2025-06-04 15:24:48 +01:00
3e2a1e4663 wrap annotations with Annotated
filter list source_name now optional, defaults to current scene

filter list now prints default values if they are unchanged
2025-06-04 15:24:35 +01:00
723d79e306 dry up the imports 2025-06-04 15:23:13 +01:00
868d40ec8d minor bump 2025-06-04 12:53:20 +01:00
30f19f4d87 add 0.16.0 to CHANGELOG 2025-06-04 12:53:04 +01:00
5b9dd97167 add screenshot sub typer 2025-06-04 12:52:51 +01:00
d41ad994b7 add screenshot section 2025-06-04 12:51:47 +01:00
14 changed files with 378 additions and 92 deletions

View File

@ -5,6 +5,18 @@ 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,7 +519,10 @@ 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"
@ -580,6 +583,24 @@ 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.15.2"
__version__ = "0.16.2"

View File

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

View File

@ -1,5 +1,7 @@
"""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
@ -19,8 +21,20 @@ def main():
@app.command('list | ls')
def list(ctx: typer.Context, source_name: str):
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,
):
"""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:
@ -44,6 +58,9 @@ def list(ctx: typer.Context, source_name: str):
)
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']),
@ -51,7 +68,7 @@ def list(ctx: typer.Context, source_name: str):
'\n'.join(
[
f'{util.snakecase_to_titlecase(k):<20} {v:>10}'
for k, v in filter['filterSettings'].items()
for k, v in settings.items()
]
),
)
@ -68,8 +85,18 @@ def _get_filter_enabled(ctx: typer.Context, source_name: str, filter_name: str):
@app.command('enable | on')
def enable(
ctx: typer.Context,
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'),
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'
),
],
):
"""Enable a filter for a source."""
if _get_filter_enabled(ctx, source_name, filter_name):
@ -85,8 +112,18 @@ def enable(
@app.command('disable | off')
def disable(
ctx: typer.Context,
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'),
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'
),
],
):
"""Disable a filter for a source."""
if not _get_filter_enabled(ctx, source_name, filter_name):
@ -102,8 +139,18 @@ def disable(
@app.command('toggle | tg')
def toggle(
ctx: typer.Context,
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'),
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'
),
],
):
"""Toggle a filter for a source."""
is_enabled = _get_filter_enabled(ctx, source_name, filter_name)
@ -119,12 +166,18 @@ def toggle(
@app.command('status | ss')
def status(
ctx: typer.Context,
source_name: str = typer.Argument(
..., help='The source to get the filter status for'
source_name: Annotated[
str,
typer.Argument(
..., show_default=False, 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'
],
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."""
is_enabled = _get_filter_enabled(ctx, source_name, filter_name)

View File

@ -1,5 +1,7 @@
"""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
@ -19,11 +21,15 @@ def main():
@app.command('list | ls')
def list(
def list_(
ctx: typer.Context,
scene_name: str = typer.Argument(
None, help='Scene name (optional, defaults to current scene)'
scene_name: Annotated[
Optional[str],
typer.Argument(
show_default='The current scene',
help='Scene name to list groups for',
),
] = None,
):
"""List groups in a scene."""
if not scene_name:
@ -75,7 +81,16 @@ def _get_group(group_name: str, resp: DataclassProtocol) -> dict | None:
@app.command('show | sh')
def show(ctx: typer.Context, scene_name: str, group_name: str):
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')
],
):
"""Show a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")
@ -96,7 +111,15 @@ def show(ctx: typer.Context, scene_name: str, group_name: str):
@app.command('hide | h')
def hide(ctx: typer.Context, scene_name: str, group_name: str):
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')
],
):
"""Hide a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")
@ -117,7 +140,15 @@ def hide(ctx: typer.Context, scene_name: str, group_name: str):
@app.command('toggle | tg')
def toggle(ctx: typer.Context, scene_name: str, group_name: str):
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')
],
):
"""Toggle a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")
@ -142,7 +173,15 @@ def toggle(ctx: typer.Context, scene_name: str, group_name: str):
@app.command('status | ss')
def status(ctx: typer.Context, scene_name: str, group_name: str):
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')
],
):
"""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,5 +1,7 @@
"""module containing commands for hotkey management."""
from typing import Annotated
import typer
from rich.console import Console
from rich.table import Table
@ -17,7 +19,7 @@ def main():
@app.command('list | ls')
def list(
def list_(
ctx: typer.Context,
):
"""List all hotkeys."""
@ -35,7 +37,9 @@ def list(
@app.command('trigger | tr')
def trigger(
ctx: typer.Context,
hotkey: str = typer.Argument(..., help='The hotkey to trigger'),
hotkey: Annotated[
str, typer.Argument(..., show_default=False, help='The hotkey to trigger')
],
):
"""Trigger a hotkey by name."""
ctx.obj.trigger_hotkey_by_name(hotkey)
@ -44,14 +48,26 @@ def trigger(
@app.command('trigger-sequence | trs')
def trigger_sequence(
ctx: typer.Context,
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(
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,
):
"""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,7 +67,12 @@ def list(
@app.command('mute | m')
def mute(ctx: typer.Context, input_name: str):
def mute(
ctx: typer.Context,
input_name: Annotated[
str, typer.Argument(..., show_default=False, help='Name of the input to mute.')
],
):
"""Mute an input."""
if not validate.input_in_inputs(ctx, input_name):
err_console.print(f"Input '{input_name}' not found.")
@ -82,7 +87,13 @@ def mute(ctx: typer.Context, input_name: str):
@app.command('unmute | um')
def unmute(ctx: typer.Context, input_name: str):
def unmute(
ctx: typer.Context,
input_name: Annotated[
str,
typer.Argument(..., show_default=False, help='Name of the input to unmute.'),
],
):
"""Unmute an input."""
if not validate.input_in_inputs(ctx, input_name):
err_console.print(f"Input '{input_name}' not found.")
@ -97,7 +108,13 @@ def unmute(ctx: typer.Context, input_name: str):
@app.command('toggle | tg')
def toggle(ctx: typer.Context, input_name: str):
def toggle(
ctx: typer.Context,
input_name: Annotated[
str,
typer.Argument(..., show_default=False, help='Name of the input to toggle.'),
],
):
"""Toggle an input."""
if not validate.input_in_inputs(ctx, input_name):
err_console.print(f"Input '{input_name}' not found.")

View File

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

View File

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

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,7 +64,9 @@ def current(
@app.command('switch | set')
def switch(
ctx: typer.Context,
scene_name: str,
scene_name: Annotated[
str, typer.Argument(..., help='Name of the scene to switch to')
],
preview: Annotated[
bool,
typer.Option(help='Switch to the preview scene instead of the program scene'),

View File

@ -1,5 +1,7 @@
"""module containing commands for manipulating scene collections."""
from typing import Annotated
import typer
from rich.console import Console
from rich.table import Table
@ -18,7 +20,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()
@ -39,7 +41,12 @@ def current(ctx: typer.Context):
@app.command('switch | set')
def switch(ctx: typer.Context, scene_collection_name: str):
def switch(
ctx: typer.Context,
scene_collection_name: Annotated[
str, typer.Argument(..., help='Name of the scene collection to switch to')
],
):
"""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.")
@ -59,7 +66,12 @@ def switch(ctx: typer.Context, scene_collection_name: str):
@app.command('create | new')
def create(ctx: typer.Context, scene_collection_name: str):
def create(
ctx: typer.Context,
scene_collection_name: Annotated[
str, typer.Argument(..., help='Name of the scene collection to create')
],
):
"""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,11 +22,15 @@ def main():
@app.command('list | ls')
def list(
def list_(
ctx: typer.Context,
scene_name: str = typer.Argument(
None, help='Scene name (optional, defaults to current scene)'
scene_name: Annotated[
Optional[str],
typer.Argument(
show_default='The current scene',
help='Scene name to list items for',
),
] = None,
):
"""List all items in a scene."""
if not scene_name:
@ -163,8 +167,10 @@ def _get_scene_name_and_item_id(
@app.command('show | sh')
def show(
ctx: typer.Context,
scene_name: str,
item_name: str,
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')
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Show an item in a scene."""
@ -194,8 +200,10 @@ def show(
@app.command('hide | h')
def hide(
ctx: typer.Context,
scene_name: str,
item_name: str,
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')
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Hide an item in a scene."""
@ -227,8 +235,10 @@ def hide(
@app.command('toggle | tg')
def toggle(
ctx: typer.Context,
scene_name: str,
item_name: str,
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')
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Toggle an item in a scene."""
@ -291,8 +301,10 @@ def toggle(
@app.command('visible | v')
def visible(
ctx: typer.Context,
scene_name: str,
item_name: str,
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')
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Check if an item in a scene is visible."""

92
obsws_cli/screenshot.py Normal file
View File

@ -0,0 +1,92 @@
"""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].")