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>
192 lines
5.7 KiB
TypeScript
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();
|
|
});
|