mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
feat: add watch history import from Trakt, Simkl, and Letterboxd (#13)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user