No description
  • TypeScript 74.8%
  • Svelte 23.1%
  • Shell 1.1%
  • Dockerfile 0.7%
  • HTML 0.3%
Find a file
Robert Kawecki 5a30ab1a9e
Some checks failed
CI / ci (push) Has been cancelled
Docs: BODY_SIZE_LIMIT vs MAX_UPLOAD_SIZE, tus vs fallback upload paths
README gets a comparison table of the two upload paths (transport, memory
behavior, resumability, which env caps each); .env.example and the
deployment env table now state explicitly that BODY_SIZE_LIMIT is a
per-request cap that never bounds tus file size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:41:42 +02:00
.forgejo/workflows Maintainability: test suite, CI, prettier, agent notes 2026-07-19 22:06:02 +02:00
.vscode Videodrome: SvelteKit video platform 2026-07-18 23:26:53 +02:00
docs Docs: BODY_SIZE_LIMIT vs MAX_UPLOAD_SIZE, tus vs fallback upload paths 2026-07-19 22:41:42 +02:00
scripts Docs: deployment guide, operations runbook, tested backup script 2026-07-19 15:51:26 +02:00
src Support movie-sized uploads: 20 GB default cap, activity-based pruning 2026-07-19 22:28:49 +02:00
static Videodrome: SvelteKit video platform 2026-07-18 23:26:53 +02:00
tests Maintainability: test suite, CI, prettier, agent notes 2026-07-19 22:06:02 +02:00
.dockerignore Production readiness: security headers, /healthz, Docker, shutdown fixes 2026-07-19 14:29:07 +02:00
.editorconfig Maintainability: test suite, CI, prettier, agent notes 2026-07-19 22:06:02 +02:00
.env.example Docs: BODY_SIZE_LIMIT vs MAX_UPLOAD_SIZE, tus vs fallback upload paths 2026-07-19 22:41:42 +02:00
.gitignore Videodrome: SvelteKit video platform 2026-07-18 23:26:53 +02:00
.npmrc Videodrome: SvelteKit video platform 2026-07-18 23:26:53 +02:00
.prettierrc Maintainability: test suite, CI, prettier, agent notes 2026-07-19 22:06:02 +02:00
CLAUDE.md Maintainability: test suite, CI, prettier, agent notes 2026-07-19 22:06:02 +02:00
docker-compose.yml Production readiness: security headers, /healthz, Docker, shutdown fixes 2026-07-19 14:29:07 +02:00
Dockerfile Production readiness: security headers, /healthz, Docker, shutdown fixes 2026-07-19 14:29:07 +02:00
package-lock.json Maintainability: test suite, CI, prettier, agent notes 2026-07-19 22:06:02 +02:00
package.json Maintainability: test suite, CI, prettier, agent notes 2026-07-19 22:06:02 +02:00
README.md Docs: BODY_SIZE_LIMIT vs MAX_UPLOAD_SIZE, tus vs fallback upload paths 2026-07-19 22:41:42 +02:00
tsconfig.json Videodrome: SvelteKit video platform 2026-07-18 23:26:53 +02:00
vite.config.ts Videodrome: SvelteKit video platform 2026-07-18 23:26:53 +02:00

Videodrome

A fast, lightweight, self-hostable video platform (YouTube-style) built with SvelteKit: accounts, channel pages, uploads (resumable via tus), comments, likes, views, search, and a moderation queue.

Design goals

  • Performance: fully server-side rendered pages (~5 KB HTML for the home page), lazy-loaded thumbnails, HTTP Range streaming, immutable cache headers on media, short shared-cache on listings. No CSS/JS frameworks beyond Svelte itself.
  • Cost: minimal infrastructure — PostgreSQL plus the app (all pure-JS dependencies, no native builds), media in a local directory. Flip one env var to serve media from Bunny.net CDN, and another to fan transcoding out to RabbitMQ workers, when you outgrow a single box.
  • Clean code: small server modules (db, storage, ffmpeg, dto), typed end to end, standard SvelteKit form actions.

