feat(plugins): trakt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 15:49:47 +01:00
co-authored by Claude Sonnet 5.5
parent 06a59e1b5c
commit 59ccc90bc1
15 changed files with 627 additions and 0 deletions
+82
View File
@@ -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 };
}
+123
View File
@@ -0,0 +1,123 @@
import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date";
import type { MediaType, SourceRecord, WorkHint } from "@sttracker/plugin-sdk/types";
interface Ids { trakt?: number; slug?: string; imdb?: string | null; tmdb?: number | null; tvdb?: number | null }
interface TMovie { title: string; year?: number | null; ids: Ids }
interface TShow { title: string; year?: number | null; ids: Ids }
interface TSeason { number: number; ids?: Ids }
interface TEpisode { season: number; number: number; title?: string | null; ids?: Ids }
export interface TItem {
type: string;
id?: number;
watched_at?: string;
rating?: number;
notes?: string | null;
movie?: TMovie;
show?: TShow;
season?: TSeason;
episode?: TEpisode;
}
function ids(map: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(map)) if (v !== undefined && v !== null && v !== "") out[k] = String(v);
return out;
}
function movieHint(m: TMovie): WorkHint {
return {
title: m.title,
mediaType: "movie",
...(m.year ? { year: m.year } : {}),
externalIds: ids({ trakt: m.ids.trakt, tmdb_movie: m.ids.tmdb, imdb: m.ids.imdb }),
};
}
function showHint(s: TShow): WorkHint {
return {
title: s.title,
mediaType: "show",
...(s.year ? { year: s.year } : {}),
externalIds: ids({ tmdb_show: s.ids.tmdb, tvdb: s.ids.tvdb, imdb: s.ids.imdb, trakt: s.ids.trakt }),
};
}
// Season and episode ids are deliberately omitted: their numeric namespaces (tvdb, tmdb, imdb, trakt)
// are shared with shows/movies, so they are identified by parent chain + position instead.
function seasonHint(show: TShow, n: number): WorkHint {
return { title: `Season ${n}`, mediaType: "season", externalIds: {}, parent: showHint(show), position: { season: n } };
}
function episodeHint(show: TShow, e: TEpisode): WorkHint {
return {
title: e.title || `Episode ${e.number}`,
mediaType: "episode",
externalIds: {},
parent: seasonHint(show, e.season),
position: { season: e.season, episode: e.number },
};
}
/** Maps a Trakt item (history, rating, watchlist or list item) to a WorkHint, or null if unsupported. */
export function hintFor(item: TItem): WorkHint | null {
switch (item.type) {
case "movie": return item.movie ? movieHint(item.movie) : null;
case "show": return item.show ? showHint(item.show) : null;
case "season": return item.show && item.season ? seasonHint(item.show, item.season.number) : null;
case "episode": return item.show && item.episode ? episodeHint(item.show, item.episode) : null;
default: return null;
}
}
export function traktIdOf(item: TItem): number | undefined {
switch (item.type) {
case "movie": return item.movie?.ids.trakt;
case "show": return item.show?.ids.trakt;
case "season": return item.season?.ids?.trakt;
case "episode": return item.episode?.ids?.trakt;
default: return undefined;
}
}
export function historyRecord(item: TItem): SourceRecord | null {
const work = hintFor(item);
if (!work || item.id === undefined) return null;
const finishedAt = parsePartialDate(item.watched_at);
return { kind: "engagement", key: `history:${item.id}`, raw: item, work, engagement: { finishedAt } };
}
export function ratingRecord(item: TItem): SourceRecord | null {
const work = hintFor(item);
const tid = traktIdOf(item);
if (!work || tid === undefined || typeof item.rating !== "number") return null;
return { kind: "entry", key: `rating:${item.type}:${tid}`, raw: item, work, entry: { rating: item.rating * 10 } };
}
export function watchlistRecord(item: TItem): SourceRecord | null {
const work = hintFor(item);
const tid = traktIdOf(item);
if (!work || tid === undefined) return null;
return { kind: "entry", key: `watchlist:${item.type}:${tid}`, raw: item, work, entry: { status: "planned" } };
}
export interface TList { name: string; description?: string | null; ids: Ids }
export function listRecord(list: TList, items: TItem[]): SourceRecord {
const mapped: { work: WorkHint; note?: string }[] = [];
for (const it of items) {
const work = hintFor(it);
if (work) mapped.push({ work, ...(it.notes ? { note: it.notes } : {}) });
}
return {
kind: "list",
key: `list:${list.ids.trakt}`,
raw: { list, items },
list: { name: list.name, ...(list.description ? { description: list.description } : {}), items: mapped },
};
}
export function searchHint(r: { type: string; movie?: TMovie; show?: TShow }): WorkHint | null {
return hintFor({ type: r.type, ...(r.movie ? { movie: r.movie } : {}), ...(r.show ? { show: r.show } : {}) });
}
export type { MediaType };
+92
View File
@@ -0,0 +1,92 @@
import type { PluginContext, SourceRecord } from "@sttracker/plugin-sdk/types";
import { ensureAccessToken, get } from "./api.ts";
import { historyRecord, listRecord, ratingRecord, watchlistRecord, type TItem, type TList } from "./map.ts";
const PAGE_LIMIT = 250;
async function* history(ctx: PluginContext, token: string, startAt?: string): AsyncGenerator<SourceRecord> {
for (let page = 1, pages = 1; page <= pages; page++) {
const r = await get<TItem[]>(ctx, token, "/sync/history", { page, limit: PAGE_LIMIT, start_at: startAt });
pages = r.pageCount;
for (const item of r.data) {
const rec = historyRecord(item);
if (rec) yield rec;
}
}
}
async function* ratings(ctx: PluginContext, token: string): AsyncGenerator<SourceRecord> {
for (const item of (await get<TItem[]>(ctx, token, "/sync/ratings")).data) {
const rec = ratingRecord(item);
if (rec) yield rec;
}
}
async function* watchlist(ctx: PluginContext, token: string): AsyncGenerator<SourceRecord> {
for (const item of (await get<TItem[]>(ctx, token, "/sync/watchlist")).data) {
const rec = watchlistRecord(item);
if (rec) yield rec;
}
}
async function* lists(ctx: PluginContext, token: string): AsyncGenerator<SourceRecord> {
for (const list of (await get<TList[]>(ctx, token, "/users/me/lists")).data) {
const items = (await get<TItem[]>(ctx, token, `/users/me/lists/${list.ids.trakt}/items`)).data;
yield listRecord(list, items);
}
}
export async function* pull(ctx: PluginContext): AsyncGenerator<SourceRecord> {
const token = await ensureAccessToken(ctx);
yield* history(ctx, token);
yield* ratings(ctx, token);
yield* watchlist(ctx, token);
yield* lists(ctx, token);
}
interface Activity { watched_at?: string | null; rated_at?: string | null; watchlisted_at?: string | null; updated_at?: string | null }
interface LastActivities { movies?: Activity; episodes?: Activity; shows?: Activity; seasons?: Activity; lists?: Activity }
interface Cursor { history: string; ratings: string; watchlist: string; lists: string }
const maxIso = (xs: (string | null | undefined)[]): string =>
xs.reduce<string>((a, x) => (x && x > a ? x : a), "");
function cursorFrom(a: LastActivities): Cursor {
return {
history: maxIso([a.movies?.watched_at, a.episodes?.watched_at]),
ratings: maxIso([a.movies?.rated_at, a.episodes?.rated_at, a.shows?.rated_at, a.seasons?.rated_at]),
watchlist: maxIso([a.movies?.watchlisted_at, a.episodes?.watchlisted_at, a.shows?.watchlisted_at, a.seasons?.watchlisted_at]),
lists: maxIso([a.lists?.updated_at]),
};
}
function parseCursor(s: string | null): Cursor | null {
if (!s) return null;
try {
const c = JSON.parse(s) as Partial<Cursor>;
return { history: c.history ?? "", ratings: c.ratings ?? "", watchlist: c.watchlist ?? "", lists: c.lists ?? "" };
} catch {
return null;
}
}
export async function* pullChanges(ctx: PluginContext): AsyncGenerator<SourceRecord> {
const token = await ensureAccessToken(ctx);
const raw = await ctx.cursor.get();
const prev = parseCursor(raw);
const next = cursorFrom((await get<LastActivities>(ctx, token, "/sync/last_activities")).data);
if (!prev) {
// No usable cursor: full pull, then remember where we are.
yield* history(ctx, token);
yield* ratings(ctx, token);
yield* watchlist(ctx, token);
yield* lists(ctx, token);
} else {
if (next.history !== prev.history) yield* history(ctx, token, prev.history || undefined);
if (next.ratings !== prev.ratings) yield* ratings(ctx, token);
if (next.watchlist !== prev.watchlist) yield* watchlist(ctx, token);
if (next.lists !== prev.lists) yield* lists(ctx, token);
}
const encoded = JSON.stringify(next);
if (encoded !== raw) await ctx.cursor.set(encoded);
}