feat: add watch history import from Trakt, Simkl, and Letterboxd (#13)

This commit is contained in:
2026-03-17 11:02:58 -04:00
committed by GitHub
parent 25736f684a
commit 710c43a01f
41 changed files with 10230 additions and 106 deletions
+3 -3
View File
@@ -64,7 +64,7 @@ All shared packages are JIT (raw TypeScript exports, no build step).
- **`@sofa/server`** — Hono API server. Hosts oRPC procedures, Better Auth, webhook/image/backup routes, and cron jobs. Runs DB migrations on startup. Dev: port 3001. Prod: serves SPA static files too, port 3000.
- **`@sofa/web`** — Vite SPA with TanStack Router (file-based routing). No SSR, no DB. All data via oRPC. Vite dev server proxies `/api/*` and `/rpc/*` to the API server.
- **`@sofa/native`** — Expo Router app with 4-tab layout (Home, Explore, Search, Settings). UniWind for styling, `@better-auth/expo` with SecureStore for auth, oRPC client for API calls. Dark-only cinema theme matching web.
- **`@sofa/public-api`** — Minimal Hono microservice deployed on Vercel. Two endpoints: `GET /v1/version` (latest release from GitHub) and `POST /v1/telemetry` (forwards instance stats to PostHog). Dev: port 3002.
- **`@sofa/public-api`** — Minimal Hono microservice deployed on Vercel. Endpoints: `GET /v1/version` (latest release from GitHub), `POST /v1/telemetry` (forwards instance stats to PostHog), and OAuth device-code proxy for Trakt/Simkl imports (`/v1/import/:provider/device-code`, `/v1/import/:provider/poll`). Dev: port 3002.
- **`sofa-docs`** — Fumadocs site (Next.js) with landing page, Markdown docs, and auto-generated OpenAPI API reference. Content lives in `docs/content/docs/`. API docs are generated from `docs/openapi.json` via `fumadocs-openapi`.
### Stack
@@ -87,7 +87,7 @@ Cross-package imports:
- `@sofa/api/contract`, `@sofa/api/schemas` — Contract and Zod types
- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/helpers`, `@sofa/db/migrate`, `@sofa/db/test-utils`
- `@sofa/tmdb/client`, `@sofa/tmdb/image`
- `@sofa/core/metadata`, `@sofa/core/tracking`, etc.
- `@sofa/core/metadata`, `@sofa/core/tracking`, `@sofa/core/imports`, etc.
- `@sofa/auth/server`, `@sofa/auth/config`
- `@sofa/config` — Path constants (`DATA_DIR`, `DATABASE_URL`, `CACHE_DIR`, `BACKUP_DIR`, `AVATAR_DIR`) and TMDB URLs
- `@sofa/logger``createLogger(name)` for structured logging
@@ -109,7 +109,7 @@ Required: `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`.
Optional:
- `DATA_DIR` — Root for DB + cache (default `./data`). `DATABASE_URL` and `CACHE_DIR` derived from it but overridable.
- `TMDB_API_BASE_URL`, `TMDB_IMAGE_BASE_URL` — Override TMDB endpoints.
- `PUBLIC_API_URL` — Base URL for centralized public API (default: `https://public-api.sofa.watch`). Used for update checks.
- `PUBLIC_API_URL` — Base URL for centralized public API (default: `https://public-api.sofa.watch`). Used for update checks and OAuth import proxy.
- `IMAGE_CACHE_ENABLED` — Default `true`. Set `false` for direct TMDB CDN URLs.
- `LOG_LEVEL``error`/`warn`/`info`/`debug` (default: `info`).
- OIDC: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER_URL`, `OIDC_PROVIDER_NAME`, `OIDC_AUTO_REGISTER`, `DISABLE_PASSWORD_LOGIN`.
+1
View File
@@ -16,6 +16,7 @@ Sofa is a self-hosted movie and TV tracker for nerds. Track what you've watched,
- Search TMDB and explore trending movies and shows without leaving your own instance
- Show streaming availability from TMDB's US provider data
- Automatically log completed watches from Plex, Jellyfin, or Emby webhooks
- Import existing watch history, ratings, and watchlists from Trakt, Simkl, or Letterboxd
- Expose your watchlist as import lists for Sonarr and Radarr
- Runs on SQLite with local image caching, built-in backups, and no external database requirement
- Supports local accounts or OIDC SSO for private instances
+6
View File
@@ -0,0 +1,6 @@
TRAKT_CLIENT_ID=
TRAKT_CLIENT_SECRET=
SIMKL_CLIENT_ID=
SIMKL_CLIENT_SECRET=
GITHUB_TOKEN=
POSTHOG_API_KEY=
+6 -1
View File
@@ -11,7 +11,12 @@
"check-types": "tsc --noEmit"
},
"dependencies": {
"hono": "4.12.8"
"@hono/zod-validator": "0.7.6",
"@sofa/api": "workspace:*",
"@sofa/core": "workspace:*",
"@vercel/firewall": "1.1.2",
"hono": "4.12.8",
"zod": "catalog:"
},
"devDependencies": {
"@types/bun": "catalog:",
+174 -31
View File
@@ -1,5 +1,9 @@
import { zValidator } from "@hono/zod-validator";
import { checkRateLimit } from "@vercel/firewall";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { z } from "zod";
import { getProvider, getProviderConfig } from "./providers";
const GITHUB_RELEASES_URL =
"https://api.github.com/repos/jakejarvis/sofa/releases/latest";
@@ -8,6 +12,8 @@ const app = new Hono();
app.use("*", cors());
// ─── Version Check ──────────────────────────────────────────
app.get("/v1/version", async (c) => {
try {
const res = await fetch(GITHUB_RELEASES_URL, {
@@ -44,42 +50,179 @@ app.get("/v1/version", async (c) => {
}
});
app.post("/v1/telemetry", async (c) => {
const body = await c.req.json();
// ─── Telemetry ──────────────────────────────────────────────
if (!body.instanceId || !body.version) {
return c.json({ error: "Missing required fields" }, 400);
}
app.post(
"/v1/telemetry",
zValidator(
"json",
z.object({
instanceId: z.string().min(1),
version: z.string().min(1),
arch: z.string().optional(),
users: z.number().optional(),
titles: z.number().optional(),
features: z.record(z.string(), z.unknown()).optional(),
}),
),
async (c) => {
const body = c.req.valid("json");
const posthogKey = process.env.POSTHOG_API_KEY;
if (!posthogKey) {
return c.body(null, 204);
}
try {
await fetch("https://us.i.posthog.com/i/v0/e/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: posthogKey,
event: "instance_report",
distinct_id: body.instanceId,
properties: {
version: body.version,
arch: body.arch,
users: body.users,
titles: body.titles,
...(body.features ?? {}),
},
}),
signal: AbortSignal.timeout(10_000),
});
} catch {
// Fire-and-forget — don't fail the request if PostHog is down
}
const posthogKey = process.env.POSTHOG_API_KEY;
if (!posthogKey) {
// return okay, this isn't the user's problem
return c.body(null, 204);
}
},
);
try {
await fetch("https://us.i.posthog.com/i/v0/e/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: posthogKey,
event: "instance_report",
distinct_id: body.instanceId,
properties: {
version: body.version,
arch: body.arch,
users: body.users,
titles: body.titles,
...(body.features ?? {}),
// ─── Import OAuth Proxy ─────────────────────────────────────
// Rate limiting: Configure via Vercel dashboard WAF/rate-limit rules:
// - "import-device-code" — device-code initiation (e.g. 5 req/IP/300 sec)
// - "import-poll" — token polling (e.g. 60 req/IP/minute)
// The self-hosted server also prevents concurrent imports per user
// via the importJobs table.
const ProviderEnum = z.enum(["trakt", "simkl"]);
app.post(
"/v1/import/:provider/device-code",
zValidator(
"param",
z.object({
provider: ProviderEnum,
}),
),
async (c) => {
try {
const { rateLimited } = await checkRateLimit("import-device-code", {
request: c.req.raw,
});
if (rateLimited) {
return c.json({ error: "Rate limit exceeded" }, 429);
}
} catch (e) {
// Rate limiter error (e.g. WAF rule not configured) — fail open but log
console.warn("checkRateLimit error (import-device-code):", e);
}
const { provider: providerName } = c.req.valid("param");
const provider = getProvider(providerName);
const config = getProviderConfig(providerName);
if (!provider || !config.clientId) {
return c.json({ error: `${providerName} is not configured` }, 503);
}
try {
const result = await provider.getDeviceCode(
config.clientId,
config.clientSecret,
);
return c.json(result);
} catch (e) {
return c.json(
{
error: e instanceof Error ? e.message : "Failed to get device code",
},
}),
signal: AbortSignal.timeout(10_000),
});
} catch {
// Fire-and-forget — don't fail the request if PostHog is down
}
502,
);
}
},
);
return c.body(null, 204);
});
app.post(
"/v1/import/:provider/poll",
zValidator(
"param",
z.object({
provider: ProviderEnum,
}),
),
zValidator(
"json",
z.object({
device_code: z.string().min(1).max(256),
}),
),
async (c) => {
try {
const { rateLimited } = await checkRateLimit("import-poll", {
request: c.req.raw,
});
if (rateLimited) {
return c.json({ error: "Rate limit exceeded" }, 429);
}
} catch (e) {
// Rate limiter error (e.g. WAF rule not configured) — fail open but log
console.warn("checkRateLimit error (import-poll):", e);
}
const { provider: providerName } = c.req.valid("param");
const { device_code } = c.req.valid("json");
const provider = getProvider(providerName);
const config = getProviderConfig(providerName);
if (!provider || !config.clientId) {
return c.json({ error: `${providerName} is not configured` }, 503);
}
try {
const result = await provider.pollForToken(
config.clientId,
config.clientSecret,
device_code,
);
if (result.status !== "authorized") {
return c.json({ status: result.status });
}
// Fetch user data and return it inline
try {
const data = await provider.fetchUserData(
result.accessToken,
config.clientId,
);
return c.json({ status: "authorized", data });
} catch (e) {
// Auth succeeded but data fetch failed. Return a distinct status so
// the client can show a meaningful error instead of polling forever.
return c.json({
status: "fetch_error",
error: e instanceof Error ? e.message : "Failed to fetch user data",
});
}
} catch (e) {
return c.json(
{ error: e instanceof Error ? e.message : "Poll failed" },
502,
);
}
},
);
export default app;
+34
View File
@@ -0,0 +1,34 @@
import { simkl } from "./simkl";
import { trakt } from "./trakt";
import type { ImportProvider } from "./types";
const providers: Record<string, ImportProvider> = {
trakt,
simkl,
};
export function getProvider(name: string): ImportProvider | undefined {
return providers[name];
}
export function getProviderConfig(name: string): {
clientId: string;
clientSecret: string;
} {
switch (name) {
case "trakt":
return {
clientId: process.env.TRAKT_CLIENT_ID ?? "",
clientSecret: process.env.TRAKT_CLIENT_SECRET ?? "",
};
case "simkl":
return {
clientId: process.env.SIMKL_CLIENT_ID ?? "",
clientSecret: process.env.SIMKL_CLIENT_SECRET ?? "",
};
default:
return { clientId: "", clientSecret: "" };
}
}
export type { DeviceCodeResponse, ImportProvider } from "./types";
+163
View File
@@ -0,0 +1,163 @@
import { parseSimklPayload } from "@sofa/core/imports/parsers";
import type {
DeviceCodeResponse,
ImportProvider,
NormalizedImport,
PollResult,
} from "./types";
const API_BASE = "https://api.simkl.com";
const AUTH_BASE = "https://simkl.com";
function simklHeaders(
clientId: string,
token?: string,
): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"simkl-api-key": clientId,
};
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
}
// ─── Simkl API types ─────────────────────────────────────────
interface SimklIds {
imdb?: string;
tmdb?: string | number;
tvdb?: string | number;
}
interface SimklApiItem {
status?: string;
user_rating?: number;
last_watched_at?: string;
movie?: { title?: string; year?: number; ids?: SimklIds };
show?: { title?: string; year?: number; ids?: SimklIds };
seasons?: {
number?: number;
episodes?: { number?: number; watched_at?: string }[];
}[];
}
/** Flatten Simkl API response items into the shape parseSimklPayload expects.
* When fetched with `episode_watched_at=yes`, the API returns ALL episodes
* in the seasons array — filter to only those with a `watched_at` timestamp
* so unwatched episodes don't get imported as watched. */
function flattenSimklItems(items: SimklApiItem[], mediaKey: "movie" | "show") {
return items.map((item) => {
const media = item[mediaKey];
// Strip unwatched episodes from API response (they lack watched_at),
// then drop seasons that end up empty so the parser's missing-episode
// warning still fires correctly.
const filteredSeasons = item.seasons
?.map((s) => ({
...s,
episodes: s.episodes?.filter((ep) => ep.watched_at),
}))
.filter((s) => s.episodes && s.episodes.length > 0);
return {
title: media?.title,
year: media?.year,
ids: media?.ids,
status: item.status,
user_rating: item.user_rating,
last_watched_at: item.last_watched_at,
seasons: filteredSeasons,
};
});
}
// ─── Provider ────────────────────────────────────────────────
export const simkl: ImportProvider = {
async getDeviceCode(clientId): Promise<DeviceCodeResponse> {
const res = await fetch(`${API_BASE}/oauth/pin?client_id=${clientId}`, {
method: "GET",
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`Simkl device code failed: ${res.status}`);
const data = (await res.json()) as {
device_code: string;
user_code: string;
verification_url: string;
expires_in: number;
interval: number;
};
return {
device_code: data.user_code, // Simkl checks PIN status by user_code, not device_code
user_code: data.user_code,
verification_url: data.verification_url || `${AUTH_BASE}/pin`,
expires_in: data.expires_in,
interval: data.interval || 5,
};
},
async pollForToken(clientId, _clientSecret, deviceCode): Promise<PollResult> {
const res = await fetch(`${API_BASE}/oauth/pin/${deviceCode}`, {
method: "GET",
headers: { "simkl-api-key": clientId },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return { status: "pending" };
const data = (await res.json()) as {
result?: string;
access_token?: string;
};
if (data.result === "OK" && data.access_token) {
return { status: "authorized", accessToken: data.access_token };
}
if (data.result === "KO") return { status: "denied" };
return { status: "pending" };
},
async fetchUserData(accessToken, clientId): Promise<NormalizedImport> {
const headers = simklHeaders(clientId, accessToken);
// Fetch movies, shows, and anime in parallel
const [moviesRes, showsRes, animeRes] = await Promise.all([
fetch(`${API_BASE}/sync/all-items/movies`, { headers }),
fetch(
`${API_BASE}/sync/all-items/shows?extended=full&episode_watched_at=yes`,
{ headers },
),
fetch(
`${API_BASE}/sync/all-items/anime?extended=full&episode_watched_at=yes`,
{ headers },
),
]);
// If all endpoints failed, throw so the caller gets a clear error
if (!moviesRes.ok && !showsRes.ok && !animeRes.ok) {
throw new Error(
`Simkl API returned errors: movies ${moviesRes.status}, shows ${showsRes.status}, anime ${animeRes.status}`,
);
}
const [moviesData, showsData, animeData] = await Promise.all([
moviesRes.ok
? (moviesRes.json() as Promise<SimklApiItem[]>)
: ([] as SimklApiItem[]),
showsRes.ok
? (showsRes.json() as Promise<SimklApiItem[]>)
: ([] as SimklApiItem[]),
animeRes.ok
? (animeRes.json() as Promise<SimklApiItem[]>)
: ([] as SimklApiItem[]),
]);
// Flatten API's nested movie/show objects into the flat format
// the core parser expects, then delegate normalization
const result = parseSimklPayload({
movies: flattenSimklItems(moviesData, "movie"),
shows: flattenSimklItems(showsData, "show"),
anime: flattenSimklItems(animeData, "show"),
});
return result.data;
},
};
+104
View File
@@ -0,0 +1,104 @@
import { parseTraktPayload } from "@sofa/core/imports/parsers";
import type {
DeviceCodeResponse,
ImportProvider,
NormalizedImport,
PollResult,
} from "./types";
const API_BASE = "https://api.trakt.tv";
function traktHeaders(
clientId: string,
token?: string,
): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"trakt-api-version": "2",
"trakt-api-key": clientId,
};
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
}
// ─── Provider ────────────────────────────────────────────────
export const trakt: ImportProvider = {
async getDeviceCode(clientId): Promise<DeviceCodeResponse> {
const res = await fetch(`${API_BASE}/oauth/device/code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: clientId }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`Trakt device code failed: ${res.status}`);
return (await res.json()) as DeviceCodeResponse;
},
async pollForToken(clientId, clientSecret, deviceCode): Promise<PollResult> {
const res = await fetch(`${API_BASE}/oauth/device/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: deviceCode,
client_id: clientId,
client_secret: clientSecret,
}),
signal: AbortSignal.timeout(10_000),
});
if (res.status === 200) {
const data = (await res.json()) as { access_token: string };
return { status: "authorized", accessToken: data.access_token };
}
if (res.status === 400) return { status: "pending" };
if (res.status === 404) return { status: "expired" };
if (res.status === 410) return { status: "expired" };
if (res.status === 418) return { status: "denied" };
if (res.status === 429) return { status: "pending" };
// 5xx: likely transient — keep polling
if (res.status >= 500) return { status: "pending" };
// Unknown 4xx: likely permanent — treat as expired
return { status: "expired" };
},
async fetchUserData(accessToken, clientId): Promise<NormalizedImport> {
const headers = traktHeaders(clientId, accessToken);
// Fetch all data in parallel
const [moviesRes, showsRes, watchlistRes, ratingsRes] = await Promise.all([
fetch(`${API_BASE}/sync/history/movies?limit=10000`, { headers }),
fetch(`${API_BASE}/sync/history/shows?limit=10000`, { headers }),
fetch(`${API_BASE}/sync/watchlist?extended=metadata&limit=10000`, {
headers,
}),
fetch(`${API_BASE}/sync/ratings`, { headers }),
]);
// If all endpoints failed, throw so the caller gets a clear error
if (!moviesRes.ok && !showsRes.ok && !watchlistRes.ok && !ratingsRes.ok) {
throw new Error(
`Trakt API returned errors: movies ${moviesRes.status}, shows ${showsRes.status}, watchlist ${watchlistRes.status}, ratings ${ratingsRes.status}`,
);
}
const [moviesData, showsData, watchlistData, ratingsData] =
await Promise.all([
moviesRes.ok ? moviesRes.json() : [],
showsRes.ok ? showsRes.json() : [],
watchlistRes.ok ? watchlistRes.json() : [],
ratingsRes.ok ? ratingsRes.json() : [],
]);
// Restructure API response into the format parseTraktPayload expects.
// The Trakt API returns the same item shapes as the JSON export format.
type TraktPayload = Parameters<typeof parseTraktPayload>[0];
const result = parseTraktPayload({
history: { movies: moviesData, shows: showsData },
watchlist: watchlistData,
ratings: ratingsData,
} as TraktPayload);
return result.data;
},
};
+33
View File
@@ -0,0 +1,33 @@
import type { NormalizedImport } from "@sofa/api/schemas";
export type { NormalizedImport };
export interface DeviceCodeResponse {
device_code: string;
user_code: string;
verification_url: string;
expires_in: number;
interval: number;
}
export type PollResult =
| { status: "pending" }
| { status: "authorized"; accessToken: string }
| { status: "expired" }
| { status: "denied" };
export interface ImportProvider {
getDeviceCode(
clientId: string,
clientSecret: string,
): Promise<DeviceCodeResponse>;
pollForToken(
clientId: string,
clientSecret: string,
deviceCode: string,
): Promise<PollResult>;
fetchUserData(
accessToken: string,
clientId: string,
): Promise<NormalizedImport>;
}
+217
View File
@@ -0,0 +1,217 @@
import type { ParseResult } from "@sofa/core/imports";
import {
countUnresolved,
parseLetterboxdExport,
parseSimklPayload,
parseTraktPayload,
processImportJob,
readImportJob,
} from "@sofa/core/imports";
import { db } from "@sofa/db/client";
import { and, eq, inArray } from "@sofa/db/helpers";
import { importJobs } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { os } from "../context";
import { authed } from "../middleware";
const log = createLogger("imports");
export const parseFile = os.imports.parseFile
.use(authed)
.handler(async ({ input }) => {
const { source, file } = input;
let result: ParseResult;
switch (source) {
case "letterboxd":
result = await parseLetterboxdExport(file);
break;
case "trakt": {
let json: unknown;
try {
json = await file.json();
} catch {
throw new Error(
"Invalid JSON file. Ensure it is a valid Trakt export.",
);
}
result = parseTraktPayload(
json as Parameters<typeof parseTraktPayload>[0],
);
break;
}
case "simkl": {
let json: unknown;
try {
json = await file.json();
} catch {
throw new Error(
"Invalid JSON file. Ensure it is a valid Simkl export.",
);
}
result = parseSimklPayload(
json as Parameters<typeof parseSimklPayload>[0],
);
break;
}
}
return {
data: result.data,
warnings: result.warnings,
diagnostics: result.diagnostics,
stats: {
movies: result.data.movies.length,
episodes: result.data.episodes.length,
watchlist: result.data.watchlist.length,
ratings: result.data.ratings.length,
},
};
});
export const parsePayload = os.imports.parsePayload
.use(authed)
.handler(({ input }) => {
const { data } = input;
return {
data,
warnings: [],
diagnostics: { unresolved: countUnresolved(data), unsupported: 0 },
stats: {
movies: data.movies.length,
episodes: data.episodes.length,
watchlist: data.watchlist.length,
ratings: data.ratings.length,
},
};
});
export const createJob = os.imports.createJob
.use(authed)
.handler(async ({ input, context }) => {
const { data, options } = input;
// Guard against oversized payloads
const totalItems =
data.movies.length +
data.episodes.length +
data.watchlist.length +
data.ratings.length;
if (totalItems > 100_000) {
throw new Error(
`Import payload too large (${totalItems} items, max 100,000)`,
);
}
// Prevent concurrent imports per user.
// Auto-cancel stale *pending* jobs (server crashed before worker started).
// Running jobs are never auto-cancelled — there's no heartbeat to
// distinguish active work from a dead worker, and killing a healthy
// long-running import is worse than making the user manually cancel.
const PENDING_STALE_MS = 5 * 60 * 1000; // 5 minutes
const now = Date.now();
const existing = db
.select()
.from(importJobs)
.where(
and(
eq(importJobs.userId, context.user.id),
inArray(importJobs.status, ["pending", "running"]),
),
)
.get();
if (existing) {
const isPending = existing.status === "pending";
const isStale =
isPending && now - existing.createdAt.getTime() > PENDING_STALE_MS;
if (isStale) {
// Mark as cancelled so the worker loop also stops if it starts late
db.update(importJobs)
.set({
status: "cancelled",
finishedAt: new Date(),
currentMessage: "Import timed out (stale job auto-cancelled)",
})
.where(eq(importJobs.id, existing.id))
.run();
log.warn(`Auto-cancelled stale import job ${existing.id}`);
} else {
throw new Error("An import is already in progress");
}
}
const job = db
.insert(importJobs)
.values({
userId: context.user.id,
source: data.source,
status: "pending",
payload: JSON.stringify(data),
importWatches: options.importWatches,
importWatchlist: options.importWatchlist,
importRatings: options.importRatings,
createdAt: new Date(),
})
.returning()
.get();
// Fire-and-forget processing
processImportJob(job.id).catch((err) => {
log.error(`Import job ${job.id} failed:`, err);
});
return readImportJob(job.id);
});
export const getJob = os.imports.getJob
.use(authed)
.handler(({ input, context }) => {
return readImportJob(input.id, context.user.id);
});
export const cancelJob = os.imports.cancelJob
.use(authed)
.handler(({ input, context }) => {
const job = readImportJob(input.id, context.user.id);
if (job.status !== "pending" && job.status !== "running") {
throw new Error("Can only cancel pending or running jobs");
}
db.update(importJobs)
.set({ status: "cancelled" })
.where(eq(importJobs.id, input.id))
.run();
return readImportJob(input.id);
});
export const jobEvents = os.imports.jobEvents
.use(authed)
.handler(async function* ({ input, context }) {
readImportJob(input.id, context.user.id);
const JOB_POLL_INTERVAL = 500;
const MAX_POLL_DURATION_MS = 30 * 60 * 1000; // 30 minutes
const startedAt = Date.now();
while (true) {
const job = readImportJob(input.id);
const isTerminal =
job.status === "success" ||
job.status === "error" ||
job.status === "cancelled";
yield {
type: (isTerminal ? "complete" : "progress") as "complete" | "progress",
job,
};
if (isTerminal) return;
if (Date.now() - startedAt > MAX_POLL_DURATION_MS) {
yield { type: "timeout" as const, job };
return;
}
await new Promise((resolve) => setTimeout(resolve, JOB_POLL_INTERVAL));
}
});
+4 -1
View File
@@ -4,7 +4,10 @@ import { os } from "../context";
import { admin, authed } from "../middleware";
export const status = os.system.status.use(authed).handler(() => {
return { tmdbConfigured: isTmdbConfigured() };
return {
tmdbConfigured: isTmdbConfigured(),
publicApiUrl: process.env.PUBLIC_API_URL || "https://public-api.sofa.watch",
};
});
export const health = os.system.health.use(admin).handler(async () => {
+9
View File
@@ -5,6 +5,7 @@ import * as dashboard from "./procedures/dashboard";
import { discover } from "./procedures/discover";
import * as episodes from "./procedures/episodes";
import * as explore from "./procedures/explore";
import * as imports from "./procedures/imports";
import * as integrations from "./procedures/integrations";
import * as people from "./procedures/people";
import { search } from "./procedures/search";
@@ -86,6 +87,14 @@ export const implementedRouter = {
uploadAvatar: account.uploadAvatar,
removeAvatar: account.removeAvatar,
},
imports: {
parseFile: imports.parseFile,
parsePayload: imports.parsePayload,
createJob: imports.createJob,
getJob: imports.getJob,
cancelJob: imports.cancelJob,
jobEvents: imports.jobEvents,
},
};
export const router = os.router(implementedRouter);
@@ -0,0 +1,964 @@
import type { NormalizedImport } from "@sofa/api/schemas";
import { IconCloudUpload, IconFileImport, IconLink } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner";
import { client, orpc } from "@/lib/orpc/client";
// ─── Source Configs ──────────────────────────────────────────
type ImportSource = "trakt" | "simkl" | "letterboxd";
interface SourceConfig {
source: ImportSource;
label: string;
description: string;
accept: string;
icon: React.ReactNode;
supportsOAuth: boolean;
}
// ─── Provider Logos ──────────────────────────────────────────
function TraktLogo({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
className={className}
aria-hidden
>
<title>Trakt</title>
<path
fill="currentColor"
d="m15.082 15.107l-.73-.73l9.578-9.583a5 5 0 0 0-.115-.575L13.662 14.382l1.08 1.08l-.73.73l-1.81-1.81l11.22-11.238c-.075-.15-.155-.3-.25-.44L11.508 14.377l2.154 2.155l-.73.73l-7.193-7.199l.73-.73l4.309 4.31L22.546 1.86A5.62 5.62 0 0 0 18.362 0H5.635A5.637 5.637 0 0 0 0 5.634V18.37A5.63 5.63 0 0 0 5.635 24h12.732C21.477 24 24 21.48 24 18.37V6.19l-8.913 8.918zm-4.314-2.155L6.814 8.988l.73-.73l3.954 3.96zm1.075-1.084l-3.954-3.96l.73-.73l3.959 3.96zm9.853 5.688a4.14 4.14 0 0 1-4.14 4.14H6.438a4.144 4.144 0 0 1-4.139-4.14V6.438A4.14 4.14 0 0 1 6.44 2.3h10.387v1.04H6.438a3.1 3.1 0 0 0-3.099 3.1v11.11c0 1.71 1.39 3.105 3.1 3.105h11.117c1.71 0 3.1-1.395 3.1-3.105v-1.754h1.04v1.754z"
/>
</svg>
);
}
function SimklLogo({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
className={className}
aria-hidden
>
<title>Simkl</title>
<path
fill="currentColor"
d="M3.84 0A3.83 3.83 0 0 0 0 3.84v16.32A3.83 3.83 0 0 0 3.84 24h16.32A3.83 3.83 0 0 0 24 20.16V3.84A3.83 3.83 0 0 0 20.16 0zm8.567 4.11q3.11 0 4.393.186q1.69.252 2.438.877q1.009.867 1.009 3.104q0 .241-.01.768h-4.234q-.021-.537-.074-.746q-.147-.615-.966-.692q-.725-.065-3.53-.066q-2.775 0-3.289.165q-.578.2-.578 1.024q0 .792.61.969q.514.143 4.633.275q3.73.11 4.76.275q1.04.165 1.654.495t.983.936q.556.892.557 2.873q0 2.212-.546 3.247q-.547 1.024-1.785 1.398q-1.219.374-6.71.374q-3.338 0-4.82-.187q-1.806-.22-2.593-.86q-.85-.684-1.008-1.93a10.5 10.5 0 0 1-.085-1.434v-.789H7.44q-.01 1.11.43 1.428q.232.151.525.203q.294.056 1.03.077a166 166 0 0 0 2.405.022q2.793-.01 3.234-.033q.83-.065 1.092-.23q.368-.242.368-1.077q0-.57-.231-.802q-.316-.318-1.503-.34q-.82 0-3.425-.132q-2.69-.133-3.488-.154q-2.08-.066-2.932-.505q-1.092-.56-1.429-1.91q-.189-.747-.189-1.956q0-2.547.925-3.59q.693-.79 2.102-1.044q1.271-.22 6.053-.22z"
/>
</svg>
);
}
function LetterboxdLogo({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
className={className}
aria-hidden
>
<title>Letterboxd</title>
<path
fill="currentColor"
d="M8.224 14.352a4.45 4.45 0 0 1-3.775 2.092C1.992 16.444 0 14.454 0 12s1.992-4.444 4.45-4.444c1.592 0 2.988.836 3.774 2.092c-.427.682-.673 1.488-.673 2.352s.246 1.67.673 2.352M15.101 12c0-.864.247-1.67.674-2.352c-.786-1.256-2.183-2.092-3.775-2.092s-2.989.836-3.775 2.092c.427.682.674 1.488.674 2.352s-.247 1.67-.674 2.352c.786 1.256 2.183 2.092 3.775 2.092s2.989-.836 3.775-2.092A4.4 4.4 0 0 1 15.1 12zm4.45-4.444a4.45 4.45 0 0 0-3.775 2.092c.427.682.673 1.488.673 2.352s-.246 1.67-.673 2.352a4.45 4.45 0 0 0 3.775 2.092C22.008 16.444 24 14.454 24 12s-1.992-4.444-4.45-4.444z"
/>
</svg>
);
}
const SOURCES: SourceConfig[] = [
{
source: "trakt",
label: "Trakt",
description: "Connect your Trakt account or upload a JSON export.",
accept: ".json",
icon: <TraktLogo className="size-4 text-primary" />,
supportsOAuth: true,
},
{
source: "simkl",
label: "Simkl",
description: "Connect your Simkl account or upload a JSON export.",
accept: ".json",
icon: <SimklLogo className="size-4 text-primary" />,
supportsOAuth: true,
},
{
source: "letterboxd",
label: "Letterboxd",
description: "Upload the ZIP export from your Letterboxd account settings.",
accept: ".zip",
icon: <LetterboxdLogo className="size-4 text-primary" />,
supportsOAuth: false,
},
];
// ─── Types ──────────────────────────────────────────────────
interface ImportPreview {
data: NormalizedImport;
warnings: string[];
diagnostics?: {
unresolved: number;
unsupported: number;
};
blockingErrors?: string[];
stats: {
movies: number;
episodes: number;
watchlist: number;
ratings: number;
};
}
interface ImportResult {
imported: number;
skipped: number;
failed: number;
errors: string[];
warnings: string[];
}
interface DeviceCodeInfo {
device_code: string;
user_code: string;
verification_url: string;
expires_in: number;
interval: number;
}
type DialogStep =
| "choose" // OAuth sources: choose between Connect or Upload
| "device-code" // displaying device code, polling
| "fetching" // OAuth authorized, fetching data from provider
| "preview" // showing parsed preview with options
| "importing" // import in progress
| "done"; // showing results
// ─── Section ────────────────────────────────────────────────
export function ImportsSection() {
return (
<div>
<div className="mb-3 flex items-center gap-2">
<IconFileImport aria-hidden className="size-4 text-muted-foreground" />
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
Import
</h2>
</div>
<div className="space-y-2.5">
{SOURCES.map((config) => (
<ImportSourceCard key={config.source} config={config} />
))}
</div>
</div>
);
}
// ─── Source Card ─────────────────────────────────────────────
function ImportSourceCard({ config }: { config: SourceConfig }) {
const { data: systemStatus } = useQuery(orpc.system.status.queryOptions());
const publicApiUrl =
systemStatus?.publicApiUrl ?? "https://public-api.sofa.watch";
const fileInputRef = useRef<HTMLInputElement>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [step, setStep] = useState<DialogStep>(
config.supportsOAuth ? "choose" : "preview",
);
const [preview, setPreview] = useState<ImportPreview | null>(null);
const [result, setResult] = useState<ImportResult | null>(null);
const [options, setOptions] = useState({
importWatches: true,
importWatchlist: true,
importRatings: true,
});
// OAuth state
const [deviceCode, setDeviceCode] = useState<DeviceCodeInfo | null>(null);
const [oauthError, setOauthError] = useState<string | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const importAbortRef = useRef<AbortController | null>(null);
const parseMutation = useMutation(
orpc.imports.parseFile.mutationOptions({
onSuccess: (data) => {
setPreview(data as ImportPreview);
setStep("preview");
setDialogOpen(true);
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : "Failed to parse file",
);
},
onSettled: () => {
if (fileInputRef.current) fileInputRef.current.value = "";
},
}),
);
const parsePayloadMutation = useMutation(
orpc.imports.parsePayload.mutationOptions({
onSuccess: (data) => {
setPreview(data as ImportPreview);
setStep("preview");
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : "Failed to parse import data",
);
setStep("choose");
},
}),
);
// Progress state
const [progress, setProgress] = useState<{
current: number;
total: number;
message: string;
} | null>(null);
function handleFileSelect(file: File) {
parseMutation.mutate({ source: config.source, 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");
if (event.job.importedCount > 0) {
toast.success(
`Imported ${event.job.importedCount} items from ${config.label}`,
);
}
} else if (event.type === "timeout") {
receivedComplete = true;
toast.info(
"Import is still running in the background. Check back later.",
);
setStep("preview");
} else {
setProgress({
current: event.job.processedItems,
total: event.job.totalItems,
message: event.job.currentMessage ?? "",
});
}
}
// Stream ended without a complete/timeout event (e.g. connection dropped)
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(
"Import is still running in the background. Check back later.",
);
setStep("preview");
}
} catch {
toast.error("Lost connection to import. Check status in settings.");
setStep("preview");
}
}
} catch (err) {
if (abort.signal.aborted) return;
toast.error(err instanceof Error ? err.message : "Import failed");
setStep("preview");
} finally {
importAbortRef.current = null;
}
}
// Clean up poll timer on unmount or dialog close
const stopPollingRef = useRef(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
});
const stopPolling = stopPollingRef.current;
function handleClose() {
stopPolling();
importAbortRef.current?.abort();
importAbortRef.current = null;
setDialogOpen(false);
setStep(config.supportsOAuth ? "choose" : "preview");
setPreview(null);
setResult(null);
setDeviceCode(null);
setOauthError(null);
setOptions({
importWatches: true,
importWatchlist: true,
importRatings: true,
});
}
// ─── OAuth: Start device code flow ────────────────────────
async function startDeviceCodeFlow() {
setOauthError(null);
setStep("device-code");
try {
const res = await fetch(
`${publicApiUrl}/v1/import/${config.source}/device-code`,
{ method: "POST" },
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(
(err as { error?: string }).error ??
`Failed to start ${config.label} connection`,
);
}
const data = (await res.json()) as DeviceCodeInfo;
setDeviceCode(data);
setDialogOpen(true);
// Start polling
startPolling(data);
} catch (e) {
setOauthError(e instanceof Error ? e.message : "Failed to connect");
setStep("choose");
setDialogOpen(true);
}
}
function startPolling(code: DeviceCodeInfo) {
stopPolling();
const interval = (code.interval || 5) * 1000;
const expiresAt = Date.now() + code.expires_in * 1000;
pollTimerRef.current = setInterval(async () => {
if (Date.now() > expiresAt) {
stopPolling();
setOauthError("Device code expired. Please try again.");
setStep("choose");
return;
}
try {
const res = await fetch(
`${publicApiUrl}/v1/import/${config.source}/poll`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_code: code.device_code }),
},
);
if (!res.ok) return;
const data = (await res.json()) as {
status: string;
data?: Record<string, unknown>;
error?: string;
};
if (data.status === "authorized" && data.data) {
stopPolling();
setStep("fetching");
parsePayloadMutation.mutate({ data: data.data as NormalizedImport });
} else if (data.status === "denied") {
stopPolling();
setOauthError("Authorization was denied. Please try again.");
setStep("choose");
} else if (data.status === "expired") {
stopPolling();
setOauthError("Device code expired. Please try again.");
setStep("choose");
} else if (data.status === "fetch_error") {
stopPolling();
setOauthError(
data.error ||
"Authorization succeeded but failed to fetch your library. Please try again.",
);
setStep("choose");
}
// "pending" → keep polling
} catch {
// Network error, keep polling
}
}, interval);
}
// Clean up on unmount
useEffect(() => stopPolling, [stopPolling]);
const isParsing = parseMutation.isPending;
return (
<Card>
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
{config.icon}
</div>
<div>
<CardTitle>{config.label}</CardTitle>
<CardDescription>{config.description}</CardDescription>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept={config.accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
}}
/>
<div className="flex gap-2">
{config.supportsOAuth && (
<Button
variant="outline"
onClick={() => {
setDialogOpen(true);
setStep("choose");
}}
>
<IconLink aria-hidden />
Connect
</Button>
)}
{!config.supportsOAuth && (
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isParsing}
>
{isParsing ? <Spinner /> : <IconCloudUpload aria-hidden />}
{isParsing ? "Parsing…" : "Upload"}
</Button>
)}
</div>
</div>
</CardContent>
<Dialog open={dialogOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-md">
{step === "choose" && (
<ChooseStep
config={config}
oauthError={oauthError}
onConnect={() => startDeviceCodeFlow()}
onUpload={() => fileInputRef.current?.click()}
isParsing={isParsing}
onCancel={handleClose}
/>
)}
{step === "device-code" && (
<DeviceCodeStep
source={config.label}
deviceCode={deviceCode}
onCancel={handleClose}
/>
)}
{step === "fetching" && <FetchingStep source={config.label} />}
{step === "preview" && preview && (
<PreviewStep
source={config.label}
preview={preview}
options={options}
setOptions={setOptions}
onImport={handleImport}
onCancel={handleClose}
/>
)}
{step === "importing" && (
<ImportingStep source={config.label} progress={progress} />
)}
{step === "done" && result && (
<DoneStep
source={config.label}
result={result}
onClose={handleClose}
/>
)}
</DialogContent>
</Dialog>
</Card>
);
}
// ─── Dialog Steps ───────────────────────────────────────────
function ChooseStep({
config,
oauthError,
onConnect,
onUpload,
isParsing,
onCancel,
}: {
config: SourceConfig;
oauthError: string | null;
onConnect: () => void;
onUpload: () => void;
isParsing: boolean;
onCancel: () => void;
}) {
return (
<>
<DialogHeader>
<DialogTitle>Import from {config.label}</DialogTitle>
<DialogDescription>
Choose how to import your {config.label} data.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
{oauthError && (
<div className="rounded-lg bg-destructive/10 p-3">
<p className="text-destructive text-sm">{oauthError}</p>
</div>
)}
<button
type="button"
className="flex w-full items-center gap-3 rounded-lg border border-border/50 p-4 text-left transition-colors hover:bg-muted/50"
onClick={onConnect}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconLink aria-hidden className="size-5 text-primary" />
</div>
<div>
<p className="font-medium text-sm">Connect with {config.label}</p>
<p className="text-muted-foreground text-xs">
Authorize Sofa to read your {config.label} library. No password
shared.
</p>
</div>
</button>
<button
type="button"
className="flex w-full items-center gap-3 rounded-lg border border-border/50 p-4 text-left transition-colors hover:bg-muted/50"
onClick={() => {
onCancel();
// Small delay so the dialog closes before file picker opens
setTimeout(onUpload, 150);
}}
disabled={isParsing}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconCloudUpload aria-hidden className="size-5 text-primary" />
</div>
<div>
<p className="font-medium text-sm">Upload export file</p>
<p className="text-muted-foreground text-xs">
Upload a {config.accept} export from your {config.label} account
settings.
</p>
</div>
</button>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />} onClick={onCancel}>
Cancel
</DialogClose>
</DialogFooter>
</>
);
}
function DeviceCodeStep({
source,
deviceCode,
onCancel,
}: {
source: string;
deviceCode: DeviceCodeInfo | null;
onCancel: () => void;
}) {
return (
<>
<DialogHeader>
<DialogTitle>Connect to {source}</DialogTitle>
<DialogDescription>
Enter the code below on {source}'s website to authorize Sofa.
</DialogDescription>
</DialogHeader>
{deviceCode ? (
<div className="space-y-4 py-4">
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground text-sm">Your code:</p>
<p className="rounded-lg bg-muted px-6 py-3 font-bold font-mono text-2xl tracking-widest">
{deviceCode.user_code}
</p>
</div>
<div className="flex justify-center">
<a
href={deviceCode.verification_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 font-medium text-primary text-sm underline-offset-4 hover:underline"
>
<IconLink aria-hidden className="size-4" />
Open {source} to enter code
</a>
</div>
<div className="flex items-center justify-center gap-2 text-muted-foreground text-xs">
<Spinner className="size-3" />
Waiting for authorization...
</div>
</div>
) : (
<div className="flex justify-center py-8">
<Spinner className="size-6" />
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
</DialogFooter>
</>
);
}
function FetchingStep({ source }: { source: string }) {
return (
<>
<DialogHeader>
<DialogTitle>Connected to {source}</DialogTitle>
<DialogDescription>
Fetching your library data from {source}...
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-3 py-8">
<Spinner className="size-8" />
<p className="text-muted-foreground text-sm">
Retrieving your watch history, watchlist, and ratings...
</p>
</div>
</>
);
}
function PreviewStep({
source,
preview,
options,
setOptions,
onImport,
onCancel,
}: {
source: string;
preview: ImportPreview;
options: {
importWatches: boolean;
importWatchlist: boolean;
importRatings: boolean;
};
setOptions: (o: typeof options) => void;
onImport: () => void;
onCancel: () => void;
}) {
const { stats, warnings } = preview;
const totalItems =
(options.importWatches ? stats.movies + stats.episodes : 0) +
(options.importWatchlist ? stats.watchlist : 0) +
(options.importRatings ? stats.ratings : 0);
return (
<>
<DialogHeader>
<DialogTitle>Import from {source}</DialogTitle>
<DialogDescription>
Review what was found and choose what to import.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid grid-cols-2 gap-3">
<StatBadge label="Movies" count={stats.movies} />
<StatBadge label="Episodes" count={stats.episodes} />
<StatBadge label="Watchlist" count={stats.watchlist} />
<StatBadge label="Ratings" count={stats.ratings} />
</div>
{preview.diagnostics && preview.diagnostics.unresolved > 0 && (
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-muted-foreground text-xs">
{preview.diagnostics.unresolved} items have no external IDs and
will be resolved by title search, which may be less accurate.
</p>
</div>
)}
<div className="space-y-2">
<p className="font-medium text-sm">Import options</p>
<OptionCheckbox
label="Watch history"
description={`${stats.movies} movies, ${stats.episodes} episodes`}
checked={options.importWatches}
onChange={(v) => setOptions({ ...options, importWatches: v })}
/>
<OptionCheckbox
label="Watchlist"
description={`${stats.watchlist} items`}
checked={options.importWatchlist}
onChange={(v) => setOptions({ ...options, importWatchlist: v })}
/>
<OptionCheckbox
label="Ratings"
description={`${stats.ratings} ratings`}
checked={options.importRatings}
onChange={(v) => setOptions({ ...options, importRatings: v })}
/>
</div>
{warnings.length > 0 && (
<div className="rounded-lg bg-yellow-500/10 p-3">
<p className="mb-1 font-medium text-xs text-yellow-600">Warnings</p>
<ul className="space-y-0.5 text-xs text-yellow-600/80">
{warnings.map((w, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static display list
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />} onClick={onCancel}>
Cancel
</DialogClose>
<Button onClick={onImport} disabled={totalItems === 0}>
Import {totalItems} items
</Button>
</DialogFooter>
</>
);
}
function ImportingStep({
source,
progress,
}: {
source: string;
progress: { current: number; total: number; message: string } | null;
}) {
const pct =
progress && progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: null;
return (
<>
<DialogHeader>
<DialogTitle>Importing from {source}</DialogTitle>
<DialogDescription>
This may take a few minutes for large libraries. Please don't close
this tab.
</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="font-medium text-sm">
{progress.current} / {progress.total}
</p>
<p className="max-w-[300px] truncate text-muted-foreground text-xs">
{progress.message}
</p>
</>
) : (
<div className="flex items-center gap-2">
<Spinner className="size-3" />
<p className="text-muted-foreground text-sm">
Starting import...
</p>
</div>
)}
</div>
</div>
</>
);
}
function DoneStep({
source,
result,
onClose,
}: {
source: string;
result: ImportResult;
onClose: () => void;
}) {
return (
<>
<DialogHeader>
<DialogTitle>Import complete</DialogTitle>
<DialogDescription>Finished importing from {source}.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid grid-cols-3 gap-3">
<StatBadge label="Imported" count={result.imported} />
<StatBadge label="Skipped" count={result.skipped} />
<StatBadge label="Failed" count={result.failed} />
</div>
{result.errors.length > 0 && (
<div className="max-h-40 overflow-y-auto rounded-lg bg-destructive/10 p-3">
<p className="mb-1 font-medium text-destructive text-xs">
Errors ({result.errors.length})
</p>
<ul className="space-y-0.5 text-destructive/80 text-xs">
{result.errors.slice(0, 50).map((e, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static display list
<li key={i}>{e}</li>
))}
{result.errors.length > 50 && (
<li>...and {result.errors.length - 50} more</li>
)}
</ul>
</div>
)}
{result.warnings.length > 0 && (
<div className="max-h-32 overflow-y-auto rounded-lg bg-yellow-500/10 p-3">
<p className="mb-1 font-medium text-xs text-yellow-600">
Warnings ({result.warnings.length})
</p>
<ul className="space-y-0.5 text-xs text-yellow-600/80">
{result.warnings.slice(0, 20).map((w, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static display list
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
</div>
<DialogFooter>
<Button onClick={onClose}>Done</Button>
</DialogFooter>
</>
);
}
// ─── Helpers ────────────────────────────────────────────────
function StatBadge({ label, count }: { label: string; count: number }) {
return (
<div className="rounded-lg bg-muted/50 p-2.5 text-center">
<p className="font-semibold text-lg leading-none">{count}</p>
<p className="mt-1 text-muted-foreground text-xs">{label}</p>
</div>
);
}
function OptionCheckbox({
label,
description,
checked,
onChange,
}: {
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
const id = `import-opt-${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>
);
}
+3
View File
@@ -10,6 +10,7 @@ import { BackupRestoreSection } from "@/components/settings/backup-restore-secti
import { BackupScheduleSection } from "@/components/settings/backup-schedule-section";
import { BackupSection } from "@/components/settings/backup-section";
import { CacheSection } from "@/components/settings/danger-section";
import { ImportsSection } from "@/components/settings/imports-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { RegistrationSection } from "@/components/settings/registration-section";
import { SettingsShell } from "@/components/settings/settings-shell";
@@ -87,6 +88,8 @@ function SettingsPage() {
<IntegrationsSection />
<ImportsSection />
{/* Server health */}
{isAdmin && (
<div>
+15
View File
@@ -85,7 +85,12 @@
"name": "@sofa/public-api",
"version": "0.1.0",
"dependencies": {
"@hono/zod-validator": "0.7.6",
"@sofa/api": "workspace:*",
"@sofa/core": "workspace:*",
"@vercel/firewall": "1.1.2",
"hono": "4.12.8",
"zod": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
@@ -219,6 +224,7 @@
"@sofa/db": "workspace:*",
"@sofa/logger": "workspace:*",
"@sofa/tmdb": "workspace:*",
"adm-zip": "0.5.16",
"date-fns": "catalog:",
"node-vibrant": "4.0.4",
"sharp": "0.34.5",
@@ -226,6 +232,7 @@
"zod": "catalog:",
},
"devDependencies": {
"@types/adm-zip": "0.5.8",
"@types/bun": "catalog:",
"typescript": "catalog:",
},
@@ -702,6 +709,8 @@
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
@@ -1172,6 +1181,8 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/adm-zip": ["@types/adm-zip@0.5.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -1240,6 +1251,8 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vercel/firewall": ["@vercel/firewall@1.1.2", "", {}, "sha512-h0sdBVrloWx8TitvWla/rGj3AnJ5JEYfL5LaGHNNOWkyMuzNqfCcGTvJgnjL2A5eSpAAzoN7Xt609YQ0L7xZdw=="],
"@vibrant/color": ["@vibrant/color@4.0.4", "", {}, "sha512-Fq2tAszz4QOPWfHZ+KuEAchXUD8i594BM2fOJt8dI/fvYbiVoBycBF/BlNH6F4IWBubxXoPqD4JmmAHvFYbNew=="],
"@vibrant/core": ["@vibrant/core@4.0.4", "", { "dependencies": { "@vibrant/color": "^4.0.4", "@vibrant/generator": "^4.0.4", "@vibrant/image": "^4.0.4", "@vibrant/quantizer": "^4.0.4", "@vibrant/worker": "^4.0.4" } }, "sha512-yZ0XSpW2biKyaJPpBC31AVYgn7NseKSO2q3KNMmDrkL2qC6TEWsBMnSQ28n0m///chZELXpQLx1CCOsWg5pj8w=="],
@@ -1272,6 +1285,8 @@
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
@@ -0,0 +1,19 @@
---
title: Cancel import job
full: true
_openapi:
method: POST
toc: []
structuredData:
headings: []
contents:
- content: >-
Cancel a pending or running import job. Already-imported items are not
rolled back.
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
Cancel a pending or running import job. Already-imported items are not rolled back.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/imports/jobs/{id}/cancel","method":"post"}]} />
@@ -0,0 +1,17 @@
---
title: Create import job
full: true
_openapi:
method: POST
toc: []
structuredData:
headings: []
contents:
- content: Create and start a background import job from previously parsed data.
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
Create and start a background import job from previously parsed data.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/imports/jobs","method":"post"}]} />
@@ -0,0 +1,17 @@
---
title: Get import job status
full: true
_openapi:
method: GET
toc: []
structuredData:
headings: []
contents:
- content: Get the current status and progress of an import job.
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
Get the current status and progress of an import job.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/imports/jobs/{id}","method":"get"}]} />
@@ -0,0 +1,19 @@
---
title: Stream import job events
full: true
_openapi:
method: GET
toc: []
structuredData:
headings: []
contents:
- content: >-
SSE stream of import job progress events. Yields progress updates and
a final complete event.
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
SSE stream of import job progress events. Yields progress updates and a final complete event.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/imports/jobs/{id}/events","method":"get"}]} />
@@ -0,0 +1,19 @@
---
title: Parse import file
full: true
_openapi:
method: POST
toc: []
structuredData:
headings: []
contents:
- content: >-
Upload and parse an export file from Trakt, Simkl, or Letterboxd.
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. */}
Upload and parse an export file from Trakt, Simkl, or Letterboxd. Returns a preview of items found without importing anything.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/imports/parse-file","method":"post"}]} />
@@ -0,0 +1,20 @@
---
title: Preview normalized import data
full: true
_openapi:
method: POST
toc: []
structuredData:
headings: []
contents:
- content: >-
Accept pre-normalized import data from the OAuth proxy and return a
preview with item counts. No parsing is needed — data is already in
NormalizedImport format.
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
Accept pre-normalized import data from the OAuth proxy and return a preview with item counts. No parsing is needed — data is already in NormalizedImport format.
<APIPage document={"./public/openapi.json"} operations={[{"path":"/imports/parse-payload","method":"post"}]} />
+91
View File
@@ -0,0 +1,91 @@
---
title: Importing from Other Apps
description: Transfer your library from Trakt, Simkl, or Letterboxd.
---
Sofa can import your existing watch history, watchlist, and ratings from other tracking services. This lets you switch to Sofa without losing your data.
## Supported Services
| Service | OAuth Connect | File Upload | Data Imported |
|---|---|---|---|
| **Trakt** | Yes | JSON export | Watches, watchlist, ratings |
| **Simkl** | Yes | JSON export | Watches, watchlist, ratings, anime (as TV) |
| **Letterboxd** | No | ZIP export | Watches, watchlist, ratings (movies only) |
## Importing via OAuth (Trakt & Simkl)
The fastest way to import. Sofa uses a device code flow — no passwords are shared.
1. Go to **Settings → Import** and click **Connect** next to Trakt or Simkl
2. A code is displayed — click the link to open the provider's website
3. Enter the code and authorize Sofa
4. Sofa fetches your library and shows a preview
5. Choose which categories to import (watches, watchlist, ratings) and confirm
The authorization token is used only for the one-time data fetch and is not stored.
## Importing via File Upload
For Letterboxd (which has no public API) or if you prefer not to use OAuth:
### Letterboxd
1. Go to [letterboxd.com/settings/data](https://letterboxd.com/settings/data/) and click **Export Your Data**
2. Download the ZIP file
3. In Sofa, go to **Settings → Import** and click **Upload** next to Letterboxd
4. Select the ZIP file — Sofa reads `diary.csv`, `watched.csv`, `watchlist.csv`, and `ratings.csv`
### Trakt & Simkl
You can also upload a JSON export file instead of using OAuth. Export your data from the provider's account settings, then upload the file in Sofa.
## Preview & Options
Before importing, Sofa shows a preview of what was found:
- **Item counts** — movies, episodes, watchlist items, and ratings detected in the export
- **Category toggles** — choose which types of data to import (watches, watchlist, ratings)
- **Warnings** — any issues found during parsing (e.g. missing files in a ZIP, unrecognized ratings)
Items without external IDs (like Letterboxd exports, which only include title and year) are resolved via TMDB search. The preview notes how many items will need title-based matching, which may be less accurate for ambiguous titles.
## How It Works
When you confirm the import, Sofa creates a background job that:
1. Resolves each item to a TMDB title using available IDs (TMDB, IMDB, TVDB) or title search
2. Fetches metadata for any titles not already in your Sofa library
3. Logs watches, sets watchlist status, and stores ratings using the original timestamps from the source
4. Reports progress in real time — you can close the tab and come back; the import continues server-side
### Deduplication
Imports are safe to re-run. Sofa checks for existing data before writing:
- **Watches** — skipped if you already have a watch record for that movie or episode
- **Watchlist** — skipped if a status already exists for that title
- **Ratings** — skipped if you already have a rating for that title
### Rating Conversion
Different services use different rating scales. Sofa converts to its 15 star scale:
| Source | Scale | Conversion |
|---|---|---|
| Trakt | 110 | `round(rating / 2)`, clamped to 15 |
| Simkl | 110 | `round(rating / 2)`, clamped to 15 |
| Letterboxd | 0.55 (half-stars) | `round(rating)`, clamped to 15 |
### Timestamps
Sofa preserves the original watch dates and rating dates from the source export. If the source only provides a date without a time (e.g. Letterboxd diary entries), the date is stored as-is.
## Troubleshooting
Check the import results summary for details on any failures:
- **"Could not resolve movie/show"** — the title couldn't be matched to a TMDB entry. This is more common with Letterboxd imports (title-only matching) or obscure titles.
- **"Season/episode not found"** — the show was found but the specific episode doesn't exist in TMDB's data. This can happen with recently aired episodes or special episodes.
- **Large imports are slow** — imports with thousands of items may take several minutes due to TMDB rate limits. The progress bar shows real-time status.
- **Import job disappeared** — if the server restarts mid-import, the job will show as failed. You can safely re-run the import; existing data won't be duplicated.
+6
View File
@@ -21,6 +21,12 @@ Import list integrations export your Sofa watchlist so Sonarr and Radarr can aut
- [Sonarr](/docs/integrations/sonarr) — exports TV shows from your watchlist
- [Radarr](/docs/integrations/radarr) — exports movies from your watchlist
## Import Watch History
Migrating from another tracking app? Sofa can import your existing watch history, watchlist, and ratings.
- [Import from Trakt, Simkl, or Letterboxd](/docs/integrations/import) — OAuth connect or file upload
## Setup
All integrations are managed in **Settings → Integrations**. The general flow:
+1 -1
View File
@@ -1,4 +1,4 @@
{
"title": "Integrations",
"pages": ["plex", "jellyfin", "emby", "sonarr", "radarr"]
"pages": ["plex", "jellyfin", "emby", "sonarr", "radarr", "import"]
}
+1 -1
View File
@@ -38,7 +38,7 @@ Sofa automatically updates statuses as you log watches so you don't have to mana
- **Unwatch an episode** on a completed show → status drops back to **In Progress**
- **Mark a movie as watched** → status changes to **Completed**
These same transitions apply when watches are logged automatically via [webhook integrations](/docs/integrations) (Plex, Jellyfin, Emby).
These same transitions apply when watches are logged automatically via [webhook integrations](/docs/integrations) (Plex, Jellyfin, Emby) or [imported from another app](/docs/integrations/import) (Trakt, Simkl, Letterboxd).
### Logging Watches
+1589 -1
View File
File diff suppressed because it is too large Load Diff
+80 -1
View File
@@ -1,4 +1,4 @@
import { oc } from "@orpc/contract";
import { eventIterator, oc } from "@orpc/contract";
import { z } from "zod";
import {
AuthConfigOutput,
@@ -7,6 +7,7 @@ import {
BackupsListOutput,
BatchWatchInput,
ContinueWatchingOutput,
CreateImportJobInput,
CreateIntegrationInput,
DashboardRecommendationsOutput,
DashboardStatsOutput,
@@ -15,12 +16,17 @@ import {
FilenameParam,
GenresOutput,
IdParam,
ImportJobEvent,
ImportJobSchema,
ImportPreviewSchema,
IntegrationOutput,
IntegrationsListOutput,
LibraryOutput,
MediaTypeParam,
PageParam,
PaginatedInput,
ParseFileInput,
ParsePayloadInput,
PersonDetailOutput,
PopularOutput,
ProviderParam,
@@ -683,4 +689,77 @@ export const contract = {
.input(z.void())
.output(z.void()),
},
imports: {
parseFile: oc
.route({
method: "POST",
path: "/imports/parse-file",
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.",
successDescription: "Preview of importable items with counts",
})
.input(ParseFileInput)
.output(ImportPreviewSchema),
parsePayload: oc
.route({
method: "POST",
path: "/imports/parse-payload",
tags: ["Imports"],
summary: "Preview normalized import data",
description:
"Accept pre-normalized import data from the OAuth proxy and return a preview with item counts. No parsing is needed — data is already in NormalizedImport format.",
successDescription: "Preview of importable items with counts",
})
.input(ParsePayloadInput)
.output(ImportPreviewSchema),
createJob: oc
.route({
method: "POST",
path: "/imports/jobs",
tags: ["Imports"],
summary: "Create import job",
description:
"Create and start a background import job from previously parsed data.",
successDescription: "Created import job",
})
.input(CreateImportJobInput)
.output(ImportJobSchema),
getJob: oc
.route({
method: "GET",
path: "/imports/jobs/{id}",
tags: ["Imports"],
summary: "Get import job status",
description: "Get the current status and progress of an import job.",
successDescription: "Current job state",
})
.input(IdParam)
.output(ImportJobSchema),
cancelJob: oc
.route({
method: "POST",
path: "/imports/jobs/{id}/cancel",
tags: ["Imports"],
summary: "Cancel import job",
description:
"Cancel a pending or running import job. Already-imported items are not rolled back.",
successDescription: "Updated job state",
})
.input(IdParam)
.output(ImportJobSchema),
jobEvents: oc
.route({
method: "GET",
path: "/imports/jobs/{id}/events",
tags: ["Imports"],
summary: "Stream import job events",
description:
"SSE stream of import job progress events. Yields progress updates and a final complete event.",
successDescription: "Stream of job progress events",
})
.input(IdParam)
.output(eventIterator(ImportJobEvent)),
},
};
+139
View File
@@ -887,6 +887,7 @@ export const SystemStatusOutput = z
tmdbConfigured: z
.boolean()
.describe("Whether a TMDB API token is configured"),
publicApiUrl: z.string().describe("Base URL of the centralized public API"),
})
.meta({ description: "Quick TMDB configuration check" });
@@ -1095,6 +1096,142 @@ export const AuthConfigOutput = z
description: "Authentication provider configuration",
});
// ─── Imports ──────────────────────────────────────────────────
export const ImportSourceEnum = z
.enum(["trakt", "simkl", "letterboxd"])
.describe("External service to import from");
export const ImportMovieSchema = z.object({
tmdbId: z.number().optional(),
imdbId: z.string().optional(),
title: z.string(),
year: z.number().optional(),
watchedAt: z
.string()
.datetime({ offset: true })
.optional()
.describe("ISO 8601 timestamp"),
watchedOn: z.string().date().optional().describe("YYYY-MM-DD date-only"),
});
export const ImportEpisodeSchema = z.object({
showTmdbId: z.number().optional(),
imdbId: z.string().optional(),
tvdbId: z.number().optional(),
showTitle: z.string().optional(),
year: z.number().optional(),
seasonNumber: z.number().int().min(0),
episodeNumber: z.number().int().min(1),
watchedAt: z.string().datetime({ offset: true }).optional(),
watchedOn: z.string().date().optional(),
});
export const ImportWatchlistItemSchema = z.object({
tmdbId: z.number().optional(),
imdbId: z.string().optional(),
tvdbId: z.number().optional(),
title: z.string(),
year: z.number().optional(),
type: z.enum(["movie", "tv"]),
});
export const ImportRatingSchema = z.object({
tmdbId: z.number().optional(),
imdbId: z.string().optional(),
tvdbId: z.number().optional(),
title: z.string(),
year: z.number().optional(),
type: z.enum(["movie", "tv"]),
rating: z.number().int().min(1).max(5).describe("Sofa 1-5 star rating"),
ratedAt: z.string().datetime({ offset: true }).optional(),
ratedOn: z.string().date().optional(),
});
export const NormalizedImportSchema = z.object({
source: ImportSourceEnum,
movies: z.array(ImportMovieSchema).max(50_000),
episodes: z.array(ImportEpisodeSchema).max(50_000),
watchlist: z.array(ImportWatchlistItemSchema).max(50_000),
ratings: z.array(ImportRatingSchema).max(50_000),
});
export const ImportOptionsSchema = z.object({
importWatches: z.boolean().describe("Import movie and episode watch history"),
importWatchlist: z.boolean().describe("Import watchlist items"),
importRatings: z.boolean().describe("Import ratings"),
});
export const ImportResultSchema = z.object({
imported: z.number().describe("Items successfully imported"),
skipped: z.number().describe("Items skipped (already exist)"),
failed: z.number().describe("Items that failed to import"),
errors: z.array(z.string()).describe("Error messages for failed items"),
warnings: z.array(z.string()).describe("Non-fatal warnings"),
});
export const ImportPreviewSchema = z.object({
data: NormalizedImportSchema,
warnings: z.array(z.string()),
stats: z.object({
movies: z.number(),
episodes: z.number(),
watchlist: z.number(),
ratings: z.number(),
}),
diagnostics: z
.object({
unresolved: z.number(),
unsupported: z.number(),
})
.optional(),
blockingErrors: z.array(z.string()).optional(),
});
export const ParseFileInput = z.object({
source: ImportSourceEnum,
file: z.file().max(100 * 1024 * 1024, "File too large (max 100 MB)"),
});
export const ParsePayloadInput = z.object({
data: NormalizedImportSchema,
});
export const ImportJobStatusEnum = z.enum([
"pending",
"running",
"success",
"error",
"cancelled",
]);
export const ImportJobSchema = z.object({
id: z.string(),
source: ImportSourceEnum,
status: ImportJobStatusEnum,
totalItems: z.number(),
processedItems: z.number(),
importedCount: z.number(),
skippedCount: z.number(),
failedCount: z.number(),
currentMessage: z.string().nullable(),
errors: z.array(z.string()),
warnings: z.array(z.string()),
createdAt: z.string(),
startedAt: z.string().nullable(),
finishedAt: z.string().nullable(),
});
export const CreateImportJobInput = z.object({
data: NormalizedImportSchema,
options: ImportOptionsSchema,
});
export const ImportJobEvent = z.object({
type: z.enum(["progress", "complete", "timeout"]),
job: ImportJobSchema,
});
// ═══════════════════════════════════════════════════════════════
// Inferred types — use these instead of hand-written interfaces
// ═══════════════════════════════════════════════════════════════
@@ -1116,4 +1253,6 @@ export type Season = z.infer<typeof SeasonSchema>;
export type SystemHealthData = z.infer<typeof SystemHealthSchema>;
export type PaginationInfo = z.infer<typeof PaginationMeta>;
export type TimePeriod = z.infer<typeof WatchHistoryInput>["period"];
export type ImportJob = z.infer<typeof ImportJobSchema>;
export type NormalizedImport = z.infer<typeof NormalizedImportSchema>;
export type UpdateCheckResult = z.infer<typeof UpdateCheckResultSchema>;
+4
View File
@@ -11,6 +11,8 @@
"./credits": "./src/credits.ts",
"./discovery": "./src/discovery.ts",
"./image-cache": "./src/image-cache.ts",
"./imports": "./src/imports/index.ts",
"./imports/parsers": "./src/imports/parsers.ts",
"./lists": "./src/lists.ts",
"./metadata": "./src/metadata.ts",
"./person": "./src/person.ts",
@@ -35,6 +37,7 @@
"@sofa/db": "workspace:*",
"@sofa/logger": "workspace:*",
"@sofa/tmdb": "workspace:*",
"adm-zip": "0.5.16",
"date-fns": "catalog:",
"node-vibrant": "4.0.4",
"sharp": "0.34.5",
@@ -42,6 +45,7 @@
"zod": "catalog:"
},
"devDependencies": {
"@types/adm-zip": "0.5.8",
"@types/bun": "catalog:",
"typescript": "catalog:"
}
+25
View File
@@ -0,0 +1,25 @@
export {
countUnresolved,
type ImportEpisode,
type ImportMovie,
type ImportRating,
type ImportSource,
type ImportWatchlistItem,
type NormalizedImport,
type ParseDiagnostics,
type ParseResult,
parseLetterboxdExport,
parseSimklPayload,
parseTraktPayload,
} from "./parsers";
export {
type ImportOptions,
type ImportResult,
processImportJob,
readImportJob,
} from "./processor";
export {
type ExternalIds,
resolveMovieTmdbId,
resolveShowTmdbId,
} from "./resolve";
+656
View File
@@ -0,0 +1,656 @@
import { createLogger } from "@sofa/logger";
const log = createLogger("imports");
// ─── Types ──────────────────────────────────────────────────────────
export interface ImportMovie {
tmdbId?: number;
imdbId?: string;
title: string;
year?: number;
watchedAt?: string;
watchedOn?: string;
}
export interface ImportEpisode {
showTmdbId?: number;
imdbId?: string;
tvdbId?: number;
showTitle?: string;
year?: number;
seasonNumber: number;
episodeNumber: number;
watchedAt?: string;
watchedOn?: string;
}
export interface ImportWatchlistItem {
tmdbId?: number;
imdbId?: string;
tvdbId?: number;
title: string;
year?: number;
type: "movie" | "tv";
}
export interface ImportRating {
tmdbId?: number;
imdbId?: string;
tvdbId?: number;
title: string;
year?: number;
type: "movie" | "tv";
rating: number; // 1-5 (Sofa scale)
ratedAt?: string;
ratedOn?: string;
}
export type ImportSource = "trakt" | "simkl" | "letterboxd";
export interface NormalizedImport {
source: ImportSource;
movies: ImportMovie[];
episodes: ImportEpisode[];
watchlist: ImportWatchlistItem[];
ratings: ImportRating[];
}
export interface ParseDiagnostics {
unresolved: number;
unsupported: number;
}
export interface ParseResult {
data: NormalizedImport;
warnings: string[];
diagnostics?: ParseDiagnostics;
}
// ─── Diagnostics ────────────────────────────────────────────────────
/** Count items that have no external IDs and will need title-based resolution. */
export function countUnresolved(data: NormalizedImport): number {
let count = 0;
for (const m of data.movies) {
if (!m.tmdbId && !m.imdbId) count++;
}
for (const e of data.episodes) {
if (!e.showTmdbId && !e.imdbId && !e.tvdbId) count++;
}
for (const w of data.watchlist) {
if (!w.tmdbId && !w.imdbId && !w.tvdbId) count++;
}
for (const r of data.ratings) {
if (!r.tmdbId && !r.imdbId && !r.tvdbId) count++;
}
return count;
}
// ─── Rating Conversion ──────────────────────────────────────────────
/** Convert a 1-10 rating to Sofa's 1-5 integer scale. */
function convertRating10to5(rating: number): number | null {
if (rating < 1 || rating > 10) return null;
return Math.max(1, Math.min(5, Math.round(rating / 2)));
}
/** Convert Letterboxd's 0.5-5 half-star rating to Sofa's 1-5 integer scale. */
function convertLetterboxdRating(rating: number): number | null {
if (rating < 0.5 || rating > 5) return null;
return Math.max(1, Math.min(5, Math.round(rating)));
}
// ─── CSV Parsing ────────────────────────────────────────────────────
/** Simple CSV parser that handles quoted fields with commas. */
function parseCsvLine(line: string): string[] {
const fields: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (inQuotes) {
if (char === '"') {
if (i + 1 < line.length && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = false;
}
} else {
current += char;
}
} else if (char === '"') {
inQuotes = true;
} else if (char === ",") {
fields.push(current.trim());
current = "";
} else {
current += char;
}
}
fields.push(current.trim());
return fields;
}
function parseCsv(text: string): Record<string, string>[] {
const lines = text.split("\n").filter((l) => l.trim().length > 0);
if (lines.length < 2) return [];
const headers = parseCsvLine(lines[0]);
const rows: Record<string, string>[] = [];
for (let i = 1; i < lines.length; i++) {
const values = parseCsvLine(lines[i]);
const row: Record<string, string> = {};
for (let j = 0; j < headers.length; j++) {
row[headers[j]] = values[j] ?? "";
}
rows.push(row);
}
return rows;
}
// ─── Trakt Parser ───────────────────────────────────────────────────
interface TraktIds {
trakt?: number;
slug?: string;
imdb?: string;
tmdb?: number;
tvdb?: number;
}
interface TraktHistoryMovie {
watched_at?: string;
movie?: { title?: string; year?: number; ids?: TraktIds };
}
interface TraktHistoryEpisode {
watched_at?: string;
show?: { title?: string; year?: number; ids?: TraktIds };
episode?: {
season?: number;
number?: number;
title?: string;
ids?: TraktIds;
};
}
interface TraktWatchlistItem {
type?: "movie" | "show";
movie?: { title?: string; year?: number; ids?: TraktIds };
show?: { title?: string; year?: number; ids?: TraktIds };
}
interface TraktRatingItem {
type?: "movie" | "show";
rating?: number;
rated_at?: string;
movie?: { title?: string; year?: number; ids?: TraktIds };
show?: { title?: string; year?: number; ids?: TraktIds };
}
export function parseTraktPayload(data: {
history?: { movies?: TraktHistoryMovie[]; shows?: TraktHistoryEpisode[] };
watchlist?: TraktWatchlistItem[];
ratings?: TraktRatingItem[];
}): ParseResult {
const warnings: string[] = [];
const movies: ImportMovie[] = [];
const episodes: ImportEpisode[] = [];
const watchlist: ImportWatchlistItem[] = [];
const ratings: ImportRating[] = [];
// Movie watch history
for (const item of data.history?.movies ?? []) {
const movie = item.movie;
if (!movie?.title) continue;
movies.push({
tmdbId: movie.ids?.tmdb,
imdbId: movie.ids?.imdb,
title: movie.title,
year: movie.year,
watchedAt: item.watched_at,
});
}
// Episode watch history
for (const item of data.history?.shows ?? []) {
const show = item.show;
const ep = item.episode;
if (!show?.title || ep?.season == null || ep?.number == null) continue;
episodes.push({
showTmdbId: show.ids?.tmdb,
imdbId: show.ids?.imdb,
tvdbId: show.ids?.tvdb,
showTitle: show.title,
year: show.year,
seasonNumber: ep.season,
episodeNumber: ep.number,
watchedAt: item.watched_at,
});
}
// Watchlist
for (const item of data.watchlist ?? []) {
const entry = item.type === "movie" ? item.movie : item.show;
if (!entry?.title) continue;
watchlist.push({
tmdbId: entry.ids?.tmdb,
imdbId: entry.ids?.imdb,
tvdbId: entry.ids?.tvdb,
title: entry.title,
year: entry.year,
type: item.type === "show" ? "tv" : "movie",
});
}
// Ratings
for (const item of data.ratings ?? []) {
const entry = item.type === "movie" ? item.movie : item.show;
if (!entry?.title || item.rating == null) continue;
const converted = convertRating10to5(item.rating);
if (converted == null) {
warnings.push(
`Skipped invalid Trakt rating ${item.rating} for "${entry.title}"`,
);
continue;
}
ratings.push({
tmdbId: entry.ids?.tmdb,
imdbId: entry.ids?.imdb,
tvdbId: entry.ids?.tvdb,
title: entry.title,
year: entry.year,
type: item.type === "show" ? "tv" : "movie",
rating: converted,
ratedAt: item.rated_at,
});
}
log.info(
`Parsed Trakt data: ${movies.length} movies, ${episodes.length} episodes, ${watchlist.length} watchlist, ${ratings.length} ratings`,
);
const normalized: NormalizedImport = {
source: "trakt",
movies,
episodes,
watchlist,
ratings,
};
return {
data: normalized,
warnings,
diagnostics: { unresolved: countUnresolved(normalized), unsupported: 0 },
};
}
// ─── Simkl Parser ───────────────────────────────────────────────────
interface SimklIds {
simkl?: number;
imdb?: string;
tmdb?: string | number;
tvdb?: string | number;
mal?: string | number;
}
interface SimklItem {
title?: string;
year?: number;
type?: string; // "movie", "show", "anime"
ids?: SimklIds;
status?: string; // "completed", "watching", "plantowatch", "dropped", "hold"
user_rating?: number;
last_watched_at?: string;
watched_episodes_count?: number;
total_episodes_count?: number;
seasons?: {
number?: number;
episodes?: { number?: number; watched_at?: string }[];
}[];
}
export function parseSimklPayload(data: {
movies?: SimklItem[];
shows?: SimklItem[];
anime?: SimklItem[];
}): ParseResult {
const warnings: string[] = [];
const movies: ImportMovie[] = [];
const episodes: ImportEpisode[] = [];
const watchlist: ImportWatchlistItem[] = [];
const ratings: ImportRating[] = [];
// Movies
for (const item of data.movies ?? []) {
if (!item.title) continue;
const tmdbId =
typeof item.ids?.tmdb === "number"
? item.ids.tmdb
: item.ids?.tmdb
? Number(item.ids.tmdb)
: undefined;
if (item.status === "plantowatch") {
watchlist.push({
tmdbId: tmdbId ?? undefined,
imdbId: item.ids?.imdb,
title: item.title,
year: item.year,
type: "movie",
});
} else if (
item.status === "completed" ||
item.status === "watching" ||
item.last_watched_at
) {
movies.push({
tmdbId: tmdbId ?? undefined,
imdbId: item.ids?.imdb,
title: item.title,
year: item.year,
watchedAt: item.last_watched_at,
});
}
if (item.user_rating != null) {
const converted = convertRating10to5(item.user_rating);
if (converted != null) {
ratings.push({
tmdbId: tmdbId ?? undefined,
imdbId: item.ids?.imdb,
title: item.title,
year: item.year,
type: "movie",
rating: converted,
});
}
}
}
// Shows + Anime (both map to TV type)
const allShows = [...(data.shows ?? []), ...(data.anime ?? [])];
for (const item of allShows) {
if (!item.title) continue;
const tmdbId =
typeof item.ids?.tmdb === "number"
? item.ids.tmdb
: item.ids?.tmdb
? Number(item.ids.tmdb)
: undefined;
const tvdbId =
typeof item.ids?.tvdb === "number"
? item.ids.tvdb
: item.ids?.tvdb
? Number(item.ids.tvdb)
: undefined;
if (item.status === "plantowatch") {
watchlist.push({
tmdbId: tmdbId ?? undefined,
imdbId: item.ids?.imdb,
tvdbId: tvdbId ?? undefined,
title: item.title,
year: item.year,
type: "tv",
});
}
// Extract individual episodes from seasons data
if (item.seasons) {
for (const season of item.seasons) {
if (season.number == null) continue;
for (const ep of season.episodes ?? []) {
if (ep.number == null) continue;
episodes.push({
showTmdbId: tmdbId ?? undefined,
imdbId: item.ids?.imdb,
tvdbId: tvdbId ?? undefined,
showTitle: item.title,
year: item.year,
seasonNumber: season.number,
episodeNumber: ep.number,
watchedAt: ep.watched_at,
});
}
}
}
if (
!item.seasons?.length &&
(item.status === "completed" || item.status === "watching")
) {
warnings.push(
`"${item.title}" is marked ${item.status} but has no episode data — episode watches were not imported.`,
);
}
if (item.user_rating != null) {
const converted = convertRating10to5(item.user_rating);
if (converted != null) {
ratings.push({
tmdbId: tmdbId ?? undefined,
imdbId: item.ids?.imdb,
tvdbId: tvdbId ?? undefined,
title: item.title,
year: item.year,
type: "tv",
rating: converted,
});
}
}
}
log.info(
`Parsed Simkl data: ${movies.length} movies, ${episodes.length} episodes, ${watchlist.length} watchlist, ${ratings.length} ratings`,
);
const normalized: NormalizedImport = {
source: "simkl",
movies,
episodes,
watchlist,
ratings,
};
return {
data: normalized,
warnings,
diagnostics: { unresolved: countUnresolved(normalized), unsupported: 0 },
};
}
// ─── Letterboxd Parser ──────────────────────────────────────────────
const LETTERBOXD_EXPECTED_FILES = [
"diary.csv",
"watched.csv",
"watchlist.csv",
"ratings.csv",
] as const;
const LETTERBOXD_IGNORED_FILES = new Set([
"reviews.csv",
"profile.csv",
"comments.csv",
]);
export async function parseLetterboxdExport(
zipFile: Blob,
): Promise<ParseResult> {
const warnings: string[] = [];
const movies: ImportMovie[] = [];
const watchlist: ImportWatchlistItem[] = [];
const ratings: ImportRating[] = [];
const AdmZip = (await import("adm-zip")).default;
let zip: InstanceType<typeof AdmZip>;
try {
const buffer = Buffer.from(await zipFile.arrayBuffer());
zip = new AdmZip(buffer);
} catch {
warnings.push(
"Failed to read ZIP file. Ensure it is a valid Letterboxd export.",
);
return {
data: {
source: "letterboxd",
movies,
episodes: [],
watchlist,
ratings,
},
warnings,
};
}
const entries = zip.getEntries();
const entryMap = new Map<string, string>();
const allFilenames: string[] = [];
for (const entry of entries) {
if (entry.isDirectory) continue;
// Letterboxd exports may nest files in a subdirectory — use the basename
const name = entry.entryName.split("/").pop() ?? entry.entryName;
allFilenames.push(name);
if (
LETTERBOXD_EXPECTED_FILES.includes(
name as (typeof LETTERBOXD_EXPECTED_FILES)[number],
)
) {
entryMap.set(name, entry.getData().toString("utf-8"));
}
}
// Check which expected files exist
for (const expected of LETTERBOXD_EXPECTED_FILES) {
if (!entryMap.has(expected)) {
warnings.push(
`${expected} not found in export — the corresponding data will not be imported.`,
);
}
}
// Log ignored files
for (const name of allFilenames) {
if (
LETTERBOXD_EXPECTED_FILES.includes(
name as (typeof LETTERBOXD_EXPECTED_FILES)[number],
)
) {
continue;
}
if (!LETTERBOXD_IGNORED_FILES.has(name)) {
log.debug(`Ignoring unknown file in Letterboxd export: ${name}`);
}
}
function readCsv(filename: string): Record<string, string>[] {
const text = entryMap.get(filename);
if (!text) return [];
return parseCsv(text);
}
// Track which movies we've already seen (for dedup between diary and watched.csv)
const seenMovies = new Set<string>();
// Parse diary.csv (watch history with dates and optional ratings)
for (const row of readCsv("diary.csv")) {
const title = row.Name || row.Title;
const yearStr = row.Year;
if (!title) continue;
const year = yearStr ? Number.parseInt(yearStr, 10) : undefined;
const watchedOn = (row["Watched Date"] || row.WatchedDate) ?? "";
// Include date in key to preserve rewatches of the same movie
const key = `${title}::${year ?? ""}::${watchedOn}`;
if (seenMovies.has(key)) continue;
seenMovies.add(key);
// Also mark title+year as seen to dedup against watched.csv
seenMovies.add(`${title}::${year ?? ""}`);
movies.push({
title,
year: year && !Number.isNaN(year) ? year : undefined,
watchedOn: row["Watched Date"] || row.WatchedDate || undefined,
});
}
// Parse watched.csv (films marked watched without specific dates)
for (const row of readCsv("watched.csv")) {
const title = row.Name || row.Title;
const yearStr = row.Year;
if (!title) continue;
const year = yearStr ? Number.parseInt(yearStr, 10) : undefined;
const key = `${title}::${year ?? ""}`;
if (seenMovies.has(key)) continue;
seenMovies.add(key);
movies.push({
title,
year: year && !Number.isNaN(year) ? year : undefined,
});
}
// Parse watchlist.csv
for (const row of readCsv("watchlist.csv")) {
const title = row.Name || row.Title;
const yearStr = row.Year;
if (!title) continue;
const year = yearStr ? Number.parseInt(yearStr, 10) : undefined;
watchlist.push({
title,
year: year && !Number.isNaN(year) ? year : undefined,
type: "movie",
});
}
// Parse ratings.csv
for (const row of readCsv("ratings.csv")) {
const title = row.Name || row.Title;
const yearStr = row.Year;
const ratingStr = row.Rating;
if (!title || !ratingStr) continue;
const rawRating = Number.parseFloat(ratingStr);
if (Number.isNaN(rawRating)) continue;
const converted = convertLetterboxdRating(rawRating);
if (converted == null) {
warnings.push(
`Skipped invalid Letterboxd rating ${rawRating} for "${title}"`,
);
continue;
}
const year = yearStr ? Number.parseInt(yearStr, 10) : undefined;
ratings.push({
title,
year: year && !Number.isNaN(year) ? year : undefined,
type: "movie",
rating: converted,
ratedOn: row.Date || undefined,
});
}
log.info(
`Parsed Letterboxd export: ${movies.length} movies, ${watchlist.length} watchlist, ${ratings.length} ratings`,
);
// Letterboxd has no IDs — all items need title-based resolution
const unresolved = movies.length + watchlist.length + ratings.length;
return {
data: { source: "letterboxd", movies, episodes: [], watchlist, ratings },
warnings,
diagnostics: { unresolved, unsupported: 0 },
};
}
+613
View File
@@ -0,0 +1,613 @@
import { type ImportJob, NormalizedImportSchema } from "@sofa/api/schemas";
import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers";
import {
episodes,
importJobs,
seasons,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getOrFetchTitleByTmdbId } from "../metadata";
import {
logEpisodeWatch,
logMovieWatch,
rateTitleStars,
setTitleStatus,
} from "../tracking";
import type {
ImportEpisode,
ImportMovie,
ImportRating,
ImportWatchlistItem,
} from "./parsers";
import { resolveMovieTmdbId, resolveShowTmdbId } from "./resolve";
const log = createLogger("imports");
function safeParseJsonArray(value: string | null): string[] {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
// ─── Types ──────────────────────────────────────────────────────────
export interface ImportOptions {
importWatches: boolean;
importWatchlist: boolean;
importRatings: boolean;
}
export interface ImportResult {
imported: number;
skipped: number;
failed: number;
errors: string[];
warnings: string[];
}
// ─── Deduplication ──────────────────────────────────────────────────
function hasExistingMovieWatch(userId: string, titleId: string): boolean {
const existing = db
.select({ id: userMovieWatches.id })
.from(userMovieWatches)
.where(
and(
eq(userMovieWatches.userId, userId),
eq(userMovieWatches.titleId, titleId),
),
)
.get();
return !!existing;
}
function hasExistingEpisodeWatch(userId: string, episodeId: string): boolean {
const existing = db
.select({ id: userEpisodeWatches.id })
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
eq(userEpisodeWatches.episodeId, episodeId),
),
)
.get();
return !!existing;
}
function hasExistingWatchlistStatus(userId: string, titleId: string): boolean {
const existing = db
.select({ status: userTitleStatus.status })
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
return !!existing;
}
function hasExistingRating(userId: string, titleId: string): boolean {
const existing = db
.select({ ratingStars: userRatings.ratingStars })
.from(userRatings)
.where(
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
)
.get();
return !!existing;
}
// ─── Item Processors ────────────────────────────────────────────────
async function processMovie(
userId: string,
movie: ImportMovie,
result: ImportResult,
cache?: Map<string, number | null>,
): Promise<void> {
const tmdbId = await resolveMovieTmdbId(
{
tmdbId: movie.tmdbId,
imdbId: movie.imdbId,
title: movie.title,
year: movie.year,
},
cache,
);
if (!tmdbId) {
result.failed++;
result.errors.push(
`Could not resolve movie: "${movie.title}"${movie.year ? ` (${movie.year})` : ""}`,
);
return;
}
const title = await getOrFetchTitleByTmdbId(tmdbId, "movie");
if (!title) {
result.failed++;
result.errors.push(`Failed to fetch metadata for movie TMDB ${tmdbId}`);
return;
}
if (hasExistingMovieWatch(userId, title.id)) {
result.skipped++;
return;
}
const watchedAt = movie.watchedAt
? new Date(movie.watchedAt)
: movie.watchedOn
? new Date(movie.watchedOn)
: undefined;
logMovieWatch(userId, title.id, "import", watchedAt);
result.imported++;
}
async function processEpisode(
userId: string,
ep: ImportEpisode,
result: ImportResult,
cache?: Map<string, number | null>,
): Promise<void> {
const showTmdbId = await resolveShowTmdbId(
{
tmdbId: ep.showTmdbId,
imdbId: ep.imdbId,
tvdbId: ep.tvdbId,
title: ep.showTitle,
year: ep.year,
},
cache,
);
if (!showTmdbId) {
result.failed++;
result.errors.push(
`Could not resolve show: "${ep.showTitle ?? "unknown"}" S${ep.seasonNumber}E${ep.episodeNumber}`,
);
return;
}
const title = await getOrFetchTitleByTmdbId(showTmdbId, "tv");
if (!title) {
result.failed++;
result.errors.push(`Failed to fetch metadata for show TMDB ${showTmdbId}`);
return;
}
// Find the specific episode in our DB
const season = db
.select()
.from(seasons)
.where(
and(
eq(seasons.titleId, title.id),
eq(seasons.seasonNumber, ep.seasonNumber),
),
)
.get();
if (!season) {
result.failed++;
result.errors.push(
`Season ${ep.seasonNumber} not found for "${title.title}"`,
);
return;
}
const episode = db
.select()
.from(episodes)
.where(
and(
eq(episodes.seasonId, season.id),
eq(episodes.episodeNumber, ep.episodeNumber),
),
)
.get();
if (!episode) {
result.failed++;
result.errors.push(
`S${ep.seasonNumber}E${ep.episodeNumber} not found for "${title.title}"`,
);
return;
}
if (hasExistingEpisodeWatch(userId, episode.id)) {
result.skipped++;
return;
}
const watchedAt = ep.watchedAt
? new Date(ep.watchedAt)
: ep.watchedOn
? new Date(ep.watchedOn)
: undefined;
logEpisodeWatch(userId, episode.id, "import", watchedAt);
result.imported++;
}
async function processWatchlistItem(
userId: string,
item: ImportWatchlistItem,
result: ImportResult,
cache?: Map<string, number | null>,
): Promise<void> {
const resolveFn =
item.type === "movie" ? resolveMovieTmdbId : resolveShowTmdbId;
const tmdbId = await resolveFn(
{
tmdbId: item.tmdbId,
imdbId: item.imdbId,
tvdbId: item.tvdbId,
title: item.title,
year: item.year,
},
cache,
);
if (!tmdbId) {
result.failed++;
result.errors.push(
`Could not resolve watchlist item: "${item.title}"${item.year ? ` (${item.year})` : ""}`,
);
return;
}
const title = await getOrFetchTitleByTmdbId(tmdbId, item.type);
if (!title) {
result.failed++;
result.errors.push(
`Failed to fetch metadata for ${item.type} TMDB ${tmdbId}`,
);
return;
}
if (hasExistingWatchlistStatus(userId, title.id)) {
result.skipped++;
return;
}
setTitleStatus(userId, title.id, "watchlist", "import");
result.imported++;
}
async function processRating(
userId: string,
item: ImportRating,
result: ImportResult,
cache?: Map<string, number | null>,
): Promise<void> {
const resolveFn =
item.type === "movie" ? resolveMovieTmdbId : resolveShowTmdbId;
const tmdbId = await resolveFn(
{
tmdbId: item.tmdbId,
imdbId: item.imdbId,
tvdbId: item.tvdbId,
title: item.title,
year: item.year,
},
cache,
);
if (!tmdbId) {
result.failed++;
result.errors.push(
`Could not resolve rating item: "${item.title}"${item.year ? ` (${item.year})` : ""}`,
);
return;
}
const title = await getOrFetchTitleByTmdbId(tmdbId, item.type);
if (!title) {
result.failed++;
result.errors.push(
`Failed to fetch metadata for ${item.type} TMDB ${tmdbId}`,
);
return;
}
if (hasExistingRating(userId, title.id)) {
result.skipped++;
return;
}
const ratedAt = item.ratedAt
? new Date(item.ratedAt)
: item.ratedOn
? new Date(item.ratedOn)
: undefined;
rateTitleStars(userId, title.id, item.rating, ratedAt);
result.imported++;
}
// ─── Read Job Helper ─────────────────────────────────────────────────
export function readImportJob(jobId: string, userId?: string): ImportJob {
const row = db
.select({
id: importJobs.id,
userId: importJobs.userId,
source: importJobs.source,
status: importJobs.status,
totalItems: importJobs.totalItems,
processedItems: importJobs.processedItems,
importedCount: importJobs.importedCount,
skippedCount: importJobs.skippedCount,
failedCount: importJobs.failedCount,
currentMessage: importJobs.currentMessage,
errors: importJobs.errors,
warnings: importJobs.warnings,
createdAt: importJobs.createdAt,
startedAt: importJobs.startedAt,
finishedAt: importJobs.finishedAt,
})
.from(importJobs)
.where(eq(importJobs.id, jobId))
.get();
if (!row) {
throw new Error(`Import job ${jobId} not found`);
}
if (userId && row.userId !== userId) {
throw new Error("Not authorized");
}
return {
id: row.id,
source: row.source as ImportJob["source"],
status: row.status as ImportJob["status"],
totalItems: row.totalItems,
processedItems: row.processedItems,
importedCount: row.importedCount,
skippedCount: row.skippedCount,
failedCount: row.failedCount,
currentMessage: row.currentMessage,
errors: safeParseJsonArray(row.errors),
warnings: safeParseJsonArray(row.warnings),
createdAt: row.createdAt.toISOString(),
startedAt: row.startedAt?.toISOString() ?? null,
finishedAt: row.finishedAt?.toISOString() ?? null,
};
}
// ─── Job Processor ───────────────────────────────────────────────────
export async function processImportJob(jobId: string): Promise<void> {
const row = db
.select()
.from(importJobs)
.where(eq(importJobs.id, jobId))
.get();
if (!row) {
throw new Error(`Import job ${jobId} not found`);
}
let rawPayload: unknown;
try {
rawPayload = JSON.parse(row.payload);
} catch {
throw new Error(`Import job ${jobId} has invalid JSON payload`);
}
const parsed = NormalizedImportSchema.safeParse(rawPayload);
if (!parsed.success) {
throw new Error(
`Import job ${jobId} has malformed payload: ${parsed.error.message}`,
);
}
const data = parsed.data;
const result: ImportResult = {
imported: 0,
skipped: 0,
failed: 0,
errors: [],
warnings: [],
};
try {
// Build item list based on options
const items: { type: string; index: number }[] = [];
if (row.importWatches) {
for (let i = 0; i < data.movies.length; i++) {
items.push({ type: "movie", index: i });
}
for (let i = 0; i < data.episodes.length; i++) {
items.push({ type: "episode", index: i });
}
}
if (row.importWatchlist) {
for (let i = 0; i < data.watchlist.length; i++) {
items.push({ type: "watchlist", index: i });
}
}
if (row.importRatings) {
for (let i = 0; i < data.ratings.length; i++) {
items.push({ type: "rating", index: i });
}
}
const total = items.length;
if (total === 0) {
result.warnings.push("No items to import with the selected options.");
db.update(importJobs)
.set({
status: "success",
finishedAt: new Date(),
totalItems: 0,
warnings: JSON.stringify(result.warnings),
})
.where(eq(importJobs.id, jobId))
.run();
return;
}
// Set status to running + totalItems atomically
db.update(importJobs)
.set({ status: "running", startedAt: new Date(), totalItems: total })
.where(eq(importJobs.id, jobId))
.run();
log.info(
`Starting ${data.source} import job ${jobId} for user ${row.userId}: ${total} items`,
);
// Shared resolution cache for the entire import job
const resolveCache = new Map<string, number | null>();
const progressInterval = 4;
for (let i = 0; i < items.length; i++) {
// Check for cancellation periodically
if (i % progressInterval === 0) {
const currentStatus = db
.select({ status: importJobs.status })
.from(importJobs)
.where(eq(importJobs.id, jobId))
.get();
if (currentStatus?.status === "cancelled") {
db.update(importJobs)
.set({
finishedAt: new Date(),
errors: JSON.stringify(result.errors),
warnings: JSON.stringify(result.warnings),
currentMessage: "Import cancelled",
})
.where(eq(importJobs.id, jobId))
.run();
log.info(`Import job ${jobId} cancelled by user`);
return;
}
}
const item = items[i];
try {
switch (item.type) {
case "movie":
await processMovie(
row.userId,
data.movies[item.index],
result,
resolveCache,
);
break;
case "episode":
await processEpisode(
row.userId,
data.episodes[item.index],
result,
resolveCache,
);
break;
case "watchlist":
await processWatchlistItem(
row.userId,
data.watchlist[item.index],
result,
resolveCache,
);
break;
case "rating":
await processRating(
row.userId,
data.ratings[item.index],
result,
resolveCache,
);
break;
}
} catch (err) {
result.failed++;
result.errors.push(
`Unexpected error: ${err instanceof Error ? err.message : String(err)}`,
);
}
// Update DB progress periodically
if (
i % progressInterval === progressInterval - 1 ||
i === items.length - 1
) {
const currentItem =
item.type === "movie"
? data.movies[item.index]
: item.type === "episode"
? data.episodes[item.index]
: item.type === "watchlist"
? data.watchlist[item.index]
: data.ratings[item.index];
const label =
"title" in currentItem
? currentItem.title
: "showTitle" in currentItem
? (currentItem.showTitle ?? "Unknown")
: "Unknown";
db.update(importJobs)
.set({
processedItems: i + 1,
importedCount: result.imported,
skippedCount: result.skipped,
failedCount: result.failed,
currentMessage: label,
})
.where(eq(importJobs.id, jobId))
.run();
}
}
// Success
db.update(importJobs)
.set({
status: "success",
finishedAt: new Date(),
processedItems: total,
importedCount: result.imported,
skippedCount: result.skipped,
failedCount: result.failed,
errors: JSON.stringify(result.errors),
warnings: JSON.stringify(result.warnings),
currentMessage: "Import complete",
})
.where(eq(importJobs.id, jobId))
.run();
log.info(
`Import job ${jobId} complete: ${result.imported} imported, ${result.skipped} skipped, ${result.failed} failed`,
);
} catch (err) {
// Fatal error
result.errors.push(
`Fatal: ${err instanceof Error ? err.message : String(err)}`,
);
db.update(importJobs)
.set({
status: "error",
finishedAt: new Date(),
errors: JSON.stringify(result.errors),
warnings: JSON.stringify(result.warnings),
currentMessage: "Import failed",
})
.where(eq(importJobs.id, jobId))
.run();
log.error(`Import job ${jobId} failed:`, err);
}
}
+185
View File
@@ -0,0 +1,185 @@
import { findByExternalId, searchMovies, searchTv } from "@sofa/tmdb/client";
// ─── Types ──────────────────────────────────────────────────────────
export interface ExternalIds {
tmdbId?: number;
imdbId?: string;
tvdbId?: number | string;
title?: string;
year?: number;
}
// ─── Rate Limiter ───────────────────────────────────────────────────
class RateLimiter {
private tokens: number;
private lastRefill: number;
constructor(
private maxTokens: number,
private refillMs: number,
) {
this.tokens = maxTokens;
this.lastRefill = Date.now();
}
async acquire(): Promise<void> {
const maxAttempts = 100;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
this.refill();
if (this.tokens > 0) {
this.tokens--;
return;
}
const waitMs = this.refillMs - (Date.now() - this.lastRefill);
await new Promise((resolve) => setTimeout(resolve, Math.max(waitMs, 50)));
}
throw new Error("Rate limiter timed out after too many attempts");
}
private refill() {
const now = Date.now();
if (now - this.lastRefill >= this.refillMs) {
this.tokens = this.maxTokens;
this.lastRefill = now;
}
}
}
/** TMDB allows 40 req/s — use 35 to leave headroom */
const tmdbLimiter = new RateLimiter(35, 1_000);
// ─── Movie Resolution ───────────────────────────────────────────────
/**
* Resolve a movie's TMDB ID from various external IDs.
* Fallback chain: direct TMDB ID → IMDB → TVDB → title+year search.
*/
export async function resolveMovieTmdbId(
ids: ExternalIds,
cache?: Map<string, number | null>,
): Promise<number | null> {
if (ids.tmdbId) return ids.tmdbId;
const key = `movie:${ids.imdbId ?? ""}:${ids.tvdbId ?? ""}:${ids.title ?? ""}:${ids.year ?? ""}`;
if (cache?.has(key)) return cache.get(key) ?? null;
if (ids.imdbId) {
await tmdbLimiter.acquire();
const result = await findByExternalId(ids.imdbId, "imdb_id");
const movie = result.movie_results?.[0];
if (movie) {
cache?.set(key, movie.id);
return movie.id;
}
}
if (ids.tvdbId) {
await tmdbLimiter.acquire();
const result = await findByExternalId(String(ids.tvdbId), "tvdb_id");
const movie = result.movie_results?.[0];
if (movie) {
cache?.set(key, movie.id);
return movie.id;
}
}
if (ids.title) {
await tmdbLimiter.acquire();
const searchResult = await searchMovies(ids.title);
const results = searchResult.results ?? [];
if (ids.year) {
const match = results.find((r) => {
const releaseYear = r.release_date
? new Date(r.release_date).getFullYear()
: null;
return releaseYear === ids.year;
});
if (match) {
cache?.set(key, match.id);
return match.id;
}
// Year specified but no match — don't fall back to an arbitrary result
} else if (results[0]) {
cache?.set(key, results[0].id);
return results[0].id;
}
}
cache?.set(key, null);
return null;
}
// ─── Show Resolution ────────────────────────────────────────────────
/**
* Resolve a TV show's TMDB ID from various external IDs.
* Fallback chain: direct TMDB ID → IMDB → TVDB → title search.
* Handles both show-level and episode-level external IDs.
*/
export async function resolveShowTmdbId(
ids: ExternalIds,
cache?: Map<string, number | null>,
): Promise<number | null> {
if (ids.tmdbId) return ids.tmdbId;
const key = `tv:${ids.imdbId ?? ""}:${ids.tvdbId ?? ""}:${ids.title ?? ""}:${ids.year ?? ""}`;
if (cache?.has(key)) return cache.get(key) ?? null;
if (ids.imdbId) {
await tmdbLimiter.acquire();
const result = await findByExternalId(ids.imdbId, "imdb_id");
// IMDB ID might reference an episode — extract the show_id
const ep = result.tv_episode_results?.[0];
if (ep) {
cache?.set(key, ep.show_id);
return ep.show_id;
}
const show = result.tv_results?.[0];
if (show) {
cache?.set(key, show.id);
return show.id;
}
}
if (ids.tvdbId) {
await tmdbLimiter.acquire();
const result = await findByExternalId(String(ids.tvdbId), "tvdb_id");
const ep = result.tv_episode_results?.[0];
if (ep) {
cache?.set(key, ep.show_id);
return ep.show_id;
}
const show = result.tv_results?.[0];
if (show) {
cache?.set(key, show.id);
return show.id;
}
}
if (ids.title) {
await tmdbLimiter.acquire();
const searchResult = await searchTv(ids.title);
const results = searchResult.results ?? [];
if (ids.year) {
const match = results.find((r) => {
const airYear = r.first_air_date
? new Date(r.first_air_date).getFullYear()
: null;
return airYear === ids.year;
});
if (match) {
cache?.set(key, match.id);
return match.id;
}
// Year specified but no match — don't fall back to an arbitrary result
} else if (results[0]) {
cache?.set(key, results[0].id);
return results[0].id;
}
}
cache?.set(key, null);
return null;
}
+10 -5
View File
@@ -16,8 +16,9 @@ export function setTitleStatus(
status: "watchlist" | "in_progress" | "completed",
// biome-ignore lint/correctness/noUnusedFunctionParameters: kept for API consistency with callers
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
addedAt?: Date,
) {
const now = new Date();
const now = addedAt ?? new Date();
db.insert(userTitleStatus)
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
.onConflictDoUpdate({
@@ -42,8 +43,9 @@ export function logMovieWatch(
userId: string,
titleId: string,
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
watchedAt?: Date,
) {
const now = new Date();
const now = watchedAt ?? new Date();
db.insert(userMovieWatches)
.values({ userId, titleId, watchedAt: now, source })
.run();
@@ -71,8 +73,9 @@ export function logEpisodeWatch(
userId: string,
episodeId: string,
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
watchedAt?: Date,
) {
const now = new Date();
const now = watchedAt ?? new Date();
db.insert(userEpisodeWatches)
.values({ userId, episodeId, watchedAt: now, source })
.run();
@@ -111,11 +114,12 @@ export function logEpisodeWatchBatch(
userId: string,
episodeIds: string[],
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
watchedAt?: Date,
) {
if (episodeIds.length === 0) return;
db.transaction((tx) => {
const now = new Date();
const now = watchedAt ?? new Date();
// Batch INSERT all watch records
for (const episodeId of episodeIds) {
@@ -382,8 +386,9 @@ export function rateTitleStars(
userId: string,
titleId: string,
ratingStars: number,
ratedAt?: Date,
) {
const now = new Date();
const now = ratedAt ?? new Date();
if (ratingStars === 0) {
db.delete(userRatings)
.where(
+18 -61
View File
@@ -9,7 +9,7 @@ import {
userMovieWatches,
} from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { findByExternalId, searchTv } from "@sofa/tmdb/client";
import { resolveMovieTmdbId, resolveShowTmdbId } from "./imports/resolve";
import { getOrFetchTitleByTmdbId } from "./metadata";
import { logEpisodeWatch, logMovieWatch } from "./tracking";
@@ -157,24 +157,6 @@ export function parseEmbyPayload(
// ─── Title Resolution ───────────────────────────────────────────────
async function resolveMovieTmdbId(event: WebhookEvent): Promise<number | null> {
if (event.tmdbId) return event.tmdbId;
if (event.imdbId) {
const result = await findByExternalId(event.imdbId, "imdb_id");
const movie = result.movie_results?.[0];
if (movie) return movie.id;
}
if (event.tvdbId) {
const result = await findByExternalId(event.tvdbId, "tvdb_id");
const movie = result.movie_results?.[0];
if (movie) return movie.id;
}
return null;
}
async function resolveEpisode(event: WebhookEvent): Promise<{
showTmdbId: number;
seasonNumber: number;
@@ -183,48 +165,15 @@ async function resolveEpisode(event: WebhookEvent): Promise<{
const { seasonNumber, episodeNumber } = event;
if (seasonNumber == null || episodeNumber == null) return null;
// Strategy 1: Use IMDB ID to find the episode and get show_id
if (event.imdbId) {
const result = await findByExternalId(event.imdbId, "imdb_id");
const ep = result.tv_episode_results?.[0];
if (ep) {
return { showTmdbId: ep.show_id, seasonNumber, episodeNumber };
}
// IMDB ID might reference the show itself
const show = result.tv_results?.[0];
if (show) {
return { showTmdbId: show.id, seasonNumber, episodeNumber };
}
}
const showTmdbId = await resolveShowTmdbId({
tmdbId: event.tmdbId,
imdbId: event.imdbId,
tvdbId: event.tvdbId,
title: event.showTitle,
});
// Strategy 2: Use TVDB ID
if (event.tvdbId) {
const result = await findByExternalId(event.tvdbId, "tvdb_id");
const ep = result.tv_episode_results?.[0];
if (ep) {
return { showTmdbId: ep.show_id, seasonNumber, episodeNumber };
}
const show = result.tv_results?.[0];
if (show) {
return { showTmdbId: show.id, seasonNumber, episodeNumber };
}
}
// Strategy 3: Use TMDB ID directly if it's the show ID
if (event.tmdbId) {
return { showTmdbId: event.tmdbId, seasonNumber, episodeNumber };
}
// Strategy 4: Search by show title
if (event.showTitle) {
const searchResult = await searchTv(event.showTitle);
const tvMatch = searchResult.results?.[0];
if (tvMatch) {
return { showTmdbId: tvMatch.id, seasonNumber, episodeNumber };
}
}
return null;
if (!showTmdbId) return null;
return { showTmdbId, seasonNumber, episodeNumber };
}
// ─── Deduplication ──────────────────────────────────────────────────
@@ -303,7 +252,15 @@ export async function processWebhook(
log.info(`Received ${provider} webhook: ${event.mediaType} "${event.title}"`);
try {
if (event.mediaType === "movie") {
const tmdbId = await resolveMovieTmdbId(event);
const hasExternalId = event.tmdbId || event.imdbId || event.tvdbId;
const tmdbId = await resolveMovieTmdbId({
tmdbId: event.tmdbId,
imdbId: event.imdbId,
tvdbId: event.tvdbId,
// Only use title fallback when at least one external ID is present;
// title-only resolution without year is too unreliable for webhooks
title: hasExternalId ? event.title : undefined,
});
if (!tmdbId) {
log.warn(
`Could not resolve TMDB ID for movie "${event.title}" from ${provider}`,
File diff suppressed because it is too large Load Diff
+390
View File
@@ -0,0 +1,390 @@
import { beforeEach, describe, expect, spyOn, test } from "bun:test";
import { eq } from "@sofa/db/helpers";
import {
importJobs,
titles,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@sofa/db/schema";
import {
clearAllTables,
insertMovieWatch,
insertTvShow,
insertUser,
testDb,
} from "@sofa/db/test-utils";
import * as tmdbClient from "@sofa/tmdb/client";
import type { NormalizedImport } from "../src/imports/parsers";
import { processImportJob, readImportJob } from "../src/imports/processor";
// ── Helpers ─────────────────────────────────────────────────────────
/** Insert a fully-fetched movie title (lastFetchedAt set so metadata won't re-fetch). */
function insertMovieTitle(
id: string,
tmdbId: number,
movieTitle = "Test Movie",
) {
testDb
.insert(titles)
.values({
id,
tmdbId,
type: "movie",
title: movieTitle,
lastFetchedAt: new Date(),
})
.run();
return id;
}
/** Insert a fully-fetched TV title with seasons/episodes (lastFetchedAt set). */
function insertTvShowWithFetchedAt(
titleId: string,
tmdbId: number,
seasonCount = 1,
epsPerSeason = 3,
) {
const result = insertTvShow(titleId, tmdbId, seasonCount, epsPerSeason);
// Mark as fully fetched so getOrFetchTitleByTmdbId skips TMDB API calls
testDb
.update(titles)
.set({ lastFetchedAt: new Date() })
.where(eq(titles.id, titleId))
.run();
return result;
}
/** Create an import job row in the DB and return its ID. */
function createJob(
userId: string,
payload: NormalizedImport,
options: {
importWatches?: boolean;
importWatchlist?: boolean;
importRatings?: boolean;
} = {},
): string {
const id = `job-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
testDb
.insert(importJobs)
.values({
id,
userId,
source: payload.source,
status: "pending",
payload: JSON.stringify(payload),
importWatches: options.importWatches ?? true,
importWatchlist: options.importWatchlist ?? true,
importRatings: options.importRatings ?? true,
createdAt: new Date(),
})
.run();
return id;
}
beforeEach(() => {
clearAllTables();
});
// ── Movie Import ────────────────────────────────────────────────────
describe("processImportJob — movies", () => {
test("imports a movie with direct tmdbId", async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
const payload: NormalizedImport = {
source: "trakt",
movies: [
{
tmdbId: 550,
title: "Fight Club",
year: 1999,
watchedAt: "2024-06-15T20:00:00Z",
},
],
episodes: [],
watchlist: [],
ratings: [],
};
const jobId = createJob(userId, payload);
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.importedCount).toBe(1);
expect(job.skippedCount).toBe(0);
expect(job.failedCount).toBe(0);
// Verify watch record created
const watches = testDb
.select()
.from(userMovieWatches)
.where(eq(userMovieWatches.userId, userId))
.all();
expect(watches).toHaveLength(1);
expect(watches[0].titleId).toBe("movie-1");
});
test("deduplicates existing movie watches", async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
insertMovieWatch(userId, "movie-1");
const payload: NormalizedImport = {
source: "trakt",
movies: [{ tmdbId: 550, title: "Fight Club" }],
episodes: [],
watchlist: [],
ratings: [],
};
const jobId = createJob(userId, payload);
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.importedCount).toBe(0);
expect(job.skippedCount).toBe(1);
// Should still be just the original watch
const watches = testDb
.select()
.from(userMovieWatches)
.where(eq(userMovieWatches.userId, userId))
.all();
expect(watches).toHaveLength(1);
});
});
// ── Episode Import ──────────────────────────────────────────────────
describe("processImportJob — episodes", () => {
test("imports episodes for a pre-seeded TV show", async () => {
const userId = insertUser();
insertTvShowWithFetchedAt("tv-1", 1399, 1, 3);
const payload: NormalizedImport = {
source: "trakt",
movies: [],
episodes: [
{
showTmdbId: 1399,
showTitle: "Test Show",
seasonNumber: 1,
episodeNumber: 1,
watchedAt: "2024-01-10T20:00:00Z",
},
{
showTmdbId: 1399,
showTitle: "Test Show",
seasonNumber: 1,
episodeNumber: 2,
watchedAt: "2024-01-11T20:00:00Z",
},
],
watchlist: [],
ratings: [],
};
const jobId = createJob(userId, payload);
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.importedCount).toBe(2);
expect(job.failedCount).toBe(0);
const watches = testDb
.select()
.from(userEpisodeWatches)
.where(eq(userEpisodeWatches.userId, userId))
.all();
expect(watches).toHaveLength(2);
});
});
// ── Watchlist Import ────────────────────────────────────────────────
describe("processImportJob — watchlist", () => {
test("sets title status to watchlist", async () => {
const userId = insertUser();
insertMovieTitle("movie-wl", 999, "Watchlist Movie");
const payload: NormalizedImport = {
source: "simkl",
movies: [],
episodes: [],
watchlist: [{ tmdbId: 999, title: "Watchlist Movie", type: "movie" }],
ratings: [],
};
const jobId = createJob(userId, payload, {
importWatches: false,
importWatchlist: true,
importRatings: false,
});
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.importedCount).toBe(1);
const statusRow = testDb
.select()
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all();
expect(statusRow).toHaveLength(1);
expect(statusRow[0].status).toBe("watchlist");
expect(statusRow[0].titleId).toBe("movie-wl");
});
});
// ── Rating Import ───────────────────────────────────────────────────
describe("processImportJob — ratings", () => {
test("stores rating correctly", async () => {
const userId = insertUser();
insertMovieTitle("movie-r", 888, "Rated Movie");
const payload: NormalizedImport = {
source: "trakt",
movies: [],
episodes: [],
watchlist: [],
ratings: [
{
tmdbId: 888,
title: "Rated Movie",
type: "movie",
rating: 4,
ratedAt: "2024-03-01T12:00:00Z",
},
],
};
const jobId = createJob(userId, payload, {
importWatches: false,
importWatchlist: false,
importRatings: true,
});
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.importedCount).toBe(1);
const ratingRows = testDb
.select()
.from(userRatings)
.where(eq(userRatings.userId, userId))
.all();
expect(ratingRows).toHaveLength(1);
expect(ratingRows[0].ratingStars).toBe(4);
expect(ratingRows[0].titleId).toBe("movie-r");
});
});
// ── Job State Transitions ───────────────────────────────────────────
describe("processImportJob — state transitions", () => {
test("job starts as pending, ends as success", async () => {
const userId = insertUser();
insertMovieTitle("movie-st", 111, "State Test");
const payload: NormalizedImport = {
source: "letterboxd",
movies: [{ tmdbId: 111, title: "State Test" }],
episodes: [],
watchlist: [],
ratings: [],
};
const jobId = createJob(userId, payload);
// Before processing
const before = readImportJob(jobId);
expect(before.status).toBe("pending");
expect(before.startedAt).toBeNull();
expect(before.finishedAt).toBeNull();
await processImportJob(jobId);
// After processing
const after = readImportJob(jobId);
expect(after.status).toBe("success");
expect(after.startedAt).not.toBeNull();
expect(after.finishedAt).not.toBeNull();
});
test("empty import with no matching options succeeds with warning", async () => {
const userId = insertUser();
const payload: NormalizedImport = {
source: "trakt",
movies: [{ tmdbId: 111, title: "A Movie" }],
episodes: [],
watchlist: [],
ratings: [],
};
// Disable all import options — movies exist but importWatches is false
const jobId = createJob(userId, payload, {
importWatches: false,
importWatchlist: false,
importRatings: false,
});
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.warnings.length).toBeGreaterThan(0);
expect(job.warnings[0]).toContain("No items to import");
});
test("readImportJob throws for non-existent job", () => {
expect(() => readImportJob("non-existent-id")).toThrow(
"Import job non-existent-id not found",
);
});
});
// ── Failed Resolution ───────────────────────────────────────────────
describe("processImportJob — failed resolution", () => {
test("records failure when movie cannot be resolved", async () => {
const userId = insertUser();
const payload: NormalizedImport = {
source: "letterboxd",
movies: [{ title: "Completely Unknown Film ZZZZZ" }],
episodes: [],
watchlist: [],
ratings: [],
};
// Mock TMDB search to return empty results (no network call)
const searchSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [],
} as never);
try {
const jobId = createJob(userId, payload);
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.failedCount).toBe(1);
expect(job.importedCount).toBe(0);
expect(job.errors.length).toBeGreaterThan(0);
expect(job.errors[0]).toContain("Could not resolve movie");
} finally {
searchSpy.mockRestore();
}
});
});
+301
View File
@@ -0,0 +1,301 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test";
import * as tmdbClient from "@sofa/tmdb/client";
import { resolveMovieTmdbId, resolveShowTmdbId } from "../src/imports/resolve";
// The TMDB client functions (findByExternalId, searchMovies, searchTv) are
// called by the resolve functions. We spy on them directly rather than on
// globalThis.fetch because openapi-fetch captures the fetch reference at
// module-init time, making globalThis.fetch mocking ineffective.
let findSpy: ReturnType<typeof spyOn>;
let searchMoviesSpy: ReturnType<typeof spyOn>;
let searchTvSpy: ReturnType<typeof spyOn>;
afterEach(() => {
findSpy?.mockRestore();
searchMoviesSpy?.mockRestore();
searchTvSpy?.mockRestore();
});
// ── resolveMovieTmdbId ──────────────────────────────────────────────
describe("resolveMovieTmdbId", () => {
test("returns tmdbId directly when provided", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId");
searchMoviesSpy = spyOn(tmdbClient, "searchMovies");
const result = await resolveMovieTmdbId({ tmdbId: 123 });
expect(result).toBe(123);
expect(findSpy).not.toHaveBeenCalled();
expect(searchMoviesSpy).not.toHaveBeenCalled();
});
test("resolves via IMDB ID lookup", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [{ id: 456 }],
tv_results: [],
tv_episode_results: [],
} as never);
const result = await resolveMovieTmdbId({ imdbId: "tt1234567" });
expect(result).toBe(456);
expect(findSpy).toHaveBeenCalledWith("tt1234567", "imdb_id");
});
test("falls back to TVDB lookup when no IMDB ID", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [{ id: 789 }],
tv_results: [],
tv_episode_results: [],
} as never);
const result = await resolveMovieTmdbId({ tvdbId: 99887 });
expect(result).toBe(789);
expect(findSpy).toHaveBeenCalledWith("99887", "tvdb_id");
});
test("IMDB returns no movie, falls back to TVDB", async () => {
let callIndex = 0;
findSpy = spyOn(tmdbClient, "findByExternalId").mockImplementation(
async () => {
callIndex++;
if (callIndex === 1) {
// IMDB lookup — no results
return {
movie_results: [],
tv_results: [],
tv_episode_results: [],
} as never;
}
// TVDB lookup — has result
return {
movie_results: [{ id: 789 }],
tv_results: [],
tv_episode_results: [],
} as never;
},
);
const result = await resolveMovieTmdbId({
imdbId: "tt0000001",
tvdbId: 99887,
});
expect(result).toBe(789);
expect(findSpy).toHaveBeenCalledTimes(2);
});
test("falls back to title search when no IDs available", async () => {
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [{ id: 321, title: "Inception", release_date: "2010-07-16" }],
} as never);
const result = await resolveMovieTmdbId({ title: "Inception" });
expect(result).toBe(321);
expect(searchMoviesSpy).toHaveBeenCalledWith("Inception");
});
test("title search with year prefers matching year", async () => {
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [
{ id: 100, title: "Dune", release_date: "1984-12-14" },
{ id: 200, title: "Dune", release_date: "2021-10-22" },
],
} as never);
const result = await resolveMovieTmdbId({ title: "Dune", year: 2021 });
expect(result).toBe(200);
});
test("title search without year match returns null", async () => {
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [
{ id: 100, title: "Dune", release_date: "1984-12-14" },
{ id: 200, title: "Dune", release_date: "2021-10-22" },
],
} as never);
// year=1999 doesn't match any result — don't fall back to an arbitrary result
const result = await resolveMovieTmdbId({ title: "Dune", year: 1999 });
expect(result).toBeNull();
});
test("returns null when all methods fail", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [],
tv_episode_results: [],
} as never);
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [],
} as never);
const result = await resolveMovieTmdbId({
imdbId: "tt0000000",
title: "NonexistentFilm",
});
expect(result).toBeNull();
});
test("returns null when no identifiers at all", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId");
searchMoviesSpy = spyOn(tmdbClient, "searchMovies");
const result = await resolveMovieTmdbId({});
expect(result).toBeNull();
expect(findSpy).not.toHaveBeenCalled();
expect(searchMoviesSpy).not.toHaveBeenCalled();
});
test("cache prevents duplicate lookups", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [{ id: 555 }],
tv_results: [],
tv_episode_results: [],
} as never);
const cache = new Map<string, number | null>();
const first = await resolveMovieTmdbId({ imdbId: "tt9999999" }, cache);
expect(first).toBe(555);
expect(findSpy).toHaveBeenCalledTimes(1);
const second = await resolveMovieTmdbId({ imdbId: "tt9999999" }, cache);
expect(second).toBe(555);
// No additional calls — cache hit
expect(findSpy).toHaveBeenCalledTimes(1);
});
test("cache stores null for unresolvable items", async () => {
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [],
} as never);
const cache = new Map<string, number | null>();
const first = await resolveMovieTmdbId({ title: "Nothing" }, cache);
expect(first).toBeNull();
// Cache should contain a null entry
expect(cache.size).toBe(1);
const cachedValue = [...cache.values()][0];
expect(cachedValue).toBeNull();
});
});
// ── resolveShowTmdbId ───────────────────────────────────────────────
describe("resolveShowTmdbId", () => {
test("returns tmdbId directly when provided", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId");
const result = await resolveShowTmdbId({ tmdbId: 42 });
expect(result).toBe(42);
expect(findSpy).not.toHaveBeenCalled();
});
test("resolves via IMDB ID — show-level result", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [{ id: 600, name: "Breaking Bad" }],
tv_episode_results: [],
} as never);
const result = await resolveShowTmdbId({ imdbId: "tt5555555" });
expect(result).toBe(600);
});
test("resolves via IMDB ID — episode-level result extracts show_id", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [],
tv_episode_results: [
{
id: 9001,
episode_number: 3,
name: "Fly",
season_number: 3,
show_id: 700,
},
],
} as never);
const result = await resolveShowTmdbId({ imdbId: "tt7777777" });
expect(result).toBe(700);
});
test("falls back to TVDB lookup", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [{ id: 800 }],
tv_episode_results: [],
} as never);
const result = await resolveShowTmdbId({ tvdbId: 12345 });
expect(result).toBe(800);
expect(findSpy).toHaveBeenCalledWith("12345", "tvdb_id");
});
test("TVDB lookup extracts show_id from episode result", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [],
tv_episode_results: [
{
id: 1,
episode_number: 1,
name: "Pilot",
season_number: 1,
show_id: 850,
},
],
} as never);
const result = await resolveShowTmdbId({ tvdbId: 54321 });
expect(result).toBe(850);
});
test("falls back to title search", async () => {
searchTvSpy = spyOn(tmdbClient, "searchTv").mockResolvedValue({
results: [{ id: 900, name: "The Office" }],
} as never);
const result = await resolveShowTmdbId({ title: "The Office" });
expect(result).toBe(900);
expect(searchTvSpy).toHaveBeenCalledWith("The Office");
});
test("returns null when all methods fail", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [],
tv_episode_results: [],
} as never);
searchTvSpy = spyOn(tmdbClient, "searchTv").mockResolvedValue({
results: [],
} as never);
const result = await resolveShowTmdbId({
imdbId: "tt0000000",
title: "Nothing",
});
expect(result).toBeNull();
});
test("cache prevents duplicate show lookups", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [{ id: 950 }],
tv_episode_results: [],
} as never);
const cache = new Map<string, number | null>();
const first = await resolveShowTmdbId({ imdbId: "tt8888888" }, cache);
expect(first).toBe(950);
expect(findSpy).toHaveBeenCalledTimes(1);
const second = await resolveShowTmdbId({ imdbId: "tt8888888" }, cache);
expect(second).toBe(950);
expect(findSpy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,25 @@
CREATE TABLE `importJobs` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`source` text NOT NULL,
`status` text NOT NULL,
`payload` text NOT NULL,
`importWatches` integer NOT NULL,
`importWatchlist` integer NOT NULL,
`importRatings` integer NOT NULL,
`totalItems` integer DEFAULT 0 NOT NULL,
`processedItems` integer DEFAULT 0 NOT NULL,
`importedCount` integer DEFAULT 0 NOT NULL,
`skippedCount` integer DEFAULT 0 NOT NULL,
`failedCount` integer DEFAULT 0 NOT NULL,
`currentMessage` text,
`errors` text,
`warnings` text,
`createdAt` integer NOT NULL,
`startedAt` integer,
`finishedAt` integer,
CONSTRAINT `fk_importJobs_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `importJobs_userId_createdAt` ON `importJobs` (`userId`,`createdAt`);--> statement-breakpoint
CREATE INDEX `importJobs_status` ON `importJobs` (`status`);
File diff suppressed because it is too large Load Diff
+37
View File
@@ -486,6 +486,43 @@ export const cronRuns = sqliteTable(
],
);
// ─── Import Jobs ────────────────────────────────────────────────────
export const importJobs = sqliteTable(
"importJobs",
{
id: uuidPk(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
source: text("source", {
enum: ["trakt", "simkl", "letterboxd"],
}).notNull(),
status: text("status", {
enum: ["pending", "running", "success", "error", "cancelled"],
}).notNull(),
payload: text("payload").notNull(),
importWatches: int("importWatches", { mode: "boolean" }).notNull(),
importWatchlist: int("importWatchlist", { mode: "boolean" }).notNull(),
importRatings: int("importRatings", { mode: "boolean" }).notNull(),
totalItems: int("totalItems").notNull().default(0),
processedItems: int("processedItems").notNull().default(0),
importedCount: int("importedCount").notNull().default(0),
skippedCount: int("skippedCount").notNull().default(0),
failedCount: int("failedCount").notNull().default(0),
currentMessage: text("currentMessage"),
errors: text("errors"),
warnings: text("warnings"),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
startedAt: int("startedAt", { mode: "timestamp" }),
finishedAt: int("finishedAt", { mode: "timestamp" }),
},
(table) => [
index("importJobs_userId_createdAt").on(table.userId, table.createdAt),
index("importJobs_status").on(table.status),
],
);
// ─── App Settings ───────────────────────────────────────────────────
export const appSettings = sqliteTable("appSettings", {