added shitty roku integration
This commit is contained in:
@@ -130,3 +130,5 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
.venv/
|
||||||
|
|||||||
@@ -11,9 +11,21 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
|
- tools
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
|
tools:
|
||||||
|
container_name: openwebui_tools
|
||||||
|
build:
|
||||||
|
context: ./tools
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
env_file: .env
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:latest
|
image: postgres:latest
|
||||||
container_name: openwebui_postgres
|
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