feat(plugins): trakt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
06a59e1b5c
commit
59ccc90bc1
@@ -0,0 +1,82 @@
|
||||
import { HttpError } from "@sttracker/plugin-sdk/http";
|
||||
import type { PluginContext } from "@sttracker/plugin-sdk/types";
|
||||
|
||||
export const BASE = "https://api.trakt.tv";
|
||||
/** Rate the host should configure for this plugin's HttpClient. */
|
||||
export const RATE_PER_MINUTE = 100;
|
||||
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<string, string> {
|
||||
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<T>(ctx: PluginContext, path: string, body: unknown): Promise<T> {
|
||||
return ctx.http.json<T>(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<string> {
|
||||
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<TokenResponse>(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<T>(
|
||||
ctx: PluginContext,
|
||||
token: string,
|
||||
path: string,
|
||||
query: Record<string, string | number | undefined> = {},
|
||||
): 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user