mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 01:05:42 -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,
|
parseLetterboxdExport,
|
||||||
parseSimklPayload,
|
parseSimklPayload,
|
||||||
parseSofaExport,
|
parseSofaExport,
|
||||||
|
parseTraktExport,
|
||||||
parseTraktPayload,
|
parseTraktPayload,
|
||||||
processImportJob,
|
processImportJob,
|
||||||
readImportJob,
|
readImportJob,
|
||||||
@@ -32,16 +33,14 @@ export const parseFile = os.imports.parseFile.use(authed).handler(async ({ input
|
|||||||
result = await parseLetterboxdExport(file);
|
result = await parseLetterboxdExport(file);
|
||||||
break;
|
break;
|
||||||
case "trakt": {
|
case "trakt": {
|
||||||
let json: unknown;
|
|
||||||
try {
|
try {
|
||||||
json = await file.json();
|
result = await parseTraktExport(file);
|
||||||
} catch {
|
} catch {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message: "Invalid JSON file",
|
message: "Invalid JSON file",
|
||||||
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
|
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
result = parseTraktPayload(json as Parameters<typeof parseTraktPayload>[0]);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "simkl": {
|
case "simkl": {
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ const SOURCES: SourceConfig[] = [
|
|||||||
{
|
{
|
||||||
source: "trakt",
|
source: "trakt",
|
||||||
label: "Trakt",
|
label: "Trakt",
|
||||||
description: "Connect your Trakt account or upload a JSON export",
|
description: "Connect your Trakt account or upload your Trakt export (ZIP or JSON)",
|
||||||
accept: ".json",
|
accept: ".json,.zip",
|
||||||
icon: <TraktLogo className="text-primary size-4" />,
|
icon: <TraktLogo className="text-primary size-4" />,
|
||||||
supportsOAuth: true,
|
supportsOAuth: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export {
|
|||||||
type ParseResult,
|
type ParseResult,
|
||||||
parseLetterboxdExport,
|
parseLetterboxdExport,
|
||||||
parseSimklPayload,
|
parseSimklPayload,
|
||||||
|
parseTraktExport,
|
||||||
parseTraktPayload,
|
parseTraktPayload,
|
||||||
} from "./parsers";
|
} from "./parsers";
|
||||||
export { parseSofaExport } from "./sofa-parser";
|
export { parseSofaExport } from "./sofa-parser";
|
||||||
|
|||||||
@@ -385,6 +385,96 @@ export function parseTraktPayload(data: {
|
|||||||
return finalizeParseResult(normalized, warnings, unsupported);
|
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 ───────────────────────────────────────────────────
|
// ─── Simkl Parser ───────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SimklIds {
|
interface SimklIds {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type ParseResult,
|
type ParseResult,
|
||||||
parseLetterboxdExport,
|
parseLetterboxdExport,
|
||||||
parseSimklPayload,
|
parseSimklPayload,
|
||||||
|
parseTraktExport,
|
||||||
parseTraktPayload,
|
parseTraktPayload,
|
||||||
} from "../src/imports/parsers";
|
} 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);
|
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