Requirements

  • Node.js ≥ 23.6 (the worker runs TypeScript via built-in type stripping)
  • PostgreSQL — DATABASE_URL (defaults to postgres://postgres:postgres@127.0.0.1:5432/videodrome); the schema is created automatically on first connection
  • ffmpeg/ffprobe on PATH — optional; enables thumbnails and duration badges. Without them uploads still work, just without thumbnails.

Run

npm install
npm run dev            # development

npm run build          # production build (adapter-node)
BODY_SIZE_LIMIT=4G ORIGIN=https://your.domain node build/index.js

BODY_SIZE_LIMIT must be raised or uploads over 512 KB are rejected by adapter-node. ORIGIN is required in production for form-action CSRF checks.

Tests & tooling

  • npm test — zero-dependency suite on Node's built-in test runner: unit tests for the microcache/rate limiter, formatting helpers, and local storage, plus integration tests for the whole data layer against real Postgres (a scratch videodrome_test database is created and dropped; set TEST_PG_ADMIN_URL if your server isn't the default local one; skipped cleanly when no server is reachable).
  • npm run lint / npm run format — prettier (config in .prettierrc).
  • CI: .forgejo/workflows/ci.yml runs check + tests (with a Postgres service) + build on push/PR — needs a Forgejo Actions runner registered.

Deployment & operations

  • docs/DEPLOYMENT.md — env reference, systemd / container / scaled-out shapes, reverse proxy, first-boot checklist, upgrades.
  • docs/OPERATIONS.md — monitoring, incident runbook, backups & restore (scripts/backup.sh), moderation ops, capacity signals.

Production checklist

  • ORIGIN=https://your.domain — enables CSRF protection, Secure session cookies, and HSTS in one go.
  • /healthz — returns 200 when the database answers, 503 otherwise; point your load balancer / container health check at it.
  • Behind a proxy: set ADDRESS_HEADER=x-forwarded-for (and XFF_DEPTH) so rate limits and view dedupe see real client IPs. Terminate TLS and do response compression at the proxy/CDN — the app doesn't gzip.
  • Headers: every response carries X-Content-Type-Options, Referrer-Policy, a baseline CSP (object-src 'none'; base-uri 'self'; frame-ancestors 'self'), and HSTS on https. A stricter script CSP is deliberately left to the deployment (SvelteKit hydration uses inline scripts; media may be served from CDN_HOST).
  • Graceful shutdown: on SIGTERM the server drains in-flight requests (adapter-node) and flushes buffered view counts before exiting.
  • Docker: docker compose up starts web + Postgres + RabbitMQ + one transcode worker (see docker-compose.yml); docker compose up web db is the minimal single-box variant with in-process transcoding. Media lives on the shared media volume, the image bundles ffmpeg, and the container healthcheck already targets /healthz. Podman works too (podman build / podman run, or podman compose with a compose provider installed); build with --format docker if you want the image-level HEALTHCHECK, which the OCI format drops.
  • Backups: pg_dump for the database plus the media directory (or Bunny storage zone) — that's the entire state.

Storage & CDN — Bunny.net as either or both

Storage (where bytes live) and CDN (what hostname serves them) are independent knobs, so Bunny can be object storage, CDN, or both:

