Files
arr-tui/webos/src/app.ts
T
nilsandClaude Opus 5.5 7f6d4d07b9 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>
2026-10-25 16:51:48 +01:00

192 lines
5.7 KiB
TypeScript

// The services, shared caches and the page stack every view uses.
import { RadarrClient } from "./api/radarr.ts";
import { SonarrClient } from "./api/sonarr.ts";
import { SABnzbdClient } from "./api/sabnzbd.ts";
import { TraktClient, refKey, TraktNotFound, type People, type Ref } from "./api/trakt.ts";
import { loadConfig, type Config } from "./config.ts";
import { h } from "./ui/dom.ts";
import { focusFirst, popLayer, pushLayer } from "./ui/focus.ts";
export type ServiceName = "Radarr" | "Sonarr" | "SABnzbd" | "Trakt";
class Services {
cfg: Config;
radarr: RadarrClient;
sonarr: SonarrClient;
sab: SABnzbdClient;
trakt: TraktClient;
// Called a few seconds after a grab (the Library's download marks).
afterGrab: (() => void)[] = [];
// Called when the config changes (Settings saved).
onConfig: (() => void)[] = [];
status: Record<ServiceName, string> = { Radarr: "", Sonarr: "", SABnzbd: "", Trakt: "" }; // "" ok, else the error
onStatus: (() => void)[] = [];
constructor() {
this.cfg = loadConfig();
const c = this.cfg;
this.radarr = new RadarrClient(c.radarr.url, c.radarr.apikey);
this.sonarr = new SonarrClient(c.sonarr.url, c.sonarr.apikey);
this.sab = new SABnzbdClient(c.sabnzbd.url, c.sabnzbd.apikey);
this.trakt = new TraktClient(c.trakt.client_id ?? "", traktURL());
}
apply(c: Config): void {
this.cfg = c;
this.radarr.configure(c.radarr.url, c.radarr.apikey);
this.sonarr.configure(c.sonarr.url, c.sonarr.apikey);
this.sab.configure(c.sabnzbd.url, c.sabnzbd.apikey);
this.trakt = new TraktClient(c.trakt.client_id ?? "", traktURL());
people.failed.clear();
for (const f of this.onConfig) f();
}
setStatus(name: ServiceName, err: unknown): void {
const text = err ? (err instanceof Error ? err.message : String(err)) : "";
if (this.status[name] === text) return;
this.status[name] = text;
for (const f of this.onStatus) f();
}
covers(): boolean {
return this.cfg.ui.covers !== false;
}
}
// traktURL: another Trakt API for development (?trakt=http://127.0.0.1:19894).
function traktURL(): string | undefined {
return new URLSearchParams(location.search).get("trakt") ?? (window as unknown as { ARR_TUI_TRAKT_URL?: string }).ARR_TUI_TRAKT_URL;
}
export const app = new Services();
// --- Pages: full-screen views opened on top (releases, episodes) ---
export interface Page {
el: HTMLElement;
close: () => void;
}
export function openPage(el: HTMLElement, onClose?: () => void): Page {
el.classList.add("page");
document.getElementById("overlays")!.appendChild(el);
let closed = false;
const page: Page = {
el,
close() {
if (closed) return;
closed = true;
popLayer(el);
el.remove();
onClose?.();
},
};
pushLayer(el, page.close);
focusFirst(el);
return page;
}
export function pageHeader(title: string, ...extra: (Node | string)[]): HTMLElement {
return h("div.page-header", h("h1", title), ...extra);
}
// --- Stores kept on the TV (local storage), like arr-tui's disk cache ---
interface Saved<T> {
saved: number; // ms
data: T;
}
class Store<T> {
private key: string;
private ttl: number;
private cap: number;
private entries: Record<string, Saved<T>> | null = null;
private timer = 0;
constructor(key: string, ttlDays: number, cap: number) {
this.key = key;
this.ttl = ttlDays * 86400_000;
this.cap = cap;
}
private load(): Record<string, Saved<T>> {
if (!this.entries) {
try {
this.entries = JSON.parse(localStorage.getItem(this.key) ?? "{}");
} catch {
this.entries = {};
}
}
return this.entries!;
}
get(k: string): T | undefined {
const e = this.load()[k];
return e && Date.now() - e.saved < this.ttl ? e.data : undefined;
}
put(k: string, data: T): void {
const all = this.load();
all[k] = { saved: Date.now(), data };
const keys = Object.keys(all);
if (keys.length > this.cap) {
keys.sort((a, b) => all[a].saved - all[b].saved);
for (const old of keys.slice(0, keys.length - this.cap)) delete all[old];
}
clearTimeout(this.timer);
this.timer = window.setTimeout(() => this.save(), 3000);
}
save(): void {
if (!this.entries) return;
const now = Date.now();
for (const [k, e] of Object.entries(this.entries)) if (now - e.saved >= this.ttl) delete this.entries[k];
try {
localStorage.setItem(this.key, JSON.stringify(this.entries));
} catch {
/* full: kept for this run only */
}
}
}
// Lookups of Trakt list titles that are not in the library (3 days).
export const lookups = new Store<unknown>("arr-tui-lookups", 3, 300);
// Casts from Trakt (7 days).
const peopleStore = new Store<People>("arr-tui-people", 7, 1000);
export const people = {
failed: new Map<string, number>(),
waiting: new Map<string, Promise<People>>(),
// load returns the cast of a title, from the store or Trakt. A title Trakt
// does not know has no cast.
load(r: Ref): Promise<People> {
const key = refKey(r);
const known = peopleStore.get(key);
if (known) return Promise.resolve(known);
const failedAt = this.failed.get(key);
if (failedAt && Date.now() - failedAt < 120_000) return Promise.reject(new Error("cast not available"));
let p = this.waiting.get(key);
if (!p) {
p = app.trakt.people(r).catch((e) => {
if (e instanceof TraktNotFound) return { cast: [], leaders: [] };
this.failed.set(key, Date.now());
throw e;
}).then((ppl) => {
peopleStore.put(key, ppl);
return ppl;
}).finally(() => this.waiting.delete(key));
this.waiting.set(key, p);
}
return p;
},
};
window.addEventListener("beforeunload", () => {
lookups.save();
peopleStore.save();
});