diff --git a/.gitignore b/.gitignore index b6e4761..f2a1fb6 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# toml config +config.toml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f4a5af --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) + +# A Python client for Streamlabs SocketIO API + +### Requirements + +- A Streamlabs SocketIO API key. + - You can acquire this by logging into your Streamlabs.com dashboard then `Settings->Api Settings->API Tokens` + +### How to install using pip + +``` +pip install streamlabsio +``` + +### How to Use + +You may store your api key in a `config.toml` file, its contents should resemble: + +```toml +[streamlabs] +token = "" +``` + +Place it next to your `__main__.py` file. + +#### Otherwise: + +You may pass it as a keyword argument. + +Example `__main__.py`: + +```python +from threading import Thread + +import streamlabsio + + +def on_twitch_event(event, msg): + print(f"{event}: {msg.attrs()}") + + +def register_callbacks(client): + client.obs.on("streamlabs", on_twitch_event) + client.obs.on("twitch_account", on_twitch_event) + + +def main(): + with streamlabsio.connect(token="") as client: + worker = Thread(target=register_callbacks, args=(client,), daemon=True) + worker.start() + + while cmd := input(" to exit\n"): + if not cmd: + break + + +if __name__ == "__main__": + main() +``` + +### Attributes + +For event messages you may inspect the available attributes using `attrs()`. + +example: + +```python +def on_twitch_event(event, msg): + print(f"{event}: {msg.attrs()}") +``` + +### Official Documentation + +- [Streamlabs SocketIO API](https://dev.streamlabs.com/docs/socket-api) diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..d181bb6 --- /dev/null +++ b/__main__.py @@ -0,0 +1,37 @@ +import logging +from threading import Thread + +import streamlabsio + +logging.basicConfig(level=logging.INFO) + + +def on_youtube_event(event, msg): + print(f"{event}: {msg.attrs()}") + + +def on_twitch_event(event, msg): + if event == "follow": + print(f"Received follow from {msg.name}") + elif event == "bits": + print(f"{msg.name} donated {msg.amount} bits! With message: {msg.message}") + + +def register_callbacks(client): + client.obs.on("streamlabs", on_twitch_event) + client.obs.on("twitch_account", on_twitch_event) + client.obs.on("youtube_account", on_youtube_event) + + +def main(): + with streamlabsio.connect() as client: + worker = Thread(target=register_callbacks, args=(client,), daemon=True) + worker.start() + + while cmd := input(" to exit\n"): + if not cmd: + break + + +if __name__ == "__main__": + main() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..2ec01f1 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,234 @@ +[[package]] +name = "black" +version = "22.10.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2022.9.24" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "charset-normalizer" +version = "2.1.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "isort" +version = "5.10.1" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6.1,<4.0" + +[package.extras] +pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] +plugins = ["setuptools"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "observable" +version = "1.0.3" +description = "minimalist event system" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pathspec" +version = "0.10.2" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "platformdirs" +version = "2.5.4" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.19.4)", "sphinx (>=5.3)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest (>=7.2)"] + +[[package]] +name = "python-engineio" +version = "3.14.2" +description = "Engine.IO server" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +asyncio_client = ["aiohttp (>=3.4)"] +client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] + +[[package]] +name = "python-socketio" +version = "4.6.0" +description = "Socket.IO server" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +python-engineio = ">=3.13.0" +requests = {version = ">=2.21.0", optional = true, markers = "extra == \"client\""} +six = ">=1.9.0" +websocket-client = {version = ">=0.54.0", optional = true, markers = "extra == \"client\""} + +[package.extras] +asyncio_client = ["aiohttp (>=3.4)", "websockets (>=7.0)"] +client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] + +[[package]] +name = "requests" +version = "2.28.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "urllib3" +version = "1.26.12" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "websocket-client" +version = "1.4.2" +description = "WebSocket client for Python with low level API options" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["Sphinx (>=3.4)", "sphinx-rtd-theme (>=0.5)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.10" +content-hash = "fd2bc058c27869f25132aa1baf2599c482e6f926ee75743cfe7ac1fa1042d4bd" + +[metadata.files] +black = [] +certifi = [] +charset-normalizer = [] +click = [] +colorama = [] +idna = [] +isort = [] +mypy-extensions = [] +observable = [] +pathspec = [] +platformdirs = [] +python-engineio = [] +python-socketio = [] +requests = [] +six = [] +tomli = [] +urllib3 = [] +websocket-client = [] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..805e5d0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[tool.poetry] +name = "streamlabsio" +version = "0.1.0" +description = "Get real time Twitch/Youtube events through Streamlabs SocketIO API" +authors = ["onyx-and-iris "] +license = "MIT" + +[tool.poetry.dependencies] +python = "^3.10" +tomli = { version = "^2.0.1", python = "<3.11" } +python-engineio = "3.14.2" +python-socketio = {version = "4.6.0", extras = ["client"]} +observable = "^1.0.3" + +[tool.poetry.dev-dependencies] +black = "^22.10.0" +isort = "^5.10.1" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/streamlabsio/__init__.py b/streamlabsio/__init__.py new file mode 100644 index 0000000..399cc23 --- /dev/null +++ b/streamlabsio/__init__.py @@ -0,0 +1 @@ +from .client import connect diff --git a/streamlabsio/client.py b/streamlabsio/client.py new file mode 100644 index 0000000..f5649eb --- /dev/null +++ b/streamlabsio/client.py @@ -0,0 +1,59 @@ +import logging +import pprint +from pathlib import Path + +import socketio +from observable import Observable + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + +from .models import as_dataclass + +pp = pprint.PrettyPrinter(indent=4) + + +class Client: + logger = logging.getLogger("socketio.socketio") + + def __init__(self, token=None): + self.token = token or self._token_from_toml() + self.sio = socketio.Client() + self.sio.on("connect", self.connect_handler) + self.sio.on("event", self.event_handler) + self.sio.on("disconnect", self.disconnect_handler) + self.obs = Observable() + + def __enter__(self): + self.sio.connect(f"https://sockets.streamlabs.com?token={self.token}") + return self + + def _token_from_toml(self) -> str: + filepath = Path.cwd() / "config.toml" + with open(filepath, "rb") as f: + conn = tomllib.load(f) + assert "token" in conn.get("streamlabs") + return conn["streamlabs"].get("token") + + def connect_handler(self): + self.logger.info("Connected to Twitch Socketio") + + def event_handler(self, data): + self.obs.trigger( + data.get("for"), + data["type"], + as_dataclass(data["type"], *data["message"]), + ) + + def disconnect_handler(self): + self.logger.info("Disconnected from Twitch Socketio") + + def __exit__(self, exc_type, exc_val, exc_tb): + self.sio.disconnect() + + +def connect(**kwargs): + SIO_cls = Client + return SIO_cls(**kwargs) diff --git a/streamlabsio/models.py b/streamlabsio/models.py new file mode 100644 index 0000000..fb0fb0c --- /dev/null +++ b/streamlabsio/models.py @@ -0,0 +1,22 @@ +import re +from dataclasses import dataclass + + +def to_snake_case(s): + return re.sub(r"(?