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:
@@ -32,6 +32,7 @@ export function appErrorMessages(
|
|||||||
IMPORT_ALREADY_RUNNING: t`Import already running`,
|
IMPORT_ALREADY_RUNNING: t`Import already running`,
|
||||||
IMPORT_CANNOT_CANCEL: t`Cannot cancel import`,
|
IMPORT_CANNOT_CANCEL: t`Cannot cancel import`,
|
||||||
REGISTRATION_CLOSED: t`Registration closed`,
|
REGISTRATION_CLOSED: t`Registration closed`,
|
||||||
|
EXPORT_FAILED: t`Failed to export data`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { openApiHandler } from "./orpc/openapi-handler";
|
|||||||
import authRoutes from "./routes/auth";
|
import authRoutes from "./routes/auth";
|
||||||
import avatarsRoutes from "./routes/avatars";
|
import avatarsRoutes from "./routes/avatars";
|
||||||
import backupsRoutes from "./routes/backups";
|
import backupsRoutes from "./routes/backups";
|
||||||
|
import exportRoutes from "./routes/export";
|
||||||
import healthRoutes from "./routes/health";
|
import healthRoutes from "./routes/health";
|
||||||
import imagesRoutes from "./routes/images";
|
import imagesRoutes from "./routes/images";
|
||||||
import listsRoutes from "./routes/lists";
|
import listsRoutes from "./routes/lists";
|
||||||
@@ -66,6 +67,7 @@ app.route("/api/health", healthRoutes);
|
|||||||
app.route("/api/auth", authRoutes);
|
app.route("/api/auth", authRoutes);
|
||||||
app.route("/api/avatars", avatarsRoutes);
|
app.route("/api/avatars", avatarsRoutes);
|
||||||
app.route("/api/backup", backupsRoutes);
|
app.route("/api/backup", backupsRoutes);
|
||||||
|
app.route("/api/export", exportRoutes);
|
||||||
app.route("/api/webhooks", webhooksRoutes);
|
app.route("/api/webhooks", webhooksRoutes);
|
||||||
app.route("/api/lists", listsRoutes);
|
app.route("/api/lists", listsRoutes);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
insertImportJob,
|
insertImportJob,
|
||||||
parseLetterboxdExport,
|
parseLetterboxdExport,
|
||||||
parseSimklPayload,
|
parseSimklPayload,
|
||||||
|
parseSofaExport,
|
||||||
parseTraktPayload,
|
parseTraktPayload,
|
||||||
processImportJob,
|
processImportJob,
|
||||||
readImportJob,
|
readImportJob,
|
||||||
@@ -57,6 +58,19 @@ export const parseFile = os.imports.parseFile.use(authed).handler(async ({ input
|
|||||||
result = parseSimklPayload(json as Parameters<typeof parseSimklPayload>[0]);
|
result = parseSimklPayload(json as Parameters<typeof parseSimklPayload>[0]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "sofa": {
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = await file.json();
|
||||||
|
} catch {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Invalid JSON file",
|
||||||
|
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result = parseSofaExport(json);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
import { auth } from "@sofa/auth/server";
|
||||||
|
import { generateUserExport } from "@sofa/core/export";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/user-data", async (c) => {
|
||||||
|
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = generateUserExport(session.user.id, {
|
||||||
|
name: session.user.name,
|
||||||
|
email: session.user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
const safeName = session.user.name.replaceAll(/[^a-zA-Z0-9-_]/g, "-").toLowerCase();
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
const filename = `sofa-export-${safeName}-${date}.json`;
|
||||||
|
const json = JSON.stringify(data, null, 2);
|
||||||
|
|
||||||
|
return new Response(json, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
"Content-Length": String(new TextEncoder().encode(json).byteLength),
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
|
IconArrowRight,
|
||||||
IconCamera,
|
IconCamera,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
|
IconCloudDownload,
|
||||||
|
IconCloudUpload,
|
||||||
IconLockPassword,
|
IconLockPassword,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
@@ -33,11 +36,13 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { authClient, signOut } from "@/lib/auth/client";
|
import { authClient, signOut } from "@/lib/auth/client";
|
||||||
import { getErrorMessage } from "@/lib/error-messages";
|
import { getErrorMessage } from "@/lib/error-messages";
|
||||||
import { orpc } from "@/lib/orpc/client";
|
import { client, orpc } from "@/lib/orpc/client";
|
||||||
|
import type { NormalizedImport } from "@sofa/api/schemas";
|
||||||
import { formatDate } from "@sofa/i18n/format";
|
import { formatDate } from "@sofa/i18n/format";
|
||||||
|
|
||||||
export function AccountSection({
|
export function AccountSection({
|
||||||
@@ -166,7 +171,7 @@ export function AccountSection({
|
|||||||
<Trans>Account</Trans>
|
<Trans>Account</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card className="pb-0">
|
||||||
<CardContent className="flex items-center gap-4">
|
<CardContent className="flex items-center gap-4">
|
||||||
{/* Avatar: click to upload (no avatar) or remove (has avatar) */}
|
{/* Avatar: click to upload (no avatar) or remove (has avatar) */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -329,11 +334,459 @@ export function AccountSection({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
<div className="border-border/30 space-y-px border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = "/api/export/user-data";
|
||||||
|
}}
|
||||||
|
className="group hover:bg-muted/40 flex w-full items-center gap-3 px-4 py-3 text-start transition-colors"
|
||||||
|
>
|
||||||
|
<div className="bg-muted group-hover:bg-primary/10 flex size-7.5 shrink-0 items-center justify-center rounded-lg">
|
||||||
|
<IconCloudDownload
|
||||||
|
aria-hidden={true}
|
||||||
|
className="text-muted-foreground group-hover:text-primary size-3.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 space-y-0.5">
|
||||||
|
<p className="text-[13px] leading-none font-medium">
|
||||||
|
<Trans>Export</Trans>
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-[11px]">
|
||||||
|
<Trans>Download your library, watch history, and ratings as JSON</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<IconArrowRight
|
||||||
|
aria-hidden={true}
|
||||||
|
className="text-muted-foreground size-3.5 shrink-0"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<SofaImportDialog />
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Sofa Import Dialog ─────────────────────────────────────
|
||||||
|
|
||||||
|
interface ImportPreview {
|
||||||
|
data: NormalizedImport;
|
||||||
|
warnings: string[];
|
||||||
|
diagnostics?: { unresolved: number; unsupported: number };
|
||||||
|
stats: { movies: number; episodes: number; watchlist: number; ratings: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
imported: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SofaImportDialog() {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const importAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [step, setStep] = useState<"preview" | "importing" | "done">("preview");
|
||||||
|
const [preview, setPreview] = useState<ImportPreview | null>(null);
|
||||||
|
const [result, setResult] = useState<ImportResult | null>(null);
|
||||||
|
const [progress, setProgress] = useState<{
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [options, setOptions] = useState({
|
||||||
|
importWatches: true,
|
||||||
|
importWatchlist: true,
|
||||||
|
importRatings: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseMutation = useMutation(
|
||||||
|
orpc.imports.parseFile.mutationOptions({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setPreview(data as ImportPreview);
|
||||||
|
setStep("preview");
|
||||||
|
setOpen(true);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(getErrorMessage(err, t, t`Failed to parse file`));
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
parseMutation.mutate({ source: "sofa", file });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!preview) return;
|
||||||
|
setStep("importing");
|
||||||
|
setProgress(null);
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
|
importAbortRef.current = abort;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = await client.imports.createJob({
|
||||||
|
data: preview.data,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventSource = await client.imports.jobEvents({ id: job.id }, { signal: abort.signal });
|
||||||
|
|
||||||
|
let receivedComplete = false;
|
||||||
|
|
||||||
|
for await (const event of eventSource) {
|
||||||
|
if (abort.signal.aborted) break;
|
||||||
|
if (event.type === "complete") {
|
||||||
|
receivedComplete = true;
|
||||||
|
setResult({
|
||||||
|
imported: event.job.importedCount,
|
||||||
|
skipped: event.job.skippedCount,
|
||||||
|
failed: event.job.failedCount,
|
||||||
|
errors: event.job.errors,
|
||||||
|
warnings: event.job.warnings,
|
||||||
|
});
|
||||||
|
setStep("done");
|
||||||
|
const importedCount = event.job.importedCount;
|
||||||
|
if (importedCount > 0) {
|
||||||
|
toast.success(t`Imported ${importedCount} items from Sofa export`);
|
||||||
|
}
|
||||||
|
} else if (event.type === "timeout") {
|
||||||
|
receivedComplete = true;
|
||||||
|
toast.info(t`Import is still running in the background. Check back later.`);
|
||||||
|
handleClose();
|
||||||
|
} else {
|
||||||
|
setProgress({
|
||||||
|
current: event.job.processedItems,
|
||||||
|
total: event.job.totalItems,
|
||||||
|
message: event.job.currentMessage ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!receivedComplete && !abort.signal.aborted) {
|
||||||
|
try {
|
||||||
|
const finalJob = await client.imports.getJob({ id: job.id });
|
||||||
|
const isTerminal =
|
||||||
|
finalJob.status === "success" ||
|
||||||
|
finalJob.status === "error" ||
|
||||||
|
finalJob.status === "cancelled";
|
||||||
|
if (isTerminal) {
|
||||||
|
setResult({
|
||||||
|
imported: finalJob.importedCount,
|
||||||
|
skipped: finalJob.skippedCount,
|
||||||
|
failed: finalJob.failedCount,
|
||||||
|
errors: finalJob.errors,
|
||||||
|
warnings: finalJob.warnings,
|
||||||
|
});
|
||||||
|
setStep("done");
|
||||||
|
} else {
|
||||||
|
toast.info(t`Import is still running in the background. Check back later.`);
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t`Lost connection to import. Check status in settings.`);
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (abort.signal.aborted) return;
|
||||||
|
toast.error(getErrorMessage(err, t, t`Import failed`));
|
||||||
|
setStep("preview");
|
||||||
|
} finally {
|
||||||
|
importAbortRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
importAbortRef.current?.abort();
|
||||||
|
importAbortRef.current = null;
|
||||||
|
setOpen(false);
|
||||||
|
setStep("preview");
|
||||||
|
setPreview(null);
|
||||||
|
setResult(null);
|
||||||
|
setProgress(null);
|
||||||
|
setOptions({ importWatches: true, importWatchlist: true, importRatings: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isParsing = parseMutation.isPending;
|
||||||
|
|
||||||
|
const totalItems = preview
|
||||||
|
? (options.importWatches ? preview.stats.movies + preview.stats.episodes : 0) +
|
||||||
|
(options.importWatchlist ? preview.stats.watchlist : 0) +
|
||||||
|
(options.importRatings ? preview.stats.ratings : 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const pct =
|
||||||
|
progress && progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isParsing}
|
||||||
|
className="group hover:bg-muted/40 flex w-full items-center gap-3 rounded-b-lg px-4 py-3 text-start transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div className="bg-muted group-hover:bg-primary/10 flex size-7.5 shrink-0 items-center justify-center rounded-lg">
|
||||||
|
{isParsing ? (
|
||||||
|
<Spinner className="text-primary size-3.5" />
|
||||||
|
) : (
|
||||||
|
<IconCloudUpload
|
||||||
|
aria-hidden={true}
|
||||||
|
className="text-muted-foreground group-hover:text-primary size-3.5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 space-y-0.5">
|
||||||
|
<p className="text-[13px] leading-none font-medium">
|
||||||
|
{isParsing ? <Trans>Parsing file...</Trans> : <Trans>Import</Trans>}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-[11px]">
|
||||||
|
<Trans>Restore from a Sofa export file</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<IconArrowRight aria-hidden={true} className="text-muted-foreground size-3.5 shrink-0" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
{step === "preview" &&
|
||||||
|
preview &&
|
||||||
|
(() => {
|
||||||
|
const movieCount = preview.stats.movies;
|
||||||
|
const episodeCount = preview.stats.episodes;
|
||||||
|
const watchlistCount = preview.stats.watchlist;
|
||||||
|
const ratingCount = preview.stats.ratings;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Import Sofa export</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Review what was found and choose what to import.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<ImportStatBadge label={t`Movies`} count={movieCount} />
|
||||||
|
<ImportStatBadge label={t`Episodes`} count={episodeCount} />
|
||||||
|
<ImportStatBadge label={t`Library`} count={watchlistCount} />
|
||||||
|
<ImportStatBadge label={t`Ratings`} count={ratingCount} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
<Trans>Import options</Trans>
|
||||||
|
</p>
|
||||||
|
<ImportOptionCheckbox
|
||||||
|
label={t`Watch history`}
|
||||||
|
description={t`${movieCount} movies, ${episodeCount} episodes`}
|
||||||
|
checked={options.importWatches}
|
||||||
|
onChange={(v) => setOptions({ ...options, importWatches: v })}
|
||||||
|
/>
|
||||||
|
<ImportOptionCheckbox
|
||||||
|
label={t`Library statuses`}
|
||||||
|
description={t`${watchlistCount} items`}
|
||||||
|
checked={options.importWatchlist}
|
||||||
|
onChange={(v) => setOptions({ ...options, importWatchlist: v })}
|
||||||
|
/>
|
||||||
|
<ImportOptionCheckbox
|
||||||
|
label={t`Ratings`}
|
||||||
|
description={t`${ratingCount} ratings`}
|
||||||
|
checked={options.importRatings}
|
||||||
|
onChange={(v) => setOptions({ ...options, importRatings: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preview.warnings.length > 0 && (
|
||||||
|
<div className="rounded-lg bg-yellow-500/10 p-3">
|
||||||
|
<p className="mb-1 text-xs font-medium text-yellow-600">
|
||||||
|
<Trans>Warnings</Trans>
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-0.5 text-xs text-yellow-600/80">
|
||||||
|
{preview.warnings.map((w, i) => (
|
||||||
|
<li key={i}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose render={<Button variant="outline" />} onClick={handleClose}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</DialogClose>
|
||||||
|
<Button onClick={handleImport} disabled={totalItems === 0}>
|
||||||
|
<Trans>Import {totalItems} items</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{step === "importing" && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Importing data</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
This may take a few minutes for large libraries. Please don't close this tab.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col items-center gap-4 py-8">
|
||||||
|
<Progress value={pct} className="w-full" />
|
||||||
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
|
{progress ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{progress.current} / {progress.total}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground max-w-[300px] truncate text-xs">
|
||||||
|
{progress.message}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Spinner className="size-3" />
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<Trans>Starting import...</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "done" &&
|
||||||
|
result &&
|
||||||
|
(() => {
|
||||||
|
const errorCount = result.errors.length;
|
||||||
|
const warningCount = result.warnings.length;
|
||||||
|
const remainingErrors = errorCount - 50;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Import complete</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Finished importing your Sofa export.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<ImportStatBadge label={t`Imported`} count={result.imported} />
|
||||||
|
<ImportStatBadge label={t`Skipped`} count={result.skipped} />
|
||||||
|
<ImportStatBadge label={t`Failed`} count={result.failed} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorCount > 0 && (
|
||||||
|
<div className="bg-destructive/10 max-h-40 overflow-y-auto rounded-lg p-3">
|
||||||
|
<p className="text-destructive mb-1 text-xs font-medium">
|
||||||
|
<Trans>Errors ({errorCount})</Trans>
|
||||||
|
</p>
|
||||||
|
<ul className="text-destructive/80 space-y-0.5 text-xs">
|
||||||
|
{result.errors.slice(0, 50).map((e, i) => (
|
||||||
|
<li key={i}>{e}</li>
|
||||||
|
))}
|
||||||
|
{errorCount > 50 && (
|
||||||
|
<li>
|
||||||
|
<Trans>...and {remainingErrors} more</Trans>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{warningCount > 0 && (
|
||||||
|
<div className="max-h-32 overflow-y-auto rounded-lg bg-yellow-500/10 p-3">
|
||||||
|
<p className="mb-1 text-xs font-medium text-yellow-600">
|
||||||
|
<Trans>Warnings ({warningCount})</Trans>
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-0.5 text-xs text-yellow-600/80">
|
||||||
|
{result.warnings.slice(0, 20).map((w, i) => (
|
||||||
|
<li key={i}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={handleClose}>
|
||||||
|
<Trans>Done</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportStatBadge({ label, count }: { label: string; count: number }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-muted/50 rounded-lg p-2.5 text-center">
|
||||||
|
<p className="text-lg leading-none font-semibold">{count}</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportOptionCheckbox({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const id = `sofa-import-${label}`;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox id={id} checked={checked} onCheckedChange={onChange} />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={id} className="text-sm">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Change Password Dialog ─────────────────────────────────
|
||||||
|
|
||||||
function ChangePasswordDialog() {
|
function ChangePasswordDialog() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function appErrorMessages(
|
|||||||
IMPORT_ALREADY_RUNNING: t`An import is already in progress`,
|
IMPORT_ALREADY_RUNNING: t`An import is already in progress`,
|
||||||
IMPORT_CANNOT_CANCEL: t`This import cannot be cancelled`,
|
IMPORT_CANNOT_CANCEL: t`This import cannot be cancelled`,
|
||||||
REGISTRATION_CLOSED: t`Registration is currently closed`,
|
REGISTRATION_CLOSED: t`Registration is currently closed`,
|
||||||
|
EXPORT_FAILED: t`Failed to export data`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ _openapi:
|
|||||||
headings: []
|
headings: []
|
||||||
contents:
|
contents:
|
||||||
- content: >-
|
- content: >-
|
||||||
Upload and parse an export file from Trakt, Simkl, or Letterboxd.
|
Upload and parse an export file from Trakt, Simkl, Letterboxd, or
|
||||||
Returns a preview of items found without importing anything.
|
Sofa. Returns a preview of items found without importing anything.
|
||||||
---
|
---
|
||||||
|
|
||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
<APIPage document={"./public/openapi.json"} operations={[{"path":"/imports/parse-file","method":"post"}]} />
|
<APIPage document={"./public/openapi.json"} operations={[{"path":"/imports/parse-file","method":"post"}]} />
|
||||||
+87
-21
@@ -6136,7 +6136,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"operationId": "imports.parseFile",
|
"operationId": "imports.parseFile",
|
||||||
"summary": "Parse import file",
|
"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.",
|
"description": "Upload and parse an export file from Trakt, Simkl, Letterboxd, or Sofa. Returns a preview of items found without importing anything.",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Imports"
|
"Imports"
|
||||||
],
|
],
|
||||||
@@ -6151,10 +6151,11 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"trakt",
|
"trakt",
|
||||||
"simkl",
|
"simkl",
|
||||||
"letterboxd"
|
"letterboxd",
|
||||||
|
"sofa"
|
||||||
],
|
],
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "External service to import from"
|
"description": "Service to import from"
|
||||||
},
|
},
|
||||||
"file": {
|
"file": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -6175,10 +6176,11 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"trakt",
|
"trakt",
|
||||||
"simkl",
|
"simkl",
|
||||||
"letterboxd"
|
"letterboxd",
|
||||||
|
"sofa"
|
||||||
],
|
],
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "External service to import from"
|
"description": "Service to import from"
|
||||||
},
|
},
|
||||||
"file": {
|
"file": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -6208,10 +6210,11 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"trakt",
|
"trakt",
|
||||||
"simkl",
|
"simkl",
|
||||||
"letterboxd"
|
"letterboxd",
|
||||||
|
"sofa"
|
||||||
],
|
],
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "External service to import from"
|
"description": "Service to import from"
|
||||||
},
|
},
|
||||||
"movies": {
|
"movies": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -6320,6 +6323,20 @@
|
|||||||
"tv"
|
"tv"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"watchlist",
|
||||||
|
"in_progress",
|
||||||
|
"completed"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"description": "Library status (default: watchlist)"
|
||||||
|
},
|
||||||
|
"addedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "When the item was added to library"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -6548,10 +6565,11 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"trakt",
|
"trakt",
|
||||||
"simkl",
|
"simkl",
|
||||||
"letterboxd"
|
"letterboxd",
|
||||||
|
"sofa"
|
||||||
],
|
],
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "External service to import from"
|
"description": "Service to import from"
|
||||||
},
|
},
|
||||||
"movies": {
|
"movies": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -6660,6 +6678,20 @@
|
|||||||
"tv"
|
"tv"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"watchlist",
|
||||||
|
"in_progress",
|
||||||
|
"completed"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"description": "Library status (default: watchlist)"
|
||||||
|
},
|
||||||
|
"addedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "When the item was added to library"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -6750,10 +6782,11 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"trakt",
|
"trakt",
|
||||||
"simkl",
|
"simkl",
|
||||||
"letterboxd"
|
"letterboxd",
|
||||||
|
"sofa"
|
||||||
],
|
],
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "External service to import from"
|
"description": "Service to import from"
|
||||||
},
|
},
|
||||||
"movies": {
|
"movies": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -6862,6 +6895,20 @@
|
|||||||
"tv"
|
"tv"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"watchlist",
|
||||||
|
"in_progress",
|
||||||
|
"completed"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"description": "Library status (default: watchlist)"
|
||||||
|
},
|
||||||
|
"addedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "When the item was added to library"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -7019,10 +7066,11 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"trakt",
|
"trakt",
|
||||||
"simkl",
|
"simkl",
|
||||||
"letterboxd"
|
"letterboxd",
|
||||||
|
"sofa"
|
||||||
],
|
],
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "External service to import from"
|
"description": "Service to import from"
|
||||||
},
|
},
|
||||||
"movies": {
|
"movies": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -7131,6 +7179,20 @@
|
|||||||
"tv"
|
"tv"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"watchlist",
|
||||||
|
"in_progress",
|
||||||
|
"completed"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"description": "Library status (default: watchlist)"
|
||||||
|
},
|
||||||
|
"addedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "When the item was added to library"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -7244,10 +7306,11 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"trakt",
|
"trakt",
|
||||||
"simkl",
|
"simkl",
|
||||||
"letterboxd"
|
"letterboxd",
|
||||||
|
"sofa"
|
||||||
],
|
],
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "External service to import from"
|
"description": "Service to import from"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -7525,10 +7588,11 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"trakt",
|
"trakt",
|
||||||
"simkl",
|
"simkl",
|
||||||
"letterboxd"
|
"letterboxd",
|
||||||
|
"sofa"
|
||||||
],
|
],
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "External service to import from"
|
"description": "Service to import from"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -7674,10 +7738,11 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"trakt",
|
"trakt",
|
||||||
"simkl",
|
"simkl",
|
||||||
"letterboxd"
|
"letterboxd",
|
||||||
|
"sofa"
|
||||||
],
|
],
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "External service to import from"
|
"description": "Service to import from"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -7903,10 +7968,11 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"trakt",
|
"trakt",
|
||||||
"simkl",
|
"simkl",
|
||||||
"letterboxd"
|
"letterboxd",
|
||||||
|
"sofa"
|
||||||
],
|
],
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "External service to import from"
|
"description": "Service to import from"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"enum": [
|
"enum": [
|
||||||
|
|||||||
@@ -733,7 +733,7 @@ export const contract = {
|
|||||||
tags: ["Imports"],
|
tags: ["Imports"],
|
||||||
summary: "Parse import file",
|
summary: "Parse import file",
|
||||||
description:
|
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",
|
successDescription: "Preview of importable items with counts",
|
||||||
})
|
})
|
||||||
.input(ParseFileInput)
|
.input(ParseFileInput)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const AppErrorCode = {
|
|||||||
IMPORT_ALREADY_RUNNING: "IMPORT_ALREADY_RUNNING",
|
IMPORT_ALREADY_RUNNING: "IMPORT_ALREADY_RUNNING",
|
||||||
IMPORT_CANNOT_CANCEL: "IMPORT_CANNOT_CANCEL",
|
IMPORT_CANNOT_CANCEL: "IMPORT_CANNOT_CANCEL",
|
||||||
REGISTRATION_CLOSED: "REGISTRATION_CLOSED",
|
REGISTRATION_CLOSED: "REGISTRATION_CLOSED",
|
||||||
|
EXPORT_FAILED: "EXPORT_FAILED",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AppErrorCode = (typeof AppErrorCode)[keyof typeof AppErrorCode];
|
export type AppErrorCode = (typeof AppErrorCode)[keyof typeof AppErrorCode];
|
||||||
|
|||||||
@@ -947,8 +947,8 @@ export const AuthConfigOutput = z
|
|||||||
// ─── Imports ──────────────────────────────────────────────────
|
// ─── Imports ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export const ImportSourceEnum = z
|
export const ImportSourceEnum = z
|
||||||
.enum(["trakt", "simkl", "letterboxd"])
|
.enum(["trakt", "simkl", "letterboxd", "sofa"])
|
||||||
.describe("External service to import from");
|
.describe("Service to import from");
|
||||||
|
|
||||||
export const ImportMovieSchema = z.object({
|
export const ImportMovieSchema = z.object({
|
||||||
tmdbId: z.number().optional(),
|
tmdbId: z.number().optional(),
|
||||||
@@ -971,6 +971,8 @@ export const ImportEpisodeSchema = z.object({
|
|||||||
watchedOn: z.string().date().optional(),
|
watchedOn: z.string().date().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const TitleStatusEnum = z.enum(["watchlist", "in_progress", "completed"]);
|
||||||
|
|
||||||
export const ImportWatchlistItemSchema = z.object({
|
export const ImportWatchlistItemSchema = z.object({
|
||||||
tmdbId: z.number().optional(),
|
tmdbId: z.number().optional(),
|
||||||
imdbId: z.string().optional(),
|
imdbId: z.string().optional(),
|
||||||
@@ -978,6 +980,12 @@ export const ImportWatchlistItemSchema = z.object({
|
|||||||
title: z.string(),
|
title: z.string(),
|
||||||
year: z.number().optional(),
|
year: z.number().optional(),
|
||||||
type: z.enum(["movie", "tv"]),
|
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({
|
export const ImportRatingSchema = z.object({
|
||||||
@@ -1070,6 +1078,55 @@ export const ImportJobEvent = z.object({
|
|||||||
job: ImportJobSchema,
|
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
|
// Inferred types — use these instead of hand-written interfaces
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"./credits": "./src/credits.ts",
|
"./credits": "./src/credits.ts",
|
||||||
"./cron": "./src/cron.ts",
|
"./cron": "./src/cron.ts",
|
||||||
"./discovery": "./src/discovery.ts",
|
"./discovery": "./src/discovery.ts",
|
||||||
|
"./export": "./src/export.ts",
|
||||||
"./image-cache": "./src/image-cache.ts",
|
"./image-cache": "./src/image-cache.ts",
|
||||||
"./imports": "./src/imports/index.ts",
|
"./imports": "./src/imports/index.ts",
|
||||||
"./imports/parsers": "./src/imports/parsers.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,
|
parseSimklPayload,
|
||||||
parseTraktPayload,
|
parseTraktPayload,
|
||||||
} from "./parsers";
|
} from "./parsers";
|
||||||
|
export { parseSofaExport } from "./sofa-parser";
|
||||||
export {
|
export {
|
||||||
type ImportOptions,
|
type ImportOptions,
|
||||||
type ImportResult,
|
type ImportResult,
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface ImportWatchlistItem {
|
|||||||
title: string;
|
title: string;
|
||||||
year?: number;
|
year?: number;
|
||||||
type: "movie" | "tv";
|
type: "movie" | "tv";
|
||||||
|
status?: "watchlist" | "in_progress" | "completed";
|
||||||
|
addedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportRating {
|
export interface ImportRating {
|
||||||
@@ -46,7 +48,7 @@ export interface ImportRating {
|
|||||||
ratedOn?: string;
|
ratedOn?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImportSource = "trakt" | "simkl" | "letterboxd";
|
export type ImportSource = "trakt" | "simkl" | "letterboxd" | "sofa";
|
||||||
|
|
||||||
export interface NormalizedImport {
|
export interface NormalizedImport {
|
||||||
source: ImportSource;
|
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: {
|
export function parseSimklPayload(data: {
|
||||||
movies?: SimklItem[];
|
movies?: SimklItem[];
|
||||||
shows?: SimklItem[];
|
shows?: SimklItem[];
|
||||||
@@ -335,15 +352,25 @@ export function parseSimklPayload(data: {
|
|||||||
? Number(item.ids.tmdb)
|
? Number(item.ids.tmdb)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (item.status === "plantowatch") {
|
const sofaStatus = mapSimklStatus(item.status);
|
||||||
|
if (sofaStatus) {
|
||||||
watchlist.push({
|
watchlist.push({
|
||||||
tmdbId: tmdbId ?? undefined,
|
tmdbId: tmdbId ?? undefined,
|
||||||
imdbId: item.ids?.imdb,
|
imdbId: item.ids?.imdb,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
year: item.year,
|
year: item.year,
|
||||||
type: "movie",
|
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({
|
movies.push({
|
||||||
tmdbId: tmdbId ?? undefined,
|
tmdbId: tmdbId ?? undefined,
|
||||||
imdbId: item.ids?.imdb,
|
imdbId: item.ids?.imdb,
|
||||||
@@ -385,7 +412,8 @@ export function parseSimklPayload(data: {
|
|||||||
? Number(item.ids.tvdb)
|
? Number(item.ids.tvdb)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (item.status === "plantowatch") {
|
const sofaStatus = mapSimklStatus(item.status);
|
||||||
|
if (sofaStatus) {
|
||||||
watchlist.push({
|
watchlist.push({
|
||||||
tmdbId: tmdbId ?? undefined,
|
tmdbId: tmdbId ?? undefined,
|
||||||
imdbId: item.ids?.imdb,
|
imdbId: item.ids?.imdb,
|
||||||
@@ -393,6 +421,7 @@ export function parseSimklPayload(data: {
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
year: item.year,
|
year: item.year,
|
||||||
type: "tv",
|
type: "tv",
|
||||||
|
status: sofaStatus,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
hasEpisodeWatch,
|
hasEpisodeWatch,
|
||||||
hasMovieWatch,
|
hasMovieWatch,
|
||||||
hasRating,
|
hasRating,
|
||||||
hasTitleStatus,
|
getTitleStatusValue,
|
||||||
updateImportJobProgress,
|
updateImportJobProgress,
|
||||||
} from "@sofa/db/queries/imports";
|
} from "@sofa/db/queries/imports";
|
||||||
import { findEpisodeBySeasonAndNumber, findSeasonByTitleAndNumber } from "@sofa/db/queries/title";
|
import { findEpisodeBySeasonAndNumber, findSeasonByTitleAndNumber } from "@sofa/db/queries/title";
|
||||||
@@ -187,12 +187,17 @@ async function processWatchlistItem(
|
|||||||
return;
|
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++;
|
result.skipped++;
|
||||||
return;
|
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++;
|
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 {
|
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
|
const existing = db
|
||||||
.select({ status: userTitleStatus.status })
|
.select({ status: userTitleStatus.status })
|
||||||
.from(userTitleStatus)
|
.from(userTitleStatus)
|
||||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
||||||
.get();
|
.get();
|
||||||
return !!existing;
|
return (existing?.status as "watchlist" | "in_progress" | "completed") ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasRating(userId: string, titleId: string): boolean {
|
export function hasRating(userId: string, titleId: string): boolean {
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ export const importJobs = sqliteTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
source: text("source", {
|
source: text("source", {
|
||||||
enum: ["trakt", "simkl", "letterboxd"],
|
enum: ["trakt", "simkl", "letterboxd", "sofa"],
|
||||||
}).notNull(),
|
}).notNull(),
|
||||||
status: text("status", {
|
status: text("status", {
|
||||||
enum: ["pending", "running", "success", "error", "cancelled"],
|
enum: ["pending", "running", "success", "error", "cancelled"],
|
||||||
|
|||||||
Reference in New Issue
Block a user