* Replace server actions with API routes and migrate SWR to TanStack Query - Add 33 new REST API routes covering titles, episodes, seasons, people, dashboard, explore, integrations, admin, backups, and account operations - Replace SWR with @tanstack/react-query: add QueryProvider, api-client helper, query-client singleton, and TanStack Query hooks for titles - Migrate all 26 server actions to fetch calls against the new API routes in 10 client components (use-title-actions, title-card, command-palette, hero-banner, integration-card, account/backup/registration/update-check settings sections) - Delete lib/actions/ directory (titles.ts, watchlist.ts, people.ts, settings.ts) and lib/swr/fetcher.ts https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb * Convert pages to TanStack Query client-side data fetching Migrate dashboard, explore, settings, and people pages from server component data fetching to client-side TanStack Query hooks. Create query hook files for each domain (dashboard, explore, people, admin, integrations). Add GET routes for admin/registration and admin/update-check. Update CLAUDE.md architecture docs to reflect TanStack Query patterns. https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb * Migrate API layer from REST routes to oRPC with TanStack Query Replace ~35 hand-written REST API routes and manual TanStack Query hooks with a contract-first oRPC setup that provides end-to-end type safety from contract → server procedures → client → TanStack Query. - Add oRPC foundation: contract, context, auth/admin middleware, RPCHandler, client (browser + SSR via globalThis), TanStack Query utils - Implement ~30 procedures across 14 files (titles, episodes, seasons, people, dashboard, explore, search, discover, stats, status, integrations, admin, account, watchlist) - Migrate all client components from api()/lib/queries/ to orpc.*.queryOptions() and client.*.method() calls - Delete all replaced REST API routes, lib/queries/, lib/api-client.ts, and old hooks (use-discover, use-search, use-stats, use-system-health) - Wire up SSR optimization via instrumentation.ts - Keep non-RPC routes: auth, images, avatars, backup restore/download, webhooks, lists, health https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb * Add strict Zod output schemas to all oRPC contract procedures Replaces the permissive z.any() output schemas with precise Zod schemas for every procedure that returns data. This makes the contract fully self-documenting and enables runtime output validation. - Define ~30 output schemas in schemas.ts covering titles, people, dashboard, explore, search, discover, stats, integrations, admin, etc. - Wire all output schemas into contract.ts - Fix procedure return type mismatches (nullable fields, enum narrowing) - Align consumer types (SearchResult, IntegrationConnection) with contract https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb * Delete lib/types.ts — infer all types from Zod schemas The oRPC Zod schemas are now the single source of truth for domain types. All 17 consumer files (services, components, atoms, utils) now import inferred types (Episode, Season, ResolvedTitle, CastMember, etc.) from lib/orpc/schemas instead of hand-written interfaces in lib/types.ts. https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb * Migrate all mutations to TanStack Query useMutation Replace raw oRPC client calls with useMutation/mutateAsync via orpc.*.mutationOptions() for consistent mutation tracking across all components: title-card, command-palette, account, system-health, integrations, backups, backup-schedule, and title-actions. https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb * Fix bugs in settings/explore and bump dependencies - Deduplicate backup list on create (filter by filename before prepend) - Add isError state to backup-schedule section with fallback UI - Add error check for avatar DELETE response before clearing state - Strengthen backup restore file guard with `instanceof File` - Change explore trending default type from "movie" to "all" - Pin all oRPC and TanStack Query packages to exact versions - Bump jotai 2.18.0→2.18.1, motion 12.35.1→12.35.2, shadcn 4.0.0→4.0.2, @types/node 25.3.5→25.4.0, semver 6.3.1→7.7.4 * Migrate title recommendations to client-side TanStack Query - Convert TitleRecommendations from server component with `"use cache"` to a client component using `orpc.titles.recommendations.queryOptions()` with an inline skeleton loading state - Delete recommendations-grid.tsx — merged into title-recommendations.tsx - Remove RecommendationsSkeleton from skeletons.tsx (now inline) - Drop Suspense wrapper in title detail page (client component handles its own loading state) - Include userStatuses in recommendations procedure output so the component no longer needs a separate session/tracking lookup - Remove `revalidate` option and all `updateTag`/`cacheTag`/`cacheLife` calls from refreshCredits and refreshRecommendations — no longer needed without "use cache" server components - Remove `cacheComponents: true` from next.config.ts * Replace Jotai title atoms with React Context + TanStack Query cache - Add title-context.tsx with TitleContext and useTitleContext / useTitleUserInfo hooks; useTitleUserInfo reads from orpc.titles.userInfo cache instead of atoms - Rewrite TitleProvider to seed the userInfo cache via queryClient.setQueryData on mount (unconditional overwrite prevents stale data on revisit / account switch) and provide titleId, titleType, titleName, seasons, and watchingEp via context - Rewrite use-title-actions to read/write userInfo via queryClient.getQueryData / setQueryData helpers instead of useStore + individual atoms; optimistic updates and rollbacks now operate on a single UserInfo object in the query cache - Migrate title-actions, title-keyboard-shortcuts, and title-seasons off useAtomValue / useSetAtom to the new context hooks - Delete per-title atoms from lib/atoms/title.ts * Migrate avatar and backup restore routes to oRPC procedures - Add `account.uploadAvatar` and `account.removeAvatar` procedures (FormData file input, auth middleware) replacing `PUT`/`DELETE` `/api/account/avatar` route - Add `admin.backups.restore` procedure (File input, admin middleware) replacing `POST /api/admin/backups/restore` route - Delete both REST route files - Migrate account-section and backup-restore-section off `useTransition` + raw `fetch` to `useMutation` via `orpc.*.mutationOptions()` - Rename `lib/utils/title-theme.ts` → `lib/theme.ts` and `getTitleThemeStyle` → `getThemeCssProperties`; update title detail page import accordingly * Add OpenAPI v1 REST API endpoint via @orpc/openapi - Add `@orpc/openapi` and `@orpc/json-schema` dependencies - Add `lib/orpc/openapi-handler.ts` with OpenAPIHandler instance - Add `app/api/v1/[[...rest]]/route.ts` catch-all serving GET/POST/PUT/DELETE at `/api/v1` with request-headers context - Annotate contract procedures with OpenAPI metadata (tags, summaries, descriptions, and successStatus codes) across all ~30 procedures * Fix 8 bugs: memory safety, data races, and correctness issues - Move queryClient.setQueryData from useState initializer to useEffect to avoid mutating the query cache during React render phase - Add SQL LIMIT to per-integration event queries instead of loading all events and trimming in memory - Lower backup restore upload limit from 500 MB to 100 MB and stream upload to disk instead of buffering entirely in memory - Reorder avatar upload to write new file before deleting old one so a failed upload doesn't remove the current avatar - Make optimistic rollback field-specific so a failed rating update doesn't erase concurrent status or episode-watch changes - Gate titles.userInfo query behind session state to prevent failing RPC calls for anonymous users - Derive OpenAPI session cookie name from BETTER_AUTH_URL so HTTPS deployments use the correct __Secure- prefixed cookie Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add explicit z.void() output schemas to all 17 void procedures Ensures the OpenAPI spec correctly generates 204 No Content responses for mutation endpoints that don't return data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
12 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Commands
bun run dev # Start Next.js dev server
bun run build # Production build
bun run lint # Biome lint check
bun run format # Biome format (auto-fix)
bun run db:push # Push schema changes to SQLite database
bun run db:generate # Generate Drizzle migration files
bun run db:migrate # Run Drizzle migrations
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 single Next.js 16 application with SQLite.
Stack
- Framework: Next.js 16 (App Router), React 19, TypeScript
- 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, shadcn components, dark cinema theme with warm primary accents
- Fonts: DM Serif Display (display), DM Sans (body), Geist Mono (mono)
- 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/Next.js recommended rules)
- External API: TMDB (The Movie Database) with Bearer token auth
Path alias
@/* maps to project root (./), e.g. @/lib/db/client → ./lib/db/client.
Key directories
lib/db/schema.ts— Single file with all Drizzle table definitions (auth tables + app tables)lib/db/migrate.ts— Auto-migration runner (executed on startup via instrumentation)lib/services/— Business logic layer (metadata, tracking, discovery, availability, credits, person, webhooks, backup, settings, colors, update-check, system-health)lib/tmdb/— TMDB API client and TypeScript typeslib/auth/— Better Auth server config (server.ts), client hooks (client.ts), cached session helper (session.ts)lib/config.ts— Server-side environment checks (TMDB configured, OIDC configured, etc.)lib/logger.ts— Structured logger withLOG_LEVELsupportlib/types/— Shared TypeScript types (e.g.title.ts)lib/cron.ts— Background job scheduler (croner,globalThissingleton)lib/query-client.ts— SingletonQueryClientwith default options (30s stale time)lib/orpc/— oRPC API layer (contract-first, type-safe RPC):contract.ts— Full API contract definition (~30 procedures)schemas.ts— Shared Zod input schemas used by contractcontext.ts— Context type &osimplementermiddleware.ts— Auth (authed) and admin (admin) middlewarerouter.ts— Assembled router implementing the contracthandler.ts— RPCHandler instance with error loggingclient.ts— Client-side oRPC client (RPCLink)client.server.ts— Server-side oRPC client (zero HTTP overhead for SSR)tanstack.ts—orpcutils 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 oRPCapp/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 primitivescomponents/query-provider.tsx—QueryClientProviderwrapper used in root layout
Auth pattern
Server components and layouts use the cached session helper to avoid redundant lookups:
import { getSession } from "@/lib/auth/session";
const session = await getSession();
oRPC procedures use auth middleware that calls Better Auth:
// lib/orpc/middleware.ts — applied to procedures
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 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.
Pattern — query in component:
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc/tanstack";
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 — procedure definition:
// lib/orpc/procedures/dashboard.ts
export const stats = os.dashboard.stats.func(async (input, context, meta) => {
const { user } = context;
return getUserStats(user.id);
}, authed);
Page patterns:
- Thin server shell (titles, people): Server component fetches initial data via service layer, passes as
initialDataprop to client component that uses TanStack Query viaorpcfor refetching/mutations. - Full client-side (dashboard, explore, settings): Server component handles auth only; client components fetch all data via
orpc.*.queryOptions()with skeleton loading states.
Non-RPC routes
Only file-serving, auth, and webhook routes remain as traditional Next.js API routes:
/api/auth/[...all]— Better Auth catch-all/api/images/[...path]— Image proxy/cache serving/api/avatars/[userId]— Avatar file serving/api/account/avatar— Avatar upload (FormData)/api/admin/backups/restore— Backup restore (FormData upload)/api/backup/[filename]— Backup file download/api/webhooks/[token]— Plex/Jellyfin/Emby webhooks/api/lists/[token]— External list feeds/api/health— Simple health check (no auth)
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
- 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. - 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 (next unwatched episode per in-progress show), library titles with availability, personalized recommendations from completed/highly-rated titles.
- 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. - person.ts: Person detail and filmography lookups.
- webhooks.ts: Processes incoming Plex/Jellyfin/Emby webhook events to auto-log watches.
- backup.ts: Database backup/restore with configurable scheduled backups and retention pruning.
- settings.ts: Runtime app settings (registration open/closed, backup config, etc.) 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 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.
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).
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
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.
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
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.
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).
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.