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:
+150
@@ -0,0 +1,150 @@
|
||||
// Builds the webOS app into dist/: the bundled script, the page, the icons
|
||||
// and config.js with the arr-tui config baked in.
|
||||
//
|
||||
// node build.mjs config from $ARR_TUI_CONFIG or ~/.config/arr-tui/config.json
|
||||
// node build.mjs --config FILE another config file
|
||||
// node build.mjs --demo the fake services of dev/screenshots (server.py)
|
||||
// node build.mjs --dev not minified
|
||||
//
|
||||
// The baked-in config holds API keys: never publish a package built with it.
|
||||
|
||||
import * as esbuild from "esbuild";
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { deflateSync } from "node:zlib";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const dist = join(here, "dist");
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name) => args.includes(name);
|
||||
const option = (name) => {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
};
|
||||
|
||||
// The oldest browser engine targeted: webOS 5 (2020 TVs) has Chromium 68.
|
||||
const target = "chrome68";
|
||||
|
||||
function configPath() {
|
||||
if (option("--config")) return option("--config");
|
||||
if (process.env.ARR_TUI_CONFIG) return process.env.ARR_TUI_CONFIG;
|
||||
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
||||
return join(base, "arr-tui", "config.json");
|
||||
}
|
||||
|
||||
function demoConfig() {
|
||||
const key = "0123456789abcdef0123456789abcdef";
|
||||
return {
|
||||
radarr: { url: "http://127.0.0.1:19891", apikey: key, default_quality_profile: "Ultra-HD", default_root_folder: "/movies" },
|
||||
sonarr: { url: "http://127.0.0.1:19892", apikey: key, default_quality_profile: "HD-1080p", default_root_folder: "/tv" },
|
||||
sabnzbd: { url: "http://127.0.0.1:19893", apikey: key },
|
||||
trakt: { client_id: key },
|
||||
ui: { theme: "dark" },
|
||||
};
|
||||
}
|
||||
|
||||
// pick keeps the settings the TV app uses, nothing else from the file.
|
||||
function pick(c) {
|
||||
const svc = (s = {}) => ({ url: s.url ?? "", apikey: s.apikey ?? "" });
|
||||
const arr = (s = {}) => ({ ...svc(s), default_quality_profile: s.default_quality_profile, default_root_folder: s.default_root_folder });
|
||||
const ui = c.ui ?? {};
|
||||
return {
|
||||
radarr: arr(c.radarr),
|
||||
sonarr: arr(c.sonarr),
|
||||
sabnzbd: svc(c.sabnzbd),
|
||||
trakt: { client_id: c.trakt?.client_id },
|
||||
ui: { theme: ui.theme, refresh_seconds: ui.refresh_seconds, covers: ui.covers, notify_downloads: ui.notify_downloads },
|
||||
};
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
if (flag("--demo")) return { config: demoConfig(), from: "the demo services", extra: 'window.ARR_TUI_TRAKT_URL = "http://127.0.0.1:19894";\n' };
|
||||
const path = configPath();
|
||||
if (!existsSync(path)) {
|
||||
console.warn(`no config at ${path}: the app starts with Settings`);
|
||||
return { config: pick({}), from: "nothing", extra: "" };
|
||||
}
|
||||
return { config: pick(JSON.parse(readFileSync(path, "utf8"))), from: path, extra: "" };
|
||||
}
|
||||
|
||||
// --- Icons: drawn here, so no image tools are needed ---
|
||||
|
||||
function png(size, draw) {
|
||||
const px = Buffer.alloc(size * size * 4);
|
||||
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) px.set(draw(x + 0.5, y + 0.5, size), (y * size + x) * 4);
|
||||
const raw = Buffer.alloc((size * 4 + 1) * size);
|
||||
for (let y = 0; y < size; y++) px.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
|
||||
const chunk = (type, data) => {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length);
|
||||
const td = Buffer.concat([Buffer.from(type), data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(td) >>> 0);
|
||||
return Buffer.concat([len, td, crc]);
|
||||
};
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(size, 0);
|
||||
ihdr.writeUInt32BE(size, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // RGBA
|
||||
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
|
||||
}
|
||||
|
||||
function crc32(buf) {
|
||||
let c = ~0;
|
||||
for (const b of buf) {
|
||||
c ^= b;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
|
||||
}
|
||||
return ~c;
|
||||
}
|
||||
|
||||
// The icon: a dark rounded square with three yellow list bars of different
|
||||
// lengths and a blue dot (a title downloading), as in the Library.
|
||||
function icon(x, y, s) {
|
||||
const u = (v) => v * s;
|
||||
const r = u(0.18);
|
||||
const inside = (() => {
|
||||
const cx = Math.min(Math.max(x, r), s - r);
|
||||
const cy = Math.min(Math.max(y, r), s - r);
|
||||
return (x - cx) ** 2 + (y - cy) ** 2 <= r * r;
|
||||
})();
|
||||
if (!inside) return [0, 0, 0, 0];
|
||||
const bars = [[0.26, 0.42, 0.74], [0.45, 0.42, 0.62], [0.64, 0.42, 0.70]];
|
||||
for (const [top, left, right] of bars) {
|
||||
if (y >= u(top) && y <= u(top + 0.1) && x >= u(left) && x <= u(right)) return [0xf2, 0xb6, 0x32, 255];
|
||||
}
|
||||
const dots = [[0.31, [0x7f, 0xcc, 0x6e]], [0.50, [0x5f, 0xad, 0xf2]], [0.69, [0x7f, 0xcc, 0x6e]]];
|
||||
for (const [cy, rgb] of dots) {
|
||||
if ((x - u(0.3)) ** 2 + (y - u(cy)) ** 2 <= u(0.055) ** 2) return [...rgb, 255];
|
||||
}
|
||||
return [0x1d, 0x20, 0x27, 255];
|
||||
}
|
||||
|
||||
// --- Build ---
|
||||
|
||||
rmSync(dist, { recursive: true, force: true });
|
||||
mkdirSync(dist, { recursive: true });
|
||||
for (const f of readdirSync(join(here, "static"))) copyFileSync(join(here, "static", f), join(dist, f));
|
||||
writeFileSync(join(dist, "icon.png"), png(80, icon));
|
||||
writeFileSync(join(dist, "largeIcon.png"), png(130, icon));
|
||||
|
||||
const { config, from, extra } = loadConfig();
|
||||
writeFileSync(join(dist, "config.js"), `// Built from ${from === "nothing" || from === "the demo services" ? from : "the arr-tui config"}. Holds API keys: keep this package private.\nwindow.ARR_TUI_CONFIG = ${JSON.stringify(config, null, 2)};\n${extra}`, { mode: 0o600 });
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [join(here, "src", "main.ts")],
|
||||
bundle: true,
|
||||
outfile: join(dist, "app.js"),
|
||||
format: "iife",
|
||||
target,
|
||||
minify: !flag("--dev"),
|
||||
sourcemap: flag("--dev") ? "inline" : false,
|
||||
legalComments: "none",
|
||||
logLevel: "warning",
|
||||
});
|
||||
|
||||
const services = ["radarr", "sonarr", "sabnzbd"].filter((s) => config[s].url).join(", ") || "none";
|
||||
console.log(`dist/ built (config from ${from}; services: ${services}${config.trakt.client_id ? ", Trakt" : ""})`);
|
||||
Reference in New Issue
Block a user