feat(plugins): trakt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Sonnet 5.5 committed 2026-10-30 15:49:47 +01:00
1 parent 06a59e1b5c
commit 59ccc90bc1
15 files changed
+627

No files matched your search

+1
View File
@@ -0,0 +1 @@
{ "device_code": "dev123", "user_code": "ABCD1234", "verification_url": "https://trakt.tv/activate", "expires_in": 600, "interval": 5 }
+11
View File
@@ -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 } }
}
]
+6
View File
@@ -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 } }
}
]
+8
View File
@@ -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" }
}
+10
View File
@@ -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 } }
}
]
+3
View File
@@ -0,0 +1,3 @@
[
{ "name": "Favourites", "description": "All-time best", "privacy": "private", "item_count": 2, "ids": { "trakt": 7777, "slug": "favourites" } }
]
+10
View File
@@ -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 } }
}
]
+4
View File
@@ -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 } } }
]
+1
View File
@@ -0,0 +1 @@
{ "access_token": "new-access", "token_type": "bearer", "expires_in": 7776000, "refresh_token": "new-refresh", "scope": "public", "created_at": 1772000000 }
+6
View File
@@ -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 } }
}
]
+215
View File
@@ -0,0 +1,215 @@
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<string, unknown>, creds: Record<string, string> | 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: "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.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({ tmdb_show: "80002", tvdb: "70002", imdb: "tt7000002", trakt: "2002" });
});
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 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<typeof createTestContext>;
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"]]);
});
});