61 Commits

Author SHA1 Message Date
ef8df5cf4d bump to 1.7.0 2024-01-21 12:34:10 +00:00
1abca0c7e4 bump to 1.7.0b0 2024-01-09 15:37:33 +00:00
85180c1d94 upd variable name 2024-01-09 12:17:47 +00:00
f4db1ad95c fix prompt 2024-01-07 14:37:15 +00:00
efaee7594e should a socket operation be attempted after socket closed
then catch and log OSError and close thread.
2024-01-07 12:35:20 +00:00
2cebd5eedb upd examples, they now use context managers 2024-01-07 11:21:01 +00:00
cac236c004 removes timeout for socket before starting worker thread 2024-01-07 11:19:33 +00:00
6aa6db09eb adds an event object and listens until its set
sets the event object on WebSocketConnectionClosedException

adds __enter__(), __exit__() methods

adds disconnect() to event client. aliases it as unsubscribe

checks for non-empty response with:
`if r := self.base_client.ws.recv()`
before attempting to json.load() it.
2024-01-05 09:57:08 +00:00
f1c2efa4a1 adds disconnect() method to ReqClient
now calling disconnect() in __exit__()
2024-01-05 09:36:02 +00:00
Adem
4654d2529f Merge pull request #39 from onyx-and-iris/dev
patch bump for PR #37
2023-10-23 14:58:56 +03:00
1494208f63 patch bump for issue #37 2023-10-23 12:43:59 +01:00
Adem
d217630289 Merge pull request #37 from aatikturk/implement_v5.3_methods
Update reqs.py

implemented  set_record_directory method. (only availabe for obs websocket v5.3 or higher)
2023-10-23 14:00:08 +03:00
Adem
5bfe792fa6 Update reqs.py
added set_record_directory  method to ReqClient.
2023-10-23 09:29:16 +03:00
3c36619173 Merge pull request #36 from onyx-and-iris/add-projector-methods
Add projector methods
2023-10-10 17:38:53 +01:00
c4cf817042 split at full stop 2023-10-09 22:34:05 +01:00
ba5da8dfef upd obsbasic hotkey list in tests 2023-10-09 22:29:18 +01:00
83577e2d61 adds projector methods with a deprecation warning
patch bump

closes #35
2023-10-09 22:06:18 +01:00
Adem
8aa2e78ba6 Merge pull request #32 from onyx-and-iris/add-request-error-class
Error handling with base error class
2023-08-14 14:38:43 +03:00
780f07e25f minor version bump 2023-08-14 12:18:29 +01:00
70a422696e expand the Requests section in README
add a section about the {ReqClient}.send() method.
2023-08-14 11:11:46 +01:00
a7ef61018b refactor OBSSDKRequestError
reword error section in README
2023-08-14 00:44:59 +01:00
013cf15024 check req_name and code
for OBSSDKRequestError class
2023-08-12 14:51:44 +01:00
f88e8ee3a6 Errors section in readme updated 2023-08-11 22:35:25 +01:00
6fa24fe609 error tests added 2023-08-11 22:33:56 +01:00
ffd215aadf send now raises an OBSSDKRequestError
it is then logged and rethrown
2023-08-11 22:33:41 +01:00
f3e75c0ddf OBSSDKError is now the base custom error class
OBSSDKTimeoutError and OBSSDKRequestError subclass it

req_name and error code set as error class attributes.
2023-08-11 22:32:50 +01:00
5db7a705c5 log and rethrow TimeoutError on connection
we can just encode challenge here.

shorten opcode != 2 message
2023-08-11 22:31:03 +01:00
ca72b92eb3 Merge pull request #30 from aatikturk/client_auth_loggers
auth logger for clients
2023-07-04 17:17:44 +01:00
98b17b6749 add .python-version to .gitignore 2023-06-30 22:44:50 +01:00
5462c47b65 log errors raised in authenticate() 2023-06-28 17:56:56 +01:00
126e5cb0a4 raise OBSSDKError if auth reponse opcode != 2 2023-06-28 17:56:29 +01:00
Adem
4ced7193df patch bump 2023-06-23 01:53:02 +03:00
Adem
468c63f697 auth logger for clients
added RpcVersion in auth loggers for both requests and events clients.
removed the check in baseclient auth function and returned the whole response.
2023-06-23 01:48:45 +03:00
Adem
24f8487d93 Merge pull request #29 from onyx-and-iris/dev
added module level loggers.
2023-06-23 00:26:30 +03:00
2c07f242ad added module level loggers.
class loggers implemented as child loggers.

patch bump
2023-06-22 22:17:20 +01:00
4e45de17ea Merge pull request #27 from aatikturk/25-question-set-timeout-for-connection-request
Added 'timeout' option for  baseclient
2023-06-19 18:25:12 +01:00
491a26aaf7 minor ver bump 2023-06-19 17:51:16 +01:00
d84d30b752 update readme Errors section 2023-06-19 17:46:43 +01:00
9e3c1d3f37 raise timeout errors.
added some error/exception logging.

