fix(imports): accept Trakt's official export ZIP and JSON files

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-04 15:39:15 -04:00
1 parent 9b1a8b07a6
commit 2767b495e3
5 files changed
+212 -5

No files matched your search

+2 -3
View File
@@ -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<typeof parseTraktPayload>[0]);
break;
}
case "simkl": {
@@ -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: <TraktLogo className="text-primary size-4" />,
supportsOAuth: true,
},
+1
View File
@@ -10,6 +10,7 @@ export {
type ParseResult,
parseLetterboxdExport,
parseSimklPayload,
parseTraktExport,
parseTraktPayload,
} from "./parsers";
export { parseSofaExport } from "./sofa-parser";
+90
View File
@@ -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<T>(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<string, unknown>;
const history = obj.history;
if (history && typeof history === "object" && !Array.isArray(history)) {
const h = history as Record<string, unknown>;
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<string, unknown>[]) {
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<ParseResult> {
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<InstanceType<typeof AdmZip>["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 {
+117
View File
@@ -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<string, string>): 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);
});
});