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>
95 lines
3.5 KiB
JavaScript
95 lines
3.5 KiB
JavaScript
// 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();
|