added timeout parameter to repr methods.
2023-06-19 17:45:49 +01:00
82b6cdcd04 add error class OBSSDKTimeoutError 2023-06-19 17:44:10 +01:00
Adem
64a7c2b753 update readme and base client 2023-06-14 01:09:44 +03:00
Adem
15559fdb33 updated readme 2023-05-29 10:48:41 +00:00
Adem
3adf094481 Added 'timeout' option for baseclient. bumped version 2023-05-29 10:34:40 +00:00
Adem
9c41f2bb59 Merge pull request #24 from onyx-and-iris/dev
check user home directory for config.toml
2023-03-11 22:48:43 +03:00
d1c7462cc6 patch bump 2023-03-09 01:38:53 +00:00
2de7151739 update README
advises placing config.toml in user home dir
2023-03-09 01:36:21 +00:00
91ba90056c adds get_filepath
traverses a list of paths for config.toml
2023-03-09 01:34:44 +00:00
Adem
5e68262a80 fix sceneItemIndex key in the payload for set_scene_item_index method 2023-01-08 20:21:12 +03:00
Adem
ef0f770c0c Merge pull request #20 from onyx-and-iris/dev
add conn info to __repr__ methods, lower required python ver to 3.9 + other small changes
2022-12-05 22:17:03 +03:00
48e90c82fb alter format of __repr__ in Req + Event clients
password now defaults to empty string, not None.
2022-12-05 18:18:10 +00:00
cc9b1e2c72 lower min python required version to 3.9
python ver test matrix added to hatch config

minor version bump
2022-12-05 16:49:17 +00:00
41b0dfbe4b ensure studio mode is disabled at end of test run 2022-12-05 16:43:07 +00:00
cf888b0c4a conn paramters added to __repr__ magic methods
add __str__ override (used in logger)
2022-12-05 16:41:34 +00:00
92e2c29bd6 enum.py renamed to subs.py.
No changes to file contents.

relative import changed in __init__.py
2022-12-05 16:39:33 +00:00
335fa42948 Merge pull request #17 from kamalmostafa/no-tomllib
allow use without installing tomllib
2022-12-04 19:37:25 +00:00
83afe31e04 Update baseclient.py
lazy load tomli/tomllib as suggested in #17
2022-12-04 19:34:55 +00:00
5294e1afe2 Merge pull request #18 from kamalmostafa/send-raw
send(..., raw=True) returns raw responseData
2022-12-04 18:07:08 +00:00
c6cbe1c894 Merge pull request #19 from kamalmostafa/fix-SendStreamCaption
SendStreamCaption requires payload "captionText"
2022-12-04 18:06:42 +00:00
Kamal Mostafa
13ef8108df SendStreamCaption requires payload "captionText" 2022-11-30 06:47:15 +00:00
Kamal Mostafa
3786739eee send(..., raw=True) returns raw responseData
Passing raw=True returns unprocessed responseData payload, allowing
for application-level handling of websocket commands unimplemented
by the library or for testing.
2022-11-30 06:46:16 +00:00
Kamal Mostafa
71c1e65483 allow use without installing tomllib
When ObsClient(host='...', port='...', password='...') are provided,
importing tomllib is not actually necessary.  Allow for tomllib to
not be installed at all, and only raise a tomllib ModuleNotFoundError
if (host, port, password) are not provided.
2022-11-30 06:09:19 +00:00
21 changed files with 400 additions and 123 deletions

8
.gitignore vendored
View File

@@ -45,6 +45,14 @@ env.bak/
venv.bak/ venv.bak/
.hatch .hatch
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
.python-version
# Test/config # Test/config
quick.py quick.py
config.toml config.toml
obsws.log
.vscode/

View File

