Compare commits

...

6 Commits

Author SHA1 Message Date
5189ee1d5b print profile list as rich table
patch bump
2025-05-23 22:37:22 +01:00
94d6c32c31 print hotkey list as rich table
patch bump
2025-05-23 22:28:43 +01:00
995500b971 print input list as rich table
patch bump
2025-05-23 22:20:10 +01:00
abeb5285d8 print group list as rich table
scene_name arg is now optional

upd README

patch bump
2025-05-23 21:55:52 +01:00
37dbbdf4e2 print list as rich table
swap out typer.echo for rich consoles

add filter status command

add util function

minor bump
2025-05-23 21:29:18 +01:00
eaa66f0bd5 add filter commands 2025-05-23 10:28:03 +01:00
10 changed files with 302 additions and 46 deletions

View File

@ -5,6 +5,12 @@ 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.12.0] - 2025-05-23
### Added
- filter commands, see [Filter](https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#filter)
# [0.11.0] - 2025-05-22 # [0.11.0] - 2025-05-22
### Added ### Added

View File

@ -205,7 +205,10 @@ obsws-cli scenecollection create test-collection
#### Group #### Group
- list: List groups in a scene. - list: List groups in a scene.
*optional*
- args: <scene_name> - args: <scene_name>
- defaults to current scene
```console ```console
obsws-cli group list START obsws-cli group list START
@ -486,6 +489,42 @@ obsws-cli hotkey trigger-sequence OBS_KEY_F1 --ctrl
obsws-cli hotkey trigger-sequence OBS_KEY_F1 --shift --ctrl obsws-cli hotkey trigger-sequence OBS_KEY_F1 --shift --ctrl
``` ```
#### Filter
- list: List filters for a source.
- args: <source_name>
```console
obsws-cli filter list "Mic/Aux"
```
- enable: Enable a filter for a source.
- args: <source_name> <filter_name>
```console
obsws-cli filter enable "Mic/Aux" "Test Compression Filter"
```
- disable: Disable a filter for a source.
- args: <source_name> <filter_name>
```console
obsws-cli filter disable "Mic/Aux" "Test Compression Filter"
```
- toggle: Toggle a filter for a source.
- args: <source_name> <filter_name>
```console
obsws-cli filter toggle "Mic/Aux" "Test Compression Filter"
```
- status: Get the status of a filter for a source.
- args: <source_name> <filter_name>
```console
obsws-cli filter status "Mic/Aux" "Test Compression Filter"
```
## License ## 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.11.0" __version__ = "0.12.4"

View File

@ -6,6 +6,7 @@ import obsws_python as obsws
import typer import typer
from . import ( from . import (
filter,
group, group,
hotkey, hotkey,
input, input,
@ -24,6 +25,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup) app = typer.Typer(cls=AliasGroup)
for module in ( for module in (
filter,
group, group,
hotkey, hotkey,
input, input,

122
obsws_cli/filter.py Normal file
View File

@ -0,0 +1,122 @@
"""module containing commands for manipulating filters in scenes."""
import typer
from rich.console import Console
from rich.table import Table
from . import util
from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
@app.callback()
def main():
"""Control filters in OBS scenes."""
@app.command('list | ls')
def list(ctx: typer.Context, source_name: str):
"""List filters for a source."""
resp = ctx.obj.get_source_filter_list(source_name)
if not resp.filters:
out_console.print(f'No filters found for source {source_name}')
return
table = Table(title=f'Filters for Source: {source_name}')
for column in ('Name', 'Kind', 'Enabled', 'Settings'):
table.add_column(column, justify='center', style='cyan')
for filter in resp.filters:
table.add_row(
filter['filterName'],
util.snakecase_to_titlecase(filter['filterKind']),
':heavy_check_mark:' if filter['filterEnabled'] else ':x:',
'\n'.join(
[
f'{util.snakecase_to_titlecase(k):<20} {v:>10}'
for k, v in filter['filterSettings'].items()
]
),
)
out_console.print(table)
def _get_filter_enabled(ctx: typer.Context, source_name: str, filter_name: str):
"""Get the status of a filter for a source."""
resp = ctx.obj.get_source_filter(source_name, filter_name)
return resp.filter_enabled
@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'),
):
"""Enable a filter for a source."""
if _get_filter_enabled(ctx, source_name, filter_name):
err_console.print(
f'Filter {filter_name} is already enabled for source {source_name}'
)
raise typer.Exit(1)
ctx.obj.set_source_filter_enabled(source_name, filter_name, enabled=True)
out_console.print(f'Enabled filter {filter_name} for source {source_name}')
@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'),
):
"""Disable a filter for a source."""
if not _get_filter_enabled(ctx, source_name, filter_name):
err_console.print(
f'Filter {filter_name} is already disabled for source {source_name}'
)
raise typer.Exit(1)
ctx.obj.set_source_filter_enabled(source_name, filter_name, enabled=False)
out_console.print(f'Disabled filter {filter_name} for source {source_name}')
@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'),
):
"""Toggle a filter for a source."""
is_enabled = _get_filter_enabled(ctx, source_name, filter_name)
new_state = not is_enabled
ctx.obj.set_source_filter_enabled(source_name, filter_name, enabled=new_state)
if new_state:
out_console.print(f'Enabled filter {filter_name} for source {source_name}')
else:
out_console.print(f'Disabled filter {filter_name} for source {source_name}')
@app.command('status | ss')
def status(
ctx: typer.Context,
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)
if is_enabled:
out_console.print(f'Filter {filter_name} is enabled for source {source_name}')
else:
out_console.print(f'Filter {filter_name} is disabled for source {source_name}')

View File

@ -1,12 +1,16 @@
"""module containing commands for manipulating groups in scenes.""" """module containing commands for manipulating groups in scenes."""
import typer import typer
from rich.console import Console
from rich.table import Table
from . import validate from . import validate
from .alias import AliasGroup from .alias import AliasGroup
from .protocols import DataclassProtocol from .protocols import DataclassProtocol
app = typer.Typer(cls=AliasGroup) app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
@app.callback() @app.callback()
@ -15,17 +19,46 @@ def main():
@app.command('list | ls') @app.command('list | ls')
def list(ctx: typer.Context, scene_name: str): def list(
ctx: typer.Context,
scene_name: str = typer.Argument(
None, help='Scene name (optional, defaults to current scene)'
),
):
"""List groups in a scene.""" """List groups in a scene."""
if not scene_name:
scene_name = ctx.obj.get_current_program_scene().scene_name
if not validate.scene_in_scenes(ctx, scene_name): if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True) err_console.print(f"Scene '{scene_name}' not found.")
raise typer.Exit(1) raise typer.Exit(1)
resp = ctx.obj.get_scene_item_list(scene_name) resp = ctx.obj.get_scene_item_list(scene_name)
groups = ( groups = [
item.get('sourceName') for item in resp.scene_items if item.get('isGroup') (item.get('sceneItemId'), item.get('sourceName'), item.get('sceneItemEnabled'))
for item in resp.scene_items
if item.get('isGroup')
]
if not groups:
err_console.print(f"No groups found in scene '{scene_name}'.")
raise typer.Exit(1)
table = Table(title=f'Groups in Scene: {scene_name}')
for column in ('ID', 'Name', 'Enabled'):
table.add_column(
column, justify='left' if column == 'Name' else 'center', style='cyan'
) )
typer.echo('\n'.join(groups))
for item_id, group_name, is_enabled in groups:
table.add_row(
str(item_id),
group_name,
':heavy_check_mark:' if is_enabled else ':x:',
)
out_console.print(table)
def _get_group(group_name: str, resp: DataclassProtocol) -> dict | None: def _get_group(group_name: str, resp: DataclassProtocol) -> dict | None:
@ -45,12 +78,12 @@ def _get_group(group_name: str, resp: DataclassProtocol) -> dict | None:
def show(ctx: typer.Context, scene_name: str, group_name: str): def show(ctx: typer.Context, scene_name: str, group_name: str):
"""Show a group in a scene.""" """Show a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name): if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True) err_console.print(f"Scene '{scene_name}' not found.")
raise typer.Exit(1) raise typer.Exit(1)
resp = ctx.obj.get_scene_item_list(scene_name) resp = ctx.obj.get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None: if (group := _get_group(group_name, resp)) is None:
typer.echo(f"Group '{group_name}' not found in scene {scene_name}.", err=True) err_console.print(f"Group '{group_name}' not found in scene {scene_name}.")
raise typer.Exit(1) raise typer.Exit(1)
ctx.obj.set_scene_item_enabled( ctx.obj.set_scene_item_enabled(
@ -59,19 +92,19 @@ def show(ctx: typer.Context, scene_name: str, group_name: str):
enabled=True, enabled=True,
) )
typer.echo(f"Group '{group_name}' is now visible.") out_console.print(f"Group '{group_name}' is now visible.")
@app.command('hide | h') @app.command('hide | h')
def hide(ctx: typer.Context, scene_name: str, group_name: str): def hide(ctx: typer.Context, scene_name: str, group_name: str):
"""Hide a group in a scene.""" """Hide a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name): if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True) err_console.print(f"Scene '{scene_name}' not found.")
raise typer.Exit(1) raise typer.Exit(1)
resp = ctx.obj.get_scene_item_list(scene_name) resp = ctx.obj.get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None: if (group := _get_group(group_name, resp)) is None:
typer.echo(f"Group '{group_name}' not found in scene {scene_name}.", err=True) err_console.print(f"Group '{group_name}' not found in scene {scene_name}.")
raise typer.Exit(1) raise typer.Exit(1)
ctx.obj.set_scene_item_enabled( ctx.obj.set_scene_item_enabled(
@ -80,19 +113,19 @@ def hide(ctx: typer.Context, scene_name: str, group_name: str):
enabled=False, enabled=False,
) )
typer.echo(f"Group '{group_name}' is now hidden.") out_console.print(f"Group '{group_name}' is now hidden.")
@app.command('toggle | tg') @app.command('toggle | tg')
def toggle(ctx: typer.Context, scene_name: str, group_name: str): def toggle(ctx: typer.Context, scene_name: str, group_name: str):
"""Toggle a group in a scene.""" """Toggle a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name): if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True) err_console.print(f"Scene '{scene_name}' not found.")
raise typer.Exit(1) raise typer.Exit(1)
resp = ctx.obj.get_scene_item_list(scene_name) resp = ctx.obj.get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None: if (group := _get_group(group_name, resp)) is None:
typer.echo(f"Group '{group_name}' not found in scene {scene_name}.", err=True) err_console.print(f"Group '{group_name}' not found in scene {scene_name}.")
raise typer.Exit(1) raise typer.Exit(1)
new_state = not group.get('sceneItemEnabled') new_state = not group.get('sceneItemEnabled')
@ -103,21 +136,21 @@ def toggle(ctx: typer.Context, scene_name: str, group_name: str):
) )
if new_state: if new_state:
typer.echo(f"Group '{group_name}' is now visible.") out_console.print(f"Group '{group_name}' is now visible.")
else: else:
typer.echo(f"Group '{group_name}' is now hidden.") out_console.print(f"Group '{group_name}' is now hidden.")
@app.command('status | ss') @app.command('status | ss')
def status(ctx: typer.Context, scene_name: str, group_name: str): def status(ctx: typer.Context, scene_name: str, group_name: str):
"""Get the status of a group in a scene.""" """Get the status of a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name): if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True) err_console.print(f"Scene '{scene_name}' not found.")
raise typer.Exit(1) raise typer.Exit(1)
resp = ctx.obj.get_scene_item_list(scene_name) resp = ctx.obj.get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None: if (group := _get_group(group_name, resp)) is None:
typer.echo(f"Group '{group_name}' not found in scene {scene_name}.", err=True) err_console.print(f"Group '{group_name}' not found in scene {scene_name}.")
raise typer.Exit(1) raise typer.Exit(1)
enabled = ctx.obj.get_scene_item_enabled( enabled = ctx.obj.get_scene_item_enabled(
@ -126,6 +159,6 @@ def status(ctx: typer.Context, scene_name: str, group_name: str):
) )
if enabled.scene_item_enabled: if enabled.scene_item_enabled:
typer.echo(f"Group '{group_name}' is now visible.") out_console.print(f"Group '{group_name}' is now visible.")
else: else:
typer.echo(f"Group '{group_name}' is now hidden.") out_console.print(f"Group '{group_name}' is now hidden.")

