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:
@@ -0,0 +1,94 @@
|
||||
// Runs the built app (dist/) in Chromium at 1920×1080 from file://, as the
|
||||
// TV loads it, presses remote buttons from a scene file and takes
|
||||
// screenshots. For development and the README pictures.
|
||||
//
|
||||
// node dev/drive.mjs SCENE OUTDIR
|
||||
//
|
||||
// CHROMIUM (default: chromium on PATH). Scene lines:
|
||||
// key Down [Down ...] remote buttons: Left Right Up Down Enter Back Red
|
||||
// Green Yellow Blue PageUp PageDown 1-7
|
||||
// type TEXT type into the focused field
|
||||
// sleep SECONDS
|
||||
// shot NAME OUTDIR/NAME.png
|
||||
// log print the app's console and the focused element
|
||||
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const [scenePath, outDir] = process.argv.slice(2);
|
||||
if (!scenePath || !outDir) {
|
||||
console.error("usage: node dev/drive.mjs SCENE OUTDIR");
|
||||
process.exit(2);
|
||||
}
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const codes = {
|
||||
Left: 37, Up: 38, Right: 39, Down: 40, Enter: 13, Back: 461,
|
||||
Red: 403, Green: 404, Yellow: 405, Blue: 406, PageUp: 33, PageDown: 34,
|
||||
};
|
||||
for (let i = 1; i <= 9; i++) codes[String(i)] = 48 + i;
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: process.env.CHROMIUM || "chromium",
|
||||
headless: true,
|
||||
// file:// pages may call the local fake services.
|
||||
args: ["--window-size=1920,1080", "--allow-file-access-from-files", "--no-sandbox"],
|
||||
defaultViewport: { width: 1920, height: 1080 },
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const logs = [];
|
||||
// Request URLs in browser messages can carry an API key (SABnzbd, posters).
|
||||
const redact = (t) => t.replace(/(apikey=)[^&\s'"]+/gi, "$1…");
|
||||
page.on("console", (m) => logs.push(redact(`${m.type()}: ${m.text()}`)));
|
||||
page.on("pageerror", (e) => logs.push(redact(`pageerror: ${e.message}`)));
|
||||
await page.goto("file://" + resolve(here, "..", "dist", "index.html"));
|
||||
|
||||
// press sends a remote button as the TV does: a keydown with its keyCode.
|
||||
async function press(name) {
|
||||
const code = codes[name];
|
||||
if (!code) throw new Error("unknown key " + name);
|
||||
await page.evaluate((code) => {
|
||||
const target = document.activeElement || document.body;
|
||||
const ev = new KeyboardEvent("keydown", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "keyCode", { get: () => code });
|
||||
Object.defineProperty(ev, "which", { get: () => code });
|
||||
target.dispatchEvent(ev);
|
||||
}, code);
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
}
|
||||
|
||||
for (const raw of readFileSync(scenePath, "utf8").split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const [cmd, ...rest] = line.split(/\s+/);
|
||||
switch (cmd) {
|
||||
case "key":
|
||||
for (const k of rest) await press(k);
|
||||
break;
|
||||
case "type":
|
||||
await page.keyboard.type(line.slice(5));
|
||||
break;
|
||||
case "sleep":
|
||||
await new Promise((r) => setTimeout(r, parseFloat(rest[0]) * 1000));
|
||||
break;
|
||||
case "shot":
|
||||
await page.screenshot({ path: join(outDir, rest[0] + ".png") });
|
||||
break;
|
||||
case "log": {
|
||||
const focused = await page.evaluate(() => {
|
||||
const a = document.activeElement;
|
||||
return a ? `${a.tagName}.${a.className} "${(a.textContent || "").slice(0, 40)}"` : "none";
|
||||
});
|
||||
console.log(logs.splice(0).join("\n"));
|
||||
console.log("focused:", focused);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error("unknown scene command: " + line);
|
||||
}
|
||||
}
|
||||
if (logs.length) console.log(logs.join("\n"));
|
||||
await browser.close();
|
||||
Reference in New Issue
Block a user