diff --git a/plugins/trakt/index.ts b/plugins/trakt/index.ts new file mode 100644 index 0000000..ea363a4 --- /dev/null +++ b/plugins/trakt/index.ts @@ -0,0 +1,55 @@ +import { HttpError } from "@sttracker/plugin-sdk/http"; +import { definePlugin } from "@sttracker/plugin-sdk/types"; +import { BASE, clientCreds, post, toCredentials, type TokenResponse } from "./src/api.ts"; +import { searchHint } from "./src/map.ts"; +import { pull, pullChanges } from "./src/sync.ts"; + +interface DeviceCode { device_code: string; user_code: string; verification_url: string; expires_in: number; interval: number } + +export default definePlugin({ + manifest: { + id: "trakt", + name: "Trakt", + version: "0.1.0", + sdkVersion: 1, + mediaTypes: ["movie", "show", "season", "episode"], + capabilities: ["pull", "pullChanges", "lookup"], + config: [ + { key: "clientId", label: "Client ID", type: "string", required: true }, + { key: "clientSecret", label: "Client secret", type: "secret", required: true }, + ], + attribution: "STTracker uses Trakt but is not endorsed or certified by Trakt.", + }, + deviceAuth: { + async start(ctx) { + const { clientId } = clientCreds(ctx); + const d = await post(ctx, "/oauth/device/code", { client_id: clientId }); + return { userCode: d.user_code, verificationUrl: d.verification_url, deviceCode: d.device_code, intervalSec: d.interval, expiresInSec: d.expires_in }; + }, + async poll(ctx, deviceCode) { + const { clientId, clientSecret } = clientCreds(ctx); + try { + const t = await post(ctx, "/oauth/device/token", { code: deviceCode, client_id: clientId, client_secret: clientSecret }); + return toCredentials(t, ctx.now()); + } catch (e) { + // 400 = pending, 429 = polling too fast; anything else (404/409/410/418) is terminal. + if (e instanceof HttpError && (e.status === 400 || e.status === 429)) return "pending"; + throw e; + } + }, + }, + pull, + pullChanges, + async lookup(ctx, q) { + const { clientId } = clientCreds(ctx); + const type = q.mediaType === "movie" || q.mediaType === "show" ? q.mediaType : "movie,show"; + const res = await ctx.http.json[0][]>(`${BASE}/search/${type}`, { + headers: { "trakt-api-version": "2", "trakt-api-key": clientId }, + query: { query: q.title, years: q.year }, + }); + return res.flatMap((r) => { + const h = searchHint(r); + return h ? [h] : []; + }); + }, +}); diff --git a/plugins/trakt/src/api.ts b/plugins/trakt/src/api.ts new file mode 100644 index 0000000..fa6787e --- /dev/null +++ b/plugins/trakt/src/api.ts @@ -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 { + 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 }; +} diff --git a/plugins/trakt/src/map.ts b/plugins/trakt/src/map.ts new file mode 100644 index 0000000..5e231a7 --- /dev/null +++ b/plugins/trakt/src/map.ts @@ -0,0 +1,128 @@ +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): Record { + const out: Record = {}; + 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_movie: 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({ trakt_show: s.ids.trakt, tmdb_show: s.ids.tmdb, tvdb_show: s.ids.tvdb, imdb: s.ids.imdb }), + }; +} + +function seasonHint(show: TShow, n: number, sid?: Ids): WorkHint { + return { + title: `Season ${n}`, + mediaType: "season", + externalIds: sid ? ids({ trakt_season: sid.trakt, tmdb_season: sid.tmdb }) : {}, + parent: showHint(show), + position: { season: n }, + }; +} + +function episodeHint(show: TShow, e: TEpisode): WorkHint { + const eid = e.ids; + return { + title: e.title || `Episode ${e.number}`, + mediaType: "episode", + externalIds: eid ? ids({ trakt_episode: eid.trakt, tvdb_episode: eid.tvdb, tmdb_episode: eid.tmdb, imdb: eid.imdb }) : {}, + 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, item.season.ids) : 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 }; diff --git a/plugins/trakt/src/sync.ts b/plugins/trakt/src/sync.ts new file mode 100644 index 0000000..10ad6e8 --- /dev/null +++ b/plugins/trakt/src/sync.ts @@ -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 { + for (let page = 1, pages = 1; page <= pages; page++) { + const r = await get(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 { + for (const item of (await get(ctx, token, "/sync/ratings")).data) { + const rec = ratingRecord(item); + if (rec) yield rec; + } +} + +async function* watchlist(ctx: PluginContext, token: string): AsyncGenerator { + for (const item of (await get(ctx, token, "/sync/watchlist")).data) { + const rec = watchlistRecord(item); + if (rec) yield rec; + } +} + +async function* lists(ctx: PluginContext, token: string): AsyncGenerator { + for (const list of (await get(ctx, token, "/users/me/lists")).data) { + const items = (await get(ctx, token, `/users/me/lists/${list.ids.trakt}/items`)).data; + yield listRecord(list, items); + } +} + +export async function* pull(ctx: PluginContext): AsyncGenerator { + 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((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; + return { history: c.history ?? "", ratings: c.ratings ?? "", watchlist: c.watchlist ?? "", lists: c.lists ?? "" }; + } catch { + return null; + } +} + +export async function* pullChanges(ctx: PluginContext): AsyncGenerator { + const token = await ensureAccessToken(ctx); + const raw = await ctx.cursor.get(); + const prev = parseCursor(raw); + const next = cursorFrom((await get(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); +} diff --git a/plugins/trakt/test/fixtures/device-code.json b/plugins/trakt/test/fixtures/device-code.json new file mode 100644 index 0000000..51187b7 --- /dev/null +++ b/plugins/trakt/test/fixtures/device-code.json @@ -0,0 +1 @@ +{ "device_code": "dev123", "user_code": "ABCD1234", "verification_url": "https://trakt.tv/activate", "expires_in": 600, "interval": 5 } diff --git a/plugins/trakt/test/fixtures/history-page1.json b/plugins/trakt/test/fixtures/history-page1.json new file mode 100644 index 0000000..04cfeb7 --- /dev/null +++ b/plugins/trakt/test/fixtures/history-page1.json @@ -0,0 +1,11 @@ +[ + { + "id": 9001, "watched_at": "2024-03-05T20:15:00.000Z", "action": "watch", "type": "movie", + "movie": { "title": "Arrival", "year": 2016, "ids": { "trakt": 1001, "slug": "arrival-2016", "imdb": "tt2543164", "tmdb": 329865 } } + }, + { + "id": 9002, "watched_at": "2024-03-06T21:30:00.000Z", "action": "watch", "type": "episode", + "episode": { "season": 2, "number": 3, "title": "The Heist", "ids": { "trakt": 5003, "tvdb": 7003, "imdb": "tt0000003", "tmdb": 8003 } }, + "show": { "title": "Great Show", "year": 2019, "ids": { "trakt": 2002, "slug": "great-show", "tvdb": 70002, "imdb": "tt7000002", "tmdb": 80002 } } + } +] diff --git a/plugins/trakt/test/fixtures/history-page2.json b/plugins/trakt/test/fixtures/history-page2.json new file mode 100644 index 0000000..afb1267 --- /dev/null +++ b/plugins/trakt/test/fixtures/history-page2.json @@ -0,0 +1,6 @@ +[ + { + "id": 9003, "watched_at": "2023-12-31T23:59:59.000Z", "action": "watch", "type": "movie", + "movie": { "title": "Heat", "year": 1995, "ids": { "trakt": 1003, "slug": "heat-1995", "imdb": "tt0113277", "tmdb": 949 } } + } +] diff --git a/plugins/trakt/test/fixtures/last-activities.json b/plugins/trakt/test/fixtures/last-activities.json new file mode 100644 index 0000000..f985775 --- /dev/null +++ b/plugins/trakt/test/fixtures/last-activities.json @@ -0,0 +1,8 @@ +{ + "all": "2024-03-07T10:00:00.000Z", + "movies": { "watched_at": "2024-03-05T20:15:00.000Z", "collected_at": null, "rated_at": "2024-03-05T22:00:00.000Z", "watchlisted_at": "2024-02-01T08:00:00.000Z", "favorited_at": null, "commented_at": null, "paused_at": null, "hidden_at": null }, + "episodes": { "watched_at": "2024-03-06T21:30:00.000Z", "collected_at": null, "rated_at": null, "watchlisted_at": null, "commented_at": null, "paused_at": null }, + "shows": { "rated_at": "2024-03-07T10:00:00.000Z", "watchlisted_at": null, "favorited_at": null, "commented_at": null, "hidden_at": null }, + "seasons": { "rated_at": null, "watchlisted_at": null, "commented_at": null, "hidden_at": null }, + "lists": { "liked_at": null, "updated_at": "2024-01-02T00:00:00.000Z" } +} diff --git a/plugins/trakt/test/fixtures/list-items.json b/plugins/trakt/test/fixtures/list-items.json new file mode 100644 index 0000000..0848b37 --- /dev/null +++ b/plugins/trakt/test/fixtures/list-items.json @@ -0,0 +1,10 @@ +[ + { + "rank": 1, "id": 1, "listed_at": "2024-01-01T00:00:00.000Z", "notes": "rewatch yearly", "type": "movie", + "movie": { "title": "Heat", "year": 1995, "ids": { "trakt": 1003, "slug": "heat-1995", "imdb": "tt0113277", "tmdb": 949 } } + }, + { + "rank": 2, "id": 2, "listed_at": "2024-01-02T00:00:00.000Z", "notes": null, "type": "show", + "show": { "title": "Great Show", "year": 2019, "ids": { "trakt": 2002, "slug": "great-show", "tvdb": 70002, "imdb": "tt7000002", "tmdb": 80002 } } + } +] diff --git a/plugins/trakt/test/fixtures/lists.json b/plugins/trakt/test/fixtures/lists.json new file mode 100644 index 0000000..8e5728a --- /dev/null +++ b/plugins/trakt/test/fixtures/lists.json @@ -0,0 +1,3 @@ +[ + { "name": "Favourites", "description": "All-time best", "privacy": "private", "item_count": 2, "ids": { "trakt": 7777, "slug": "favourites" } } +] diff --git a/plugins/trakt/test/fixtures/ratings.json b/plugins/trakt/test/fixtures/ratings.json new file mode 100644 index 0000000..f340269 --- /dev/null +++ b/plugins/trakt/test/fixtures/ratings.json @@ -0,0 +1,10 @@ +[ + { + "rated_at": "2024-03-05T22:00:00.000Z", "rating": 8, "type": "movie", + "movie": { "title": "Arrival", "year": 2016, "ids": { "trakt": 1001, "slug": "arrival-2016", "imdb": "tt2543164", "tmdb": 329865 } } + }, + { + "rated_at": "2024-03-07T10:00:00.000Z", "rating": 10, "type": "show", + "show": { "title": "Great Show", "year": 2019, "ids": { "trakt": 2002, "slug": "great-show", "tvdb": 70002, "imdb": "tt7000002", "tmdb": 80002 } } + } +] diff --git a/plugins/trakt/test/fixtures/search.json b/plugins/trakt/test/fixtures/search.json new file mode 100644 index 0000000..c7dba2c --- /dev/null +++ b/plugins/trakt/test/fixtures/search.json @@ -0,0 +1,4 @@ +[ + { "type": "movie", "score": 90.1, "movie": { "title": "Arrival", "year": 2016, "ids": { "trakt": 1001, "slug": "arrival-2016", "imdb": "tt2543164", "tmdb": 329865 } } }, + { "type": "show", "score": 40.2, "show": { "title": "Arrival Show", "year": 2020, "ids": { "trakt": 2010, "slug": "arrival-show", "tvdb": 71010, "imdb": "tt7100010", "tmdb": 81010 } } } +] diff --git a/plugins/trakt/test/fixtures/token-refresh.json b/plugins/trakt/test/fixtures/token-refresh.json new file mode 100644 index 0000000..17b0f43 --- /dev/null +++ b/plugins/trakt/test/fixtures/token-refresh.json @@ -0,0 +1 @@ +{ "access_token": "new-access", "token_type": "bearer", "expires_in": 7776000, "refresh_token": "new-refresh", "scope": "public", "created_at": 1772000000 } diff --git a/plugins/trakt/test/fixtures/watchlist.json b/plugins/trakt/test/fixtures/watchlist.json new file mode 100644 index 0000000..60b9fb9 --- /dev/null +++ b/plugins/trakt/test/fixtures/watchlist.json @@ -0,0 +1,6 @@ +[ + { + "rank": 1, "id": 333, "listed_at": "2024-02-01T08:00:00.000Z", "notes": null, "type": "movie", + "movie": { "title": "Dune: Part Two", "year": 2024, "ids": { "trakt": 1004, "slug": "dune-part-two-2024", "imdb": "tt15239678", "tmdb": 693134 } } + } +] diff --git a/plugins/trakt/test/trakt.test.ts b/plugins/trakt/test/trakt.test.ts new file mode 100644 index 0000000..accfd1a --- /dev/null +++ b/plugins/trakt/test/trakt.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from "bun:test"; +import { collect, createFixtureHttp, createTestContext } from "@sttracker/plugin-sdk/testing"; +import type { PluginContext } from "@sttracker/plugin-sdk/types"; +import plugin from "../index.ts"; +import history1 from "./fixtures/history-page1.json"; +import history2 from "./fixtures/history-page2.json"; +import ratings from "./fixtures/ratings.json"; +import watchlist from "./fixtures/watchlist.json"; +import lists from "./fixtures/lists.json"; +import listItems from "./fixtures/list-items.json"; +import lastActivities from "./fixtures/last-activities.json"; +import tokenRefresh from "./fixtures/token-refresh.json"; +import deviceCode from "./fixtures/device-code.json"; +import search from "./fixtures/search.json"; + +const B = "https://api.trakt.tv"; +const NOW = new Date("2024-04-01T00:00:00.000Z"); +const FRESH = { accessToken: "old-access", refreshToken: "old-refresh", expiresAt: "2024-06-01T00:00:00.000Z" }; + +const paged = (req: Request) => { + const page = new URL(req.url).searchParams.get("page"); + return Response.json(page === "2" ? history2 : history1, { headers: { "X-Pagination-Page-Count": "2" } }); +}; + +function setup(routes: Record, creds: Record | null = FRESH, cursor?: string) { + const fx = createFixtureHttp(routes); + const ctx = createTestContext({ fetch: fx.fetch, now: () => NOW, config: { clientId: "cid", clientSecret: "csec" } }); + return (async () => { + if (creds) await ctx.credentials.save(creds); + ctx.saved.credentials.length = 0; + if (cursor) await ctx.cursor.set(cursor); + ctx.saved.cursor.length = 0; + return { fx, ctx }; + })(); +} + +const fullRoutes = { + [`GET ${B}/sync/history`]: paged, + [`GET ${B}/sync/ratings`]: ratings, + [`GET ${B}/sync/watchlist`]: watchlist, + [`GET ${B}/users/me/lists`]: lists, + [`GET ${B}/users/me/lists/7777/items`]: listItems, +}; + +describe("trakt pull", () => { + test("history pages through X-Pagination-Page-Count", async () => { + const { fx, ctx } = await setup(fullRoutes); + const recs = await collect(plugin.pull!(ctx)); + const hist = recs.filter((r) => r.key.startsWith("history:")); + expect(hist.map((r) => r.key)).toEqual(["history:9001", "history:9002", "history:9003"]); + const pages = fx.calls.filter((c) => c.url.includes("/sync/history")).map((c) => new URL(c.url).searchParams.get("page")); + expect(pages).toEqual(["1", "2"]); + expect(new URL(fx.calls[0]!.url).searchParams.get("limit")).toBe("250"); + expect(fx.calls[0]!.headers.get("trakt-api-version")).toBe("2"); + expect(fx.calls[0]!.headers.get("trakt-api-key")).toBe("cid"); + expect(fx.calls[0]!.headers.get("authorization")).toBe("Bearer old-access"); + }); + + test("movie history: day precision, full datetime kept, ids", async () => { + const { ctx } = await setup(fullRoutes); + const r = (await collect(plugin.pull!(ctx))).find((x) => x.key === "history:9001")!; + if (r.kind !== "engagement") throw new Error("kind"); + expect(r.work.mediaType).toBe("movie"); + expect(r.work.externalIds).toEqual({ trakt_movie: "1001", tmdb_movie: "329865", imdb: "tt2543164" }); + expect(r.engagement.finishedAt).toEqual({ value: "2024-03-05T20:15:00.000Z", precision: "day" }); + }); + + test("episode history: episode -> season -> show chain with position", async () => { + const { ctx } = await setup(fullRoutes); + const r = (await collect(plugin.pull!(ctx))).find((x) => x.key === "history:9002")!; + if (r.kind !== "engagement") throw new Error("kind"); + expect(r.work.mediaType).toBe("episode"); + expect(r.work.position).toEqual({ season: 2, episode: 3 }); + expect(r.work.externalIds).toEqual({ trakt_episode: "5003", tvdb_episode: "7003", tmdb_episode: "8003", imdb: "tt0000003" }); + expect(r.work.parent?.mediaType).toBe("season"); + expect(r.work.parent?.position).toEqual({ season: 2 }); + const show = r.work.parent?.parent; + expect(show?.mediaType).toBe("show"); + expect(show?.externalIds).toEqual({ trakt_show: "2002", tmdb_show: "80002", tvdb_show: "70002", imdb: "tt7000002" }); + }); + + test("rating 8 becomes 80", async () => { + const { ctx } = await setup(fullRoutes); + const recs = await collect(plugin.pull!(ctx)); + const m = recs.find((x) => x.key === "rating:movie:1001")!; + const s = recs.find((x) => x.key === "rating:show:2002")!; + if (m.kind !== "entry" || s.kind !== "entry") throw new Error("kind"); + expect(m.entry.rating).toBe(80); + expect(s.entry.rating).toBe(100); + }); + + test("watchlist becomes planned", async () => { + const { ctx } = await setup(fullRoutes); + const r = (await collect(plugin.pull!(ctx))).find((x) => x.key === "watchlist:movie:1004")!; + if (r.kind !== "entry") throw new Error("kind"); + expect(r.entry.status).toBe("planned"); + expect(r.work.title).toBe("Dune: Part Two"); + }); + + test("custom lists with items and notes", async () => { + const { ctx } = await setup(fullRoutes); + const r = (await collect(plugin.pull!(ctx))).find((x) => x.key === "list:7777")!; + if (r.kind !== "list") throw new Error("kind"); + expect(r.list.name).toBe("Favourites"); + expect(r.list.items.map((i) => i.work.title)).toEqual(["Heat", "Great Show"]); + expect(r.list.items[0]!.note).toBe("rewatch yearly"); + }); + + test("a token near expiry is refreshed and saved before the first /sync call", async () => { + let savedWhenSyncCalled = -1; + const routes = { + ...fullRoutes, + [`POST ${B}/oauth/token`]: () => tokenRefresh, + [`GET ${B}/sync/history`]: (req: Request) => { + if (savedWhenSyncCalled < 0) savedWhenSyncCalled = ctxRef.saved.credentials.length; + return paged(req); + }, + }; + const { fx, ctx } = await setup(routes, { ...FRESH, expiresAt: "2024-04-01T12:00:00.000Z" }); + ctxRef = ctx; + await collect(plugin.pull!(ctx)); + expect(fx.calls[0]!.method).toBe("POST"); + expect(await fx.calls[0]!.json()).toEqual({ + refresh_token: "old-refresh", client_id: "cid", client_secret: "csec", + redirect_uri: "urn:ietf:wg:oauth:2.0:oob", grant_type: "refresh_token", + }); + expect(new URL(fx.calls[0]!.url).pathname).toBe("/oauth/token"); + expect(new URL(fx.calls[1]!.url).pathname).toBe("/sync/history"); + expect(savedWhenSyncCalled).toBe(1); + expect(ctx.saved.credentials).toEqual([ + { accessToken: "new-access", refreshToken: "new-refresh", expiresAt: new Date((1772000000 + 7776000) * 1000).toISOString() }, + ]); + expect(fx.calls[1]!.headers.get("authorization")).toBe("Bearer new-access"); + }); + + test("a failed refresh saves nothing and never calls /sync", async () => { + const { fx, ctx } = await setup( + { ...fullRoutes, [`POST ${B}/oauth/token`]: () => new Response("{}", { status: 400 }) }, + { ...FRESH, expiresAt: "2024-04-01T12:00:00.000Z" }, + ); + await expect(collect(plugin.pull!(ctx))).rejects.toThrow("400"); + expect(ctx.saved.credentials).toEqual([]); + expect(fx.calls.map((c) => new URL(c.url).pathname)).toEqual(["/oauth/token"]); + }); + + test("a token with more than 24h left is not refreshed", async () => { + const { fx, ctx } = await setup(fullRoutes); + await collect(plugin.pull!(ctx)); + expect(fx.calls.some((c) => c.url.includes("/oauth/token"))).toBe(false); + expect(ctx.saved.credentials).toEqual([]); + }); +}); + +let ctxRef: ReturnType; + +describe("trakt pullChanges", () => { + const cursorOf = (o: object) => JSON.stringify(o); + const same = { + history: "2024-03-06T21:30:00.000Z", + ratings: "2024-03-07T10:00:00.000Z", + watchlist: "2024-02-01T08:00:00.000Z", + lists: "2024-01-02T00:00:00.000Z", + }; + const routes = { ...fullRoutes, [`GET ${B}/sync/last_activities`]: lastActivities }; + + test("unchanged last_activities yields nothing and fetches nothing else", async () => { + const { fx, ctx } = await setup(routes, FRESH, cursorOf(same)); + expect(await collect(plugin.pullChanges!(ctx))).toEqual([]); + expect(fx.calls.map((c) => new URL(c.url).pathname)).toEqual(["/sync/last_activities"]); + expect(ctx.saved.cursor).toEqual([]); + }); + + test("only changed categories are re-fetched; history uses start_at; cursor advances", async () => { + const prev = { ...same, history: "2024-03-01T00:00:00.000Z", watchlist: "2024-01-01T00:00:00.000Z" }; + const { fx, ctx } = await setup(routes, FRESH, cursorOf(prev)); + const recs = await collect(plugin.pullChanges!(ctx)); + expect(recs.some((r) => r.key.startsWith("history:"))).toBe(true); + expect(recs.some((r) => r.key.startsWith("watchlist:"))).toBe(true); + expect(recs.some((r) => r.key.startsWith("rating:"))).toBe(false); + const h = fx.calls.find((c) => c.url.includes("/sync/history"))!; + expect(new URL(h.url).searchParams.get("start_at")).toBe("2024-03-01T00:00:00.000Z"); + expect(fx.calls.some((c) => c.url.includes("/sync/ratings"))).toBe(false); + expect(ctx.saved.cursor).toEqual([cursorOf(same)]); + }); + + test("no cursor: full pull and cursor set", async () => { + const { ctx } = await setup(routes); + const recs = await collect(plugin.pullChanges!(ctx)); + expect(recs.length).toBeGreaterThan(5); + expect(ctx.saved.cursor).toEqual([cursorOf(same)]); + }); +}); + +describe("trakt auth and lookup", () => { + test("manifest", () => { + expect(plugin.manifest.id).toBe("trakt"); + expect(plugin.manifest.capabilities).toEqual(["pull", "pullChanges", "lookup"]); + expect(plugin.manifest.attribution).toBe("STTracker uses Trakt but is not endorsed or certified by Trakt."); + }); + + test("device auth start and poll", async () => { + let n = 0; + const { ctx } = await setup({ + [`POST ${B}/oauth/device/code`]: deviceCode, + [`POST ${B}/oauth/device/token`]: () => (n++ === 0 ? new Response("", { status: 400 }) : tokenRefresh), + }, null); + const start = await plugin.deviceAuth!.start(ctx); + expect(start).toEqual({ userCode: "ABCD1234", verificationUrl: "https://trakt.tv/activate", deviceCode: "dev123", intervalSec: 5, expiresInSec: 600 }); + expect(await plugin.deviceAuth!.poll(ctx, "dev123")).toBe("pending"); + expect(await plugin.deviceAuth!.poll(ctx, "dev123")).toEqual({ + accessToken: "new-access", refreshToken: "new-refresh", expiresAt: new Date((1772000000 + 7776000) * 1000).toISOString(), + }); + }); + + test("device auth poll throws on terminal errors", async () => { + const { ctx } = await setup({ [`POST ${B}/oauth/device/token`]: () => new Response("", { status: 418 }) }, null); + await expect(plugin.deviceAuth!.poll(ctx as PluginContext, "x")).rejects.toThrow("418"); + }); + + test("lookup searches movie,show", async () => { + const { fx, ctx } = await setup({ [`GET ${B}/search/movie,show`]: search }, null); + const hints = await plugin.lookup!(ctx, { title: "Arrival" }); + expect(new URL(fx.calls[0]!.url).searchParams.get("query")).toBe("Arrival"); + expect(hints.map((h) => [h.mediaType, h.title])).toEqual([["movie", "Arrival"], ["show", "Arrival Show"]]); + }); +});