diff --git a/apps/server/src/orpc/procedures/imports.ts b/apps/server/src/orpc/procedures/imports.ts index d5236e7..b9d1537 100644 --- a/apps/server/src/orpc/procedures/imports.ts +++ b/apps/server/src/orpc/procedures/imports.ts @@ -8,6 +8,7 @@ import { parseLetterboxdExport, parseSimklPayload, parseSofaExport, + parseTraktExport, parseTraktPayload, processImportJob, readImportJob, @@ -32,16 +33,14 @@ export const parseFile = os.imports.parseFile.use(authed).handler(async ({ input result = await parseLetterboxdExport(file); break; case "trakt": { - let json: unknown; try { - json = await file.json(); + result = await parseTraktExport(file); } catch { throw new ORPCError("BAD_REQUEST", { message: "Invalid JSON file", data: { code: AppErrorCode.IMPORT_INVALID_FILE }, }); } - result = parseTraktPayload(json as Parameters[0]); break; } case "simkl": { diff --git a/apps/web/src/components/settings/imports-section.tsx b/apps/web/src/components/settings/imports-section.tsx index 02e1f49..9e1886d 100644 --- a/apps/web/src/components/settings/imports-section.tsx +++ b/apps/web/src/components/settings/imports-section.tsx @@ -99,8 +99,8 @@ const SOURCES: SourceConfig[] = [ { source: "trakt", label: "Trakt", - description: "Connect your Trakt account or upload a JSON export", - accept: ".json", + description: "Connect your Trakt account or upload your Trakt export (ZIP or JSON)", + accept: ".json,.zip", icon: , supportsOAuth: true, }, diff --git a/packages/core/src/imports/index.ts b/packages/core/src/imports/index.ts index 19ef25d..39c41a6 100644 --- a/packages/core/src/imports/index.ts +++ b/packages/core/src/imports/index.ts @@ -10,6 +10,7 @@ export { type ParseResult, parseLetterboxdExport, parseSimklPayload, + parseTraktExport, parseTraktPayload, } from "./parsers"; export { parseSofaExport } from "./sofa-parser"; diff --git a/packages/core/src/imports/parsers.ts b/packages/core/src/imports/parsers.ts index 6f20805..3c957c3 100644 --- a/packages/core/src/imports/parsers.ts +++ b/packages/core/src/imports/parsers.ts @@ -385,6 +385,96 @@ export function parseTraktPayload(data: { return finalizeParseResult(normalized, warnings, unsupported); } +interface TraktAggregate { + history: { movies: TraktHistoryMovie[]; shows: TraktHistoryEpisode[] }; + watchlist: TraktWatchlistItem[]; + ratings: TraktRatingItem[]; +} + +function pushArray(target: T[], value: unknown): void { + if (Array.isArray(value)) target.push(...(value as T[])); +} + +/** Sort one JSON document's Trakt items into the aggregate parseTraktPayload expects. */ +function addTraktDocument(agg: TraktAggregate, json: unknown): void { + if (json && typeof json === "object" && !Array.isArray(json)) { + // Already-aggregated format: { history: { movies, shows }, watchlist, ratings } + const obj = json as Record; + const history = obj.history; + if (history && typeof history === "object" && !Array.isArray(history)) { + const h = history as Record; + pushArray(agg.history.movies, h.movies); + pushArray(agg.history.shows, h.shows); + } + pushArray(agg.watchlist, obj.watchlist); + pushArray(agg.ratings, obj.ratings); + return; + } + if (!Array.isArray(json)) return; + for (const item of json as Record[]) { + if (!item || typeof item !== "object") continue; + if ("watched_at" in item) { + if (item.episode && item.show) agg.history.shows.push(item as TraktHistoryEpisode); + else if (item.movie) agg.history.movies.push(item as TraktHistoryMovie); + } else if ("rating" in item) { + agg.ratings.push(item as TraktRatingItem); + } else if ("listed_at" in item) { + agg.watchlist.push(item as TraktWatchlistItem); + } + } +} + +/** + * Parse an uploaded Trakt export: Trakt's official ZIP (watched-history-*.json, + * ratings-*.json, lists-watchlist.json, …), any single JSON file from it, or the + * aggregated { history, watchlist, ratings } JSON. Items are classified by their + * fields rather than file names. + */ +export async function parseTraktExport(file: Blob): Promise { + const agg: TraktAggregate = { history: { movies: [], shows: [] }, watchlist: [], ratings: [] }; + const buf = Buffer.from(await file.arrayBuffer()); + + if (buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04) { + const AdmZip = (await import("adm-zip")).default; + let entries: ReturnType["getEntries"]>; + try { + entries = new AdmZip(buf).getEntries(); + } catch { + throw new Error("Invalid export file"); + } + for (const entry of entries) { + if (entry.isDirectory) continue; + const name = entry.entryName.split("/").pop() ?? entry.entryName; + if (!name.toLowerCase().endsWith(".json")) continue; + try { + addTraktDocument(agg, JSON.parse(entry.getData().toString("utf-8"))); + } catch { + log.debug(`Skipping unreadable Trakt export entry: ${entry.entryName}`); + } + } + } else { + let json: unknown; + try { + json = JSON.parse(buf.toString("utf8")); + } catch { + throw new Error("Invalid JSON file"); + } + addTraktDocument(agg, json); + } + + const empty = + agg.history.movies.length === 0 && + agg.history.shows.length === 0 && + agg.watchlist.length === 0 && + agg.ratings.length === 0; + + const result = parseTraktPayload(agg); + if (empty) { + result.warnings.push("No Trakt history, ratings or watchlist items were found in this file."); + } + return result; +} + // ─── Simkl Parser ─────────────────────────────────────────────────── interface SimklIds { diff --git a/packages/core/test/import-parsers.test.ts b/packages/core/test/import-parsers.test.ts index 9f05814..612e3fa 100644 --- a/packages/core/test/import-parsers.test.ts +++ b/packages/core/test/import-parsers.test.ts @@ -7,6 +7,7 @@ import { type ParseResult, parseLetterboxdExport, parseSimklPayload, + parseTraktExport, parseTraktPayload, } from "../src/imports/parsers"; @@ -1255,3 +1256,119 @@ describe("parser output always matches the API schema", () => { expect(result.warnings.some((w) => w.includes("50,000"))).toBe(true); }); }); + +// ─── parseTraktExport ──────────────────────────────────────────────── + +function createZip(files: Record): Blob { + const zip = new AdmZip(); + for (const [name, content] of Object.entries(files)) { + zip.addFile(name, Buffer.from(content, "utf-8")); + } + return new Blob([zip.toBuffer()], { type: "application/zip" }); +} + +function jsonBlob(value: unknown): Blob { + return new Blob([JSON.stringify(value)], { type: "application/json" }); +} + +describe("parseTraktExport", () => { + const historyItems = [ + { + watched_at: "2024-01-15T20:00:00.000Z", + type: "movie", + movie: { title: "Inception", year: 2010, ids: { tmdb: 27205 } }, + }, + { + watched_at: "2024-01-16T20:00:00.000Z", + type: "episode", + show: { title: "Lost", year: 2004, ids: { tmdb: 4607 } }, + episode: { season: 1, number: 2 }, + }, + ]; + const ratingItems = [ + { + rated_at: "2024-01-01T00:00:00.000Z", + rating: 8, + type: "movie", + movie: { title: "M", ids: { tmdb: 1 } }, + }, + ]; + const watchlistItems = [ + { + listed_at: "2024-01-01T00:00:00.000Z", + type: "show", + show: { title: "S", ids: { tmdb: 2 } }, + }, + ]; + + test("parses a watched-history file (array of mixed movie/episode plays)", async () => { + const result = await parseTraktExport(jsonBlob(historyItems)); + expect(result.data.source).toBe("trakt"); + expect(result.data.movies).toHaveLength(1); + expect(result.data.episodes).toHaveLength(1); + }); + + test("parses a ratings file", async () => { + const result = await parseTraktExport(jsonBlob(ratingItems)); + expect(result.data.ratings).toHaveLength(1); + }); + + test("parses a watchlist file", async () => { + const result = await parseTraktExport(jsonBlob(watchlistItems)); + expect(result.data.watchlist).toHaveLength(1); + expect(result.data.watchlist[0]?.type).toBe("tv"); + }); + + test("parses the official ZIP", async () => { + const zip = createZip({ + "trakt-export/watched-history-1.json": JSON.stringify(historyItems), + "trakt-export/watched-history-2.json": JSON.stringify([ + { + watched_at: "2024-03-01T20:00:00.000Z", + type: "movie", + movie: { title: "Heat", year: 1995, ids: { tmdb: 949 } }, + }, + ]), + "trakt-export/ratings-movies.json": JSON.stringify(ratingItems), + "trakt-export/lists-watchlist.json": JSON.stringify(watchlistItems), + "trakt-export/user-profile.json": "{}", + }); + const result = await parseTraktExport(zip); + expect(result.data.movies).toHaveLength(2); + expect(result.data.episodes).toHaveLength(1); + expect(result.data.ratings).toHaveLength(1); + expect(result.data.watchlist).toHaveLength(1); + }); + + test("still accepts the aggregated format", async () => { + const result = await parseTraktExport( + jsonBlob({ + history: { movies: [historyItems[0]], shows: [] }, + watchlist: [], + ratings: [], + }), + ); + expect(result.data.movies).toHaveLength(1); + }); + + test("reports unrecognized files instead of silently returning nothing", async () => { + const result = await parseTraktExport(jsonBlob({ foo: 1 })); + expect(result.data.movies).toHaveLength(0); + expect(result.data.episodes).toHaveLength(0); + expect(result.data.watchlist).toHaveLength(0); + expect(result.data.ratings).toHaveLength(0); + expect(result.warnings.some((w) => w.includes("No Trakt history, ratings or watchlist"))).toBe( + true, + ); + }); + + test("skips an unparsable JSON entry inside the ZIP", async () => { + const zip = createZip({ + "watched-history-1.json": JSON.stringify(historyItems), + "broken.json": "{not json", + }); + const result = await parseTraktExport(zip); + expect(result.data.movies).toHaveLength(1); + expect(result.data.episodes).toHaveLength(1); + }); +});