fix(imports): accept Simkl's nested item shape in uploaded backups

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-04 15:35:02 -04:00
1 parent 405ced71e8
commit 9b1a8b07a6
2 files changed
+120 -2

No files matched your search

+38 -2
View File
@@ -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),
});
}
+82
View File
@@ -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 ───────────────────────────────────────────