Mode Env Media path Cost
Single box (default) nothing app serves /media/* with Range support free
CDN only CDN_HOST=<pull zone over this app> bytes stay local; Bunny edge caches the immutable /media/* responses ~$0.0050.01/GB traffic
Storage + CDN STORAGE=bunny, BUNNY_STORAGE_ZONE, BUNNY_API_KEY, CDN_HOST=<linked pull zone> uploads pushed to the Storage Zone, served by its pull zone + ~$0.01/GB·mo storage

The upload pipeline is identical in all modes: stage → probe/thumbnail with ffmpeg (if present) → push to the storage backend (src/lib/server/ingest.ts).

Adaptive streaming (self-hosted HLS — no vendor lock-in)

When ffmpeg is present, every upload is queued for background transcoding (src/lib/server/transcode.ts) into an h264/aac HLS ladder (up to 1080/720/480/360, capped at source resolution). Videos are watchable immediately as progressive MP4 and upgrade to adaptive HLS when the transcode finishes. The player uses native HLS on Safari and lazy-loaded hls.js elsewhere, with progressive fallback.

Everything is plain immutable files (hls/<id>/…) pushed through the same storage abstraction — local disk, Bunny Storage, any CDN, or a future vendor: migrating means copying a directory. The transcoder pulls its source back out of storage, so interrupted or queued jobs resume after a restart from hls_status in the DB. One ffmpeg job runs at a time per process; ffmpeg multithreads internally.

Scaling transcodes with RabbitMQ

By default jobs run inside the web process (zero infrastructure). Set AMQP_URL and they are published to a durable RabbitMQ queue instead, consumed by any number of dedicated worker processes:

AMQP_URL=amqp://localhost npm run worker    # run N of these
  • Durability: persistent messages + publisher confirms; a job survives worker crashes (redelivered), broker restarts, and web restarts. Jobs are idempotent — output keys are deterministic and content-identical — so a duplicate delivery wastes CPU, never correctness.
  • Workers need: ffmpeg on PATH, DATABASE_URL reaching the shared Postgres, and with STORAGE=local the same DATA_DIR volume as the web app (that's where media lives). With STORAGE=bunny sources and output go over HTTP, so workers can run on any machine anywhere.
  • Tuning: one job per worker is the default (ffmpeg saturates its cores); WORKER_PREFETCH=n raises per-process concurrency. With a broker configured the web process itself no longer needs ffmpeg for HLS.

The queue/dispatch logic lives in src/lib/server/queue.ts, job execution in src/lib/server/transcode.ts, the worker entry in src/worker.ts (run directly by Node ≥ 23.6 via type stripping — no build step).

Moderation — reports & review queue

Logged-in viewers can report a video from the watch page (reason + optional details, rate-limited, one open report per viewer per video). Reports land in a review queue at /admin/reports — visible only to admins (404 for everyone else) — grouped per video, oldest first, with two outcomes:

  • Dismiss: closes the video's open reports; the video stays up. The same viewer may report it again later.
  • Remove video: deletes the video (comments and likes cascade) and its stored media — source, thumbnail, HLS tree — through the storage backend (local disk or Bunny). Closed reports survive as an audit trail: they keep a title snapshot and their resolved/dismissed status.

Admins are regular users with is_admin set — durably via UPDATE users SET is_admin = true WHERE username = '…', or bootstrapped with ADMIN_USERNAMES=alice,bob (comma-separated, no DB touch).

Uploads are resumable (tus 1.0.0)

/tus implements the tus protocol (creation, offset HEAD, chunked PATCH, termination) with a dependency-free browser client (src/lib/tus-client.ts): 8 MB chunks, retry with backoff, and the upload URL is fingerprinted into localStorage — an interrupted upload resumes where it stopped, even after a page reload or server restart (offsets derive from staged file size on disk). Files up to MAX_UPLOAD_SIZE (default 20 GB) are accepted; staging files idle for 24 h (no writes) are pruned. A plain multipart form action remains as the no-JS fallback.

The two upload paths, and what limits which

tus (JS clients — the normal path) multipart form (no-JS fallback)
Transport many 8 MB PATCH requests one POST with the whole file
Server memory ~one chunk; streamed to disk entire file (request.formData() buffers it)
Resumable yes — survives reloads, restarts, dropped links no — a hiccup restarts from zero
Capped by MAX_UPLOAD_SIZE (per file, default 20 GB) BODY_SIZE_LIMIT (per request)

BODY_SIZE_LIMIT is adapter-node's cap on any single request body, so it governs both paths — but for tus it only needs to exceed one 8 MB chunk (hence the 64M default suggestion), while for the fallback it must fit the whole file. Raising it makes the fallback accept bigger files at the cost of buffering them in RAM; it never affects how large a file tus can take, which is MAX_UPLOAD_SIZE's job. Rule of thumb: keep BODY_SIZE_LIMIT modest, treat the fallback as a convenience for small clips, and let tus carry anything heavy.

Surviving hot releases

Designed so a viral video doesn't melt the box:

  • Microcache (cache.ts): watch-page payload, comment lists, and home listing are cached in-process for 12 s — thousands of req/s become ~1 DB read/s per hot key, while staleness stays imperceptible.
  • Batched views (views.ts): view counts accumulate in memory and flush as one batched UPDATE every 3 s, so page views cost no per-request writes. Views dedupe per viewer per 10 min window. Flush also runs on SIGINT/SIGTERM.
  • Session cache: one session lookup per user per minute, not per request.
  • Rate limits on signup, login, comments, and upload creation.
  • CDN offload: media URLs are immutable (Cache-Control: immutable), so in either Bunny mode the bandwidth spike never reaches your origin.
  • Async scrypt password hashing keeps signup storms off the event loop.

Layout

src/lib/server/db/          Postgres data layer: client.ts (pool, lazy
                            schema init, query helpers), schema.ts, and
                            one module per domain (videos, users,
                            comments, reports, likes) behind index.ts
src/lib/server/storage.ts   local-dir / Bunny storage + CDN URL mapping
src/lib/server/ingest.ts    shared upload finalization pipeline
src/lib/server/queue.ts     transcode dispatch: RabbitMQ or in-process
src/lib/server/transcode.ts HLS job execution (ffmpeg ladder)
src/worker.ts               standalone transcode worker (npm run worker)
src/lib/server/tus.ts       tus staging state (survives restarts)
src/lib/server/views.ts     buffered, deduped view counting
src/lib/server/cache.ts     TTL microcache + rate limiter
src/lib/server/auth.ts      scrypt passwords, cookie sessions
src/lib/server/moderation.ts report outcomes: takedown / dismiss
src/lib/tus-client.ts       dependency-free resumable upload client
src/routes/                 /, /watch/[id], /channel/[username], /upload,
                            /login, /signup, /logout, /admin/reports,
                            /tus, /tus/[id], /media/[...key]
data/                       tus staging, transcode scratch, local media (gitignored)

Cost-scaling path

  1. One VPS, local storage (default): ~$5/mo total.
  2. + Bunny CDN pull zone (CDN_HOST): media bandwidth moves to the edge, bytes stay on your box. Pay-as-you-go, no minimums.
  3. + Bunny Storage (STORAGE=bunny): disk stops being a constraint.
  4. + RabbitMQ workers (AMQP_URL): transcode throughput stops being a constraint — CPU-heavy ffmpeg moves off the web box onto N workers.
  5. + web nodes behind a load balancer: with Postgres shared and ffmpeg off-box, web processes are near-stateless — add nodes when SSR CPU saturates. The in-process microcaches stay per-node by design: a 12 s TTL bounds staleness regardless of node count, and each node absorbs its own hot-key traffic with zero network hops.

Why no Redis (yet)

Every candidate job for Redis is already covered more cheaply:

  • Hot-page caching: the per-node microcache is better than a shared cache here — a 12 s TTL bounds staleness, and N nodes mean N reads/s per hot key against Postgres, which is nothing. A Redis hop per page view would add latency and a new hot spot.
  • Likes: single atomic Postgres transaction on a denormalized counter; a cache would only add a coherence problem.
  • Views: buffered per node and flushed in one batched UPDATE every 3 s — write volume scales with node count, not viewer count. The 10-minute viewer dedupe window is per-node, so behind a round-robin LB a viewer can count up to N times per window: an acceptable bound for an approximate counter.
  • Rate limits / session cache: per-node, so effective rate limits are multiplied by node count — fine at small N.

Redis earns its place only when you need exact cross-node semantics: strict global rate limits, exactly-once view dedupe, or instant cross-node cache invalidation. All three are bounded-error today, so it's deferred.