From f33571b8df6ffb08168be0a7f2ead6e04349fb63 Mon Sep 17 00:00:00 2001 From: ION606 Date: Thu, 11 Sep 2025 17:28:09 -0400 Subject: [PATCH] added shitty roku integration --- .gitignore | 2 + docker-compose.yml | 12 ++++ tools/.dockerignore | 1 + tools/Dockerfile | 11 +++ tools/main.py | 90 +++++++++++++++++++++++ tools/requirements.txt | 6 ++ tools/rokuHandler.py | 91 +++++++++++++++++++++++ tools/spec/roku.openapi.json | 136 +++++++++++++++++++++++++++++++++++ 8 files changed, 349 insertions(+) create mode 100644 tools/.dockerignore create mode 100644 tools/Dockerfile create mode 100644 tools/main.py create mode 100644 tools/requirements.txt create mode 100644 tools/rokuHandler.py create mode 100644 tools/spec/roku.openapi.json diff --git a/.gitignore b/.gitignore index ceaea36..c935f0e 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,5 @@ dist .yarn/install-state.gz .pnp.* +__pycache__/ +.venv/ diff --git a/docker-compose.yml b/docker-compose.yml index 9f36c7c..65b93f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,9 +11,21 @@ services: restart: always depends_on: - postgres + - tools networks: - internal + tools: + container_name: openwebui_tools + build: + context: ./tools + dockerfile: Dockerfile + env_file: .env + restart: unless-stopped + + networks: + - internal + postgres: image: postgres:latest container_name: openwebui_postgres diff --git a/tools/.dockerignore b/tools/.dockerignore new file mode 100644 index 0000000..21d0b89 --- /dev/null +++ b/tools/.dockerignore @@ -0,0 +1 @@ +.venv/ diff --git a/tools/Dockerfile b/tools/Dockerfile new file mode 100644 index 0000000..028f7d7 --- /dev/null +++ b/tools/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.13.7-alpine3.22 + +WORKDIR /usr/app + +COPY . . + +ENV PYTHONUNBUFFERED=1 + +RUN pip install -r requirements.txt + +CMD [ "python", "main.py" ] \ No newline at end of file diff --git a/tools/main.py b/tools/main.py new file mode 100644 index 0000000..d182fd7 --- /dev/null +++ b/tools/main.py @@ -0,0 +1,90 @@ +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +import urllib.parse as urlparse +import os +import json +import sys +import traceback + +from pathlib import Path +from rokuHandler import RokuWrapper, ROKU_IP + +HOST = os.environ.get("HOST", "0.0.0.0") +PORT = int(os.environ.get("PORT", "1331")) +ROOT = Path(__file__).parent.resolve() +print("root at", ROOT) + +class HTTPHandler(BaseHTTPRequestHandler): + def setup(self): + print("setting up...") + + super().setup() + self.rapp = RokuWrapper(self) + + def _cors(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "*") + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + + def _send(self, code=200, body=b''): + self.send_response(code) + self._cors() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if body: + self.wfile.write(body) + + def _send_file(self, path: Path, content_type: str): + if not path.exists() or not path.is_file(): + self._send(404, json.dumps({"error": "file not found"}).encode()) + return + # send file content as JSON payload (openapi/spec are JSON) + data = path.read_bytes() + self._send(200, data) + + def do_OPTIONS(self): + self.send_response(204) + self._cors() + self.end_headers() + + def do_GET(self): + parsed = urlparse.urlparse(self.path) + path = parsed.path.rstrip("/").lower() + + try: + # specs + if path == "/roku/openapi.json": + return self._send_file(ROOT / "spec" / "roku.openapi.json", "application/json") + + if path.startswith("/roku"): + return self.rapp.run_command(path.replace("/roku/", '')) + + # catch-all + self._send(404, json.dumps({"error": "unknown endpoint"}).encode()) + except Exception as ex: + err = {"error": str(ex)} + self.log_error("Handler error: %s\n%s", ex, traceback.format_exc()) + self._send(500, json.dumps(err).encode()) + + def log_message(self, format, *args): + # keep logs short and to stderr (default) + sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0], + self.log_date_time_string(), format % args)) + + +def run(host=HOST, port=PORT): + server = ThreadingHTTPServer((host, port), HTTPHandler) + + try: + print(f"Listening on {host}:{port} - Roku at {ROKU_IP}") + server.serve_forever() + print('finished running') + except KeyboardInterrupt: + print("Shutting down") + server.server_close() + + +if __name__ == "__main__": + run() +else: + print("Not running because this is not main") \ No newline at end of file diff --git a/tools/requirements.txt b/tools/requirements.txt new file mode 100644 index 0000000..f76832d --- /dev/null +++ b/tools/requirements.txt @@ -0,0 +1,6 @@ +certifi==2025.8.3 +charset-normalizer==3.4.3 +idna==3.10 +requests==2.32.5 +roku==4.1.0 +urllib3==2.5.0 diff --git a/tools/rokuHandler.py b/tools/rokuHandler.py new file mode 100644 index 0000000..5f4328c --- /dev/null +++ b/tools/rokuHandler.py @@ -0,0 +1,91 @@ +from roku import Roku +import os +import json +import threading + +ROKU_IP = os.environ.get("ROKU_IP") +if not ROKU_IP: + raise Exception("Roku IP not found in env!") + +COMMANDS = { + # Standard Keys + "home": "Home", + "reverse": "Rev", + "forward": "Fwd", + "play": "Play", + "select": "Select", + "left": "Left", + "right": "Right", + "down": "Down", + "up": "Up", + "back": "Back", + "replay": "InstantReplay", + "info": "Info", + "backspace": "Backspace", + "search": "Search", + "enter": "Enter", + "literal": "Lit", + # For devices that support "Find Remote" + "find_remote": "FindRemote", + # For Roku TV + "volume_down": "VolumeDown", + "volume_up": "VolumeUp", + "volume_mute": "VolumeMute", + # For Roku TV while on TV tuner channel + "channel_up": "ChannelUp", + "channel_down": "ChannelDown", + # For Roku TV current input + "input_tuner": "InputTuner", + "input_hdmi1": "InputHDMI1", + "input_hdmi2": "InputHDMI2", + "input_hdmi3": "InputHDMI3", + "input_hdmi4": "InputHDMI4", + "input_av1": "InputAV1", + # For devices that support being turned on/off + "power": "Power", + "poweroff": "PowerOff", + "poweron": "PowerOn", +} + +class RokuWrapper(): + def __init__(self, parent): + self.parent = parent + + # eh maybe close the connection? Not really needed + self.timer: threading.Timer | None = None + + try: + self.rapp = Roku(ROKU_IP) + print(f"Roku client connected to {ROKU_IP}", flush=True) + except Exception as e: + self.parent.log_error("Failed to create Roku client: %s", e) + return self.parent._send(500, json.dumps({"error": "roku connection failed"}).encode()) + + def run_command(self, command: str): + if not command: + return self.parent._send(404, json.dumps({"error": "command not given"}).encode()) + command = command.split('/')[0] + + # If command is not known, nothing to do + if command not in COMMANDS: + return self.parent._send(404, json.dumps({"error": "unknown command"}).encode()) + + if hasattr(self.rapp, command): + attr = getattr(self.rapp, command) + if callable(attr): + attr() + return {"result": f"{command} executed"} + + # Fallback: send the Roku key name using common API methods + key_name = COMMANDS[command] + # common method names in Roku libs + for send_fn in ("press", "keypress", "send_key", "send_keypress", "button"): + if hasattr(self.rapp, send_fn): + fn = getattr(self.rapp, send_fn) + try: + fn(key_name) + return {"result": f"{command} sent as {key_name} via {send_fn}"} + except TypeError: + continue + + self.parent._send(404, json.dumps({"error": "unknown endpoint"}).encode()) diff --git a/tools/spec/roku.openapi.json b/tools/spec/roku.openapi.json new file mode 100644 index 0000000..92a0120 --- /dev/null +++ b/tools/spec/roku.openapi.json @@ -0,0 +1,136 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Roku Remote HTTP API", + "description": "tiny http wrapper around the `roku` python library", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://{host}:{port}", + "variables": { + "host": { + "default": "0.0.0.0" + }, + "port": { + "default": "8000" + } + } + } + ], + "paths": { + "/{command}": { + "get": { + "operationId": "sendRokuCommand", + "summary": "send a command to the configured roku", + "parameters": [ + { + "name": "command", + "in": "path", + "required": true, + "description": "one of the supported roku commands.", + "schema": { + "$ref": "#/components/schemas/RokuCommand" + } + } + ], + "responses": { + "200": { + "description": "command accepted and sent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Result" + } + } + } + }, + "404": { + "description": "unknown endpoint or unsupported command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "unhandled server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Result": { + "type": "object", + "additionalProperties": false, + "properties": { + "result": { + "type": "string" + } + }, + "required": [ + "result" + ] + }, + "Error": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + }, + "RokuCommand": { + "type": "string", + "enum": [ + "home", + "reverse", + "forward", + "play", + "select", + "left", + "right", + "down", + "up", + "back", + "replay", + "info", + "backspace", + "search", + "enter", + "literal", + "find_remote", + "volume_down", + "volume_up", + "volume_mute", + "channel_up", + "channel_down", + "input_tuner", + "input_hdmi1", + "input_hdmi2", + "input_hdmi3", + "input_hdmi4", + "input_av1", + "power", + "poweroff", + "poweron" + ] + } + } + } +} \ No newline at end of file