@@ -5,15 +5,14 @@
# A Python SDK for OBS Studio WebSocket v5.0 # A Python SDK for OBS Studio WebSocket v5.0
This is a wrapper around OBS Websocket.
Not all endpoints in the official documentation are implemented. Not all endpoints in the official documentation are implemented.
## Requirements ## Requirements
- [OBS Studio](https://obsproject.com/) - [OBS Studio](https://obsproject.com/)
- [OBS Websocket v5 Plugin](https://github.com/obsproject/obs-websocket/releases/tag/5.0.0) - [OBS Websocket v5 Plugin](https://github.com/obsproject/obs-websocket/releases/tag/5.0.0)
- With the release of OBS Studio version 28, Websocket plugin is included by default. But it should be manually installed for earlier versions of OBS. - With the release of OBS Studio version 28, Websocket plugin is included by default. But it should be manually installed for earlier versions of OBS.
- Python 3.10 or greater - Python 3.9 or greater
### How to install using pip ### How to install using pip
@@ -25,9 +24,10 @@ pip install obsws-python
By default the clients connect with parameters: By default the clients connect with parameters:
- `host`: "localhost" - `host`: "localhost"
- `port`: 4455 - `port`: 4455
- `password`: None - `password`: ""
- `timeout`: None
You may override these parameters by storing them in a toml config file or passing them as keyword arguments. You may override these parameters by storing them in a toml config file or passing them as keyword arguments.
@@ -44,7 +44,7 @@ port = 4455
password = "mystrongpass" password = "mystrongpass"
``` ```
It should be placed next to your `__main__.py` file. It should be placed in your user home directory.
#### Otherwise: #### Otherwise:
@@ -54,7 +54,7 @@ Example `__main__.py`:
import obsws_python as obs import obsws_python as obs
# pass conn info if not in config.toml # pass conn info if not in config.toml
cl = obs.ReqClient(host='localhost', port=4455, password='mystrongpass') cl = obs.ReqClient(host='localhost', port=4455, password='mystrongpass', timeout=3)
# Toggle the mute state of your Mic input # Toggle the mute state of your Mic input
cl.toggle_input_mute('Mic/Aux') cl.toggle_input_mute('Mic/Aux')
@@ -62,7 +62,7 @@ cl.toggle_input_mute('Mic/Aux')
### Requests ### Requests
Method names for requests match the API calls but snake cased. Method names for requests match the API calls but snake cased. If a successful call is made with the Request client and the response is expected to contain fields then a response object will be returned. You may then access the response fields as class attributes. They will be snake cased.
example: example:
@@ -70,13 +70,28 @@ example:
# load conn info from config.toml # load conn info from config.toml
cl = obs.ReqClient() cl = obs.ReqClient()
# GetVersion # GetVersion, returns a response object
resp = cl.get_version() resp = cl.get_version()
# Access it's field as an attribute
print(f"OBS Version: {resp.obs_version}")
# SetCurrentProgramScene # SetCurrentProgramScene
cl.set_current_program_scene("BRB") cl.set_current_program_scene("BRB")
``` ```
#### `send(param, data=None, raw=False)`
If you prefer to work with the JSON data directly the {ReqClient}.send() method accepts an argument, `raw`. If set to True the raw response data will be returned, instead of a response object.
example:
```python
resp = cl_req.send("GetVersion", raw=True)
print(f"response data: {resp}")
```
For a full list of requests refer to [Requests](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#requests) For a full list of requests refer to [Requests](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#requests)
### Events ### Events
@@ -128,9 +143,13 @@ def on_scene_created(data):
### Errors ### Errors
If a request fails an `OBSSDKError` will be raised with a status code. - `OBSSDKError`: Base error class.
- `OBSSDKTimeoutError`: Raised if a timeout occurs during sending/receiving a request or receiving an event
For a full list of status codes refer to [Codes](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#requeststatus) - `OBSSDKRequestError`: Raised when a request returns an error code.
- The following attributes are available:
- `req_name`: name of the request.
- `code`: request status code.
- For a full list of status codes refer to [Codes](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#requeststatus)
### Logging ### Logging
@@ -163,4 +182,4 @@ pytest -v
For the full documentation: For the full documentation:
- [OBS Websocket SDK](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#obs-websocket-501-protocol) - [OBS Websocket SDK](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#obs-websocket-501-protocol)

View File

@@ -6,7 +6,7 @@ Registers a list of callback functions to hook into OBS events.
Simply run the code and trigger the events, press `<Enter>` to exit. Simply run the code and trigger the events, press `<Enter>` to exit.
This example assumes the existence of a `config.toml`, placed next to `__main__.py`: This example assumes the existence of a `config.toml`, placed in your user home directory:
```toml ```toml
[connection] [connection]

View File

@@ -17,6 +17,12 @@ class Observer:
print(f"Registered events: {self._client.callback.get()}") print(f"Registered events: {self._client.callback.get()}")
self.running = True self.running = True
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, exc_traceback):
self._client.disconnect()
def on_current_program_scene_changed(self, data): def on_current_program_scene_changed(self, data):
"""The current program scene has changed.""" """The current program scene has changed."""
print(f"Switched to scene {data.scene_name}") print(f"Switched to scene {data.scene_name}")
@@ -31,13 +37,11 @@ class Observer:
def on_exit_started(self, _): def on_exit_started(self, _):
"""OBS has begun the shutdown process.""" """OBS has begun the shutdown process."""
print(f"OBS closing!") print("OBS closing!")
self._client.unsubscribe()
self.running = False self.running = False
if __name__ == "__main__": if __name__ == "__main__":
observer = Observer() with Observer() as observer:
while observer.running:
while observer.running: time.sleep(0.1)
time.sleep(0.1)

View File

@@ -8,7 +8,7 @@ Requires [Python Keyboard library](https://github.com/boppreh/keyboard).
Simply run the code and press the assigned hotkeys. Press `ctrl+enter` to exit. Simply run the code and press the assigned hotkeys. Press `ctrl+enter` to exit.
This example assumes the existence of a `config.toml`, placed next to `__main__.py`: This example assumes the existence of a `config.toml`, placed in your user home directory:
```toml ```toml
[connection] [connection]

View File

@@ -1,6 +1,7 @@
import inspect import inspect
import keyboard import keyboard
import obsws_python as obs import obsws_python as obs
@@ -10,6 +11,12 @@ class Observer:
self._client.callback.register(self.on_current_program_scene_changed) self._client.callback.register(self.on_current_program_scene_changed)
print(f"Registered events: {self._client.callback.get()}") print(f"Registered events: {self._client.callback.get()}")
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, exc_traceback):
self._client.disconnect()
@property @property
def event_identifier(self): def event_identifier(self):
return inspect.stack()[1].function return inspect.stack()[1].function
@@ -31,13 +38,12 @@ def set_scene(scene, *args):
if __name__ == "__main__": if __name__ == "__main__":
req_client = obs.ReqClient() with obs.ReqClient() as req_client:
observer = Observer() with Observer() as observer:
keyboard.add_hotkey("0", version)
keyboard.add_hotkey("1", set_scene, args=("START",))
keyboard.add_hotkey("2", set_scene, args=("BRB",))
keyboard.add_hotkey("3", set_scene, args=("END",))
keyboard.add_hotkey("0", version) print("press ctrl+enter to quit")
keyboard.add_hotkey("1", set_scene, args=("START",)) keyboard.wait("ctrl+enter")
keyboard.add_hotkey("2", set_scene, args=("BRB",))
keyboard.add_hotkey("3", set_scene, args=("END",))
print("press ctrl+enter to quit")
keyboard.wait("ctrl+enter")

View File

@@ -4,7 +4,7 @@ Prints POSTFADER level values for audio device `Desktop Audio`. If mute toggled
## Use ## Use
This example assumes the existence of a `config.toml`, placed next to `__main__.py`: This example assumes the existence of a `config.toml`, placed in your user home directory:
```toml ```toml
[connection] [connection]

View File

@@ -9,6 +9,8 @@ LEVELTYPE = IntEnum(
start=0, start=0,
) )
DEVICE = "Desktop Audio"
def on_input_mute_state_changed(data): def on_input_mute_state_changed(data):
"""An input's mute state has changed.""" """An input's mute state has changed."""
@@ -32,15 +34,14 @@ def on_input_volume_meters(data):
def main(): def main():
client = obs.EventClient(subs=(obs.Subs.LOW_VOLUME | obs.Subs.INPUTVOLUMEMETERS)) with obs.EventClient(
client.callback.register([on_input_volume_meters, on_input_mute_state_changed]) subs=(obs.Subs.LOW_VOLUME | obs.Subs.INPUTVOLUMEMETERS)
) as client:
client.callback.register([on_input_volume_meters, on_input_mute_state_changed])
while cmd := input("<Enter> to exit>\n"): while _ := input("Press <Enter> to exit\n"):
if not cmd: pass
break
if __name__ == "__main__": if __name__ == "__main__":
DEVICE = "Desktop Audio"
main() main()

View File

@@ -4,7 +4,7 @@ Collects the names of all available scenes, rotates through them and prints thei
## Use ## Use
This example assumes the existence of a `config.toml`, placed next to `__main__.py`: This example assumes the existence of a `config.toml`, placed in your user home directory:
```toml ```toml
[connection] [connection]

View File

@@ -1,6 +1,6 @@
from .enum import Subs
from .events import EventClient from .events import EventClient
from .reqs import ReqClient from .reqs import ReqClient
from .subs import Subs
from .version import version as __version__ from .version import version as __version__
__ALL__ = ["ReqClient", "EventClient", "Subs"] __ALL__ = ["ReqClient", "EventClient", "Subs"]

View File

@@ -4,22 +4,26 @@ import json
import logging import logging
from pathlib import Path from pathlib import Path
from random import randint from random import randint
from typing import Optional
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
import websocket import websocket
from websocket import WebSocketTimeoutException
from .error import OBSSDKError from .error import OBSSDKError, OBSSDKTimeoutError
logger = logging.getLogger(__name__)
class ObsClient: class ObsClient:
logger = logging.getLogger("baseclient.obsclient")
def __init__(self, **kwargs): def __init__(self, **kwargs):
defaultkwargs = {"host": "localhost", "port": 4455, "password": None, "subs": 0} self.logger = logger.getChild(self.__class__.__name__)
defaultkwargs = {
"host": "localhost",
"port": 4455,
"password": "",
"subs": 0,
"timeout": None,
}
if not any(key in kwargs for key in ("host", "port", "password")): if not any(key in kwargs for key in ("host", "port", "password")):
kwargs |= self._conn_from_toml() kwargs |= self._conn_from_toml()
kwargs = defaultkwargs | kwargs kwargs = defaultkwargs | kwargs
@@ -27,21 +31,47 @@ class ObsClient:
setattr(self, attr, val) setattr(self, attr, val)
self.logger.info( self.logger.info(
"Connecting with parameters: {host} {port} {password} {subs}".format( "Connecting with parameters: host='{host}' port={port} password='{password}' subs={subs} timeout={timeout}".format(
**self.__dict__ **self.__dict__
) )
) )
self.ws = websocket.WebSocket() try:
self.ws.connect(f"ws://{self.host}:{self.port}") self.ws = websocket.WebSocket()
self.server_hello = json.loads(self.ws.recv()) self.ws.connect(f"ws://{self.host}:{self.port}", timeout=self.timeout)
self.server_hello = json.loads(self.ws.recv())
except ValueError as e:
self.logger.error(f"{type(e).__name__}: {e}")
raise
except (ConnectionRefusedError, TimeoutError, WebSocketTimeoutException) as e:
self.logger.exception(f"{type(e).__name__}: {e}")
raise
def _conn_from_toml(self) -> dict: def _conn_from_toml(self) -> dict:
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
def get_filepath() -> Optional[Path]:
"""
traverses a list of paths for a 'config.toml'
returns the first config file found or None.
"""
filepaths = [
Path.cwd() / "config.toml",
Path.home() / "config.toml",
Path.home() / ".config" / "obsws-python" / "config.toml",
]
for filepath in filepaths:
if filepath.exists():
return filepath
conn = {} conn = {}
filepath = Path.cwd() / "config.toml" if filepath := get_filepath():
if filepath.exists():
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
conn = tomllib.load(f) conn = tomllib.load(f)
self.logger.info(f"loading config from {filepath}")
return conn["connection"] if "connection" in conn else conn return conn["connection"] if "connection" in conn else conn
def authenticate(self): def authenticate(self):
@@ -67,9 +97,9 @@ class ObsClient:
auth = base64.b64encode( auth = base64.b64encode(
hashlib.sha256( hashlib.sha256(
( (
secret.decode() secret
+ self.server_hello["d"]["authentication"]["challenge"] + self.server_hello["d"]["authentication"]["challenge"].encode()
).encode() )
).digest() ).digest()
).decode() ).decode()
@@ -78,9 +108,15 @@ class ObsClient:
self.ws.send(json.dumps(payload)) self.ws.send(json.dumps(payload))
try: try:
response = json.loads(self.ws.recv()) response = json.loads(self.ws.recv())
return response["op"] == 2 if response["op"] != 2:
raise OBSSDKError(
"failed to identify client with the server, expected response with OpCode 2"
)
return response["d"]
except json.decoder.JSONDecodeError: except json.decoder.JSONDecodeError:
raise OBSSDKError("failed to identify client with the server") raise OBSSDKError(
"failed to identify client with the server, please check connection settings"
)
def req(self, req_type, req_data=None): def req(self, req_type, req_data=None):
payload = { payload = {
@@ -90,7 +126,11 @@ class ObsClient:
if req_data: if req_data:
payload["d"]["requestData"] = req_data payload["d"]["requestData"] = req_data
self.logger.debug(f"Sending request {payload}") self.logger.debug(f"Sending request {payload}")
self.ws.send(json.dumps(payload)) try:
response = json.loads(self.ws.recv()) self.ws.send(json.dumps(payload))
response = json.loads(self.ws.recv())
except WebSocketTimeoutException as e:
self.logger.exception(f"{type(e).__name__}: {e}")
raise OBSSDKTimeoutError("Timeout while trying to send the request") from e
self.logger.debug(f"Response received {response}") self.logger.debug(f"Response received {response}")
return response["d"] return response["d"]

View File

@@ -1,4 +1,18 @@
class OBSSDKError(Exception): class OBSSDKError(Exception):
"""general errors""" """Base class for OBSSDK errors"""
pass
class OBSSDKTimeoutError(OBSSDKError):
"""Exception raised when a connection times out"""
class OBSSDKRequestError(OBSSDKError):
"""Exception raised when a request returns an error code"""
def __init__(self, req_name, code, comment):
self.req_name = req_name
self.code = code
message = f"Request {self.req_name} returned code {self.code}."
if comment:
message += f" With message: {comment}"
super().__init__(message)

View File

@@ -1,11 +1,13 @@
import json import json
import logging import logging
import time import threading
from threading import Thread
from websocket import WebSocketConnectionClosedException, WebSocketTimeoutException
from .baseclient import ObsClient from .baseclient import ObsClient
from .callback import Callback from .callback import Callback
from .enum import Subs from .error import OBSSDKError, OBSSDKTimeoutError
from .subs import Subs
""" """
A class to interact with obs-websocket events A class to interact with obs-websocket events
@@ -13,47 +15,77 @@ defined in official github repo
https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#events https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#events
""" """
logger = logging.getLogger(__name__)
class EventClient: class EventClient:
logger = logging.getLogger("events.eventclient")
DELAY = 0.001
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.logger = logger.getChild(self.__class__.__name__)
defaultkwargs = {"subs": Subs.LOW_VOLUME} defaultkwargs = {"subs": Subs.LOW_VOLUME}
kwargs = defaultkwargs | kwargs kwargs = defaultkwargs | kwargs
self.base_client = ObsClient(**kwargs) self.base_client = ObsClient(**kwargs)
if self.base_client.authenticate(): try:
self.logger.info(f"Successfully identified {self} with the server") success = self.base_client.authenticate()
self.logger.info(
f"Successfully identified {self} with the server using RPC version:{success['negotiatedRpcVersion']}"
)
except OBSSDKError as e:
self.logger.error(f"{type(e).__name__}: {e}")
raise
self.callback = Callback() self.callback = Callback()
self.subscribe() self.subscribe()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, exc_traceback):
self.disconnect()
def __repr__(self): def __repr__(self):
return type(
self
).__name__ + "(host='{host}', port={port}, password='{password}', subs={subs}, timeout={timeout})".format(
**self.base_client.__dict__,
)
def __str__(self):
return type(self).__name__ return type(self).__name__
def subscribe(self): def subscribe(self):
worker = Thread(target=self.trigger, daemon=True) self.base_client.ws.settimeout(None)
worker.start() stop_event = threading.Event()
self.worker = threading.Thread(
target=self.trigger, daemon=True, args=(stop_event,)
)
self.worker.start()
def trigger(self): def trigger(self, stop_event):
""" """
Continuously listen for events. Continuously listen for events.
Triggers a callback on event received. Triggers a callback on event received.
""" """
self.running = True while not stop_event.is_set():
while self.running: try:
event = json.loads(self.base_client.ws.recv()) if response := self.base_client.ws.recv():
self.logger.debug(f"Event received {event}") event = json.loads(response)
type_, data = ( self.logger.debug(f"Event received {event}")
event["d"].get("eventType"), type_, data = (
event["d"].get("eventData"), event["d"].get("eventType"),
) event["d"].get("eventData"),
self.callback.trigger(type_, data if data else {}) )
time.sleep(self.DELAY) self.callback.trigger(type_, data if data else {})
except WebSocketTimeoutException as e:
self.logger.exception(f"{type(e).__name__}: {e}")
raise OBSSDKTimeoutError("Timeout while waiting for event") from e
except (WebSocketConnectionClosedException, OSError) as e:
self.logger.debug(f"{type(e).__name__} terminating the event thread")
stop_event.set()
def disconnect(self):
"""stop listening for events"""
def unsubscribe(self):
"""
stop listening for events
"""
self.running = False
self.base_client.ws.close() self.base_client.ws.close()
self.worker.join()
unsubscribe = disconnect

View File

@@ -1,7 +1,8 @@
import logging import logging
from warnings import warn
from .baseclient import ObsClient from .baseclient import ObsClient
from .error import OBSSDKError from .error import OBSSDKError, OBSSDKRequestError
from .util import as_dataclass from .util import as_dataclass
""" """
@@ -10,34 +11,56 @@ defined in official github repo
https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#Requests https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#Requests
""" """
logger = logging.getLogger(__name__)
class ReqClient: class ReqClient:
logger = logging.getLogger("reqs.reqclient")
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.logger = logger.getChild(self.__class__.__name__)
self.base_client = ObsClient(**kwargs) self.base_client = ObsClient(**kwargs)
if self.base_client.authenticate(): try:
self.logger.info(f"Successfully identified {self} with the server") success = self.base_client.authenticate()
self.logger.info(
f"Successfully identified {self} with the server using RPC version:{success['negotiatedRpcVersion']}"
)
except OBSSDKError as e:
self.logger.error(f"{type(e).__name__}: {e}")
raise
def __enter__(self): def __enter__(self):
return self return self
def __exit__(self, exc_type, exc_value, exc_traceback): def __exit__(self, exc_type, exc_value, exc_traceback):
self.base_client.ws.close() self.disconnect()
def __repr__(self): def __repr__(self):
return type(
self
).__name__ + "(host='{host}', port={port}, password='{password}', timeout={timeout})".format(
**self.base_client.__dict__,
)
def __str__(self):
return type(self).__name__ return type(self).__name__
def send(self, param, data=None): def disconnect(self):
response = self.base_client.req(param, data) self.base_client.ws.close()
if not response["requestStatus"]["result"]:
error = ( def send(self, param, data=None, raw=False):
f"Request {response['requestType']} returned code {response['requestStatus']['code']}", try:
) response = self.base_client.req(param, data)
if "comment" in response["requestStatus"]: if not response["requestStatus"]["result"]:
error += (f"With message: {response['requestStatus']['comment']}",) raise OBSSDKRequestError(
raise OBSSDKError("\n".join(error)) response["requestType"],
response["requestStatus"]["code"],
response["requestStatus"].get("comment"),
)
except OBSSDKRequestError as e:
self.logger.exception(f"{type(e).__name__}: {e}")
raise
if "responseData" in response: if "responseData" in response:
if raw:
return response["responseData"]
return as_dataclass(response["requestType"], response["responseData"]) return as_dataclass(response["requestType"], response["responseData"])
def get_version(self): def get_version(self):
@@ -413,6 +436,19 @@ class ReqClient:
""" """
return self.send("GetRecordDirectory") return self.send("GetRecordDirectory")
def set_record_directory(self, recordDirectory):
"""
Sets the current directory that the record output writes files to.
IMPORTANT NOTE: Requires obs websocket v5.3 or higher.
:param recordDirectory: Output directory
:type recordDirectory: str
"""
payload = {
"recordDirectory": recordDirectory,
}
return self.send("SetRecordDirectory", payload)
def get_source_active(self, name): def get_source_active(self, name):
""" """
Gets the active and show state of a source Gets the active and show state of a source
@@ -1485,7 +1521,7 @@ class ReqClient:
payload = { payload = {
"sceneName": scene_name, "sceneName": scene_name,
"sceneItemId": item_id, "sceneItemId": item_id,
"sceneItemLocked": item_index, "sceneItemIndex": item_index,
} }
self.send("SetSceneItemIndex", payload) self.send("SetSceneItemIndex", payload)
@@ -1729,7 +1765,10 @@ class ReqClient:
""" """
self.send("SendStreamCaption") payload = {
"captionText": caption,
}
self.send("SendStreamCaption", payload)
def get_record_status(self): def get_record_status(self):
""" """
@@ -1916,3 +1955,66 @@ class ReqClient:
""" """
return self.send("GetMonitorList") return self.send("GetMonitorList")
def open_video_mix_projector(
self, video_mix_type, monitor_index=-1, projector_geometry=None
):
"""
Opens a projector for a specific output video mix.
The available mix types are:
OBS_WEBSOCKET_VIDEO_MIX_TYPE_PREVIEW
OBS_WEBSOCKET_VIDEO_MIX_TYPE_PROGRAM
OBS_WEBSOCKET_VIDEO_MIX_TYPE_MULTIVIEW
:param video_mix_type: Type of mix to open.
:type video_mix_type: str
:param monitor_index: Monitor index, use GetMonitorList to obtain index
:type monitor_index: int
:param projector_geometry:
Size/Position data for a windowed projector, in Qt Base64 encoded format.
Mutually exclusive with monitorIndex
:type projector_geometry: str
"""
warn(
"open_video_mix_projector request serves to provide feature parity with 4.x. "
"It is very likely to be changed/deprecated in a future release.",
DeprecationWarning,
stacklevel=2,
)
payload = {
"videoMixType": video_mix_type,
"monitorIndex": monitor_index,
"projectorGeometry": projector_geometry,
}
self.send("OpenVideoMixProjector", payload)
def open_source_projector(
self, source_name, monitor_index=-1, projector_geometry=None
):
"""
Opens a projector for a source.
:param source_name: Name of the source to open a projector for
:type source_name: str
:param monitor_index: Monitor index, use GetMonitorList to obtain index
:type monitor_index: int
:param projector_geometry:
Size/Position data for a windowed projector, in Qt Base64 encoded format.
Mutually exclusive with monitorIndex
:type projector_geometry: str
"""
warn(
"open_source_projector request serves to provide feature parity with 4.x. "
"It is very likely to be changed/deprecated in a future release.",
DeprecationWarning,
stacklevel=2,
)
payload = {
"sourceName": source_name,
"monitorIndex": monitor_index,
"projectorGeometry": projector_geometry,
}
self.send("OpenSourceProjector", payload)

View File

@@ -1 +1 @@
version = "1.3.0" version = "1.7.0"

View File

@@ -8,7 +8,7 @@ dynamic = ["version"]
description = "A Python SDK for OBS Studio WebSocket v5.0" description = "A Python SDK for OBS Studio WebSocket v5.0"
readme = "README.md" readme = "README.md"
license = "GPL-3.0-only" license = "GPL-3.0-only"
requires-python = ">=3.10" requires-python = ">=3.9"
authors = [ authors = [
{ name = "Adem Atikturk", email = "aatikturk@gmail.com" }, { name = "Adem Atikturk", email = "aatikturk@gmail.com" },
] ]
@@ -37,7 +37,18 @@ include = [
] ]
[tool.hatch.envs.e.scripts] [tool.hatch.envs.e.scripts]
events = "py {root}\\examples\\events\\." events = "python {root}\\examples\\events\\."
hotkeys = "py {root}\\examples\\hotkeys\\." hotkeys = "python {root}\\examples\\hotkeys\\."
levels = "py {root}\\examples\\levels\\." levels = "python {root}\\examples\\levels\\."
scene_rotate = "py {root}\\examples\\scene_rotate\\." scene_rotate = "python {root}\\examples\\scene_rotate\\."
[tool.hatch.envs.test]
dependencies = [
"pytest",
]
[tool.hatch.envs.test.scripts]
run = 'pytest -v'
[[tool.hatch.envs.test.matrix]]
python = ["39", "310", "311"]

View File

@@ -40,7 +40,7 @@ EXTRAS_REQUIRE = {
} }
# Python version requirement # Python version requirement
PYTHON_REQUIRES = ">=3.10" PYTHON_REQUIRES = ">=3.9"
setup( setup(
name=PACKAGE_NAME, name=PACKAGE_NAME,

View File

@@ -13,4 +13,7 @@ def teardown_module():
req_cl.remove_scene("START_TEST") req_cl.remove_scene("START_TEST")
req_cl.remove_scene("BRB_TEST") req_cl.remove_scene("BRB_TEST")
req_cl.remove_scene("END_TEST") req_cl.remove_scene("END_TEST")
resp = req_cl.get_studio_mode_enabled()
if resp.studio_mode_enabled:
req_cl.set_studio_mode_enabled(False)
req_cl.base_client.ws.close() req_cl.base_client.ws.close()

35
tests/test_error.py Normal file
View File

@@ -0,0 +1,35 @@
import pytest
import obsws_python as obsws
from tests import req_cl
class TestErrors:
__test__ = True
def test_it_raises_an_obssdk_error_on_incorrect_password(self):
bad_conn = {"host": "localhost", "port": 4455, "password": "incorrectpassword"}
with pytest.raises(
obsws.error.OBSSDKError,
match="failed to identify client with the server, please check connection settings",
):
obsws.ReqClient(**bad_conn)
def test_it_raises_an_obssdk_error_if_auth_enabled_but_no_password_provided(self):
bad_conn = {"host": "localhost", "port": 4455, "password": ""}
with pytest.raises(
obsws.error.OBSSDKError,
match="authentication enabled but no password provided",
):
obsws.ReqClient(**bad_conn)
def test_it_raises_a_request_error_on_invalid_request(self):
with pytest.raises(
obsws.error.OBSSDKRequestError,
match="Request SetCurrentProgramScene returned code 600. With message: No source was found by the name of `invalid`.",
) as exc_info:
req_cl.set_current_program_scene("invalid")
e = exc_info.value
assert e.req_name == "SetCurrentProgramScene"
assert e.code == 600

View File

@@ -15,9 +15,9 @@ class TestRequests:
resp = req_cl.get_hot_key_list() resp = req_cl.get_hot_key_list()
obsbasic_hotkey_list = [ obsbasic_hotkey_list = [
"OBSBasic.SelectScene", "OBSBasic.SelectScene",
"OBSBasic.SelectScene", "OBSBasic.QuickTransition.1",
"OBSBasic.SelectScene", "OBSBasic.QuickTransition.2",
"OBSBasic.SelectScene", "OBSBasic.QuickTransition.3",
"OBSBasic.StartStreaming", "OBSBasic.StartStreaming",
"OBSBasic.StopStreaming", "OBSBasic.StopStreaming",
"OBSBasic.ForceStopStreaming", "OBSBasic.ForceStopStreaming",
@@ -25,15 +25,17 @@ class TestRequests:
"OBSBasic.StopRecording", "OBSBasic.StopRecording",
"OBSBasic.PauseRecording", "OBSBasic.PauseRecording",
"OBSBasic.UnpauseRecording", "OBSBasic.UnpauseRecording",
"OBSBasic.SplitFile",
"OBSBasic.StartReplayBuffer", "OBSBasic.StartReplayBuffer",
"OBSBasic.StopReplayBuffer", "OBSBasic.StopReplayBuffer",
"OBSBasic.StartVirtualCam", "OBSBasic.StartVirtualCam",
"OBSBasic.StopVirtualCam", "OBSBasic.StopVirtualCam",
"OBSBasic.EnablePreview", "OBSBasic.EnablePreview",
"OBSBasic.DisablePreview", "OBSBasic.DisablePreview",
"OBSBasic.EnablePreviewProgram",
"OBSBasic.DisablePreviewProgram",
"OBSBasic.ShowContextBar", "OBSBasic.ShowContextBar",
"OBSBasic.HideContextBar", "OBSBasic.HideContextBar",
"OBSBasic.TogglePreviewProgram",
"OBSBasic.Transition", "OBSBasic.Transition",
"OBSBasic.ResetStats", "OBSBasic.ResetStats",
"OBSBasic.Screenshot", "OBSBasic.Screenshot",