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:
2026-03-21 17:12:15 -04:00
parent 58cf2e689f
commit 61013ee41f
23 changed files with 1214 additions and 39 deletions
+64
View File
@@ -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(),
})),
};
}
+1
View File
@@ -12,6 +12,7 @@ export {
parseSimklPayload,
parseTraktPayload,
} from "./parsers";
export { parseSofaExport } from "./sofa-parser";
export {
type ImportOptions,
type ImportResult,
+33 -4
View File
@@ -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,
});
}
+8 -3
View File
@@ -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++;
}
+67
View File
@@ -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 },
};
}