Skip to content

Commit c7f1ba4

Browse files
author
Stanislav Ochotnicky
committed
Add generic CLI utility
This adds a basic CLI utility that uses introspection to get all possible commands and enables running them from console. It also provides way to specify complex JSON arguments if needed. Fixes #46
1 parent a1d411d commit c7f1ba4

File tree

4 files changed

+302
-2
lines changed

4 files changed

+302
-2
lines changed

README.md

+47-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# obs-websocket-py
2-
Python library to communicate with an [obs-websocket](https://github.com/Palakis/obs-websocket) server.
2+
Python library & CLI to communicate with an [obs-websocket](https://github.com/Palakis/obs-websocket) server.
33

44
_Licensed under the MIT License_
55

@@ -95,6 +95,52 @@ obswebsocket.core.obsws = class obsws
9595
| :return: Nothing
9696
```
9797

98+
There is also a simple CLI provided with the installation. It can be used in variety of ways, but is not meant to cover all use cases.
99+
100+
```
101+
$ obs-web-cli --help
102+
OBS Studio CLI using OBS Websocket Plugin
103+
104+
optional arguments:
105+
-h, --help show this help message and exit
106+
--host HOST Hostname to connect to (default: localhost)
107+
--port PORT Port to connect to (default: 4444)
108+
--password PASSWORD Password to use. Defaults to OBS_WEBSOCKET_PASS env
109+
var (default: None)
110+
--debug Enable debugging output (default: False)
111+
112+
Recognized commands:
113+
{GetStreamingStatus,StartStopStreaming,StartStreaming,StopStreaming,SetStreamSettings,GetStreamSettings,SaveStreamSettings,SendCaptions,GetStudioModeStatus,GetPreviewScene,SetPreviewScene,TransitionToProgram,EnableStudioMode,DisableStudioMode,ToggleStudioMode,ListOutputs,GetOutputInfo,StartOutput,StopOutput,StartStopReplayBuffer,StartReplayBuffer,StopReplayBuffer,SaveReplayBuffer,SetCurrentScene,GetCurrentScene,GetSceneList,ReorderSceneItems,SetCurrentProfile,GetCurrentProfile,ListProfiles,GetVersion,GetAuthRequired,Authenticate,SetHeartbeat,SetFilenameFormatting,GetFilenameFormatting,GetStats,BroadcastCustomMessage,GetVideoInfo,StartStopRecording,StartRecording,StopRecording,PauseRecording,ResumeRecording,SetRecordingFolder,GetRecordingFolder,GetSourcesList,GetSourceTypesList,GetVolume,SetVolume,GetMute,SetMute,ToggleMute,SetSyncOffset,GetSyncOffset,GetSourceSettings,SetSourceSettings,GetTextGDIPlusProperties,SetTextGDIPlusProperties,GetTextFreetype2Properties,SetTextFreetype2Properties,GetBrowserSourceProperties,SetBrowserSourceProperties,GetSpecialSources,GetSourceFilters,GetSourceFilterInfo,AddFilterToSource,RemoveFilterFromSource,ReorderSourceFilter,MoveSourceFilter,SetSourceFilterSettings,SetSourceFilterVisibility,TakeSourceScreenshot,SetCurrentSceneCollection,GetCurrentSceneCollection,ListSceneCollections,GetTransitionList,GetCurrentTransition,SetCurrentTransition,SetTransitionDuration,GetTransitionDuration,GetSceneItemProperties,SetSceneItemProperties,ResetSceneItem,SetSceneItemRender,SetSceneItemPosition,SetSceneItemTransform,SetSceneItemCrop,DeleteSceneItem,DuplicateSceneItem}
114+
```
115+
116+
Simple arguments can be provided directly on the command line:
117+
118+
```
119+
$ obs-web-cli SetCurrentSceneCollection "Untitled"
120+
INFO:obswebsocket.core:Connecting...
121+
INFO:obswebsocket.core:Connected!
122+
{}
123+
INFO:obswebsocket.core:Disconnecting...
124+
```
125+
126+
More complex arguments might require passing in a JSON string `json:` prefix. For example:
127+
128+
```
129+
$ obs-web-cli SetSourceSettings "gif_source1" 'json:{"looping": true}' ffmpeg_source
130+
INFO:obswebsocket.core:Connecting...
131+
INFO:obswebsocket.core:Connected!
132+
{
133+
"sourceName": "gif_source1",
134+
"sourceSettings": {
135+
"hw_decode": true,
136+
"local_file": "/images/demo.gif",
137+
"looping": true
138+
},
139+
"sourceType": "ffmpeg_source"
140+
}
141+
INFO:obswebsocket.core:Disconnecting...
142+
```
143+
98144
## Problems?
99145

100146
Please check on [Github project issues](https://github.com/Elektordi/obs-websocket-py/issues), and if nobody else have experienced it before, you can [file a new issue](https://github.com/Elektordi/obs-websocket-py/issues/new).

obswebsocket/cli.py

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import argparse
2+
import inspect
3+
import json
4+
import logging
5+
import os
6+
import sys
7+
8+
import obswebsocket
9+
import obswebsocket.requests
10+
11+
12+
def setup_parser():
13+
parser = argparse.ArgumentParser(
14+
description="OBS Studio CLI using OBS Websocket Plugin",
15+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
16+
)
17+
subparsers = parser.add_subparsers(
18+
help="OBS Commands", title="Recognized commands", dest="command"
19+
)
20+
21+
parser.add_argument("--host", default="localhost", help="Hostname to connect to")
22+
parser.add_argument("--port", default=4444, type=int, help="Port to connect to")
23+
parser.add_argument(
24+
"--password",
25+
default=os.environ.get("OBS_WEBSOCKET_PASS", None),
26+
help="Password to use. Defaults to OBS_WEBSOCKET_PASS env var",
27+
)
28+
parser.add_argument(
29+
"--debug", default=False, action="store_true", help="Enable debugging output"
30+
)
31+
32+
for subclass in obswebsocket.base_classes.Baserequests.__subclasses__():
33+
# Generate a subcommand for each subclass with argument
34+
subparser = subparsers.add_parser(subclass.__name__)
35+
for arg in inspect.getargspec(subclass.__init__).args:
36+
if arg == "self":
37+
continue
38+
# TODO: add defaults and maybe deal with optional args?
39+
subparser.add_argument(arg)
40+
41+
return parser
42+
43+
44+
def main():
45+
parser = setup_parser()
46+
args = parser.parse_args()
47+
if not args.command:
48+
parser.error("No command specified")
49+
50+
log_level = logging.INFO
51+
if args.debug:
52+
log_level = logging.DEBUG
53+
54+
logging.basicConfig(level=log_level)
55+
56+
client = obswebsocket.obsws(args.host, args.port, args.password or "")
57+
try:
58+
client.connect()
59+
except obswebsocket.exceptions.ConnectionFailure as e:
60+
logging.error(e)
61+
sys.exit(1)
62+
63+
for subclass in obswebsocket.base_classes.Baserequests.__subclasses__():
64+
if subclass.__name__ != args.command:
65+
continue
66+
67+
# OK, found which request class we need to instantiate.
68+
# Now let's populate the arguments if we can
69+
command_args = []
70+
for arg in inspect.getfullargspec(subclass.__init__).args:
71+
if arg == "self":
72+
continue
73+
val = args.__dict__.get(arg)
74+
if val.startswith("json:"):
75+
# Support "objects" by parsing JSON strings if the argument is
76+
# prefixed with "json:"
77+
val = json.loads(val[5:])
78+
else:
79+
# Try to convert numbers from string
80+
try:
81+
val = int(val)
82+
except ValueError:
83+
pass
84+
command_args.append(val)
85+
86+
# Instantiate the request class based on collected args
87+
instance = subclass(*command_args)
88+
ret = client.call(instance)
89+
if not ret.status:
90+
# Call failed let's report and exit
91+
logging.error("Call to OBS failed: %s", ret.datain["error"])
92+
client.disconnect()
93+
sys.exit(1)
94+
95+
print(json.dumps(ret.datain, indent=4))
96+
97+
client.disconnect()
98+
99+
100+
if __name__ == "__main__":
101+
main()

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def run(self):
4444
author_email='elektordi@elektordi.net',
4545
url='https://github.com/Elektordi/obs-websocket-py',
4646
keywords=['obs', 'obs-studio', 'websocket'],
47+
entry_points={"console_scripts": ["obs-web-cli=obswebsocket.cli:main"]},
4748
classifiers=[
4849
'License :: OSI Approved :: MIT License',
4950
'Environment :: Plugins',

test_ci.py

+153-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from obswebsocket import obsws, requests, events
1+
import os
2+
from obswebsocket import obsws, requests, events, cli
23

34
host = "127.0.0.1"
45
port = 4444
@@ -18,3 +19,154 @@ def test_build_ok_requests():
1819
def test_build_ok_events():
1920
e = events.Heartbeat()
2021
assert e.name == "Heartbeat"
22+
23+
24+
def test_cli_parser():
25+
os.environ["OBS_WEBSOCKET_PASS"] = "obs-password"
26+
parser = cli.setup_parser()
27+
assert parser
28+
subparsers = parser._get_positional_actions()[0]
29+
# We expect at least these subcommands. Ideally would be kept up
30+
# to date so we remove commands
31+
expected_subcommands = {
32+
"PlayPauseMedia",
33+
"RestartMedia",
34+
"StopMedia",
35+
"NextMedia",
36+
"PreviousMedia",
37+
"GetMediaDuration",
38+
"GetMediaTime",
39+
"SetMediaTime",
40+
"ScrubMedia",
41+
"GetMediaState",
42+
"GetStreamingStatus",
43+
"StartStopStreaming",
44+
"StartStreaming",
45+
"StopStreaming",
46+
"SetStreamSettings",
47+
"GetStreamSettings",
48+
"SaveStreamSettings",
49+
"SendCaptions",
50+
"GetStudioModeStatus",
51+
"GetPreviewScene",
52+
"SetPreviewScene",
53+
"TransitionToProgram",
54+
"EnableStudioMode",
55+
"DisableStudioMode",
56+
"ToggleStudioMode",
57+
"ListOutputs",
58+
"GetOutputInfo",
59+
"StartOutput",
60+
"StopOutput",
61+
"GetReplayBufferStatus",
62+
"StartStopReplayBuffer",
63+
"StartReplayBuffer",
64+
"StopReplayBuffer",
65+
"SaveReplayBuffer",
66+
"SetCurrentScene",
67+
"GetCurrentScene",
68+
"GetSceneList",
69+
"CreateScene",
70+
"ReorderSceneItems",
71+
"SetSceneTransitionOverride",
72+
"RemoveSceneTransitionOverride",
73+
"GetSceneTransitionOverride",
74+
"SetCurrentProfile",
75+
"GetCurrentProfile",
76+
"ListProfiles",
77+
"GetVersion",
78+
"GetAuthRequired",
79+
"Authenticate",
80+
"SetHeartbeat",
81+
"SetFilenameFormatting",
82+
"GetFilenameFormatting",
83+
"GetStats",
84+
"BroadcastCustomMessage",
85+
"GetVideoInfo",
86+
"OpenProjector",
87+
"TriggerHotkeyByName",
88+
"TriggerHotkeyBySequence",
89+
"GetRecordingStatus",
90+
"StartStopRecording",
91+
"StartRecording",
92+
"StopRecording",
93+
"PauseRecording",
94+
"ResumeRecording",
95+
"SetRecordingFolder",
96+
"GetRecordingFolder",
97+
"GetMediaSourcesList",
98+
"CreateSource",
99+
"GetSourcesList",
100+
"GetSourceTypesList",
101+
"GetVolume",
102+
"SetVolume",
103+
"GetMute",
104+
"SetMute",
105+
"ToggleMute",
106+
"GetAudioActive",
107+
"SetSourceName",
108+
"SetSyncOffset",
109+
"GetSyncOffset",
110+
"GetSourceSettings",
111+
"SetSourceSettings",
112+
"GetTextGDIPlusProperties",
113+
"SetTextGDIPlusProperties",
114+
"GetTextFreetype2Properties",
115+
"SetTextFreetype2Properties",
116+
"GetBrowserSourceProperties",
117+
"SetBrowserSourceProperties",
118+
"GetSpecialSources",
119+
"GetSourceFilters",
120+
"GetSourceFilterInfo",
121+
"AddFilterToSource",
122+
"RemoveFilterFromSource",
123+
"ReorderSourceFilter",
124+
"MoveSourceFilter",
125+
"SetSourceFilterSettings",
126+
"SetSourceFilterVisibility",
127+
"GetAudioMonitorType",
128+
"SetAudioMonitorType",
129+
"TakeSourceScreenshot",
130+
"SetCurrentSceneCollection",
131+
"GetCurrentSceneCollection",
132+
"ListSceneCollections",
133+
"GetTransitionList",
134+
"GetCurrentTransition",
135+
"SetCurrentTransition",
136+
"SetTransitionDuration",
137+
"GetTransitionDuration",
138+
"GetTransitionPosition",
139+
"GetTransitionSettings",
140+
"SetTransitionSettings",
141+
"ReleaseTBar",
142+
"SetTBarPosition",
143+
"GetSceneItemList",
144+
"GetSceneItemProperties",
145+
"SetSceneItemProperties",
146+
"ResetSceneItem",
147+
"SetSceneItemRender",
148+
"SetSceneItemPosition",
149+
"SetSceneItemTransform",
150+
"SetSceneItemCrop",
151+
"DeleteSceneItem",
152+
"AddSceneItem",
153+
"DuplicateSceneItem",
154+
}
155+
assert not expected_subcommands - set(subparsers.choices.keys())
156+
157+
# Basic arguments + environ parsing
158+
args = parser.parse_args(["--host", "hostname", "--port", "1234", "GetVideoInfo"])
159+
assert args.host == "hostname"
160+
assert args.port == 1234
161+
assert args.password == "obs-password"
162+
assert args.command == "GetVideoInfo"
163+
164+
# More complex command parsing
165+
args = parser.parse_args(
166+
["SetSourceSettings", "SourceName", 'json:{"looping": true}', "ffmpeg_source"]
167+
)
168+
assert args.command == "SetSourceSettings"
169+
# These will change depending on the sub-command
170+
assert args.sourceName == "SourceName"
171+
assert args.sourceSettings == 'json:{"looping": true}'
172+
assert args.sourceType == "ffmpeg_source"

0 commit comments

Comments
 (0)