3 Commits
12 changed files with 2757 additions and 152 deletions

No files matched your search

+282
View File
@@ -0,0 +1,282 @@
# API Reference
This document describes the currently implemented API surfaces in Linear Void.
## Base URLs
- Public app and admin: <https://linearvoid.tv>
- WebSocket endpoint: <wss://linearvoid.tv/ws>
- Local fallback: <http://localhost> and <ws://localhost/ws>
## Authentication Model
Admin REST endpoints use a cookie session.
- Cookie name: linearvoid_admin_session
- Cookie attributes: HttpOnly, SameSite=Strict, Secure when request is HTTPS (or X-Forwarded-Proto=https)
- Session TTL: controlled by ADMIN_SESSION_TTL_HOURS (default 12h)
## Chat WebSocket API
### Connect
Endpoint:
- GET /ws?username={name}&room={room}
Client sends JSON messages over the socket.
### Message Envelope (client -> server)
```json
{
"room": "main",
"username": "viewer123",
"text": "hello world",
"type": "public"
}
```
Notes:
- type is usually public for room chat and private for DM room routing.
- Private room names are formatted like dm:userA-userB (sorted usernames).
- Moderation/system events are sent as type=system messages.
### Message Envelope (server -> client)
```json
{
"room": "main",
"username": "viewer123",
"text": "hello world",
"type": "public",
"timestamp": "2026-08-04T12:34:56Z"
}
```
## Admin Session API
### Login
- POST /api/admin/login
- Auth: none
Request:
```json
{
"username": "admin",
"password": "your-password"
}
```
Success response:
```json
{
"authenticated": true,
"username": "admin"
}
```
### Logout
- POST /api/admin/logout
- Auth: optional (clears cookie either way)
Response:
```json
{
"authenticated": false
}
```
### Session Check
- GET /api/admin/session
- Auth: cookie required
Success response:
```json
{
"authenticated": true,
"username": "admin"
}
```
Failure:
- 401 unauthorized
## Admin Data API
All endpoints below require admin authentication.
### Overview
- GET /api/admin/overview
Returns aggregate counters and runtime/system snapshot fields.
### Playlist
- GET /api/admin/playlist
- PUT /api/admin/playlist
PUT request:
```json
{
"content": "file '/app/media/video1.mp4'\nfile '/app/media/video2.mp4'\n"
}
```
### Schedule
- GET /api/admin/schedule
Returns parsed playlist entries plus matched media metadata/runtime.
### Moderation
- GET /api/admin/moderation/logs?limit=50
- POST /api/admin/moderation/actions
Action request:
```json
{
"action": "mute",
"target": "username-or-id",
"moderator": "admin",
"reason": "spam",
"room": "main"
}
```
### Analytics
- GET /api/admin/analytics
Returns totals (messages, moderation actions, rooms) and top room summary.
### System
- GET /api/admin/system
Returns service health snapshot for chat/playout/redis/database and config metadata.
### Active Users
- GET /api/admin/users
Returns currently discovered presence users (room + username) when Redis is available.
## Media Library API
### Media Collection
- GET /api/admin/media
- POST /api/admin/media (multipart upload, legacy path)
Multipart form field name: file
### Media Item
- GET /api/admin/media/{id}
- PUT /api/admin/media/{id}
- DELETE /api/admin/media/{id}
PUT request:
```json
{
"status": "ready",
"title": "My Clip",
"tags": "music,live"
}
```
## Resumable Upload API
Maximum file size: 2GB
Default chunk size: 8MB
### Initialize or Resume Session
- POST /api/admin/media/uploads
Request:
```json
{
"uploadId": "optional-existing-id",
"fileName": "clip.mp4",
"fileSize": 123456789,
"mimeType": "video/mp4",
"chunkSize": 8388608,
"totalChunks": 15
}
```
Response:
```json
{
"uploadId": "hex-token",
"fileName": "clip.mp4",
"fileSize": 123456789,
"chunkSize": 8388608,
"totalChunks": 15,
"uploadedChunks": 3,
"progress": 20
}
```
### Check Upload Status
- GET /api/admin/media/uploads/{uploadId}
Returns same status envelope as initialize.
### Upload Chunk
- PUT /api/admin/media/uploads/{uploadId}/chunk?index={chunkIndex}
- Content-Type: application/octet-stream
Response returns updated upload status envelope.
### Complete Upload
- POST /api/admin/media/uploads/{uploadId}/complete
On success returns admin media listing payload (files + generatedAt).
## Metrics and Health
### Chat metrics
- GET /metrics
Prometheus exposition includes connected clients, stored messages, moderation actions, Redis indicators, active admin sessions, and build info.
### Health
- GET /health on chat service
- GET /health on playout service
- GET /health on nginx reverse proxy
## HTTP Status Conventions
- 200/201: successful request
- 400: malformed payload, invalid input, missing required fields
- 401: unauthorized admin request
- 404: not found
- 405: method not allowed
- 409: logical conflict (for example, mismatched resumed upload)
- 413: payload too large
- 500: internal server error
+6
View File
@@ -13,6 +13,8 @@
- Added admin UI runtime display for schedule/dashboard plus media library edit/delete actions.
- Added a dedicated plain TypeScript admin module for tab navigation, media CRUD actions, schedule/system analytics refresh, and moderation workflows without Alpine dependencies.
- Added resumable chunked admin media upload flow with upload-session endpoints and client progress indicators.
- Expanded architecture documentation with scaling strategy, API surface reference, detailed video normalization spec, and Cloudflare configuration reference.
- Added `API.md`, `DEPLOYMENT.md`, `MONITORING.md`, and `SECURITY.md` as dedicated operational documentation guides.
### Changed
@@ -20,7 +22,10 @@
- Updated chat runtime image to include ffmpeg/ffprobe for media duration probing on upload.
- Rebuilt viewer, admin, and admin-login pages around custom semantic class structures with accessibility-focused landmarks, tab semantics, and keyboard-focus behavior.
- Replaced legacy and Tailwind-based stylesheet rules with a custom design-token CSS system covering responsive layouts, forms, tables, and reduced-motion behavior.
- Switched the frontend theme to dark-mode-first token defaults with a light-scheme override path for users who prefer light appearance.
- Refactored the viewer page to a single header plus unified content layout with responsive video/chat split, mobile stacking, and chat panel toggle behavior.
- Code-split `hls.js` via lazy loading in the viewer bootstrap to shrink the main frontend bundle and isolate stream playback logic into a dedicated chunk.
- Added navigation links in root README and architecture docs to the new API, deployment, monitoring, and security guide files.
### Fixed
@@ -30,6 +35,7 @@
- Removed Tailwind and Alpine runtime/build dependencies from the frontend package configuration and eliminated Tailwind directives/warnings during production build.
- Enforced 2GB upload ceiling plus server-side media file type/size validation for admin uploads.
- Increased nginx `client_max_body_size` to allow resumable admin upload chunks and prevent `413 Content Too Large` responses.
- Removed legacy now-playing viewer UI elements that conflicted with the streamlined single-header layout.
## [1.0.3] - 2026-08-03
+169
View File
@@ -0,0 +1,169 @@
# Production Deployment Guide
This guide describes a production-style deployment for Linear Void using Docker Compose, Gitea registry images, and Cloudflare fronting.
## Target Topology
- Host: Linux server with Docker Engine
- Reverse proxy: nginx container (linearvoid-nginx)
- Core services: playout, chat, redis, postgres
- Observability: prometheus, grafana
- CDN/edge: Cloudflare
## Prerequisites
- Docker Engine + Docker Compose plugin
- Network access to git.allucanget.biz
- A valid .env file with production values
- DNS configured for linearvoid.tv (and media.linearvoid.tv if split media domain is used)
## Environment File
Use .env with at least:
- CI_REGISTRY_URL
- CI_REGISTRY_NAMESPACE
- CHAT_PORT
- NGINX_PORT
- DATABASE_URL (if overridden)
- REDIS_URL (if overridden)
- ADMIN_USERNAME
- ADMIN_PASSWORD
- VITE_PUBLIC_APP_URL
- VITE_PUBLIC_WS_URL
- VITE_PUBLIC_HLS_URL
- Cloudflare/R2 variables when media sync is enabled
See .env.example and SETUP.md for full variable reference.
## Pull-and-Run Deployment
From repository root:
```bash
docker login git.allucanget.biz
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
```
## First-Time or Recovery Provisioning
1. Ensure host paths exist:
```bash
mkdir -p data/chat data/playlist data/media data/hls
```
1. Confirm volumes and networks:
```bash
docker volume ls
docker network ls
```
1. Start stack:
```bash
docker compose -f docker-compose.prod.yml up -d
```
## Health Verification
After startup, check service status:
```bash
docker compose -f docker-compose.prod.yml ps
```
Endpoint checks:
- App: <http://{host}:8080/>
- Nginx health: <http://{host}:8080/health>
- Chat health: <http://{host}:3000/health>
- Playout health: <http://{host}:8081/health>
- Prometheus: <http://{host}:9090>
- Grafana: <http://{host}:3001>
Domain checks:
- <https://linearvoid.tv>
- <https://linearvoid.tv/admin>
- <https://linearvoid.tv/metrics>
## Zero/Low-Downtime Update Pattern
1. Pull newest images.
1. Recreate changed services only:
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d nginx chat playout
```
1. Re-run health checks.
## Rollback
If new images are unhealthy:
1. Pin previous tags in docker-compose.prod.yml.
2. Pull pinned tags.
3. Restart impacted services.
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d nginx chat playout
```
## Operational Tasks
### View logs
```bash
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
```
### Restart one service
```bash
docker compose -f docker-compose.prod.yml restart chat
```
### Stop stack
```bash
docker compose -f docker-compose.prod.yml down
```
### Keep volumes (recommended) vs remove volumes (destructive)
```bash
# keep data
docker compose -f docker-compose.prod.yml down
# destructive (remove data)
docker compose -f docker-compose.prod.yml down -v
```
## Nginx and Proxy Considerations
- If an external proxy (for example Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx.
- For resumable admin uploads, ensure client_max_body_size supports chunk requests in both proxy layers.
## Security Baseline
- Use strong ADMIN_PASSWORD and rotate regularly.
- Keep Docker host access restricted.
- Prefer HTTPS/WSS through Cloudflare.
- Restrict exposed ports if only proxy access is needed.
## Common Deployment Pitfalls
- Missing frontend build artifacts in local-only compose workflows.
- Wrong registry credentials or missing login.
- Misaligned domain env vars (app/ws/hls) causing mixed-origin errors.
- Proxy-layer body size limits returning 413 on upload.
- Health checks failing before service warmup is complete.
+137
View File
@@ -0,0 +1,137 @@
# Monitoring Guide
This guide covers Prometheus and Grafana setup for Linear Void.
## Current Monitoring Components
- Prometheus container: linearvoid-prometheus
- Grafana container: linearvoid-grafana
- Metrics target: chat service /metrics
- Base scrape config: monitoring/prometheus/prometheus.yml
## Prometheus Configuration
Current scrape config:
- Job chat -> chat:3000
- Job prometheus -> prometheus:9090
- Global scrape interval: 15s
File:
- monitoring/prometheus/prometheus.yml
## Start Monitoring Stack
With compose stack up:
- Prometheus UI: <http://localhost:9090>
- Grafana UI: <http://localhost:3001>
Compose services include prometheus and grafana in both docker-compose.yml and docker-compose.prod.yml.
## Grafana Provisioning
Current mount paths:
- Dashboards directory: monitoring/grafana/dashboards
- Datasources directory: monitoring/grafana/datasources
Notes:
- The dashboards directory includes a Prometheus overview dashboard file.
- Datasource provisioning can be done with a YAML file in monitoring/grafana/datasources, or by configuring a datasource in Grafana UI.
Example datasource provisioning file (create if needed):
```yaml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
```
## Dashboard References
- Provisioned dashboard: [monitoring/grafana/dashboards/dashboard.json](monitoring/grafana/dashboards/dashboard.json)
- Root dashboard snapshot: [monitoring/grafana/dashboard.json](monitoring/grafana/dashboard.json)
Grafana screenshot:
![Grafana dashboard screenshot](docs/img/grafana-screenshot.png)
## Key Metrics to Track
### Chat/Application
- linearvoid_chat_connected_clients
- linearvoid_chat_stored_messages_total
- linearvoid_chat_moderation_actions_total
- linearvoid_chat_redis_enabled
- linearvoid_chat_redis_up
- linearvoid_chat_active_admin_sessions
### Prometheus Runtime
- up
- process_cpu_seconds_total
- process_resident_memory_bytes
- prometheus_tsdb_head_series
- prometheus_tsdb_wal_writes_failed_total
- prometheus_tsdb_compactions_failed_total
## Suggested Alerts
Create a Prometheus rules file (for example monitoring/prometheus/alerts.yml) and wire it into Prometheus.
Recommended baseline alerts:
- Chat target down for 2m
- Prometheus target down for 2m
- Redis indicator down for 2m
- TSDB WAL/compaction failure rate above zero
- Elevated handler/query latency p99
## Validate Monitoring End-to-End
1. Verify /metrics through nginx:
- <https://linearvoid.tv/metrics>
1. Confirm targets in Prometheus:
- Status -> Targets page in Prometheus UI
1. Confirm dashboard data in Grafana:
- Import or open provisioned dashboards
## Troubleshooting
### No chat metrics in Prometheus
- Check chat container health and logs.
- Confirm /metrics is reachable from Prometheus container.
- Verify scrape target is chat:3000 in prometheus.yml.
### Grafana shows no data
- Ensure datasource points to <http://prometheus:9090>.
- Confirm Prometheus is healthy.
- Check dashboard query labels match active jobs.
### High cardinality or query slowness
- Reduce unneeded labels.
- Limit expensive dashboard panels.
- Tune retention and query windows.
## Operational Recommendations
- Keep alert thresholds tied to actual user impact.
- Review dashboards after each release that changes endpoints or metrics names.
- Periodically test alert routing and incident response flow.
+4
View File
@@ -204,6 +204,10 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
- [SETUP.md](SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
- [API.md](API.md) - Implemented WebSocket and REST API reference.
- [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide.
- [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
- [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline.
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
+127
View File
@@ -0,0 +1,127 @@
# Security Guide
This document summarizes security controls and best practices for Linear Void.
## Security Model Overview
Security is layered across:
- Cloudflare edge
- nginx reverse proxy
- chat/admin backend
- container and host operations
## Authentication and Sessions
Admin access is session-cookie based.
- Login endpoint: /api/admin/login
- Session endpoint: /api/admin/session
- Logout endpoint: /api/admin/logout
- Cookie: linearvoid_admin_session
- Cookie flags: HttpOnly, SameSite=Strict, Secure over HTTPS
- Session expiry enforced server-side
Best practices:
- Set strong ADMIN_USERNAME and ADMIN_PASSWORD values.
- Rotate admin credentials periodically.
- Keep ADMIN_SESSION_TTL_HOURS aligned with operational policy.
## Transport Security
- Enforce HTTPS for app/admin endpoints.
- Use WSS for chat WebSocket.
- Keep Cloudflare TLS mode strict and valid at origin.
## Reverse Proxy Controls
nginx controls include:
- auth_request guard for /admin and /api/admin paths
- dedicated unauthorized behavior for admin API responses
- explicit websocket upgrade headers for /ws
- upload body-size limits for resumable media chunk uploads
Best practices:
- Keep proxy body-size and timeout settings aligned across all proxy layers.
- Avoid exposing backend service ports publicly unless required.
## Input Validation and Upload Safety
Server-side protections include:
- extension allow-list for media uploads
- MIME checks
- file-size ceiling (2GB)
- resumable chunk bounds and chunk-index validation
- media path safety checks on delete
Best practices:
- Keep upload chunk size bounded.
- Reject unsupported content types consistently.
- Monitor 4xx/5xx rates on upload routes.
## Rate Limiting and Abuse Controls
- Chat rate limiting uses Redis INCR + EXPIRE windows.
- Current baseline: more than 10 messages in 10 seconds is throttled.
- Moderation actions are logged for auditability.
Best practices:
- Tune thresholds by observed abuse patterns.
- Keep moderation logs reviewed and retained per policy.
## Infrastructure and Secrets
- Store secrets in .env (not in committed source).
- Use Gitea Actions secrets for CI/CD credentials and admin credentials.
- Restrict Docker host control plane exposure.
- Limit who can read deployment environment files.
## Cloudflare and Edge Security
Recommended baseline:
- WAF enabled
- Rate limits on sensitive paths
- Bot protection where appropriate
- Cache rules scoped carefully to avoid caching private/control-plane content
## Data Protection and Retention
- Persistent data: postgres, redis append-only, chat state, playlist/media metadata
- Backup and restore drills should be periodic
- Restore procedures should be tested in isolated environments
## Logging and Monitoring for Security
Track and alert on:
- repeated failed admin login attempts
- spikes in 401 and 403 responses
- sudden upload errors (413/400/500 patterns)
- unusual websocket disconnect or message-rate anomalies
## Hardening Checklist
- [ ] HTTPS/WSS enforced end-to-end
- [ ] Strong admin credentials configured
- [ ] Secrets only in env/secrets stores
- [ ] Proxy auth and websocket headers validated
- [ ] Upload limits and validation active
- [ ] Rate limiting active and tested
- [ ] Cloudflare WAF/rate-limit rules active
- [ ] Backup/restore drills completed
- [ ] Security-related monitoring alerts routed
## Incident Response Baseline
1. Detect issue (alerts/logs/user reports).
2. Triage scope (edge, proxy, app, data plane).
3. Contain impact (rate limit, credential rotate, route disable).
4. Recover services and verify health.
5. Record timeline and corrective actions.
+168 -1
View File
@@ -5,6 +5,7 @@
## Table of Contents
- [Introduction and Goals](#introduction-and-goals)
- [Companion Documentation](#companion-documentation)
- [Architecture Constraints](#architecture-constraints)
- [Context and Scope](#context-and-scope)
- [Solution Strategy](#solution-strategy)
@@ -17,6 +18,13 @@
- [Risks and Technical Debts](#risks-and-technical-debts)
- [Glossary](#glossary)
## Companion Documentation
- [API.md](../../API.md) - Implemented WebSocket and REST API reference.
- [DEPLOYMENT.md](../../DEPLOYMENT.md) - Production deployment and operational runbook.
- [MONITORING.md](../../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
- [SECURITY.md](../../SECURITY.md) - Security model, controls, and hardening baseline.
## Introduction and Goals
### Requirements Overview
@@ -311,7 +319,39 @@ Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = ne
### Video Normalization & Playout
All source assets must be pre-normalized (H.264/AVC, AAC-LC, constant 30 fps, 2s keyframe interval, CRF). Prevents glitches during continuous concat. FFmpeg `-c copy` preferred for efficiency.
All source assets must be pre-normalized before they are accepted into production playlists. This prevents decode resets, timestamp drift, and audio/video discontinuities during continuous concat playout.
#### Required Source Profile (Linear Void v1)
| Field | Required Value | Notes |
| ----------------------- | ---------------------------------- | ------------------------------------------------------- |
| Container | MP4 | Single-video-asset input format |
| Video codec | H.264/AVC (`libx264`) | Baseline delivery codec for broad compatibility |
| Audio codec | AAC-LC | Stereo preferred |
| Resolution | 1280x720 or 1920x1080 | Keep single resolution per playlist block when possible |
| Framerate | Constant 30 fps (`-r 30`) | Variable frame rate content must be normalized |
| GOP / keyframe interval | 2 seconds (`-g 60 -keyint_min 60`) | Matches HLS segment boundaries better |
| Pixel format | `yuv420p` | Browser compatibility baseline |
| Audio sample rate | 48 kHz | Stable timing for concat playout |
| Audio bitrate | 128k-192k | Predictable quality envelope |
#### Reference Normalization Command
```bash
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
```
#### Verification Gate (ffprobe)
- Validate codec, fps, resolution, and keyframe cadence before upload acceptance.
- Reject non-conforming files from admin ingest with actionable error feedback.
- Keep verification logic aligned with runtime playout assumptions to preserve `-c copy` efficiency where possible.
### Monitoring & Resilience
@@ -366,6 +406,98 @@ Operational resilience baseline:
- Weekly backup and restore drills for stateful data (`data/chat`, playlist metadata, Postgres).
- Incident runbook with triage order: edge reachability -> origin health -> playout progression -> chat connectivity -> data-plane dependencies.
### API Documentation Reference
Linear Void currently exposes three operational API surfaces. The canonical detailed contract should live in `API.md`, while this section provides the architecture-level map.
#### Chat WebSocket API
- Endpoint: `/ws`
- Transport: `wss://` in production, `ws://` locally
- Query parameters:
- `username`
- `room`
- Message envelope (typical):
- `room`
- `username`
- `text`
- `type` (`public`, `private`, `system`)
- Responsibilities:
- room fan-out
- private DM channel routing
- moderation command handling
- rate limiting and presence integration
#### Playout and Delivery REST API
- Health endpoints:
- playout health (`/health` on playout service)
- edge/origin health (`/health` via nginx)
- Stream delivery:
- HLS manifest and segment path namespace under `/hls/`
- Responsibilities:
- expose liveness for orchestration and monitoring
- provide stable HLS path contract for viewer and CDN
#### Admin REST API
- Auth/session:
- `/api/admin/login`
- `/api/admin/logout`
- `/api/admin/session`
- Operational/admin domains:
- overview, analytics, users, moderation, schedule, playlist, media
- Media upload domain:
- `/api/admin/media/uploads` (init/resume)
- `/api/admin/media/uploads/{uploadId}/chunk`
- `/api/admin/media/uploads/{uploadId}/complete`
- Responsibilities:
- protected control plane for operator workflows
- media metadata + upload orchestration
- moderation/audit and system status management
### Cloudflare Configuration Reference
Cloudflare is part of the production control plane, not only a CDN edge. The following configuration set is required to maintain cost, latency, and reliability targets.
#### DNS and Zone Routing
- `linearvoid.tv` -> application origin (nginx)
- `www.linearvoid.tv` -> redirect to apex
- `media.linearvoid.tv` -> media domain for HLS/object delivery when split-domain mode is enabled
#### Cache Rules Baseline
- HLS segments (`.ts`, `.m4s`):
- Cache Everything
- long TTL (immutable)
- respect immutable semantics
- Manifests (`.m3u8`):
- short TTL (1-10s)
- revalidation-friendly policy for timeline freshness
#### Edge Security and Controls
- Enforce HTTPS/WSS and redirect HTTP to HTTPS.
- Enable WAF baseline protections for common web exploits.
- Apply rate limits for abusive request patterns (especially auth/admin paths).
- Keep origin exposure minimized (prefer Cloudflare-only public ingress where feasible).
#### Worker and Header Policy
- Use Worker logic for immutable cache headers and request normalization where cache rules alone are insufficient.
- Keep CORS behavior explicit for HLS resources to support browser playback.
#### Operations and Validation
- Validate cache behavior after deployment:
- expected cache status progression (MISS -> HIT)
- manifest freshness under active playout
- Track edge metrics and adjust:
- cache hit ratio
- request volume and error rates
- ToS/cost posture for sustained traffic profiles
### Security & Moderation
Security is split between edge controls (Cloudflare), reverse proxy controls (nginx), and application controls (chat/admin backend).
@@ -490,6 +622,41 @@ See **Quality Goals** table in the Introduction section. Primary scenarios focus
- Trigger: origin CPU/memory pressure on playout host.
- Action: optimize FFmpeg profile, isolate services, add secondary origin.
### Scaling Strategy
Scaling is designed to preserve cost efficiency first, then increase throughput and resilience in controlled phases.
#### Phase 1: Vertical + Cache-First (Current Baseline)
- Single primary origin in US-East with strong Cloudflare edge caching.
- Optimize segment immutability and manifest TTL before adding infrastructure.
- Scale compute vertically when CPU/memory pressure appears on playout/chat host.
#### Phase 2: Service Separation
- Split playout, chat, and observability workloads across dedicated nodes/VMs.
- Isolate noisy workloads (analytics, heavy admin tasks) from latency-sensitive chat paths.
- Introduce managed PostgreSQL/Redis or dedicated state nodes where operationally justified.
#### Phase 3: Multi-Origin Resilience
- Add warm standby or active-secondary origin.
- Use health-aware DNS/origin failover strategy.
- Keep HLS path and cache contract stable so viewer impact during failover is minimized.
#### Phase 4: Regional and Throughput Expansion
- Add additional origin region only when audience distribution and latency justify it.
- Keep chat affinity strategy explicit (regional routing, state replication considerations).
- Continuously evaluate cache miss economics vs additional origin complexity.
#### Scaling Triggers
- Rising cache miss ratio with sustained traffic growth.
- Chat p95/p99 latency drift or websocket error growth.
- Persistent origin resource saturation despite optimization.
- Increased blast-radius concerns from single-host dependency.
### Disaster Recovery and Backup Procedures
#### Recovery Targets
Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

+32 -50
View File
@@ -10,63 +10,45 @@
<body class="lv-body">
<main class="viewer-shell" aria-label="Live player and chat">
<section class="viewer-main" aria-label="Video stream panel">
<header class="viewer-header">
<div class="viewer-brand-group">
<div class="brand-badge" aria-hidden="true">LV</div>
<div>
<h1 class="viewer-title">Linear Void</h1>
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
</div>
<header class="viewer-header">
<div class="viewer-brand-group">
<div class="brand-badge" aria-hidden="true">LV</div>
<div>
<h1 class="viewer-title">Linear Void</h1>
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
</div>
<div class="now-playing-pill" aria-live="polite">
<span class="live-dot" aria-hidden="true"></span>
<span id="now-playing">Loading schedule...</span>
</div>
</header>
</div>
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">Hide Chat</button>
</header>
<section class="viewer-content" aria-label="Live stream and chat layout">
<section class="player-stage" aria-label="Video stage">
<video id="player" class="player" controls autoplay muted playsinline></video>
<div id="now-playing-overlay" class="now-playing-overlay hidden" aria-live="polite">
<div class="now-playing-overlay-inner">
<span class="live-label">LIVE</span>
<span id="overlay-title" class="overlay-title"></span>
</div>
</div>
</section>
<footer class="viewer-footer">
<div>Latency: ~25s • Synced</div>
<nav class="room-links" aria-label="Quick room switch">
<a href="#" onclick="switchRoom('main'); return false;">Main Channel</a>
<a href="#" onclick="switchRoom('chill'); return false;">Chill Room</a>
<a href="#" onclick="switchRoom('talk'); return false;">Talk Room</a>
</nav>
</footer>
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
<header class="chat-header">
<div class="chat-room-title">
<span class="chat-live-marker" aria-hidden="true">●</span>
<span id="room-name">Main Channel</span>
</div>
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
</header>
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
<div class="chat-input-area">
<div class="chat-input-row">
<label for="message-input" class="sr-only">Message input</label>
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if(event.key === 'Enter') sendMessage()" />
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
</div>
<p class="chat-note">
Synced with video • Moderation active
</p>
</div>
</aside>
</section>
<aside class="chat-sidebar" aria-label="Chat panel">
<header class="chat-header">
<div class="chat-room-title">
<span class="chat-live-marker" aria-hidden="true">●</span>
<span id="room-name">Main Channel</span>
</div>
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
</header>
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
<div class="chat-input-area">
<div class="chat-input-row">
<label for="message-input" class="sr-only">Message input</label>
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if(event.key === 'Enter') sendMessage()" />
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
</div>
<p class="chat-note">
Synced with video • Moderation active
</p>
</div>
</aside>
</main>
<script type="module" src="/src/main.ts"></script>
+15 -15
View File
@@ -1,12 +1,13 @@
import "./style.css";
type HlsClass = typeof import("hls.js")["default"];
type HlsClass = (typeof import("hls.js"))["default"];
declare global {
interface Window {
switchRoom: (room: string) => void;
sendMessage: () => void;
togglePrivateDM: () => void;
toggleChatPanel: () => void;
}
}
@@ -14,6 +15,7 @@ let hls: InstanceType<HlsClass> | null = null;
let ws: WebSocket | null = null;
let currentRoom = "main";
let username = "viewer" + Math.floor(Math.random() * 1000);
let chatVisible = true;
function resolveWebSocketBaseUrl() {
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
@@ -134,25 +136,22 @@ async function initPlayerAndChat() {
connectWebSocket("main");
setInterval(() => {
const titles = [
"Midnight Jazz Lounge",
"Synthwave Nights",
"Classic Cinema",
"Ambient Void",
];
const title = titles[Math.floor(Math.random() * titles.length)];
document.getElementById("now-playing")!.textContent = title;
const overlay = document.getElementById("now-playing-overlay")!;
overlay.classList.remove("hidden");
document.getElementById("overlay-title")!.textContent = title;
}, 15000);
console.log(
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
);
}
function toggleChatPanel() {
const shell = document.querySelector(".viewer-shell");
const toggle = document.getElementById("chat-toggle");
if (!shell || !toggle) return;
chatVisible = !chatVisible;
shell.classList.toggle("chat-collapsed", !chatVisible);
toggle.textContent = chatVisible ? "Hide Chat" : "Show Chat";
toggle.setAttribute("aria-expanded", String(chatVisible));
}
function sendMessage() {
const input = document.getElementById("message-input") as HTMLInputElement;
const text = input.value.trim();
@@ -190,6 +189,7 @@ function togglePrivateDM() {
window.switchRoom = switchRoom;
window.sendMessage = sendMessage;
window.togglePrivateDM = togglePrivateDM;
window.toggleChatPanel = toggleChatPanel;
document.addEventListener("DOMContentLoaded", () => {
void initPlayerAndChat();
+188 -86
View File
@@ -1,29 +1,48 @@
:root {
color-scheme: dark;
--font-body:
"Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", Consolas, monospace;
--bg-0: #f4f6ef;
--bg-1: #e3e8db;
--ink-0: #101b18;
--ink-1: #2d3a36;
--ink-soft: #5a6963;
--bg-0: #0d1417;
--bg-1: #172227;
--bg-2: #0a1012;
--ink-0: #edf6f3;
--ink-1: #cadbd6;
--ink-soft: #9cb2ab;
--panel: #fffffd;
--panel-2: #f7fbf2;
--line: #c6d1c5;
--panel: #17252b;
--panel-2: #1f3138;
--line: #30464d;
--line-soft: #3d5962;
--brand: #005f73;
--brand-strong: #00464f;
--brand-contrast: #f9fffd;
--ok: #2f7a43;
--warn: #b76a00;
--danger: #a7283f;
--brand: #2ec4b6;
--brand-strong: #20a79a;
--brand-contrast: #061412;
--ok: #5add94;
--warn: #f2b560;
--danger: #ff7d8f;
--danger-soft: #3a1f27;
--focus: #0a9396;
--shadow-1: 0 14px 30px rgba(31, 54, 47, 0.12);
--shadow-2: 0 22px 44px rgba(20, 39, 35, 0.2);
--focus: #78f2e0;
--shadow-1: 0 14px 30px rgba(0, 0, 0, 0.34);
--shadow-2: 0 22px 44px rgba(0, 0, 0, 0.45);
--bg-grad-a: #132129;
--bg-grad-b: var(--bg-0);
--bg-grad-c: var(--bg-2);
--live-dot: #ff6b6b;
--overlay-bg: rgba(4, 14, 18, 0.84);
--input-bg: #131f24;
--input-border: var(--line-soft);
--placeholder: #90a8a1;
--badge-start: #35c8bb;
--badge-end: #1f8d85;
--radius-1: 0.6rem;
--radius-2: 1rem;
@@ -40,6 +59,50 @@
--fast: 160ms ease;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg-0: #f4f6ef;
--bg-1: #e3e8db;
--bg-2: #eff3ea;
--ink-0: #101b18;
--ink-1: #2d3a36;
--ink-soft: #5a6963;
--panel: #fffffd;
--panel-2: #f7fbf2;
--line: #c6d1c5;
--line-soft: #d8e2d8;
--brand: #005f73;
--brand-strong: #00464f;
--brand-contrast: #f9fffd;
--ok: #2f7a43;
--warn: #b76a00;
--danger: #a7283f;
--danger-soft: #fff3f6;
--focus: #0a9396;
--shadow-1: 0 14px 30px rgba(31, 54, 47, 0.12);
--shadow-2: 0 22px 44px rgba(20, 39, 35, 0.2);
--bg-grad-a: #dce9d6;
--bg-grad-b: var(--bg-0);
--bg-grad-c: #eff3ea;
--live-dot: #cb271a;
--overlay-bg: rgba(0, 16, 19, 0.84);
--input-bg: #fff;
--input-border: #c2d2c1;
--placeholder: #74817b;
--badge-start: #0a9396;
--badge-end: #005f73;
}
}
* {
box-sizing: border-box;
}
@@ -48,6 +111,7 @@ html,
body {
margin: 0;
padding: 0;
height: 100%;
min-height: 100%;
}
@@ -56,9 +120,9 @@ body {
color: var(--ink-0);
background: radial-gradient(
circle at 20% 10%,
#dce9d6 0%,
var(--bg-0) 50%,
#eff3ea 100%
var(--bg-grad-a) 0%,
var(--bg-grad-b) 50%,
var(--bg-grad-c) 100%
);
line-height: 1.45;
}
@@ -172,13 +236,13 @@ select {
}
.btn-danger {
background: #fff3f6;
background: var(--danger-soft);
color: var(--danger);
border-color: #f3c0ca;
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
}
.btn-danger:hover {
background: #ffe8ee;
background: color-mix(in srgb, var(--danger-soft) 70%, var(--danger));
}
.btn-block {
@@ -198,13 +262,13 @@ select {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius-1);
background: #fff;
background: var(--input-bg);
color: var(--ink-0);
padding: 0.65rem 0.8rem;
}
.text-input::placeholder {
color: #74817b;
color: var(--placeholder);
}
.text-area {
@@ -239,7 +303,7 @@ select {
.data-table td {
text-align: left;
padding: 0.72rem 0.65rem;
border-bottom: 1px solid #dfe7de;
border-bottom: 1px solid var(--line-soft);
vertical-align: top;
}
@@ -279,34 +343,44 @@ select {
.lv-body {
min-height: 100vh;
padding: var(--space-5);
width: 100vw;
height: 100vh;
padding: 0;
overflow: hidden;
}
.viewer-shell {
width: min(1250px, 100%);
margin: 0 auto;
width: 100vw;
height: 100vh;
margin: 0;
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
background: linear-gradient(170deg, #f7fbf2 0%, #ebf2e7 40%, #f8fbf5 100%);
grid-template-rows: auto minmax(0, 1fr);
background: linear-gradient(
170deg,
color-mix(in srgb, var(--panel-2) 72%, var(--panel)) 0%,
var(--panel) 45%,
color-mix(in srgb, var(--panel) 75%, var(--bg-1)) 100%
);
border: 1px solid var(--line);
border-radius: var(--radius-3);
border-radius: 0;
box-shadow: var(--shadow-2);
overflow: hidden;
}
.viewer-main {
.viewer-content {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 76vh;
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px);
min-height: 0;
height: 100%;
}
.viewer-header,
.viewer-footer {
.viewer-header {
padding: var(--space-5);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
border-bottom: 1px solid var(--line);
}
.viewer-brand-group {
@@ -324,8 +398,12 @@ select {
font-weight: 800;
font-size: 0.95rem;
letter-spacing: 0.05em;
color: #fff;
background: linear-gradient(140deg, #0a9396 0%, #005f73 100%);
color: var(--brand-contrast);
background: linear-gradient(
140deg,
var(--badge-start) 0%,
var(--badge-end) 100%
);
}
.brand-text,
@@ -347,29 +425,33 @@ select {
align-items: center;
gap: var(--space-2);
border-radius: 999px;
background: #fff;
border: 1px solid #c9d6c8;
background: var(--panel);
border: 1px solid var(--line);
color: var(--ink-1);
padding: 0.35rem 0.8rem;
}
.live-dot,
.chat-live-marker {
color: #cb271a;
color: var(--live-dot);
font-weight: 800;
}
.player-stage {
position: relative;
padding: 0 var(--space-5) var(--space-5);
min-height: 0;
height: 100%;
padding: var(--space-4);
display: grid;
}
.player {
width: 100%;
height: 100%;
border-radius: 1.1rem;
border: 1px solid #bfd0be;
border: 1px solid var(--line-soft);
background: #000;
box-shadow: var(--shadow-1);
object-fit: contain;
}
.now-playing-overlay {
@@ -382,7 +464,7 @@ select {
display: flex;
align-items: center;
gap: var(--space-2);
background: rgba(0, 16, 19, 0.84);
background: var(--overlay-bg);
color: #f0fffb;
padding: 0.44rem 0.72rem;
border-radius: 999px;
@@ -402,23 +484,16 @@ select {
font-size: 0.86rem;
}
.room-links {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.room-links a {
text-decoration: none;
font-weight: 650;
.chat-toggle-btn {
white-space: nowrap;
}
.chat-sidebar {
background: #f0f6ee;
background: color-mix(in srgb, var(--panel) 82%, var(--bg-1));
border-left: 1px solid var(--line);
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 76vh;
min-height: 0;
}
.chat-header,
@@ -430,7 +505,7 @@ select {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #d4dfd3;
border-bottom: 1px solid var(--line-soft);
gap: var(--space-3);
}
@@ -474,20 +549,20 @@ select {
}
.message-other {
background: #ffffff;
border: 1px solid #d3e0d2;
background: var(--panel);
border: 1px solid var(--line-soft);
}
.message-self {
background: #005f73;
color: #fff;
background: var(--brand);
color: var(--brand-contrast);
}
.message-system {
font-size: 0.83rem;
color: #1d4441;
background: #d9efea;
border: 1px solid #b5ddd6;
color: var(--ink-1);
background: color-mix(in srgb, var(--brand) 22%, var(--panel-2));
border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line));
border-radius: 999px;
padding: 0.24rem 0.68rem;
animation: messageIn var(--fast);
@@ -524,16 +599,28 @@ select {
.input {
width: 100%;
border: 1px solid #c2d2c1;
border: 1px solid var(--input-border);
border-radius: var(--radius-1);
padding: 0.65rem 0.8rem;
background: #fff;
background: var(--input-bg);
color: var(--ink-0);
}
.admin-body {
min-height: 100vh;
padding: var(--space-4);
background:
radial-gradient(
circle at 82% 12%,
rgba(20, 48, 54, 0.5) 0%,
transparent 44%
),
radial-gradient(
circle at 16% 85%,
rgba(8, 28, 34, 0.55) 0%,
transparent 40%
),
var(--bg-0);
}
.admin-shell {
@@ -544,13 +631,13 @@ select {
border-radius: var(--radius-3);
overflow: hidden;
border: 1px solid var(--line);
background: #f7fbf3;
background: color-mix(in srgb, var(--panel) 88%, var(--bg-1));
box-shadow: var(--shadow-2);
min-height: calc(100vh - 2 * var(--space-4));
}
.admin-sidebar {
background: #16423d;
background: #102a2f;
color: #ecfff6;
display: grid;
grid-template-rows: auto auto 1fr;
@@ -594,9 +681,9 @@ select {
}
.tab-trigger.is-active {
background: #0a9396;
color: #fff;
border-color: #89d1c8;
background: var(--brand);
color: var(--brand-contrast);
border-color: color-mix(in srgb, var(--brand) 55%, #fff);
}
.admin-main {
@@ -604,6 +691,14 @@ select {
overflow: auto;
}
.viewer-shell.chat-collapsed .viewer-content {
grid-template-columns: 1fr;
}
.viewer-shell.chat-collapsed .chat-sidebar {
display: none;
}
.tab-panel {
display: grid;
gap: var(--space-4);
@@ -622,9 +717,9 @@ select {
}
.panel-card {
border: 1px solid #d8e2d8;
border: 1px solid var(--line-soft);
border-radius: var(--radius-2);
background: #fff;
background: var(--panel);
padding: var(--space-4);
box-shadow: var(--shadow-1);
}
@@ -636,10 +731,10 @@ select {
}
.upload-dropzone {
border: 2px dashed #97b8aa;
border: 2px dashed color-mix(in srgb, var(--brand) 55%, var(--line));
border-radius: var(--radius-2);
padding: var(--space-5);
background: #effaf5;
background: color-mix(in srgb, var(--brand) 12%, var(--panel-2));
display: grid;
justify-items: start;
gap: var(--space-2);
@@ -647,7 +742,7 @@ select {
.upload-dropzone.is-dragover {
border-color: var(--brand);
background: #e6f6ef;
background: color-mix(in srgb, var(--brand) 20%, var(--panel-2));
}
.upload-progress {
@@ -668,9 +763,9 @@ select {
}
.stat-card {
border: 1px solid #dce7db;
border: 1px solid var(--line-soft);
border-radius: var(--radius-2);
background: #fff;
background: var(--panel);
padding: var(--space-4);
box-shadow: var(--shadow-1);
}
@@ -704,7 +799,7 @@ select {
}
.service-row {
border: 1px solid #dbe5db;
border: 1px solid var(--line-soft);
border-radius: var(--radius-1);
padding: 0.75rem;
display: flex;
@@ -748,9 +843,9 @@ select {
}
.auth-card {
border: 1px solid #cad8c9;
border: 1px solid var(--line);
border-radius: var(--radius-3);
background: #fff;
background: var(--panel);
box-shadow: var(--shadow-2);
padding: var(--space-6);
display: grid;
@@ -781,11 +876,15 @@ select {
}
@media (max-width: 1080px) {
.viewer-shell,
.admin-shell {
grid-template-columns: 1fr;
}
.viewer-content {
grid-template-columns: 1fr;
grid-template-rows: minmax(320px, 52vh) minmax(0, 1fr);
}
.chat-sidebar {
border-left: 0;
border-top: 1px solid var(--line);
@@ -821,16 +920,15 @@ select {
}
.viewer-header,
.viewer-footer,
.player-stage,
.chat-header,
.chat-input-area {
padding: var(--space-3);
}
.viewer-footer {
.viewer-header {
align-items: flex-start;
flex-direction: column;
align-items: start;
}
.admin-nav {
@@ -848,6 +946,10 @@ select {
grid-template-columns: 1fr;
}
.viewer-shell.chat-collapsed .viewer-content {
grid-template-rows: 1fr;
}
.auth-card {
padding: var(--space-5);
}
File diff suppressed because it is too large. Load diff