// 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 = {}): Promise { 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 { return decodeList(await this.get(l.path, { page: 1, limit }), l).slice(0, limit); } async people(r: Ref): Promise { 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; }; 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 { 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[]; 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) => { 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"; }