This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Follow the contribution policy for AI agents in @AGENTS.md.
Tunarr is a live TV channel creation platform that allows users to create custom TV channels using media from local files or remote media servers such as Plex, Jellyfin, or Emby servers. It's a TypeScript monorepo using pnpm workspaces and Turbo for task running.
This is a monorepo with four main packages:
- server (
@tunarr/server): Node.js backend (Fastify server, SQLite database) - web (
@tunarr/web): React frontend (Vite + Material-UI) - types (
@tunarr/types): Shared TypeScript types and Zod schemas - shared (
@tunarr/shared): Utility functions shared between server and web
- Never cast types using
as any - Never use inline
import()type annotations — use top-levelimport typestatements instead - All edits must comply with the project's oxlint rules. Run
pnpm lint-changedto verify before considering work complete - Nullability checks: For values typed
T | undefined(notT | null), always use!== undefined— never!= null. The loose equality!= nullcatches bothnullandundefined, which is misleading whennullis not a possible value. Reserve!= nullfor values that are genuinelyT | null | undefined. - No unguarded non-null assertions (
!): Never use!to assert a value is non-null/non-undefined without first narrowing its type via a conditional check, early return, or explicit throw. If a value is optional, either guard it with anifcheck that narrows the type, or throw an explicit error explaining the invariant. The!operator silently crashes at runtime when the assumption is wrong.
- Use pnpm (v10.28.0) for all dependency management - never use npm or yarn
- Use Turbo for running tasks across the monorepo
- Node.js version: 22 (specified in package.json engines)
- Releases use CalVer (
YYYY.M.PATCH), so the version says when a release shipped, not what changed. - The
mainbranch creates "stable" releases. It receives fixes, chores, build/CI changes, docs, refactors, and small to medium features, including those with database migrations. - The
devbranch creates prereleases. It is reserved for large features that need many prerelease iterations before they reach stable, such as infinite schedules or remote streaming sources.
- Write all plans, designs, and audits to
plans/at the repo root. Never use.claude/plans/. - Write issue investigations to
plans/investigations/, namedissue-<n>-<slug>-YYYY-MM-DD.md. These report whether a bug is real and why. A design that follows from one goes inplans/. - Name files
<topic>.md, adding a date suffix (-YYYY-MM-DD) for point-in-time reports. .claude/settings.jsonsetsplansDirectorytoplans, so plan-mode plans land there too.
The maintainer cannot approve their own PRs on GitHub, so review agents record their verdict with labels.
| Label | Meaning |
|---|---|
approved |
Reviewed and ready to merge |
changes requested |
Review found issues; the PR needs another pass |
- A reviewer sets exactly one of these labels and removes the other.
- A reviewer also leaves a comment with the findings. The label carries the verdict and the comment carries the reasons.
- Whoever pushes new commits to an
approvedPR removes the label, because nobody has reviewed the new code. - An author who addresses the findings removes
changes requestedso the PR returns to review. - Only merge a PR labeled
approved. - Commands:
gh pr edit <n> --add-label approved --remove-label "changes requested"and the reverse.
- Build and typecheck through Turbo (
pnpm turbo build,pnpm turbo typecheck). Turbo rebuilds the packages a package depends on first. - To check a single package directly, run its own script (
pnpm typecheck/pnpm buildinside the package). These usetsc -p tsconfig.build.json. Never run a baretscwithout-p tsconfig.build.json, because the defaulttsconfig.jsonpulls in test files the build excludes.
# Install dependencies
pnpm i
# Start all dev servers (backend on :8000, frontend on :5173/web)
pnpm turbo dev
# Build all packages
pnpm turbo build
# Run tests across all packages
pnpm turbo test
# Run single test file
cd server && pnpm test path/to/test.ts
# Format code
pnpm fmt
# Lint changed files only
pnpm lint-changed
# Run the typechecker
pnpm turbo typecheck
# Build the working tree into a Docker image and run it (see docker/test/)
pnpm docker-test
# Server-specific commands
cd server
pnpm dev # Start server with tsx watch
pnpm debug # Start server with debugger
pnpm kysely # Run Kysely CLI for database operations
pnpm tunarr # Run CLI commands (see src/cli/commands.ts)
pnpm generate-openapi # Generate OpenAPI spec
pnpm resolve-migrations # Regenerate this branch's migration after a merge/rebase conflict
# Web-specific commands
cd web
pnpm dev # Start Vite dev server
pnpm bundle # Build production bundle
pnpm generate-client # Generate API client from OpenAPI spec
pnpm regen-routes # Regenerate TanStack Router routesFramework & Dependencies:
- Fastify for HTTP server with Zod-based type provider
- Inversify for dependency injection (IoC container in
container.ts) - Kysely + Drizzle ORM for database access (dual ORM approach, migrating to Drizzle)
- better-sqlite3 for SQLite database
- Meilisearch for search functionality
- Zod for schema validation
Key Directories:
api/- Fastify route handlers organized by domain (channels, plex, jellyfin, etc.)db/- Database layer with both Kysely and Drizzle implementationsschema/- Database schema definitionsinterfaces/- DB interface abstractionsChannelDB.ts,ProgramDB.ts- Main database access classes
services/- Business logic layer (event handling, scheduling, media source integration)scanner/- Media scanning functionalityscheduling/- Channel scheduling logicstartup/- Startup tasks
stream/- Video streaming pipeline (HLS, concat streams, FFmpeg integration)- Organized by session type (
hls/,ConcatSession); source-specific logic lives in stream details fetchers andplugins/
- Organized by session type (
ffmpeg/- FFmpeg wrapper and pipeline builderexternal/- External API clients for Plex, Jellyfin, Embytasks/- Background tasks and fixerscli/- CLI commands (accessible viapnpm tunarr <command>)
Dependency Injection: The server uses Inversify for DI. Service registration happens in module files:
container.ts- Root container and module registrationDBModule.ts- Database servicesServicesModule.ts- Business servicesStreamModule.ts- Streaming servicesFFmpegModule.ts- FFmpeg servicesTasksModule.ts- Bindings for tasks
Other modules are files ending with Module.ts
To inject dependencies, use the @injectable() decorator and constructor injection with @inject(KEYS.ServiceName). Keys are defined in types/inject.ts.
Database: The codebase uses both Kysely (legacy) and Drizzle ORM (current/future):
- Schema is defined in
db/schema/using Drizzle - Most queries still use Kysely but new code should use Drizzle
- Access via
DBAccesswhich provides bothdb(Kysely) anddrizzle(Drizzle) instances - Database migrations are in
migration/
Framework & Libraries:
- React 18 with TypeScript
- Vite for bundling and dev server
- Material-UI (MUI) v7 for components
- TanStack Router for routing (file-based routes in
routes/) - TanStack Query for data fetching and caching
- Zustand for global state management
- React Hook Form for form handling
- Zod for form/data validation
Key Directories:
routes/- File-based routing (generatesrouteTree.gen.ts)components/- Reusable React componentshooks/- Custom React hooksstore/- Zustand state storesgenerated/- Auto-generated API client codepages/- Legacy page components (being migrated to routes/)
API Integration:
The web app uses a generated API client (generated/) created from the server's OpenAPI spec. Regenerate with pnpm generate-client when server API changes.
@tunarr/types:
- Type definitions and Zod schemas shared between server and web
- Organized by domain:
plex/,jellyfin/,emby/,api/,schemas/ - Build with
pnpm buildto generate.d.tsand.jsfiles
@tunarr/shared:
- Utility functions for both server and web
- Notable: Custom DSL parser for scheduling/filtering (uses Chevrotain)
- Every code change ships with unit tests. A bug fix adds a test that fails without the fix. If the logic sits in a private method or behind heavy dependencies, move it into a small exported function and test that.
- Vitest is used for testing across all packages
- Test files use
.test.tsextension - Run tests with
pnpm turbo testorpnpm test:watchfor watch mode - Server tests often use
@faker-js/fakerfor test data generation
- Playwright suite that visits every web route against a seeded server. Local only; CI does not run it yet.
- Run with
cd e2e && pnpm e2e. First time only:pnpm e2e:install-browsers. Needsffmpegon the PATH and the Meilisearch binary (cd server && pnpm install-meilisearch). - Uses its own ports (server 18000, search 17700, web 15173), so it can run beside
pnpm turbo dev. - The fixture is rebuilt on every run: generated test clips, three local media sources, three channels, a custom show, a filler list, and a smart collection (
e2e/fixture/seed.ts). - A route fails on uncaught exceptions,
console.erroroutput (including React warnings), 5xx API responses, or the error page. - New routes must be added to
e2e/tests/routes.ts; a coverage test compares it againstrouteTree.gen.ts. - Pre-existing warnings are listed per route in
e2e/tests/knownIssues.ts. Remove an entry when its cause is fixed.
- Prettier for formatting (config in root
package.json) - oxlint for linting (config in
.oxlintrc.jsonat the root, extended per package) - Husky + lint-staged for pre-commit hooks
- Import aliases: Use
@/for server, web has configured path aliases
- Always update relevant documentation.
- Double-check for unintended side-effects of the change
- Prefer Drizzle ORM for new queries over Kysely
- Both ORMs access the same SQLite database via
DBAccess - Database interfaces are in
db/interfaces/- implement these for new DB access classes - Use dependency injection to get database instances
- All database schema changes should have migrations generated via
pnpm drizzle-kit generate
- Time slots and random slots are being deprecated in favor of infinite schedules (
architecture/infinite-schedules/README.md, "Deprecation plan"). - A PR that adds or changes classic slot-scheduler behavior must add a row to
architecture/infinite-schedules/parity-checklist.md.
- Streaming logic is complex - see
stream/directory - Sessions are managed by
SessionManager - Different stream types:
VideoStream,ConcatSession,DirectStreamSession - FFmpeg pipeline is built using builder pattern in
ffmpeg/builder/
- External API clients are in
external/(Plex, Jellyfin, Emby) - Canonicalization logic converts external media to Tunarr's internal format
- Media scanning is handled by scanner services in
services/scanner/ - Media source libraries are refreshed via
MediaSourceLibraryRefresher
- Define route in
server/src/api/{domain}Api.ts - Use Fastify + Zod type provider for type-safe requests/responses
- Register in
api/index.ts - Regenerate OpenAPI spec:
cd server && pnpm generate-openapi - Regenerate web client:
cd web && pnpm generate-client
- NFO parsing is in
server/src/nfo/ - Different parsers for different media types (TV shows, movies, etc.)
- Uses Zod schemas defined in
NfoSchemas.ts
- Server builds to single executable using
@yao-pkg/pkg - Web builds static assets with Vite
- macOS app bundle support in
macos/directory - Docker images available (
chrisbenincasa/tunarr)