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: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 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.ts — app.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 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.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 contract — infra/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):

    • Wake — auto_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.ts — usePwaInstall() 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:player → wrangler 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):

  • Config — VitePWA() 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-only — UpdateToast 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 feel — viewport-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.

  • Config — docs/.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.

  • 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 (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.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_codes tables with FK constraints (CASCADE DELETE / SET NULL)
    • src/client.ts — createDb(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) → 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)

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