// 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 = { 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 { saved: number; // ms data: T; } class Store { private key: string; private ttl: number; private cap: number; private entries: Record> | 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> { 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("arr-tui-lookups", 3, 300); // Casts from Trakt (7 days). const peopleStore = new Store("arr-tui-people", 7, 1000); export const people = { failed: new Map(), waiting: new Map>(), // 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 { 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(); });