mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 01:05:42 -04:00
fix(imports): normalize nulls and drop invalid items instead of failing the parse
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
3 files changed
+187
-23
No files matched your search
@@ -1,3 +1,11 @@
|
|||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ImportEpisodeSchema,
|
||||||
|
ImportMovieSchema,
|
||||||
|
ImportRatingSchema,
|
||||||
|
ImportWatchlistItemSchema,
|
||||||
|
} from "@sofa/api/schemas";
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
|
|
||||||
const log = createLogger("imports");
|
const log = createLogger("imports");
|
||||||
@@ -89,6 +97,76 @@ export function countUnresolved(data: NormalizedImport): number {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mirrors the `.max()` on each list in NormalizedImportSchema. */
|
||||||
|
const MAX_ITEMS_PER_LIST = 50_000;
|
||||||
|
|
||||||
|
/** Turn `null` / `NaN` field values into `undefined` (the API schemas allow optional, not null). */
|
||||||
|
function withoutNulls<T extends object>(item: T): T {
|
||||||
|
const clean: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(item)) {
|
||||||
|
if (value === null || (typeof value === "number" && Number.isNaN(value))) continue;
|
||||||
|
clean[key] = value;
|
||||||
|
}
|
||||||
|
return clean as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make parser output conform to NormalizedImportSchema. Real exports contain
|
||||||
|
* nulls (Trakt sends `"imdb": null`, `"year": null`) and occasional invalid
|
||||||
|
* entries (e.g. episode number 0); one bad item must not fail the whole parse,
|
||||||
|
* so invalid items are dropped and summarized in a warning.
|
||||||
|
*/
|
||||||
|
export function finalizeParseResult(
|
||||||
|
data: NormalizedImport,
|
||||||
|
warnings: string[],
|
||||||
|
unsupported = 0,
|
||||||
|
): ParseResult {
|
||||||
|
let dropped = 0;
|
||||||
|
|
||||||
|
function clean<T extends object>(list: string, items: T[], schema: z.ZodType<T>): T[] {
|
||||||
|
const kept: T[] = [];
|
||||||
|
const examples: string[] = [];
|
||||||
|
let invalid = 0;
|
||||||
|
for (const raw of items) {
|
||||||
|
const result = schema.safeParse(withoutNulls(raw));
|
||||||
|
if (result.success) {
|
||||||
|
kept.push(result.data);
|
||||||
|
} else {
|
||||||
|
invalid++;
|
||||||
|
if (examples.length < 3) {
|
||||||
|
const issue = result.error.issues[0];
|
||||||
|
examples.push(`${issue?.path.join(".") || "item"}: ${issue?.message ?? "invalid"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (invalid > 0) {
|
||||||
|
dropped += invalid;
|
||||||
|
warnings.push(`Skipped ${invalid} invalid ${list} (${examples.join("; ")})`);
|
||||||
|
}
|
||||||
|
if (kept.length > MAX_ITEMS_PER_LIST) {
|
||||||
|
warnings.push(
|
||||||
|
`Only the first ${MAX_ITEMS_PER_LIST.toLocaleString("en-US")} ${list} were included (${kept.length.toLocaleString("en-US")} found)`,
|
||||||
|
);
|
||||||
|
return kept.slice(0, MAX_ITEMS_PER_LIST);
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: NormalizedImport = {
|
||||||
|
source: data.source,
|
||||||
|
movies: clean("movies", data.movies, ImportMovieSchema),
|
||||||
|
episodes: clean("episodes", data.episodes, ImportEpisodeSchema),
|
||||||
|
watchlist: clean("watchlist items", data.watchlist, ImportWatchlistItemSchema),
|
||||||
|
ratings: clean("ratings", data.ratings, ImportRatingSchema),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: normalized,
|
||||||
|
warnings,
|
||||||
|
diagnostics: { unresolved: countUnresolved(normalized), unsupported: unsupported + dropped },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Rating Conversion ──────────────────────────────────────────────
|
// ─── Rating Conversion ──────────────────────────────────────────────
|
||||||
|
|
||||||
/** Convert a 1-10 rating to Sofa's 1-5 integer scale. */
|
/** Convert a 1-10 rating to Sofa's 1-5 integer scale. */
|
||||||
@@ -304,11 +382,7 @@ export function parseTraktPayload(data: {
|
|||||||
ratings,
|
ratings,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return finalizeParseResult(normalized, warnings, unsupported);
|
||||||
data: normalized,
|
|
||||||
warnings,
|
|
||||||
diagnostics: { unresolved: countUnresolved(normalized), unsupported },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Simkl Parser ───────────────────────────────────────────────────
|
// ─── Simkl Parser ───────────────────────────────────────────────────
|
||||||
@@ -500,11 +574,7 @@ export function parseSimklPayload(data: {
|
|||||||
ratings,
|
ratings,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return finalizeParseResult(normalized, warnings);
|
||||||
data: normalized,
|
|
||||||
warnings,
|
|
||||||
diagnostics: { unresolved: countUnresolved(normalized), unsupported: 0 },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Letterboxd Parser ──────────────────────────────────────────────
|
// ─── Letterboxd Parser ──────────────────────────────────────────────
|
||||||
@@ -668,12 +738,9 @@ export async function parseLetterboxdExport(zipFile: Blob): Promise<ParseResult>
|
|||||||
`Parsed Letterboxd export: ${movies.length} movies, ${watchlist.length} watchlist, ${ratings.length} ratings`,
|
`Parsed Letterboxd export: ${movies.length} movies, ${watchlist.length} watchlist, ${ratings.length} ratings`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Letterboxd has no IDs — all items need title-based resolution
|
// Letterboxd has no IDs — all items need title-based resolution (counted by the finalizer)
|
||||||
const unresolved = movies.length + watchlist.length + ratings.length;
|
return finalizeParseResult(
|
||||||
|
{ source: "letterboxd", movies, episodes: [], watchlist, ratings },
|
||||||
return {
|
|
||||||
data: { source: "letterboxd", movies, episodes: [], watchlist, ratings },
|
|
||||||
warnings,
|
warnings,
|
||||||
diagnostics: { unresolved, unsupported: 0 },
|
);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import { SofaExportSchema } from "@sofa/api/schemas";
|
|||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
|
|
||||||
import type { NormalizedImport, ParseResult } from "./parsers";
|
import type { NormalizedImport, ParseResult } from "./parsers";
|
||||||
import { countUnresolved } from "./parsers";
|
import { finalizeParseResult } from "./parsers";
|
||||||
|
|
||||||
const log = createLogger("imports");
|
const log = createLogger("imports");
|
||||||
|
|
||||||
@@ -59,9 +59,5 @@ export function parseSofaExport(data: unknown): ParseResult {
|
|||||||
`Parsed Sofa export: ${normalized.movies.length} movies, ${normalized.episodes.length} episodes, ${normalized.watchlist.length} library, ${normalized.ratings.length} ratings`,
|
`Parsed Sofa export: ${normalized.movies.length} movies, ${normalized.episodes.length} episodes, ${normalized.watchlist.length} library, ${normalized.ratings.length} ratings`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return finalizeParseResult(normalized, warnings);
|
||||||
data: normalized,
|
|
||||||
warnings,
|
|
||||||
diagnostics: { unresolved: countUnresolved(normalized), unsupported: 0 },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { NormalizedImportSchema } from "@sofa/api/schemas";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ParseResult,
|
type ParseResult,
|
||||||
parseLetterboxdExport,
|
parseLetterboxdExport,
|
||||||
@@ -1072,3 +1074,102 @@ describe("parseLetterboxdExport", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Output always matches the API schema ────────────────────────────
|
||||||
|
|
||||||
|
describe("parser output always matches the API schema", () => {
|
||||||
|
test("Trakt nulls become undefined", () => {
|
||||||
|
const result = parseTraktPayload({
|
||||||
|
history: {
|
||||||
|
movies: [
|
||||||
|
{
|
||||||
|
watched_at: "2024-01-15T20:00:00.000Z",
|
||||||
|
movie: { title: "No IDs", year: null, ids: { trakt: 1, tmdb: null, imdb: null } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shows: [
|
||||||
|
{
|
||||||
|
watched_at: "2024-01-15T21:00:00.000Z",
|
||||||
|
show: { title: "Show", year: null, ids: { tmdb: 1396, imdb: null, tvdb: null } },
|
||||||
|
episode: { season: 1, number: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
watchlist: [
|
||||||
|
{ type: "movie", movie: { title: "W", year: null, ids: { tmdb: 5, imdb: null } } },
|
||||||
|
],
|
||||||
|
ratings: [
|
||||||
|
{
|
||||||
|
type: "movie",
|
||||||
|
rating: 8,
|
||||||
|
rated_at: null,
|
||||||
|
movie: { title: "R", year: 2000, ids: { tmdb: 6, imdb: null } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(NormalizedImportSchema.safeParse(result.data).success).toBe(true);
|
||||||
|
expect(result.data.movies).toHaveLength(1);
|
||||||
|
expect(result.data.episodes).toHaveLength(1);
|
||||||
|
expect(result.data.watchlist).toHaveLength(1);
|
||||||
|
expect(result.data.ratings).toHaveLength(1);
|
||||||
|
expect(result.data.movies[0].imdbId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid items are dropped with a warning, not fatal", () => {
|
||||||
|
const result = parseTraktPayload({
|
||||||
|
history: {
|
||||||
|
shows: [
|
||||||
|
{
|
||||||
|
watched_at: "2024-01-15T21:00:00.000Z",
|
||||||
|
show: { title: "Show", ids: { tmdb: 1396 } },
|
||||||
|
episode: { season: 0, number: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
watched_at: "2024-01-15T21:00:00.000Z",
|
||||||
|
show: { title: "Show", ids: { tmdb: 1396 } },
|
||||||
|
episode: { season: 1, number: 2 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.data.episodes).toHaveLength(1);
|
||||||
|
expect(NormalizedImportSchema.safeParse(result.data).success).toBe(true);
|
||||||
|
expect(result.warnings.some((w) => w.includes("episodes"))).toBe(true);
|
||||||
|
expect(result.diagnostics?.unsupported).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Simkl nulls become undefined", () => {
|
||||||
|
const result = parseSimklPayload({
|
||||||
|
movies: [
|
||||||
|
{
|
||||||
|
title: "M",
|
||||||
|
year: null,
|
||||||
|
status: "completed",
|
||||||
|
last_watched_at: null,
|
||||||
|
ids: { tmdb: 7, imdb: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(result.data.movies).toHaveLength(1);
|
||||||
|
expect(NormalizedImportSchema.safeParse(result.data).success).toBe(true);
|
||||||
|
expect(result.data.movies[0].watchedAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("oversized lists are truncated with a warning", () => {
|
||||||
|
const result = parseTraktPayload({
|
||||||
|
history: {
|
||||||
|
movies: Array.from({ length: 50_001 }, (_, i) => ({
|
||||||
|
watched_at: "2024-01-15T20:00:00.000Z",
|
||||||
|
movie: { title: `M${i}`, ids: { tmdb: i + 1 } },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.data.movies).toHaveLength(50_000);
|
||||||
|
expect(NormalizedImportSchema.safeParse(result.data).success).toBe(true);
|
||||||
|
expect(result.warnings.some((w) => w.includes("50,000"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user