View File

@ -1,10 +1,14 @@
"""module containing commands for hotkey management.""" """module containing commands for hotkey management."""
import typer import typer
from rich.console import Console
from rich.table import Table
from .alias import AliasGroup from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup) app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
@app.callback() @app.callback()
@ -18,7 +22,14 @@ def list(
): ):
"""List all hotkeys.""" """List all hotkeys."""
resp = ctx.obj.get_hotkey_list() resp = ctx.obj.get_hotkey_list()
typer.echo('\n'.join(resp.hotkeys))
table = Table(title='Hotkeys')
table.add_column('Name', justify='left', style='cyan')
for hotkey in resp.hotkeys:
table.add_row(hotkey)
out_console.print(table)
@app.command('trigger | tr') @app.command('trigger | tr')

View File

@ -3,11 +3,15 @@
from typing import Annotated from typing import Annotated
import typer import typer
from rich.console import Console
from rich.table import Table
from . import validate from . import util, validate
from .alias import AliasGroup from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup) app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
@app.callback() @app.callback()
@ -35,18 +39,38 @@ def list(
if not any([input, output, colour]): if not any([input, output, colour]):
kinds = ['input', 'output', 'color'] kinds = ['input', 'output', 'color']
inputs = filter( inputs = [
(input_.get('inputName'), input_.get('inputKind'))
for input_ in filter(
lambda input_: any(kind in input_.get('inputKind') for kind in kinds), lambda input_: any(kind in input_.get('inputKind') for kind in kinds),
resp.inputs, resp.inputs,
) )
typer.echo('\n'.join(input_.get('inputName') for input_ in inputs)) ]
if not inputs:
err_console.print('No inputs found.')
raise typer.Exit(1)
table = Table(title='Inputs')
for column in ('Name', 'Kind'):
table.add_column(
column, justify='left' if column == 'Name' else 'center', style='cyan'
)
for input_name, input_kind in inputs:
table.add_row(
input_name,
util.snakecase_to_titlecase(input_kind),
)
out_console.print(table)
@app.command('mute | m') @app.command('mute | m')
def mute(ctx: typer.Context, input_name: str): def mute(ctx: typer.Context, input_name: str):
"""Mute an input.""" """Mute an input."""
if not validate.input_in_inputs(ctx, input_name): if not validate.input_in_inputs(ctx, input_name):
typer.echo(f"Input '{input_name}' not found.", err=True) err_console.print(f"Input '{input_name}' not found.")
raise typer.Exit(1) raise typer.Exit(1)
ctx.obj.set_input_mute( ctx.obj.set_input_mute(
@ -54,14 +78,14 @@ def mute(ctx: typer.Context, input_name: str):
muted=True, muted=True,
) )
typer.echo(f"Input '{input_name}' muted.") out_console.print(f"Input '{input_name}' muted.")
@app.command('unmute | um') @app.command('unmute | um')
def unmute(ctx: typer.Context, input_name: str): def unmute(ctx: typer.Context, input_name: str):
"""Unmute an input.""" """Unmute an input."""
if not validate.input_in_inputs(ctx, input_name): if not validate.input_in_inputs(ctx, input_name):
typer.echo(f"Input '{input_name}' not found.", err=True) err_console.print(f"Input '{input_name}' not found.")
raise typer.Exit(1) raise typer.Exit(1)
ctx.obj.set_input_mute( ctx.obj.set_input_mute(
@ -69,17 +93,16 @@ def unmute(ctx: typer.Context, input_name: str):
muted=False, muted=False,
) )
typer.echo(f"Input '{input_name}' unmuted.") out_console.print(f"Input '{input_name}' unmuted.")
@app.command('toggle | tg') @app.command('toggle | tg')
def toggle(ctx: typer.Context, input_name: str): def toggle(ctx: typer.Context, input_name: str):
"""Toggle an input.""" """Toggle an input."""
if not validate.input_in_inputs(ctx, input_name): if not validate.input_in_inputs(ctx, input_name):
typer.echo(f"Input '{input_name}' not found.", err=True) err_console.print(f"Input '{input_name}' not found.")
raise typer.Exit(1) raise typer.Exit(1)
# Get the current mute state
resp = ctx.obj.get_input_mute(name=input_name) resp = ctx.obj.get_input_mute(name=input_name)
new_state = not resp.input_muted new_state = not resp.input_muted
@ -88,6 +111,6 @@ def toggle(ctx: typer.Context, input_name: str):
muted=new_state, muted=new_state,
) )
typer.echo( out_console.print(
f"Input '{input_name}' {'muted' if new_state else 'unmuted'}.", f"Input '{input_name}' {'muted' if new_state else 'unmuted'}.",
) )

