mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 01:05:42 -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"
|
status?: string; // "completed", "watching", "plantowatch", "dropped", "hold"
|
||||||
user_rating?: number;
|
user_rating?: number;
|
||||||
last_watched_at?: string;
|
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;
|
watched_episodes_count?: number;
|
||||||
total_episodes_count?: number;
|
total_episodes_count?: number;
|
||||||
seasons?: {
|
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: {
|
export function parseSimklPayload(data: {
|
||||||
movies?: SimklItem[];
|
movies?: SimklItem[];
|
||||||
shows?: SimklItem[];
|
shows?: SimklItem[];
|
||||||
@@ -438,7 +471,8 @@ export function parseSimklPayload(data: {
|
|||||||
const ratings: ImportRating[] = [];
|
const ratings: ImportRating[] = [];
|
||||||
|
|
||||||
// Movies
|
// Movies
|
||||||
for (const item of data.movies ?? []) {
|
for (const raw of data.movies ?? []) {
|
||||||
|
const item = normalizeSimklItem(raw);
|
||||||
if (!item.title) continue;
|
if (!item.title) continue;
|
||||||
const tmdbId =
|
const tmdbId =
|
||||||
typeof item.ids?.tmdb === "number"
|
typeof item.ids?.tmdb === "number"
|
||||||
@@ -456,6 +490,7 @@ export function parseSimklPayload(data: {
|
|||||||
year: item.year,
|
year: item.year,
|
||||||
type: "movie",
|
type: "movie",
|
||||||
status: sofaStatus,
|
status: sofaStatus,
|
||||||
|
addedAt: simklAddedAt(item.added_to_watchlist_at),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +526,7 @@ export function parseSimklPayload(data: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Shows + Anime (both map to TV type)
|
// 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) {
|
for (const item of allShows) {
|
||||||
if (!item.title) continue;
|
if (!item.title) continue;
|
||||||
const tmdbId =
|
const tmdbId =
|
||||||
@@ -517,6 +552,7 @@ export function parseSimklPayload(data: {
|
|||||||
year: item.year,
|
year: item.year,
|
||||||
type: "tv",
|
type: "tv",
|
||||||
status: sofaStatus,
|
status: sofaStatus,
|
||||||
|
addedAt: simklAddedAt(item.added_to_watchlist_at),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -729,6 +729,88 @@ describe("parseSimklPayload", () => {
|
|||||||
// movie watch + library item, both without IDs
|
// movie watch + library item, both without IDs
|
||||||
expect(result.diagnostics?.unresolved).toBe(2);
|
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 ───────────────────────────────────────────
|
// ─── parseLetterboxdExport ───────────────────────────────────────────
|
||||||
|
|||||||
Reference in new issue
Block a user