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,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";
|
||||
}
|
||||
Reference in New Issue
Block a user