View File

@ -1,11 +1,15 @@
"""module containing commands for manipulating profiles in OBS.""" """module containing commands for manipulating profiles in OBS."""
import typer import typer
from rich.console import Console
from rich.table import Table
from . import validate from . import validate
from .alias import AliasGroup from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup) app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
@app.callback() @app.callback()
@ -17,52 +21,62 @@ def main():
def list(ctx: typer.Context): def list(ctx: typer.Context):
"""List profiles.""" """List profiles."""
resp = ctx.obj.get_profile_list() resp = ctx.obj.get_profile_list()
table = Table(title='Profiles')
for column in ('Name', 'Current'):
table.add_column(
column, justify='left' if column == 'Name' else 'center', style='cyan'
)
for profile in resp.profiles: for profile in resp.profiles:
typer.echo(profile) table.add_row(
profile,
':heavy_check_mark:' if profile == resp.current_profile_name else '',
)
out_console.print(table)
@app.command('current | get') @app.command('current | get')
def current(ctx: typer.Context): def current(ctx: typer.Context):
"""Get the current profile.""" """Get the current profile."""
resp = ctx.obj.get_profile_list() resp = ctx.obj.get_profile_list()
typer.echo(resp.current_profile_name) out_console.print(resp.current_profile_name)
@app.command('switch | set') @app.command('switch | set')
def switch(ctx: typer.Context, profile_name: str): def switch(ctx: typer.Context, profile_name: str):
"""Switch to a profile.""" """Switch to a profile."""
if not validate.profile_exists(ctx, profile_name): if not validate.profile_exists(ctx, profile_name):
typer.echo(f"Profile '{profile_name}' not found.", err=True) err_console.print(f"Profile '{profile_name}' not found.")
raise typer.Exit(1) raise typer.Exit(1)
resp = ctx.obj.get_profile_list() resp = ctx.obj.get_profile_list()
if resp.current_profile_name == profile_name: if resp.current_profile_name == profile_name:
typer.echo( err_console.print(f"Profile '{profile_name}' is already the current profile.")
f"Profile '{profile_name}' is already the current profile.", err=True
)
raise typer.Exit(1) raise typer.Exit(1)
ctx.obj.set_current_profile(profile_name) ctx.obj.set_current_profile(profile_name)
typer.echo(f"Switched to profile '{profile_name}'.") out_console.print(f"Switched to profile '{profile_name}'.")
@app.command('create | new') @app.command('create | new')
def create(ctx: typer.Context, profile_name: str): def create(ctx: typer.Context, profile_name: str):
"""Create a new profile.""" """Create a new profile."""
if validate.profile_exists(ctx, profile_name): if validate.profile_exists(ctx, profile_name):
typer.echo(f"Profile '{profile_name}' already exists.", err=True) err_console.print(f"Profile '{profile_name}' already exists.")
raise typer.Exit(1) raise typer.Exit(1)
ctx.obj.create_profile(profile_name) ctx.obj.create_profile(profile_name)
typer.echo(f"Created profile '{profile_name}'.") out_console.print(f"Created profile '{profile_name}'.")
@app.command('remove | rm') @app.command('remove | rm')
def remove(ctx: typer.Context, profile_name: str): def remove(ctx: typer.Context, profile_name: str):
"""Remove a profile.""" """Remove a profile."""
if not validate.profile_exists(ctx, profile_name): if not validate.profile_exists(ctx, profile_name):
typer.echo(f"Profile '{profile_name}' not found.", err=True) err_console.print(f"Profile '{profile_name}' not found.")
raise typer.Exit(1) raise typer.Exit(1)
ctx.obj.remove_profile(profile_name) ctx.obj.remove_profile(profile_name)
typer.echo(f"Removed profile '{profile_name}'.") out_console.print(f"Removed profile '{profile_name}'.")

6
obsws_cli/util.py Normal file
View File

@ -0,0 +1,6 @@
"""module contains utility functions for the obsws_cli package."""
def snakecase_to_titlecase(snake_str):
"""Convert a snake_case string to a title case string."""
return snake_str.replace('_', ' ').title()