Files
arr-tui/webos/src/api/http.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

65 lines
2.3 KiB
TypeScript

// Shared HTTP helpers. The TV app talks to Radarr, Sonarr, SABnzbd and Trakt
// directly from the browser: all four answer cross-origin requests (CORS).
export class HTTPError extends Error {
status: number;
constructor(status: number, message: string) {
super(message ? `HTTP ${status}: ${message}` : `unexpected response: HTTP ${status}`);
this.status = status;
}
}
export class NotConfiguredError extends Error {
constructor(what: string) {
super(`${what} is not configured: set its address and API key in Settings`);
}
}
// request sends a request that gives up after timeoutMs. Network failures
// become an Error naming the host only (the URL may carry an API key).
export async function request(url: string, init: RequestInit = {}, timeoutMs = 30_000): Promise<Response> {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
const outer = init.signal;
if (outer) {
if (outer.aborted) ctl.abort();
else outer.addEventListener("abort", () => ctl.abort());
}
try {
return await fetch(url, { ...init, signal: ctl.signal });
} catch (e) {
if (outer?.aborted) throw new DOMException("cancelled", "AbortError");
if (ctl.signal.aborted) throw new Error(`${hostOf(url)}: no answer within ${Math.round(timeoutMs / 1000)} s`);
// A browser cannot follow a redirect (e.g. http to https) of a cross-origin
// request unless the redirect itself allows it; the TUI never notices.
const hint = url.startsWith("http://") ? "; if the address redirects to https, enter the https address" : "";
throw new Error(`${hostOf(url)}: cannot connect (${(e as Error).message})${hint}`);
} finally {
clearTimeout(timer);
}
}
export function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return "server";
}
}
export function isAbort(e: unknown): boolean {
return e instanceof DOMException && e.name === "AbortError";
}
export function query(params: Record<string, string | number | boolean | undefined>): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) parts.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(v)));
}
return parts.length ? "?" + parts.join("&") : "";
}
export function trimSlash(u: string): string {
return u.trim().replace(/\/+$/, "");
}