mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 23:55:43 -04:00
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:
5 files changed
+212
-5
No files matched your search
@@ -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,
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
type ParseResult,
|
||||
parseLetterboxdExport,
|
||||
parseSimklPayload,
|
||||
parseTraktExport,
|
||||
parseTraktPayload,
|
||||
} from "./parsers";
|
||||
export { parseSofaExport } from "./sofa-parser";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user