mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 23:55:43 -04:00
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:
2 files changed
+120
-2
No files matched your search
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user