import { HttpError } from "@sttracker/plugin-sdk/http"; import type { PluginContext } from "@sttracker/plugin-sdk/types"; export const BASE = "https://api.trakt.tv"; const REFRESH_WINDOW_MS = 24 * 60 * 60 * 1000; const REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob"; export interface TokenResponse { access_token: string; refresh_token: string; expires_in: number; created_at?: number; } export function clientCreds(ctx: PluginContext): { clientId: string; clientSecret: string } { const clientId = ctx.config.clientId; const clientSecret = ctx.config.clientSecret; if (typeof clientId !== "string" || clientId === "") throw new Error("trakt: clientId is not configured"); if (typeof clientSecret !== "string" || clientSecret === "") throw new Error("trakt: clientSecret is not configured"); return { clientId, clientSecret }; } function headers(ctx: PluginContext, accessToken?: string, body = false): Headers { const h = new Headers({ "trakt-api-version": "2", "trakt-api-key": clientCreds(ctx).clientId }); if (body) h.set("content-type", "application/json"); if (accessToken) h.set("authorization", `Bearer ${accessToken}`); return h; } export function toCredentials(t: TokenResponse, now: Date): Record { const createdMs = typeof t.created_at === "number" ? t.created_at * 1000 : now.getTime(); return { accessToken: t.access_token, refreshToken: t.refresh_token, expiresAt: new Date(createdMs + t.expires_in * 1000).toISOString(), }; } export async function post(ctx: PluginContext, path: string, body: unknown): Promise { return ctx.http.json(BASE + path, { method: "POST", headers: headers(ctx, undefined, true), body: JSON.stringify(body) }); } /** * Returns a usable access token. Refreshes when it expires within 24 h; Trakt refresh tokens are * single-use, so the rotated credentials are saved before this returns (before any further request). */ export async function ensureAccessToken(ctx: PluginContext): Promise { const creds = await ctx.credentials.get(); if (!creds?.accessToken) throw new Error("trakt: not connected (no credentials)"); const expiresMs = Date.parse(creds.expiresAt ?? ""); if (!Number.isNaN(expiresMs) && expiresMs - ctx.now().getTime() > REFRESH_WINDOW_MS) return creds.accessToken; if (!creds.refreshToken) throw new Error("trakt: access token expiring and no refresh token"); const { clientId, clientSecret } = clientCreds(ctx); const t = await post(ctx, "/oauth/token", { refresh_token: creds.refreshToken, client_id: clientId, client_secret: clientSecret, redirect_uri: REDIRECT_URI, grant_type: "refresh_token", }); await ctx.credentials.save(toCredentials(t, ctx.now())); return t.access_token; } /** Authenticated GET. Returns the parsed body plus the X-Pagination-Page-Count header (1 if absent). */ export async function get( ctx: PluginContext, token: string, path: string, query: Record = {}, ): Promise<{ data: T; pageCount: number }> { const u = new URL(BASE + path); for (const [k, v] of Object.entries(query)) if (v !== undefined) u.searchParams.set(k, String(v)); const h = headers(ctx, token); h.set("accept", "application/json"); const res = await ctx.http.raw(u.toString(), { headers: h }); if (!res.ok) throw new HttpError(res.status, await res.text(), u.toString()); const pc = Number(res.headers.get("x-pagination-page-count")); return { data: (await res.json()) as T, pageCount: Number.isFinite(pc) && pc >= 1 ? pc : 1 }; }