Replace the ad-hoc `worker.js` with a self-contained `linearvoid-media` worker under `infrastructure/cloudflare/` that owns `media.linearvoid.tv`. It serves HLS objects from the R2 bucket, enforcing immutable Cache-Control on segments, a short TTL on live manifests, plus HLS CORS, OPTIONS preflight, Range support, and a `/health` probe. Includes wrapper config with production domain, Vitest unit tests, Miniflare R2 integration tests, automated deploy workflow, and docs updates.
Architecture Documentation for Linear Void
Linear Void architecture is documented using the arc42 template.
Companion docs: see Documentation README and
adr/for full decision rationale, alternatives, and consequences.
Introduction and Goals
Requirements Overview — Linear Void is an independent 24/7 linear video channel broadcasting self-owned pre-recorded videos to a primarily US audience via a custom lightweight HTML/CSS/JS SPA with synchronized HLS playback, schedule metadata, public chat rooms, and private 1:1 messaging. Fully independent of third-party video platforms (YouTube, Twitch, etc.).
Core Requirements (merged from all ADRs):
- Continuous synchronized playlist playback (live-style HLS, not VOD).
- Self-hosted source media, frontend, schedule, and chat data.
- Primary US audience/origin (East Coast preferred for low latency/peering).
- Extreme cost efficiency (near-zero egress via Cloudflare R2 + edge caching).
- Custom chat with rooms, DMs, presence, moderation, persistence.
- Storage for 48h of 720p30/1080p30 content.
- Browser-compatible playback (
hls.js+ native HLS). - Operational simplicity and scalability.
Full rationale in adr/ADR-001.md.
Quality Goals
| # | Attribute | Goal | Scenario |
|---|---|---|---|
| 1 | Cost Efficiency | <$25/month at moderate scale (100s concurrent) | R2 $0 egress + immutable .m4s/.ts edge caching removes per-viewer bandwidth cost |
| 2 | Performance/Latency | Low US latency; acceptable HLS delay | Hetzner Ashburn origin + Cloudflare US POPs (15–35s end-to-end) |
| 3 | Availability | 24/7 with minimal downtime | FFmpeg/MediaMTX auto-restart, fallback slate, monitoring |
| 4 | Scalability | Grow 10s→1000s viewers without proportional cost | Edge caching + R2; only request volume limits |
| 5 | Maintainability | Lightweight, clear configs, low ops | Static SPA, Go/Node chat, SQLite/PostgreSQL+Redis |
| 6 | Security/Usability | Secure transport, moderation, good UX | HTTPS/WSS, Cloudflare WAF, rate limits, intuitive chat/player |
Stakeholders: Product Owner, Engineering Team, US Viewers, Moderators/Admins, Operations — all expecting a cost-effective, reliable, low-latency, low-TCO independent channel with rich chat and easy management.
Architecture Constraints
- No YouTube/Twitch/paid Cloudflare Stream dependency.
- US-centric (origin + audience).
- Cost stays low even at scale (bandwidth dominant).
- Standard browser playback only (no plugins).
- Cloudflare ToS compliance for video (R2 as origin for large immutable files).
- HLS primary (standard, not LL-HLS initially).
- Self-hosted playlist normalization and continuous playout.
Context and Scope
Business Context — Linear Void runs a continuous scheduled, playlist-driven program to a US audience. The custom viewer site replaces traditional platforms, giving full control over content, schedule, UI, and real-time chat (public rooms + DMs).
High-Level Context Diagram
flowchart TD
subgraph "External Users"
Viewers[US Viewers<br/>Browser SPA + hls.js + Chat UI]
Admins[Admins / Moderators<br/>Playlist & Schedule UI]
end
subgraph "Cloudflare Global Edge"
CDN[Cloudflare CDN<br/>Cache Rules + WAF + TLS]
Workers[Cloudflare Workers<br/>Static SPA Hosting]
R2[Cloudflare R2<br/>HLS Segments + Source MP4s<br/>enam location hint]
end
subgraph "US-East Origin (Hetzner Ashburn VPS)"
VPS[VPS CX22<br/>Nginx Proxy + Monitoring]
Playout[Playout Service<br/>FFmpeg + MediaMTX/SRS<br/>Continuous HLS from Playlist]
Chat[Chat Backend<br/>WebSockets + Redis + DB<br/>Public rooms + Private DMs]
end
Admins -->|HTTPS - Schedule/Playlist| Playout
Playout -->|S3-compatible Upload| R2
R2 -->|Cached HLS| CDN
Viewers -->|HTTPS| Workers
Viewers -->|HLS Requests| CDN
Viewers -->|WSS Chat| Chat
Chat --> VPS
VPS -->|Proxy / Static| Workers
CDN -->|Origin Pull on Miss| R2
External Interfaces: Cloudflare (CDN cache rules for .m4s/.ts vs .m3u8, R2 $0 egress, Workers static hosting, WAF/TLS); Hetzner VPS (US-East compute, good peering); Browsers (native HLS + WebSocket); Source (pre-normalized MP4s in R2).
Technical Context
- Playout: FFmpeg consuming normalized playlist → continuous HLS.
- Packaging/Delivery: MediaMTX/SRS/Nginx → immutable
.m4s/.tsto R2; edge cache. - Frontend: Static SPA with hls.js.
- Chat: Lightweight WebSocket service + Redis (presence/rate limits) + PostgreSQL/SQLite (history/moderation).
- Normalization: All sources H.264/AAC, constant 30 fps, 2s keyframes.
Mapping: Input = normalized MP4s in R2 + playlist; Output = live master.m3u8 + segments via Cloudflare, chat via WSS.
Solution Strategy
- US-East Playout on Hetzner Ashburn VPS (CX22+): FFmpeg + MediaMTX/SRS continuous playlist→HLS (
-c copywhere possible). - Immutable HLS to Cloudflare R2: unique segment names; R2 origin with $0 egress.
- Aggressive Edge Caching: long TTL for immutable
.m4s/.ts; short (1–10s) for live.m3u8; Tiered Cache enabled — viewer delivery effectively free. - Lightweight Custom Frontend: static SPA + hls.js + chat sidebar.
- Separate co-located Chat Service: WebSockets with persistence/moderation (low bandwidth).
- Normalization & Monitoring: pre-process all videos; dashboards + alerts for playout health, cache hit ratio, R2 ops.
Accepted Trade-off: standard HLS latency (15–35s) acceptable for linear programming/chat; LL-HLS/WebRTC deferred.
Building Block View
Whitebox Overall System
graph TD
subgraph Cloudflare
CDN[CDN + Cache Rules<br/>.m4s and .ts immutable long TTL<br/>.m3u8 short TTL]
R2[R2 - HLS + Source]
Workers[Workers - Static SPA]
end
VPS[VPS Hetzner Ashburn]
Playout[FFmpeg + MediaMTX/SRS<br/>Playlist → HLS]
Chat[WebSocket Chat<br/>Node/Go + Redis + DB]
Viewer[US Viewers]
Admin[Admin UI]
Admin --> Playout
Playout --> R2
R2 --> CDN
Viewer --> Workers
Viewer --> CDN
Viewer --> Chat
Chat --> VPS
VPS --> Nginx[Nginx Proxy]
Motivation: decouple playout (continuous linear), delivery (cached immutable segments), interaction (chat). R2 + edge caching solves bandwidth cost while keeping synchronization and independence.
Contained blocks: Playout (FFmpeg + MediaMTX/SRS); Storage/Delivery (R2 + CDN); Frontend (SPA); Chat Service (WebSocket); Proxy/Static (Nginx).
Important interfaces: HTTP(S) for HLS, WSS for chat, S3-compatible for R2 uploads.
Level 2 — components with code locations and persistence
graph TB
subgraph "Cloudflare Edge"
CF[CDN + WAF<br/>Cache Rules]
R2[R2 Bucket<br/>media + HLS]
WORKERS[Workers<br/>SPA hosting]
end
subgraph "Docker Host (Hetzner CX22)"
NGINX[nginx<br/>:80/:443<br/>proxy + auth]
CHAT[chat service<br/>:3000<br/>Go + gorilla/websocket]
PLAYOUT[playout service<br/>:8081<br/>Go + FFmpeg]
REDIS[Redis 7<br/>:6379<br/>presence/rate/pubsub]
DB[PostgreSQL 15<br/>:5432<br/>chat history/moderation]
end
subgraph "External"
VIEWER[Viewer Browser<br/>hls.js + WebSocket]
ADMIN[Admin Browser<br/>SPA + uploads]
end
VIEWER -->|HTTPS| CF
VIEWER -->|WSS| NGINX
ADMIN -->|HTTPS| CF
CF --> NGINX
NGINX --> CHAT
NGINX --> PLAYOUT
NGINX -->|/hls/*| R2
CHAT --> REDIS
CHAT --> DB
PLAYOUT --> R2
PLAYOUT -->|reads| R2
Playout Service (backend/playout/src/playlist.go)
- Role: continuous HLS encoding loop via FFmpeg concat demuxer.
- Input:
data/playlist/playlist.txt(ordered MP4 paths); Output: HLS segments (.m4s,.ts) + live.m3u8todata/hls/, mirrored to R2. - Resilience: restart counter (>5/60s → slate); Prometheus on 8081.
- Validation: ffprobe gate (codec/fps/resolution/keyframe);
-validatemode checks playlist without running. - Seamless looping: resets HLS only when playlist fingerprint (SHA-256) changes, crash/slate recovery, or first run; else continues via
-start_number. - R2 retention:
cleanupOldRunskeeps newestR2_KEEP_RUNS(default 2) capped atR2_MAX_RETENTION_BYTES(default 10 GiB), after each run + hourly backstop. - Shared config: reads runtime
config.json(loadSharedHLSConfig) as env fallback for HLS segment duration/window.
Chat Service (backend/chat/src/main.go)
- Role: WebSocket chat (rooms, DMs, moderation, admin API).
- Persistence: PostgreSQL 15 via GORM (messages, moderation, media metadata); SQLite dev fallback.
- Redis: presence, rate limiting, pub/sub multi-instance fan-out.
- Auth: session-cookie admin auth, Redis-backed expiry.
- Metrics on
/metrics: connected clients, stored messages, DMs, moderation actions.
Frontend SPA (frontend/)
- Viewer
src/main.ts: hls.js player, WS chat, presence, schedule overlay. - Admin
src/admin.ts: 9 tabs (dashboard, upload, playlist, schedule, moderation, chat-log, users, analytics, system). - Build: Vite →
frontend/dist/.
Reverse Proxy (infrastructure/nginx/nginx.conf)
auth_requestgate on/adminand/api/admin; WS upgrade for/ws; HLS CORS + OPTIONS preflight;/healthJSON +/nginx-statusstub_status.
Level 3 — Chat Service Modules
| Module | Location | Responsibility |
|---|---|---|
| Connection Manager | main.go (hub, clients map) |
Track active WS connections per room, fan-out |
| Message Parser | main.go (readPump, handleMessage) |
Parse JSON envelope {type, room?, ...}, validate, route |
| Room Registry | main.go (rooms map) |
Room name → client set, join/leave/close |
| DM Router | main.go (handleDirectMessage) |
Resolve user, deliver private message |
| Persistence Layer | main.go (GORM: Message, DirectMessage, ModerationLog) |
Store/retrieve messages, DMs, moderation |
| Redis Subsystem | main.go (redisClient) |
INCR+EXPIRE rate limit, SETEX presence, PSUBSCRIBE fan-out |
| Auth Handler | main.go (handleAdminLogin, adminAuthMiddleware) |
Session cookie create/validate, login rate limit |
| User Auth Handler | handlers/handlers.go |
Viewer register/login/logout/session (bcrypt, user cookie) |
| DM History/Search | handlers/handlers.go |
Paginated DM history + search |
| Runtime Config | config/runtime.go |
Thread-safe JSON runtime config (HLS, Matomo) |
| Admin API Router | main.go (handleAdmin*) |
Upload, playlist CRUD, schedule overrides, moderation, analytics, system |
| Metrics Exporter | main.go |
connected_clients, messages_total, dms_total, moderation_actions, redis_up |
Level 3 — Playout Service Modules
| Module | Location | Responsibility |
|---|---|---|
| Playlist Parser | playlist.go (parsePlaylist) |
Read playlist, skip comments/blanks, resolve paths |
| FFmpeg Manager | playlist.go (runPlayout) |
Spawn/monitor FFmpeg concat, restart on crash |
| Loop Controller | playlist.go (playlistFingerprint, hlsNextStartNumber) |
Detect changes (SHA-256), continue numbering |
| Slate Controller | playlist.go (activateSlate/deactivateSlate) |
Switch to slate on excessive failures |
| R2 Sync | playlist.go (syncToR2) |
Upload segments + manifest to R2 |
| R2 Cleanup | playlist.go (cleanupOldRuns) |
Keep last N run folders under size cap; delete hls/r-*/* |
| Health Endpoint | playlist.go (healthHandler) |
/health JSON: mode, segment count, uptime |
| Metrics Exporter | playlist.go |
restarts_total, mode, loops_total, run_duration_seconds, r2_cleanup_objects_total |
Level 3 — Frontend Modules
| Module | File | Responsibility |
|---|---|---|
| HLS Player | src/main.ts |
hls.js init, attach, error recovery |
| WebSocket Client | src/main.ts |
Connect/parse envelope/dispatch |
| Chat UI | src/main.ts |
Message list, room tabs, DM panel, presence dots, typing, user-list |
| DM Search | src/main.ts |
/api/dm/search + render |
| Auth UI | src/main.ts + index.html |
Viewer login/register/logout, session, user label |
| Schedule Overlay | src/main.ts |
Poll /api/schedule, render current/next |
| Admin Router | src/admin.ts |
Tab nav, dynamic pages |
| Upload Manager | src/admin.ts |
Multipart chunk upload, normalization badges |
| Playlist Editor | src/admin.ts |
Drag-reorder (gated to ready media), save |
| Moderation Panel | src/admin.ts + public/admin/moderation.html |
Mute/ban, action log |
| Analytics Dashboard | src/admin.ts + public/admin/analytics.html |
Message counts, top rooms, moderation stats |
| System Config | src/admin.ts + public/admin/system.html |
Health, runtime config form, restart |
Runtime View
Playout & Delivery Scenario
- Admin adds normalized MP4 to R2 + updates playlist.
- Playout worker (FFmpeg concat loop) reads next file, packages HLS segments (2s).
- Segments uploaded to R2 with unique immutable names; live
.m3u8updated. - Viewer loads SPA → requests manifest → hls.js follows current edge.
- Cloudflare serves
.m4s/.tsfrom nearest edge cache (origin hit once per POP/region). - Parallel WebSocket to chat service for synchronized interaction.
Manifest freshness keeps all viewers on the same timeline; cache hit ratio drives cost/latency. Fallback slate activates on failure. (Moderation events, scale spikes, failover in ADR.)
Deployment View
CI/CD Pipeline — Parallel workflows on each push to main: CI builds/pushes images while CD deploys to the test lab (192.168.88.40); a dedicated release workflow handles semantic version tags. Production Hetzner VPS planned but not yet provisioned — same pipeline deploys there by changing DOCKER_HOST.
flowchart LR
subgraph "Gitea Actions"
CI[CI Workflow<br/>Build + Push Images]
CD[CD Workflow<br/>Pull + Deploy]
end
subgraph "Gitea Container Registry"
REGISTRY[git.allucanget.biz<br/>playout:latest<br/>chat:latest<br/>nginx:latest]
end
subgraph "Test Lab Host (192.168.88.40) — Production Hetzner TBD"
DOCKER[Docker Engine<br/>via TCP :2375]
PLAYOUT[linearvoid-playout]
CHAT[linearvoid-chat]
NGINX[linearvoid-nginx]
REDIS[linearvoid-redis]
DB[linearvoid-db]
PROM[linearvoid-prometheus]
GRAF[linearvoid-grafana]
end
CI -->|push| REGISTRY
REGISTRY -->|pull| CD
CD -->|deploy via DOCKER_HOST| DOCKER
DOCKER --> PLAYOUT
DOCKER --> CHAT
DOCKER --> NGINX
DOCKER --> REDIS
DOCKER --> DB
DOCKER --> PROM
DOCKER --> GRAF
Pipeline sequence
- CI (
.gitea/workflows/ci.yml): onpushtomain+pull_request;testjob (frontend lint/build, Go validation);dockerjob (after test, main only) buildsplayout/chat/nginxwith Buildx, pushesmain+latesttags to Gitea registry. - CD (
.gitea/workflows/cd.yml): onpushtomain(parallel) orworkflow_dispatch; logs into remote Docker host, generates.envfrom secrets, seeds Prometheus/Grafana volumes, deploys viadocker compose -f docker-compose.prod.yml up -d --remove-orphans --force-recreateto192.168.88.40:2375(switch to Hetzner IP for prod); health checks with retry for playout init. - Release (
.gitea/workflows/release.yml): on tags*.*.*(e.g.1.0.9); pushesX.Y.Z,X.Y,latest; creates Gitea release.
Infrastructure Level 1 — Single low-cost US-East origin (Hetzner) with global edge caching. High cache-hit ratio, low US latency, edge-scale WAF/DDoS. Mapping: Playout + Chat + Nginx → Hetzner CX22 (Ashburn, VA); HLS storage/caching/static SPA → Cloudflare R2 + CDN + Workers (enam location hint); all public traffic through Cloudflare.
Infrastructure Level 2
- Hetzner VPS (planned): playout (FFmpeg/MediaMTX), chat backend, Nginx; monitoring dashboards + UptimeRobot; backups to R2. Test lab currently fills this role (see
DEPLOYMENT.md). - Cloudflare: R2 bucket; Cache Rules (
.m4s/.tsCache Everything/immutable/long TTL;.m3u8short TTL/must-revalidate); WAF, rate limiting, Tiered Cache; Workers for SPA. - Future: Docker Compose, managed DB, secondary regions (later ADRs).
Cross-cutting Concepts
Caching & Cost Optimization — Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = near-zero viewer bandwidth. Long TTL on .m4s/.ts, short on live manifests. Monitor hit ratio and R2 Class B ops.
Video Normalization & Playout — All sources must meet the Linear Void source profile before production playlist acceptance. The chat backend auto-normalizes non-conforming uploads on ingest (media.NormalizeVideo), removing manual pre-processing. Prevents decode resets, timestamp drift, AV discontinuities during concat playout.
Required Source Profile (v1)
| Field | Required Value | Notes |
|---|---|---|
| Container | MP4 | Single-video-asset input |
| Video codec | H.264/AVC (libx264) |
Baseline delivery codec |
| Audio codec | AAC-LC | Stereo preferred |
| Resolution | 1280x720 or 1920x1080 | Keep single res per playlist block when possible |
| Framerate | Constant 30 fps (-r 30) |
VFR must be normalized |
| GOP/keyframe | 2s (-g 60 -keyint_min 60) |
Matches HLS segment boundaries |
| Pixel format | yuv420p |
Browser baseline |
| Audio sample rate | 48 kHz | Stable concat timing |
| Audio bitrate | 128k–192k | Predictable envelope |
Reference normalization command
ffmpeg -i input.mov \
-c:v libx264 -preset medium -crf 21 \
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
-pix_fmt yuv420p \
-c:a aac -b:a 160k -ar 48000 -ac 2 \
-movflags +faststart \
output-normalized.mp4
Automatic normalization & verification (ffprobe)
media.NormalizeVideoruns ffmpeg with the profile, writes*.normalized.mp4, re-verifies with ffprobe.- Media status:
ready,needs_normalization,normalizing,normalization_failed; admin UI "Normalize" action with polling. POST /api/admin/media/{id}/normalizere-triggers manually.- Keep verification aligned with runtime playout assumptions to preserve
-c copyefficiency.
Runtime Configuration Store — Thread-safe, JSON-persisted store in backend/chat/src/config/runtime.go, shared with playout via the playlist volume (/app/playlist/config.json).
- Editable via
GET/PUT /api/admin/config. - Fields:
hlsSegmentDuration(2–15s, default 2),hlsWindowSize(3–100, default 12),matomoUrl,matomoSiteId. - Playout reads same file (
loadSharedHLSConfig) as env fallback. - Typed setters validate; invalid →
400.
Analytics (Matomo) — Viewer sends pageviews via public GET /api/matomo, returning matomoUrl + matomoSiteId from the runtime config. Optional per deployment (empty matomoUrl disables).
Monitoring & Resilience — Two-layer model: (1) application layer (chat + platform health) via Prometheus + lightweight Grafana; (2) infrastructure/runtime layer (Prometheus self-observability, TSDB, Go runtime) via detailed Grafana dashboard.
Prometheus baseline (monitoring/prometheus/prometheus.yml): 15s scrape; 7 jobs — chat→chat:3000, playout→playout:8081, nginx→nginx-exporter:9113, redis→redis-exporter:9121, postgres→postgres-exporter:9187, node→node-exporter:9100, prometheus→localhost:9090. TSDB retention 30d + 15GB disk cap (both compose files).
Grafana dashboards: monitoring/grafana/dashboard.json (app metrics: connected clients, messages, moderation, Redis reachability, admin sessions, scrape health); monitoring/grafana/dashboards/dashboard.json (Prometheus ops: status, uptime, CPU/mem, TSDB ingestion/churn/errors, HTTP latencies, query load, GC/scheduler). Use app dashboard for service behavior; Prometheus overview for platform diagnostics/capacity.
Alerting rules (monitoring/prometheus/alerts.yml, loaded via rule_files): 10 rules — ChatDown, PlayoutDown, RedisDown, PlayoutSlateActive, HighChatLatency, PrometheusWALFailing, HighCPU, LowCacheHitRatio, HighRedisMemoryUsage, LowDiskSpace. Route warning/critical to ops channel; attach runbook links.
Resilience baseline: emergency slate fallback; auto-restart for playout/chat/nginx; weekly backup/restore drills for stateful data; incident runbook triage: edge reachability → origin health → playout progression → chat connectivity → data-plane dependencies.
API Reference — Three operational API surfaces (canonical contract in API.md).
Chat WebSocket API: endpoint /ws; wss:// prod / ws:// local; query params username, room; envelope {room, username, text, type: public|private|system}; responsibilities — room fan-out, DM routing, moderation commands, rate limiting/presence.
Playout & Delivery REST: playout /health; edge/origin /health via nginx; HLS manifest/segment namespace under /hls/. Liveness for orchestration + stable HLS path contract.
Admin REST: auth/session /api/admin/login|logout|session; domains overview/analytics/users/moderation/schedule/playlist/media; runtime config GET/PUT /api/admin/config; normalization POST /api/admin/media/{id}/normalize; uploads /api/admin/media/uploads (init/resume), /uploads/{uploadId}/chunk, /complete. Protected control plane; media metadata + upload orchestration; moderation/audit + system status.
Viewer REST: auth POST /api/auth/register|login|logout, GET /api/auth/session; DMs GET /api/dm/history (paginated), GET /api/dm/search; analytics GET /api/matomo (public).
Cloudflare Configuration Reference — Cloudflare is part of the production control plane, not only a CDN.
DNS & zone routing: linearvoid.tv → app origin (nginx); www.linearvoid.tv → redirect apex; media.linearvoid.tv → media domain for HLS/object delivery when split-domain enabled.
Cache rules baseline: .ts/.m4s Cache Everything, long TTL, immutable; .m3u8 short TTL (1–10s), revalidation-friendly.
Edge security/controls: enforce HTTPS/WSS + redirect HTTP; WAF baseline; rate limits on abusive patterns (auth/admin); minimize origin exposure.
Worker & header policy: the linearvoid-media Worker (infrastructure/cloudflare/, R2-bound) owns media.linearvoid.tv and enforces immutable caching for segments, short TTL for live manifests, plus HLS CORS/Range; the linearvoid-frontend Worker (frontend/wrangler.toml) serves the static SPA on linearvoid.tv. Automated deploys: workers-deploy.yml (SPA) and workers-media-deploy.yml (media).
Operations & validation: verify cache status progression (MISS→HIT), manifest freshness under active playout; track hit ratio, request/error rates, ToS/cost posture.
Security & Moderation — Split across edge (Cloudflare), proxy (nginx), and application (chat/admin).
- Transport/edge: HTTPS/WSS; WAF + rate limiting; Worker enforces immutable headers.
- nginx:
auth_requeston/admin/,/api/admin/; admin UI → redirect/admin/login/; admin API → JSON401; WS upgrade forwarded for/ws. - Admin auth: cookie session (
linearvoid_admin_session);HttpOnly,SameSite=Strict,Securewhen HTTPS (from TLS orX-Forwarded-Proto); server-side expiry. - Viewer accounts: register/login/logout; bcrypt passwords;
linearvoid_user_sessioncookie, 30-day default TTL (USER_SESSION_TTL_HOURS);SameSite=None; Secureover HTTPS (cross-site from Workers SPA),SameSite=Laxover plain HTTP (local dev); sign-in UI infrontend/index.html. - Rate limiting/abuse: Redis
INCR + EXPIRE; baseline>10messages in10s→ throttle. Redis down → presence/rate-limit degrade gracefully (warn logged; core chat available). - CORS: HLS endpoints
Access-Control-Allow-Origin: *; API routes reflect allowlisted origin +Access-Control-Allow-Credentials: true; viewer cookieSameSite=None; Securecross-site; admin cookie staysSameSite=Strict. - Moderation/privacy: commands
/mod,/mute,/banhandled in chat flow, persisted. DMs are application-level private, not end-to-end encrypted. Formalize moderation/audit retention in runbook/ops docs for production hardening.
Chat — WebSockets + Redis (presence, pub/sub, rate limits) + DB (history, audit). Public rooms + on-demand private chats; scales independently of video. DM history/search persisted; viewer UI has typing indicator, user-list toggle, DM search. (Details/cache tables/bitrate/cost model/configs in ADR-001.)
Architecture Decisions
Consolidated ADRs (in adr/):
- ADR-001: overall architecture, HLS + R2 caching, storage calcs, cost model, risks.
- ADR-002: project folder/module structure and rationale.
- ADR-003: tech stack (TypeScript frontend; Go for chat/playout wrapper; Bash/Python scripts), Docker-first, documented assumptions.
- ADR-004: video admin web interface (upload, playlist, schedule overrides, normalization).
- ADR-005: admin UI for users, moderation dashboard, analytics, system config.
- ADR-006: data storage (SQLite vs PostgreSQL for v1, migration path).
- ADR-007: Redis usage (use cases, TTL, persistence, fallback, admin config).
Key decisions: self-managed US-East playout (Hetzner Ashburn) FFmpeg + MediaMTX/SRS → HLS → R2 + edge cache; module structure mirroring arc42 blocks; Go for perf-critical services, TypeScript frontend, Docker everywhere; admin interfaces (HTMX + custom CSS); SQLite primary for v1 with PostgreSQL migration path; Redis for rate limiting/presence/pub/sub/session with defined TTL and fallback.
Quality Requirements
See Quality Goals. Primary: cost (near-zero egress), availability (auto-restart + fallback), scalability (edge caching), usability (synchronized player + responsive chat).
Performance Benchmarks & Capacity Planning
Baseline SLO targets (v1): playback startup < 4s after first manifest request on warm cache; no segment starvation in steady state; end-to-end HLS 15–35s; chat delivery p95 < 500ms same region; admin read APIs p95 < 1s.
Workload assumptions: US audience, US-East origin; edge serves most segments, origin handles manifest churn + cache misses; chat low-bandwidth vs video.
Initial capacity model: bitrates — 720p30 ~2.6–4.2 Mbps, 1080p30 ~5.1–8.2 Mbps (with audio). 48h library — 720p30 ~57–90 GB raw (alloc ~80–110 GB); 1080p30 ~110–175 GB raw (alloc ~130–180 GB). Cost driven by cache efficiency + request volume, not direct origin egress.
Benchmark plan: playback (time-to-first-frame, rebuffers across US regions; manifest freshness/segment availability); chat (concurrent WS + fan-out; p50/p95 latency, Redis fallback); admin/API (p95 latency, error rate); platform (TSDB ingestion, query p99, GC in Grafana).
Capacity triggers/actions: viewer growth + rising cache-miss → tighten cache, enable/verify Tiered Cache, review manifest TTL; chat p95 drift/WS errors → scale chat, tune Redis, isolate moderation; Prometheus WAL/query saturation → tune retention/cardinality, add resources, split queries; origin CPU/mem pressure → optimize FFmpeg, isolate services, add secondary origin.
Scaling Strategy — preserve cost efficiency first, then throughput/resilience in phases:
- Phase 1 — Vertical + Cache-First (current): single US-East origin, strong edge caching; optimize segment immutability + manifest TTL before adding infra; scale compute vertically on pressure.
- Phase 2 — Service Separation: split playout/chat/observability across nodes; isolate noisy workloads from latency-sensitive chat; introduce managed PostgreSQL/Redis or dedicated state nodes where justified.
- Phase 3 — Multi-Origin Resilience: warm standby or active-secondary origin; health-aware DNS/origin failover; keep HLS/cache contract stable.
- Phase 4 — Regional/Throughput Expansion: add origin region only when audience/latency justify; explicit chat affinity strategy; evaluate cache-miss economics vs origin complexity.
- Scaling triggers: rising cache-miss with sustained growth; chat p95/p99 drift or WS errors; persistent origin saturation; increased blast-radius from single-host dependency.
Disaster Recovery & Backup
Recovery targets: RPO ≤ 24h (chat/moderation/admin metadata); RTO ≤ 60 min (restore baseline streaming + chat).
Backup scope: persistent volumes — Postgres (postgres-data), Redis append-only (redis-data) where enabled, Prometheus TSDB (prometheus-data) if retention required, Grafana state (grafana-data); host-mounted dirs — data/chat, data/playlist, data/media (authoritative may be R2), data/hls (optional, regenerable); source of truth — compose, nginx, monitoring configs, env template.
Cadence: daily logical Postgres backups (+ weekly full snapshot); daily sync of chat/playlist dirs; weekly restore integrity check in isolated env. Retention: daily 14 days, weekly 8 weeks, monthly 6 months.
Recovery runbook (minimum):
- Restore infra config (compose, nginx, env, monitoring).
- Restore Postgres + Redis state.
- Restore
data/playlist+ chat persistence dirs. - Rehydrate media from authoritative object storage if missing.
- Start stack; validate in order: playout
/health, chat/health, nginx/health, HLS availability, WS chat connectivity. - Verify admin auth + moderation audit visibility.
- Announce recovery; elevated monitoring ≥ one playlist cycle.
Failure modes: single-host loss → redeploy on standby, restore backups, repoint DNS/edge; storage corruption → restore last good snapshot + replay logical backups; partial failure → replace only impacted service, restore scoped data. Rehearse quarterly.
Risks and Technical Debts
- Cloudflare ToS risk for heavy video on free tier (use R2; fallback to BunnyCDN).
- HLS latency (15–35s) may affect perceived chat sync.
- Single VPS = SPOF (mitigate with DNS failover, Phase 3).
- Upfront effort for normalization + cache tuning.
- Moderation/abuse operational load at scale.
- R2 request costs on cache misses (optimize Tiered Cache; measure in prod).
- Deferred: LL-HLS, ABR, E2EE, paid access controls, full Docker Compose.
- Debt: detailed service configs, CI/CD, advanced resilience left for implementation ADRs.
Glossary
| Term | Definition |
|---|---|
| HLS | HTTP Live Streaming — .m3u8 manifests + media segments (fMP4). |
| Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. |
| R2 | Cloudflare object storage ($0 egress; ideal origin for cached HLS). |
| Edge Cache / POP | Cloudflare points-of-presence caching/serving content near viewers. |
| Playout | Continuous generation of a scheduled linear program from a playlist. |
| Normalization | Pre-processing to consistent codec/fps/keyframe settings for seamless playback. |
| Cache Rule | Cloudflare config for caching behavior, TTL, headers per URL pattern. |
| SPOF | Single Point of Failure (current single VPS origin). |
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
References: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js docs, video bitrate calculators. Original root ADR-001-*.md files consolidated and can be archived/deleted.