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>
169 lines
6.2 KiB
TypeScript
169 lines
6.2 KiB
TypeScript
// 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";
|
|
}
|