* 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>
14 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Commands
# Root commands (via Turborepo)
bun run dev # Start API server + Vite dev server
bun run build # Production build (both apps)
bun run lint # Biome lint check
bun run format # Biome format (auto-fix)
bun run check-types # TypeScript type check
bun run test # Run tests
# 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:
- Use
bun <file>instead ofnode <file>orts-node <file> - Use
bun testinstead ofjestorvitest - Use
bun build <file.html|file.ts|file.css>instead ofwebpackoresbuild - Use
bun installinstead ofnpm installoryarn installorpnpm install - Use
bun run <script>instead ofnpm run <script>oryarn run <script>orpnpm run <script> - Use
bunx <package> <command>instead ofnpx <package> <command> - Bun automatically loads .env, so don't use dotenv.
For more information, read the Bun API docs in node_modules/bun-types/docs/**.mdx.
Pre-commit checks
Before committing, all three of these must pass with zero warnings or errors:
bun run lint
bun run check-types
bun run test
Architecture
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
- 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) - Auth: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO via
genericOAuthplugin - 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) — self-hosted via
@fontsource - API: oRPC (contract-first, type-safe RPC) with
@orpc/tanstack-queryfor TanStack Query integration - State: Jotai (client), TanStack Query (data fetching & mutations via oRPC)
- Linting: Biome (2-space indent, organized imports, React recommended rules)
- External API: TMDB (The Movie Database) with Bearer token auth
Package imports
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
API server (apps/server/src/):
index.ts— Hono app entry point, startup, CORS, route mounting, graceful shutdowncron.ts— Background job scheduler (croner)orpc/— oRPC server layer (context, middleware, router, handler, procedures/)routes/— Non-RPC Hono routes (auth, images, avatars, backups, webhooks, lists, health)
Web app (apps/web/src/):
main.tsx— React root: creates router + renders<RouterProvider />routes/— TanStack Router file-based routes (auto-generatesrouteTree.gen.ts)__root.tsx— Root layout (providers, head meta, global error/not-found)_app.tsx— Authenticated layout (auth guard viabeforeLoad, navbar, shell)_app/dashboard.tsx,_app/explore.tsx,_app/settings.tsx,_app/titles.$id.tsx,_app/people.$id.tsx_auth.tsx— Auth layout (centering wrapper)_auth/login.tsx,_auth/register.tsxindex.tsx,setup.tsx
components/— App components +components/ui/for shadcn primitiveslib/orpc/client.ts— oRPC client (always same-origin via Vite proxy or Hono static serving)lib/orpc/tanstack.ts—orpcutils for TanStack Querylib/auth/client.ts— Better Auth client hookslib/theme.ts— Color theme CSS properties from title palettesstyles/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 typespackages/db/src/schema.ts— All Drizzle table definitionspackages/db/drizzle/— Migration filespackages/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
Web app — Route beforeLoad guards check session via Better Auth client SDK:
// In route file (e.g. _app.tsx)
beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) throw redirect({ to: "/login" });
return { session };
},
Session is available to child routes via Route.useRouteContext().
API server — oRPC procedures use auth middleware that calls Better Auth:
// apps/server/src/orpc/middleware.ts
export const authed = base.middleware(async ({ context, next }) => {
const session = await auth.api.getSession({ headers: context.headers });
if (!session) throw new ORPCError("UNAUTHORIZED");
return next({ context: { user: session.user, session: session.session } });
});
oRPC API layer
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:
import type { ResolvedTitle, Season } from "@sofa/api/schemas";
import { contract } from "@sofa/api/contract";
Pattern — query in component:
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/client";
const { data } = useQuery(orpc.dashboard.stats.queryOptions());
Pattern — mutation:
import { client } from "@/lib/orpc/client";
await client.titles.updateStatus({ id: titleId, status: "in_progress" });
Pattern — route loader with TanStack Query prefetch:
export const Route = createFileRoute("/_app/titles/$id")({
loader: async ({ params, context }) => {
await context.queryClient.ensureQueryData(
orpc.titles.detail.queryOptions({ input: { id: params.id } }),
);
},
component: TitlePage,
});
Page patterns:
- Route loaders (titles, people):
beforeLoad/loaderprefetch data into TanStack Query cache viaqueryClient.ensureQueryData(). Components read viauseQuery()/useSuspenseQuery(). - 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 (API server)
Hono route handlers in apps/server/src/routes/:
/images/:category/:filename— Image proxy/cache serving/api/auth/*— Better Auth catch-all/api/avatars/:userId— Avatar file serving/api/backup/:filename— Backup file download/api/webhooks/:token— Plex/Jellyfin/Emby webhooks/api/lists/:token— External list feeds (Sonarr/Radarr)/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
All app tables use UUIDv7 text primary keys generated via Bun.randomUUIDv7(). Better Auth tables use their own ID format. Key relationships:
titles→seasons→episodes(TV hierarchy)persons+titleCast(cast/crew linked to titles)userTitleStatuslinks users to titles with status (watchlist/in_progress/completed)userMovieWatches/userEpisodeWatchestrack watch history (source: manual/import/plex/jellyfin/emby)userRatingsstores 1-5 star ratingsavailabilityOfferscaches streaming provider data per titletitleRecommendationsstores TMDB recommendations and similar titleswebhookConnections/webhookEventLog— Plex/Jellyfin/Emby webhook integrationscronRuns— Background job execution historyappSettings— Key-value store for runtime app configuration
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. - 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 setsin_progress; all episodes watched auto-completes the series. - discovery.ts: Feed generators — continue watching, library with availability, personalized recommendations.
- availability.ts: Caches US streaming providers from TMDB watch/providers endpoint.
- credits.ts: Fetches and caches cast/crew data from TMDB.
- person.ts: Person detail and filmography lookups.
- webhooks.ts: Processes incoming Plex/Jellyfin/Emby webhook events.
- backup.ts: Database backup/restore with configurable scheduled backups.
- settings.ts: Runtime app settings via
appSettingstable. - colors.ts: Extracts dominant color palettes from title posters via node-vibrant.
- update-check.ts: Checks for new Sofa releases.
- system-health.ts: System health diagnostics.
TMDB images
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 and served via /images/:category/:filename. When disabled, tmdbImageUrl() returns direct TMDB CDN URLs.
Background jobs
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):
nightlyRefreshLibrary(0 3 * * *) — refreshes stale library titles (7d) and non-library titles (30d)refreshAvailability(0 */6 * * *) — streaming provider datarefreshRecommendations(0 */12 * * *)refreshTvChildren(30 */12 * * *) — episodes for returning/in-production TV showscacheImages(0 1,13 * * *) — posters, backdrops, stills, logos, profile photosrefreshCredits(0 2 * * *) — cast/crew data for library titlesscheduledBackup(configurable via settings) — database backups with retention pruningupdateCheck(0 */6 * * *) — checks for new Sofa releases
Environment variables
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).
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
Use agent-browser for web automation. Run agent-browser --help for all commands.
Core workflow:
agent-browser open <url>- Navigate to pageagent-browser snapshot -i- Get interactive elements with refs (@e1, @e2)agent-browser click @e1/fill @e2 "text"- Interact using refs- Re-snapshot after page changes
When encountering auth, use demo@sofa.watch for the username/email and password for the password.