Migrate web app from Next.js to Vite + TanStack Router SPA (#6)

* Convert to Turborepo monorepo with shared API contract package

Restructure the repository as a monorepo in preparation for adding
future clients (mobile app, CLI). Extract the oRPC contract and Zod
schemas into `@sofa/api` (packages/api/) as a JIT internal package,
and relocate the Next.js app to `@sofa/web` (apps/web/).

- Add Turborepo with Bun workspaces for task orchestration and caching
- Extract `contract.ts` and `schemas.ts` into `@sofa/api` package
- Move all app code, configs, tests, and migrations to `apps/web/`
- Update 17 import paths from `@/lib/orpc/schemas` to `@sofa/api/schemas`
- Add `outputFileTracingRoot` and `transpilePackages` to next.config.ts
- Rewrite Dockerfile with `turbo prune --docker` for efficient builds
- Update CI workflows to use `turbo run` for lint/check-types/test
- Update CLAUDE.md with monorepo structure and commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract standalone Hono API server and split shared packages

Separate all server-side concerns from the Next.js frontend into a new
`apps/server/` Hono app and dedicated shared packages, making `@sofa/web`
a frontend-only app with no direct DB or service access.

- Add `@sofa/server` (`apps/server/`) — Hono API on port 3001 hosting
  oRPC procedures, Better Auth, cron jobs, and non-RPC routes
- Add `@sofa/core` (`packages/core/`) — All 15 business logic services
  moved from `apps/web/lib/services/`; tests moved to `packages/core/test/`
- Add `@sofa/db` (`packages/db/`) — DB client, schema, migrations,
  constants, and logger extracted from `apps/web/lib/db/` and `lib/`
- Add `@sofa/tmdb` (`packages/tmdb/`) — TMDB client and image helpers
  moved from `apps/web/lib/tmdb/`
- Add `@sofa/auth` (`packages/auth/`) — Better Auth server config moved
  from `apps/web/lib/auth/`
- Move oRPC procedures, handler, router, middleware to `apps/server/src/orpc/`
- Move Hono route handlers (avatars, backups, images, lists, webhooks,
  health) to `apps/server/src/routes/`; delete equivalent Next.js API routes
- Strip `apps/web` to frontend-only: no DB imports, no service imports,
  all data via oRPC client calls to the API server
- Add `entrypoint.sh` to start API server, wait for health, then Next.js
- Update `next.config.ts` rewrites to proxy `/rpc/*` and `/api/*` to
  `INTERNAL_API_URL` (default `http://localhost:3001`)
- Update Dockerfile and CLAUDE.md for the new structure

* Migrate web app from Next.js to Vite + TanStack Router SPA and add workspace catalog

Replace Next.js with a pure Vite SPA using TanStack Router for file-based routing,
removing all SSR complexity. The API server (Hono) now serves both API routes and
SPA static files in production, simplifying Docker to a single-process container.

Key changes:
- Vite 7 + @tanstack/react-router with file-based routing via plugin
- Route guards via beforeLoad + authClient.getSession() (replaces server-side auth)
- Route loaders with queryClient.ensureQueryData() (replaces SSR data fetching)
- Self-hosted fonts via @fontsource (replaces next/font/google)
- Tailwind v4 via @tailwindcss/vite (replaces @tailwindcss/postcss)
- Single oRPC client (removed SSR client and server-side session helper)
- Hono serves SPA static files in production (single port 3000)
- Single-process Dockerfile (removed entrypoint.sh)
- Bun workspace catalog for centralized dependency version management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract @sofa/logger and @sofa/config shared packages

- Add `@sofa/logger` (`packages/logger/`) — standalone logger package
  extracted from `@sofa/db/logger`; update all imports across server,
  core, auth, db, and tmdb packages
- Add `@sofa/config` (`packages/config/`) — standalone config/constants
  package extracted from `@sofa/db/constants`; exports `DATA_DIR`,
  `DATABASE_URL`, `CACHE_DIR`, `AVATAR_DIR`, `BACKUP_DIR`
- Move `.env.example` from `apps/web/` to repo root; update server dev
  scripts to load it via `--env-file=../../.env`
- Move image serving from `/api/images` to `/images`; add `serveStatic`
  fast path in `index.ts` for cached files before falling back to the
  TMDB fetch route; add `/images` proxy to Vite dev config
- Fix `Sparkline` component: replace `ResponsiveContainer` with
  `ResizeObserver` to avoid SSR/hydration issues with recharts
- Replace `VITE_SERVER_URL` env var with `window.location.origin` in
  the oRPC client (always same-origin in both dev and production)

* Fix asset caching, SPA 404 fallback, and DATA_DIR resolution

- Add `Cache-Control: immutable` header for hashed `/assets/*` files;
  return 404 for missing asset paths instead of falling back to
  `index.html` (prevents serving stale chunks after deploy)
- Wrap `query.invalidate` in an arrow function in the oRPC QueryClient
  error handler to avoid illegal invocation errors
- Resolve `DATA_DIR` to an absolute path via `path.resolve()` so
  relative paths work regardless of the process working directory

* Migrate @sofa/logger to pino for structured logging

- Replace custom logger implementation in `packages/logger/` with pino
  + pino-pretty; add both as workspace catalog dependencies
- Add `pino` and `pino-pretty` to the workspace catalog in `package.json`
- Fix `log.error()` calls in oRPC and OpenAPI handlers to pass the
  error directly instead of wrapping it in `{ error }` to match pino's
  serializer expectations

* Rename discoverProcedure/statsProcedure exports to discover/stats

* Add TanStackDevtools unified panel and VS Code workspace config

- Replace separate Router/Query devtools with unified `TanStackDevtools`
  from `@tanstack/react-devtools` + `@tanstack/devtools-vite` plugin
- Wrap app in `<StrictMode>` in `main.tsx`
- Add `.vscode/settings.json` (Biome formatter, format-on-save, readonly
  `routeTree.gen.ts`) and `.vscode/extensions.json` (recommended extensions)

* Move test DB helpers to @sofa/db/test-utils and add root bunfig.toml

Extract in-memory SQLite setup and fixture helpers (insertUser, insertTitle,
etc.) from packages/core/test/sqlite.ts into packages/db/src/test-utils.ts
so DB test utilities live alongside the schema they depend on. Use
import.meta.dir for CWD-independent migration path resolution.

Add root bunfig.toml so `bun test` works from the repo root in addition
to `bun run test` (turbo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix devtools plugin order and whitespace-only TMDB token check

Move devtools() to first position in Vite plugins array per TanStack
docs, and trim TMDB_API_READ_ACCESS_TOKEN before boolean coercion so
whitespace-only values are treated as unconfigured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 16:50:34 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 26558b29e4
commit a326c968b7
325 changed files with 26970 additions and 28519 deletions
+8
View File
@@ -0,0 +1,8 @@
import { implement } from "@orpc/server";
import { contract } from "@sofa/api/contract";
export interface Context {
headers: Headers;
}
export const os = implement(contract).$context<Context>();
+14
View File
@@ -0,0 +1,14 @@
import { onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import { createLogger } from "@sofa/logger";
import { router } from "./router";
const log = createLogger("orpc");
export const handler = new RPCHandler(router, {
interceptors: [
onError((error) => {
log.error("oRPC error", error);
}),
],
});
+44
View File
@@ -0,0 +1,44 @@
import { oo } from "@orpc/openapi";
import { os as baseOs, ORPCError } from "@orpc/server";
import { auth } from "@sofa/auth/server";
const base = baseOs.$context<{ headers: Headers }>();
export const authed = oo.spec(
base.middleware(async ({ context, next }) => {
const sessionData = await auth.api.getSession({
headers: context.headers,
});
if (!sessionData?.session || !sessionData?.user) {
throw new ORPCError("UNAUTHORIZED");
}
return next({
context: {
user: sessionData.user,
session: sessionData.session,
},
});
}),
{ security: [{ session: [] }] },
);
export const admin = oo.spec(
base.middleware(async ({ context, next }) => {
const sessionData = await auth.api.getSession({
headers: context.headers,
});
if (!sessionData?.session || !sessionData?.user) {
throw new ORPCError("UNAUTHORIZED");
}
if (sessionData.user.role !== "admin") {
throw new ORPCError("FORBIDDEN");
}
return next({
context: {
user: sessionData.user,
session: sessionData.session,
},
});
}),
{ security: [{ session: [] }] },
);
+48
View File
@@ -0,0 +1,48 @@
import { SmartCoercionPlugin } from "@orpc/json-schema";
import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
import { onError } from "@orpc/server";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { createLogger } from "@sofa/logger";
import { router } from "./router";
const log = createLogger("openapi");
const isSecure = (process.env.BETTER_AUTH_URL ?? "").startsWith("https://");
const sessionCookieName = isSecure
? "__Secure-better-auth.session_token"
: "better-auth.session_token";
// https://orpc.dev/docs/openapi/plugins/smart-coercion
const schemaConverters = [new ZodToJsonSchemaConverter()];
export const openApiHandler = new OpenAPIHandler(router, {
plugins: [
new SmartCoercionPlugin({ schemaConverters }),
new OpenAPIReferencePlugin({
schemaConverters,
specGenerateOptions: {
info: {
title: "Sofa API",
version: process.env.APP_VERSION || "0.0.0",
},
servers: [{ url: "/api/v1" }],
components: {
securitySchemes: {
session: {
type: "apiKey",
name: sessionCookieName,
in: "cookie",
description: "Better Auth session cookie",
},
},
},
},
}),
],
interceptors: [
onError((error) => {
log.error("OpenAPI error", error);
}),
],
});
@@ -0,0 +1,68 @@
import { mkdir, rename } from "node:fs/promises";
import path from "node:path";
import { auth } from "@sofa/auth/server";
import { AVATAR_DIR } from "@sofa/config";
import { os } from "../context";
import { authed } from "../middleware";
const MIME_TO_EXT: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
};
export const updateName = os.account.updateName
.use(authed)
.handler(async ({ input, context }) => {
await auth.api.updateUser({
body: { name: input.name },
headers: context.headers,
});
});
export const uploadAvatar = os.account.uploadAvatar
.use(authed)
.handler(async ({ input: file, context }) => {
await mkdir(AVATAR_DIR, { recursive: true });
// Write new avatar first (atomic: temp file + rename)
const ext = MIME_TO_EXT[file.type] || "jpg";
const filename = `${context.user.id}.${ext}`;
const filePath = path.join(AVATAR_DIR, filename);
const tmpPath = `${filePath}.tmp.${Date.now()}`;
await Bun.write(tmpPath, file);
await rename(tmpPath, filePath);
// Remove any previous avatar with a different extension
const glob = new Bun.Glob(`${context.user.id}.*`);
const existing = await Array.fromAsync(glob.scan(AVATAR_DIR));
for (const match of existing) {
if (match !== filename) {
await Bun.file(path.join(AVATAR_DIR, match)).delete();
}
}
// Update user via Better Auth
const imageUrl = `/api/avatars/${context.user.id}?v=${Date.now()}`;
await auth.api.updateUser({
body: { image: imageUrl },
headers: context.headers,
});
return { imageUrl };
});
export const removeAvatar = os.account.removeAvatar
.use(authed)
.handler(async ({ context }) => {
const glob = new Bun.Glob(`${context.user.id}.*`);
const matches = await Array.fromAsync(glob.scan(AVATAR_DIR));
for (const match of matches) {
await Bun.file(path.join(AVATAR_DIR, match)).delete();
}
await auth.api.updateUser({
body: { image: "" },
headers: context.headers,
});
});
+130
View File
@@ -0,0 +1,130 @@
import path from "node:path";
import { ORPCError } from "@orpc/server";
import { BACKUP_DIR } from "@sofa/config";
import {
createBackup,
deleteBackup,
ensureBackupDir,
listBackups,
restoreFromBackup,
} from "@sofa/core/backup";
import { getSetting, setSetting } from "@sofa/core/settings";
import {
getCachedUpdateCheck,
isUpdateCheckEnabled,
} from "@sofa/core/update-check";
import { rescheduleBackup, triggerJob } from "../../cron";
import { os } from "../context";
import { admin } from "../middleware";
// ─── Backups ───────────────────────────────────────────────────
export const backupsList = os.admin.backups.list
.use(admin)
.handler(async () => {
const backups = await listBackups();
return { backups };
});
export const backupsCreate = os.admin.backups.create
.use(admin)
.handler(async () => {
return await createBackup();
});
export const backupsDelete = os.admin.backups.delete
.use(admin)
.handler(async ({ input }) => {
await deleteBackup(input.filename);
});
export const backupsRestore = os.admin.backups.restore
.use(admin)
.handler(async ({ input: file }) => {
// Stream upload to disk to avoid buffering the entire file in memory
await ensureBackupDir();
const tmpPath = path.join(
BACKUP_DIR,
`.upload-${Date.now()}-${crypto.randomUUID()}.db`,
);
try {
await Bun.write(tmpPath, file);
await restoreFromBackup(tmpPath);
} catch (err) {
// Clean up the upload file if restoreFromBackup didn't consume it
const f = Bun.file(tmpPath);
if (await f.exists()) await f.delete();
throw err;
}
});
export const backupsSchedule = os.admin.backups.schedule
.use(admin)
.handler(() => {
return {
enabled: getSetting("scheduledBackups") === "true",
maxRetention: Number.parseInt(
getSetting("maxBackupRetention") ?? "7",
10,
),
frequency: getSetting("backupScheduleFrequency") ?? "1d",
time: getSetting("backupScheduleTime") ?? "02:00",
dayOfWeek: Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10),
};
});
export const backupsUpdateSchedule = os.admin.backups.updateSchedule
.use(admin)
.handler(({ input }) => {
if (input.enabled !== undefined)
setSetting("scheduledBackups", String(input.enabled));
if (input.frequency !== undefined)
setSetting("backupScheduleFrequency", input.frequency);
if (input.time !== undefined) setSetting("backupScheduleTime", input.time);
if (input.dayOfWeek !== undefined)
setSetting("backupScheduleDow", String(input.dayOfWeek));
if (input.maxRetention !== undefined)
setSetting("maxBackupRetention", String(input.maxRetention));
if (input.frequency || input.time || input.dayOfWeek !== undefined) {
rescheduleBackup();
}
});
// ─── Registration ──────────────────────────────────────────────
export const registration = os.admin.registration.use(admin).handler(() => {
return { open: getSetting("registrationOpen") === "true" };
});
export const toggleRegistration = os.admin.toggleRegistration
.use(admin)
.handler(({ input }) => {
setSetting("registrationOpen", String(input.open));
});
// ─── Update Check ──────────────────────────────────────────────
export const updateCheck = os.admin.updateCheck.use(admin).handler(() => {
const enabled = isUpdateCheckEnabled();
const check = enabled ? getCachedUpdateCheck() : null;
return { enabled, updateCheck: check };
});
export const toggleUpdateCheck = os.admin.toggleUpdateCheck
.use(admin)
.handler(({ input }) => {
setSetting("updateCheckEnabled", String(input.enabled));
});
// ─── Jobs ──────────────────────────────────────────────────────
export const triggerJobProcedure = os.admin.triggerJob
.use(admin)
.handler(async ({ input }) => {
const triggered = await triggerJob(input.name);
if (!triggered) {
throw new ORPCError("NOT_FOUND", { message: "Job not found" });
}
return { ok: true as const };
});
@@ -0,0 +1,73 @@
import {
getContinueWatchingFeed,
getNewAvailableFeed,
getRecommendationsFeed,
getUserStats,
} from "@sofa/core/discovery";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
export const stats = os.dashboard.stats.use(authed).handler(({ context }) => {
return getUserStats(context.user.id);
});
export const continueWatching = os.dashboard.continueWatching
.use(authed)
.handler(({ context }) => {
const feed = getContinueWatchingFeed(context.user.id);
const items = feed.map((item) => ({
title: {
id: item.title.id,
title: item.title.title,
backdropPath: tmdbImageUrl(item.title.backdropPath, "backdrops"),
},
nextEpisode: item.nextEpisode
? {
seasonNumber: item.nextEpisode.seasonNumber,
episodeNumber: item.nextEpisode.episodeNumber,
name: item.nextEpisode.name,
stillPath: tmdbImageUrl(item.nextEpisode.stillPath, "stills"),
}
: null,
totalEpisodes: item.totalEpisodes,
watchedEpisodes: item.watchedEpisodes,
}));
return { items };
});
export const library = os.dashboard.library
.use(authed)
.handler(({ context }) => {
const feed = getNewAvailableFeed(context.user.id);
const items = feed.slice(0, 10).map((t) => ({
id: t.titleId,
tmdbId: t.tmdbId,
type: t.type,
title: t.title,
posterPath: tmdbImageUrl(t.posterPath, "posters"),
releaseDate: t.releaseDate ?? t.firstAirDate ?? null,
voteAverage: t.voteAverage,
userStatus: t.userStatus,
}));
return { items };
});
export const recommendations = os.dashboard.recommendations
.use(authed)
.handler(({ context }) => {
const feed = getRecommendationsFeed(context.user.id);
const items = feed
.filter((t): t is NonNullable<typeof t> => t != null)
.slice(0, 10)
.map((t) => ({
id: t.id,
tmdbId: t.tmdbId,
type: t.type,
title: t.title,
posterPath: tmdbImageUrl(t.posterPath, "posters"),
releaseDate: t.releaseDate ?? t.firstAirDate ?? null,
voteAverage: t.voteAverage,
}));
return { items };
});
@@ -0,0 +1,55 @@
import { ORPCError } from "@orpc/server";
import {
getEpisodeProgressByTmdbIds,
getUserStatusesByTmdbIds,
} from "@sofa/core/tracking";
import { discover as discoverTmdb } from "@sofa/tmdb/client";
import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
export const discover = os.discover
.use(authed)
.handler(async ({ input, context }) => {
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured.",
});
}
const results = await discoverTmdb(input.mediaType, {
sort_by: "popularity.desc",
"vote_count.gte": "50",
with_genres: String(input.genreId),
});
type DiscoverResult = NonNullable<typeof results.results>[number] & {
title?: string;
name?: string;
release_date?: string;
first_air_date?: string;
};
const items = ((results.results ?? []) as DiscoverResult[])
.filter((r) => r.poster_path)
.map((r) => ({
tmdbId: r.id,
type: input.mediaType,
title: r.title ?? r.name ?? "",
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
releaseDate: r.release_date ?? r.first_air_date ?? null,
voteAverage: r.vote_average,
}));
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
const [userStatuses, episodeProgress] =
lookups.length > 0
? [
getUserStatusesByTmdbIds(context.user.id, lookups),
getEpisodeProgressByTmdbIds(context.user.id, lookups),
]
: [{}, {}];
return { items, userStatuses, episodeProgress };
});
@@ -0,0 +1,25 @@
import {
logEpisodeWatch,
logEpisodeWatchBatch,
unwatchEpisode,
} from "@sofa/core/tracking";
import { os } from "../context";
import { authed } from "../middleware";
export const watch = os.episodes.watch
.use(authed)
.handler(({ input, context }) => {
logEpisodeWatch(context.user.id, input.id);
});
export const unwatch = os.episodes.unwatch
.use(authed)
.handler(({ input, context }) => {
unwatchEpisode(context.user.id, input.id);
});
export const batchWatch = os.episodes.batchWatch
.use(authed)
.handler(({ input, context }) => {
logEpisodeWatchBatch(context.user.id, input.episodeIds);
});
+119
View File
@@ -0,0 +1,119 @@
import { ORPCError } from "@orpc/server";
import {
getEpisodeProgressByTmdbIds,
getUserStatusesByTmdbIds,
} from "@sofa/core/tracking";
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
function requireTmdb() {
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured.",
});
}
}
export const trending = os.explore.trending
.use(authed)
.handler(async ({ input, context }) => {
requireTmdb();
const data = await getTrending(input.type, "day");
const results = (data.results ?? []) as Record<string, unknown>[];
const items = results
.filter((r) => r.poster_path)
.map((r) => {
const mediaType =
r.media_type === "movie" || r.media_type === "tv"
? r.media_type
: "movie";
return {
tmdbId: r.id as number,
type: mediaType as "movie" | "tv",
title: ((r.title ?? r.name) as string) || "",
posterPath: tmdbImageUrl(
(r.poster_path as string) ?? null,
"posters",
),
releaseDate: ((r.release_date ?? r.first_air_date) as string) ?? null,
voteAverage: r.vote_average as number,
};
});
const heroResult = results.find(
(r) =>
r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
);
const hero = heroResult
? {
tmdbId: heroResult.id as number,
type: heroResult.media_type as "movie" | "tv",
title:
((heroResult.title ?? heroResult.name) as string | undefined) ?? "",
overview: (heroResult.overview as string | undefined) ?? "",
backdropPath: tmdbImageUrl(
(heroResult.backdrop_path as string) ?? null,
"backdrops",
),
voteAverage: heroResult.vote_average as number,
}
: null;
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
const [userStatuses, episodeProgress] =
lookups.length > 0
? [
getUserStatusesByTmdbIds(context.user.id, lookups),
getEpisodeProgressByTmdbIds(context.user.id, lookups),
]
: [{}, {}];
return { items, hero, userStatuses, episodeProgress };
});
export const popular = os.explore.popular
.use(authed)
.handler(async ({ input, context }) => {
requireTmdb();
const data = await getPopular(input.type);
const items = ((data.results ?? []) as Record<string, unknown>[])
.filter((r) => r.poster_path)
.map((r) => ({
tmdbId: r.id as number,
type: input.type,
title: ((r.title ?? r.name) as string) || "",
posterPath: tmdbImageUrl((r.poster_path as string) ?? null, "posters"),
releaseDate: ((r.release_date ?? r.first_air_date) as string) ?? null,
voteAverage: r.vote_average as number,
}));
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
const [userStatuses, episodeProgress] =
lookups.length > 0
? [
getUserStatusesByTmdbIds(context.user.id, lookups),
getEpisodeProgressByTmdbIds(context.user.id, lookups),
]
: [{}, {}];
return { items, userStatuses, episodeProgress };
});
export const genres = os.explore.genres
.use(authed)
.handler(async ({ input }) => {
requireTmdb();
const data = await getGenres(input.type);
return {
genres: (data.genres ?? []).map((g) => ({
id: g.id,
name: g.name ?? "",
})),
};
});
@@ -0,0 +1,152 @@
import { ORPCError } from "@orpc/server";
import { db } from "@sofa/db/client";
import { and, desc, eq } from "@sofa/db/helpers";
import { integrationEvents, integrations } from "@sofa/db/schema";
import { os } from "../context";
import { authed } from "../middleware";
const LIST_PROVIDERS = new Set(["sonarr", "radarr"]);
function integrationTypeFor(provider: string): "webhook" | "list" {
return LIST_PROVIDERS.has(provider) ? "list" : "webhook";
}
function generateToken() {
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
"hex",
);
}
function serializeIntegration(row: {
id: string;
provider: string;
type: "webhook" | "list";
token: string;
enabled: boolean;
lastEventAt: Date | null;
createdAt: Date;
}) {
return {
...row,
lastEventAt: row.lastEventAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
export const list = os.integrations.list.use(authed).handler(({ context }) => {
const userIntegrations = db
.select()
.from(integrations)
.where(eq(integrations.userId, context.user.id))
.all();
const eventsByIntegration = new Map<
string,
(typeof integrationEvents.$inferSelect)[]
>();
for (const integration of userIntegrations) {
const events = db
.select()
.from(integrationEvents)
.where(eq(integrationEvents.integrationId, integration.id))
.orderBy(desc(integrationEvents.receivedAt))
.limit(10)
.all();
eventsByIntegration.set(integration.id, events);
}
const result = userIntegrations.map((integration) => {
const events = eventsByIntegration.get(integration.id) ?? [];
return {
...serializeIntegration(integration),
recentEvents: events.map((e) => ({
id: e.id,
eventType: e.eventType,
mediaType: e.mediaType,
mediaTitle: e.mediaTitle,
status: e.status,
receivedAt: e.receivedAt.toISOString(),
})),
};
});
return { integrations: result };
});
export const create = os.integrations.create
.use(authed)
.handler(({ input, context }) => {
const existing = db
.select()
.from(integrations)
.where(
and(
eq(integrations.userId, context.user.id),
eq(integrations.provider, input.provider),
),
)
.get();
if (existing) {
if (input.enabled !== undefined) {
const row = db
.update(integrations)
.set({ enabled: input.enabled })
.where(eq(integrations.id, existing.id))
.returning()
.get();
return serializeIntegration(row);
}
return serializeIntegration(existing);
}
const row = db
.insert(integrations)
.values({
userId: context.user.id,
provider: input.provider,
type: integrationTypeFor(input.provider),
token: generateToken(),
enabled: input.enabled ?? true,
createdAt: new Date(),
})
.returning()
.get();
return serializeIntegration(row);
});
export const deleteProcedure = os.integrations.delete
.use(authed)
.handler(({ input, context }) => {
db.delete(integrations)
.where(
and(
eq(integrations.userId, context.user.id),
eq(integrations.provider, input.provider),
),
)
.run();
});
export const regenerateToken = os.integrations.regenerateToken
.use(authed)
.handler(({ input, context }) => {
const row = db
.update(integrations)
.set({ token: generateToken() })
.where(
and(
eq(integrations.userId, context.user.id),
eq(integrations.provider, input.provider),
),
)
.returning()
.get();
if (!row) {
throw new ORPCError("NOT_FOUND", { message: "Integration not found" });
}
return serializeIntegration(row);
});
+34
View File
@@ -0,0 +1,34 @@
import { ORPCError } from "@orpc/server";
import {
getLocalFilmography,
getOrFetchPerson,
getOrFetchPersonByTmdbId,
} from "@sofa/core/person";
import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
import { os } from "../context";
import { authed } from "../middleware";
export const detail = os.people.detail
.use(authed)
.handler(async ({ input, context }) => {
const person = await getOrFetchPerson(input.id);
if (!person)
throw new ORPCError("NOT_FOUND", { message: "Person not found" });
const filmography = getLocalFilmography(person.id);
const userStatuses = getUserStatusesByTitleIds(
context.user.id,
filmography.map((c) => c.titleId),
);
return { person, filmography, userStatuses };
});
export const resolve = os.people.resolve
.use(authed)
.handler(async ({ input }) => {
const person = await getOrFetchPersonByTmdbId(input.tmdbId);
if (!person)
throw new ORPCError("NOT_FOUND", { message: "Person not found" });
return { id: person.id };
});
+103
View File
@@ -0,0 +1,103 @@
import { ORPCError } from "@orpc/server";
import {
searchMovies,
searchMulti,
searchPerson,
searchTv,
} from "@sofa/tmdb/client";
import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
export const search = os.search.use(authed).handler(async ({ input }) => {
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured.",
});
}
const query = input.query.trim();
if (!query) {
return { results: [] };
}
const type = input.type ?? null;
if (type === "person") {
const personResults = await searchPerson(query);
return {
results: (personResults.results ?? []).map((r) => ({
tmdbId: r.id,
type: "person" as const,
title: r.name ?? "",
posterPath: null,
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
overview: "",
releaseDate: null,
popularity: r.popularity,
voteAverage: 0,
knownForDepartment: r.known_for_department,
knownFor: r.known_for
?.slice(0, 3)
.map((k) => k.title ?? (k as { name?: string }).name)
.filter(Boolean) as string[] | undefined,
})),
};
}
const raw =
type === "movie"
? await searchMovies(query)
: type === "tv"
? await searchTv(query)
: await searchMulti(query);
type SearchResult = {
id: number;
media_type?: string;
title?: string;
name?: string;
overview?: string;
poster_path?: string | null;
profile_path?: string | null;
release_date?: string;
first_air_date?: string;
popularity?: number;
vote_average?: number;
};
const mapped = ((raw.results ?? []) as SearchResult[])
.map((r) => {
if (r.media_type === "person") {
return {
tmdbId: r.id,
type: "person" as const,
title: r.name ?? "Unknown",
posterPath: null,
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
overview: "",
releaseDate: null,
popularity: r.popularity ?? 0,
voteAverage: 0,
};
}
const mediaType =
r.media_type === "movie" || r.media_type === "tv" ? r.media_type : type;
if (!mediaType) return null;
return {
tmdbId: r.id,
type: mediaType,
title: r.title ?? r.name ?? "",
overview: r.overview ?? "",
releaseDate: r.release_date ?? r.first_air_date ?? null,
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
popularity: r.popularity ?? 0,
voteAverage: r.vote_average ?? 0,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
return { results: mapped };
});
@@ -0,0 +1,26 @@
import { logEpisodeWatchBatch, unwatchSeason } from "@sofa/core/tracking";
import { db } from "@sofa/db/client";
import { eq } from "@sofa/db/helpers";
import { episodes } from "@sofa/db/schema";
import { os } from "../context";
import { authed } from "../middleware";
export const watch = os.seasons.watch
.use(authed)
.handler(({ input, context }) => {
const seasonEps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, input.id))
.all();
logEpisodeWatchBatch(
context.user.id,
seasonEps.map((ep) => ep.id),
);
});
export const unwatch = os.seasons.unwatch
.use(authed)
.handler(({ input, context }) => {
unwatchSeason(context.user.id, input.id);
});
+9
View File
@@ -0,0 +1,9 @@
import { getWatchCount, getWatchHistory } from "@sofa/core/discovery";
import { os } from "../context";
import { authed } from "../middleware";
export const stats = os.stats.use(authed).handler(({ input, context }) => {
const count = getWatchCount(context.user.id, input.type, input.period);
const history = getWatchHistory(context.user.id, input.type, input.period);
return { count, history };
});
+17
View File
@@ -0,0 +1,17 @@
import { getSystemHealth } from "@sofa/core/system-health";
import { isTmdbConfigured } from "@sofa/tmdb/config";
import { os } from "../context";
import { authed } from "../middleware";
export const systemStatus = os.systemStatus
.use(authed)
.handler(async ({ context }) => {
const tmdbConfigured = isTmdbConfigured();
if (context.user.role === "admin") {
const health = await getSystemHealth();
return { tmdbConfigured, health };
}
return { tmdbConfigured };
});
+49
View File
@@ -0,0 +1,49 @@
import {
getOidcProviderName,
isOidcConfigured,
isPasswordLoginDisabled,
} from "@sofa/auth/config";
import { getUserCount, isRegistrationOpen } from "@sofa/core/settings";
import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
// Well-known TMDB poster paths for the background collage
const posterPaths = [
"/qJ2tW6WMUDux911r6m7haRef0WH.jpg",
"/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg",
"/rCzpDGLbOoPwLjy3OAm5NUPOTrC.jpg",
"/pB8BM7pdSp6B6Ih7QZ4DrQ3PmJK.jpg",
"/d5NXSklXo0qyIYkgV94XAgMIckC.jpg",
"/ztkUQFLlC19CCMYHW9o1zWhJRNq.jpg",
"/7DJKHzAi83BmQrWLrYYOqcoKfhR.jpg",
"/u3bZgnGQ9T01sWNhyveQz0wH0Hl.jpg",
"/ggFHVNu6YYI5L9pCfOacjizRGt.jpg",
"/q6y0Go1tsGEsmtFryDOJo3dEmqu.jpg",
"/pIkRyD18kl4FhoCNQuWxWu5cBLM.jpg",
"/saHP97rTPS5eLmrLQEcANmKrsFl.jpg",
];
export const publicInfo = os.system.publicInfo.handler(async () => {
const posterUrls = posterPaths
.map((p) => tmdbImageUrl(p, "posters", "w300"))
.filter(Boolean) as string[];
return {
tmdbConfigured: isTmdbConfigured(),
userCount: getUserCount(),
registrationOpen: isRegistrationOpen(),
posterUrls,
};
});
export const authConfig = os.system.authConfig.handler(async () => {
const oidcEnabled = isOidcConfigured();
return {
oidcEnabled,
oidcProviderName: oidcEnabled ? getOidcProviderName() : null,
passwordLoginDisabled: isPasswordLoginDisabled(),
registrationOpen: isRegistrationOpen(),
userCount: getUserCount(),
};
});
+88
View File
@@ -0,0 +1,88 @@
import { ORPCError } from "@orpc/server";
import { getRecommendationsForTitle } from "@sofa/core/discovery";
import {
ensureTvHydrated,
getOrFetchTitle,
getOrFetchTitleByTmdbId,
} from "@sofa/core/metadata";
import {
getUserStatusesByTitleIds,
getUserTitleInfo,
logMovieWatch,
markAllEpisodesWatched,
rateTitleStars,
removeTitleStatus,
setTitleStatus,
} from "@sofa/core/tracking";
import { os } from "../context";
import { authed } from "../middleware";
export const detail = os.titles.detail
.use(authed)
.handler(async ({ input }) => {
const result = await getOrFetchTitle(input.id);
if (!result)
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
return result;
});
export const resolve = os.titles.resolve
.use(authed)
.handler(async ({ input }) => {
const title = await getOrFetchTitleByTmdbId(input.tmdbId, input.type);
if (!title)
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
return { id: title.id };
});
export const updateStatus = os.titles.updateStatus
.use(authed)
.handler(({ input, context }) => {
if (input.status === null) {
removeTitleStatus(context.user.id, input.id);
} else {
setTitleStatus(context.user.id, input.id, input.status);
}
});
export const updateRating = os.titles.updateRating
.use(authed)
.handler(({ input, context }) => {
rateTitleStars(context.user.id, input.id, input.stars);
});
export const watchMovie = os.titles.watchMovie
.use(authed)
.handler(({ input, context }) => {
logMovieWatch(context.user.id, input.id);
});
export const watchAll = os.titles.watchAll
.use(authed)
.handler(({ input, context }) => {
markAllEpisodesWatched(context.user.id, input.id);
});
export const userInfo = os.titles.userInfo
.use(authed)
.handler(({ input, context }) => {
return getUserTitleInfo(context.user.id, input.id);
});
export const recommendations = os.titles.recommendations
.use(authed)
.handler(({ input, context }) => {
const recs = getRecommendationsForTitle(input.id);
const userStatuses = getUserStatusesByTitleIds(
context.user.id,
recs.map((r) => r.id),
);
return { recommendations: recs, userStatuses };
});
export const hydrateSeasons = os.titles.hydrateSeasons
.use(authed)
.handler(async ({ input }) => {
const seasons = await ensureTvHydrated(input.id, input.tmdbId);
return { seasons };
});
@@ -0,0 +1,36 @@
import { ORPCError } from "@orpc/server";
import { getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
import { setTitleStatus } from "@sofa/core/tracking";
import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers";
import { userTitleStatus } from "@sofa/db/schema";
import { os } from "../context";
import { authed } from "../middleware";
export const quickAdd = os.watchlist.quickAdd
.use(authed)
.handler(async ({ input, context }) => {
const title = await getOrFetchTitleByTmdbId(input.tmdbId, input.type);
if (!title) {
throw new ORPCError("BAD_GATEWAY", {
message: "Failed to import title",
});
}
const existing = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, context.user.id),
eq(userTitleStatus.titleId, title.id),
),
)
.get();
if (!existing) {
setTitleStatus(context.user.id, title.id, "watchlist");
}
return { id: title.id, alreadyAdded: !!existing };
});
+93
View File
@@ -0,0 +1,93 @@
import { os } from "./context";
import * as account from "./procedures/account";
import * as admin from "./procedures/admin";
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 integrations from "./procedures/integrations";
import * as people from "./procedures/people";
import { search } from "./procedures/search";
import * as seasons from "./procedures/seasons";
import { stats } from "./procedures/stats";
import { systemStatus } from "./procedures/status";
import * as system from "./procedures/system";
import * as titles from "./procedures/titles";
import * as watchlist from "./procedures/watchlist";
export const router = os.router({
titles: {
detail: titles.detail,
resolve: titles.resolve,
updateStatus: titles.updateStatus,
updateRating: titles.updateRating,
watchMovie: titles.watchMovie,
watchAll: titles.watchAll,
userInfo: titles.userInfo,
recommendations: titles.recommendations,
hydrateSeasons: titles.hydrateSeasons,
},
episodes: {
watch: episodes.watch,
unwatch: episodes.unwatch,
batchWatch: episodes.batchWatch,
},
seasons: {
watch: seasons.watch,
unwatch: seasons.unwatch,
},
people: {
detail: people.detail,
resolve: people.resolve,
},
dashboard: {
stats: dashboard.stats,
continueWatching: dashboard.continueWatching,
library: dashboard.library,
recommendations: dashboard.recommendations,
},
explore: {
trending: explore.trending,
popular: explore.popular,
genres: explore.genres,
},
search,
discover,
stats,
systemStatus,
system: {
publicInfo: system.publicInfo,
authConfig: system.authConfig,
},
integrations: {
list: integrations.list,
create: integrations.create,
delete: integrations.deleteProcedure,
regenerateToken: integrations.regenerateToken,
},
admin: {
backups: {
list: admin.backupsList,
create: admin.backupsCreate,
delete: admin.backupsDelete,
restore: admin.backupsRestore,
schedule: admin.backupsSchedule,
updateSchedule: admin.backupsUpdateSchedule,
},
registration: admin.registration,
toggleRegistration: admin.toggleRegistration,
updateCheck: admin.updateCheck,
toggleUpdateCheck: admin.toggleUpdateCheck,
triggerJob: admin.triggerJobProcedure,
},
account: {
updateName: account.updateName,
uploadAvatar: account.uploadAvatar,
removeAvatar: account.removeAvatar,
},
watchlist: {
quickAdd: watchlist.quickAdd,
},
});
export type Router = typeof router;