mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
feat: add watch history import from Trakt, Simkl, and Letterboxd (#13)
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
TRAKT_CLIENT_ID=
|
||||
TRAKT_CLIENT_SECRET=
|
||||
SIMKL_CLIENT_ID=
|
||||
SIMKL_CLIENT_SECRET=
|
||||
GITHUB_TOKEN=
|
||||
POSTHOG_API_KEY=
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}]} />
|
||||
@@ -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 1–5 star scale:
|
||||
|
||||
| Source | Scale | Conversion |
|
||||
|---|---|---|
|
||||
| Trakt | 1–10 | `round(rating / 2)`, clamped to 1–5 |
|
||||
| Simkl | 1–10 | `round(rating / 2)`, clamped to 1–5 |
|
||||
| Letterboxd | 0.5–5 (half-stars) | `round(rating)`, clamped to 1–5 |
|
||||
|
||||
### 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.
|
||||
@@ -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,4 +1,4 @@
|
||||
{
|
||||
"title": "Integrations",
|
||||
"pages": ["plex", "jellyfin", "emby", "sonarr", "radarr"]
|
||||
"pages": ["plex", "jellyfin", "emby", "sonarr", "radarr", "import"]
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user