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,64 @@
|
||||
// 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(/\/+$/, "");
|
||||
}
|
||||
Reference in New Issue
Block a user