├── .github └── workflows │ └── main.yml ├── .gitignore ├── Dockerfile ├── README.md ├── config.ini ├── data └── irltk_icon.ico ├── docker-compose.yml ├── entrypoint.sh ├── main.py └── requirements.txt /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: 'Build Portable Binaries' 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | windows-build: 10 | name: 'Windows Latest' 11 | runs-on: windows-latest 12 | if: contains(github.event.head_commit.message, '[skip ci]') != true 13 | steps: 14 | - name: Checkout Repository 15 | uses: actions/checkout@v2 16 | 17 | - name: Install Python 3.9 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: 3.9 21 | 22 | - name: Install Python Prerequisites 23 | run: pip install -r requirements.txt pyinstaller 24 | 25 | - name: Run PyInstaller 26 | run: pyinstaller main.py --onefile --name obs-websocket-http --icon data/irltk_icon.ico 27 | 28 | - name: Upload Artifacts 29 | uses: actions/upload-artifact@v2 30 | with: 31 | name: obs-websocket-http-Windows 32 | path: dist/obs-websocket-http* 33 | linux-build: 34 | name: 'Ubuntu Latest' 35 | runs-on: ubuntu-latest 36 | if: contains(github.event.head_commit.message, '[skip ci]') != true 37 | steps: 38 | - name: Checkout Repository 39 | uses: actions/checkout@v2 40 | 41 | - name: Install Python 3.9 42 | uses: actions/setup-python@v2 43 | with: 44 | python-version: 3.9 45 | 46 | - name: Install Python Prerequisites 47 | run: pip install -r requirements.txt pyinstaller 48 | 49 | - name: Run PyInstaller 50 | run: pyinstaller main.py --onefile --name obs-websocket-http --icon data/irltk_icon.ico 51 | 52 | - name: Upload Artifacts 53 | uses: actions/upload-artifact@v2 54 | with: 55 | name: obs-websocket-http-Ubuntu 56 | path: dist/obs-websocket-http* 57 | macos-build: 58 | name: 'macOS Latest' 59 | runs-on: macos-latest 60 | if: contains(github.event.head_commit.message, '[skip ci]') != true 61 | steps: 62 | - name: Checkout Repository 63 | uses: actions/checkout@v2 64 | 65 | - name: Install Python 3.9 66 | uses: actions/setup-python@v2 67 | with: 68 | python-version: 3.9 69 | 70 | - name: Install Python Prerequisites 71 | run: pip install -r requirements.txt pyinstaller 72 | 73 | - name: Run PyInstaller 74 | run: pyinstaller main.py --onefile --name obs-websocket-http --icon data/irltk_icon.ico 75 | 76 | - name: Upload Artifacts 77 | uses: actions/upload-artifact@v2 78 | with: 79 | name: obs-websocket-http-MacOS 80 | path: dist/obs-websocket-http* 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | 154 | dist/ 155 | build/ 156 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | From python:3 2 | COPY entrypoint.sh requirements.txt ./ 3 | RUN pip install -r requirements.txt 4 | COPY main.py ./ 5 | ENTRYPOINT [ "sh", "./entrypoint.sh" ] 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # obs-websocket-http 2 | A Python-based program that provides HTTP endpoints for obs-websocket 3 | 4 | ## Please Note 5 | This branch is **only** for versions of obs-websocket that are 5.0.0 or higher. If you are using obs-websocket pre-5.0.0, use the `old-4.x` branch. 6 | 7 | Click [here](https://github.com/IRLToolkit/obs-websocket-http/tree/old-4.x) to go to the pre-5.0.0 branch. 8 | 9 | ## Installing on Ubuntu: 10 | - Clone/download the repository 11 | - Edit `config.ini` and set the address, port, and authentication details for the HTTP server (leave `authentication_key` empty for no auth). Set your obs-websocket connection settings in the `[obsws]` section. 12 | - `sudo apt update && sudo apt install python3.8 python3-pip` 13 | - `python3.8 -m pip install -r requirements.txt` 14 | - CD into the `obs-websocket-http` directory 15 | - Run with `python3.8 main.py` 16 | 17 | Use `python3.8 main.py --help` to see command line options, which allow you to run this script without a config.ini. 18 | 19 | ## Running with Docker 20 | - Clone/download the repository 21 | - Edit `docker-compose.yml` to have the correct IPs and ports for this machine and the one running OBS Studio (it may be the same machine). You do NOT need to edit `config.ini` if using docker because it will be created by the container from the values in `docker-compose.yml`. 22 | - Start obs-websocket-http by running `docker-compose up -d && docker-compose logs -f`. This will give you log output and you can press `Ctrl-C` when you wish to return to terminal and the container will run in the background. 23 | 24 | ## Protocol: 25 | The web server contains these endpoints: 26 | - `/emit/{requestType}` sends off a websocket event without waiting for a response, and immediately returns a generic `{"result": true}` JSON response without a request result. 27 | - `/call/{requestType}` Makes a full request to obs-websocket, and waits for a response. The recieved response is then returned to the HTTP caller. 28 | - Example JSON response: `{"result": true, "requestResult": {"requestType": "GetCurrentProgramScene", "requestStatus": {"result": true, "code": 100}, "responseData": {"currentProgramSceneName": "Scene 5"}}}` 29 | 30 | If authentication is set, then each request much contain an `Authorization` header with the configured auth key as the value. 31 | 32 | A request type is always required, however the request body is optional, and is forwarded as the request data for the obs-websocket request. If your obs-websocket request does not require request data, then no body is needed. 33 | 34 | For a list of request types, refer to the [obs-websocket protocol docs](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#requests) 35 | 36 | ## Example cURL commands: 37 | - `curl -XPOST -H 'Authorization: pp123' -H "Content-type: application/json" -d '{"sceneName": "Scene 5"}' 'http://127.0.0.1:4456/emit/SetCurrentProgramScene'` 38 | - `curl -XPOST -H "Content-type: application/json" 'http://127.0.0.1:4456/call/GetCurrentProgramScene'` 39 | ` 40 | ## CORS Errors/Setup 41 | 42 | If you receive errors about CORS and not having `Access-Control-Allow-Origin` header for the endpoint, be sure to modify `config.ini` or `docker-compose.yml` to properly set `cors_domains`. 43 | 44 | > A domain of `*` will allow *ALL* domains privileges to make successful requests, so use it at your own risk. Either configure appropriately, or use an Auth key for the HTTP configuration. 45 | 46 | ## IRLTookit Links 47 | 48 | - Please go to the [obs-websocket Discord](https://discord.gg/WBaSQ3A) for support. 49 | - https://twitter.com/IRLToolkit 50 | - https://irltoolkit.com 51 | -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [http] 2 | bind_to_address = 0.0.0.0 3 | bind_to_port = 4456 4 | cors_domains = * 5 | #Leave empty if no authentication is required. 6 | authentication_key = 7 | 8 | [obsws] 9 | #obs-websocket v5 uses port 4455 by default 10 | ws_url = ws://127.0.0.1:4455 11 | #Only necessary if "Enable authentication" is checked in the obs-websocket settings menu. 12 | ws_password = 13 | -------------------------------------------------------------------------------- /data/irltk_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IRLToolkit/obs-websocket-http/52417c9eea1f1bcf5a328bf5075b170f82bfa9d6/data/irltk_icon.ico -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | 3 | services: 4 | obswebsocket: 5 | build: . 6 | restart: unless-stopped 7 | environment: 8 | # Address of this machine 9 | - API_ADDRESS=0.0.0.0 10 | # Port you wish to use for API 11 | - API_PORT=4456 12 | # Auth key you wish to set 13 | - API_KEY= 14 | # WebSocket Connect URL 15 | - OBS_URL=ws://host.docker.internal:4455 16 | # OBS password, if used 17 | - OBS_PASSWORD= 18 | # CORS Domains to accept requests from (comma separated, no spaces. *=all domains) 19 | - CORS_DOMAINS=* 20 | ports: 21 | # Set to same value as API_PORT 22 | - '4456:4456' 23 | extra_hosts: 24 | # Allows routing from docker container to Host OS (see: https://stackoverflow.com/a/43541681) 25 | - 'host.docker.internal:host-gateway' 26 | 27 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Create config.ini 4 | cat << EOF > ./config.ini 5 | [http] 6 | bind_to_address = @api_address@ 7 | bind_to_port = @api_port@ 8 | cors_domains = @cors_domains@ 9 | authentication_key = @api_key@ 10 | 11 | [obsws] 12 | ws_url = @obs_url@ 13 | ws_password = @obs_password@ 14 | EOF 15 | sed -i "s|@api_address@|${API_ADDRESS}|" ./config.ini 16 | sed -i "s|@api_port@|${API_PORT}|" ./config.ini 17 | sed -i "s|@api_key@|${API_KEY}|" ./config.ini 18 | sed -i "s|@cors_domains@|${CORS_DOMAINS}|" ./config.ini 19 | 20 | sed -i "s|@obs_url@|${OBS_URL}|" ./config.ini 21 | sed -i "s|@obs_password@|${OBS_PASSWORD}|" ./config.ini 22 | 23 | # Start the server 24 | python3 ./main.py 25 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logging.basicConfig(level=logging.INFO) 3 | import os 4 | import argparse 5 | import asyncio 6 | import json 7 | import simpleobsws 8 | import aiohttp 9 | from aiohttp import web 10 | import aiohttp_cors 11 | from configparser import ConfigParser 12 | 13 | app = web.Application() 14 | ws = None 15 | 16 | # Make aiohttp shut up 17 | aiohttpLogger = logging.getLogger('aiohttp') 18 | aiohttpLogger.setLevel(logging.WARNING) 19 | 20 | def fail_response(comment): 21 | return web.json_response({'result': False, 'comment': comment}) 22 | 23 | def validate_request(request): 24 | if not httpAuthKey: 25 | return True, None 26 | if 'Authorization' not in request.headers: 27 | return False, 'You are missing the `Authorization` header.' 28 | if request.headers['Authorization'] != httpAuthKey: 29 | return False, 'Invalid authorization key.' 30 | return True, None 31 | 32 | async def get_json(request): 33 | try: 34 | return await request.json() 35 | except json.decoder.JSONDecodeError: 36 | return None 37 | 38 | def response_to_object(response: simpleobsws.RequestResponse): 39 | ret = {} 40 | ret['requestType'] = response.requestType 41 | ret['requestStatus'] = {'result': response.requestStatus.result, 'code': response.requestStatus.code} 42 | if response.requestStatus.comment: 43 | ret['requestStatus']['comment'] = response.requestStatus.comment 44 | if response.responseData: 45 | ret['responseData'] = response.responseData 46 | return ret 47 | 48 | async def request_callback(request, emit): 49 | if not ws or not ws.is_identified(): 50 | return fail_response('obs-websocket is not connected.') 51 | authOk, comment = validate_request(request) 52 | if not authOk: 53 | return fail_response(comment) 54 | 55 | requestType = request.match_info.get('requestType') 56 | if not requestType: 57 | return fail_response('Your path is missing a request type.') 58 | requestData = await get_json(request) 59 | req = simpleobsws.Request(requestType, requestData) 60 | 61 | logging.info('Performing request for request type `{}` | Emit: {} | Client IP: {}'.format(requestType, emit, request.remote)) 62 | logging.debug('Request data:\n{}'.format(requestData)) 63 | 64 | if emit: 65 | await ws.emit(req) 66 | return web.json_response({'result': True}) 67 | 68 | try: 69 | ret = await ws.call(req) 70 | except simpleobsws.MessageTimeout: 71 | return fail_response('The obs-websocket request timed out.') 72 | responseData = {'result': True, 'requestResult': response_to_object(ret)} 73 | return web.json_response(responseData) 74 | 75 | async def call_request_callback(request): 76 | return await request_callback(request, False) 77 | 78 | async def emit_request_callback(request): 79 | return await request_callback(request, True) 80 | 81 | async def init(): 82 | logging.info('Connecting to obs-websocket: {}'.format(wsUrl)) 83 | 84 | try: 85 | await ws.connect() 86 | except ConnectionRefusedError: 87 | logging.error('Failed to connect to the obs-websocket server. Got connection refused.') 88 | return False 89 | if not await ws.wait_until_identified(): 90 | logging.error('Identification with obs-websocket timed out. Could it be using 4.x?') 91 | return False 92 | logging.info('Connected to obs-websocket.') 93 | return True 94 | 95 | async def shutdown(app): 96 | logging.info('Shutting down...') 97 | if ws.is_identified(): 98 | logging.info('Disconnecting from obs-websocket...') 99 | await ws.disconnect() 100 | logging.info('Disconnected from obs-websocket.') 101 | else: 102 | logging.info('Not connected to obs-websocket, not disconnecting.') 103 | 104 | def setup_cors(corsDomains): 105 | cors_settings = { 106 | "allow_credentials": True, 107 | "expose_headers": "*", 108 | "allow_headers": "*" 109 | } 110 | 111 | resource_options = aiohttp_cors.ResourceOptions(**cors_settings) 112 | defaults = {domain: resource_options for domain in corsDomains} 113 | cors = aiohttp_cors.setup(app, defaults=defaults) 114 | 115 | for route in list(app.router.routes()): 116 | cors.add(route) 117 | 118 | if __name__ == '__main__': 119 | config = ConfigParser() 120 | config.read('config.ini') 121 | 122 | # Command line args take priority, with fallback to config.ini, and further fallback to defaults. 123 | parser = argparse.ArgumentParser(description='A Python-based program that provides HTTP endpoints for obs-websocket') 124 | parser.add_argument('--http_bind_addres', dest='http_bind_addres', default=config.get('http', 'bind_to_address', fallback='0.0.0.0')) 125 | parser.add_argument('--http_bind_port', dest='http_bind_port', type=int, default=config.getint('http', 'bind_to_port', fallback=4456)) 126 | parser.add_argument('--cors_domains', dest='cors_domains', default=config.get('http', 'cors_domains', fallback='*')) 127 | parser.add_argument('--http_auth_key', dest='http_auth_key', default=config.get('http', 'authentication_key', fallback='')) 128 | parser.add_argument('--ws_url', dest='ws_url', default=config.get('obsws', 'ws_url', fallback='ws://127.0.0.1:4455')) 129 | parser.add_argument('--ws_password', dest='ws_password', default=config.get('obsws', 'ws_password', fallback='')) 130 | args = parser.parse_args() 131 | 132 | httpAddress = args.http_bind_addres 133 | httpPort = args.http_bind_port 134 | httpAuthKey = args.http_auth_key 135 | corsDomains = args.cors_domains.split(',') 136 | wsUrl = args.ws_url 137 | wsPassword = args.ws_password 138 | 139 | if httpAuthKey: 140 | logging.info('HTTP server will start with AuthKey set to `{}`'.format(httpAuthKey)) 141 | else: 142 | logging.info('HTTP server will start without authentication.') 143 | httpAuthKey = None 144 | 145 | logging.info('CORS Domains Accepted: {}'.format(", ".join(corsDomains))) 146 | logging.info('HTTP Server Running: {}:{}'.format(httpAddress, httpPort)) 147 | 148 | ws = simpleobsws.WebSocketClient(url=wsUrl, password=wsPassword) 149 | 150 | loop = asyncio.get_event_loop() 151 | if not loop.run_until_complete(init()): 152 | os._exit(1) 153 | 154 | app.add_routes([ 155 | web.post('/call/{requestType}', call_request_callback), 156 | web.post('/emit/{requestType}', emit_request_callback) 157 | ]) 158 | 159 | app.on_cleanup.append(shutdown) 160 | 161 | setup_cors(corsDomains) 162 | 163 | web.run_app(app, host=httpAddress, port=httpPort, loop=loop) 164 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | simpleobsws>=1.4.0 2 | aiohttp 3 | configparser 4 | aiohttp_cors 5 | --------------------------------------------------------------------------------