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
+3 -4
View File
@@ -1,5 +1,4 @@
node_modules node_modules
.next
.git .git
.gitignore .gitignore
*.md *.md
@@ -7,10 +6,10 @@ node_modules
*.db-* *.db-*
.env .env
.env.* .env.*
!.env.example
data
.DS_Store .DS_Store
.vercel data
coverage coverage
.vscode .vscode
.idea .idea
.turbo
.tanstack
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
- name: Get app version - name: Get app version
id: version id: version
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" run: echo "version=$(jq -r .version apps/web/package.json)" >> "$GITHUB_OUTPUT"
- name: Docker metadata - name: Docker metadata
id: meta id: meta
+7 -7
View File
@@ -18,11 +18,11 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Run tests with coverage - name: Lint
run: bun test --coverage --coverage-reporter=lcov run: bunx turbo run lint
# - name: Upload to Codecov - name: Type check
# uses: codecov/codecov-action@v3 run: bunx turbo run check-types
# with:
# file: ./coverage/lcov.info - name: Run tests
# fail_ci_if_error: true run: bunx turbo run test -- --coverage --coverage-reporter=lcov
+42 -33
View File
@@ -1,47 +1,56 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # Dependencies
/node_modules node_modules
/.pnp .pnp
.pnp.* .pnp.js
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing # Build outputs
/coverage dist
build
*.tsbuildinfo
.tanstack
# next.js # Environment variables
/.next/ .env
/out/ .env*.local
!.env.example
# production # IDEs and editors
/build .vscode/*
/data !.vscode/settings.json
!.vscode/tasks.json
# misc !.vscode/launch.json
!.vscode/extensions.json
.idea
*.swp
*.swo
*~
.DS_Store .DS_Store
*.pem
# sqlite # Logs
*.db logs
*.db-* *.log
# debug
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # Turbo
.env* .turbo
!.env.example
# vercel # Testing
.vercel coverage
.nyc_output
# typescript # Misc
*.tsbuildinfo *.tgz
next-env.d.ts .cache
tmp
temp
# local data
*.db
*.db-*
/data
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"biomejs.biome",
"bradlc.vscode-tailwindcss",
"vitest.explorer",
"vercel.turbo-vsc"
]
}
+41
View File
@@ -0,0 +1,41 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
},
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"emmet.showExpandedAbbreviation": "never",
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+140 -79
View File
@@ -5,14 +5,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands ## Commands
```bash ```bash
bun run dev # Start Next.js dev server # Root commands (via Turborepo)
bun run build # Production build bun run dev # Start API server + Vite dev server
bun run build # Production build (both apps)
bun run lint # Biome lint check bun run lint # Biome lint check
bun run format # Biome format (auto-fix) bun run format # Biome format (auto-fix)
bun run db:push # Push schema changes to SQLite database bun run check-types # TypeScript type check
bun run db:generate # Generate Drizzle migration files bun run test # Run tests
bun run db:migrate # Run Drizzle migrations
bun run db:studio # Open Drizzle Studio (visual DB browser) # Database commands (run from packages/db/)
cd packages/db && bun run db:push # Push schema changes to SQLite database
cd packages/db && bun run db:generate # Generate Drizzle migration files
cd packages/db && bun run db:migrate # Run Drizzle migrations
cd packages/db && bun run db:studio # Open Drizzle Studio (visual DB browser)
``` ```
IMPORTANT: Default to using Bun instead of Node.js: IMPORTANT: Default to using Bun instead of Node.js:
@@ -39,67 +44,113 @@ bun run test
## Architecture ## Architecture
**Sofa** is a self-hosted movie & TV tracking app (like Trakt/TVTime) built as a single Next.js 16 application with SQLite. **Sofa** is a self-hosted movie & TV tracking app (like Trakt/TVTime) built as a Turborepo monorepo with a standalone Hono API server, a Vite + TanStack Router SPA frontend, and shared packages.
### Monorepo structure
```
couch-potato/
├── apps/
│ ├── server/ # @sofa/server — Hono API server (oRPC, auth, cron, webhooks)
│ └── web/ # @sofa/web — Vite SPA (TanStack Router, TanStack Query)
├── packages/
│ ├── api/ # @sofa/api — oRPC contract + Zod schemas (shared)
│ ├── auth/ # @sofa/auth — Better Auth server config
│ ├── core/ # @sofa/core — Business logic services
│ ├── db/ # @sofa/db — Database client, schema, migrations, constants, logger
│ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper
├── turbo.json # Turborepo task configuration
├── biome.json # Shared Biome config
├── Dockerfile # Multi-stage Docker build (turbo prune, single process)
└── package.json # Root workspace config
```
- **`@sofa/server`** (`apps/server/`) — Hono API server. Hosts oRPC procedures, Better Auth, webhook/image/backup routes, and cron jobs. Runs DB migrations on startup. In production, also serves the SPA static files on port 3000. In dev, runs on port 3001.
- **`@sofa/web`** (`apps/web/`) — Vite SPA with TanStack Router (file-based routing). No SSR, no DB access, no services. All data fetched via oRPC client calls. Vite dev server proxies `/api/*` and `/rpc/*` to the API server.
- **`@sofa/api`** (`packages/api/`) — JIT package with the oRPC contract and Zod schemas. No build step.
- **`@sofa/db`** (`packages/db/`) — Database layer: Drizzle schema, client, migrations, constants, logger.
- **`@sofa/tmdb`** (`packages/tmdb/`) — TMDB API client (openapi-fetch) and image URL construction.
- **`@sofa/core`** (`packages/core/`) — All business logic services (metadata, tracking, discovery, etc.).
- **`@sofa/auth`** (`packages/auth/`) — Better Auth server config + environment checks.
All shared packages are JIT (raw TypeScript exports, no build step). Consumers transpile via their own bundlers.
### Stack ### Stack
- **Framework**: Next.js 16 (App Router), React 19, TypeScript - **Frontend**: Vite 7 SPA, React 19, TypeScript, TanStack Router (file-based routing)
- **API Server**: Hono (standalone Bun server)
- **Monorepo**: Turborepo with Bun workspaces
- **Database**: SQLite via bun:sqlite + Drizzle ORM (WAL mode, singleton via `globalThis`, sync queries, auto-migrations on startup) - **Database**: SQLite via bun:sqlite + Drizzle ORM (WAL mode, singleton via `globalThis`, sync queries, auto-migrations on startup)
- **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO via `genericOAuth` plugin - **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO via `genericOAuth` plugin
- **Styling**: Tailwind CSS v4, shadcn components, dark cinema theme with warm primary accents - **Styling**: Tailwind CSS v4 (via `@tailwindcss/vite`), shadcn components, dark cinema theme with warm primary accents
- **Fonts**: DM Serif Display (display), DM Sans (body), Geist Mono (mono) - **Fonts**: DM Serif Display (display), DM Sans (body), Geist Mono (mono) — self-hosted via `@fontsource`
- **API**: oRPC (contract-first, type-safe RPC) with `@orpc/tanstack-query` for TanStack Query integration - **API**: oRPC (contract-first, type-safe RPC) with `@orpc/tanstack-query` for TanStack Query integration
- **State**: Jotai (client), TanStack Query (data fetching & mutations via oRPC) - **State**: Jotai (client), TanStack Query (data fetching & mutations via oRPC)
- **Linting**: Biome (2-space indent, organized imports, React/Next.js recommended rules) - **Linting**: Biome (2-space indent, organized imports, React recommended rules)
- **External API**: TMDB (The Movie Database) with Bearer token auth - **External API**: TMDB (The Movie Database) with Bearer token auth
### Path alias ### Package imports
`@/*` maps to project root (`./`), e.g. `@/lib/db/client``./lib/db/client`. Within `apps/web/`, `@/*` maps to `apps/web/src/`, e.g. `@/components/nav-bar``apps/web/src/components/nav-bar`.
Cross-package imports use the package name:
- `@sofa/api/contract`, `@sofa/api/schemas` — Contract and types
- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/constants`, `@sofa/db/logger` — Database layer
- `@sofa/tmdb/client`, `@sofa/tmdb/image` — TMDB client
- `@sofa/core/metadata`, `@sofa/core/tracking`, etc. — Business logic
- `@sofa/auth/server`, `@sofa/auth/config` — Auth config
### Key directories ### Key directories
- `lib/db/schema.ts` — Single file with all Drizzle table definitions (auth tables + app tables) **API server** (`apps/server/src/`):
- `lib/db/migrate.ts` — Auto-migration runner (executed on startup via instrumentation) - `index.ts` — Hono app entry point, startup, CORS, route mounting, graceful shutdown
- `lib/services/` — Business logic layer (metadata, tracking, discovery, availability, credits, person, webhooks, backup, settings, colors, update-check, system-health) - `cron.ts` — Background job scheduler (croner)
- `lib/tmdb/` — TMDB API client and TypeScript types - `orpc/` — oRPC server layer (context, middleware, router, handler, procedures/)
- `lib/auth/` — Better Auth server config (`server.ts`), client hooks (`client.ts`), cached session helper (`session.ts`) - `routes/` — Non-RPC Hono routes (auth, images, avatars, backups, webhooks, lists, health)
- `lib/config.ts` — Server-side environment checks (TMDB configured, OIDC configured, etc.)
- `lib/logger.ts` — Structured logger with `LOG_LEVEL` support **Web app** (`apps/web/src/`):
- `lib/types/` — Shared TypeScript types (e.g. `title.ts`) - `main.tsx` — React root: creates router + renders `<RouterProvider />`
- `lib/cron.ts` — Background job scheduler (croner, `globalThis` singleton) - `routes/` — TanStack Router file-based routes (auto-generates `routeTree.gen.ts`)
- `lib/query-client.ts`Singleton `QueryClient` with default options (30s stale time) - `__root.tsx`Root layout (providers, head meta, global error/not-found)
- `lib/orpc/` — oRPC API layer (contract-first, type-safe RPC): - `_app.tsx` — Authenticated layout (auth guard via `beforeLoad`, navbar, shell)
- `contract.ts` — Full API contract definition (~30 procedures) - `_app/dashboard.tsx`, `_app/explore.tsx`, `_app/settings.tsx`, `_app/titles.$id.tsx`, `_app/people.$id.tsx`
- `schemas.ts`Shared Zod input schemas used by contract - `_auth.tsx`Auth layout (centering wrapper)
- `context.ts` — Context type & `os` implementer - `_auth/login.tsx`, `_auth/register.tsx`
- `middleware.ts` — Auth (`authed`) and admin (`admin`) middleware - `index.tsx`, `setup.tsx`
- `router.ts` — Assembled router implementing the contract
- `handler.ts` — RPCHandler instance with error logging
- `client.ts` — Client-side oRPC client (RPCLink)
- `client.server.ts` — Server-side oRPC client (zero HTTP overhead for SSR)
- `tanstack.ts``orpc` utils for TanStack Query (`orpc.*.queryOptions()`)
- `procedures/` — Procedure implementations by domain (titles, episodes, seasons, people, dashboard, explore, search, discover, stats, status, integrations, admin, account, watchlist)
- `app/rpc/[[...rest]]/route.ts` — Next.js catch-all route handler for oRPC
- `app/api/` — Non-RPC routes (auth, images, avatars, backups, webhooks, lists, health)
- `app/(pages)/` — Authenticated pages (dashboard, explore, titles/[id], people/[id], settings)
- `app/(auth)/` — Auth pages (login, register, setup)
- `components/` — App components + `components/ui/` for shadcn primitives - `components/` — App components + `components/ui/` for shadcn primitives
- `components/query-provider.tsx``QueryClientProvider` wrapper used in root layout - `lib/orpc/client.ts`oRPC client (always same-origin via Vite proxy or Hono static serving)
- `lib/orpc/tanstack.ts``orpc` utils for TanStack Query
- `lib/auth/client.ts` — Better Auth client hooks
- `lib/theme.ts` — Color theme CSS properties from title palettes
- `styles/globals.css` — Tailwind + font imports
**Shared packages**:
- `packages/api/src/contract.ts` — Full oRPC API contract definition (~30 procedures)
- `packages/api/src/schemas.ts` — Shared Zod schemas and inferred TypeScript types
- `packages/db/src/schema.ts` — All Drizzle table definitions
- `packages/db/drizzle/` — Migration files
- `packages/core/src/` — All service files (metadata, tracking, discovery, availability, credits, person, webhooks, backup, settings, colors, update-check, system-health, lists, image-cache)
- `packages/core/test/` — Service tests with in-memory SQLite
### Auth pattern ### Auth pattern
Server components and layouts use the cached session helper to avoid redundant lookups: **Web app** — Route `beforeLoad` guards check session via Better Auth client SDK:
```typescript ```typescript
import { getSession } from "@/lib/auth/session"; // In route file (e.g. _app.tsx)
const session = await getSession(); beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) throw redirect({ to: "/login" });
return { session };
},
``` ```
oRPC procedures use auth middleware that calls Better Auth: Session is available to child routes via `Route.useRouteContext()`.
**API server** — oRPC procedures use auth middleware that calls Better Auth:
```typescript ```typescript
// lib/orpc/middleware.ts — applied to procedures // apps/server/src/orpc/middleware.ts
export const authed = base.middleware(async ({ context, next }) => { export const authed = base.middleware(async ({ context, next }) => {
const session = await auth.api.getSession({ headers: context.headers }); const session = await auth.api.getSession({ headers: context.headers });
if (!session) throw new ORPCError("UNAUTHORIZED"); if (!session) throw new ORPCError("UNAUTHORIZED");
@@ -109,12 +160,18 @@ export const authed = base.middleware(async ({ context, next }) => {
### oRPC API layer ### oRPC API layer
All API procedures use oRPC with a contract-first approach. The contract defines the API shape in `lib/orpc/contract.ts`, procedures implement it in `lib/orpc/procedures/`, and the client consumes it with full type safety. All API procedures use oRPC with a contract-first approach. The contract is defined in `packages/api/src/contract.ts`, procedures implement it in `apps/server/src/orpc/procedures/`, and the client consumes it with full type safety.
**Pattern — importing types from the shared package:**
```typescript
import type { ResolvedTitle, Season } from "@sofa/api/schemas";
import { contract } from "@sofa/api/contract";
```
**Pattern — query in component:** **Pattern — query in component:**
```typescript ```typescript
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/tanstack"; import { orpc } from "@/lib/orpc/client";
const { data } = useQuery(orpc.dashboard.stats.queryOptions()); const { data } = useQuery(orpc.dashboard.stats.queryOptions());
``` ```
@@ -126,32 +183,36 @@ import { client } from "@/lib/orpc/client";
await client.titles.updateStatus({ id: titleId, status: "in_progress" }); await client.titles.updateStatus({ id: titleId, status: "in_progress" });
``` ```
**Pattern — procedure definition:** **Pattern — route loader with TanStack Query prefetch:**
```typescript ```typescript
// lib/orpc/procedures/dashboard.ts export const Route = createFileRoute("/_app/titles/$id")({
export const stats = os.dashboard.stats.func(async (input, context, meta) => { loader: async ({ params, context }) => {
const { user } = context; await context.queryClient.ensureQueryData(
return getUserStats(user.id); orpc.titles.detail.queryOptions({ input: { id: params.id } }),
}, authed); );
},
component: TitlePage,
});
``` ```
**Page patterns:** **Page patterns:**
- **Thin server shell** (titles, people): Server component fetches initial data via service layer, passes as `initialData` prop to client component that uses TanStack Query via `orpc` for refetching/mutations. - **Route loaders** (titles, people): `beforeLoad`/`loader` prefetch data into TanStack Query cache via `queryClient.ensureQueryData()`. Components read via `useQuery()`/`useSuspenseQuery()`.
- **Full client-side** (dashboard, explore, settings): Server component handles auth only; client components fetch all data via `orpc.*.queryOptions()` with skeleton loading states. - **Full client-side** (dashboard, explore, settings): Components fetch all data via `orpc.*.queryOptions()` with skeleton loading states.
- **Error/loading states**: Routes use `pendingComponent`, `errorComponent`, `notFoundComponent`.
### Non-RPC routes ### Non-RPC routes (API server)
Only file-serving, auth, and webhook routes remain as traditional Next.js API routes: Hono route handlers in `apps/server/src/routes/`:
- `/api/auth/[...all]` — Better Auth catch-all - `/images/:category/:filename` — Image proxy/cache serving
- `/api/images/[...path]` — Image proxy/cache serving - `/api/auth/*` — Better Auth catch-all
- `/api/avatars/[userId]` — Avatar file serving - `/api/avatars/:userId` — Avatar file serving
- `/api/account/avatar` — Avatar upload (FormData) - `/api/backup/:filename` — Backup file download
- `/api/admin/backups/restore` — Backup restore (FormData upload) - `/api/webhooks/:token` — Plex/Jellyfin/Emby webhooks
- `/api/backup/[filename]` — Backup file download - `/api/lists/:token` — External list feeds (Sonarr/Radarr)
- `/api/webhooks/[token]` — Plex/Jellyfin/Emby webhooks
- `/api/lists/[token]` — External list feeds
- `/api/health` — Simple health check (no auth) - `/api/health` — Simple health check (no auth)
In dev, Vite proxies these to the API server. In production, Hono serves both API and SPA — the browser only sees port 3000.
### Database schema ### Database schema
All app tables use UUIDv7 text primary keys generated via `Bun.randomUUIDv7()`. Better Auth tables use their own ID format. Key relationships: All app tables use UUIDv7 text primary keys generated via `Bun.randomUUIDv7()`. Better Auth tables use their own ID format. Key relationships:
@@ -167,35 +228,31 @@ All app tables use UUIDv7 text primary keys generated via `Bun.randomUUIDv7()`.
- `cronRuns` — Background job execution history - `cronRuns` — Background job execution history
- `appSettings` — Key-value store for runtime app configuration - `appSettings` — Key-value store for runtime app configuration
### Service layer ### Service layer (`packages/core/src/`)
- **metadata.ts**: `importTitle()` fetches from TMDB, inserts into DB, fire-and-forgets availability + recommendations + credits + image caching. `refreshTvChildren()` fetches all seasons/episodes with 250ms rate limiting. - **metadata.ts**: `importTitle()` fetches from TMDB, inserts into DB, fire-and-forgets availability + recommendations + credits + image caching. `refreshTvChildren()` fetches all seasons/episodes with 250ms rate limiting.
- **image-cache.ts**: Downloads and caches TMDB images to local disk. `cacheImagesForTitle()` caches poster + backdrop, `cacheEpisodeStills()` caches episode stills, `cacheProviderLogos()` caches streaming provider logos, `cacheProfilePhotos()` caches person profile images. - **image-cache.ts**: Downloads and caches TMDB images to local disk.
- **tracking.ts**: Auto-transitions — logging a movie watch sets status to `completed`; logging an episode watch sets `in_progress`; all episodes watched auto-completes the series. - **tracking.ts**: Auto-transitions — logging a movie watch sets status to `completed`; logging an episode watch sets `in_progress`; all episodes watched auto-completes the series.
- **discovery.ts**: Feed generators — continue watching (next unwatched episode per in-progress show), library titles with availability, personalized recommendations from completed/highly-rated titles. - **discovery.ts**: Feed generators — continue watching, library with availability, personalized recommendations.
- **availability.ts**: Caches US streaming providers from TMDB watch/providers endpoint. - **availability.ts**: Caches US streaming providers from TMDB watch/providers endpoint.
- **credits.ts**: Fetches and caches cast/crew data from TMDB, links persons to titles via `titleCast`. - **credits.ts**: Fetches and caches cast/crew data from TMDB.
- **person.ts**: Person detail and filmography lookups. - **person.ts**: Person detail and filmography lookups.
- **webhooks.ts**: Processes incoming Plex/Jellyfin/Emby webhook events to auto-log watches. - **webhooks.ts**: Processes incoming Plex/Jellyfin/Emby webhook events.
- **backup.ts**: Database backup/restore with configurable scheduled backups and retention pruning. - **backup.ts**: Database backup/restore with configurable scheduled backups.
- **settings.ts**: Runtime app settings (registration open/closed, backup config, etc.) via `appSettings` table. - **settings.ts**: Runtime app settings via `appSettings` table.
- **colors.ts**: Extracts dominant color palettes from title posters via node-vibrant. - **colors.ts**: Extracts dominant color palettes from title posters via node-vibrant.
- **update-check.ts**: Checks for new Sofa releases. - **update-check.ts**: Checks for new Sofa releases.
- **system-health.ts**: System health diagnostics. - **system-health.ts**: System health diagnostics.
### TMDB images ### TMDB images
Only paths are stored in DB. Image URLs are resolved **server-side only** — API routes and server components call `tmdbImageUrl()` from `lib/tmdb/image.ts` before sending data to clients. Client components never import `tmdbImageUrl`; they receive ready-to-use URLs. Only paths are stored in DB. Image URLs are resolved by the API server — `tmdbImageUrl()` from `@sofa/tmdb/image` is called before sending data to clients. Client components receive ready-to-use URLs.
When `IMAGE_CACHE_ENABLED` is set (default), images are downloaded to local disk (`CACHE_DIR`, derived from `DATA_DIR/images`) and served via `app/api/images/[...path]/route.ts`. Categories: `posters` (w500), `backdrops` (w1280), `stills` (w1280), `logos` (w92), `profiles` (w185). When disabled, `tmdbImageUrl()` returns direct TMDB CDN URLs using `TMDB_IMAGE_BASE_URL` (defaults to `https://image.tmdb.org/t/p`). When `IMAGE_CACHE_ENABLED` is set (default), images are downloaded to local disk and served via `/images/:category/:filename`. When disabled, `tmdbImageUrl()` returns direct TMDB CDN URLs.
Core files: `lib/services/image-cache.ts` (caching logic), `lib/tmdb/image.ts` (URL construction), `app/api/images/[...path]/route.ts` (proxy route).
### Background jobs ### Background jobs
Defined in `lib/cron.ts` using croner cron expressions, started via Next.js instrumentation hook (`instrumentation.ts`) when `NEXT_RUNTIME === "nodejs"`. The instrumentation hook also runs DB migrations on startup and registers graceful shutdown handlers. Defined in `apps/server/src/cron.ts` using croner, started when the API server boots. Jobs (all use `protect: true` to prevent overlapping runs, 300ms delay between TMDB calls):
Jobs (all use `protect: true` to prevent overlapping runs, 300ms delay between TMDB calls):
- `nightlyRefreshLibrary` (`0 3 * * *`) — refreshes stale library titles (7d) and non-library titles (30d) - `nightlyRefreshLibrary` (`0 3 * * *`) — refreshes stale library titles (7d) and non-library titles (30d)
- `refreshAvailability` (`0 */6 * * *`) — streaming provider data - `refreshAvailability` (`0 */6 * * *`) — streaming provider data
- `refreshRecommendations` (`0 */12 * * *`) - `refreshRecommendations` (`0 */12 * * *`)
@@ -207,10 +264,14 @@ Jobs (all use `protect: true` to prevent overlapping runs, 300ms delay between T
### Environment variables ### Environment variables
See `.env.example`: `DATA_DIR` (root for DB + cache, default `./data`), `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. `DATABASE_URL` and `CACHE_DIR` are derived from `DATA_DIR` but can be overridden individually. `DATA_DIR` (root for DB + cache, default `./data`), `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. `DATABASE_URL` and `CACHE_DIR` are derived from `DATA_DIR` but can be overridden individually.
Optional: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER_URL`, `OIDC_PROVIDER_NAME`, `OIDC_AUTO_REGISTER`, `DISABLE_PASSWORD_LOGIN` (OIDC/SSO support). `LOG_LEVEL` (error/warn/info/debug, default: info). `IMAGE_CACHE_ENABLED` (default: true). Optional: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER_URL`, `OIDC_PROVIDER_NAME`, `OIDC_AUTO_REGISTER`, `DISABLE_PASSWORD_LOGIN` (OIDC/SSO support). `LOG_LEVEL` (error/warn/info/debug, default: info). `IMAGE_CACHE_ENABLED` (default: true).
### Docker deployment
Single container, single process. Hono serves both the API and the Vite-built SPA static files on port 3000. API routes (`/rpc/*`, `/api/*`) are mounted first; unmatched routes fall back to `index.html` for SPA routing.
## Browser Automation ## Browser Automation
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands. Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
+25 -28
View File
@@ -1,62 +1,59 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM oven/bun:1-alpine AS base FROM oven/bun:1-alpine AS base
# --- Dependencies --- # --- Prune workspace for Docker layer caching ---
FROM base AS prepare
WORKDIR /app
RUN bun add -g turbo@^2
COPY . .
RUN turbo prune @sofa/web @sofa/server --docker
# --- Install dependencies (cached by package.json + lockfile only) ---
FROM base AS deps FROM base AS deps
WORKDIR /app WORKDIR /app
COPY --from=prepare /app/out/json/ .
COPY package.json bun.lock ./
RUN --mount=type=cache,target=/root/.bun/install/cache \ RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --no-save --frozen-lockfile bun install --no-save --frozen-lockfile
# --- Builder --- # --- Build ---
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/ .
COPY --from=deps /app/node_modules ./node_modules COPY --from=prepare /app/out/full/ .
COPY . .
ARG APP_VERSION ARG APP_VERSION
ARG GIT_COMMIT_SHA ARG GIT_COMMIT_SHA
ENV NODE_ENV=production ENV NODE_ENV=production
ENV APP_VERSION=${APP_VERSION} ENV APP_VERSION=${APP_VERSION}
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
ENV NEXT_TELEMETRY_DISABLED=1
RUN bun run build RUN bunx turbo run build --filter=@sofa/web --filter=@sofa/server
# --- Runner --- # --- Production runner ---
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ARG APP_VERSION
ARG GIT_COMMIT_SHA
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
ENV DATA_DIR=/data ENV DATA_DIR=/data
ENV APP_VERSION=${APP_VERSION}
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder --chown=bun:bun /app/public ./public # API server + packages
COPY --from=builder --chown=bun:bun /app/apps/server/ ./apps/server/
COPY --from=builder --chown=bun:bun /app/packages/ ./packages/
COPY --from=builder --chown=bun:bun /app/node_modules/ ./node_modules/
RUN mkdir .next \ # Built SPA
&& chown bun:bun .next COPY --from=builder --chown=bun:bun /app/apps/web/dist/ ./apps/web/dist/
COPY --from=builder --chown=bun:bun /app/.next/standalone ./ # DB migrations
COPY --from=builder --chown=bun:bun /app/.next/static ./.next/static COPY --from=builder --chown=bun:bun /app/packages/db/drizzle/ ./packages/db/drizzle/
COPY --from=builder --chown=bun:bun /app/.next/cache ./.next/cache
COPY --from=builder --chown=bun:bun /app/drizzle ./drizzle
RUN mkdir -p /data \
&& chown bun:bun /data
RUN mkdir -p /data && chown bun:bun /data
USER bun USER bun
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD ["bun", "-e", "fetch('http://localhost:3000/api/health').then(r => process.exit(+!r.ok)).catch(() => process.exit(1))"] CMD ["bun", "-e", "fetch('http://localhost:3000/api/health').then(r=>process.exit(+!r.ok)).catch(()=>process.exit(1))"]
CMD ["bun", "server.js"] CMD ["bun", "apps/server/src/index.ts"]
-15
View File
@@ -1,15 +0,0 @@
import { Suspense } from "react";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main className="min-h-screen">
<div className="flex min-h-[80vh] items-center justify-center px-4">
<Suspense>{children}</Suspense>
</div>
</main>
);
}
-32
View File
@@ -1,32 +0,0 @@
import { redirect } from "next/navigation";
import { AuthForm } from "@/components/auth-form";
import { getSession } from "@/lib/auth/session";
import {
getOidcProviderName,
isOidcConfigured,
isPasswordLoginDisabled,
} from "@/lib/config";
import { getUserCount, isRegistrationOpen } from "@/lib/services/settings";
export default async function LoginPage() {
const session = await getSession();
if (session) redirect("/dashboard");
if (getUserCount() === 0) {
redirect("/register");
}
const oidcEnabled = isOidcConfigured();
return (
<AuthForm
mode="login"
authConfig={{
oidcEnabled,
oidcProviderName: oidcEnabled ? getOidcProviderName() : null,
passwordLoginDisabled: isPasswordLoginDisabled(),
registrationOpen: isRegistrationOpen(),
}}
/>
);
}
-21
View File
@@ -1,21 +0,0 @@
import { getSession } from "@/lib/auth/session";
import { ContinueWatchingSection } from "./_components/continue-watching-section";
import { LibrarySection } from "./_components/library-section";
import { RecommendationsSection } from "./_components/recommendations-section";
import { StatsSection } from "./_components/stats-section";
import { WelcomeHeader } from "./_components/welcome-header";
export default async function DashboardPage() {
const session = await getSession();
if (!session) return null;
return (
<div className="space-y-10">
<WelcomeHeader name={session.user.name} />
<StatsSection />
<ContinueWatchingSection />
<LibrarySection />
<RecommendationsSection />
</div>
);
}
-47
View File
@@ -1,47 +0,0 @@
import { TitleCardSkeleton } from "@/components/title-card";
import { Skeleton } from "@/components/ui/skeleton";
function TitleRowSkeleton({ withGenreChips }: { withGenreChips?: boolean }) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-6 w-40" />
</div>
{withGenreChips && (
<div className="-mx-4 flex gap-2 overflow-x-hidden px-4 sm:-mx-0 sm:flex-wrap sm:px-0">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
key={`chip-${i}`}
className="h-7 w-20 shrink-0 rounded-full"
/>
))}
</div>
)}
<div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0">
{Array.from({ length: 6 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
<div key={`card-${i}`} className="w-[140px] shrink-0 sm:w-[160px]">
<TitleCardSkeleton />
</div>
))}
</div>
</div>
);
}
export default function ExploreLoading() {
return (
<div className="space-y-10">
{/* Hero skeleton — full viewport width like the actual HeroBanner */}
<div className="-mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)]">
<Skeleton className="aspect-[21/9] max-h-[420px] min-h-[280px] w-full rounded-none" />
</div>
<TitleRowSkeleton />
<TitleRowSkeleton withGenreChips />
<TitleRowSkeleton withGenreChips />
</div>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { ExploreClient } from "./_components/explore-client";
export default function ExplorePage() {
return <ExploreClient />;
}
-56
View File
@@ -1,56 +0,0 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { CommandPalette } from "@/components/command-palette";
import { MobileTabBar, NavBar } from "@/components/nav-bar";
import { ProgressProvider } from "@/components/navigation-progress";
import { QueryProvider } from "@/components/query-provider";
import { UpdateToast } from "@/components/update-toast";
import { getSession } from "@/lib/auth/session";
import { getCachedUpdateCheck } from "@/lib/services/update-check";
export default function PagesLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Suspense>
<QueryProvider>
<ProgressProvider>
<AuthenticatedShell>{children}</AuthenticatedShell>
</ProgressProvider>
</QueryProvider>
</Suspense>
);
}
async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
const session = await getSession();
if (!session) redirect("/login");
return (
<>
<div className="relative z-0 min-h-screen pb-[calc(3.5rem+env(safe-area-inset-bottom))] sm:pb-0">
<NavBar
userName={session.user.name}
userEmail={session.user.email}
userImage={session.user.image || undefined}
userRole={session.user.role || undefined}
/>
{/* Ambient glow — smaller on mobile to add warmth without overwhelming */}
<div className="pointer-events-none fixed top-1/4 left-1/2 h-[600px] w-[800px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/3 blur-[200px]" />
<main
id="main-content"
className="relative mx-auto max-w-6xl py-6 pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] sm:pr-[max(1.5rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))]"
>
{children}
</main>
</div>
<MobileTabBar />
<CommandPalette />
{session.user.role === "admin" && (
<UpdateToast data={getCachedUpdateCheck()} />
)}
</>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { PersonDetailSkeleton } from "./_components/person-detail-client";
export default function PersonLoading() {
return <PersonDetailSkeleton />;
}
-51
View File
@@ -1,51 +0,0 @@
import { eq } from "drizzle-orm";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { persons } from "@/lib/db/schema";
import { getLocalFilmography, getOrFetchPerson } from "@/lib/services/person";
import { getUserStatusesByTitleIds } from "@/lib/services/tracking";
import { PersonDetailClient } from "./_components/person-detail-client";
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const person = db.select().from(persons).where(eq(persons.id, id)).get();
if (!person) return { title: "Not Found — Sofa" };
return {
title: `${person.name} — Sofa`,
description: person.biography?.slice(0, 160),
};
}
export default async function PersonDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const person = await getOrFetchPerson(id);
if (!person) notFound();
const filmography = getLocalFilmography(person.id);
const session = await getSession();
const userStatuses = session
? getUserStatusesByTitleIds(
session.user.id,
filmography.map((c) => c.titleId),
)
: {};
return (
<PersonDetailClient
id={id}
initialData={{ person, filmography, userStatuses }}
/>
);
}
-48
View File
@@ -1,48 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
function SectionSkeleton({
headingWidth,
children,
}: {
headingWidth: string;
children: React.ReactNode;
}) {
return (
<div>
<div className="mb-3 flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<Skeleton className={`h-3.5 ${headingWidth}`} />
</div>
{children}
</div>
);
}
export default function SettingsLoading() {
return (
<div className="mx-auto max-w-2xl space-y-8">
{/* Header */}
<div>
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-9 w-40" />
</div>
<Skeleton className="mt-1 h-4 w-64" />
</div>
{/* Account card */}
<SectionSkeleton headingWidth="w-16">
<Skeleton className="h-[76px] w-full rounded-xl" />
</SectionSkeleton>
{/* Integrations cards */}
<SectionSkeleton headingWidth="w-24">
<div className="space-y-3">
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
</div>
</SectionSkeleton>
</div>
);
}
@@ -1,14 +0,0 @@
import { ensureTvHydrated } from "@/lib/services/metadata";
import { TitleSeasons } from "./title-seasons";
export async function AsyncTitleSeasons({
titleId,
tmdbId,
}: {
titleId: string;
tmdbId: number;
}) {
const seasons = await ensureTvHydrated(titleId, tmdbId);
if (seasons.length === 0) return null;
return <TitleSeasons seasons={seasons} />;
}
@@ -1,74 +0,0 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import type { Season } from "@/lib/orpc/schemas";
import { orpc } from "@/lib/orpc/tanstack";
import { TitleContext } from "./title-context";
export function TitleProvider({
titleId,
titleType,
titleName,
initialStatus,
initialRating,
initialEpisodeWatches,
seasons: initialSeasons,
children,
}: {
titleId: string;
titleType: "movie" | "tv";
titleName: string;
initialStatus: string | null;
initialRating: number;
initialEpisodeWatches: string[];
seasons: Season[];
children: React.ReactNode;
}) {
const queryClient = useQueryClient();
const [seasons, setSeasons] = useState(initialSeasons);
const [watchingEp, setWatchingEp] = useState<string | null>(null);
// Seed the query cache with server-fetched data on mount.
// Unlike initialData (which is a no-op when the cache already has an entry),
// this ensures revisiting a title or signing into a different account never
// renders stale or another user's data.
// The parent passes key={title.id}, so React remounts on navigation and the
// cache for the new title ID starts empty.
useEffect(() => {
queryClient.setQueryData(
orpc.titles.userInfo.queryKey({ input: { id: titleId } }),
{
status: initialStatus as
| "watchlist"
| "in_progress"
| "completed"
| null,
rating: initialRating,
episodeWatches: initialEpisodeWatches,
},
);
}, [
queryClient,
titleId,
initialStatus,
initialRating,
initialEpisodeWatches,
]);
return (
<TitleContext
value={{
titleId,
titleType,
titleName,
seasons,
setSeasons,
watchingEp,
setWatchingEp,
}}
>
{children}
</TitleContext>
);
}
-33
View File
@@ -1,33 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function TitleDetailLoading() {
return (
<div className="space-y-10">
<Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 rounded-none md:h-[28rem]" />
<div className="flex flex-col gap-4 md:flex-row md:gap-8">
<Skeleton className="aspect-[2/3] w-[140px] shrink-0 self-center rounded-xl md:w-[220px] md:self-start" />
<div className="flex-1 space-y-5">
<div>
<Skeleton className="h-7 w-2/3 md:h-12" />
<div className="mt-2 flex items-center gap-2">
<Skeleton className="h-5 w-6 rounded" />
<Skeleton className="h-4 w-10" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-16" />
</div>
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
<div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-9 w-28 rounded-lg" />
<Skeleton className="h-4 w-px" />
<Skeleton className="h-5 w-24 rounded" />
</div>
</div>
</div>
</div>
);
}
-46
View File
@@ -1,46 +0,0 @@
import Link from "next/link";
export default function TitleNotFound() {
return (
<div className="flex flex-col items-center gap-6 py-24 text-center">
<h1
className="animate-stagger-item font-display text-[6rem] text-foreground/[0.06] leading-[0.85] tracking-tight sm:text-[8rem]"
style={{ "--stagger-index": 0 } as React.CSSProperties}
>
404
</h1>
<div
className="-mt-4 animate-stagger-item space-y-2"
style={{ "--stagger-index": 1 } as React.CSSProperties}
>
<h2 className="font-display text-2xl tracking-tight sm:text-3xl">
Title not found
</h2>
<p className="mx-auto max-w-sm text-muted-foreground text-sm leading-relaxed">
The title you&apos;re looking for doesn&apos;t exist or may have been
removed from the database.
</p>
</div>
<div
className="flex animate-stagger-item items-center gap-3"
style={{ "--stagger-index": 2 } as React.CSSProperties}
>
<Link
href="/explore"
className="group relative inline-flex h-10 items-center justify-center overflow-hidden rounded-lg bg-primary px-5 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-lg hover:shadow-primary/20"
>
<span className="relative z-10">Explore titles</span>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
<Link
href="/dashboard"
className="inline-flex h-10 items-center justify-center rounded-lg border border-border px-5 font-medium text-sm transition-colors hover:border-primary/40 hover:bg-primary/5"
>
Dashboard
</Link>
</div>
</div>
);
}
-104
View File
@@ -1,104 +0,0 @@
import { eq } from "drizzle-orm";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { cache, Suspense } from "react";
import { getSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { titles } from "@/lib/db/schema";
import { getOrFetchTitle } from "@/lib/services/metadata";
import { getUserTitleInfo } from "@/lib/services/tracking";
import { getThemeCssProperties } from "@/lib/theme";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import { AsyncTitleSeasons } from "./_components/async-title-seasons";
import { TitleActions } from "./_components/title-actions";
import { TitleAvailability } from "./_components/title-availability";
import { TitleCast } from "./_components/title-cast";
import { TitleHero } from "./_components/title-hero";
import { TitleKeyboardShortcuts } from "./_components/title-keyboard-shortcuts";
import { TitleProvider } from "./_components/title-provider";
import { TitleRecommendations } from "./_components/title-recommendations";
import { SeasonsSkeleton, TitleSeasons } from "./_components/title-seasons";
import { TitleTheme } from "./_components/title-theme";
const getCachedOrFetchTitle = cache((id: string) => getOrFetchTitle(id));
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const title = db.select().from(titles).where(eq(titles.id, id)).get();
if (!title) return { title: "Not Found — Sofa" };
const year = (title.releaseDate ?? title.firstAirDate)?.slice(0, 4);
return {
title: `${title.title}${year ? ` (${year})` : ""} — Sofa`,
description: title.overview?.slice(0, 160),
openGraph: {
title: title.title,
images: title.posterPath
? [{ url: tmdbImageUrl(title.posterPath, "posters") ?? "" }]
: [],
},
};
}
export default async function TitleDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Fetch title + user info in parallel
const session = await getSession();
const [result, userInfo] = await Promise.all([
getCachedOrFetchTitle(id),
session ? getUserTitleInfo(session.user.id, id) : null,
]);
if (!result) notFound();
const { title, seasons, needsHydration, availability, cast } = result;
const themeStyle = getThemeCssProperties(title.colorPalette);
return (
<div className="relative space-y-10" style={themeStyle}>
<TitleTheme style={themeStyle as Record<string, string>} />
<TitleProvider
key={title.id}
titleId={title.id}
titleType={title.type}
titleName={title.title}
initialStatus={userInfo?.status ?? null}
initialRating={userInfo?.rating ?? 0}
initialEpisodeWatches={userInfo?.episodeWatches ?? []}
seasons={seasons}
>
<TitleHero
title={title}
trailerVideoKey={title.trailerVideoKey}
actions={<TitleActions />}
>
<TitleAvailability availability={availability} />
</TitleHero>
{title.type === "tv" && needsHydration && (
<Suspense fallback={<SeasonsSkeleton />}>
<AsyncTitleSeasons titleId={title.id} tmdbId={title.tmdbId} />
</Suspense>
)}
{title.type === "tv" && !needsHydration && seasons.length > 0 && (
<TitleSeasons />
)}
<TitleCast cast={cast} titleType={title.type} />
<TitleKeyboardShortcuts />
</TitleProvider>
<TitleRecommendations titleId={title.id} />
</div>
);
}
-4
View File
@@ -1,4 +0,0 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth/server";
export const { GET, POST } = toNextJsHandler(auth);
-42
View File
@@ -1,42 +0,0 @@
import path from "node:path";
import { type NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth/session";
import { AVATAR_DIR } from "@/lib/constants";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ userId: string }> },
) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId } = await params;
// Sanitize userId to prevent path traversal
const safeUserId = path.basename(userId);
if (!safeUserId || safeUserId !== userId || safeUserId.includes("..")) {
return NextResponse.json({ error: "Invalid user ID" }, { status: 400 });
}
// Find the avatar file (could be .jpg, .png, .webp, .gif)
const glob = new Bun.Glob(`${safeUserId}.*`);
const matches = await Array.fromAsync(glob.scan(AVATAR_DIR));
if (matches.length === 0) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const file = Bun.file(path.join(AVATAR_DIR, matches[0]));
if (!(await file.exists())) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Cache for 1 year (cache-busted via ?v= query param)
return new NextResponse(await file.arrayBuffer(), {
status: 200,
headers: {
"Content-Type": file.type,
},
});
}
-42
View File
@@ -1,42 +0,0 @@
import path from "node:path";
import { type NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth/session";
import { getBackupPath } from "@/lib/services/backup";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ filename: string }> },
) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (session.user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { filename } = await params;
// Sanitize to prevent path traversal
const safe = path.basename(filename);
if (!safe || safe !== filename || safe.includes("..")) {
return NextResponse.json({ error: "Invalid filename" }, { status: 400 });
}
const backupPath = await getBackupPath(safe);
if (!backupPath) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const file = Bun.file(backupPath);
const buffer = await file.arrayBuffer();
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
"Content-Type": "application/x-sqlite3",
"Content-Disposition": `attachment; filename="${safe}"`,
"Content-Length": String(file.size),
},
});
}
-19
View File
@@ -1,19 +0,0 @@
import { sql } from "drizzle-orm";
import { connection, NextResponse } from "next/server";
import { db } from "@/lib/db/client";
import { createLogger } from "@/lib/logger";
const log = createLogger("health");
export async function GET() {
await connection();
try {
db.run(sql`SELECT 1`);
return NextResponse.json({ status: "healthy" }, { status: 200 });
} catch (err) {
log.error("Health check failed:", err);
return NextResponse.json({ status: "unhealthy" }, { status: 503 });
}
}
-64
View File
@@ -1,64 +0,0 @@
import path from "node:path";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import {
fetchAndMaybeCache,
imageCacheEnabled,
} from "@/lib/services/image-cache";
const categorySchema = z.enum([
"posters",
"backdrops",
"stills",
"logos",
"profiles",
]);
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const segments = await params;
if (!imageCacheEnabled()) {
return NextResponse.json(
{ error: "Image cache disabled" },
{ status: 404 },
);
}
if (segments.path.length !== 2) {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
const [rawCategory, rawFilename] = segments.path;
const catResult = categorySchema.safeParse(rawCategory);
if (!catResult.success) {
return NextResponse.json({ error: "Invalid category" }, { status: 400 });
}
const category = catResult.data;
// Sanitize filename — only allow basename to prevent path traversal
const filename = path.basename(rawFilename);
if (!filename || filename !== rawFilename || filename.includes("..")) {
return NextResponse.json({ error: "Invalid filename" }, { status: 400 });
}
const tmdbPath = `/${filename}`;
const result = await fetchAndMaybeCache(tmdbPath, category);
if (!result) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return new NextResponse(new Uint8Array(result.buffer), {
status: 200,
headers: {
"Content-Type": result.contentType,
"Cache-Control": IMMUTABLE_CACHE,
},
});
}
-26
View File
@@ -1,26 +0,0 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
getRadarrList,
getSonarrList,
parseStatusParam,
resolveListToken,
} from "@/lib/services/lists";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> },
) {
const { token } = await params;
const result = resolveListToken(token);
if (!result) {
return NextResponse.json([]);
}
const statuses = parseStatusParam(req.nextUrl.searchParams.get("status"));
if (result.provider === "sonarr") {
return NextResponse.json(await getSonarrList(result.userId, statuses));
}
return NextResponse.json(getRadarrList(result.userId, statuses));
}
-14
View File
@@ -1,14 +0,0 @@
import { openApiHandler } from "@/lib/orpc/openapi-handler";
async function handleRequest(request: Request) {
const { response } = await openApiHandler.handle(request, {
prefix: "/api/v1",
context: { headers: request.headers },
});
return response ?? new Response("Not found", { status: 404 });
}
export const GET = handleRequest;
export const POST = handleRequest;
export const PUT = handleRequest;
export const DELETE = handleRequest;
-60
View File
@@ -1,60 +0,0 @@
"use client";
import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react";
import Link from "next/link";
import { useEffect } from "react";
export default function PagesError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[pages error boundary]", error);
}, [error]);
return (
<div className="flex flex-col items-center gap-6 py-24 text-center">
{/* Icon ring */}
<div className="flex size-14 items-center justify-center rounded-full border border-destructive/20 bg-destructive/5 text-destructive">
<IconAlertTriangle className="size-6" strokeWidth={1.5} />
</div>
<div className="space-y-2">
<h1 className="font-display text-2xl tracking-tight sm:text-3xl">
Something went wrong
</h1>
<p className="mx-auto max-w-sm text-muted-foreground text-sm leading-relaxed">
An unexpected error occurred while loading this page. You can try
again or head back to the dashboard.
</p>
</div>
{error.digest && (
<p className="font-mono text-[11px] text-muted-foreground/40">
Error ID: {error.digest}
</p>
)}
<div className="flex items-center gap-3">
<button
type="button"
onClick={reset}
className="group relative inline-flex h-10 items-center justify-center gap-2 overflow-hidden rounded-lg bg-primary px-5 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-lg hover:shadow-primary/20"
>
<IconRefresh className="size-4" />
<span className="relative z-10">Try again</span>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
</button>
<Link
href="/dashboard"
className="inline-flex h-10 items-center justify-center rounded-lg border border-border px-5 font-medium text-sm transition-colors hover:border-primary/40 hover:bg-primary/5"
>
Dashboard
</Link>
</div>
</div>
);
}
-161
View File
@@ -1,161 +0,0 @@
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html lang="en" style={{ colorScheme: "dark" }}>
<head>
<style>{`
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: 'DM Sans', ui-sans-serif, system-ui, -apple-system, sans-serif;
background-color: oklch(0.13 0.006 55);
color: oklch(0.93 0.015 80);
padding: 1.5rem;
text-align: center;
overflow: hidden;
}
.ge-glow {
position: fixed;
left: 50%;
top: 40%;
width: 400px;
height: 400px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: oklch(0.8 0.14 65 / 0.04);
filter: blur(120px);
pointer-events: none;
}
.ge-icon-ring {
width: 48px;
height: 48px;
margin: 0 auto 1.5rem;
border-radius: 50%;
border: 1px solid oklch(0.8 0.14 65 / 0.2);
display: flex;
align-items: center;
justify-content: center;
color: oklch(0.8 0.14 65);
}
.ge-title {
font-family: 'DM Serif Display', 'Georgia', ui-serif, serif;
font-size: 1.75rem;
font-weight: 400;
letter-spacing: -0.01em;
margin: 0 0 0.75rem;
}
.ge-desc {
font-size: 0.875rem;
line-height: 1.6;
color: oklch(0.63 0.02 80);
max-width: 22rem;
margin: 0 auto 2rem;
}
.ge-digest {
font-size: 0.7rem;
font-family: monospace;
color: oklch(0.63 0.02 80 / 0.5);
margin-bottom: 1.5rem;
}
.ge-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
}
.ge-btn {
height: 40px;
padding: 0 1.5rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: box-shadow 0.2s, border-color 0.2s, background 0.2s;
}
.ge-btn-primary {
border: none;
background: oklch(0.8 0.14 65);
color: oklch(0.13 0.006 55);
}
.ge-btn-primary:hover, .ge-btn-primary:focus-visible {
box-shadow: 0 10px 25px -5px oklch(0.8 0.14 65 / 0.2);
}
.ge-btn-secondary {
border: 1px solid oklch(1 0.015 65 / 0.1);
background: transparent;
color: oklch(0.93 0.015 80);
}
.ge-btn-secondary:hover, .ge-btn-secondary:focus-visible {
border-color: oklch(0.8 0.14 65 / 0.4);
background: oklch(0.8 0.14 65 / 0.05);
}
`}</style>
</head>
<body>
<div className="ge-glow" />
<div style={{ position: "relative", zIndex: 1 }}>
<div className="ge-icon-ring">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Error"
>
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M13.41 10.59l2.59 -2.59" />
</svg>
</div>
<h1 className="ge-title">Intermission</h1>
<p className="ge-desc">
We hit a snag loading this page. This is usually temporary &mdash;
try refreshing or come back in a moment.
</p>
{error.digest && (
<p className="ge-digest">Error ID: {error.digest}</p>
)}
<div className="ge-actions">
<button
type="button"
onClick={reset}
className="ge-btn ge-btn-primary"
>
Try again
</button>
<button
type="button"
onClick={() => {
window.location.href = "/";
}}
className="ge-btn ge-btn-secondary"
>
Return home
</button>
</div>
</div>
</body>
</html>
);
}
-66
View File
@@ -1,66 +0,0 @@
import { Provider as StoreProvider } from "jotai";
import { MotionConfig } from "motion/react";
import type { Metadata, Viewport } from "next";
import { DM_Sans, DM_Serif_Display, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css";
const dmSans = DM_Sans({
variable: "--font-dm-sans",
subsets: ["latin"],
});
const dmSerif = DM_Serif_Display({
variable: "--font-dm-serif",
weight: "400",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Sofa",
description: "Track your movies and TV shows",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: "#090706",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" style={{ colorScheme: "dark" }}>
<body
className={`${dmSans.variable} ${dmSerif.variable} ${geistMono.variable} overflow-x-hidden font-sans antialiased`}
style={{ touchAction: "manipulation" }}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground"
>
Skip to main content
</a>
<StoreProvider>
<MotionConfig reducedMotion="user">
<TooltipProvider>{children}</TooltipProvider>
<Toaster position="bottom-right" />
</MotionConfig>
</StoreProvider>
</body>
</html>
);
}
-25
View File
@@ -1,25 +0,0 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Sofa",
short_name: "Sofa",
start_url: "/",
scope: "/",
display: "standalone",
icons: [
{
src: "/web-app-manifest-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/web-app-manifest-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}
-57
View File
@@ -1,57 +0,0 @@
import Link from "next/link";
import { SofaLogo } from "@/components/sofa-logo";
export default function NotFound() {
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-6">
{/* Warm projector glow */}
<div className="pointer-events-none absolute top-[40%] left-1/2 h-[500px] w-[500px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/4 blur-[160px]" />
<div className="relative z-10 flex flex-col items-center text-center">
{/* Large ghosted 404 */}
<h1
className="animate-stagger-item font-display text-[8rem] text-foreground/[0.06] leading-[0.85] tracking-tight sm:text-[11rem] md:text-[14rem]"
style={{ "--stagger-index": 0 } as React.CSSProperties}
>
404
</h1>
{/* Message */}
<div
className="animate-stagger-item space-y-3"
style={{ "--stagger-index": 1 } as React.CSSProperties}
>
<h2 className="font-display text-2xl tracking-tight sm:text-3xl">
Scene not found
</h2>
<p className="mx-auto max-w-sm text-muted-foreground text-sm leading-relaxed">
This page was left on the cutting room floor. It may have been
moved, removed, or never made it past the screenplay.
</p>
</div>
{/* Actions */}
<div
className="mt-8 flex animate-stagger-item items-center gap-3"
style={{ "--stagger-index": 2 } as React.CSSProperties}
>
<Link
href="/"
className="group relative inline-flex h-10 items-center justify-center overflow-hidden rounded-lg bg-primary px-6 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-lg hover:shadow-primary/20"
>
<span className="relative z-10">Return home</span>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
</div>
{/* Logo watermark */}
<div
className="mt-16 animate-stagger-item text-muted-foreground/20"
style={{ "--stagger-index": 3 } as React.CSSProperties}
>
<SofaLogo className="size-8" />
</div>
</div>
</div>
);
}
-50
View File
@@ -1,50 +0,0 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { LandingPage } from "@/components/landing-page";
import { getSession } from "@/lib/auth/session";
import { isTmdbConfigured } from "@/lib/config";
import { getUserCount, isRegistrationOpen } from "@/lib/services/settings";
import { tmdbImageUrl } from "@/lib/tmdb/image";
// Well-known TMDB poster paths for the background collage
const posterPaths = [
"/qJ2tW6WMUDux911r6m7haRef0WH.jpg", // The Dark Knight
"/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg", // Interstellar
"/rCzpDGLbOoPwLjy3OAm5NUPOTrC.jpg", // The Lord of the Rings
"/pB8BM7pdSp6B6Ih7QZ4DrQ3PmJK.jpg", // Fight Club
"/d5NXSklXo0qyIYkgV94XAgMIckC.jpg", // Dune
"/ztkUQFLlC19CCMYHW9o1zWhJRNq.jpg", // Breaking Bad
"/7DJKHzAi83BmQrWLrYYOqcoKfhR.jpg", // The Office
"/u3bZgnGQ9T01sWNhyveQz0wH0Hl.jpg", // Stranger Things
"/ggFHVNu6YYI5L9pCfOacjizRGt.jpg", // Back to the Future
"/q6y0Go1tsGEsmtFryDOJo3dEmqu.jpg", // The Shawshank Redemption
"/pIkRyD18kl4FhoCNQuWxWu5cBLM.jpg", // Inception
"/saHP97rTPS5eLmrLQEcANmKrsFl.jpg", // Forrest Gump
];
const posterUrls = posterPaths
.map((p) => tmdbImageUrl(p, "posters", "w300"))
.filter(Boolean) as string[];
export default function Home() {
return (
<Suspense>
<HomeContent />
</Suspense>
);
}
async function HomeContent() {
const session = await getSession();
if (session?.user) redirect("/dashboard");
if (!isTmdbConfigured()) redirect("/setup");
const userCount = getUserCount();
return (
<LandingPage
posterUrls={posterUrls}
freshInstall={userCount === 0}
registrationOpen={isRegistrationOpen()}
/>
);
}
-12
View File
@@ -1,12 +0,0 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
disallow: ["/"],
},
],
};
}
-12
View File
@@ -1,12 +0,0 @@
import { handler } from "@/lib/orpc/handler";
async function handleRequest(request: Request) {
const { response } = await handler.handle(request, {
prefix: "/rpc",
context: { headers: request.headers },
});
return response ?? new Response("Not found", { status: 404 });
}
export const GET = handleRequest;
export const POST = handleRequest;
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@sofa/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --env-file=../../.env --watch src/index.ts",
"start": "bun --env-file=../../.env src/index.ts",
"lint": "biome check",
"format": "biome format --write",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/json-schema": "1.13.6",
"@orpc/openapi": "1.13.6",
"@orpc/server": "1.13.6",
"@orpc/zod": "1.13.6",
"@sofa/api": "workspace:*",
"@sofa/auth": "workspace:*",
"@sofa/config": "workspace:*",
"@sofa/core": "workspace:*",
"@sofa/db": "workspace:*",
"@sofa/logger": "workspace:*",
"@sofa/tmdb": "workspace:*",
"croner": "10.0.2-dev.2",
"hono": "4.12.7",
"zod": "catalog:"
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
+23 -33
View File
@@ -1,6 +1,22 @@
import { Cron } from "croner"; import { refreshAvailability } from "@sofa/core/availability";
import { and, eq, inArray, isNotNull, lt, or } from "drizzle-orm"; import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup";
import { db } from "@/lib/db/client"; import { refreshCredits } from "@sofa/core/credits";
import {
cacheEpisodeStills,
cacheImagesForTitle,
cacheProfilePhotos,
cacheProviderLogos,
imageCacheEnabled,
} from "@sofa/core/image-cache";
import {
refreshRecommendations,
refreshTitle,
refreshTvChildren,
} from "@sofa/core/metadata";
import { getSetting } from "@sofa/core/settings";
import { performUpdateCheck } from "@sofa/core/update-check";
import { db } from "@sofa/db/client";
import { and, eq, inArray, isNotNull, lt, or } from "@sofa/db/helpers";
import { import {
availabilityOffers, availabilityOffers,
cronRuns, cronRuns,
@@ -8,30 +24,10 @@ import {
titleCast, titleCast,
titles, titles,
userTitleStatus, userTitleStatus,
} from "@/lib/db/schema"; } from "@sofa/db/schema";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@sofa/logger";
import { refreshAvailability } from "@/lib/services/availability"; import { getTvDetails } from "@sofa/tmdb/client";
import { import { Cron } from "croner";
createBackup,
ensureBackupDir,
pruneBackups,
} from "@/lib/services/backup";
import { refreshCredits } from "@/lib/services/credits";
import {
cacheEpisodeStills,
cacheImagesForTitle,
cacheProfilePhotos,
cacheProviderLogos,
imageCacheEnabled,
} from "@/lib/services/image-cache";
import {
refreshRecommendations,
refreshTitle,
refreshTvChildren,
} from "@/lib/services/metadata";
import { getSetting } from "@/lib/services/settings";
import { performUpdateCheck } from "@/lib/services/update-check";
import { getTvDetails } from "@/lib/tmdb/client";
export type BackupFrequency = "6h" | "12h" | "1d" | "7d"; export type BackupFrequency = "6h" | "12h" | "1d" | "7d";
@@ -166,7 +162,6 @@ async function refreshAvailabilityJob() {
log.debug(`Checking availability for ${libraryIds.length} library titles`); log.debug(`Checking availability for ${libraryIds.length} library titles`);
const stale = new Date(Date.now() - DAY); const stale = new Date(Date.now() - DAY);
// Batch: find titles with any offers, and titles with stale offers
const titlesWithOffers = new Set( const titlesWithOffers = new Set(
db db
.select({ titleId: availabilityOffers.titleId }) .select({ titleId: availabilityOffers.titleId })
@@ -200,7 +195,6 @@ async function refreshAvailabilityJob() {
} }
} }
// Refresh recommendations for recently active titles
async function refreshRecommendationsJob() { async function refreshRecommendationsJob() {
const libraryIds = getLibraryTitleIds(); const libraryIds = getLibraryTitleIds();
log.debug( log.debug(
@@ -213,7 +207,6 @@ async function refreshRecommendationsJob() {
} }
} }
// Refresh TV episodes for returning shows
async function refreshTvChildrenJob() { async function refreshTvChildrenJob() {
const returningStatuses = ["Returning Series", "In Production"]; const returningStatuses = ["Returning Series", "In Production"];
const stale = new Date(Date.now() - 7 * DAY); const stale = new Date(Date.now() - 7 * DAY);
@@ -232,7 +225,6 @@ async function refreshTvChildrenJob() {
log.debug(`Checking ${tvShows.length} returning TV shows for stale episodes`); log.debug(`Checking ${tvShows.length} returning TV shows for stale episodes`);
// Batch: find shows with at least one stale season
const tvIds = tvShows.map((s) => s.id); const tvIds = tvShows.map((s) => s.id);
const titlesWithStaleSeasons = new Set( const titlesWithStaleSeasons = new Set(
tvIds.length > 0 tvIds.length > 0
@@ -260,7 +252,6 @@ async function refreshTvChildrenJob() {
} }
} }
// Cache images for all library titles (posters, backdrops, stills, logos)
async function cacheImagesJob() { async function cacheImagesJob() {
if (!imageCacheEnabled()) return; if (!imageCacheEnabled()) return;
@@ -282,7 +273,6 @@ async function cacheImagesJob() {
} }
} }
// Refresh credits for library titles where cast is stale or missing
async function refreshCreditsJob() { async function refreshCreditsJob() {
const libraryIds = getLibraryTitleIds(); const libraryIds = getLibraryTitleIds();
log.debug(`Checking credits for ${libraryIds.length} library titles`); log.debug(`Checking credits for ${libraryIds.length} library titles`);
+139
View File
@@ -0,0 +1,139 @@
import { CACHE_DIR } from "@sofa/config";
import { ensureBackupDir } from "@sofa/core/backup";
import { ensureImageDirs, imageCacheEnabled } from "@sofa/core/image-cache";
import { registerJobScheduleProvider } from "@sofa/core/system-health";
import { closeDatabase } from "@sofa/db/client";
import { runMigrations } from "@sofa/db/migrate";
import { createLogger } from "@sofa/logger";
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { cors } from "hono/cors";
import { getJobSchedules, startJobs, stopJobs } from "./cron";
import { handler as rpcHandler } from "./orpc/handler";
import { openApiHandler } from "./orpc/openapi-handler";
import authRoutes from "./routes/auth";
import avatarsRoutes from "./routes/avatars";
import backupsRoutes from "./routes/backups";
import healthRoutes from "./routes/health";
import imagesRoutes from "./routes/images";
import listsRoutes from "./routes/lists";
import webhooksRoutes from "./routes/webhooks";
const log = createLogger("server");
// ─── Startup ──────────────────────────────────────────────────
// Ensure directories
if (imageCacheEnabled()) {
await ensureImageDirs();
}
await ensureBackupDir();
// Run database migrations
runMigrations();
// Wire up job schedule provider for system health
registerJobScheduleProvider(getJobSchedules);
// Start background jobs
startJobs();
// ─── App ──────────────────────────────────────────────────────
const app = new Hono();
// CORS — allow the web app's origin
app.use(
"*",
cors({
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
credentials: true,
}),
);
// Non-RPC routes
app.route("/api/health", healthRoutes);
app.route("/api/auth", authRoutes);
app.route("/api/avatars", avatarsRoutes);
app.route("/api/backup", backupsRoutes);
app.route("/api/webhooks", webhooksRoutes);
app.route("/api/lists", listsRoutes);
// Cached images — serve from disk (fast path), fall back to TMDB fetch on miss
if (imageCacheEnabled()) {
app.use(
"/images/*",
serveStatic({
root: CACHE_DIR,
rewriteRequestPath: (path) => path.replace("/images", ""),
onFound: (_path, c) => {
c.header("Cache-Control", "public, max-age=31536000, immutable");
},
}),
);
}
app.route("/images", imagesRoutes);
// oRPC RPC handler
app.all("/rpc/*", async (c) => {
const { response } = await rpcHandler.handle(c.req.raw, {
prefix: "/rpc",
context: { headers: c.req.raw.headers },
});
return response ?? c.text("Not found", 404);
});
// oRPC OpenAPI handler + Scalar docs
app.all("/api/v1/*", async (c) => {
const { response } = await openApiHandler.handle(c.req.raw, {
prefix: "/api/v1",
context: { headers: c.req.raw.headers },
});
return response ?? c.text("Not found", 404);
});
// ─── SPA static serving (production) ─────────────────────────
if (process.env.NODE_ENV === "production") {
const { resolve } = await import("node:path");
const spaDir = resolve(import.meta.dir, "../../../apps/web/dist");
// Hashed assets — immutable cache
app.use(
"/assets/*",
serveStatic({
root: spaDir,
onFound: (_path, c) => {
c.header("Cache-Control", "public, max-age=31536000, immutable");
},
}),
);
// Return 404 for missing /assets/* (stale chunks after deploy) instead of SPA fallback
app.all("/assets/*", (c) => c.text("Not found", 404));
// Other static files (icons, manifest, etc.)
app.use("*", serveStatic({ root: spaDir }));
// SPA fallback — serve index.html for all unmatched routes
app.get("*", serveStatic({ root: spaDir, path: "/index.html" }));
}
// ─── Graceful shutdown ────────────────────────────────────────
const shutdown = () => {
log.info("Stopping scheduler...");
stopJobs();
log.info("Closing database...");
closeDatabase();
log.info("Clean shutdown complete");
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
const port = Number(process.env.PORT || process.env.API_PORT || 3001);
log.info(`API server listening on port ${port}`);
export default {
port,
fetch: app.fetch,
};
@@ -1,5 +1,5 @@
import { implement } from "@orpc/server"; import { implement } from "@orpc/server";
import { contract } from "./contract"; import { contract } from "@sofa/api/contract";
export interface Context { export interface Context {
headers: Headers; headers: Headers;
@@ -1,6 +1,6 @@
import { onError } from "@orpc/server"; import { onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch"; import { RPCHandler } from "@orpc/server/fetch";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@sofa/logger";
import { router } from "./router"; import { router } from "./router";
const log = createLogger("orpc"); const log = createLogger("orpc");
@@ -8,7 +8,7 @@ const log = createLogger("orpc");
export const handler = new RPCHandler(router, { export const handler = new RPCHandler(router, {
interceptors: [ interceptors: [
onError((error) => { onError((error) => {
log.error("oRPC error", { error }); log.error("oRPC error", error);
}), }),
], ],
}); });
@@ -1,6 +1,6 @@
import { oo } from "@orpc/openapi"; import { oo } from "@orpc/openapi";
import { os as baseOs, ORPCError } from "@orpc/server"; import { os as baseOs, ORPCError } from "@orpc/server";
import { auth } from "@/lib/auth/server"; import { auth } from "@sofa/auth/server";
const base = baseOs.$context<{ headers: Headers }>(); const base = baseOs.$context<{ headers: Headers }>();
@@ -3,7 +3,7 @@ import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"; import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
import { onError } from "@orpc/server"; import { onError } from "@orpc/server";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@sofa/logger";
import { router } from "./router"; import { router } from "./router";
const log = createLogger("openapi"); const log = createLogger("openapi");
@@ -42,7 +42,7 @@ export const openApiHandler = new OpenAPIHandler(router, {
], ],
interceptors: [ interceptors: [
onError((error) => { onError((error) => {
log.error("OpenAPI error", { error }); log.error("OpenAPI error", error);
}), }),
], ],
}); });
@@ -1,7 +1,7 @@
import { mkdir, rename } from "node:fs/promises"; import { mkdir, rename } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { auth } from "@/lib/auth/server"; import { auth } from "@sofa/auth/server";
import { AVATAR_DIR } from "@/lib/constants"; import { AVATAR_DIR } from "@sofa/config";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
@@ -1,19 +1,19 @@
import path from "node:path"; import path from "node:path";
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { BACKUP_DIR } from "@/lib/constants"; import { BACKUP_DIR } from "@sofa/config";
import { rescheduleBackup, triggerJob } from "@/lib/cron";
import { import {
createBackup, createBackup,
deleteBackup, deleteBackup,
ensureBackupDir, ensureBackupDir,
listBackups, listBackups,
restoreFromBackup, restoreFromBackup,
} from "@/lib/services/backup"; } from "@sofa/core/backup";
import { getSetting, setSetting } from "@/lib/services/settings"; import { getSetting, setSetting } from "@sofa/core/settings";
import { import {
getCachedUpdateCheck, getCachedUpdateCheck,
isUpdateCheckEnabled, isUpdateCheckEnabled,
} from "@/lib/services/update-check"; } from "@sofa/core/update-check";
import { rescheduleBackup, triggerJob } from "../../cron";
import { os } from "../context"; import { os } from "../context";
import { admin } from "../middleware"; import { admin } from "../middleware";
@@ -3,8 +3,8 @@ import {
getNewAvailableFeed, getNewAvailableFeed,
getRecommendationsFeed, getRecommendationsFeed,
getUserStats, getUserStats,
} from "@/lib/services/discovery"; } from "@sofa/core/discovery";
import { tmdbImageUrl } from "@/lib/tmdb/image"; import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
@@ -1,15 +1,15 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { isTmdbConfigured } from "@/lib/config";
import { import {
getEpisodeProgressByTmdbIds, getEpisodeProgressByTmdbIds,
getUserStatusesByTmdbIds, getUserStatusesByTmdbIds,
} from "@/lib/services/tracking"; } from "@sofa/core/tracking";
import { discover } from "@/lib/tmdb/client"; import { discover as discoverTmdb } from "@sofa/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image"; import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
export const discoverProcedure = os.discover export const discover = os.discover
.use(authed) .use(authed)
.handler(async ({ input, context }) => { .handler(async ({ input, context }) => {
if (!isTmdbConfigured()) { if (!isTmdbConfigured()) {
@@ -18,7 +18,7 @@ export const discoverProcedure = os.discover
}); });
} }
const results = await discover(input.mediaType, { const results = await discoverTmdb(input.mediaType, {
sort_by: "popularity.desc", sort_by: "popularity.desc",
"vote_count.gte": "50", "vote_count.gte": "50",
with_genres: String(input.genreId), with_genres: String(input.genreId),
@@ -2,7 +2,7 @@ import {
logEpisodeWatch, logEpisodeWatch,
logEpisodeWatchBatch, logEpisodeWatchBatch,
unwatchEpisode, unwatchEpisode,
} from "@/lib/services/tracking"; } from "@sofa/core/tracking";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
@@ -1,11 +1,11 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { isTmdbConfigured } from "@/lib/config";
import { import {
getEpisodeProgressByTmdbIds, getEpisodeProgressByTmdbIds,
getUserStatusesByTmdbIds, getUserStatusesByTmdbIds,
} from "@/lib/services/tracking"; } from "@sofa/core/tracking";
import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client"; import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image"; import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
@@ -1,7 +1,7 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { and, desc, eq } from "drizzle-orm"; import { db } from "@sofa/db/client";
import { db } from "@/lib/db/client"; import { and, desc, eq } from "@sofa/db/helpers";
import { integrationEvents, integrations } from "@/lib/db/schema"; import { integrationEvents, integrations } from "@sofa/db/schema";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
@@ -3,8 +3,8 @@ import {
getLocalFilmography, getLocalFilmography,
getOrFetchPerson, getOrFetchPerson,
getOrFetchPersonByTmdbId, getOrFetchPersonByTmdbId,
} from "@/lib/services/person"; } from "@sofa/core/person";
import { getUserStatusesByTitleIds } from "@/lib/services/tracking"; import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
@@ -1,12 +1,12 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { isTmdbConfigured } from "@/lib/config";
import { import {
searchMovies, searchMovies,
searchMulti, searchMulti,
searchPerson, searchPerson,
searchTv, searchTv,
} from "@/lib/tmdb/client"; } from "@sofa/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image"; import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm"; import { logEpisodeWatchBatch, unwatchSeason } from "@sofa/core/tracking";
import { db } from "@/lib/db/client"; import { db } from "@sofa/db/client";
import { episodes } from "@/lib/db/schema"; import { eq } from "@sofa/db/helpers";
import { logEpisodeWatchBatch, unwatchSeason } from "@/lib/services/tracking"; import { episodes } from "@sofa/db/schema";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
+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 };
});
@@ -1,5 +1,5 @@
import { isTmdbConfigured } from "@/lib/config"; import { getSystemHealth } from "@sofa/core/system-health";
import { getSystemHealth } from "@/lib/services/system-health"; import { isTmdbConfigured } from "@sofa/tmdb/config";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
+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(),
};
});
@@ -1,9 +1,10 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { getRecommendationsForTitle } from "@/lib/services/discovery"; import { getRecommendationsForTitle } from "@sofa/core/discovery";
import { import {
ensureTvHydrated,
getOrFetchTitle, getOrFetchTitle,
getOrFetchTitleByTmdbId, getOrFetchTitleByTmdbId,
} from "@/lib/services/metadata"; } from "@sofa/core/metadata";
import { import {
getUserStatusesByTitleIds, getUserStatusesByTitleIds,
getUserTitleInfo, getUserTitleInfo,
@@ -12,7 +13,7 @@ import {
rateTitleStars, rateTitleStars,
removeTitleStatus, removeTitleStatus,
setTitleStatus, setTitleStatus,
} from "@/lib/services/tracking"; } from "@sofa/core/tracking";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
@@ -78,3 +79,10 @@ export const recommendations = os.titles.recommendations
); );
return { recommendations: recs, userStatuses }; 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 };
});
@@ -1,9 +1,9 @@
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { and, eq } from "drizzle-orm"; import { getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
import { db } from "@/lib/db/client"; import { setTitleStatus } from "@sofa/core/tracking";
import { userTitleStatus } from "@/lib/db/schema"; import { db } from "@sofa/db/client";
import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata"; import { and, eq } from "@sofa/db/helpers";
import { setTitleStatus } from "@/lib/services/tracking"; import { userTitleStatus } from "@sofa/db/schema";
import { os } from "../context"; import { os } from "../context";
import { authed } from "../middleware"; import { authed } from "../middleware";
@@ -2,15 +2,16 @@ import { os } from "./context";
import * as account from "./procedures/account"; import * as account from "./procedures/account";
import * as admin from "./procedures/admin"; import * as admin from "./procedures/admin";
import * as dashboard from "./procedures/dashboard"; import * as dashboard from "./procedures/dashboard";
import { discoverProcedure } from "./procedures/discover"; import { discover } from "./procedures/discover";
import * as episodes from "./procedures/episodes"; import * as episodes from "./procedures/episodes";
import * as explore from "./procedures/explore"; import * as explore from "./procedures/explore";
import * as integrations from "./procedures/integrations"; import * as integrations from "./procedures/integrations";
import * as people from "./procedures/people"; import * as people from "./procedures/people";
import { search } from "./procedures/search"; import { search } from "./procedures/search";
import * as seasons from "./procedures/seasons"; import * as seasons from "./procedures/seasons";
import { statsProcedure } from "./procedures/stats"; import { stats } from "./procedures/stats";
import { systemStatus } from "./procedures/status"; import { systemStatus } from "./procedures/status";
import * as system from "./procedures/system";
import * as titles from "./procedures/titles"; import * as titles from "./procedures/titles";
import * as watchlist from "./procedures/watchlist"; import * as watchlist from "./procedures/watchlist";
@@ -24,6 +25,7 @@ export const router = os.router({
watchAll: titles.watchAll, watchAll: titles.watchAll,
userInfo: titles.userInfo, userInfo: titles.userInfo,
recommendations: titles.recommendations, recommendations: titles.recommendations,
hydrateSeasons: titles.hydrateSeasons,
}, },
episodes: { episodes: {
watch: episodes.watch, watch: episodes.watch,
@@ -50,9 +52,13 @@ export const router = os.router({
genres: explore.genres, genres: explore.genres,
}, },
search, search,
discover: discoverProcedure, discover,
stats: statsProcedure, stats,
systemStatus, systemStatus,
system: {
publicInfo: system.publicInfo,
authConfig: system.authConfig,
},
integrations: { integrations: {
list: integrations.list, list: integrations.list,
create: integrations.create, create: integrations.create,
+8
View File
@@ -0,0 +1,8 @@
import { auth } from "@sofa/auth/server";
import { Hono } from "hono";
const app = new Hono();
app.all("/*", (c) => auth.handler(c.req.raw));
export default app;
+43
View File
@@ -0,0 +1,43 @@
import path from "node:path";
import { auth } from "@sofa/auth/server";
import { AVATAR_DIR } from "@sofa/config";
import { Hono } from "hono";
const app = new Hono();
app.get("/:userId", async (c) => {
// Auth check
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
const userId = c.req.param("userId");
// Sanitize userId to prevent path traversal
const safeUserId = path.basename(userId);
if (!safeUserId || safeUserId !== userId || safeUserId.includes("..")) {
return c.json({ error: "Invalid user ID" }, 400);
}
// Find the avatar file (could be .jpg, .png, .webp, .gif)
const glob = new Bun.Glob(`${safeUserId}.*`);
const matches = await Array.fromAsync(glob.scan(AVATAR_DIR));
if (matches.length === 0) {
return c.json({ error: "Not found" }, 404);
}
const file = Bun.file(path.join(AVATAR_DIR, matches[0]));
if (!(await file.exists())) {
return c.json({ error: "Not found" }, 404);
}
return new Response(await file.arrayBuffer(), {
status: 200,
headers: {
"Content-Type": file.type,
},
});
});
export default app;
+44
View File
@@ -0,0 +1,44 @@
import path from "node:path";
import { auth } from "@sofa/auth/server";
import { getBackupPath } from "@sofa/core/backup";
import { Hono } from "hono";
const app = new Hono();
app.get("/:filename", async (c) => {
// Auth + admin check
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
if (session.user.role !== "admin") {
return c.json({ error: "Forbidden" }, 403);
}
const filename = c.req.param("filename");
// Sanitize to prevent path traversal
const safe = path.basename(filename);
if (!safe || safe !== filename || safe.includes("..")) {
return c.json({ error: "Invalid filename" }, 400);
}
const backupPath = await getBackupPath(safe);
if (!backupPath) {
return c.json({ error: "Not found" }, 404);
}
const file = Bun.file(backupPath);
const buffer = await file.arrayBuffer();
return new Response(new Uint8Array(buffer), {
status: 200,
headers: {
"Content-Type": "application/x-sqlite3",
"Content-Disposition": `attachment; filename="${safe}"`,
"Content-Length": String(file.size),
},
});
});
export default app;
+20
View File
@@ -0,0 +1,20 @@
import { db } from "@sofa/db/client";
import { sql } from "@sofa/db/helpers";
import { createLogger } from "@sofa/logger";
import { Hono } from "hono";
const log = createLogger("health");
const app = new Hono();
app.get("/", (c) => {
try {
db.run(sql`SELECT 1`);
return c.json({ status: "healthy" }, 200);
} catch (err) {
log.error("Health check failed:", err);
return c.json({ status: "unhealthy" }, 503);
}
});
export default app;
+51
View File
@@ -0,0 +1,51 @@
import path from "node:path";
import { fetchAndMaybeCache, imageCacheEnabled } from "@sofa/core/image-cache";
import { Hono } from "hono";
import { z } from "zod";
const categorySchema = z.enum([
"posters",
"backdrops",
"stills",
"logos",
"profiles",
]);
const app = new Hono();
app.get("/:category/:filename", async (c) => {
if (!imageCacheEnabled()) {
return c.json({ error: "Image cache disabled" }, 404);
}
const rawCategory = c.req.param("category");
const rawFilename = c.req.param("filename");
const catResult = categorySchema.safeParse(rawCategory);
if (!catResult.success) {
return c.json({ error: "Invalid category" }, 400);
}
const category = catResult.data;
// Sanitize filename — only allow basename to prevent path traversal
const filename = path.basename(rawFilename);
if (!filename || filename !== rawFilename || filename.includes("..")) {
return c.json({ error: "Invalid filename" }, 400);
}
const tmdbPath = `/${filename}`;
const result = await fetchAndMaybeCache(tmdbPath, category);
if (!result) {
return c.json({ error: "Not found" }, 404);
}
return new Response(new Uint8Array(result.buffer), {
status: 200,
headers: {
"Content-Type": result.contentType,
},
});
});
export default app;
+26
View File
@@ -0,0 +1,26 @@
import {
getRadarrList,
getSonarrList,
parseStatusParam,
resolveListToken,
} from "@sofa/core/lists";
import { Hono } from "hono";
const app = new Hono();
app.get("/:token", async (c) => {
const token = c.req.param("token");
const result = resolveListToken(token);
if (!result) {
return c.json([]);
}
const statuses = parseStatusParam(c.req.query("status") ?? null);
if (result.provider === "sonarr") {
return c.json(await getSonarrList(result.userId, statuses));
}
return c.json(getRadarrList(result.userId, statuses));
});
export default app;
@@ -1,24 +1,22 @@
import { eq } from "drizzle-orm"; import type { WebhookEvent } from "@sofa/core/webhooks";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { db } from "@/lib/db/client";
import { integrations } from "@/lib/db/schema";
import { createLogger } from "@/lib/logger";
import type { WebhookEvent } from "@/lib/services/webhooks";
import { import {
parseEmbyPayload, parseEmbyPayload,
parseJellyfinPayload, parseJellyfinPayload,
parsePlexPayload, parsePlexPayload,
processWebhook, processWebhook,
} from "@/lib/services/webhooks"; } from "@sofa/core/webhooks";
import { db } from "@sofa/db/client";
import { eq } from "@sofa/db/helpers";
import { integrations } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { Hono } from "hono";
const log = createLogger("webhooks"); const log = createLogger("webhooks");
export async function POST( const app = new Hono();
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }, app.post("/:token", async (c) => {
) { const token = c.req.param("token");
const { token } = await params;
// Look up connection by token — this IS the auth // Look up connection by token — this IS the auth
const connection = db const connection = db
@@ -29,12 +27,12 @@ export async function POST(
if (!connection || !connection.enabled) { if (!connection || !connection.enabled) {
// Always return 200 to avoid retry storms from media servers // Always return 200 to avoid retry storms from media servers
return NextResponse.json({ ok: true }); return c.json({ ok: true });
} }
// Only webhook-type integrations are handled here // Only webhook-type integrations are handled here
if (connection.type !== "webhook") { if (connection.type !== "webhook") {
return NextResponse.json({ ok: true }); return c.json({ ok: true });
} }
const provider = connection.provider as "plex" | "jellyfin" | "emby"; const provider = connection.provider as "plex" | "jellyfin" | "emby";
@@ -42,19 +40,19 @@ export async function POST(
try { try {
let event: WebhookEvent | null; let event: WebhookEvent | null;
if (provider === "plex") { if (provider === "plex") {
const formData = await req.formData(); const formData = await c.req.raw.formData();
event = parsePlexPayload(formData); event = parsePlexPayload(formData as unknown as FormData);
} else if (provider === "emby") { } else if (provider === "emby") {
const body = await req.json(); const body = await c.req.json();
event = parseEmbyPayload(body); event = parseEmbyPayload(body);
} else { } else {
const body = await req.json(); const body = await c.req.json();
event = parseJellyfinPayload(body); event = parseJellyfinPayload(body);
} }
if (!event) { if (!event) {
// Not a relevant event type — silently ignore // Not a relevant event type — silently ignore
return NextResponse.json({ ok: true }); return c.json({ ok: true });
} }
await processWebhook(connection.id, connection.userId, provider, event); await processWebhook(connection.id, connection.userId, provider, event);
@@ -63,5 +61,7 @@ export async function POST(
log.debug("Webhook processing failed:", err); log.debug("Webhook processing failed:", err);
} }
return NextResponse.json({ ok: true }); return c.json({ ok: true });
} });
export default app;
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["esnext"],
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true,
"skipLibCheck": true,
"types": ["bun"],
"jsx": "react-jsx"
},
"include": ["src"]
}
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "base-mira", "style": "base-mira",
"rsc": true, "rsc": false,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "app/globals.css", "css": "src/styles/globals.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en" style="color-scheme: dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
</head>
<body style="touch-action: manipulation">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
{
"name": "@sofa/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "biome check",
"format": "biome format --write",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@base-ui/react": "1.2.0",
"@fontsource-variable/dm-sans": "5.2.8",
"@fontsource-variable/geist-mono": "5.2.7",
"@fontsource/dm-serif-display": "5.2.8",
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/tanstack-query": "1.13.6",
"@player.style/sutro": "0.2.1",
"@sofa/api": "workspace:*",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.4.1",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-router": "1.166.6",
"better-auth": "catalog:",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"date-fns": "catalog:",
"jotai": "2.18.1",
"media-chrome": "4.18.0",
"motion": "12.35.2",
"react": "19.2.4",
"react-day-picker": "9.14.0",
"react-dom": "19.2.4",
"recharts": "3.8.0",
"shadcn": "4.0.3",
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
"tw-animate-css": "1.4.0",
"vaul": "1.1.2",
"youtube-video-element": "1.9.0",
"zod": "catalog:"
},
"devDependencies": {
"@tailwindcss/vite": "4.2.1",
"@tanstack/devtools-vite": "0.5.3",
"@tanstack/react-devtools": "0.9.10",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-router-devtools": "1.166.6",
"@tanstack/router-plugin": "1.166.6",
"@types/bun": "catalog:",
"@types/node": "25.4.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.4",
"babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "4.2.1",
"typescript": "catalog:",
"vite": "7.3.1",
"vite-tsconfig-paths": "6.1.1"
}
}

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before

Width:  |  Height:  |  Size: 579 B

After

Width:  |  Height:  |  Size: 579 B

+21
View File
@@ -0,0 +1,21 @@
{
"name": "Sofa",
"short_name": "Sofa",
"start_url": "/",
"scope": "/",
"display": "standalone",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

@@ -1,9 +1,6 @@
"use client";
import { IconKey } from "@tabler/icons-react"; import { IconKey } from "@tabler/icons-react";
import { Link, useNavigate } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { SofaLogo } from "@/components/sofa-logo"; import { SofaLogo } from "@/components/sofa-logo";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -38,7 +35,7 @@ export function AuthForm({
mode: "login" | "register"; mode: "login" | "register";
authConfig?: AuthConfig; authConfig?: AuthConfig;
}) { }) {
const router = useRouter(); const navigate = useNavigate();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -69,7 +66,7 @@ export function AuthForm({
return; return;
} }
} }
router.push("/dashboard"); void navigate({ to: "/dashboard" });
} catch { } catch {
setError("Something went wrong"); setError("Something went wrong");
} finally { } finally {
@@ -104,7 +101,7 @@ export function AuthForm({
transition={{ type: "spring" as const, stiffness: 200, damping: 20 }} transition={{ type: "spring" as const, stiffness: 200, damping: 20 }}
> >
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<Link href="/" className="inline-flex justify-center text-primary"> <Link to="/" className="inline-flex justify-center text-primary">
<SofaLogo className="size-9" /> <SofaLogo className="size-9" />
</Link> </Link>
<h1 className="text-balance font-medium text-lg"> <h1 className="text-balance font-medium text-lg">
@@ -263,7 +260,7 @@ export function AuthForm({
<> <>
Already have an account?{" "} Already have an account?{" "}
<Link <Link
href="/login" to="/login"
className="font-medium text-primary transition-colors hover:text-primary/80" className="font-medium text-primary transition-colors hover:text-primary/80"
> >
Sign in Sign in
@@ -273,7 +270,7 @@ export function AuthForm({
<> <>
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link <Link
href="/register" to="/register"
className="font-medium text-primary transition-colors hover:text-primary/80" className="font-medium text-primary transition-colors hover:text-primary/80"
> >
Register Register
@@ -1,5 +1,3 @@
"use client";
import { import {
IconDeviceTv, IconDeviceTv,
IconHome, IconHome,
@@ -11,9 +9,8 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys"; import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys";
import { skipToken, useMutation, useQuery } from "@tanstack/react-query"; import { skipToken, useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress"; import { useProgress } from "@/components/navigation-progress";
@@ -43,7 +40,7 @@ import {
MAX_RECENT, MAX_RECENT,
recentSearchesAtom, recentSearchesAtom,
} from "@/lib/atoms/command-palette"; } from "@/lib/atoms/command-palette";
import { orpc } from "@/lib/orpc/tanstack"; import { orpc } from "@/lib/orpc/client";
// Static shortcut descriptions for the help dialog. // Static shortcut descriptions for the help dialog.
// TanStack's HotkeyManager/SequenceManager handle all actual key listening. // TanStack's HotkeyManager/SequenceManager handle all actual key listening.
@@ -84,7 +81,7 @@ interface SearchResult {
} }
export function CommandPalette() { export function CommandPalette() {
const router = useRouter(); const navigate = useNavigate();
const progress = useProgress(); const progress = useProgress();
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom( const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(
commandPaletteOpenAtom, commandPaletteOpenAtom,
@@ -109,7 +106,7 @@ export function CommandPalette() {
["G", "H"], ["G", "H"],
() => { () => {
progress.start(); progress.start();
router.push("/dashboard"); void navigate({ to: "/dashboard" });
}, },
{ enabled, timeout: 500 }, { enabled, timeout: 500 },
); );
@@ -117,7 +114,7 @@ export function CommandPalette() {
["G", "E"], ["G", "E"],
() => { () => {
progress.start(); progress.start();
router.push("/explore"); void navigate({ to: "/explore" });
}, },
{ enabled, timeout: 500 }, { enabled, timeout: 500 },
); );
@@ -150,7 +147,7 @@ export function CommandPalette() {
const resolvePersonMutation = useMutation( const resolvePersonMutation = useMutation(
orpc.people.resolve.mutationOptions({ orpc.people.resolve.mutationOptions({
onSuccess: ({ id }) => { onSuccess: ({ id }) => {
if (id) router.push(`/people/${id}`); if (id) void navigate({ to: "/people/$id", params: { id } });
else progress.done(); else progress.done();
}, },
onError: () => { onError: () => {
@@ -163,7 +160,7 @@ export function CommandPalette() {
const resolveTitleMutation = useMutation( const resolveTitleMutation = useMutation(
orpc.titles.resolve.mutationOptions({ orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => { onSuccess: ({ id }) => {
if (id) router.push(`/titles/${id}`); if (id) void navigate({ to: "/titles/$id", params: { id } });
else progress.done(); else progress.done();
}, },
onError: () => { onError: () => {
@@ -261,11 +258,13 @@ export function CommandPalette() {
{r.type === "person" ? ( {r.type === "person" ? (
<div className="size-10 shrink-0 overflow-hidden rounded-full bg-muted"> <div className="size-10 shrink-0 overflow-hidden rounded-full bg-muted">
{r.profilePath ? ( {r.profilePath ? (
<Image <img
src={r.profilePath as string} src={r.profilePath}
alt={r.title} alt={r.title}
width={40} width={40}
height={40} height={40}
loading="lazy"
decoding="async"
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
) : ( ) : (
@@ -280,11 +279,13 @@ export function CommandPalette() {
) : ( ) : (
<div className="h-12 w-8 shrink-0 overflow-hidden rounded bg-muted"> <div className="h-12 w-8 shrink-0 overflow-hidden rounded bg-muted">
{r.posterPath ? ( {r.posterPath ? (
<Image <img
src={r.posterPath as string} src={r.posterPath}
alt={r.title} alt={r.title}
width={32} width={32}
height={48} height={48}
loading="lazy"
decoding="async"
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
) : ( ) : (
@@ -387,7 +388,7 @@ export function CommandPalette() {
onSelect={() => { onSelect={() => {
setCommandPaletteOpen(false); setCommandPaletteOpen(false);
progress.start(); progress.start();
router.push("/dashboard"); void navigate({ to: "/dashboard" });
}} }}
> >
<IconHome aria-hidden={true} className="size-3.5" /> <IconHome aria-hidden={true} className="size-3.5" />
@@ -398,7 +399,7 @@ export function CommandPalette() {
onSelect={() => { onSelect={() => {
setCommandPaletteOpen(false); setCommandPaletteOpen(false);
progress.start(); progress.start();
router.push("/explore"); void navigate({ to: "/explore" });
}} }}
> >
<IconSearch aria-hidden={true} className="size-3.5" /> <IconSearch aria-hidden={true} className="size-3.5" />
@@ -1,6 +1,6 @@
import { IconPlayerPlay } from "@tabler/icons-react"; import { IconPlayerPlay } from "@tabler/icons-react";
import Image from "next/image";
import Link from "next/link"; import { Link } from "@tanstack/react-router";
export interface ContinueWatchingItemProps { export interface ContinueWatchingItemProps {
title: { title: {
@@ -32,17 +32,18 @@ export function ContinueWatchingCard({
return ( return (
<Link <Link
href={`/titles/${item.title.id}`} to="/titles/$id"
params={{ id: item.title.id }}
className="group relative inline-block w-64 shrink-0 overflow-hidden rounded-xl bg-card/50 ring-1 ring-white/[0.06] transition-shadow hover:shadow-black/25 hover:shadow-lg sm:w-72" className="group relative inline-block w-64 shrink-0 overflow-hidden rounded-xl bg-card/50 ring-1 ring-white/[0.06] transition-shadow hover:shadow-black/25 hover:shadow-lg sm:w-72"
> >
<div className="relative aspect-video overflow-hidden rounded-t-xl bg-muted"> <div className="relative aspect-video overflow-hidden rounded-t-xl bg-muted">
{stillUrl ? ( {stillUrl ? (
<Image <img
src={stillUrl} src={stillUrl}
alt={item.nextEpisode?.name ?? item.title.title} alt={item.nextEpisode?.name ?? item.title.title}
fill loading="lazy"
sizes="(min-width: 640px) 288px, 256px" decoding="async"
className="object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105" className="absolute inset-0 h-full w-full object-cover motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-105"
/> />
) : ( ) : (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card via-secondary to-muted"> <div className="flex h-full items-center justify-center bg-gradient-to-br from-card via-secondary to-muted">
@@ -1,8 +1,6 @@
"use client";
import { IconPlayerPlay } from "@tabler/icons-react"; import { IconPlayerPlay } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/tanstack"; import { orpc } from "@/lib/orpc/client";
import { import {
ContinueWatchingList, ContinueWatchingList,
ContinueWatchingSectionSkeleton, ContinueWatchingSectionSkeleton,
@@ -1,8 +1,6 @@
"use client";
import { IconBooks } from "@tabler/icons-react"; import { IconBooks } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/tanstack"; import { orpc } from "@/lib/orpc/client";
import { FeedSection } from "./feed-section"; import { FeedSection } from "./feed-section";
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid"; import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
@@ -1,8 +1,6 @@
"use client";
import { IconThumbUp } from "@tabler/icons-react"; import { IconThumbUp } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/tanstack"; import { orpc } from "@/lib/orpc/client";
import { FeedSection } from "./feed-section"; import { FeedSection } from "./feed-section";
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid"; import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
@@ -1,7 +1,5 @@
"use client"; import { useEffect, useId, useRef, useState } from "react";
import { Area, AreaChart, YAxis } from "recharts";
import { useId } from "react";
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
interface SparklineProps { interface SparklineProps {
data: Array<{ bucket: string; count: number }>; data: Array<{ bucket: string; count: number }>;
@@ -11,16 +9,36 @@ interface SparklineProps {
export function Sparkline({ data, color }: SparklineProps) { export function Sparkline({ data, color }: SparklineProps) {
const uniqueId = useId(); const uniqueId = useId();
const gradientId = `sparkline-${uniqueId.replace(/:/g, "")}`; const gradientId = `sparkline-${uniqueId.replace(/:/g, "")}`;
const containerRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) {
setSize({ width, height });
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
if (!data.some((d) => d.count > 0)) return null; if (!data.some((d) => d.count > 0)) return null;
return ( return (
<div <div
ref={containerRef}
className={`pointer-events-none absolute inset-0 overflow-hidden ${color}`} className={`pointer-events-none absolute inset-0 overflow-hidden ${color}`}
> >
<ResponsiveContainer width="100%" height="100%"> {size.width > 0 && size.height > 0 && (
<AreaChart <AreaChart
data={data} data={data}
width={size.width}
height={size.height}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }} margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
> >
<defs> <defs>
@@ -40,7 +58,7 @@ export function Sparkline({ data, color }: SparklineProps) {
isAnimationActive={false} isAnimationActive={false}
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> )}
</div> </div>
); );
} }
@@ -1,5 +1,8 @@
"use client"; import type {
DashboardStats,
HistoryBucket,
TimePeriod,
} from "@sofa/api/schemas";
import { import {
IconCheck, IconCheck,
IconLibrary, IconLibrary,
@@ -16,12 +19,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/tanstack"; import { orpc } from "@/lib/orpc/client";
import type {
DashboardStats,
HistoryBucket,
TimePeriod,
} from "@/lib/services/discovery";
import { Sparkline } from "./sparkline"; import { Sparkline } from "./sparkline";
function StatCardSkeleton() { function StatCardSkeleton() {
@@ -1,9 +1,7 @@
"use client";
import { IconDeviceTv } from "@tabler/icons-react"; import { IconDeviceTv } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import Link from "next/link"; import { Link } from "@tanstack/react-router";
import { orpc } from "@/lib/orpc/tanstack"; import { orpc } from "@/lib/orpc/client";
import { StatsDisplay, StatsSectionSkeleton } from "./stats-display"; import { StatsDisplay, StatsSectionSkeleton } from "./stats-display";
export function StatsSection() { export function StatsSection() {
@@ -35,7 +33,7 @@ export function StatsSection() {
</p> </p>
</div> </div>
<Link <Link
href="/explore" to="/explore"
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20" className="inline-flex h-9 items-center rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20"
> >
Start exploring Start exploring
@@ -1,5 +1,3 @@
"use client";
import { TitleCard, TitleCardSkeleton } from "@/components/title-card"; import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -1,9 +1,7 @@
"use client";
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react"; import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/tanstack"; import { orpc } from "@/lib/orpc/client";
import { FilterableTitleRow } from "./filterable-title-row"; import { FilterableTitleRow } from "./filterable-title-row";
import { HeroBanner } from "./hero-banner"; import { HeroBanner } from "./hero-banner";
import { TitleRow } from "./title-row"; import { TitleRow } from "./title-row";
@@ -1,11 +1,9 @@
"use client";
import { skipToken, useQuery } from "@tanstack/react-query"; import { skipToken, useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { TitleCard, TitleCardSkeleton } from "@/components/title-card"; import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { orpc } from "@/lib/orpc/tanstack"; import { orpc } from "@/lib/orpc/client";
interface Genre { interface Genre {
id: number; id: number;
@@ -1,5 +1,3 @@
"use client";
import { import {
IconDeviceTv, IconDeviceTv,
IconMovie, IconMovie,
@@ -7,11 +5,11 @@ import {
IconStar, IconStar,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import Image from "next/image";
import { useRouter } from "next/navigation"; import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useProgress } from "@/components/navigation-progress"; import { useProgress } from "@/components/navigation-progress";
import { orpc } from "@/lib/orpc/tanstack"; import { orpc } from "@/lib/orpc/client";
interface HeroBannerProps { interface HeroBannerProps {
tmdbId: number; tmdbId: number;
@@ -30,12 +28,12 @@ export function HeroBanner({
backdropPath, backdropPath,
voteAverage, voteAverage,
}: HeroBannerProps) { }: HeroBannerProps) {
const router = useRouter(); const navigate = useNavigate();
const progress = useProgress(); const progress = useProgress();
const resolveMutation = useMutation( const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({ orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => { onSuccess: ({ id }) => {
if (id) router.push(`/titles/${id}`); if (id) void navigate({ to: "/titles/$id", params: { id } });
else progress.done(); else progress.done();
}, },
onError: () => { onError: () => {
@@ -55,12 +53,12 @@ export function HeroBanner({
<div className="relative -mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)] animate-stagger-item overflow-hidden"> <div className="relative -mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)] animate-stagger-item overflow-hidden">
<div className="relative aspect-[21/9] max-h-[420px] min-h-[280px] w-full"> <div className="relative aspect-[21/9] max-h-[420px] min-h-[280px] w-full">
{backdropPath ? ( {backdropPath ? (
<Image <img
src={backdropPath} src={backdropPath}
alt={title} alt={title}
fill loading="eager"
priority decoding="async"
className="object-cover" className="absolute inset-0 h-full w-full object-cover"
/> />
) : ( ) : (
<div className="h-full w-full bg-gradient-to-br from-card via-secondary to-muted" /> <div className="h-full w-full bg-gradient-to-br from-card via-secondary to-muted" />
@@ -1,8 +1,5 @@
"use client"; import { Link } from "@tanstack/react-router";
import { motion } from "motion/react"; import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import { SofaLogo } from "@/components/sofa-logo"; import { SofaLogo } from "@/components/sofa-logo";
// Poster positions arranged in angled columns behind the hero // Poster positions arranged in angled columns behind the hero
@@ -63,11 +60,13 @@ export function LandingPage({
}} }}
> >
<div className="overflow-hidden rounded-xl shadow-lg"> <div className="overflow-hidden rounded-xl shadow-lg">
<Image <img
src={url} src={url}
alt="" alt=""
width={300} width={300}
height={450} height={450}
loading="lazy"
decoding="async"
className="h-auto w-full" className="h-auto w-full"
/> />
</div> </div>
@@ -147,7 +146,7 @@ export function LandingPage({
> >
{freshInstall ? ( {freshInstall ? (
<Link <Link
href="/register" to="/register"
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20" className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
> >
<span className="relative z-10">Get Started</span> <span className="relative z-10">Get Started</span>
@@ -156,7 +155,7 @@ export function LandingPage({
) : ( ) : (
<> <>
<Link <Link
href="/login" to="/login"
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20" className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-shadow hover:shadow-lg hover:shadow-primary/20"
> >
<span className="relative z-10">Sign In</span> <span className="relative z-10">Sign In</span>
@@ -164,7 +163,7 @@ export function LandingPage({
</Link> </Link>
{registrationOpen && ( {registrationOpen && (
<Link <Link
href="/register" to="/register"
className="inline-flex h-12 items-center justify-center rounded-lg border border-border px-8 font-medium transition-colors hover:border-primary/40 hover:bg-primary/5" className="inline-flex h-12 items-center justify-center rounded-lg border border-border px-8 font-medium transition-colors hover:border-primary/40 hover:bg-primary/5"
> >
Register Register
@@ -1,5 +1,3 @@
"use client";
import { import {
IconCompass, IconCompass,
IconHome, IconHome,
@@ -7,10 +5,9 @@ import {
IconSearch, IconSearch,
IconSettings, IconSettings,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { motion } from "motion/react"; import { motion } from "motion/react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useLayoutEffect, useRef, useState } from "react"; import { useLayoutEffect, useRef, useState } from "react";
import { SofaLogo } from "@/components/sofa-logo"; import { SofaLogo } from "@/components/sofa-logo";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -127,8 +124,8 @@ export function NavBar({
userImage?: string; userImage?: string;
userRole?: string; userRole?: string;
}) { }) {
const router = useRouter(); const navigate = useNavigate();
const pathname = usePathname(); const { pathname } = useLocation();
const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom); const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom);
const initial = userName?.charAt(0).toUpperCase() ?? "?"; const initial = userName?.charAt(0).toUpperCase() ?? "?";
@@ -150,7 +147,7 @@ export function NavBar({
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-5 pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] sm:gap-0 sm:pr-[max(1.5rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))]"> <div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-5 pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] sm:gap-0 sm:pr-[max(1.5rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))]">
<div className="flex items-center gap-3 sm:gap-6"> <div className="flex items-center gap-3 sm:gap-6">
<Link <Link
href="/dashboard" to="/dashboard"
className="shrink-0 text-foreground transition-colors hover:text-primary" className="shrink-0 text-foreground transition-colors hover:text-primary"
> >
<SofaLogo className="size-7" /> <SofaLogo className="size-7" />
@@ -168,7 +165,7 @@ export function NavBar({
ref={(el) => { ref={(el) => {
linkRefs.current[i] = el; linkRefs.current[i] = el;
}} }}
href={link.href} to={link.href}
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
className="relative inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40" className="relative inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
> >
@@ -248,7 +245,7 @@ export function NavBar({
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
render={<Link href="/settings" />} render={<Link to="/settings" />}
className="cursor-pointer text-[13px]" className="cursor-pointer text-[13px]"
> >
<IconSettings className="size-3.5" /> <IconSettings className="size-3.5" />
@@ -259,8 +256,7 @@ export function NavBar({
variant="destructive" variant="destructive"
onSelect={async () => { onSelect={async () => {
await signOut(); await signOut();
router.push("/"); void navigate({ to: "/" });
router.refresh();
}} }}
className="cursor-pointer text-[13px]" className="cursor-pointer text-[13px]"
> >
@@ -271,7 +267,7 @@ export function NavBar({
</DropdownMenu> </DropdownMenu>
{/* Mobile: simple avatar link to settings */} {/* Mobile: simple avatar link to settings */}
<Link <Link
href="/settings" to="/settings"
className="rounded-full ring-2 ring-transparent transition-all hover:ring-primary/40 sm:hidden" className="rounded-full ring-2 ring-transparent transition-all hover:ring-primary/40 sm:hidden"
aria-label="Settings" aria-label="Settings"
> >
@@ -289,7 +285,7 @@ export function NavBar({
} }
export function MobileTabBar() { export function MobileTabBar() {
const pathname = usePathname(); const { pathname } = useLocation();
const activeIndex = mobileTabs.findIndex((tab) => const activeIndex = mobileTabs.findIndex((tab) =>
isLinkActive(pathname, tab.href), isLinkActive(pathname, tab.href),
@@ -318,7 +314,7 @@ export function MobileTabBar() {
ref={(el) => { ref={(el) => {
tabRefs.current[i] = el; tabRefs.current[i] = el;
}} }}
href={tab.href} to={tab.href}
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
className="relative flex flex-1 flex-col items-center justify-center gap-0.5 focus-visible:text-foreground focus-visible:outline-none" className="relative flex flex-1 flex-col items-center justify-center gap-0.5 focus-visible:text-foreground focus-visible:outline-none"
> >
@@ -1,6 +1,4 @@
"use client"; import { useLocation } from "@tanstack/react-router";
import { usePathname, useSearchParams } from "next/navigation";
import { import {
createContext, createContext,
type ReactNode, type ReactNode,
@@ -25,8 +23,7 @@ function clamp(n: number, min: number, max: number) {
} }
export function ProgressProvider({ children }: { children: ReactNode }) { export function ProgressProvider({ children }: { children: ReactNode }) {
const pathname = usePathname(); const { pathname, searchStr } = useLocation();
const searchParams = useSearchParams();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
@@ -135,7 +132,7 @@ export function ProgressProvider({ children }: { children: ReactNode }) {
}, []); }, []);
// Finish on route change — routeKey is intentionally a dep to trigger on navigation // Finish on route change — routeKey is intentionally a dep to trigger on navigation
const routeKey = pathname + (searchParams?.toString() ?? ""); const routeKey = pathname + (searchStr ?? "");
const firstRenderRef = useRef(true); const firstRenderRef = useRef(true);
// biome-ignore lint/correctness/useExhaustiveDependencies: routeKey drives re-runs on route change // biome-ignore lint/correctness/useExhaustiveDependencies: routeKey drives re-runs on route change
useEffect(() => { useEffect(() => {
@@ -1,5 +1,4 @@
"use client"; import type { PersonCredit } from "@sofa/api/schemas";
import { IconMovie } from "@tabler/icons-react"; import { IconMovie } from "@tabler/icons-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { TitleCard } from "@/components/title-card"; import { TitleCard } from "@/components/title-card";
@@ -11,7 +10,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import type { PersonCredit } from "@/lib/orpc/schemas";
type Filter = "all" | "movie" | "tv"; type Filter = "all" | "movie" | "tv";
type Sort = "newest" | "rating"; type Sort = "newest" | "rating";
@@ -1,9 +1,7 @@
"use client"; import type { PersonCredit, ResolvedPerson } from "@sofa/api/schemas";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import type { PersonCredit, ResolvedPerson } from "@/lib/orpc/schemas"; import { orpc } from "@/lib/orpc/client";
import { orpc } from "@/lib/orpc/tanstack";
import { FilmographyGrid } from "./filmography-grid"; import { FilmographyGrid } from "./filmography-grid";
import { PersonHero } from "./person-hero"; import { PersonHero } from "./person-hero";
@@ -36,17 +34,10 @@ export interface PersonDetailResponse {
userStatuses: Record<string, "watchlist" | "in_progress" | "completed">; userStatuses: Record<string, "watchlist" | "in_progress" | "completed">;
} }
export function PersonDetailClient({ export function PersonDetailClient({ id }: { id: string }) {
id, const { data, isPending } = useQuery(
initialData, orpc.people.detail.queryOptions({ input: { id } }),
}: { );
id: string;
initialData?: PersonDetailResponse;
}) {
const { data, isPending } = useQuery({
...orpc.people.detail.queryOptions({ input: { id } }),
initialData,
});
if (isPending) return <PersonDetailSkeleton />; if (isPending) return <PersonDetailSkeleton />;
if (!data) return null; if (!data) return null;

Some files were not shown because too many files have changed in this diff Show More