webOS: a TV app with the views of arr-tui

A TypeScript web app for LG webOS TVs (webOS 5 / Chromium 68 and newer),
driven by the remote: Movies and Series search with the Trakt lists,
Library with status, title and file-property filters and download marks,
seasons and episodes, release search and grab, Downloads, Calendar,
Settings and Log. It talks to Radarr, Sonarr, SABnzbd and Trakt directly
(they answer cross-origin requests); the arr-tui config is baked in at
build time and can be changed on the TV.

dev/screenshots/server.py answers cross-origin requests and accepts writes
for it; webos/dev/drive.mjs drives the app in Chromium for screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 16:51:48 +01:00
co-authored by Claude Opus 5.5
parent 1015135a4b
commit 7f6d4d07b9
47 changed files with 12294 additions and 1 deletions
+21
View File
@@ -220,6 +220,27 @@ def sab(q):
def handler(port):
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
# Like the real services, answer cross-origin requests (the webOS app
# runs in a browser).
def end_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
super().end_headers()
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
self.send_header("Access-Control-Allow-Headers", self.headers.get("Access-Control-Request-Headers", "*"))
self.end_headers()
# Writes (add, grab, commands, monitoring, removal) are accepted and
# change nothing; an added title comes back with an id.
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n) or b"{}") if n else {}
out = dict(body, id=900) if isinstance(body, dict) and self.path.split("?")[0].endswith(("/movie", "/series")) else {}
b = json.dumps(out).encode()
self.send_response(201 if out else 200); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
do_PUT = do_POST
def do_DELETE(self):
self.send_response(200); self.send_header("Content-Length", "0"); self.end_headers()
def do_GET(self):
u = urllib.parse.urlparse(self.path); q = urllib.parse.parse_qs(u.query); path = u.path
if path.startswith("/MediaCover/") or path.startswith("/remote/"):