Skip to content

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)

bash
# 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)

bash
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 workspace

Docs site (VitePress)

bash
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-docs

Testing

bash
pnpm test:e2e                           # run API E2E suite (requires Docker)
pnpm --filter @strum-vod/api run test:e2e  # same, scoped to the api package

E2E 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.

FileRole
src/tests/globalSetup.tsStarts PostgreSQL + Redis containers once; sets all required process.env vars
src/tests/setupFiles.tsMocks @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner
src/tests/helpers/server.tscreateTestApp() — runs migrations, returns a ready Fastify instance
src/tests/helpers/auth.tssignUp() / bearer() for test authentication
src/tests/helpers/fixtures.tscreateAsset() fixture
src/tests/e2e/health.test.ts/health/* and /v1/config
src/tests/e2e/auth.test.tsSignup, login, /v1/auth/me
src/tests/e2e/assets.test.tsAsset CRUD, upload flow, import, process, org isolation
src/tests/e2e/playback.test.tsPlayback, 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:

bash
pnpm --filter @strum-vod/worker run test:e2e
  • src/tests/globalSetup.ts — starts Postgres + Redis + MinIO containers once, creates the MinIO test bucket, bootstraps the schema (helpers/schema.ts), and sets DATABASE_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 — minimal CREATE TABLE bootstrap for the tables apps/worker touches (assets/jobs/renditions/ai_jobs/highlights), hand-mirrored from apps/api/src/db.ts's runMigrations() (the real migration source of truth) rather than imported from it, to avoid coupling this package's tests to apps/api's own env validation (JWT_SECRET, SHARED_AUTH_SECRET, etc.). settings is 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/deleteTestAsset cascade-delete via the real FK constraints; putTestObject/deleteTestObject clean up after themselves in afterAll).
  • src/tests/e2e/streams.test.ts — the generic Streams consumer-group helper (streams.ts): message delivery + ack, and XCLAIM-based reclaim of a message left pending by a simulated crashed consumer.
  • src/tests/e2e/bridge.test.ts — all three bridge flows (bridge.ts): BullMQ transcode → stream, stream → BullMQ ai-process, stream → BullMQ webhook-delivery.
  • src/tests/e2e/ai-worker.test.ts — the full ai-process pipeline (ai-worker.ts) with seedFakeAi (no real AI vendor calls, see ai/providers/fake-provider.ts): downloads the pre-extracted audio from MinIO, runs processAi, asserts the ai_jobs row.
  • src/tests/e2e/full-pipeline.test.ts — the real thing, no shortcuts: builds the actual apps/transcoder Go binary (helpers/transcoderProcess.ts, requires a local Go toolchain + ffmpeg/ffprobe on PATH, same as pnpm 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: BullMQ transcode → bridge → go:transcode:jobsreal ffmpeg HLS ladder encode → real MinIO upload → go:ai:dispatch → bridge → ai-processai-worker.ts (fake AI). Asserts a real rendition row, real HLS/thumbnail/master-playlist objects in MinIO, and a completed ai_jobs row. ~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 as apps/worker's globalSetup.ts, but sets apps/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 real cmd/node-agent binary (--hwaccel disabled for a deterministic CPU path in CI) and synthesize a tiny test video, mirroring apps/worker's transcoderProcess.ts/testVideo.ts (duplicated rather than imported — separate packages, deliberately trivial).
  • src/tests/e2e-node-agent/node-agent.test.tsapp.listen()s a real apps/api instance (not just app.inject() — the node-agent binary needs a real HTTP address to poll), registers a node and enables nodeRoutingEnabled for a real org, uploads a real video through the real presigned-URL flow, calls POST /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 is ready. Asserts a real rendition row, real HLS/thumbnail/master-playlist/archived-source objects in MinIO, jobs.workerKind === 'self-hosted', and that the ai-process BullMQ job was enqueued (stops short of running AI itself — that consumer lives in apps/worker, already covered by its own full-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 stoppedstarted (API's own wake call) → transcoder stoppedstarted (worker bridge's wake call) → asset ready (real ffmpeg encode) → transcoder startedstopped 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.ts

The 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 to ffmpeg for AI highlight-clip cutting, ai/highlights.ts). Three jobs:

    1. Transcode bridge (src/bridge.ts::startTranscodeBridge) — a BullMQ Worker('transcode', ...) that does no transcoding; it just XADDs the job onto the go:transcode:jobs Redis Stream and returns. Real completion is tracked via Postgres (assets/jobs tables), not BullMQ's own result.
    2. AI-dispatch bridge (src/bridge.ts::startAiDispatchBridge) — consumes go: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.
    3. AI worker (src/ai-worker.ts) — consumes ai-process, downloads the pre-extracted ai/audio.mp3 and the archived original source (sources/<assetId>/input.mp4, both uploaded by the Go transcoder) from R2, then runs ai/process.ts (Whisper/Deepgram transcription, LLM chapters/highlights). Silent sources are skipped entirely — the transcoder leaves assets.ai_audio_path NULL when the source has no audio track, and the worker marks the ai_jobs row + all four steps SKIPPED instead of failing the audio download. The queue carries four job shapes:
      • Main flow — creates the ai_jobs row, runs processAi(). For sync providers (local/deepgram) this blocks until transcription is done, then runs subtitles/chapters/highlights. For the async modal provider it only dispatches (see below): processAi POSTs the audio to Modal, stores the callId in providerJobId, and returns — the job "pauses" until the webhook callback lands.
      • resume-ai-pipeline — enqueued by apps/api's /v1/ai/whisper-callback when a modal transcription completes. Reuses the existing ai_jobs row (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 with transcriptionAttempts left; re-dispatches the audio.
      • reap-stuck-transcriptions — scheduled by apps/api every 5 min (scheduleAiReaperJob()); reapStuckTranscriptions() re-dispatches (or fails) modal jobs whose callback never arrived, gated by an atomic transcriptionAttempts increment so concurrent reapers can't double-dispatch.
    4. A fourth bridge loop (startWebhookDispatchBridge) relays go:webhook:dispatch → BullMQ webhook-delivery (the same queue apps/api's own webhook worker already consumes) for asset.ready/asset.error domain events the Go side emits.

    Async (modal) transcription contractinfra/modal-whisper/app.py is a scale-to-zero Modal app that never holds a request open across cold start + inference: the worker POSTs multipart/form-data (file, callback_url, asset_id, ai_job_id) to <endpoint>/transcribe with Authorization: Bearer <WHISPER_API_KEY> and gets an immediate 202 {callId}; the actual transcription runs in a .spawn()ed call, which later POSTs the result to <API_PUBLIC_URL>/v1/ai/whisper-callback with an X-Signature: sha256=<hmac> header over the raw body (secret: WHISPER_WEBHOOK_SECRET). apps/api verifies the HMAC with timingSafeEqual, correlates callId === provider_job_id (anti-replay), and is idempotent for already-settled jobs. See infra/modal-whisper/README.md for the full deploy/setup.

  • apps/transcoder (Go, strum-vod-transcoder) — consumes go:transcode:jobs via a Redis Streams consumer group (XREADGROUP/XACK, with XCLAIM-based recovery of messages orphaned by a crashed instance — see internal/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 via os/exec + ffmpeg/ffprobe/yt-dlp, uploads everything to R2, marks the asset ready, 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-idle auto_stop_machines is unsafe for a Redis-Stream-driven process (it could stop a machine mid-transcode, since it has no HTTP connection open while encoding):

    • Wakeauto_start_machines=true; apps/worker's transcode bridge (src/fly-transcoder-wake.ts::wakeTranscoderMachine) POSTs https://<FLY_TRANSCODER_APP>.fly.dev/wake after every XADD onto go: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 /wake handler (internal/health/health.go) just acks.
    • Stop — self-managed by internal/selfstop (Go), not by Fly's proxy: it tracks in-process job activity via MarkBusy/MarkIdle (wrapped around every job in cmd/transcoder/main.go, including startup pending-message reclaim) and, once it has observed zero active jobs and an empty stream (XLEN) continuously for SELF_STOP_IDLE_SECONDS (default 120s), just triggers an ordinary graceful shutdown (the same context.CancelFunc SIGTERM already drives) so the process exits 0. No Fly Machines API call or token required — a Fly Machine automatically transitions to stopped when its init process exits on its own (see Fly's long-running-tasks blueprint). Gated on FLY_MACHINE_ID (auto-injected by Fly) so it never fires in local dev/Docker Compose.

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 from infra/r2/strum-vod-cors.json via pnpm 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 shutdown
    • src/db.ts — Database connection, raw SQL migrations with FK constraints and indexes
    • src/env.ts — Zod-validated environment variables (includes optional SHARED_AUTH_SECRET)
    • src/routes/assets.ts — CRUD + upload-url / upload-token / import / process / delete (hard delete), search, thumbnail, download, audio, transcript/chapters/highlights endpoints
    • src/routes/collections.ts — Collections (folders) CRUD + per-collection video counts
    • src/routes/tus.ts — Resumable video upload (TUS protocol) via @tus/server + @tus/s3-store, mounted at /upload/videos; verifies the same SHARED_AUTH_SECRET JWT upload-token issues, 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/ai status + POST /v1/ai/whisper-callback (raw-body parser, HMAC-SHA256 verification with timingSafeEqual, callId correlation + idempotent settled-check; enqueues resume-ai-pipeline/retry-transcription on the ai-process queue)
    • src/routes/health.ts — Health check with DB connectivity verification
    • src/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 billing
    • src/services/asset.ts — Shared business logic (findAssetOrFail, URL builders)
    • src/s3.ts — Two S3 clients: s3Client (internal) and s3PublicClient (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's ai-process queue — used by the whisper-callback route to enqueue resume/retry jobs) + scheduleAiReaperJob() (5-min reap-stuck-transcriptions scheduler)
  • 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 in apps/transcoder now.

    • src/index.ts — Entry point: HTTP health/wake, bridge/AI-worker/analytics-worker wiring, graceful shutdown
    • src/env.ts — Zod-validated environment variables (bridge stream keys, AI provider config)
    • src/bridge.ts — BullMQ "transcode" → go:transcode:jobs relay; go:ai:dispatch/go:webhook:dispatch → BullMQ relays (relays ai-process with attempts: 3 + exponential backoff)
    • src/streams.ts — Generic Redis Streams consumer-group helper (XREADGROUP/XACK/XCLAIM) used by bridge.ts
    • src/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), and reap-stuck-transcriptions (5-min reaper — see "Worker architecture")
    • src/ffmpeg.ts — FFmpeg/ffprobe wrappers (now only used by ai/highlights.ts for clip cutting)
    • src/s3.ts — S3 client singleton: streaming upload + download (no ACL — R2 uses bucket-level public access)
    • src/ai/process.ts — Split into processAi (dispatch; for modal it POSTs to the endpoint and returns after storing callId — see isAsyncTranscription) and resumeAiPipeline (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 from apps/worker for 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 shutdown
    • internal/config — Env parsing + hardware-adaptive concurrency/thread/pool-size formulas
    • internal/queue — Redis Streams consumer group client (XREADGROUP/XACK/XCLAIM stalled-message recovery)
    • internal/pipeline — Job orchestration: source resolution, single-pass ladder + per-profile MP4 remux, audio/thumbnails, upload, AI dispatch
    • internal/ladder — Rendition profiles (Ladder), single-pass multi-output ffmpeg command, shared EXT-X-MEDIA audio, master playlist, MP4 remux
    • internal/vaapi, internal/thumbnails, internal/ytdlp, internal/ffmpegx — Hardware-accel args, tiled sprite/VTT generation, yt-dlp wrapper, ffmpeg/ffprobe process spawning
    • internal/storage — S3/R2 client: streaming upload/download, directory-tree bulk uploader
    • internal/db — pgx pool + hand-written queries against packages/db/src/schema.ts's tables
    • internal/constants — Hand-mirrored copy of packages/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 via vite-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 toggle
    • src/components/Player.tsx — Full-featured HLS player (used inside the dashboard for preview)
    • src/components/InstallAppButton.tsx — "Add to Home Screen" button (native beforeinstallprompt on Android/Chrome, instructions modal on iOS)
    • src/lib/pwa.tsusePwaInstall() hook (beforeinstallprompt / standalone / iOS detection)
    • src/lib/types.ts — TypeScript interfaces
    • src/lib/api.ts — Authenticated API client helper
    • src/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:playerwrangler pages deploy dist --project-name=strum-vod-player). PWA — installable too (same vite-plugin-pwa setup as the dashboard).

    • src/App.tsx — Routes: /embed/:playbackId, /watch/:playbackId
    • src/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 page
    • src/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):

  • ConfigVitePWA() in each vite.config.ts: injectRegister: false (SW registered from React, see below), display: standalone, navigateFallback: '/index.html' for the SPA routes, skipWaiting/clientsClaim, 5 MiB maximumFileSizeToCacheInBytes (the dashboard bundle is big). Update mode differs per app: the player uses registerType: 'autoUpdate' (silent takeover); the dashboard uses registerType: 'prompt' and surfaces updates through a "New version available → Reload" toast (src/components/UpdateToast.tsx, useRegisterSW from virtual:pwa-register/react) so an update lands like a native app's update prompt.
  • Registration is top-frame-onlyUpdateToast renders nothing when window.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//watch pages 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.svg in each app. Manifest icons are referenced from public/.
  • public/_headers — both apps ship a _headers file so Cloudflare (Workers Assets for the dashboard, Pages for the player) serves sw.js/workbox-*.js/manifest.webmanifest with Cache-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 feelviewport-fit=cover + pt-safe/pb-safe/px-safe utilities (env(safe-area-inset-*)) keep content clear of the notch/home indicator when installed; overscroll-behavior-y: none, -webkit-tap-highlight-color: transparent and touch-action: manipulation kill web-page-isms (rubber-banding, tap flash, double-tap zoom); layouts use h-dvh/min-h-dvh instead of 100vh so 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.

  • Configdocs/.vitepress/config.mts: dark theme, vitepress-plugin-mermaid (mermaid blocks from CLAUDE.md/architecture render inline), local search, ignoreDeadLinks for repo-relative links, and a buildEnd hook that copies docs/diagrams/ (SVG/PNG) into the build output.

  • Commandspnpm 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 (see docs/wrangler.toml). .github/workflows/docs.yml rebuilds + redeploys automatically on every push to main touching docs/** 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 to http://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.tscollections, 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_codes tables with FK constraints (CASCADE DELETE / SET NULL)
    • src/client.tscreateDb(url, poolConfig?) — PostgreSQL pool factory (postgres-js; SSL for neon.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 / console

  • packages/subtitles — WebVTT / SRT generation from transcript segments

  • packages/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) → queuedprocessingready | 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)

  1. POST /v1/assets/:id/upload-url → API returns a signed S3 PUT URL
  2. Browser PUT the file directly to S3/R2
  3. POST /v1/assets/:id/upload-complete → API confirms the object exists

Resumable (TUS)

  1. POST /v1/assets/:id/upload-token → API returns a 15-min JWT (sub: sourceKey, aud: videos, maxLen: fileSize)
  2. Browser opens TUS upload to VITE_TUS_SERVER_URL/upload/videos with Authorization: Bearer <token>VITE_TUS_SERVER_URL is just the API's own origin (defaults to VITE_API_BASE_URL)
  3. 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 exact sourceKey — and streams chunks to R2 via @tus/s3-store (S3 multipart under the hood); namingFunction binds the upload id to sourceKey directly, so the finished object lands at the same key getSourceKey() expects with no separate move step
  4. POST /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 with nanoid(16) — lengths defined in ID_LENGTH
  • DB column names use snake_case, Drizzle schema fields use camelCase
  • S3 paths: sources at sources/{assetId}/input.mp4, HLS output at playback/{assetId}/ — prefixes defined in S3_PATHS
  • ESM throughout ("type": "module" in all packages), imports use .js extensions
  • No ACL: 'public-read' on any PutObjectCommand — 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-apply pnpm r2:cors:set after creating/replacing the strum-vod bucket, or browser-direct presigned uploads break (no Access-Control-Allow-Origin).
  • Uploads set per-object Cache-Control (cacheControlForKey() — mirrored in apps/transcoder/internal/storage/s3.go, apps/worker/src/s3.ts, and the backfill script). For objects written before that, backfill the headers with pnpm backfill:cache-control (S3 copy-onto-self + MetadataDirective: REPLACE; DRY_RUN=1 to preview, PREFIX=... to scope — see apps/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:

AppContext includes
apps/apipackage.json, pnpm-workspace.yaml, packages/db/, apps/api/
apps/workerpackage.json, pnpm-workspace.yaml, packages/db/, apps/worker/
apps/dashboardpackage.json, pnpm-workspace.yaml, apps/dashboard/
apps/transcoderapps/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):

QualityResolutionBitrateH.264 profile/levelCodecTag
360p640x3601000kmain 3.0avc1.4d401e
480p854x4801800kmain 3.0avc1.4d401e
720p1280x7203000kmain 3.1avc1.4d401f
1080p1920x10806000khigh 4.0avc1.640028
1440p2560x144010000khigh 5.0avc1.640032
2160p3840x216020000khigh 5.1avc1.640033
4320p7680x432040000khigh 6.0avc1.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):

VariableDefaultDescription
RENDITION_CODECh264h264 or hevc — base codec for every rendition
MAX_RENDITION_HEIGHT0Cap the tallest rendition (0 = full 360p–4320p) — e.g. 2160 drops the niche 4320p rung
HEVC_MIN_HEIGHT0Hybrid 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_HWACCELautoauto | 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 encodeTranscodeLadder 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 hvc1 for HEVC; every scale filter pins format=yuv420p-profile:v main/high rejects 4:4:4 sources (e.g. testsrc, some screen recordings)
  • VA-API path (FFMPEG_HWACCEL=vaapi): h264_vaapi / hevc_vaapi with 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 by CreateMasterPlaylist (VERSION 6, EXT-X-INDEPENDENT-SEGMENTS, per-variant FRAME-RATE + measured AVERAGE-BANDWIDTH, shared EXT-X-MEDIA AUDIO group)
  • download.mp4 per rendition — fast -c copy remux of the rendition playlist plus the audio playlist when present (CreateDownloadableMp4), +faststart
  • thumbnail.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, a select window + tile per 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.vtt references each file as sprite_%03d.jpg#xywh=...
  • audio.m4a — the public-download AAC file, derived from the shared EXT-X-MEDIA audio rendition by stream copy (CreateDownloadableM4a, -c copy from audio/index.m3u8) — no second encode; it inherits the HLS track's AUDIO_PLAYBACK_* settings exactly
  • AI audio — ExtractAiAudio is a separate low-quality mono MP3 (AUDIO_AI_BITRATE_KBPS 64k / 16k mono) for Whisper

Scaling & Hardware Adaptation

VariableUsed byDefaultDescription
WORKER_CONCURRENCYTranscoder (Go)auto (CPU/RAM)Concurrent transcode jobs — override for the auto formula
FFMPEG_THREADSTranscoder (Go)autoThreads per FFmpeg process (fed to the single-pass -threads)
DB_POOL_SIZEAPI, TranscoderautoDatabase 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 with openssl rand -base64 32 (see scripts/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/videos to. Defaults to VITE_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 bridging apps/worker (Node) and apps/transcoder (Go); must match on both sides. Default to go: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 when TRANSCRIPTION_PROVIDER=modal. Must be identical to the whisper-webhook-secret Modal Secret (see infra/modal-whisper/README.md); apps/api verifies it on POST /v1/ai/whisper-callback. Generate with openssl rand -hex 32.
  • API_PUBLIC_URL — publicly reachable base URL of apps/api (only for modal transcription). The worker builds the Modal callback_url from it (<API_PUBLIC_URL>/v1/ai/whisper-callback); Modal calls back from its own cloud, so localhost won't work — use a tunnel (ngrok, Cloudflare Tunnel) or a deployed API in dev. Set on both apps/api and apps/worker.
  • FLY_TRANSCODER_APP (set on apps/worker) — Fly app name of apps/transcoder (strum-vod-transcoder); when set, the transcode bridge POSTs its public /wake URL 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 on apps/transcoder) — how long internal/selfstop waits 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.

STRUM Proprietary License — © 2026 Strum. All rights reserved.