mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
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:
@@ -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>();
|
||||
@@ -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);
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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: [] }] },
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user