mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
feat: add user data export and sofa export re-import support
- Add `GET /api/export/user-data` route that streams a JSON attachment of the authenticated user's full library data, named `sofa-export-<name>-<date>.json` - Add `generateUserExport` in `@sofa/core/export` and a matching `parseSofaExport` parser so exported files can be re-imported via the existing import pipeline - Register `sofa` as a new `ImportSource` in the API contract/schemas and wire up `parseSofaExport` in the `parseFile` procedure - Add `EXPORT_FAILED` error code to the API error registry and surface it in web and native error-message maps - Update the account settings section with export/import UI (download button, import progress) - Add tests for `generateUserExport`, `parseSofaExport`, and round-trip fidelity
This commit is contained in:
@@ -733,7 +733,7 @@ export const contract = {
|
||||
tags: ["Imports"],
|
||||
summary: "Parse import file",
|
||||
description:
|
||||
"Upload and parse an export file from Trakt, Simkl, or Letterboxd. Returns a preview of items found without importing anything.",
|
||||
"Upload and parse an export file from Trakt, Simkl, Letterboxd, or Sofa. Returns a preview of items found without importing anything.",
|
||||
successDescription: "Preview of importable items with counts",
|
||||
})
|
||||
.input(ParseFileInput)
|
||||
|
||||
@@ -14,6 +14,7 @@ export const AppErrorCode = {
|
||||
IMPORT_ALREADY_RUNNING: "IMPORT_ALREADY_RUNNING",
|
||||
IMPORT_CANNOT_CANCEL: "IMPORT_CANNOT_CANCEL",
|
||||
REGISTRATION_CLOSED: "REGISTRATION_CLOSED",
|
||||
EXPORT_FAILED: "EXPORT_FAILED",
|
||||
} as const;
|
||||
|
||||
export type AppErrorCode = (typeof AppErrorCode)[keyof typeof AppErrorCode];
|
||||
|
||||
@@ -947,8 +947,8 @@ export const AuthConfigOutput = z
|
||||
// ─── Imports ──────────────────────────────────────────────────
|
||||
|
||||
export const ImportSourceEnum = z
|
||||
.enum(["trakt", "simkl", "letterboxd"])
|
||||
.describe("External service to import from");
|
||||
.enum(["trakt", "simkl", "letterboxd", "sofa"])
|
||||
.describe("Service to import from");
|
||||
|
||||
export const ImportMovieSchema = z.object({
|
||||
tmdbId: z.number().optional(),
|
||||
@@ -971,6 +971,8 @@ export const ImportEpisodeSchema = z.object({
|
||||
watchedOn: z.string().date().optional(),
|
||||
});
|
||||
|
||||
export const TitleStatusEnum = z.enum(["watchlist", "in_progress", "completed"]);
|
||||
|
||||
export const ImportWatchlistItemSchema = z.object({
|
||||
tmdbId: z.number().optional(),
|
||||
imdbId: z.string().optional(),
|
||||
@@ -978,6 +980,12 @@ export const ImportWatchlistItemSchema = z.object({
|
||||
title: z.string(),
|
||||
year: z.number().optional(),
|
||||
type: z.enum(["movie", "tv"]),
|
||||
status: TitleStatusEnum.optional().describe("Library status (default: watchlist)"),
|
||||
addedAt: z
|
||||
.string()
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe("When the item was added to library"),
|
||||
});
|
||||
|
||||
export const ImportRatingSchema = z.object({
|
||||
@@ -1070,6 +1078,55 @@ export const ImportJobEvent = z.object({
|
||||
job: ImportJobSchema,
|
||||
});
|
||||
|
||||
// ─── Sofa Export ─────────────────────────────────────────────
|
||||
|
||||
const SofaLibraryItemSchema = z.object({
|
||||
tmdbId: z.number(),
|
||||
title: z.string(),
|
||||
year: z.number().optional(),
|
||||
type: z.enum(["movie", "tv"]),
|
||||
status: TitleStatusEnum,
|
||||
addedAt: z.string().datetime({ offset: true }),
|
||||
});
|
||||
|
||||
const SofaMovieWatchSchema = z.object({
|
||||
tmdbId: z.number(),
|
||||
title: z.string(),
|
||||
year: z.number().optional(),
|
||||
watchedAt: z.string().datetime({ offset: true }),
|
||||
});
|
||||
|
||||
const SofaEpisodeWatchSchema = z.object({
|
||||
showTmdbId: z.number(),
|
||||
showTitle: z.string(),
|
||||
showYear: z.number().optional(),
|
||||
seasonNumber: z.number().int().min(0),
|
||||
episodeNumber: z.number().int().min(1),
|
||||
episodeName: z.string().optional(),
|
||||
watchedAt: z.string().datetime({ offset: true }),
|
||||
});
|
||||
|
||||
const SofaRatingSchema = z.object({
|
||||
tmdbId: z.number(),
|
||||
title: z.string(),
|
||||
year: z.number().optional(),
|
||||
type: z.enum(["movie", "tv"]),
|
||||
rating: z.number().int().min(1).max(5),
|
||||
ratedAt: z.string().datetime({ offset: true }),
|
||||
});
|
||||
|
||||
export const SofaExportSchema = z.object({
|
||||
version: z.literal(1),
|
||||
exportedAt: z.string().datetime({ offset: true }),
|
||||
user: z.object({ name: z.string(), email: z.string() }),
|
||||
library: z.array(SofaLibraryItemSchema).max(50_000),
|
||||
movieWatches: z.array(SofaMovieWatchSchema).max(50_000),
|
||||
episodeWatches: z.array(SofaEpisodeWatchSchema).max(50_000),
|
||||
ratings: z.array(SofaRatingSchema).max(50_000),
|
||||
});
|
||||
|
||||
export type SofaExport = z.infer<typeof SofaExportSchema>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Inferred types — use these instead of hand-written interfaces
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"./credits": "./src/credits.ts",
|
||||
"./cron": "./src/cron.ts",
|
||||
"./discovery": "./src/discovery.ts",
|
||||
"./export": "./src/export.ts",
|
||||
"./image-cache": "./src/image-cache.ts",
|
||||
"./imports": "./src/imports/index.ts",
|
||||
"./imports/parsers": "./src/imports/parsers.ts",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { SofaExport } from "@sofa/api/schemas";
|
||||
import {
|
||||
getUserEpisodeWatches,
|
||||
getUserLibrary,
|
||||
getUserMovieWatches,
|
||||
getUserRatings,
|
||||
} from "@sofa/db/queries/export";
|
||||
|
||||
function extractYear(
|
||||
releaseDate?: string | null,
|
||||
firstAirDate?: string | null,
|
||||
): number | undefined {
|
||||
const dateStr = releaseDate ?? firstAirDate;
|
||||
if (!dateStr) return undefined;
|
||||
const year = Number.parseInt(dateStr.slice(0, 4), 10);
|
||||
return Number.isNaN(year) ? undefined : year;
|
||||
}
|
||||
|
||||
export function generateUserExport(
|
||||
userId: string,
|
||||
user: { name: string; email: string },
|
||||
): SofaExport {
|
||||
const libraryRows = getUserLibrary(userId);
|
||||
const movieWatchRows = getUserMovieWatches(userId);
|
||||
const episodeWatchRows = getUserEpisodeWatches(userId);
|
||||
const ratingRows = getUserRatings(userId);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
user: { name: user.name, email: user.email },
|
||||
library: libraryRows.map((row) => ({
|
||||
tmdbId: row.tmdbId,
|
||||
title: row.title,
|
||||
year: extractYear(row.year, row.firstAirDate),
|
||||
type: row.type as "movie" | "tv",
|
||||
status: row.status as "watchlist" | "in_progress" | "completed",
|
||||
addedAt: row.addedAt.toISOString(),
|
||||
})),
|
||||
movieWatches: movieWatchRows.map((row) => ({
|
||||
tmdbId: row.tmdbId,
|
||||
title: row.title,
|
||||
year: extractYear(row.year),
|
||||
watchedAt: row.watchedAt.toISOString(),
|
||||
})),
|
||||
episodeWatches: episodeWatchRows.map((row) => ({
|
||||
showTmdbId: row.showTmdbId,
|
||||
showTitle: row.showTitle,
|
||||
showYear: extractYear(row.showFirstAirDate),
|
||||
seasonNumber: row.seasonNumber,
|
||||
episodeNumber: row.episodeNumber,
|
||||
episodeName: row.episodeName ?? undefined,
|
||||
watchedAt: row.watchedAt.toISOString(),
|
||||
})),
|
||||
ratings: ratingRows.map((row) => ({
|
||||
tmdbId: row.tmdbId,
|
||||
title: row.title,
|
||||
year: extractYear(row.year, row.firstAirDate),
|
||||
type: row.type as "movie" | "tv",
|
||||
rating: row.ratingStars,
|
||||
ratedAt: row.ratedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export {
|
||||
parseSimklPayload,
|
||||
parseTraktPayload,
|
||||
} from "./parsers";
|
||||
export { parseSofaExport } from "./sofa-parser";
|
||||
export {
|
||||
type ImportOptions,
|
||||
type ImportResult,
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ImportWatchlistItem {
|
||||
title: string;
|
||||
year?: number;
|
||||
type: "movie" | "tv";
|
||||
status?: "watchlist" | "in_progress" | "completed";
|
||||
addedAt?: string;
|
||||
}
|
||||
|
||||
export interface ImportRating {
|
||||
@@ -46,7 +48,7 @@ export interface ImportRating {
|
||||
ratedOn?: string;
|
||||
}
|
||||
|
||||
export type ImportSource = "trakt" | "simkl" | "letterboxd";
|
||||
export type ImportSource = "trakt" | "simkl" | "letterboxd" | "sofa";
|
||||
|
||||
export interface NormalizedImport {
|
||||
source: ImportSource;
|
||||
@@ -314,6 +316,21 @@ interface SimklItem {
|
||||
}[];
|
||||
}
|
||||
|
||||
function mapSimklStatus(status?: string): "watchlist" | "in_progress" | "completed" | undefined {
|
||||
switch (status) {
|
||||
case "plantowatch":
|
||||
return "watchlist";
|
||||
case "watching":
|
||||
case "dropped":
|
||||
case "hold":
|
||||
return "in_progress";
|
||||
case "completed":
|
||||
return "completed";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSimklPayload(data: {
|
||||
movies?: SimklItem[];
|
||||
shows?: SimklItem[];
|
||||
@@ -335,15 +352,25 @@ export function parseSimklPayload(data: {
|
||||
? Number(item.ids.tmdb)
|
||||
: undefined;
|
||||
|
||||
if (item.status === "plantowatch") {
|
||||
const sofaStatus = mapSimklStatus(item.status);
|
||||
if (sofaStatus) {
|
||||
watchlist.push({
|
||||
tmdbId: tmdbId ?? undefined,
|
||||
imdbId: item.ids?.imdb,
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
type: "movie",
|
||||
status: sofaStatus,
|
||||
});
|
||||
} else if (item.status === "completed" || item.status === "watching" || item.last_watched_at) {
|
||||
}
|
||||
|
||||
if (
|
||||
item.status === "completed" ||
|
||||
item.status === "watching" ||
|
||||
item.status === "dropped" ||
|
||||
item.status === "hold" ||
|
||||
item.last_watched_at
|
||||
) {
|
||||
movies.push({
|
||||
tmdbId: tmdbId ?? undefined,
|
||||
imdbId: item.ids?.imdb,
|
||||
@@ -385,7 +412,8 @@ export function parseSimklPayload(data: {
|
||||
? Number(item.ids.tvdb)
|
||||
: undefined;
|
||||
|
||||
if (item.status === "plantowatch") {
|
||||
const sofaStatus = mapSimklStatus(item.status);
|
||||
if (sofaStatus) {
|
||||
watchlist.push({
|
||||
tmdbId: tmdbId ?? undefined,
|
||||
imdbId: item.ids?.imdb,
|
||||
@@ -393,6 +421,7 @@ export function parseSimklPayload(data: {
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
type: "tv",
|
||||
status: sofaStatus,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
hasEpisodeWatch,
|
||||
hasMovieWatch,
|
||||
hasRating,
|
||||
hasTitleStatus,
|
||||
getTitleStatusValue,
|
||||
updateImportJobProgress,
|
||||
} from "@sofa/db/queries/imports";
|
||||
import { findEpisodeBySeasonAndNumber, findSeasonByTitleAndNumber } from "@sofa/db/queries/title";
|
||||
@@ -187,12 +187,17 @@ async function processWatchlistItem(
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTitleStatus(userId, title.id)) {
|
||||
const STATUS_RANK = { watchlist: 0, in_progress: 1, completed: 2 } as const;
|
||||
const targetStatus = item.status ?? "watchlist";
|
||||
const currentStatus = getTitleStatusValue(userId, title.id);
|
||||
|
||||
if (currentStatus && STATUS_RANK[currentStatus] >= STATUS_RANK[targetStatus]) {
|
||||
result.skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
setTitleStatus(userId, title.id, "watchlist", "import");
|
||||
const addedAt = item.addedAt ? new Date(item.addedAt) : undefined;
|
||||
setTitleStatus(userId, title.id, targetStatus, "import", addedAt);
|
||||
result.imported++;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { SofaExportSchema } from "@sofa/api/schemas";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import type { NormalizedImport, ParseResult } from "./parsers";
|
||||
import { countUnresolved } from "./parsers";
|
||||
|
||||
const log = createLogger("imports");
|
||||
|
||||
export function parseSofaExport(data: unknown): ParseResult {
|
||||
const warnings: string[] = [];
|
||||
|
||||
const parsed = SofaExportSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
|
||||
return {
|
||||
data: { source: "sofa", movies: [], episodes: [], watchlist: [], ratings: [] },
|
||||
warnings: [`Invalid Sofa export file: ${issues.join("; ")}`],
|
||||
diagnostics: { unresolved: 0, unsupported: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const exported = parsed.data;
|
||||
|
||||
const normalized: NormalizedImport = {
|
||||
source: "sofa",
|
||||
movies: exported.movieWatches.map((m) => ({
|
||||
tmdbId: m.tmdbId,
|
||||
title: m.title,
|
||||
year: m.year,
|
||||
watchedAt: m.watchedAt,
|
||||
})),
|
||||
episodes: exported.episodeWatches.map((e) => ({
|
||||
showTmdbId: e.showTmdbId,
|
||||
showTitle: e.showTitle,
|
||||
year: e.showYear,
|
||||
seasonNumber: e.seasonNumber,
|
||||
episodeNumber: e.episodeNumber,
|
||||
watchedAt: e.watchedAt,
|
||||
})),
|
||||
watchlist: exported.library.map((l) => ({
|
||||
tmdbId: l.tmdbId,
|
||||
title: l.title,
|
||||
year: l.year,
|
||||
type: l.type,
|
||||
status: l.status,
|
||||
addedAt: l.addedAt,
|
||||
})),
|
||||
ratings: exported.ratings.map((r) => ({
|
||||
tmdbId: r.tmdbId,
|
||||
title: r.title,
|
||||
year: r.year,
|
||||
type: r.type,
|
||||
rating: r.rating,
|
||||
ratedAt: r.ratedAt,
|
||||
})),
|
||||
};
|
||||
|
||||
log.info(
|
||||
`Parsed Sofa export: ${normalized.movies.length} movies, ${normalized.episodes.length} episodes, ${normalized.watchlist.length} library, ${normalized.ratings.length} ratings`,
|
||||
);
|
||||
|
||||
return {
|
||||
data: normalized,
|
||||
warnings,
|
||||
diagnostics: { unresolved: countUnresolved(normalized), unsupported: 0 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
clearAllTables,
|
||||
insertEpisodeWatch,
|
||||
insertMovieWatch,
|
||||
insertRating,
|
||||
insertStatus,
|
||||
insertTitle,
|
||||
insertTvShow,
|
||||
insertUser,
|
||||
} from "@sofa/db/test-utils";
|
||||
|
||||
import { generateUserExport } from "../src/export";
|
||||
|
||||
beforeEach(() => clearAllTables());
|
||||
|
||||
describe("generateUserExport", () => {
|
||||
test("exports empty data for user with no tracking", () => {
|
||||
insertUser("user-1");
|
||||
|
||||
const result = generateUserExport("user-1", { name: "Test", email: "test@example.com" });
|
||||
|
||||
expect(result.version).toBe(1);
|
||||
expect(result.user).toEqual({ name: "Test", email: "test@example.com" });
|
||||
expect(result.library).toHaveLength(0);
|
||||
expect(result.movieWatches).toHaveLength(0);
|
||||
expect(result.episodeWatches).toHaveLength(0);
|
||||
expect(result.ratings).toHaveLength(0);
|
||||
expect(result.exportedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("exports library statuses", () => {
|
||||
insertUser("user-1");
|
||||
insertTitle({
|
||||
id: "t1",
|
||||
tmdbId: 550,
|
||||
type: "movie",
|
||||
title: "Fight Club",
|
||||
releaseDate: "1999-10-15",
|
||||
});
|
||||
insertTitle({ id: "t2", tmdbId: 1396, type: "tv", title: "Breaking Bad" });
|
||||
insertTitle({ id: "t3", tmdbId: 100, type: "movie", title: "Watchlisted" });
|
||||
|
||||
insertStatus("user-1", "t1", "completed");
|
||||
insertStatus("user-1", "t2", "in_progress");
|
||||
insertStatus("user-1", "t3", "watchlist");
|
||||
|
||||
const result = generateUserExport("user-1", { name: "Test", email: "t@t.com" });
|
||||
|
||||
expect(result.library).toHaveLength(3);
|
||||
|
||||
const fightClub = result.library.find((l) => l.tmdbId === 550);
|
||||
expect(fightClub?.status).toBe("completed");
|
||||
expect(fightClub?.type).toBe("movie");
|
||||
expect(fightClub?.title).toBe("Fight Club");
|
||||
expect(fightClub?.year).toBe(1999);
|
||||
|
||||
const bb = result.library.find((l) => l.tmdbId === 1396);
|
||||
expect(bb?.status).toBe("in_progress");
|
||||
expect(bb?.type).toBe("tv");
|
||||
});
|
||||
|
||||
test("exports movie watches", () => {
|
||||
insertUser("user-1");
|
||||
insertTitle({
|
||||
id: "t1",
|
||||
tmdbId: 550,
|
||||
type: "movie",
|
||||
title: "Fight Club",
|
||||
releaseDate: "1999-10-15",
|
||||
});
|
||||
|
||||
const watchedAt = new Date("2025-06-15T20:00:00.000Z");
|
||||
insertMovieWatch("user-1", "t1", watchedAt);
|
||||
|
||||
const result = generateUserExport("user-1", { name: "Test", email: "t@t.com" });
|
||||
|
||||
expect(result.movieWatches).toHaveLength(1);
|
||||
expect(result.movieWatches[0].tmdbId).toBe(550);
|
||||
expect(result.movieWatches[0].title).toBe("Fight Club");
|
||||
expect(result.movieWatches[0].year).toBe(1999);
|
||||
expect(result.movieWatches[0].watchedAt).toBe(watchedAt.toISOString());
|
||||
});
|
||||
|
||||
test("exports episode watches with show context", () => {
|
||||
insertUser("user-1");
|
||||
const { episodeIds } = insertTvShow("tv-1", 1396, 1, 2, { title: "Breaking Bad" });
|
||||
|
||||
const watchedAt = new Date("2025-02-01T20:00:00.000Z");
|
||||
insertEpisodeWatch("user-1", episodeIds[0], watchedAt);
|
||||
|
||||
const result = generateUserExport("user-1", { name: "Test", email: "t@t.com" });
|
||||
|
||||
expect(result.episodeWatches).toHaveLength(1);
|
||||
expect(result.episodeWatches[0].showTmdbId).toBe(1396);
|
||||
expect(result.episodeWatches[0].showTitle).toBe("Breaking Bad");
|
||||
expect(result.episodeWatches[0].seasonNumber).toBe(1);
|
||||
expect(result.episodeWatches[0].episodeNumber).toBe(1);
|
||||
expect(result.episodeWatches[0].episodeName).toBe("S1E1");
|
||||
expect(result.episodeWatches[0].watchedAt).toBe(watchedAt.toISOString());
|
||||
});
|
||||
|
||||
test("exports ratings", () => {
|
||||
insertUser("user-1");
|
||||
insertTitle({
|
||||
id: "t1",
|
||||
tmdbId: 550,
|
||||
type: "movie",
|
||||
title: "Fight Club",
|
||||
releaseDate: "1999-10-15",
|
||||
});
|
||||
insertRating("user-1", "t1", 5);
|
||||
|
||||
const result = generateUserExport("user-1", { name: "Test", email: "t@t.com" });
|
||||
|
||||
expect(result.ratings).toHaveLength(1);
|
||||
expect(result.ratings[0].tmdbId).toBe(550);
|
||||
expect(result.ratings[0].type).toBe("movie");
|
||||
expect(result.ratings[0].rating).toBe(5);
|
||||
});
|
||||
|
||||
test("does not include other users data", () => {
|
||||
insertUser("user-1");
|
||||
insertUser("user-2");
|
||||
insertTitle({ id: "t1", tmdbId: 550, type: "movie", title: "Fight Club" });
|
||||
insertTitle({ id: "t2", tmdbId: 100, type: "movie", title: "Other Movie" });
|
||||
|
||||
insertStatus("user-1", "t1", "completed");
|
||||
insertMovieWatch("user-1", "t1");
|
||||
insertRating("user-1", "t1", 5);
|
||||
|
||||
insertStatus("user-2", "t2", "watchlist");
|
||||
insertMovieWatch("user-2", "t2");
|
||||
insertRating("user-2", "t2", 3);
|
||||
|
||||
const result = generateUserExport("user-1", { name: "Test", email: "t@t.com" });
|
||||
|
||||
expect(result.library).toHaveLength(1);
|
||||
expect(result.library[0].tmdbId).toBe(550);
|
||||
expect(result.movieWatches).toHaveLength(1);
|
||||
expect(result.movieWatches[0].tmdbId).toBe(550);
|
||||
expect(result.ratings).toHaveLength(1);
|
||||
expect(result.ratings[0].tmdbId).toBe(550);
|
||||
});
|
||||
});
|
||||
@@ -697,7 +697,8 @@ describe("parseSimklPayload", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.diagnostics?.unresolved).toBe(1);
|
||||
// movie watch + library item, both without IDs
|
||||
expect(result.diagnostics?.unresolved).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { parseSofaExport } from "../src/imports/sofa-parser";
|
||||
|
||||
describe("parseSofaExport", () => {
|
||||
const validExport = {
|
||||
version: 1,
|
||||
exportedAt: "2026-01-15T12:00:00.000Z",
|
||||
user: { name: "Test", email: "test@example.com" },
|
||||
library: [
|
||||
{
|
||||
tmdbId: 550,
|
||||
title: "Fight Club",
|
||||
year: 1999,
|
||||
type: "movie",
|
||||
status: "completed",
|
||||
addedAt: "2025-01-15T10:30:00.000Z",
|
||||
},
|
||||
{
|
||||
tmdbId: 1396,
|
||||
title: "Breaking Bad",
|
||||
year: 2008,
|
||||
type: "tv",
|
||||
status: "in_progress",
|
||||
addedAt: "2025-02-01T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
movieWatches: [
|
||||
{
|
||||
tmdbId: 550,
|
||||
title: "Fight Club",
|
||||
year: 1999,
|
||||
watchedAt: "2025-01-15T10:30:00.000Z",
|
||||
},
|
||||
],
|
||||
episodeWatches: [
|
||||
{
|
||||
showTmdbId: 1396,
|
||||
showTitle: "Breaking Bad",
|
||||
showYear: 2008,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
episodeName: "Pilot",
|
||||
watchedAt: "2025-02-01T20:00:00.000Z",
|
||||
},
|
||||
],
|
||||
ratings: [
|
||||
{
|
||||
tmdbId: 550,
|
||||
title: "Fight Club",
|
||||
year: 1999,
|
||||
type: "movie",
|
||||
rating: 5,
|
||||
ratedAt: "2025-01-15T10:35:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test("parses a valid Sofa export", () => {
|
||||
const result = parseSofaExport(validExport);
|
||||
|
||||
expect(result.data.source).toBe("sofa");
|
||||
expect(result.data.movies).toHaveLength(1);
|
||||
expect(result.data.episodes).toHaveLength(1);
|
||||
expect(result.data.watchlist).toHaveLength(2);
|
||||
expect(result.data.ratings).toHaveLength(1);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("maps movie watches correctly", () => {
|
||||
const result = parseSofaExport(validExport);
|
||||
const movie = result.data.movies[0];
|
||||
|
||||
expect(movie.tmdbId).toBe(550);
|
||||
expect(movie.title).toBe("Fight Club");
|
||||
expect(movie.year).toBe(1999);
|
||||
expect(movie.watchedAt).toBe("2025-01-15T10:30:00.000Z");
|
||||
});
|
||||
|
||||
test("maps episode watches correctly", () => {
|
||||
const result = parseSofaExport(validExport);
|
||||
const ep = result.data.episodes[0];
|
||||
|
||||
expect(ep.showTmdbId).toBe(1396);
|
||||
expect(ep.showTitle).toBe("Breaking Bad");
|
||||
expect(ep.seasonNumber).toBe(1);
|
||||
expect(ep.episodeNumber).toBe(1);
|
||||
expect(ep.watchedAt).toBe("2025-02-01T20:00:00.000Z");
|
||||
});
|
||||
|
||||
test("preserves status and addedAt on library items", () => {
|
||||
const result = parseSofaExport(validExport);
|
||||
|
||||
const movie = result.data.watchlist[0];
|
||||
expect(movie.status).toBe("completed");
|
||||
expect(movie.addedAt).toBe("2025-01-15T10:30:00.000Z");
|
||||
|
||||
const show = result.data.watchlist[1];
|
||||
expect(show.status).toBe("in_progress");
|
||||
expect(show.addedAt).toBe("2025-02-01T08:00:00.000Z");
|
||||
});
|
||||
|
||||
test("maps ratings correctly", () => {
|
||||
const result = parseSofaExport(validExport);
|
||||
const rating = result.data.ratings[0];
|
||||
|
||||
expect(rating.tmdbId).toBe(550);
|
||||
expect(rating.rating).toBe(5);
|
||||
expect(rating.type).toBe("movie");
|
||||
expect(rating.ratedAt).toBe("2025-01-15T10:35:00.000Z");
|
||||
});
|
||||
|
||||
test("handles empty export", () => {
|
||||
const result = parseSofaExport({
|
||||
version: 1,
|
||||
exportedAt: "2026-01-15T12:00:00.000Z",
|
||||
user: { name: "Test", email: "test@example.com" },
|
||||
library: [],
|
||||
movieWatches: [],
|
||||
episodeWatches: [],
|
||||
ratings: [],
|
||||
});
|
||||
|
||||
expect(result.data.movies).toHaveLength(0);
|
||||
expect(result.data.episodes).toHaveLength(0);
|
||||
expect(result.data.watchlist).toHaveLength(0);
|
||||
expect(result.data.ratings).toHaveLength(0);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rejects invalid data with warnings", () => {
|
||||
const result = parseSofaExport({ version: 2, bad: true });
|
||||
|
||||
expect(result.data.movies).toHaveLength(0);
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
expect(result.warnings[0]).toContain("Invalid Sofa export file");
|
||||
});
|
||||
|
||||
test("rejects non-object input", () => {
|
||||
const result = parseSofaExport("not json");
|
||||
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
expect(result.warnings[0]).toContain("Invalid Sofa export file");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import {
|
||||
episodes,
|
||||
seasons,
|
||||
titles,
|
||||
userEpisodeWatches,
|
||||
userMovieWatches,
|
||||
userRatings,
|
||||
userTitleStatus,
|
||||
} from "../schema";
|
||||
|
||||
export function getUserLibrary(userId: string) {
|
||||
return db
|
||||
.select({
|
||||
tmdbId: titles.tmdbId,
|
||||
title: titles.title,
|
||||
year: titles.releaseDate,
|
||||
firstAirDate: titles.firstAirDate,
|
||||
type: titles.type,
|
||||
status: userTitleStatus.status,
|
||||
addedAt: userTitleStatus.addedAt,
|
||||
})
|
||||
.from(userTitleStatus)
|
||||
.innerJoin(titles, eq(userTitleStatus.titleId, titles.id))
|
||||
.where(eq(userTitleStatus.userId, userId))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getUserMovieWatches(userId: string) {
|
||||
return db
|
||||
.select({
|
||||
tmdbId: titles.tmdbId,
|
||||
title: titles.title,
|
||||
year: titles.releaseDate,
|
||||
watchedAt: userMovieWatches.watchedAt,
|
||||
})
|
||||
.from(userMovieWatches)
|
||||
.innerJoin(titles, eq(userMovieWatches.titleId, titles.id))
|
||||
.where(eq(userMovieWatches.userId, userId))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getUserEpisodeWatches(userId: string) {
|
||||
return db
|
||||
.select({
|
||||
showTmdbId: titles.tmdbId,
|
||||
showTitle: titles.title,
|
||||
showFirstAirDate: titles.firstAirDate,
|
||||
seasonNumber: seasons.seasonNumber,
|
||||
episodeNumber: episodes.episodeNumber,
|
||||
episodeName: episodes.name,
|
||||
watchedAt: userEpisodeWatches.watchedAt,
|
||||
})
|
||||
.from(userEpisodeWatches)
|
||||
.innerJoin(episodes, eq(userEpisodeWatches.episodeId, episodes.id))
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.innerJoin(titles, eq(seasons.titleId, titles.id))
|
||||
.where(eq(userEpisodeWatches.userId, userId))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getUserRatings(userId: string) {
|
||||
return db
|
||||
.select({
|
||||
tmdbId: titles.tmdbId,
|
||||
title: titles.title,
|
||||
year: titles.releaseDate,
|
||||
firstAirDate: titles.firstAirDate,
|
||||
type: titles.type,
|
||||
ratingStars: userRatings.ratingStars,
|
||||
ratedAt: userRatings.ratedAt,
|
||||
})
|
||||
.from(userRatings)
|
||||
.innerJoin(titles, eq(userRatings.titleId, titles.id))
|
||||
.where(eq(userRatings.userId, userId))
|
||||
.all();
|
||||
}
|
||||
@@ -30,12 +30,19 @@ export function hasEpisodeWatch(userId: string, episodeId: string): boolean {
|
||||
}
|
||||
|
||||
export function hasTitleStatus(userId: string, titleId: string): boolean {
|
||||
return !!getTitleStatusValue(userId, titleId);
|
||||
}
|
||||
|
||||
export function getTitleStatusValue(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
): "watchlist" | "in_progress" | "completed" | null {
|
||||
const existing = db
|
||||
.select({ status: userTitleStatus.status })
|
||||
.from(userTitleStatus)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
return !!existing;
|
||||
return (existing?.status as "watchlist" | "in_progress" | "completed") ?? null;
|
||||
}
|
||||
|
||||
export function hasRating(userId: string, titleId: string): boolean {
|
||||
|
||||
@@ -449,7 +449,7 @@ export const importJobs = sqliteTable(
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
source: text("source", {
|
||||
enum: ["trakt", "simkl", "letterboxd"],
|
||||
enum: ["trakt", "simkl", "letterboxd", "sofa"],
|
||||
}).notNull(),
|
||||
status: text("status", {
|
||||
enum: ["pending", "running", "success", "error", "cancelled"],
|
||||
|
||||
Reference in New Issue
Block a user