added shitty roku integration
This commit is contained in:
@@ -130,3 +130,5 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
__pycache__/
|
||||
.venv/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.venv/
|
||||
@@ -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" ]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user