CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Strum VOD is a self-hosted VOD (Video on Demand) platform. It handles video upload (standard presigned-URL or resumable TUS), transcoding to a multi-quality HLS ladder (360p–4320p), and playback delivery via Cloudflare R2 (both local dev and production).
Package Manager
This project uses pnpm with workspaces. Always use pnpm commands, not npm.
Commands
Cloudflare (wrangler — primary deploy path)
# First-time setup
cp .env.example .env
pnpm install
# R2 buckets + CORS (see infra/r2/)
pnpm r2:list # list buckets
pnpm r2:cors:list # show CORS rules on the strum-vod bucket
pnpm r2:cors:set # apply infra/r2/strum-vod-cors.json
pnpm r2:cors:delete # clear CORS rules
# Frontends
wrangler deploy --config apps/dashboard/wrangler.toml
pnpm deploy:player # player app (wrangler pages)Per-app development (requires local PostgreSQL, Redis, and R2 credentials in .env)
pnpm run build # build all workspaces
pnpm run typecheck # typecheck all workspaces
# Start everything at once (uses concurrently, color-coded output per process)
pnpm run dev
# Or start subsets:
pnpm run dev:web # dashboard + player only (frontend-only work)
pnpm run dev:api # API only
pnpm run dev:worker # Worker only (Node: bridge + AI + analytics)
pnpm run dev:transcoder # Transcoder only (Go: ffmpeg ladder — needs local Go toolchain)
pnpm run dev:dashboard # Dashboard only
pnpm run dev:player # Player app only
# Individual builds
pnpm run build -w @strum-vod/db # must run before api/worker
pnpm run build -w @strum-vod/api
pnpm run build -w @strum-vod/worker
pnpm run build -w @strum-vod/dashboard
pnpm run build:player
pnpm run build:transcoder # apps/transcoder (Go) — needs Go 1.23+ toolchain, not a pnpm workspaceDocs site (VitePress)
pnpm docs:dev # dev server with hot reload (default: http://localhost:5173)
pnpm docs:build # static build → docs/.vitepress/dist
pnpm docs:preview # serve the built site (default: http://localhost:4173)
pnpm deploy:docs # build + wrangler pages deploy → Cloudflare Pages project strum-vod-docsTesting
pnpm test:e2e # run API E2E suite (requires Docker)
pnpm --filter @strum-vod/api run test:e2e # same, scoped to the api packageE2E tests live in apps/api/src/tests/e2e/ and run with Vitest via vitest.config.e2e.ts. They use Testcontainers to start real PostgreSQL 16 and Redis 7 Docker containers automatically for the run — no manual DB setup, no mocked database layer. S3 is mocked with vi.mock. Docker must be running.
| File | Role |
|---|---|
src/tests/globalSetup.ts | Starts PostgreSQL + Redis containers once; sets all required process.env vars |
src/tests/setupFiles.ts | Mocks @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner |
src/tests/helpers/server.ts | createTestApp() — runs migrations, returns a ready Fastify instance |
src/tests/helpers/auth.ts | signUp() / bearer() for test authentication |
src/tests/helpers/fixtures.ts | createAsset() fixture |
src/tests/e2e/health.test.ts | /health/* and /v1/config |
src/tests/e2e/auth.test.ts | Signup, login, /v1/auth/me |
src/tests/e2e/assets.test.ts | Asset CRUD, upload flow, import, process, org isolation |
src/tests/e2e/playback.test.ts | Playback, comments, reactions |
pool: 'forks' ensures forked test workers inherit the env vars set by globalSetup. fileParallelism: false keeps test files sequential so they share the same containers safely.
apps/worker's E2E suite is fully dockerized via Testcontainers — same pattern as apps/api's (Postgres + Redis), plus a real MinIO container standing in for R2 so S3 upload/download isn't mocked. No real credentials, nothing shared/production touched, Docker must be running:
pnpm --filter @strum-vod/worker run test:e2esrc/tests/globalSetup.ts— starts Postgres + Redis + MinIO containers once, creates the MinIO test bucket, bootstraps the schema (helpers/schema.ts), and setsDATABASE_URL/REDIS_URL/S3_*. Also generates a run-unique id and namespaces every BullMQ queue name and Redis Stream key the worker touches (TRANSCODE_QUEUE_NAME,AI_PROCESS_QUEUE_NAME,WEBHOOK_QUEUE_NAME,TRANSCODE_STREAM_KEY,AI_DISPATCH_STREAM_KEY,WEBHOOK_DISPATCH_STREAM_KEY) — belt-and-suspenders isolation even though the containers are already throwaway.src/tests/helpers/schema.ts— minimalCREATE TABLEbootstrap for the tablesapps/workertouches (assets/jobs/renditions/ai_jobs/highlights), hand-mirrored fromapps/api/src/db.ts'srunMigrations()(the real migration source of truth) rather than imported from it, to avoid coupling this package's tests toapps/api's own env validation (JWT_SECRET,SHARED_AUTH_SECRET, etc.).settingsis deliberately not created —ai-worker.ts's settings lookup already treats a missing table as "fall back to env vars," so omitting it exercises that path for real.src/tests/helpers/db.ts/s3.ts— fixture helpers against the containers (insertTestAsset/deleteTestAssetcascade-delete via the real FK constraints;putTestObject/deleteTestObjectclean up after themselves inafterAll).src/tests/e2e/streams.test.ts— the generic Streams consumer-group helper (streams.ts): message delivery + ack, andXCLAIM-based reclaim of a message left pending by a simulated crashed consumer.src/tests/e2e/bridge.test.ts— all three bridge flows (bridge.ts): BullMQtranscode→ stream, stream → BullMQai-process, stream → BullMQwebhook-delivery.src/tests/e2e/ai-worker.test.ts— the fullai-processpipeline (ai-worker.ts) withseedFakeAi(no real AI vendor calls, seeai/providers/fake-provider.ts): downloads the pre-extracted audio from MinIO, runsprocessAi, asserts theai_jobsrow.src/tests/e2e/full-pipeline.test.ts— the real thing, no shortcuts: builds the actualapps/transcoderGo binary (helpers/transcoderProcess.ts, requires a local Go toolchain + ffmpeg/ffprobe on PATH, same aspnpm dev:transcoder) and runs it as a child process against the same Testcontainers Postgres/Redis/MinIO (their ports are published to the host, so no Docker-network wiring needed). A tiny real video is synthesized with ffmpeg lavfi test sources (helpers/testVideo.ts— no fixture file to maintain) and pushed through the full chain: BullMQtranscode→ bridge →go:transcode:jobs→ real ffmpeg HLS ladder encode → real MinIO upload →go:ai:dispatch→ bridge →ai-process→ai-worker.ts(fake AI). Asserts a real rendition row, real HLS/thumbnail/master-playlist objects in MinIO, and a completedai_jobsrow. ~90s timeout — building the Go binary + a real (if tiny) x264 encode aren't instant.
apps/api's node-agent full-pipeline E2E (pnpm --filter @strum-vod/api run test:e2e:node-agent) — the self-hosted Nodes feature's equivalent of apps/worker's full-pipeline.test.ts above, but exercising apps/transcoder/cmd/node-agent (the BYO-compute worker a user runs on their own machine) instead of the platform's own apps/transcoder binary. Separate config from apps/api's main E2E suite (vitest.config.e2e-node-agent.ts, own src/tests/globalSetupNodeAgent.ts) because the main suite mocks the AWS SDK entirely (setupFiles.ts) for speed, while this one needs a real S3-compatible endpoint — the real node-agent binary downloads its source and uploads renditions via real presigned URLs, which only work against a real server. Requires the same local Go toolchain + ffmpeg/ffprobe on PATH as the worker's full-pipeline test, plus Docker running.
src/tests/globalSetupNodeAgent.ts— same Postgres + Redis + MinIO Testcontainers recipe asapps/worker'sglobalSetup.ts, but setsapps/api's own env vars (JWT_SECRET,S3_PUBLIC_BASE_URL, etc.) rather than the worker's.src/tests/helpers/nodeAgentProcess.ts/testVideo.ts— build + spawn the realcmd/node-agentbinary (--hwaccel disabledfor a deterministic CPU path in CI) and synthesize a tiny test video, mirroringapps/worker'stranscoderProcess.ts/testVideo.ts(duplicated rather than imported — separate packages, deliberately trivial).src/tests/e2e-node-agent/node-agent.test.ts—app.listen()s a realapps/apiinstance (not justapp.inject()— the node-agent binary needs a real HTTP address to poll), registers a node and enablesnodeRoutingEnabledfor a real org, uploads a real video through the real presigned-URL flow, callsPOST /v1/assets/:id/process, then spawns the real node-agent binary with only its scoped node token (no static R2 credentials) and polls the DB until the asset isready. Asserts a real rendition row, real HLS/thumbnail/master-playlist/archived-source objects in MinIO,jobs.workerKind === 'self-hosted', and that theai-processBullMQ job was enqueued (stops short of running AI itself — that consumer lives inapps/worker, already covered by its ownfull-pipeline.test.ts).
Live scale-to-zero smoke test (scripts/smoke-test-scale-to-zero.sh, pnpm smoke:scale-to-zero) — the one test that isn't Testcontainers-based by design: it hits the deployed strum-vod-api/strum-vod-worker/strum-vod-transcoder Fly apps for real, because scale-to-zero is a Fly Machines runtime behavior (proxy autostart, internal/selfstop's self-exit) that no local container can reproduce. Requires fly, curl, jq on PATH and API_KEY=<org API key> (X-Api-Key). It POSTs /v1/assets/demo-seed (real public sample video, seedFakeAi: true so it needs no AI provider keys), then polls fly machine list --app <app> --json through the full cycle: worker stopped→started (API's own wake call) → transcoder stopped→started (worker bridge's wake call) → asset ready (real ffmpeg encode) → transcoder started→stopped again (internal/selfstop's idle self-exit, ~SELF_STOP_IDLE_SECONDS later). Tails both apps' fly logs in the background and prints the [selfstop]/[*-wake] lines at the end regardless of outcome. Deletes the seeded asset on exit (KEEP_ASSET=1 to keep it for manual inspection). Timeouts are env-overridable (WAKE_TIMEOUT_SECONDS, TRANSCODE_TIMEOUT_SECONDS, SHUTDOWN_TIMEOUT_SECONDS) — see the script's header comment for the full list.
Build order
@strum-vod/db must be built first — both @strum-vod/api and @strum-vod/worker depend on it. The root pnpm run build handles this via workspace ordering.
Architecture
Browser
├── Dashboard (CF Workers + Assets) → API (Fly.io / Docker)
└── Player app (CF Pages) → API (Fly.io / Docker)
Upload paths:
Standard: Browser → API (presigned URL) → R2
Resumable: Browser → API (TUS, @tus/server + @tus/s3-store) → R2
Transcode (two deployables — see "Worker architecture" below):
API → BullMQ "transcode" (Redis) → Worker (Node, bridge) → go:transcode:jobs (Redis Stream)
→ Transcoder (Go) → ffmpeg ladder + upload → R2
→ go:ai:dispatch (Redis Stream) → Worker (Node, bridge) → BullMQ "ai-process" → AI pipeline
AI transcription (sync providers — local/deepgram):
ai-process (BullMQ) → Worker → whisper/LLM → upload → R2
AI transcription (async provider — modal, scale-to-zero GPU):
ai-process (BullMQ) → Worker → POST Modal /transcribe → 202 {callId} [job "pauses"]
→ Modal: cold start + inference in a spawned call (no long HTTP hold)
→ POST <API_PUBLIC_URL>/v1/ai/whisper-callback [HMAC-signed]
→ apps/api → BullMQ "ai-process" (resume-ai-pipeline | retry-transcription) → Worker resumes
apps/api also schedules "reap-stuck-transcriptions" every 5 min — re-dispatches or fails
modal jobs whose callback never arrived (transcriptionAttempts gate). See routes/ai.tsThe full transcode → AI → webhook pipeline as a flowchart (queue/stream names are the env defaults — see apps/worker/src/env.ts / apps/transcoder):
The async Modal transcription flow as a sequence diagram (same as infra/modal-whisper/README.md):
Worker architecture: Node bridge + Go transcoder
The transcode pipeline is split across two deployables — porting the ffmpeg-heavy core to Go for lower memory/CPU overhead while keeping the AI pipeline (LLM/Whisper calls, I/O-bound, no Go SDK equivalent for schema-enforced generation) in Node:
apps/worker(Node,strum-vod-worker) — bridge + AI + analytics. Never touches ffmpeg for the main ladder anymore (still shells out toffmpegfor AI highlight-clip cutting,ai/highlights.ts). Three jobs:- Transcode bridge (
src/bridge.ts::startTranscodeBridge) — a BullMQWorker('transcode', ...)that does no transcoding; it justXADDs the job onto thego:transcode:jobsRedis Stream and returns. Real completion is tracked via Postgres (assets/jobstables), not BullMQ's own result. - AI-dispatch bridge (
src/bridge.ts::startAiDispatchBridge) — consumesgo:ai:dispatch(XREADGROUP, written by the Go transcoder once a job's ladder+upload finishes) and relays each message onto a Node-only BullMQ queue,ai-process. - AI worker (
src/ai-worker.ts) — consumesai-process, downloads the pre-extractedai/audio.mp3and the archived original source (sources/<assetId>/input.mp4, both uploaded by the Go transcoder) from R2, then runsai/process.ts(Whisper/Deepgram transcription, LLM chapters/highlights). Silent sources are skipped entirely — the transcoder leavesassets.ai_audio_pathNULL when the source has no audio track, and the worker marks theai_jobsrow + all four steps SKIPPED instead of failing the audio download. The queue carries four job shapes:- Main flow — creates the
ai_jobsrow, runsprocessAi(). For sync providers (local/deepgram) this blocks until transcription is done, then runs subtitles/chapters/highlights. For the asyncmodalprovider it only dispatches (see below):processAiPOSTs the audio to Modal, stores thecallIdinproviderJobId, and returns — the job "pauses" until the webhook callback lands. resume-ai-pipeline— enqueued byapps/api's/v1/ai/whisper-callbackwhen a modal transcription completes. Reuses the existingai_jobsrow (transcript already written by the callback) and runs only the post-transcription steps (resumeAiPipeline(): subtitles, chapters, highlights).retry-transcription— enqueued by the callback when Modal reports a failed transcription withtranscriptionAttemptsleft; re-dispatches the audio.reap-stuck-transcriptions— scheduled byapps/apievery 5 min (scheduleAiReaperJob());reapStuckTranscriptions()re-dispatches (or fails) modal jobs whose callback never arrived, gated by an atomictranscriptionAttemptsincrement so concurrent reapers can't double-dispatch.
- Main flow — creates the
- A fourth bridge loop (
startWebhookDispatchBridge) relaysgo:webhook:dispatch→ BullMQwebhook-delivery(the same queueapps/api's own webhook worker already consumes) forasset.ready/asset.errordomain events the Go side emits.
Async (modal) transcription contract —
infra/modal-whisper/app.pyis a scale-to-zero Modal app that never holds a request open across cold start + inference: the worker POSTsmultipart/form-data(file,callback_url,asset_id,ai_job_id) to<endpoint>/transcribewithAuthorization: Bearer <WHISPER_API_KEY>and gets an immediate202 {callId}; the actual transcription runs in a.spawn()ed call, which later POSTs the result to<API_PUBLIC_URL>/v1/ai/whisper-callbackwith anX-Signature: sha256=<hmac>header over the raw body (secret:WHISPER_WEBHOOK_SECRET).apps/apiverifies the HMAC withtimingSafeEqual, correlatescallId === provider_job_id(anti-replay), and is idempotent for already-settled jobs. Seeinfra/modal-whisper/README.mdfor the full deploy/setup.- Transcode bridge (
apps/transcoder(Go,strum-vod-transcoder) — consumesgo:transcode:jobsvia a Redis Streams consumer group (XREADGROUP/XACK, withXCLAIM-based recovery of messages orphaned by a crashed instance — seeinternal/queue), resolves the source (local shared volume → yt-dlp URL import → S3 fallback, same 3-tier priority as before), runs the full HLS ladder as a single ffmpeg pass (internal/ladder, 7 renditions 360p–4320p + one shared EXT-X-MEDIA audio track) + thumbnails + audio extraction viaos/exec+ ffmpeg/ffprobe/yt-dlp, uploads everything to R2, marks the assetready, then dispatches AI processing and archives the source. Scales to zero (fly.transcoder.toml,min_machines_running=0) via a split wake/stop design, since Fly's own proxy-idleauto_stop_machinesis unsafe for a Redis-Stream-driven process (it could stop a machine mid-transcode, since it has no HTTP connection open while encoding):- Wake —
auto_start_machines=true;apps/worker's transcode bridge (src/fly-transcoder-wake.ts::wakeTranscoderMachine) POSTshttps://<FLY_TRANSCODER_APP>.fly.dev/wakeafter everyXADDontogo:transcode:jobs, same pattern as the worker's own wake (apps/api/src/services/fly-machine.ts). Fly's proxy autostarts the stopped machine on that request; the transcoder's/wakehandler (internal/health/health.go) just acks. - Stop — self-managed by
internal/selfstop(Go), not by Fly's proxy: it tracks in-process job activity viaMarkBusy/MarkIdle(wrapped around every job incmd/transcoder/main.go, including startup pending-message reclaim) and, once it has observed zero active jobs and an empty stream (XLEN) continuously forSELF_STOP_IDLE_SECONDS(default 120s), just triggers an ordinary graceful shutdown (the samecontext.CancelFuncSIGTERM already drives) so the process exits 0. No Fly Machines API call or token required — a Fly Machine automatically transitions tostoppedwhen its init process exits on its own (see Fly's long-running-tasks blueprint). Gated onFLY_MACHINE_ID(auto-injected by Fly) so it never fires in local dev/Docker Compose.
- Wake —
Both processes share three Redis Streams as their interop boundary (TRANSCODE_STREAM_KEY/AI_DISPATCH_STREAM_KEY/WEBHOOK_DISPATCH_STREAM_KEY env vars, same names on both sides) instead of the Go side ever having to speak BullMQ's Lua-script protocol directly — BullMQ stays 100% Node-owned (apps/api and apps/worker only). Enum/constant strings (ASSET_STATUS, JOB_STATUS, S3_PATHS, etc.) are mirrored by hand in apps/transcoder/internal/constants from packages/db/src/constants.ts — there is no generated single source yet, so a change to one needs the other updated too. apps/transcoder/internal/constants/parity_test.go enforces the mirror (parses constants.ts and fails on drift); run it with pnpm test:transcoder (uses -count=1 since Go's test cache can't see changes to the .ts file).
Cloudflare infrastructure (wrangler)
Cloudflare resources are deployed with wrangler — no IaC tool is used anymore.
Cloudflare resources:
- R2 bucket
strum-vod(video/audio storage) +strum-vod-backups(DB backups) — CORS is applied frominfra/r2/strum-vod-cors.jsonviapnpm r2:cors:set(required for browser-direct presigned uploads from the dashboard/player) - Dashboard as a Cloudflare Worker + static assets (SPA mode) —
apps/dashboard/wrangler.toml - Player as a Cloudflare Pages app —
apps/player/wrangler.toml(pages_build_output_dir = "dist")
There is no TUS Worker — resumable video upload is served by apps/api itself (src/routes/tus.ts), so it deploys with the API (Fly.io/Docker), not as a separate Cloudflare resource.
Monorepo using pnpm workspaces with 11 packages/apps (5 apps + 6 shared packages):
apps/api— Fastify REST server. Env validated with Zod (src/env.ts). Enqueues transcode jobs to BullMQ.src/index.ts— Entry point: Fastify setup, plugin registration, graceful shutdownsrc/db.ts— Database connection, raw SQL migrations with FK constraints and indexessrc/env.ts— Zod-validated environment variables (includes optionalSHARED_AUTH_SECRET)src/routes/assets.ts— CRUD + upload-url / upload-token / import / process / delete (hard delete), search, thumbnail, download, audio, transcript/chapters/highlights endpointssrc/routes/collections.ts— Collections (folders) CRUD + per-collection video countssrc/routes/tus.ts— Resumable video upload (TUS protocol) via@tus/server+@tus/s3-store, mounted at/upload/videos; verifies the sameSHARED_AUTH_SECRETJWTupload-tokenissues, on every request (not just creation)src/routes/playback.ts— HLS playback URL resolution + public playback endpoints (/v1/playback/:id, audio/download/highlights)src/routes/ai.ts— Public/v1/playback/:playbackId/aistatus +POST /v1/ai/whisper-callback(raw-body parser, HMAC-SHA256 verification withtimingSafeEqual,callIdcorrelation + idempotent settled-check; enqueuesresume-ai-pipeline/retry-transcriptionon theai-processqueue)src/routes/health.ts— Health check with DB connectivity verificationsrc/routes/auth.ts/orgs.ts/settings.ts/webhooks.ts/analytics.ts/comments.ts/stats.ts/backups.ts/admin.ts/billing.ts— Auth (JWT + OTP), orgs/members/API keys, settings (branding + AI config), webhook config & delivery log, analytics ingestion/reads, comments & reactions, fleet stats, DB backups, superadmin admin, Stripe billingsrc/services/asset.ts— Shared business logic (findAssetOrFail, URL builders)src/s3.ts— Two S3 clients:s3Client(internal) ands3PublicClient(presigned URLs)src/middleware/error-handler.ts— Centralized error handling (AppError, NotFoundError, ZodError)src/queue.ts— BullMQ queues (transcode/analytics/webhook/db-backup) +aiProcessQueue(the worker'sai-processqueue — used by the whisper-callback route to enqueue resume/retry jobs) +scheduleAiReaperJob()(5-minreap-stuck-transcriptionsscheduler)
apps/worker— Node: BullMQ↔Redis-Streams bridge + AI pipeline + analytics aggregation. See "Worker architecture" above for the full topology; the ffmpeg transcode ladder itself lives inapps/transcodernow.src/index.ts— Entry point: HTTP health/wake, bridge/AI-worker/analytics-worker wiring, graceful shutdownsrc/env.ts— Zod-validated environment variables (bridge stream keys, AI provider config)src/bridge.ts— BullMQ "transcode" →go:transcode:jobsrelay;go:ai:dispatch/go:webhook:dispatch→ BullMQ relays (relaysai-processwithattempts: 3+ exponential backoff)src/streams.ts— Generic Redis Streams consumer-group helper (XREADGROUP/XACK/XCLAIM) used bybridge.tssrc/ai-worker.ts— BullMQ "ai-process" consumer with four branches: main transcribe flow,resume-ai-pipeline(modal callback completed →resumeAiPipeline),retry-transcription(modal callback failed → atomic re-dispatch), andreap-stuck-transcriptions(5-min reaper — see "Worker architecture")src/ffmpeg.ts— FFmpeg/ffprobe wrappers (now only used byai/highlights.tsfor clip cutting)src/s3.ts— S3 client singleton: streaming upload + download (no ACL — R2 uses bucket-level public access)src/ai/process.ts— Split intoprocessAi(dispatch; formodalit POSTs to the endpoint and returns after storingcallId— seeisAsyncTranscription) andresumeAiPipeline(post-transcription subtitles/chapters/highlights, reused by the resume branch)src/ai/providers/modal-whisper.ts— Async dispatch provider:dispatchModalTranscription()(202 +callId) +buildWhisperCallbackUrl()src/ai/— AI transcription, subtitle, chapter, and highlight-clip generation
apps/transcoder— Go: the ffmpeg-heavy transcode ladder (ported fromapps/workerfor lower memory/CPU overhead — see "Worker architecture" above). Hardware-adaptive: auto-detects CPU/RAM (cgroup-aware) at startup.cmd/transcoder/main.go— Entry point: config, DB pool, Redis, consumer-group worker pool, health server, graceful shutdowninternal/config— Env parsing + hardware-adaptive concurrency/thread/pool-size formulasinternal/queue— Redis Streams consumer group client (XREADGROUP/XACK/XCLAIMstalled-message recovery)internal/pipeline— Job orchestration: source resolution, single-pass ladder + per-profile MP4 remux, audio/thumbnails, upload, AI dispatchinternal/ladder— Rendition profiles (Ladder), single-pass multi-output ffmpeg command, shared EXT-X-MEDIA audio, master playlist, MP4 remuxinternal/vaapi,internal/thumbnails,internal/ytdlp,internal/ffmpegx— Hardware-accel args, tiled sprite/VTT generation, yt-dlp wrapper, ffmpeg/ffprobe process spawninginternal/storage— S3/R2 client: streaming upload/download, directory-tree bulk uploaderinternal/db— pgx pool + hand-written queries againstpackages/db/src/schema.ts's tablesinternal/constants— Hand-mirrored copy ofpackages/db/src/constants.ts's enum strings (keep both in sync manually)
apps/dashboard— React SPA (Vite + Tailwind CSS v4). Authenticated management UI for uploading and managing assets. PWA — installable (manifest + Workbox SW viavite-plugin-pwa, see "PWA" below).src/App.tsx— Entry point with routing and layout (no embed/watch routes — those are in the player app)src/pages/NewVideoPage.tsx— Upload page with TUS resumable / presigned-URL togglesrc/components/Player.tsx— Full-featured HLS player (used inside the dashboard for preview)src/components/InstallAppButton.tsx— "Add to Home Screen" button (nativebeforeinstallprompton Android/Chrome, instructions modal on iOS)src/lib/pwa.ts—usePwaInstall()hook (beforeinstallprompt / standalone / iOS detection)src/lib/types.ts— TypeScript interfacessrc/lib/api.ts— Authenticated API client helpersrc/lib/helpers.ts— Utility functions (timeAgo, formatDuration, STATUS_CFG, etc.)
apps/player— Standalone Cloudflare Pages app (Vite + React). Serves the public embeddable player. No auth, no dashboard chrome. Deployed with wrangler pages (pnpm deploy:player→wrangler pages deploy dist --project-name=strum-vod-player). PWA — installable too (samevite-plugin-pwasetup as the dashboard).src/App.tsx— Routes:/embed/:playbackId,/watch/:playbackIdsrc/components/Player.tsx— HLS player (ported from dashboard, no i18n dependency)src/components/EmbedPlayer.tsx— Embed wrapper (fetches/v1/playback/:id)src/components/WatchPage.tsx— Minimal public watch pagesrc/lib/analytics.ts— PlayerAnalytics class (heartbeat + batch flush)src/lib/api.ts— Unauthenticated API client
PWA (dashboard + player)
Both frontend apps are installable PWAs (vite-plugin-pwa v1 + Workbox generateSW):
- Config —
VitePWA()in eachvite.config.ts:injectRegister: false(SW registered from React, see below),display: standalone,navigateFallback: '/index.html'for the SPA routes,skipWaiting/clientsClaim, 5 MiBmaximumFileSizeToCacheInBytes(the dashboard bundle is big). Update mode differs per app: the player usesregisterType: 'autoUpdate'(silent takeover); the dashboard usesregisterType: 'prompt'and surfaces updates through a "New version available → Reload" toast (src/components/UpdateToast.tsx,useRegisterSWfromvirtual:pwa-register/react) so an update lands like a native app's update prompt. - Registration is top-frame-only —
UpdateToastrenders nothing whenwindow.self !== window.top, so the SW registers only in top-level frames. Chrome disallows SW registration in cross-origin iframes, and the dashboard serves/embed//watchpages that are embedded on other sites; we don't want an embed to claim the origin's SW. - Icons — generated by
scripts/generate-pwa-icons.py(Pillow; run it after changing the brand color):public/pwa-192x192.png,pwa-512x512.png,pwa-512x512-maskable.png,apple-touch-icon.png,favicon.svgin each app. Manifest icons are referenced frompublic/. public/_headers— both apps ship a_headersfile so Cloudflare (Workers Assets for the dashboard, Pages for the player) servessw.js/workbox-*.js/manifest.webmanifestwithCache-Control: no-cache— a long edge cache would pin clients to a stale service worker and break PWA updates. Do not remove it.- Native-app feel —
viewport-fit=cover+pt-safe/pb-safe/px-safeutilities (env(safe-area-inset-*)) keep content clear of the notch/home indicator when installed;overscroll-behavior-y: none,-webkit-tap-highlight-color: transparentandtouch-action: manipulationkill web-page-isms (rubber-banding, tap flash, double-tap zoom); layouts useh-dvh/min-h-dvhinstead of100vhso the mobile URL bar doesn't cause layout jumps.
Documentation site (VitePress)
The docs/ directory doubles as a VitePress site (docs/.vitepress/config.mts) — the same markdown that lives in the repo is served as a browsable docs site. Wrapper pages (docs/readme.md, docs/docker.md, docs/contributing.md, docs/security.md, docs/changelog.md, docs/claude.md) import the root-level markdown (../README.md, ../DOCKER.md, etc.), so there is one source of truth — edit the root files, the site updates.
Config —
docs/.vitepress/config.mts: dark theme,vitepress-plugin-mermaid(mermaid blocks from CLAUDE.md/architecture render inline), local search,ignoreDeadLinksfor repo-relative links, and abuildEndhook that copiesdocs/diagrams/(SVG/PNG) into the build output.Commands —
pnpm docs:dev(dev server, :5173),pnpm docs:build(static build →docs/.vitepress/dist),pnpm docs:preview(serve built site, :4173),pnpm deploy:docs(build +wrangler pages deploy).Deploy — Cloudflare Pages project
strum-vod-docs(seedocs/wrangler.toml)..github/workflows/docs.ymlrebuilds + redeploys automatically on every push tomaintouchingdocs/**or the root markdown the site imports; PRs get a preview URL. Secrets:CLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_ID.VITE_DOCS_URL— dashboard build-time env var for the sidebar's "Documentation" link (apps/dashboard/src/components/layout/Sidebar.tsx), which opens in a new tab. Defaults tohttp://localhost:4173(the local preview); set it to the deployed docs URL (e.g.https://docs.strum-vod.dev) in production builds.packages/db— Shared Drizzle ORM schemas (PostgreSQL, pg-core), connection factory, constants.src/schema.ts—collections,assets,renditions,jobs,ai_jobs,highlights,analytics_events,analytics_daily,analytics_asset_stats,settings,comments,reactions,users,organizations,org_members,api_keys,admin_audit_log,domain_events,webhook_deliveries,db_backups,otp_codestables with FK constraints (CASCADE DELETE / SET NULL)src/client.ts—createDb(url, poolConfig?)— PostgreSQL pool factory (postgres-js; SSL forneon.tech/sslmode=require)src/constants.ts— Shared enums (ASSET_STATUS, JOB_STATUS, SOURCE_TYPE, S3_PATHS, ID_LENGTH, COLLECTION_COLORS, TIER_LIMITS, …)
packages/email— Transactional email templates (OTP magic codes) via Resend / SMTP / consolepackages/subtitles— WebVTT / SRT generation from transcript segmentspackages/workbench— BullMQ admin dashboard UI (mounted at/v1/admin/workbench)packages/player-ui— Shared player components (VideoHeatmap, …)packages/design-tokens— Design tokens / CSS custom properties (brand palette)
Asset Lifecycle
created → (upload-url or upload-token or import) → uploaded → (process) → queued → processing → ready | error
Delete is a hard delete (DELETE /v1/assets/:id removes the DB row — FK CASCADE takes renditions/jobs/ai_jobs — plus the sources/ and playback/ objects in R2). The deleted status constant is retained only to keep the Go-side mirror (apps/transcoder/internal/constants) in sync; no current code path writes it.
Upload Flows
Standard (presigned URL)
POST /v1/assets/:id/upload-url→ API returns a signed S3 PUT URL- Browser PUT the file directly to S3/R2
POST /v1/assets/:id/upload-complete→ API confirms the object exists
Resumable (TUS)
POST /v1/assets/:id/upload-token→ API returns a 15-min JWT (sub: sourceKey, aud: videos, maxLen: fileSize)- Browser opens TUS upload to
VITE_TUS_SERVER_URL/upload/videoswithAuthorization: Bearer <token>—VITE_TUS_SERVER_URLis just the API's own origin (defaults toVITE_API_BASE_URL) apps/api's TUS route (src/routes/tus.ts) verifies the JWT on every request — not just creation, so an in-progress upload can't be resumed without a token valid for that exactsourceKey— and streams chunks to R2 via@tus/s3-store(S3 multipart under the hood);namingFunctionbinds the upload id tosourceKeydirectly, so the finished object lands at the same keygetSourceKey()expects with no separate move stepPOST /v1/assets/:id/upload-complete→ API confirms via HeadObject
Key Conventions
- All API responses wrap data in
{ data: {...} }or return{ error: "..." } - Status strings use shared constants from
@strum-vod/db(ASSET_STATUS, JOB_STATUS, etc.) - IDs generated with
nanoid(12), playback IDs withnanoid(16)— lengths defined inID_LENGTH - DB column names use
snake_case, Drizzle schema fields usecamelCase - S3 paths: sources at
sources/{assetId}/input.mp4, HLS output atplayback/{assetId}/— prefixes defined inS3_PATHS - ESM throughout (
"type": "module"in all packages), imports use.jsextensions - No
ACL: 'public-read'on anyPutObjectCommand— R2 uses bucket-level public access, not per-object ACLs - No
PutBucketCorsCommand— R2 CORS is configured via Cloudflare dashboard, not the S3 API - Error handling: API uses centralized error handler (AppError/NotFoundError), Worker wraps DB updates in try/catch
Database
PostgreSQL 16+ (Neon-compatible) with Drizzle ORM (pg-core + postgres-js driver). Schema defined in packages/db/src/schema.ts. Migrations are raw SQL CREATE TABLE IF NOT EXISTS in apps/api/src/db.ts. Connection pool is hardware-adaptive.
Storage
Cloudflare R2 everywhere — both local dev and production. No per-object ACL; CORS and public access are configured at the bucket level with wrangler (see pnpm r2:cors:* and infra/r2/strum-vod-cors.json).
- API (including TUS uploads), transcoder, and dashboard preview all use R2's S3-compatible API (
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com) — same client config everywhere, local dev and production alike. - Buckets are managed with wrangler (
pnpm r2:list). R2 CORS is a bucket-level setting and does not survive a bucket recreate — re-applypnpm r2:cors:setafter creating/replacing thestrum-vodbucket, or browser-direct presigned uploads break (noAccess-Control-Allow-Origin). - Uploads set per-object Cache-Control (
cacheControlForKey()— mirrored inapps/transcoder/internal/storage/s3.go,apps/worker/src/s3.ts, and the backfill script). For objects written before that, backfill the headers withpnpm backfill:cache-control(S3 copy-onto-self +MetadataDirective: REPLACE;DRY_RUN=1to preview,PREFIX=...to scope — seeapps/worker/scripts/backfill-r2-cache-control.ts).
The S3_PUBLIC_BASE_URL env var controls the URL prefix prepended to HLS manifest paths for public playback.
Docker Build Context Isolation
Each app Dockerfile has a companion Dockerfile.dockerignore that limits the Docker build context to only what that app needs:
| App | Context includes |
|---|---|
apps/api | package.json, pnpm-workspace.yaml, packages/db/, apps/api/ |
apps/worker | package.json, pnpm-workspace.yaml, packages/db/, apps/worker/ |
apps/dashboard | package.json, pnpm-workspace.yaml, apps/dashboard/ |
apps/transcoder | apps/transcoder/ only — a standalone Go module, no pnpm workspace files needed |
All Dockerfiles use pnpm (via corepack) instead of npm. node_modules and dist directories from the host are excluded to avoid copying broken pnpm symlinks into the build context.
Transcoding Profiles
Defined in apps/transcoder/internal/ladder/ladder.go as ladder.Ladder (hand-mirrored from the retired apps/worker/src/transcoding.ts, which no longer exists — keep the ladder in sync manually; ladder.FilterLadder derives the per-source subset and assigns codecs per tier — see the config table below):
| Quality | Resolution | Bitrate | H.264 profile/level | CodecTag |
|---|---|---|---|---|
| 360p | 640x360 | 1000k | main 3.0 | avc1.4d401e |
| 480p | 854x480 | 1800k | main 3.0 | avc1.4d401e |
| 720p | 1280x720 | 3000k | main 3.1 | avc1.4d401f |
| 1080p | 1920x1080 | 6000k | high 4.0 | avc1.640028 |
| 1440p | 2560x1440 | 10000k | high 5.0 | avc1.640032 |
| 2160p | 3840x2160 | 20000k | high 5.1 | avc1.640033 |
| 4320p | 7680x4320 | 40000k | high 6.0 | avc1.64003c |
FilterLadder keeps only renditions at/below the source's short side (portrait videos skip the taller steps, e.g. a 1080x1920 source only gets up to 1080p) and the configured MAX_RENDITION_HEIGHT cap, always retaining at least the lowest rendition.
Ladder configuration (env):
| Variable | Default | Description |
|---|---|---|
RENDITION_CODEC | h264 | h264 or hevc — base codec for every rendition |
MAX_RENDITION_HEIGHT | 0 | Cap the tallest rendition (0 = full 360p–4320p) — e.g. 2160 drops the niche 4320p rung |
HEVC_MIN_HEIGHT | 0 | Hybrid ladder: renditions at/above this height encode in HEVC while lower rungs keep the base codec (0 = disabled) — e.g. 2160 gives 4K+ HEVC + H.264 below |
FFMPEG_HWACCEL | auto | auto | vaapi | disabled — hardware acceleration for the ladder encode |
The profile/level/CodecTag column shows the H.264 rendering. With RENDITION_CODEC=hevc (or HEVC_MIN_HEIGHT tiering) the encoder switches to libx265/hevc_vaapi, the profile becomes main (HEVC), and the CodecTag becomes the HEVC hvc1.1.6.L{levelIdc}.B0 form (vaapi.CodecTag) — the levelIdc is derived from the H.264 ladder's level tag (informational only, the encoders don't enforce it). Note: 4K+ HEVC rungs only play on HEVC-capable clients (Safari, modern Chrome/Edge with hardware decode); the ABR falls back to the highest H.264 rung on devices without HEVC support.
Single-pass encode — TranscodeLadder runs the whole ladder as one ffmpeg process (buildLadderArgs): the source is decoded once, a filter_complex split fans the decoded frames out to one scaled branch per rendition, and each branch writes its own HLS output — replacing the old N-decode × N-encode sequential passes, so total transcode time drops to roughly the longest rendition's encode. Settings are identical to the old per-rendition commands:
- CPU path:
libx264/libx265(RENDITION_CODEC=hevc) with CRF 23/28 + maxrate/bufsize (2× maxrate),-preset fast, profile/level,-tag:v hvc1for HEVC; every scale filter pinsformat=yuv420p—-profile:v main/highrejects 4:4:4 sources (e.g.testsrc, some screen recordings) - VA-API path (
FFMPEG_HWACCEL=vaapi):h264_vaapi/hevc_vaapiwith CBR (-rc_mode CBR+ maxrate/bufsize) - 2s keyframes (
-force_key_frames expr:gte(t,n_forced*2)), HLS segments 6s, VOD playlist type - Dynamic timeout:
max(5min, duration×4s)for the whole ladder
Rich master playlist (CreateMasterPlaylist) — declares #EXT-X-VERSION:6 + #EXT-X-INDEPENDENT-SEGMENTS (every segment decodes standalone — guaranteed by the ladder's 2s forced keyframes) and enriches each #EXT-X-STREAM-INF with the source's FRAME-RATE (the ladder never resamples, from ffprobe's avg_frame_rate) and a measured AVERAGE-BANDWIDTH: the produced segments' total bits divided by the Media Playlist duration (RFC 8216), clamped to BANDWIDTH, falling back to the configured target when segments are missing.
Shared audio (EXT-X-MEDIA) — instead of re-encoding AAC into every rendition, the same pass emits one shared audio track to audio/index.m3u8, declared in master.m3u8 as an EXT-X-MEDIA AUDIO group (GROUP-ID="audio", DEFAULT=YES, AUTOSELECT=YES, LANGUAGE="und"). Renditions are video-only (-an) and mark AUDIO="audio" on their #EXT-X-STREAM-INF, with BANDWIDTH, AVERAGE-BANDWIDTH (audio's measured segments added) and CODECS including the audio track (mp4a.40.2). Settings come from AUDIO_PLAYBACK_BITRATE_KBPS (128k) / AUDIO_PLAYBACK_SAMPLE_RATE (48k) / AUDIO_PLAYBACK_CHANNELS (2). Sources without an audio track (probe HasAudio=false) skip the audio rendition entirely — silent assets still reach ready.
Other ladder outputs:
master.m3u8— written byCreateMasterPlaylist(VERSION 6,EXT-X-INDEPENDENT-SEGMENTS, per-variantFRAME-RATE+ measuredAVERAGE-BANDWIDTH, sharedEXT-X-MEDIAAUDIO group)download.mp4per rendition — fast-c copyremux of the rendition playlist plus the audio playlist when present (CreateDownloadableMp4),+faststartthumbnail.jpg— poster at 25% duration (ExtractPosterThumbnail)thumbnails/sprite_%03d.jpg— scrub-bar thumbs tiled at ~100 thumbs per file (5×20 grid of 160px-wide thumbs, 5s interval) in a single ffmpeg pass (thumbnails.Generate— one decode, aselectwindow +tileper file), so a hover preview downloads one small JPEG instead of one unbounded sheet (the old single sprite also hit libjpeg's 65500px dimension ceiling around 18h of video). All tiles in a multi-tile run share identical dimensions (the tile filter pads the short final tile), and a single-tile video is sized to exactly the rows it uses — both keep the player's single global sprite-size math correct.thumbnails.vttreferences each file assprite_%03d.jpg#xywh=...audio.m4a— the public-download AAC file, derived from the shared EXT-X-MEDIA audio rendition by stream copy (CreateDownloadableM4a,-c copyfromaudio/index.m3u8) — no second encode; it inherits the HLS track'sAUDIO_PLAYBACK_*settings exactly- AI audio —
ExtractAiAudiois a separate low-quality mono MP3 (AUDIO_AI_BITRATE_KBPS64k / 16k mono) for Whisper
Scaling & Hardware Adaptation
| Variable | Used by | Default | Description |
|---|---|---|---|
WORKER_CONCURRENCY | Transcoder (Go) | auto (CPU/RAM) | Concurrent transcode jobs — override for the auto formula |
FFMPEG_THREADS | Transcoder (Go) | auto | Threads per FFmpeg process (fed to the single-pass -threads) |
DB_POOL_SIZE | API, Transcoder | auto | Database connection pool size |
Transcoder sizing is cgroup-aware (internal/config): concurrency = min(availableMem/1.5GB, cpuCores/threadsPerJob) with threadsPerJob capped at 4, FFmpeg threads = cpuCores ÷ concurrency, pool = concurrency×2+2. It reads container memory limits (cgroup v2 → v1 → /proc/meminfo) instead of host totals, fixing the old Node worker's os.totalmem() overestimate in memory-limited containers.
Environment Variables
Defined in .env.example. API and Worker validate all env vars at startup via Zod. Dashboard and Player use Vite VITE_* build-time vars.
Key vars:
SHARED_AUTH_SECRET— base64 key the API uses to both sign (upload-token) and verify (routes/tus.ts) the TUS upload JWT. Generate withopenssl rand -base64 32(seescripts/init-shared-auth-secret.sh). Set once as an API secret (fly secrets set/.env) — nothing else needs it now that TUS lives in the API.VITE_TUS_SERVER_URL— dashboard build-time base URL the TUS client appends/upload/videosto. Defaults toVITE_API_BASE_URL; only set it explicitly if the API is reachable at a different origin than the dashboard otherwise uses.VITE_PLAYER_BASE_URL— dashboard build-time URL of the player app (for embed link generation)TRANSCODE_STREAM_KEY/AI_DISPATCH_STREAM_KEY/WEBHOOK_DISPATCH_STREAM_KEY— Redis Stream keys bridgingapps/worker(Node) andapps/transcoder(Go); must match on both sides. Default togo:transcode:jobs/go:ai:dispatch/go:webhook:dispatch— only override if running multiple independent stacks against the same Redis instance. See "Worker architecture" above.WHISPER_WEBHOOK_SECRET— HMAC secret Modal signs its transcription callback with (X-Signature: sha256=<hmac>over the raw body). Only needed whenTRANSCRIPTION_PROVIDER=modal. Must be identical to thewhisper-webhook-secretModal Secret (seeinfra/modal-whisper/README.md);apps/apiverifies it onPOST /v1/ai/whisper-callback. Generate withopenssl rand -hex 32.API_PUBLIC_URL— publicly reachable base URL ofapps/api(only formodaltranscription). The worker builds the Modalcallback_urlfrom it (<API_PUBLIC_URL>/v1/ai/whisper-callback); Modal calls back from its own cloud, solocalhostwon't work — use a tunnel (ngrok, Cloudflare Tunnel) or a deployed API in dev. Set on bothapps/apiandapps/worker.FLY_TRANSCODER_APP(set onapps/worker) — Fly app name ofapps/transcoder(strum-vod-transcoder); when set, the transcode bridge POSTs its public/wakeURL after every relayed job so Fly's proxy autostarts a scaled-to-zero transcoder machine. No-op if unset (local dev, non-Fly deployments). See "Worker architecture" above.SELF_STOP_IDLE_SECONDS(optional, set onapps/transcoder) — how longinternal/selfstopwaits with zero active jobs and an empty stream before exiting so Fly stops the machine. Defaults to 120. No Fly API token needed — see "Worker architecture" above.