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,191 @@
|
||||
// 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();
|
||||
});
|
||||
Reference in New Issue
Block a user