- TypeScript 74.8%
- Svelte 23.1%
- Shell 1.1%
- Dockerfile 0.7%
- HTML 0.3%
|
Some checks failed
CI / ci (push) Has been cancelled
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> |
||
|---|---|---|
| .forgejo/workflows | ||
| .vscode | ||
| docs | ||
| scripts | ||
| src | ||
| static | ||
| tests | ||
| .dockerignore | ||
| .editorconfig | ||
| .env.example | ||
| .gitignore | ||
| .npmrc | ||
| .prettierrc | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
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 topostgres://postgres:postgres@127.0.0.1:5432/videodrome); the schema is created automatically on first connection ffmpeg/ffprobeon 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 scratchvideodrome_testdatabase is created and dropped; setTEST_PG_ADMIN_URLif 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.ymlruns 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,Securesession 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(andXFF_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 fromCDN_HOST). - Graceful shutdown: on SIGTERM the server drains in-flight requests (adapter-node) and flushes buffered view counts before exiting.
- Docker:
docker compose upstarts web + Postgres + RabbitMQ + one transcode worker (seedocker-compose.yml);docker compose up web dbis the minimal single-box variant with in-process transcoding. Media lives on the sharedmediavolume, the image bundles ffmpeg, and the container healthcheck already targets/healthz. Podman works too (podman build/podman run, orpodman composewith a compose provider installed); build with--format dockerif you want the image-level HEALTHCHECK, which the OCI format drops. - Backups:
pg_dumpfor 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.005–0.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_URLreaching the shared Postgres, and withSTORAGE=localthe sameDATA_DIRvolume as the web app (that's where media lives). WithSTORAGE=bunnysources 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=nraises 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/dismissedstatus.
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 1–2 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
- One VPS, local storage (default): ~$5/mo total.
- + Bunny CDN pull zone (
CDN_HOST): media bandwidth moves to the edge, bytes stay on your box. Pay-as-you-go, no minimums. - + Bunny Storage (
STORAGE=bunny): disk stops being a constraint. - + RabbitMQ workers (
AMQP_URL): transcode throughput stops being a constraint — CPU-heavy ffmpeg moves off the web box onto N workers. - + 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 1–2 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 1–2 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.