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:
2026-10-25 16:51:48 +01:00
co-authored by Claude Opus 5.5
parent 1015135a4b
commit 7f6d4d07b9
47 changed files with 12294 additions and 1 deletions
+168
View File
@@ -0,0 +1,168 @@
// Trakt's public lists and casts (client ID only), ported from internal/trakt.
import { query, request, trimSlash } from "./http.ts";
export type Kind = "movie" | "show";
export interface List {
name: string;
path: string;
count: string; // field holding the list's number, "" for none
note?: (n: number) => string;
kind: Kind;
}
export interface IDs {
trakt: number;
slug?: string;
imdb?: string;
tmdb?: number;
tvdb?: number;
}
export interface Entry {
title: string;
year: number;
ids: IDs;
count: number; // the list's number for it, 0 if none
}
export interface Person {
name: string;
role: string;
}
export interface People {
cast: Person[];
leaders: Person[]; // directors of a movie, creators of a show
}
export interface Ref {
kind: Kind;
tmdb?: number;
tvdb?: number;
imdb?: string;
}
export class TraktNotFound extends Error {
constructor() {
super("trakt: not found");
}
}
const watching = (n: number) => compact(n) + " watching";
const watchers = (n: number) => compact(n) + " watched it";
const onLists = (n: number) => "on " + compact(n) + " lists";
const revenue = (n: number) => "$" + compact(n) + " last weekend";
export const movieLists: List[] = [
{ name: "Trending now", path: "/movies/trending", count: "watchers", note: watching, kind: "movie" },
{ name: "Most watched this week", path: "/movies/watched/weekly", count: "watcher_count", note: watchers, kind: "movie" },
{ name: "Anticipated", path: "/movies/anticipated", count: "list_count", note: onLists, kind: "movie" },
{ name: "Popular", path: "/movies/popular", count: "", kind: "movie" },
{ name: "Box office (US)", path: "/movies/boxoffice", count: "revenue", note: revenue, kind: "movie" },
];
export const showLists: List[] = [
{ name: "Trending now", path: "/shows/trending", count: "watchers", note: watching, kind: "show" },
{ name: "Most watched this week", path: "/shows/watched/weekly", count: "watcher_count", note: watchers, kind: "show" },
{ name: "Anticipated", path: "/shows/anticipated", count: "list_count", note: onLists, kind: "show" },
{ name: "Popular", path: "/shows/popular", count: "", kind: "show" },
];
export function listNote(l: List, n: number): string {
return l.note && n > 0 ? l.note(n) : "";
}
export function refKey(r: Ref): string {
const k = r.kind + ":";
if (r.kind === "movie" && r.tmdb) return k + "tmdb:" + r.tmdb;
if (r.kind === "show" && r.tvdb) return k + "tvdb:" + r.tvdb;
if (r.imdb) return k + "imdb:" + r.imdb;
return "";
}
export class TraktClient {
baseURL: string;
clientID: string;
constructor(clientID: string, baseURL = "https://api.trakt.tv") {
this.clientID = clientID.trim();
this.baseURL = trimSlash(baseURL);
}
configured(): boolean {
return this.clientID !== "";
}
async get(path: string, params: Record<string, string | number> = {}): Promise<unknown> {
if (!this.configured()) throw new Error("no Trakt client ID: create an API app on trakt.tv and enter its client ID in Settings");
// No User-Agent: browsers do not let pages set it.
const resp = await request(this.baseURL + path + query(params), {
headers: { "Content-Type": "application/json", "trakt-api-key": this.clientID, "trakt-api-version": "2" },
}, 20_000);
if (resp.status === 401 || resp.status === 403) throw new Error(`trakt: the client ID was not accepted (HTTP ${resp.status})`);
if (resp.status === 429) throw new Error("trakt: too many requests, try again in a few minutes");
if (resp.status === 404) throw new TraktNotFound();
if (!resp.ok) throw new Error(`trakt: HTTP ${resp.status}`);
return resp.json();
}
async entries(l: List, limit: number): Promise<Entry[]> {
return decodeList(await this.get(l.path, { page: 1, limit }), l).slice(0, limit);
}
async people(r: Ref): Promise<People> {
const id = await this.traktID(r);
const raw = (await this.get(`/${r.kind}s/${id}/people`)) as {
cast?: { character?: string; characters?: string[]; person: { name: string } }[];
crew?: Record<string, { job?: string; jobs?: string[]; person: { name: string } }[]>;
};
const p: People = { cast: [], leaders: [] };
for (const m of raw.cast ?? []) {
const role = m.characters?.length ? m.characters.join(" / ") : (m.character ?? "");
p.cast.push({ name: m.person.name, role });
}
const [key, job] = r.kind === "show" ? ["created by", ""] : ["directing", "Director"];
for (const m of raw.crew?.[key] ?? []) {
const jobs = [m.job ?? "", ...(m.jobs ?? [])];
if (!job || jobs.includes(job)) p.leaders.push({ name: m.person.name, role: m.job ?? "" });
}
return p;
}
async traktID(r: Ref): Promise<number> {
let type: string, id: string;
if (r.kind === "movie" && r.tmdb) [type, id] = ["tmdb", String(r.tmdb)];
else if (r.kind === "show" && r.tvdb) [type, id] = ["tvdb", String(r.tvdb)];
else if (r.imdb) [type, id] = ["imdb", r.imdb];
else throw new TraktNotFound();
const results = (await this.get(`/search/${type}/${encodeURIComponent(id)}`, { type: r.kind })) as Record<string, { ids?: IDs }>[];
for (const res of results) {
const t = res[r.kind]?.ids?.trakt;
if (t) return t;
}
throw new TraktNotFound();
}
}
// decodeList reads a list answer: bare titles, or items with the title under
// "movie"/"show" next to the list's number.
export function decodeList(data: unknown, l: List): Entry[] {
if (!Array.isArray(data)) throw new Error(`trakt: unexpected answer for ${l.path}`);
return data.map((item: Record<string, unknown>) => {
const t = (item[l.kind] ?? item) as { title: string; year: number; ids: IDs };
const n = l.count ? Number(item[l.count] ?? 0) : 0;
return { title: t.title, year: t.year, ids: t.ids ?? { trakt: 0 }, count: Number.isFinite(n) ? n : 0 };
});
}
// compact writes a number briefly: 950, 1.2k, 34k, 1.5M.
export function compact(n: number): string {
const trim = (s: string) => s.replace(".0", "");
if (n < 1000) return String(Math.trunc(n));
if (n < 10_000) return trim((n / 1000).toFixed(1) + "k");
if (n < 1_000_000) return Math.trunc(n / 1000) + "k";
if (n < 10_000_000) return trim((n / 1_000_000).toFixed(1) + "M");
return Math.trunc(n / 1_000_000) + "M";
}