diff --git a/packages/core/src/imports/parsers.ts b/packages/core/src/imports/parsers.ts index 31eb4d2..6f20805 100644 --- a/packages/core/src/imports/parsers.ts +++ b/packages/core/src/imports/parsers.ts @@ -403,6 +403,9 @@ interface SimklItem { status?: string; // "completed", "watching", "plantowatch", "dropped", "hold" user_rating?: number; last_watched_at?: string; + added_to_watchlist_at?: string; + movie?: { title?: string; year?: number; ids?: SimklIds }; + show?: { title?: string; year?: number; ids?: SimklIds }; watched_episodes_count?: number; total_episodes_count?: number; seasons?: { @@ -426,6 +429,36 @@ function mapSimklStatus(status?: string): "watchlist" | "in_progress" | "complet } } +/** + * Simkl's API and its SimklBackup.json nest title metadata under `movie`/`show`; + * Sofa's public-api flattens it first. Accept both shapes. When episodes carry + * `watched_at`, only those are watched (the API lists unwatched ones too); + * when none do, every listed episode counts as watched. + */ +function normalizeSimklItem(item: SimklItem): SimklItem { + const media = item.movie ?? item.show; + const anyTimestamp = item.seasons?.some((s) => s.episodes?.some((ep) => ep.watched_at)); + const seasons = anyTimestamp + ? item.seasons + ?.map((s) => ({ ...s, episodes: s.episodes?.filter((ep) => ep.watched_at) })) + .filter((s) => (s.episodes?.length ?? 0) > 0) + : item.seasons; + return { + ...item, + title: item.title ?? media?.title, + year: item.year ?? media?.year, + ids: item.ids ?? media?.ids, + seasons, + }; +} + +/** ISO timestamp for the schema's `.datetime()`, or undefined if unparseable. */ +function simklAddedAt(value?: string): string | undefined { + if (!value) return undefined; + const ms = Date.parse(value); + return Number.isNaN(ms) ? undefined : new Date(ms).toISOString(); +} + export function parseSimklPayload(data: { movies?: SimklItem[]; shows?: SimklItem[]; @@ -438,7 +471,8 @@ export function parseSimklPayload(data: { const ratings: ImportRating[] = []; // Movies - for (const item of data.movies ?? []) { + for (const raw of data.movies ?? []) { + const item = normalizeSimklItem(raw); if (!item.title) continue; const tmdbId = typeof item.ids?.tmdb === "number" @@ -456,6 +490,7 @@ export function parseSimklPayload(data: { year: item.year, type: "movie", status: sofaStatus, + addedAt: simklAddedAt(item.added_to_watchlist_at), }); } @@ -491,7 +526,7 @@ export function parseSimklPayload(data: { } // Shows + Anime (both map to TV type) - const allShows = [...(data.shows ?? []), ...(data.anime ?? [])]; + const allShows = [...(data.shows ?? []), ...(data.anime ?? [])].map(normalizeSimklItem); for (const item of allShows) { if (!item.title) continue; const tmdbId = @@ -517,6 +552,7 @@ export function parseSimklPayload(data: { year: item.year, type: "tv", status: sofaStatus, + addedAt: simklAddedAt(item.added_to_watchlist_at), }); } diff --git a/packages/core/test/import-parsers.test.ts b/packages/core/test/import-parsers.test.ts index 6b42b2b..9f05814 100644 --- a/packages/core/test/import-parsers.test.ts +++ b/packages/core/test/import-parsers.test.ts @@ -729,6 +729,88 @@ describe("parseSimklPayload", () => { // movie watch + library item, both without IDs expect(result.diagnostics?.unresolved).toBe(2); }); + + test("parses nested backup items", () => { + const result = parseSimklPayload({ + movies: [ + { + status: "completed", + last_watched_at: "2024-01-15T20:00:00Z", + user_rating: 8, + movie: { title: "Inception", year: 2010, ids: { tmdb: 27205, imdb: "tt1375666" } }, + }, + ], + shows: [ + { + status: "watching", + show: { title: "Lost", year: 2004, ids: { tvdb: 73739 } }, + seasons: [ + { + number: 1, + episodes: [{ number: 1, watched_at: "2024-01-16T20:00:00Z" }, { number: 2 }], + }, + ], + }, + ], + anime: [{ status: "plantowatch", show: { title: "Frieren", ids: { tmdb: 209867 } } }], + } as never); + + expect(result.data.movies).toHaveLength(1); + expect(result.data.movies[0].title).toBe("Inception"); + expect(result.data.movies[0].tmdbId).toBe(27205); + expect(result.data.ratings.filter((r) => r.type === "movie")).toHaveLength(1); + expect(result.data.episodes).toHaveLength(1); + expect(result.data.episodes[0].seasonNumber).toBe(1); + expect(result.data.episodes[0].episodeNumber).toBe(1); + expect(result.data.watchlist.some((w) => w.type === "tv" && w.title === "Frieren")).toBe(true); + }); + + test("treats all listed episodes as watched when none carry watched_at", () => { + const result = parseSimklPayload({ + shows: [ + { + status: "watching", + show: { title: "Severance", ids: { tmdb: 95396 } }, + seasons: [{ number: 1, episodes: [{ number: 1 }, { number: 2 }] }], + }, + ], + } as never); + + expect(result.data.episodes).toHaveLength(2); + }); + + test("maps added_to_watchlist_at to addedAt", () => { + const result = parseSimklPayload({ + movies: [ + { + status: "plantowatch", + added_to_watchlist_at: "2023-05-01T10:00:00Z", + movie: { title: "Dune", year: 2021, ids: { tmdb: 438631 } }, + }, + ], + } as never); + + expect(result.data.watchlist).toHaveLength(1); + expect(new Date(result.data.watchlist[0].addedAt as string).toISOString()).toBe( + "2023-05-01T10:00:00.000Z", + ); + }); + + test("keeps a watchlist item whose added_to_watchlist_at is unparseable", () => { + const result = parseSimklPayload({ + movies: [ + { + status: "plantowatch", + added_to_watchlist_at: "not a date", + movie: { title: "Odd Date", ids: { tmdb: 99 } }, + }, + ], + } as never); + + expect(result.data.watchlist).toHaveLength(1); + expect(result.data.watchlist[0].title).toBe("Odd Date"); + expect(result.data.watchlist[0].addedAt).toBeUndefined(); + }); }); // ─── parseLetterboxdExport ───────────────────────────────────────────