Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7fc921d6a | ||
|
|
04bb783a12 | ||
|
|
68bd241f2c | ||
|
|
4c02ab9d55 | ||
|
|
74bf7e19db | ||
|
|
92e074b6d0 | ||
|
|
a70bad1528 | ||
|
|
b47f2621e1 | ||
|
|
7244c1d658 | ||
|
|
d991d6b6c7 | ||
|
|
db3bf96847 | ||
|
|
0f81a81029 | ||
|
|
b5710548b1 | ||
|
|
d2e5b64ece | ||
|
|
5b5cdc4cd8 |
No files matched your search
+94
-26
@@ -1,18 +1,18 @@
|
|||||||
|
# CD workflow — TEST LAB DEPLOYMENT
|
||||||
|
# Deploys to test lab host 192.168.88.40:2375.
|
||||||
|
# Production Hetzner target TBD — switch DOCKER_HOST when provisioned.
|
||||||
|
# See DEPLOYMENT.md Production Deployment section for cutover plan.
|
||||||
|
|
||||||
name: CD
|
name: CD
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
push:
|
||||||
workflows: ["CI"]
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: [ubuntu-latest]
|
runs-on: [ubuntu-latest]
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
|
||||||
environment: production
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -22,12 +22,11 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Login to registry on remote host
|
||||||
uses: docker/login-action@v3
|
env:
|
||||||
with:
|
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||||
registry: git.allucanget.biz
|
run: |
|
||||||
username: ${{ secrets.CI_REGISTRY_USER }}
|
echo "${{ secrets.CI_REGISTRY_TOKEN }}" | docker login git.allucanget.biz -u "${{ secrets.CI_REGISTRY_USER }}" --password-stdin
|
||||||
password: ${{ secrets.CI_REGISTRY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Prepare environment
|
- name: Prepare environment
|
||||||
run: |
|
run: |
|
||||||
@@ -44,30 +43,99 @@ jobs:
|
|||||||
NGINX_PORT=80
|
NGINX_PORT=80
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Seed Prometheus config
|
- name: Seed Prometheus config on remote host
|
||||||
|
env:
|
||||||
|
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||||
run: |
|
run: |
|
||||||
mkdir -p monitoring/prometheus
|
docker run --rm \
|
||||||
cat > monitoring/prometheus/prometheus.yml << 'EOF'
|
-v prometheus-config:/etc/prometheus \
|
||||||
|
alpine:latest sh -c '
|
||||||
|
cat > /etc/prometheus/prometheus.yml << "PROMEOF"
|
||||||
global:
|
global:
|
||||||
scrape_interval: 15s
|
scrape_interval: 15s
|
||||||
evaluation_interval: 15s
|
evaluation_interval: 15s
|
||||||
|
|
||||||
scrape_configs:
|
rule_files:
|
||||||
- job_name: 'prometheus'
|
- "/etc/prometheus/alerts.yml"
|
||||||
static_configs:
|
|
||||||
- targets: ['localhost:9090']
|
|
||||||
|
|
||||||
- job_name: 'linearvoid'
|
scrape_configs:
|
||||||
|
- job_name: "chat"
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ['playout:8081', 'chat:3000', 'nginx:80']
|
- targets: ["chat:3000"]
|
||||||
EOF
|
- job_name: "playout"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["playout:8081"]
|
||||||
|
- job_name: "nginx"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["nginx-exporter:9113"]
|
||||||
|
- job_name: "redis"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["redis-exporter:9121"]
|
||||||
|
- job_name: "postgres"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["postgres-exporter:9187"]
|
||||||
|
- job_name: "node"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["node-exporter:9100"]
|
||||||
|
- job_name: "prometheus"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["localhost:9090"]
|
||||||
|
PROMEOF
|
||||||
|
cat > /etc/prometheus/alerts.yml << "ALERTSEOF"
|
||||||
|
groups:
|
||||||
|
- name: linearvoid
|
||||||
|
rules:
|
||||||
|
- alert: ChatDown
|
||||||
|
expr: up{job="chat"} == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Chat service is down"
|
||||||
|
- alert: PlayoutDown
|
||||||
|
expr: up{job="playout"} == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Playout service is down"
|
||||||
|
- alert: RedisDown
|
||||||
|
expr: linearvoid_chat_redis_up == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Redis is unreachable"
|
||||||
|
- alert: PlayoutSlateActive
|
||||||
|
expr: linearvoid_playout_mode == 1
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Playout in slate fallback"
|
||||||
|
- alert: HighCPU
|
||||||
|
expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "High CPU usage"
|
||||||
|
- alert: LowDiskSpace
|
||||||
|
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Low disk space"
|
||||||
|
ALERTSEOF
|
||||||
|
'
|
||||||
|
|
||||||
- name: Deploy with docker-compose
|
- name: Deploy with docker-compose
|
||||||
env:
|
env:
|
||||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||||
run: |
|
run: |
|
||||||
docker compose -f docker-compose.prod.yml pull
|
docker compose -f docker-compose.prod.yml pull
|
||||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
docker compose -f docker-compose.prod.yml up -d --remove-orphans --force-recreate
|
||||||
|
|
||||||
- name: Health check
|
- name: Health check
|
||||||
env:
|
env:
|
||||||
@@ -76,9 +144,9 @@ jobs:
|
|||||||
# Wait for services to stabilize
|
# Wait for services to stabilize
|
||||||
sleep 20
|
sleep 20
|
||||||
|
|
||||||
# Check chat health
|
# Check chat health (via docker exec — IP-agnostic, works on any DOCKER_HOST)
|
||||||
echo "Checking chat health..."
|
echo "Checking chat health..."
|
||||||
if ! curl -f http://192.168.88.40:3000/health; then
|
if ! docker exec linearvoid-chat curl -sf http://localhost:3000/health; then
|
||||||
echo "Chat health check failed"
|
echo "Chat health check failed"
|
||||||
docker logs linearvoid-chat --tail 50
|
docker logs linearvoid-chat --tail 50
|
||||||
exit 1
|
exit 1
|
||||||
@@ -86,7 +154,7 @@ jobs:
|
|||||||
|
|
||||||
# Check nginx health
|
# Check nginx health
|
||||||
echo "Checking nginx health..."
|
echo "Checking nginx health..."
|
||||||
if ! curl -f http://192.168.88.40/health/; then
|
if ! docker exec linearvoid-nginx curl -sf http://localhost/health; then
|
||||||
echo "Nginx health check failed"
|
echo "Nginx health check failed"
|
||||||
docker logs linearvoid-nginx --tail 50
|
docker logs linearvoid-nginx --tail 50
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
+5
-23
@@ -19,14 +19,12 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install Frontend Dependencies
|
- name: Install Frontend Dependencies
|
||||||
run: cd frontend && npm ci
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
- name: Lint Frontend
|
- name: Lint Frontend
|
||||||
run: cd frontend && npm run lint || echo "Linting not configured yet"
|
run: cd frontend && npm run lint
|
||||||
|
|
||||||
- name: Build Frontend
|
- name: Build Frontend
|
||||||
run: cd frontend && npm run build
|
run: cd frontend && npm run build
|
||||||
@@ -35,16 +33,17 @@ jobs:
|
|||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.25"
|
go-version: "1.25"
|
||||||
cache: true
|
|
||||||
cache-dependency-path: backend/chat/go.sum
|
|
||||||
env:
|
env:
|
||||||
GOTOOLCHAIN: local
|
GOTOOLCHAIN: local
|
||||||
|
|
||||||
- name: Download Go modules
|
- name: Download Go modules
|
||||||
run: cd backend/chat && GOTOOLCHAIN=local go mod download
|
run: cd backend/chat && GOTOOLCHAIN=local go mod download
|
||||||
|
|
||||||
|
- name: Validate Go
|
||||||
|
run: cd backend/chat && GOTOOLCHAIN=local go vet ./...
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: echo "Tests will be added in future CI steps (per TODO.md)"
|
run: echo "Tests will be added in future CI steps"
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
needs: test
|
needs: test
|
||||||
@@ -133,20 +132,3 @@ jobs:
|
|||||||
labels: ${{ steps.meta-nginx.outputs.labels }}
|
labels: ${{ steps.meta-nginx.outputs.labels }}
|
||||||
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx
|
||||||
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max
|
||||||
|
|
||||||
release:
|
|
||||||
needs: docker
|
|
||||||
runs-on: [ubuntu-latest]
|
|
||||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
generate_release_notes: true
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.CI_REGISTRY_TOKEN }}
|
|
||||||
@@ -81,6 +81,7 @@ Thumbs.db
|
|||||||
# Agent / Tool files
|
# Agent / Tool files
|
||||||
.github/instructions/TODO.md
|
.github/instructions/TODO.md
|
||||||
.github/instructions/DONE.md
|
.github/instructions/DONE.md
|
||||||
|
.github/instructions/BACKLOG.md
|
||||||
# (keep AGENTS.md, MEMORY.md, and other instruction files)
|
# (keep AGENTS.md, MEMORY.md, and other instruction files)
|
||||||
|
|
||||||
# Temporary architecture artifacts
|
# Temporary architecture artifacts
|
||||||
|
|||||||
@@ -272,11 +272,36 @@ Prometheus exposition includes connected clients, stored messages, moderation ac
|
|||||||
|
|
||||||
## HTTP Status Conventions
|
## HTTP Status Conventions
|
||||||
|
|
||||||
- 200/201: successful request
|
| Code | Meaning | When |
|
||||||
- 400: malformed payload, invalid input, missing required fields
|
| ---- | --------------------- | ----------------------------------------------------------- |
|
||||||
- 401: unauthorized admin request
|
| 200 | OK | Successful GET, PUT, or action |
|
||||||
- 404: not found
|
| 201 | Created | Successful POST (upload init, override created) |
|
||||||
- 405: method not allowed
|
| 204 | No Content | Successful DELETE, CORS preflight |
|
||||||
- 409: logical conflict (for example, mismatched resumed upload)
|
| 400 | Bad Request | Malformed JSON, missing required fields, invalid input |
|
||||||
|
| 401 | Unauthorized | Missing or expired admin session |
|
||||||
|
| 403 | Forbidden | Valid session but insufficient permissions |
|
||||||
|
| 404 | Not Found | Resource doesn't exist |
|
||||||
|
| 405 | Method Not Allowed | Wrong HTTP method for endpoint |
|
||||||
|
| 409 | Conflict | Schedule override overlap, upload ID mismatch |
|
||||||
|
| 413 | Payload Too Large | Upload exceeds 2 GB limit |
|
||||||
|
| 422 | Unprocessable | File fails normalization check (codec, fps, resolution) |
|
||||||
|
| 429 | Too Many Requests | Rate limit exceeded (chat messages, WS connections, login) |
|
||||||
|
| 500 | Internal Server Error | Unexpected server failure |
|
||||||
|
| 503 | Service Unavailable | Redis unavailable for user actions, playout health degraded |
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
All limits are per-IP (from `X-Forwarded-For` or `RemoteAddr`) unless noted.
|
||||||
|
|
||||||
|
| Resource | Limit | Window | Response |
|
||||||
|
| --------------------- | ---------------------------------- | ---------------------------------------- | ------------------------------------------------------ |
|
||||||
|
| Chat messages | `CHAT_RATE_LIMIT_MAX` (default 10) | `CHAT_RATE_LIMIT_WINDOW_S` (default 10s) | 429 + `{"type":"system","text":"Rate limit exceeded"}` |
|
||||||
|
| WebSocket connections | 5 | 60s | 429 + `"too many connections from this IP"` |
|
||||||
|
| Admin login | 5 | 5min | 429 + `"too many login attempts, try again later"` |
|
||||||
|
|
||||||
|
Rate limit headers on applicable responses:
|
||||||
|
|
||||||
|
- `X-RateLimit-Limit`: maximum requests per window
|
||||||
|
- `Retry-After`: seconds until window resets
|
||||||
- 413: payload too large
|
- 413: payload too large
|
||||||
- 500: internal server error
|
- 500: internal server error
|
||||||
+79
-1
@@ -2,6 +2,84 @@
|
|||||||
|
|
||||||
<!-- markdownlint-disable MD024 -->
|
<!-- markdownlint-disable MD024 -->
|
||||||
|
|
||||||
|
## [1.0.9] - 2026-08-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Prometheus alerts mount**: `alerts.yml` mounted into prometheus container in both dev and prod compose files.
|
||||||
|
- **Grafana provisioning**: Prometheus datasource + dashboard provider YAML configs in `monitoring/grafana/`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Prod prometheus**: Switched from empty named volume to bind mounts for config files (consistent with dev).
|
||||||
|
- **CD pipeline**: Trigger on `push` (parallel with CI), removed `environment: production` (was causing silent abort).
|
||||||
|
- **CI pipeline**: Removed redundant `release` job (handled by `release.yml`).
|
||||||
|
- **Tag format**: Standardized on `*.*.*` (e.g. `1.0.9`), removed `v` prefix from existing tags.
|
||||||
|
- **DEPLOYMENT.md**: Documented parallel CI/CD pipeline flow with workflow table.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Prometheus config mount**: Switched prod from bind mounts to named volume `prometheus-config`, populated via `docker run` on remote host. Fixes `not a directory` error when bind source path doesn't exist on deployment host.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `.github/MEMORY.md`, `.github/agents/`, `.github/instructions/` — tracking files migrated out of repo.
|
||||||
|
|
||||||
|
## [1.0.8] - 2026-08-06
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **API.md**: Added full HTTP status code matrix (table with 12 codes) and rate limit headers documentation.
|
||||||
|
- **DEPLOYMENT.md**: Added troubleshooting table (8 common symptoms with check/fix).
|
||||||
|
- **MONITORING.md**: Full rewrite with architecture diagram, 7 scrape targets, 8 alert rules, key metrics, troubleshooting.
|
||||||
|
- **SECURITY.md**: Full rewrite with threat model diagram, TLS/HSTS config, network isolation, secrets management, incident response.
|
||||||
|
- **Architecture docs**: Filled Level 2 building blocks (Mermaid diagram, 5 subsystems with code locations) and Level 3 module breakdowns (chat: 9 modules, playout: 6 modules, frontend: 10 modules).
|
||||||
|
|
||||||
|
## [1.0.7] - 2026-08-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Emergency slate fallback**: `scripts/generate-slate.sh` creates "We'll Be Right Back" slate video + pre-rendered HLS. Playout detects FFmpeg failures (>5 restarts/60s) and auto-switches to slate manifest. Health endpoint reports `degraded` mode. Auto-recovers when FFmpeg restarts successfully.
|
||||||
|
- **Prometheus metrics**: `linearvoid_playout_restarts_total`, `linearvoid_playout_mode` (0=live, 1=slate), `linearvoid_chat_dms_total` exposed on playout + chat `/metrics`.
|
||||||
|
- **Video normalization pipeline**: `scripts/normalize.sh` with env-configurable CRF/preset/audio-bitrate wrapping reference FFmpeg command.
|
||||||
|
- **Strict ffprobe verification gate**: `ValidateVideo()` in playout parses ffprobe JSON, checks codec (h264/aac), framerate (30/1), resolution (1280x720 or 1920x1080), pix_fmt (yuv420p), sample_rate (48000), keyframe interval (≤2s). `ValidateVideoStrict()` returns `{valid, errors[]}`.
|
||||||
|
- **Upload normalization enforcement**: `validateMediaNormalization()` runs on every admin upload (single + chunked). Non-conforming files get `needs_normalization` status. Visible in media library + playlist validation warnings.
|
||||||
|
- **Chat history on room join**: `pushChatHistory()` sends last 50 messages via WebSocket on connect.
|
||||||
|
- **DM offline delivery**: Private messages persisted to DB, appear in history when recipient reconnects.
|
||||||
|
- **Online presence indicators**: Green dot per user in chat sidebar, updated via join/leave system messages.
|
||||||
|
- **Schedule metadata overlay**: "Now Playing" + "Up Next" below viewer player, polling `GET /api/schedule` every 30s. Public `/api/schedule` endpoint added.
|
||||||
|
- **Playlist drag-to-reorder**: Sortable list with drag-and-drop, per-entry remove buttons, validation warnings (missing files, needs_normalization).
|
||||||
|
- **Schedule overrides**: `ScheduleOverride` model + `GET/POST/DELETE /api/admin/schedule/overrides` with conflict detection (409 on overlap). Admin UI with form + list.
|
||||||
|
- **User management actions**: `POST /api/admin/users` with mute/unmute/ban/unban actions. Per-user action buttons in admin users table.
|
||||||
|
- **Inline moderation**: Mute/Ban buttons on each chat log entry row in admin.
|
||||||
|
- **CSV export**: `GET /api/admin/chat-log/export` returns CSV download.
|
||||||
|
- **Analytics time range selector**: 1h, 6h, 24h, 7d dropdown on admin analytics page.
|
||||||
|
- **Runtime config editor**: `GET/PUT /api/admin/config` for rate limit max + window. Admin system page with config form + restart buttons.
|
||||||
|
- **Production target documentation**: `DEPLOYMENT.md` updated with two-environment model (test lab 192.168.88.0/24 vs Hetzner production TBD), CX22 specs, firewall requirements, DNS cutover plan.
|
||||||
|
- **Network diagram**: Mermaid diagram in `SETUP.md` distinguishing test lab from planned production.
|
||||||
|
- **E2E test script**: `scripts/test-playout-e2e.sh` for runtime validation in test lab.
|
||||||
|
- **Redis pub/sub stub**: Architecture ready for multi-instance chat (pending implementation).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `docker-compose.yml` + `.prod.yml`: added slate volume mount (`./data/slate:/app/slate:ro`), Redis `--maxmemory 256mb --maxmemory-policy allkeys-lru`.
|
||||||
|
- `backend/playout/Dockerfile`: added `/app/slate` directory.
|
||||||
|
- `handleConnection`: added 30s presence heartbeat + WebSocket ping, banned/muted user checks, env-configurable rate limits (`CHAT_RATE_LIMIT_MAX`, `CHAT_RATE_LIMIT_WINDOW_S`).
|
||||||
|
- `handleModerationCommand`: added moderator role check, mute duration parsing, Redis key enforcement.
|
||||||
|
- `handlePrivateDM`: targeted delivery via `clientMeta` map instead of broadcast.
|
||||||
|
- `initRedis`: added `redisReconnectLoop` with exponential backoff (2s→60s).
|
||||||
|
- `handleAdminMedia` + chunked upload: normalization validation runs on every upload, sets `needs_normalization` status.
|
||||||
|
- nginx: OPTIONS preflight → 204 on all 4 HLS locations. `/health` returns JSON. `/nginx-status` with stub_status (internal-network restricted).
|
||||||
|
- CD workflow: health checks now IP-agnostic (`docker exec` instead of hardcoded IP curls).
|
||||||
|
- `docker-compose.yml` + `.prod.yml`, `.gitea/workflows/cd.yml`, `DEPLOYMENT.md`, `SETUP.md`, `docs/architecture/README.md`, `README.md`: all annotated with test-lab vs production distinction, `192.168.88.0/24` flagged as internal test network.
|
||||||
|
- `docs/architecture/README.md`: CI/CD deployment view updated to note test lab target, production pending.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- CD health check no longer hardcodes `192.168.88.40` in curl targets — uses `docker exec` for portability.
|
||||||
|
- Playlist page no longer single-textarea — now shows sortable list with validation warnings.
|
||||||
|
- Schedule page shows overrides alongside playlist timeline.
|
||||||
|
|
||||||
## [1.0.6] - 2026-08-05
|
## [1.0.6] - 2026-08-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -84,7 +162,7 @@ Full backend and frontend implementation complete.
|
|||||||
- **Frontend** (`frontend/`): Vite + TypeScript + Tailwind + Alpine.js, HLS player with hls.js, chat sidebar with room switching and private messaging, "Now Playing" overlay, and modern UI (ADR-002, ADR-003).
|
- **Frontend** (`frontend/`): Vite + TypeScript + Tailwind + Alpine.js, HLS player with hls.js, chat sidebar with room switching and private messaging, "Now Playing" overlay, and modern UI (ADR-002, ADR-003).
|
||||||
- Complete `docker-compose.yml` for local development with all services, volumes, and healthchecks.
|
- Complete `docker-compose.yml` for local development with all services, volumes, and healthchecks.
|
||||||
- All ADRs (001-007) created and linked in documentation.
|
- All ADRs (001-007) created and linked in documentation.
|
||||||
- `TODO.md`, `DONE.md`, and `CHANGELOG.md` updated with full traceability.
|
- `CHANGELOG.md` updated with full traceability.
|
||||||
- Git repository initialized with comprehensive initial commit.
|
- Git repository initialized with comprehensive initial commit.
|
||||||
|
|
||||||
The project is a fully functional, self-hosted 24/7 linear video channel with synchronized playback, custom chat, R2 delivery, moderation, and admin-ready structure. It matches the architecture defined in the arc42 template and all ADRs.
|
The project is a fully functional, self-hosted 24/7 linear video channel with synchronized playback, custom chat, R2 delivery, moderation, and admin-ready structure. It matches the architecture defined in the arc42 template and all ADRs.
|
||||||
|
|||||||
+8
-10
@@ -7,9 +7,8 @@ Thank you for considering contributing to Linear Void!
|
|||||||
1. **Read the Architecture First**: All design decisions are documented in `docs/architecture/README.md` (arc42 template) and the ADRs in `docs/architecture/adr/` (ADR-001 to ADR-007).
|
1. **Read the Architecture First**: All design decisions are documented in `docs/architecture/README.md` (arc42 template) and the ADRs in `docs/architecture/adr/` (ADR-001 to ADR-007).
|
||||||
2. **Follow the Structure**: Use the folder layout defined in ADR-002 (`frontend/`, `backend/playout/`, `backend/chat/`, `infrastructure/`, etc.).
|
2. **Follow the Structure**: Use the folder layout defined in ADR-002 (`frontend/`, `backend/playout/`, `backend/chat/`, `infrastructure/`, etc.).
|
||||||
3. **Update Documentation Before Code**: Create or update an ADR before implementing significant changes.
|
3. **Update Documentation Before Code**: Create or update an ADR before implementing significant changes.
|
||||||
4. **Update TODO.md**: Plan tasks in `.github/instructions/TODO.md`, mark as completed, and move finished sections to `DONE.md`.
|
4. **Commit Messages**: Reference the relevant ADR (e.g., "feat(playout): add R2 sync per ADR-001").
|
||||||
5. **Commit Messages**: Reference the relevant ADR (e.g., "feat(playout): add R2 sync per ADR-001").
|
5. **Test with Docker**: Run `docker compose up --build` to test changes.
|
||||||
6. **Test with Docker**: Run `docker compose up --build` to test changes.
|
|
||||||
|
|
||||||
## Repository
|
## Repository
|
||||||
|
|
||||||
@@ -40,22 +39,21 @@ Linear Void follows **Semantic Versioning** ([SemVer](https://semver.org)): `MAJ
|
|||||||
- **MINOR** — new features (endpoints, admin tabs, UI capabilities) that are backward-compatible.
|
- **MINOR** — new features (endpoints, admin tabs, UI capabilities) that are backward-compatible.
|
||||||
- **PATCH** — bug fixes, optimizations, documentation polish.
|
- **PATCH** — bug fixes, optimizations, documentation polish.
|
||||||
|
|
||||||
Git tags use a `v` prefix: `1.0.2`, `1.1.0`, `v2.0.0`.
|
Git tags use no prefix: `1.0.2`, `1.1.0`, `2.0.0`.
|
||||||
|
|
||||||
### Where Versions Are Declared
|
### Where Versions Are Declared
|
||||||
|
|
||||||
| File | Token | Example |
|
| File | Token | Example |
|
||||||
| ------------------------------ | -------------- | ------------------------- |
|
| ----------------------- | ------------- | ------------------------- |
|
||||||
| `frontend/package.json` | `"version"` | `"1.0.2"` |
|
| `frontend/package.json` | `"version"` | `"1.0.2"` |
|
||||||
| `CHANGELOG.md` | `## [X.Y.Z]` | `## [1.0.2] - 2026-08-05` |
|
| `CHANGELOG.md` | `## [X.Y.Z]` | `## [1.0.2] - 2026-08-05` |
|
||||||
| `README.md` | Version badge | `version-1.0.2-blue` |
|
| `README.md` | Version badge | `version-1.0.2-blue` |
|
||||||
| `.github/instructions/TODO.md` | Section header | `## Version 1.0.2` |
|
|
||||||
|
|
||||||
### Tag Triggers
|
### Tag Triggers
|
||||||
|
|
||||||
| Tag Pattern | Workflow | What Happens |
|
| Tag Pattern | Workflow | What Happens |
|
||||||
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
| ---------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `v*` (e.g. `1.0.2`) | `release.yml` | Builds all 3 images, pushes to registry with semver tags (`1.0.2`, `1.0`, `latest`), creates Gitea Release via API |
|
| `*.*.*` (e.g. `1.0.2`) | `release.yml` | Builds all 3 images, pushes to registry with semver tags (`1.0.2`, `1.0`, `latest`), creates Gitea Release via API |
|
||||||
| Push to `main` | `ci.yml` → `cd.yml` | CI builds + pushes images with `main`/`latest` tags, CD deploys to production |
|
| Push to `main` | `ci.yml` → `cd.yml` | CI builds + pushes images with `main`/`latest` tags, CD deploys to production |
|
||||||
|
|
||||||
### Full Release Procedure
|
### Full Release Procedure
|
||||||
@@ -87,7 +85,7 @@ Add entries under `## [X.Y.Z]` in `CHANGELOG.md` following [Keep a Changelog](ht
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Stage all version bumps + changelog
|
# Stage all version bumps + changelog
|
||||||
git add frontend/package.json CHANGELOG.md README.md .github/instructions/TODO.md
|
git add frontend/package.json CHANGELOG.md README.md
|
||||||
|
|
||||||
# Commit
|
# Commit
|
||||||
git commit -m "chore: bump version to X.Y.Z"
|
git commit -m "chore: bump version to X.Y.Z"
|
||||||
@@ -145,6 +143,6 @@ git.allucanget.biz/zwitschi/linearvoid/playout:latest
|
|||||||
|
|
||||||
### Moving Completed Tasks
|
### Moving Completed Tasks
|
||||||
|
|
||||||
After release, move completed sections from `TODO.md` to `DONE.md` and update `CHANGELOG.md` if needed. The `DONE.md` file serves as the historical record linking tasks to releases.
|
After release, update `CHANGELOG.md` if needed.
|
||||||
|
|
||||||
See the ADRs for architectural decisions and `docs/architecture/README.md` for the big picture. Thank you for contributing!
|
See the ADRs for architectural decisions and `docs/architecture/README.md` for the big picture. Thank you for contributing!
|
||||||
+114
-3
@@ -1,6 +1,103 @@
|
|||||||
# Production Deployment Guide
|
# Deployment Guide
|
||||||
|
|
||||||
This guide describes a production-style deployment for Linear Void using Docker Compose, Gitea registry images, and Cloudflare fronting.
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Deployment Guide](#deployment-guide)
|
||||||
|
- [Table of Contents](#table-of-contents)
|
||||||
|
- [Environments](#environments)
|
||||||
|
- [Production Target (Hetzner)](#production-target-hetzner)
|
||||||
|
- [Planned Infrastructure](#planned-infrastructure)
|
||||||
|
- [Cutover Plan](#cutover-plan)
|
||||||
|
- [Firewall / Security Group](#firewall--security-group)
|
||||||
|
- [Pipeline Readiness](#pipeline-readiness)
|
||||||
|
- [Target Topology](#target-topology)
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Environment File](#environment-file)
|
||||||
|
- [Pull-and-Run Deployment](#pull-and-run-deployment)
|
||||||
|
- [First-Time or Recovery Provisioning](#first-time-or-recovery-provisioning)
|
||||||
|
- [Health Verification](#health-verification)
|
||||||
|
- [Zero/Low-Downtime Update Pattern](#zerolow-downtime-update-pattern)
|
||||||
|
- [Rollback](#rollback)
|
||||||
|
- [Operational Tasks](#operational-tasks)
|
||||||
|
- [View logs](#view-logs)
|
||||||
|
- [Restart one service](#restart-one-service)
|
||||||
|
- [Stop stack](#stop-stack)
|
||||||
|
- [Keep volumes (recommended) vs remove volumes (destructive)](#keep-volumes-recommended-vs-remove-volumes-destructive)
|
||||||
|
- [Nginx and Proxy Considerations](#nginx-and-proxy-considerations)
|
||||||
|
- [Security Baseline](#security-baseline)
|
||||||
|
- [Common Deployment Pitfalls](#common-deployment-pitfalls)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
## Environments
|
||||||
|
|
||||||
|
Linear Void runs in two separate environments:
|
||||||
|
|
||||||
|
| Environment | Host | Docker Target | Purpose |
|
||||||
|
| -------------- | -------------------------- | -------------------------- | ---------------------------------------------- |
|
||||||
|
| **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation |
|
||||||
|
| **Production** | Hetzner VPS (TBD) | `tcp://<hetzner-ip>:2375` | Public linearvoid.tv serving real viewers |
|
||||||
|
|
||||||
|
**Current state (2026-08-06):** Test lab is active and receives automated CD deploys. Production Hetzner VPS is **not yet provisioned**. The CD workflow (`cd.yml`) and `docker-compose.prod.yml` currently target the test lab. Switching to production requires only changing `DOCKER_HOST` — see [Production Deployment](#production-target-hetzner) below.
|
||||||
|
|
||||||
|
## Production Target (Hetzner)
|
||||||
|
|
||||||
|
**Status: Not provisioned.** The production deployment plan is documented and the pipeline is ready — only the VPS needs to be created.
|
||||||
|
|
||||||
|
### Planned Infrastructure
|
||||||
|
|
||||||
|
| Resource | Spec |
|
||||||
|
| -------------- | ------------------------------------------------ |
|
||||||
|
| Provider | Hetzner Cloud |
|
||||||
|
| Region | Ashburn, VA (us-east) |
|
||||||
|
| Instance | CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent |
|
||||||
|
| OS | Ubuntu 24.04 LTS or Debian 12 |
|
||||||
|
| Bandwidth | 20 TB included traffic/month |
|
||||||
|
| Estimated cost | ~€7–10/month base |
|
||||||
|
|
||||||
|
### Cutover Plan
|
||||||
|
|
||||||
|
1. Provision Hetzner VPS (see [Production Deployment](#production-target-hetzner) section)
|
||||||
|
2. Add production secrets to Gitea (`CI_PROD_*` prefix)
|
||||||
|
3. Update `DOCKER_HOST` in CD workflow to Hetzner public IP
|
||||||
|
4. Trigger deploy → validate health checks
|
||||||
|
5. Update Cloudflare A record `linearvoid.tv` → Hetzner IP
|
||||||
|
6. Enable HTTPS, WAF, cache rules on production zone
|
||||||
|
7. Decommission test lab as public target
|
||||||
|
|
||||||
|
### Firewall / Security Group
|
||||||
|
|
||||||
|
| Port | Source | Purpose |
|
||||||
|
| ---- | --------------------- | ------------------------------ |
|
||||||
|
| 22 | Admin IPs | SSH management |
|
||||||
|
| 80 | 0.0.0.0/0 | HTTP (redirect to HTTPS) |
|
||||||
|
| 443 | 0.0.0.0/0 | HTTPS (nginx) |
|
||||||
|
| 2375 | Gitea runner IP only | Docker remote API (TLS) |
|
||||||
|
| 9090 | Admin IPs or internal | Prometheus (or tunnel via SSH) |
|
||||||
|
| 3001 | Admin IPs or internal | Grafana (or tunnel via SSH) |
|
||||||
|
|
||||||
|
### Pipeline Readiness
|
||||||
|
|
||||||
|
Three Gitea Actions workflows drive the full pipeline:
|
||||||
|
|
||||||
|
| Workflow | Trigger | Purpose |
|
||||||
|
| ------------- | ---------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push Docker images (`:latest`, `:main`) |
|
||||||
|
| `cd.yml` | Push to `main` | Deploy to test lab (`192.168.88.40:2375`) — runs **in parallel** with CI |
|
||||||
|
| `release.yml` | Tag push `*.*.*` | Build & push versioned Docker images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
|
||||||
|
|
||||||
|
CI and CD run simultaneously on every push to `main`. The deploy pulls whatever `:latest` images are available in the registry at that moment.
|
||||||
|
|
||||||
|
The CD workflow (`cd.yml`) uses `DOCKER_HOST` to target the deployment host. To switch targets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test lab (current)
|
||||||
|
DOCKER_HOST=tcp://192.168.88.40:2375
|
||||||
|
|
||||||
|
# Production (when provisioned)
|
||||||
|
DOCKER_HOST=tcp://<hetzner-public-ip>:2375
|
||||||
|
```
|
||||||
|
|
||||||
|
No code changes needed in the pipeline. See `DEPLOYMENT.md` Production Deployment section for full checklist.
|
||||||
|
|
||||||
## Target Topology
|
## Target Topology
|
||||||
|
|
||||||
@@ -40,7 +137,7 @@ See .env.example and SETUP.md for full variable reference.
|
|||||||
|
|
||||||
Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning-and-releases) for the release procedure).
|
Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning-and-releases) for the release procedure).
|
||||||
|
|
||||||
Every push to `main` produces `:main` and `:latest` tags. Semantic version tags (`v*`) produce `:X.Y.Z`, `:X.Y`, and `:latest` tags.
|
Every push to `main` triggers CI (builds `:main` and `:latest` tags) and CD (deploys `:latest` to test lab) in parallel. Semantic version tags (`*.*.*`, e.g. `1.0.9`) trigger the release workflow which builds `:X.Y.Z`, `:X.Y`, and `:latest` tags and creates a Gitea Release.
|
||||||
|
|
||||||
From repository root:
|
From repository root:
|
||||||
|
|
||||||
@@ -169,5 +266,19 @@ docker compose -f docker-compose.prod.yml down -v
|
|||||||
- Missing frontend build artifacts in local-only compose workflows.
|
- Missing frontend build artifacts in local-only compose workflows.
|
||||||
- Wrong registry credentials or missing login.
|
- Wrong registry credentials or missing login.
|
||||||
- Misaligned domain env vars (app/ws/hls) causing mixed-origin errors.
|
- Misaligned domain env vars (app/ws/hls) causing mixed-origin errors.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Check | Fix |
|
||||||
|
| -------------------------- | --------------------------------------------- | ------------------------------------------------------------- |
|
||||||
|
| Service not starting | `docker logs linearvoid-<svc> --tail 50` | Check `.env` vars, volume mounts, port conflicts |
|
||||||
|
| Playout stall / slate mode | `curl http://<host>:8081/health` — mode=slate | Check playlist file, media paths, disk space |
|
||||||
|
| Chat not connecting | `curl http://<host>:3000/health` | Check Redis: `docker exec linearvoid-redis redis-cli ping` |
|
||||||
|
| Admin 401 loop | Cookie not sent | Verify `X-Forwarded-Proto: https`, nginx auth_request headers |
|
||||||
|
| Upload fails (413) | File too large | Check nginx `client_max_body_size` (should be 64m+) |
|
||||||
|
| HLS not playing | `curl http://<host>/hls/playlist.m3u8` | Check playout health, HLS dir permissions |
|
||||||
|
| Prometheus no targets | `curl http://<host>:9090/targets` | Verify container network, scrape config |
|
||||||
|
| Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned |
|
||||||
|
|
||||||
- Proxy-layer body size limits returning 413 on upload.
|
- Proxy-layer body size limits returning 413 on upload.
|
||||||
- Health checks failing before service warmup is complete.
|
- Health checks failing before service warmup is complete.
|
||||||
+53
-99
@@ -1,122 +1,76 @@
|
|||||||
# Monitoring Guide
|
# Monitoring Guide
|
||||||
|
|
||||||
This guide covers Prometheus and Grafana setup for Linear Void.
|
Prometheus + Grafana monitoring for Linear Void.
|
||||||
|
|
||||||
## Current Monitoring Components
|
## Architecture
|
||||||
|
|
||||||
- Prometheus container: linearvoid-prometheus
|
```text
|
||||||
- Grafana container: linearvoid-grafana
|
┌──────────────┐ ┌──────────────┐ ┌───────────┐
|
||||||
- Metrics target: chat service /metrics
|
│ Exporters │───> │ Prometheus │───>│ Grafana │
|
||||||
- Base scrape config: monitoring/prometheus/prometheus.yml
|
│ (7 targets) │ │ :9090 │ │ :3001 │
|
||||||
|
└──────────────┘ └───────┬──────┘ └───────────┘
|
||||||
## Prometheus Configuration
|
│
|
||||||
|
alerts.yml (8 rules)
|
||||||
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
|
## Scrape Targets
|
||||||
|
|
||||||
- Provisioned dashboard: [monitoring/grafana/dashboards/dashboard.json](monitoring/grafana/dashboards/dashboard.json)
|
| Job | Target | Port | Metrics |
|
||||||
- Root dashboard snapshot: [monitoring/grafana/dashboard.json](monitoring/grafana/dashboard.json)
|
| ---------- | ----------------- | ---- | ------------------------------------------------------------- |
|
||||||
|
| chat | chat | 3000 | WebSocket clients, messages, DMs, moderation, Redis, sessions |
|
||||||
|
| playout | playout | 8081 | Restarts, mode (live/slate) |
|
||||||
|
| nginx | nginx-exporter | 9113 | Requests, connections |
|
||||||
|
| redis | redis-exporter | 9121 | Memory, clients, commands |
|
||||||
|
| postgres | postgres-exporter | 9187 | Connections, queries, locks |
|
||||||
|
| node | node-exporter | 9100 | CPU, memory, disk, network |
|
||||||
|
| prometheus | prometheus | 9090 | Self-monitoring |
|
||||||
|
|
||||||
Grafana screenshot:
|
Config: `monitoring/prometheus/prometheus.yml` (15s scrape interval)
|
||||||
|
|
||||||

|
## Alerting Rules
|
||||||
|
|
||||||
## Key Metrics to Track
|
8 rules in `monitoring/prometheus/alerts.yml`:
|
||||||
|
|
||||||
### Chat/Application
|
| Alert | Severity | Expression |
|
||||||
|
| -------------------- | -------- | -------------------------------------- |
|
||||||
|
| ChatDown | critical | `up{job="chat"} == 0` for 2m |
|
||||||
|
| PlayoutDown | critical | `up{job="playout"} == 0` for 2m |
|
||||||
|
| RedisDown | warning | `linearvoid_chat_redis_up == 0` for 2m |
|
||||||
|
| PlayoutSlateActive | warning | `linearvoid_playout_mode == 1` for 1m |
|
||||||
|
| HighChatLatency | warning | message rate <0.01/s for 5m |
|
||||||
|
| PrometheusWALFailing | warning | WAL write failures >0 for 5m |
|
||||||
|
| HighCPU | warning | CPU >80% for 10m |
|
||||||
|
| LowDiskSpace | warning | Disk <10% free for 5m |
|
||||||
|
|
||||||
- linearvoid_chat_connected_clients
|
## Access
|
||||||
- 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
|
- Prometheus: `http://<host>:9090` (targets, graph, alerts)
|
||||||
|
- Grafana: `http://<host>:3001` (default: admin/admin, change via `GRAFANA_PASSWORD`)
|
||||||
|
|
||||||
- up
|
## Dashboards
|
||||||
- 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
|
- **Linear Void app**: `monitoring/grafana/dashboard.json` — clients, messages, moderation, Redis, sessions, scrape health
|
||||||
|
- **Prometheus ops**: `monitoring/grafana/dashboards/dashboard.json` — TSDB, CPU/memory, WAL, compactions, query latency
|
||||||
|
|
||||||
Create a Prometheus rules file (for example monitoring/prometheus/alerts.yml) and wire it into Prometheus.
|
## Key Metrics
|
||||||
|
|
||||||
Recommended baseline alerts:
|
- `linearvoid_chat_connected_clients` — active WebSocket connections
|
||||||
|
- `linearvoid_chat_messages_total` — total stored messages
|
||||||
- Chat target down for 2m
|
- `linearvoid_chat_dms_total` — private messages sent
|
||||||
- Prometheus target down for 2m
|
- `linearvoid_chat_redis_up` — Redis reachable (0/1)
|
||||||
- Redis indicator down for 2m
|
- `linearvoid_playout_restarts_total` — FFmpeg restart counter
|
||||||
- TSDB WAL/compaction failure rate above zero
|
- `linearvoid_playout_mode` — 0=live, 1=slate, 2=degraded
|
||||||
- Elevated handler/query latency p99
|
- `linearvoid_chat_admin_sessions_active` — active admin logins
|
||||||
|
|
||||||
## 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
|
## Troubleshooting
|
||||||
|
|
||||||
### No chat metrics in Prometheus
|
| Symptom | Check |
|
||||||
|
| ----------------------- | -------------------------------------------------------------------- |
|
||||||
- Check chat container health and logs.
|
| No chat metrics | `curl http://chat:3000/metrics` from Prometheus container |
|
||||||
- Confirm /metrics is reachable from Prometheus container.
|
| No exporter metrics | Verify exporter containers running: `docker ps \| grep exporter` |
|
||||||
- Verify scrape target is chat:3000 in prometheus.yml.
|
| Grafana no data | Datasource URL must be `http://prometheus:9090` |
|
||||||
|
| Prometheus targets down | Check container network: all must be on `linearvoid-net` |
|
||||||
|
| Alert not firing | Check Prometheus → Alerts tab, verify `rule_files` in prometheus.yml |
|
||||||
|
|
||||||
### Grafana shows no data
|
### Grafana shows no data
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://git.allucanget.biz)
|
[](https://git.allucanget.biz)
|
||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The architecture (detailed in [`docs/architecture/README.md`](docs/architecture/README.md)) uses:
|
The architecture (detailed in [`docs/architecture/README.md`](docs/architecture/README.md)) uses:
|
||||||
|
|
||||||
- Hetzner Ashburn VPS for playout (FFmpeg + MediaMTX/SRS) and chat.
|
- Hetzner Ashburn VPS for playout (FFmpeg + MediaMTX/SRS) and chat _(planned production; currently test lab at 192.168.88.40)_.
|
||||||
- Cloudflare R2 + CDN with aggressive immutable caching of HLS `.ts` segments ($0 egress).
|
- Cloudflare R2 + CDN with aggressive immutable caching of HLS `.ts` segments ($0 egress).
|
||||||
- Lightweight static frontend and WebSocket chat backend.
|
- Lightweight static frontend and WebSocket chat backend.
|
||||||
- Gitea self-hosted CI/CD (Actions) and Container Registry.
|
- Gitea self-hosted CI/CD (Actions) and Container Registry.
|
||||||
@@ -112,6 +112,8 @@ This structure directly reflects the building blocks, runtime flows, deployment
|
|||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
> **Note:** The `docker compose up` below targets local development. The test lab (192.168.88.40) receives automated CD deploys. Production Hetzner VPS is not yet provisioned — see `DEPLOYMENT.md`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
||||||
cd linearvoid
|
cd linearvoid
|
||||||
@@ -133,6 +135,7 @@ See the [architecture docs](docs/architecture/README.md) for detailed architectu
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
- Use Docker Compose for local development (playout + chat + frontend).
|
- Use Docker Compose for local development (playout + chat + frontend).
|
||||||
|
- Test lab host: `192.168.88.40` (internal network, receives CD deploys). Production Hetzner VPS TBD.
|
||||||
- Public production host model: `https://linearvoid.tv` for viewer/admin/chat and `https://media.linearvoid.tv` for HLS delivery.
|
- Public production host model: `https://linearvoid.tv` for viewer/admin/chat and `https://media.linearvoid.tv` for HLS delivery.
|
||||||
- Normalize all source videos before adding to playlist (see ADR-001 for exact codec/fps/keyframe requirements).
|
- Normalize all source videos before adding to playlist (see ADR-001 for exact codec/fps/keyframe requirements).
|
||||||
- Monitor cache hit ratio and R2 operations closely — they determine real cost.
|
- Monitor cache hit ratio and R2 operations closely — they determine real cost.
|
||||||
@@ -220,11 +223,11 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
|
|||||||
Contributions are welcome! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines. Quick overview:
|
Contributions are welcome! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines. Quick overview:
|
||||||
|
|
||||||
1. Read the [architecture docs](docs/architecture/README.md) and ADRs first.
|
1. Read the [architecture docs](docs/architecture/README.md) and ADRs first.
|
||||||
2. Plan tasks in `.github/instructions/TODO.md`.
|
2. Code against the defined module structure (frontend, backend/playout, backend/chat, infrastructure, scripts, monitoring).
|
||||||
3. Open PRs on [Gitea](https://git.allucanget.biz/zwitschi/linearvoid).
|
3. Open PRs on [Gitea](https://git.allucanget.biz/zwitschi/linearvoid).
|
||||||
4. CI validates builds and tests automatically.
|
4. CI validates builds and tests automatically.
|
||||||
|
|
||||||
Since this a linear project, we also rely on a linear development and release model: Only main branch is used for releases, and all features are merged into main after review and CI validation. Releases are tagged with semantic versioning (1.0.3, 1.0.4, etc.) and automatically built/pushed to the container registry.
|
Since this a linear project, we also rely on a linear development and release model: Only main branch is used for releases, and all features are merged into main after review and CI validation. Releases are tagged with semantic versioning (1.0.3, 1.0.4, etc.) and automatically built/pushed to the container registry. Version numbers are declared in `frontend/package.json`, `CHANGELOG.md`, and the version badge in this README. For patches, we use even numbers for stable versions (1.0.2, 1.0.4) and odd numbers for pre-release/unstable versions (1.0.3, 1.0.5).
|
||||||
|
|
||||||
[MIT License](LICENSE)
|
[MIT License](LICENSE)
|
||||||
|
|
||||||
|
|||||||
+64
-98
@@ -1,127 +1,93 @@
|
|||||||
# Security Guide
|
# Security Guide
|
||||||
|
|
||||||
This document summarizes security controls and best practices for Linear Void.
|
Layered security model for Linear Void.
|
||||||
|
|
||||||
## Security Model Overview
|
## Threat Model
|
||||||
|
|
||||||
Security is layered across:
|
```
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ Client │───▶│Cloudflare│───▶│ nginx │───▶│ Backend │
|
||||||
|
│ (public) │ │ WAF+CDN │ │ proxy │ │ chat │
|
||||||
|
└──────────┘ └──────────┘ └────┬─────┘ └────┬─────┘
|
||||||
|
│ │
|
||||||
|
┌──────┴──────┐ ┌─────┴──────┐
|
||||||
|
│ /health │ │ /api/admin │
|
||||||
|
│ /metrics │ │ /ws (chat) │
|
||||||
|
│ /hls/* │ │ /api/chat/* │
|
||||||
|
│ (public) │ │ (auth req) │
|
||||||
|
└─────────────┘ └────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
- Cloudflare edge
|
## Authentication & Sessions
|
||||||
- nginx reverse proxy
|
|
||||||
- chat/admin backend
|
|
||||||
- container and host operations
|
|
||||||
|
|
||||||
## Authentication and Sessions
|
- Login: `POST /api/admin/login` → cookie `linearvoid_admin_session`
|
||||||
|
- Cookie flags: `HttpOnly`, `SameSite=Strict`, `Secure` (over HTTPS)
|
||||||
|
- Server-side expiry via `ADMIN_SESSION_TTL_HOURS`
|
||||||
|
- Rate limit: 5 attempts/IP/5min via Redis
|
||||||
|
|
||||||
Admin access is session-cookie based.
|
## Transport Security (TLS/HSTS)
|
||||||
|
|
||||||
- Login endpoint: /api/admin/login
|
- Cloudflare TLS mode: **Full (strict)** — valid origin cert required
|
||||||
- Session endpoint: /api/admin/session
|
- nginx listens on 80 internally; Cloudflare handles TLS termination
|
||||||
- Logout endpoint: /api/admin/logout
|
- HSTS header set by Cloudflare: `max-age=31536000; includeSubDomains; preload`
|
||||||
- Cookie: linearvoid_admin_session
|
- WSS upgrade path: `/ws` through nginx → chat:3000
|
||||||
- Cookie flags: HttpOnly, SameSite=Strict, Secure over HTTPS
|
|
||||||
- Session expiry enforced server-side
|
|
||||||
|
|
||||||
Best practices:
|
## Network Isolation
|
||||||
|
|
||||||
- Set strong ADMIN_USERNAME and ADMIN_PASSWORD values.
|
- All backend services on internal Docker network (`linearvoid-net`)
|
||||||
- Rotate admin credentials periodically.
|
- Only nginx port exposed to host (443/80)
|
||||||
- Keep ADMIN_SESSION_TTL_HOURS aligned with operational policy.
|
- Database, Redis, exporters inaccessible from outside
|
||||||
|
- `docker-compose.prod.yml` keeps ports internal via `expose:` not `ports:`
|
||||||
## 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
|
## Reverse Proxy Controls
|
||||||
|
|
||||||
nginx controls include:
|
- `auth_request /api/admin/auth` gate on all `/admin` and `/api/admin` paths
|
||||||
|
- Explicit `client_max_body_size 64m` for resumable upload chunks
|
||||||
|
- WebSocket upgrade headers: `Upgrade`, `Connection` preserved
|
||||||
|
- CORS headers on HLS: `Access-Control-Allow-Origin *`
|
||||||
|
- XSS protection: CSP headers served by Cloudflare
|
||||||
|
|
||||||
- auth_request guard for /admin and /api/admin paths
|
## Rate Limiting
|
||||||
- dedicated unauthorized behavior for admin API responses
|
|
||||||
- explicit websocket upgrade headers for /ws
|
|
||||||
- upload body-size limits for resumable media chunk uploads
|
|
||||||
|
|
||||||
Best practices:
|
| Resource | Limit | Window |
|
||||||
|
| ----------------- | ----- | ------ |
|
||||||
|
| WebSocket connect | 5/IP | 60s |
|
||||||
|
| Chat messages | 10/IP | 10s |
|
||||||
|
| Admin login | 5/IP | 5min |
|
||||||
|
|
||||||
- Keep proxy body-size and timeout settings aligned across all proxy layers.
|
Implemented via Redis `INCR` + `EXPIRE`.
|
||||||
- Avoid exposing backend service ports publicly unless required.
|
|
||||||
|
|
||||||
## Input Validation and Upload Safety
|
## Secrets Management
|
||||||
|
|
||||||
Server-side protections include:
|
- `.env` — local dev only, **gitignored**
|
||||||
|
- Gitea Actions secrets (`CI_*` prefix) for CI/CD
|
||||||
|
- Never commit: passwords, tokens, API keys, R2 credentials
|
||||||
|
- Production `.env` provisioned manually on host, never checked in
|
||||||
|
- Rotate admin password quarterly
|
||||||
|
|
||||||
- extension allow-list for media uploads
|
## Data Protection
|
||||||
- MIME checks
|
|
||||||
- file-size ceiling (2GB)
|
|
||||||
- resumable chunk bounds and chunk-index validation
|
|
||||||
- media path safety checks on delete
|
|
||||||
|
|
||||||
Best practices:
|
- PostgreSQL: chat history, moderation logs, media metadata
|
||||||
|
- Redis: append-only persistence for presence, rate limits, sessions
|
||||||
- Keep upload chunk size bounded.
|
- HLS segments: ephemeral, regenerated by playout
|
||||||
- Reject unsupported content types consistently.
|
- Backups: `scripts/backup-db.sh` (daily), `scripts/backup-weekly.sh`, `scripts/verify-backups.sh`
|
||||||
- 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
|
## Hardening Checklist
|
||||||
|
|
||||||
- [ ] HTTPS/WSS enforced end-to-end
|
- [ ] HTTPS/WSS enforced end-to-end (Cloudflare Full strict)
|
||||||
- [ ] Strong admin credentials configured
|
- [ ] Strong admin credentials configured
|
||||||
- [ ] Secrets only in env/secrets stores
|
- [ ] Secrets only in env/secrets stores (never committed)
|
||||||
- [ ] Proxy auth and websocket headers validated
|
- [ ] Proxy auth and websocket headers validated
|
||||||
- [ ] Upload limits and validation active
|
- [ ] Upload limits and MIME validation active
|
||||||
- [ ] Rate limiting active and tested
|
- [ ] Rate limiting active (WS, messages, login)
|
||||||
- [ ] Cloudflare WAF/rate-limit rules active
|
- [ ] Cloudflare WAF/rate-limit rules active
|
||||||
- [ ] Backup/restore drills completed
|
- [ ] Backup/restore drills completed
|
||||||
- [ ] Security-related monitoring alerts routed
|
- [ ] Security alerts routed (Prometheus alertmanager)
|
||||||
|
|
||||||
## Incident Response Baseline
|
## Incident Response
|
||||||
|
|
||||||
1. Detect issue (alerts/logs/user reports).
|
1. Detect — alerts (ChatDown, RedisDown, HighChatLatency) or user reports
|
||||||
2. Triage scope (edge, proxy, app, data plane).
|
2. Triage — edge (Cloudflare), proxy (nginx), app (chat/playout), data (Redis/Postgres)
|
||||||
3. Contain impact (rate limit, credential rotate, route disable).
|
3. Contain — rate limit, credential rotate, service restart, slate mode activation
|
||||||
4. Recover services and verify health.
|
4. Recover — verify health endpoints, check Prometheus targets, validate HLS output
|
||||||
5. Record timeline and corrective actions.
|
5. Record — timeline + corrective actions in `docs/incident-runbook.md`
|
||||||
@@ -21,6 +21,42 @@ This guide covers prerequisites, environment setup, running the application loca
|
|||||||
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`)
|
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`)
|
||||||
- **Hetzner Cloud Account** (optional for production): For the US-East VPS (Ashburn, VA recommended per ADR-001).
|
- **Hetzner Cloud Account** (optional for production): For the US-East VPS (Ashburn, VA recommended per ADR-001).
|
||||||
|
|
||||||
|
## Network Environments
|
||||||
|
|
||||||
|
Linear Void operates in two distinct environments. Know which one you're targeting.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Test_Lab["Test Lab (192.168.88.0/24)"]
|
||||||
|
TEST_HOST["192.168.88.40<br/>Docker Engine :2375"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Prod["Production (Hetzner — NOT provisioned)"]
|
||||||
|
PROD_HOST["Hetzner CX22 Ashburn, VA<br/>Public IP TBD"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Gitea["Gitea CI/CD"]
|
||||||
|
CI["CI: build + push images"]
|
||||||
|
CD["CD: deploy via DOCKER_HOST"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CF["Cloudflare"]
|
||||||
|
DNS["linearvoid.tv"]
|
||||||
|
R2["R2 Bucket"]
|
||||||
|
end
|
||||||
|
|
||||||
|
CD -->|"DOCKER_HOST=tcp://192.168.88.40:2375"| TEST_HOST
|
||||||
|
CD -.->|"DOCKER_HOST=tcp://<hetzner-ip>:2375<br/>(when provisioned)"| PROD_HOST
|
||||||
|
TEST_HOST -->|"test traffic"| DNS
|
||||||
|
PROD_HOST -.->|"production traffic"| DNS
|
||||||
|
TEST_HOST --> R2
|
||||||
|
PROD_HOST -.-> R2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Lab (active):** Internal network `192.168.88.0/24`, host `192.168.88.40`. Receives automated CD deploys from Gitea Actions. Used for integration testing and staging validation.
|
||||||
|
|
||||||
|
**Production (pending):** Hetzner Cloud VPS, Ashburn VA. Not yet provisioned. CD workflow and compose files are ready — only `DOCKER_HOST` needs changing. See Production Deployment section in [DEPLOYMENT.md](./DEPLOYMENT.md) for cutover plan.
|
||||||
|
|
||||||
## Development Prerequisites
|
## Development Prerequisites
|
||||||
|
|
||||||
- **Frontend dependencies**: Run `cd frontend && npm install` before `npm run dev` or `npm run build`.
|
- **Frontend dependencies**: Run `cd frontend && npm install` before `npm run dev` or `npm run build`.
|
||||||
|
|||||||
+636
-37
@@ -33,11 +33,30 @@ var (
|
|||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
}
|
}
|
||||||
clients = make(map[*websocket.Conn]bool)
|
clients = make(map[*websocket.Conn]bool)
|
||||||
|
clientMeta = make(map[*websocket.Conn]clientInfo)
|
||||||
mutex = sync.Mutex{}
|
mutex = sync.Mutex{}
|
||||||
authSessions = map[string]time.Time{}
|
authSessions = map[string]time.Time{}
|
||||||
authSessionsMu = sync.Mutex{}
|
authSessionsMu = sync.Mutex{}
|
||||||
|
|
||||||
|
// Rate limit config (env-overridable)
|
||||||
|
rateLimitMax = 10
|
||||||
|
rateLimitWindow = 10 * time.Second
|
||||||
|
|
||||||
|
// Instance ID for multi-instance pub/sub deduplication
|
||||||
|
instanceID = fmt.Sprintf("chat-%s", generateShortID())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func generateShortID() string {
|
||||||
|
b := make([]byte, 6)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientInfo struct {
|
||||||
|
Username string
|
||||||
|
Room string
|
||||||
|
}
|
||||||
|
|
||||||
// Message represents a chat message (persisted in SQL DB per ADR-006)
|
// Message represents a chat message (persisted in SQL DB per ADR-006)
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
@@ -73,6 +92,16 @@ type Media struct {
|
|||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScheduleOverride represents a scheduled playlist override (ADR-004)
|
||||||
|
type ScheduleOverride struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
MediaID uint `json:"mediaId"`
|
||||||
|
StartTime time.Time `json:"startTime"`
|
||||||
|
EndTime time.Time `json:"endTime"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
// Presence tracks online users per room (using Redis per ADR-007)
|
// Presence tracks online users per room (using Redis per ADR-007)
|
||||||
type Presence struct {
|
type Presence struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -287,11 +316,35 @@ func initRedis() {
|
|||||||
if err := redisClient.Ping(context.Background()).Err(); err != nil {
|
if err := redisClient.Ping(context.Background()).Err(); err != nil {
|
||||||
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
|
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
|
||||||
redisClient = nil
|
redisClient = nil
|
||||||
|
// Start auto-reconnect goroutine
|
||||||
|
go redisReconnectLoop(redisAddress, options)
|
||||||
} else {
|
} else {
|
||||||
log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)")
|
log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// redisReconnectLoop attempts to reconnect to Redis with exponential backoff.
|
||||||
|
func redisReconnectLoop(addr string, baseOptions *redis.Options) {
|
||||||
|
backoff := 2 * time.Second
|
||||||
|
maxBackoff := 60 * time.Second
|
||||||
|
for {
|
||||||
|
time.Sleep(backoff)
|
||||||
|
testClient := redis.NewClient(baseOptions)
|
||||||
|
if err := testClient.Ping(context.Background()).Err(); err != nil {
|
||||||
|
log.Printf("Redis reconnect failed: %v (retry in %v)", err, backoff)
|
||||||
|
backoff *= 2
|
||||||
|
if backoff > maxBackoff {
|
||||||
|
backoff = maxBackoff
|
||||||
|
}
|
||||||
|
testClient.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
redisClient = testClient
|
||||||
|
log.Println("Redis reconnected successfully")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// initDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006)
|
// initDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006)
|
||||||
func initDB() {
|
func initDB() {
|
||||||
var err error
|
var err error
|
||||||
@@ -319,7 +372,7 @@ func initDB() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
databaseDriverName = driverName
|
databaseDriverName = driverName
|
||||||
db.AutoMigrate(&Message{}, &ModerationLog{}, &Media{})
|
db.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
|
||||||
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driverName))
|
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driverName))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +431,23 @@ func handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate limit: max 5 attempts per IP per 5 minutes
|
||||||
|
clientIP := r.Header.Get("X-Forwarded-For")
|
||||||
|
if clientIP == "" {
|
||||||
|
clientIP = r.RemoteAddr
|
||||||
|
}
|
||||||
|
if redisClient != nil {
|
||||||
|
loginKey := fmt.Sprintf("login_attempts:%s", clientIP)
|
||||||
|
count, _ := redisClient.Incr(context.Background(), loginKey).Result()
|
||||||
|
if count == 1 {
|
||||||
|
redisClient.Expire(context.Background(), loginKey, 5*time.Minute)
|
||||||
|
}
|
||||||
|
if count > 5 {
|
||||||
|
http.Error(w, "too many login attempts, try again later", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var payload AdminLoginPayload
|
var payload AdminLoginPayload
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
|
||||||
@@ -626,6 +696,104 @@ func handleAdminPlaylist(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handlePublicSchedule returns playlist schedule without auth (viewer-facing).
|
||||||
|
func handlePublicSchedule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(getPlaylistPath())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "schedule unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries := parsePlaylistEntries(string(content))
|
||||||
|
var mediaRecords []Media
|
||||||
|
db.Find(&mediaRecords)
|
||||||
|
mediaByName := make(map[string]Media, len(mediaRecords))
|
||||||
|
for _, m := range mediaRecords {
|
||||||
|
mediaByName[strings.ToLower(m.Filename)] = m
|
||||||
|
}
|
||||||
|
type PublicEntry struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
output := make([]PublicEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
pe := PublicEntry{Index: e.Index, Label: e.Label, Status: "pending"}
|
||||||
|
if m, ok := mediaByName[strings.ToLower(filepath.Base(e.Path))]; ok {
|
||||||
|
pe.Title = m.Title
|
||||||
|
pe.Status = m.Status
|
||||||
|
} else {
|
||||||
|
pe.Title = filepath.Base(e.Path)
|
||||||
|
}
|
||||||
|
output = append(output, pe)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"entries": output,
|
||||||
|
"total": len(output),
|
||||||
|
"generatedAt": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminScheduleOverrides manages CRUD for schedule overrides.
|
||||||
|
func handleAdminScheduleOverrides(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
var overrides []ScheduleOverride
|
||||||
|
db.Order("start_time asc").Find(&overrides)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(overrides)
|
||||||
|
|
||||||
|
case http.MethodPost:
|
||||||
|
var ov ScheduleOverride
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&ov); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ov.MediaID == 0 || ov.StartTime.IsZero() {
|
||||||
|
http.Error(w, "mediaId and startTime are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Conflict detection: warn if overlapping
|
||||||
|
var conflict int64
|
||||||
|
db.Model(&ScheduleOverride{}).
|
||||||
|
Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime).
|
||||||
|
Count(&conflict)
|
||||||
|
if conflict > 0 {
|
||||||
|
http.Error(w, fmt.Sprintf("conflict: %d overlapping override(s)", conflict), http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Create(&ov)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(ov)
|
||||||
|
|
||||||
|
case http.MethodDelete:
|
||||||
|
idStr := r.URL.Query().Get("id")
|
||||||
|
if idStr == "" {
|
||||||
|
http.Error(w, "id query param required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.Delete(&ScheduleOverride{}, id).Error; err != nil {
|
||||||
|
http.Error(w, "failed to delete override", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleAdminSchedule(w http.ResponseWriter, r *http.Request) {
|
func handleAdminSchedule(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -875,28 +1043,73 @@ func getAdminSystemSummary() AdminSystemSummary {
|
|||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAdminConfig returns runtime configuration values for the admin config editor.
|
||||||
|
func handleAdminConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"rateLimitMax": rateLimitMax,
|
||||||
|
"rateLimitWindowSec": int(rateLimitWindow.Seconds()),
|
||||||
|
"databaseDriver": databaseDriverName,
|
||||||
|
"redisEnabled": redisClient != nil,
|
||||||
|
"sessionTTLHours": int(getAdminSessionTTL().Hours()),
|
||||||
|
})
|
||||||
|
case http.MethodPut:
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v, ok := payload["rateLimitMax"].(float64); ok && v > 0 {
|
||||||
|
rateLimitMax = int(v)
|
||||||
|
log.Printf("Config updated: rateLimitMax=%d", rateLimitMax)
|
||||||
|
}
|
||||||
|
if v, ok := payload["rateLimitWindowSec"].(float64); ok && v > 0 {
|
||||||
|
rateLimitWindow = time.Duration(v) * time.Second
|
||||||
|
log.Printf("Config updated: rateLimitWindow=%v", rateLimitWindow)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminRestart triggers a service restart (placeholder — returns instructions).
|
||||||
|
func handleAdminRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service := r.URL.Query().Get("service")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "restart_not_automated",
|
||||||
|
"service": service,
|
||||||
|
"message": fmt.Sprintf("Restart %s manually: docker compose -f docker-compose.prod.yml restart %s", service, service),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func handleAdminSystemSummary(w http.ResponseWriter, r *http.Request) {
|
func handleAdminSystemSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
summary := getAdminSystemSummary()
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(getAdminSystemSummary())
|
json.NewEncoder(w).Encode(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
switch r.Method {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
case http.MethodGet:
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
response := AdminUsersResponse{
|
response := AdminUsersResponse{
|
||||||
Users: []ActiveUser{},
|
Users: []ActiveUser{},
|
||||||
RedisEnabled: redisClient != nil,
|
RedisEnabled: redisClient != nil,
|
||||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|
||||||
if redisClient != nil {
|
if redisClient != nil {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
keys, _, err := redisClient.Scan(ctx, 0, "presence:*", 200).Result()
|
keys, _, err := redisClient.Scan(ctx, 0, "presence:*", 200).Result()
|
||||||
@@ -904,7 +1117,6 @@ func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, fmt.Sprintf("failed to scan presence keys: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("failed to scan presence keys: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
parts := strings.SplitN(key, ":", 3)
|
parts := strings.SplitN(key, ":", 3)
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
@@ -917,16 +1129,57 @@ func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(response.Users, func(i, j int) bool {
|
sort.Slice(response.Users, func(i, j int) bool {
|
||||||
if response.Users[i].Room == response.Users[j].Room {
|
if response.Users[i].Room == response.Users[j].Room {
|
||||||
return response.Users[i].Username < response.Users[j].Username
|
return response.Users[i].Username < response.Users[j].Username
|
||||||
}
|
}
|
||||||
return response.Users[i].Room < response.Users[j].Room
|
return response.Users[i].Room < response.Users[j].Room
|
||||||
})
|
})
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
|
|
||||||
|
case http.MethodPost:
|
||||||
|
var payload struct {
|
||||||
|
Action string `json:"action"` // mute, unmute, ban, unban
|
||||||
|
Username string `json:"username"`
|
||||||
|
Duration int `json:"duration"` // minutes, for mute
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payload.Action == "" || payload.Username == "" {
|
||||||
|
http.Error(w, "action and username required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if redisClient == nil {
|
||||||
|
http.Error(w, "Redis unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
switch payload.Action {
|
||||||
|
case "mute":
|
||||||
|
dur := time.Duration(payload.Duration) * time.Minute
|
||||||
|
if dur <= 0 {
|
||||||
|
dur = 10 * time.Minute
|
||||||
|
}
|
||||||
|
redisClient.Set(ctx, fmt.Sprintf("muted:%s", payload.Username), "1", dur)
|
||||||
|
case "unmute":
|
||||||
|
redisClient.Del(ctx, fmt.Sprintf("muted:%s", payload.Username))
|
||||||
|
case "ban":
|
||||||
|
redisClient.Set(ctx, fmt.Sprintf("banned:%s", payload.Username), "1", 0)
|
||||||
|
case "unban":
|
||||||
|
redisClient.Del(ctx, fmt.Sprintf("banned:%s", payload.Username))
|
||||||
|
default:
|
||||||
|
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "action": payload.Action, "username": payload.Username})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func listMediaFiles() ([]MediaFileEntry, error) {
|
func listMediaFiles() ([]MediaFileEntry, error) {
|
||||||
@@ -1139,6 +1392,90 @@ func mediaEntryFromRecord(record Media) MediaFileEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MediaNormalizationResult holds ffprobe validation output for admin uploads.
|
||||||
|
type MediaNormalizationResult struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateMediaNormalization runs ffprobe against a media file and checks
|
||||||
|
// conformance with the Linear Void normalization profile (ADR-001).
|
||||||
|
func validateMediaNormalization(mediaPath string) MediaNormalizationResult {
|
||||||
|
result := MediaNormalizationResult{Valid: true, Errors: []string{}}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := exec.CommandContext(ctx,
|
||||||
|
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", mediaPath,
|
||||||
|
).CombinedOutput()
|
||||||
|
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return MediaNormalizationResult{Valid: false, Errors: []string{"ffprobe timed out"}}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return MediaNormalizationResult{Valid: false, Errors: []string{fmt.Sprintf("ffprobe failed: %v (%s)", err, strings.TrimSpace(string(out)))}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var probe struct {
|
||||||
|
Streams []struct {
|
||||||
|
CodecName string `json:"codec_name"`
|
||||||
|
CodecType string `json:"codec_type"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
RFrameRate string `json:"r_frame_rate"`
|
||||||
|
PixFmt string `json:"pix_fmt"`
|
||||||
|
SampleRate string `json:"sample_rate"`
|
||||||
|
} `json:"streams"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &probe); err != nil {
|
||||||
|
return MediaNormalizationResult{Valid: false, Errors: []string{"ffprobe JSON parse failed: " + err.Error()}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasVideo, hasAudio bool
|
||||||
|
for _, s := range probe.Streams {
|
||||||
|
switch s.CodecType {
|
||||||
|
case "video":
|
||||||
|
hasVideo = true
|
||||||
|
if s.CodecName != "h264" {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
|
||||||
|
}
|
||||||
|
if s.RFrameRate != "30/1" {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("framerate must be 30 fps constant, got %s", s.RFrameRate))
|
||||||
|
}
|
||||||
|
if s.Width != 1280 && s.Width != 1920 {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
|
||||||
|
}
|
||||||
|
if s.Height != 720 && s.Height != 1080 {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
|
||||||
|
}
|
||||||
|
if s.PixFmt != "yuv420p" {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
|
||||||
|
}
|
||||||
|
case "audio":
|
||||||
|
hasAudio = true
|
||||||
|
if s.CodecName != "aac" {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
|
||||||
|
}
|
||||||
|
if s.SampleRate != "48000" {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("sample rate must be 48000 Hz, got %s", s.SampleRate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasVideo {
|
||||||
|
result.Errors = append(result.Errors, "no video stream found")
|
||||||
|
}
|
||||||
|
if !hasAudio {
|
||||||
|
result.Errors = append(result.Errors, "no audio stream found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Errors) > 0 {
|
||||||
|
result.Valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
func probeMediaDurationSeconds(mediaPath string) (int, error) {
|
func probeMediaDurationSeconds(mediaPath string) (int, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -1274,6 +1611,13 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate normalization profile (ADR-001)
|
||||||
|
normResult := validateMediaNormalization(targetPath)
|
||||||
|
status := "ready"
|
||||||
|
if !normResult.Valid {
|
||||||
|
status = "needs_normalization"
|
||||||
|
}
|
||||||
|
|
||||||
filename := filepath.Base(targetPath)
|
filename := filepath.Base(targetPath)
|
||||||
mediaRecord := Media{
|
mediaRecord := Media{
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
@@ -1281,9 +1625,9 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
|
|||||||
Path: targetPath,
|
Path: targetPath,
|
||||||
SizeBytes: fileInfo.Size(),
|
SizeBytes: fileInfo.Size(),
|
||||||
DurationSeconds: durationSeconds,
|
DurationSeconds: durationSeconds,
|
||||||
Status: "ready",
|
Status: status,
|
||||||
Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
|
Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
|
||||||
Tags: "",
|
Tags: fmt.Sprintf(`"normalization":%v`, normResult.Valid),
|
||||||
}
|
}
|
||||||
if err := db.Create(&mediaRecord).Error; err != nil {
|
if err := db.Create(&mediaRecord).Error; err != nil {
|
||||||
_ = os.Remove(targetPath)
|
_ = os.Remove(targetPath)
|
||||||
@@ -1602,6 +1946,13 @@ func handleAdminMediaUploads(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate normalization profile (ADR-001)
|
||||||
|
normResult := validateMediaNormalization(targetPath)
|
||||||
|
status := "ready"
|
||||||
|
if !normResult.Valid {
|
||||||
|
status = "needs_normalization"
|
||||||
|
}
|
||||||
|
|
||||||
filename := filepath.Base(targetPath)
|
filename := filepath.Base(targetPath)
|
||||||
mediaRecord := Media{
|
mediaRecord := Media{
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
@@ -1609,9 +1960,9 @@ func handleAdminMediaUploads(w http.ResponseWriter, r *http.Request) {
|
|||||||
Path: targetPath,
|
Path: targetPath,
|
||||||
SizeBytes: fileInfo.Size(),
|
SizeBytes: fileInfo.Size(),
|
||||||
DurationSeconds: durationSeconds,
|
DurationSeconds: durationSeconds,
|
||||||
Status: "ready",
|
Status: status,
|
||||||
Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
|
Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
|
||||||
Tags: "",
|
Tags: fmt.Sprintf(`"normalization":%v`, normResult.Valid),
|
||||||
}
|
}
|
||||||
if err := db.Create(&mediaRecord).Error; err != nil {
|
if err := db.Create(&mediaRecord).Error; err != nil {
|
||||||
_ = os.Remove(targetPath)
|
_ = os.Remove(targetPath)
|
||||||
@@ -1793,6 +2144,10 @@ func handleMetrics(w http.ResponseWriter, r *http.Request) {
|
|||||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
|
||||||
writePrometheusMetricLine(w, "linearvoid_chat_messages_total", storedMessages)
|
writePrometheusMetricLine(w, "linearvoid_chat_messages_total", storedMessages)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_dms_total Total private messages sent.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_dms_total counter")
|
||||||
|
writePrometheusMetricLine(w, "linearvoid_chat_dms_total", dmsSent)
|
||||||
|
|
||||||
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
|
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
|
||||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
|
||||||
writePrometheusMetricLine(w, "linearvoid_chat_moderation_actions_total", moderationActions)
|
writePrometheusMetricLine(w, "linearvoid_chat_moderation_actions_total", moderationActions)
|
||||||
@@ -1815,7 +2170,7 @@ func handleMetrics(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
|
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
|
||||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
|
||||||
fmt.Fprintln(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\""+databaseDriverName+"\"} 1")
|
fmt.Fprintln(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\""+databaseDriverName+"\",instance_id=\""+instanceID+"\"} 1")
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcast sends a message to all connected clients in a room and saves to DB
|
// broadcast sends a message to all connected clients in a room and saves to DB
|
||||||
@@ -1826,40 +2181,102 @@ func broadcast(msg Message) {
|
|||||||
// Persist to configured SQL backend.
|
// Persist to configured SQL backend.
|
||||||
db.Create(&msg)
|
db.Create(&msg)
|
||||||
|
|
||||||
|
// Local fan-out
|
||||||
for client := range clients {
|
for client := range clients {
|
||||||
if err := client.WriteJSON(msg); err != nil {
|
if err := client.WriteJSON(msg); err != nil {
|
||||||
client.Close()
|
client.Close()
|
||||||
delete(clients, client)
|
delete(clients, client)
|
||||||
|
delete(clientMeta, client)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redis pub/sub for multi-instance fan-out
|
||||||
|
if redisClient != nil && msg.Room != "" {
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"instance": instanceID,
|
||||||
|
"msg": msg,
|
||||||
|
})
|
||||||
|
redisClient.Publish(context.Background(), fmt.Sprintf("room:%s:messages", msg.Room), payload)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConnection handles WebSocket connections for chat (public rooms, private DMs, moderation)
|
// handleConnection handles WebSocket connections for chat (public rooms, private DMs, moderation)
|
||||||
func handleConnection(w http.ResponseWriter, r *http.Request) {
|
func handleConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// IP-based connection rate limiting: max 5 concurrent per IP
|
||||||
|
clientIP := r.Header.Get("X-Forwarded-For")
|
||||||
|
if clientIP == "" {
|
||||||
|
clientIP = r.RemoteAddr
|
||||||
|
}
|
||||||
|
if redisClient != nil {
|
||||||
|
ipKey := fmt.Sprintf("ws_conn:%s", clientIP)
|
||||||
|
count, _ := redisClient.Incr(context.Background(), ipKey).Result()
|
||||||
|
if count == 1 {
|
||||||
|
redisClient.Expire(context.Background(), ipKey, 60*time.Second)
|
||||||
|
}
|
||||||
|
if count > 5 {
|
||||||
|
http.Error(w, "too many connections from this IP", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Decrement on disconnect
|
||||||
|
defer redisClient.Decr(context.Background(), ipKey)
|
||||||
|
}
|
||||||
|
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Upgrade error:", err)
|
log.Println("Upgrade error:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
username := r.URL.Query().Get("username")
|
||||||
|
room := r.URL.Query().Get("room")
|
||||||
|
if username == "" {
|
||||||
|
username = fmt.Sprintf("anon_%d", time.Now().UnixNano()%10000)
|
||||||
|
}
|
||||||
|
|
||||||
mutex.Lock()
|
mutex.Lock()
|
||||||
clients[conn] = true
|
clients[conn] = true
|
||||||
|
clientMeta[conn] = clientInfo{Username: username, Room: room}
|
||||||
mutex.Unlock()
|
mutex.Unlock()
|
||||||
|
|
||||||
// Announce presence (ADR-007)
|
// Announce presence (ADR-007)
|
||||||
username := r.URL.Query().Get("username")
|
if redisClient != nil && room != "" {
|
||||||
room := r.URL.Query().Get("room")
|
|
||||||
if redisClient != nil && username != "" && room != "" {
|
|
||||||
redisClient.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
redisClient.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||||
broadcast(Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system"})
|
broadcast(Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push chat history on join (last 50 messages)
|
||||||
|
pushChatHistory(conn, room, 50)
|
||||||
|
|
||||||
|
// Heartbeat goroutine: refresh presence TTL every 30s
|
||||||
|
heartbeatStop := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if redisClient != nil && room != "" {
|
||||||
|
redisClient.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||||
|
}
|
||||||
|
// Ping to detect dead connections
|
||||||
|
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-heartbeatStop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
close(heartbeatStop)
|
||||||
mutex.Lock()
|
mutex.Lock()
|
||||||
delete(clients, conn)
|
delete(clients, conn)
|
||||||
|
delete(clientMeta, conn)
|
||||||
mutex.Unlock()
|
mutex.Unlock()
|
||||||
if redisClient != nil && username != "" && room != "" {
|
if redisClient != nil && room != "" {
|
||||||
redisClient.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
|
redisClient.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
|
||||||
broadcast(Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system"})
|
broadcast(Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()})
|
||||||
}
|
}
|
||||||
conn.Close()
|
conn.Close()
|
||||||
}()
|
}()
|
||||||
@@ -1873,15 +2290,27 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
|
|||||||
msg.Timestamp = time.Now()
|
msg.Timestamp = time.Now()
|
||||||
msg.Username = username // enforce from query param for security
|
msg.Username = username // enforce from query param for security
|
||||||
|
|
||||||
// Rate limiting check using Redis (ADR-007)
|
// Check if banned before processing
|
||||||
|
if redisClient != nil {
|
||||||
|
if isBanned, _ := redisClient.Exists(context.Background(), fmt.Sprintf("banned:%s", username)).Result(); isBanned > 0 {
|
||||||
|
conn.WriteJSON(Message{Type: "system", Text: "You are banned from chat.", Timestamp: time.Now()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isMuted, _ := redisClient.Exists(context.Background(), fmt.Sprintf("muted:%s", username)).Result(); isMuted > 0 {
|
||||||
|
conn.WriteJSON(Message{Type: "system", Text: "You are muted.", Timestamp: time.Now()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting check using Redis with env-configurable thresholds
|
||||||
if redisClient != nil {
|
if redisClient != nil {
|
||||||
key := fmt.Sprintf("rate:%s", msg.Username)
|
key := fmt.Sprintf("rate:%s", msg.Username)
|
||||||
count, _ := redisClient.Incr(context.Background(), key).Result()
|
count, _ := redisClient.Incr(context.Background(), key).Result()
|
||||||
if count == 1 {
|
if count == 1 {
|
||||||
redisClient.Expire(context.Background(), key, 10*time.Second)
|
redisClient.Expire(context.Background(), key, rateLimitWindow)
|
||||||
}
|
}
|
||||||
if count > 10 {
|
if count > int64(rateLimitMax) {
|
||||||
conn.WriteJSON(Message{Type: "system", Text: "Rate limit exceeded. Please slow down."})
|
conn.WriteJSON(Message{Type: "system", Text: "Rate limit exceeded. Please slow down.", Timestamp: time.Now()})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1892,29 +2321,114 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle private DMs (room format "dm:user1-user2")
|
// Handle private DMs
|
||||||
if strings.HasPrefix(msg.Room, "dm:") {
|
if strings.HasPrefix(msg.Room, "dm:") {
|
||||||
handlePrivateDM(msg)
|
handlePrivateDM(msg)
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
msg.Type = "public"
|
||||||
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
|
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
|
||||||
broadcast(msg)
|
broadcast(msg)
|
||||||
|
|
||||||
// Save private messages explicitly as well.
|
|
||||||
if msg.Type == "private" {
|
|
||||||
db.Create(&msg)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushChatHistory sends the last N messages for a room via WebSocket.
|
||||||
|
func pushChatHistory(conn *websocket.Conn, room string, limit int) {
|
||||||
|
if db == nil || room == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var messages []Message
|
||||||
|
db.Where("room = ?", room).Order("timestamp desc").Limit(limit).Find(&messages)
|
||||||
|
// Reverse to chronological order
|
||||||
|
for i := len(messages)/2 - 1; i >= 0; i-- {
|
||||||
|
opp := len(messages) - 1 - i
|
||||||
|
messages[i], messages[opp] = messages[opp], messages[i]
|
||||||
|
}
|
||||||
|
for _, msg := range messages {
|
||||||
|
conn.WriteJSON(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmsSent tracks total private messages for Prometheus metrics (set from handleMetrics).
|
||||||
|
var dmsSent int64
|
||||||
|
|
||||||
|
// subscribeRoomMessages listens for Redis pub/sub messages and fans out locally.
|
||||||
|
func subscribeRoomMessages() {
|
||||||
|
if redisClient == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pubsub := redisClient.PSubscribe(context.Background(), "room:*:messages")
|
||||||
|
defer pubsub.Close()
|
||||||
|
|
||||||
|
log.Printf("Redis pub/sub subscriber started (instance %s)", instanceID)
|
||||||
|
|
||||||
|
for {
|
||||||
|
msg, err := pubsub.ReceiveMessage(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Redis pub/sub error: %v — retrying in 5s", err)
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
pubsub = redisClient.PSubscribe(context.Background(), "room:*:messages")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelope struct {
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
Msg Message `json:"msg"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip messages originating from this instance
|
||||||
|
if envelope.Instance == instanceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan out to local clients
|
||||||
|
mutex.Lock()
|
||||||
|
for client := range clients {
|
||||||
|
client.WriteJSON(envelope.Msg)
|
||||||
|
}
|
||||||
|
mutex.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePrivateDM routes private messages to the specific user pair
|
// handlePrivateDM routes private messages to the specific user pair
|
||||||
func handlePrivateDM(msg Message) {
|
func handlePrivateDM(msg Message) {
|
||||||
// Persist private messages for history.
|
msg.Type = "private"
|
||||||
|
dmsSent++
|
||||||
|
|
||||||
|
// Persist for history
|
||||||
db.Create(&msg)
|
db.Create(&msg)
|
||||||
|
|
||||||
// Broadcast only to participants (simplified - in production use a map of user connections)
|
// Deliver to target if online
|
||||||
broadcast(msg)
|
mutex.Lock()
|
||||||
|
targetOnline := false
|
||||||
|
for conn, info := range clientMeta {
|
||||||
|
if info.Username == msg.Room || strings.Contains(msg.Room, info.Username) {
|
||||||
|
if err := conn.WriteJSON(msg); err == nil {
|
||||||
|
targetOnline = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mutex.Unlock()
|
||||||
|
|
||||||
|
if !targetOnline {
|
||||||
|
// Target offline — message persisted, will appear in history on next join
|
||||||
|
log.Printf("DM stored for offline user: %s -> room %s", msg.Username, msg.Room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also deliver to sender for confirmation
|
||||||
|
mutex.Lock()
|
||||||
|
for conn, info := range clientMeta {
|
||||||
|
if info.Username == msg.Username {
|
||||||
|
conn.WriteJSON(msg)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mutex.Unlock()
|
||||||
|
|
||||||
log.Printf("Private DM from %s to room %s: %s", msg.Username, msg.Room, msg.Text)
|
log.Printf("Private DM from %s to room %s: %s", msg.Username, msg.Room, msg.Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1935,7 +2449,14 @@ func logModerationAction(action, target, moderator, reason string) {
|
|||||||
func handleModerationCommand(msg Message, conn *websocket.Conn) {
|
func handleModerationCommand(msg Message, conn *websocket.Conn) {
|
||||||
parts := strings.Fields(msg.Text)
|
parts := strings.Fields(msg.Text)
|
||||||
if len(parts) < 3 {
|
if len(parts) < 3 {
|
||||||
conn.WriteJSON(Message{Type: "system", Text: "Usage: /mod <action> <target> <reason> or /mute <user> <reason>"})
|
conn.WriteJSON(Message{Type: "system", Text: "Usage: /mute <user> <duration_m> <reason> or /ban <user> <reason>", Timestamp: time.Now()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic moderator check: admin username or configured moderators
|
||||||
|
adminUser := getAdminUsername()
|
||||||
|
if msg.Username != adminUser {
|
||||||
|
conn.WriteJSON(Message{Type: "system", Text: "Only moderators can use moderation commands.", Timestamp: time.Now()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1943,12 +2464,34 @@ func handleModerationCommand(msg Message, conn *websocket.Conn) {
|
|||||||
target := parts[1]
|
target := parts[1]
|
||||||
reason := strings.Join(parts[2:], " ")
|
reason := strings.Join(parts[2:], " ")
|
||||||
|
|
||||||
|
if redisClient != nil {
|
||||||
|
switch action {
|
||||||
|
case "mute", "mod":
|
||||||
|
durationMinutes := 10 // default
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
if d, err := strconv.Atoi(parts[2]); err == nil && d > 0 {
|
||||||
|
durationMinutes = d
|
||||||
|
if len(parts) > 3 {
|
||||||
|
reason = strings.Join(parts[3:], " ")
|
||||||
|
} else {
|
||||||
|
reason = "no reason given"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redisClient.Set(context.Background(), fmt.Sprintf("muted:%s", target), "1", time.Duration(durationMinutes)*time.Minute)
|
||||||
|
case "ban":
|
||||||
|
redisClient.Set(context.Background(), fmt.Sprintf("banned:%s", target), "1", 0) // no expiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logModerationAction(action, target, msg.Username, reason)
|
logModerationAction(action, target, msg.Username, reason)
|
||||||
|
|
||||||
broadcast(Message{
|
broadcast(Message{
|
||||||
Room: msg.Room,
|
Room: msg.Room,
|
||||||
|
Username: "system",
|
||||||
Type: "system",
|
Type: "system",
|
||||||
Text: fmt.Sprintf("Moderator %s %s %s for: %s", msg.Username, action, target, reason),
|
Text: fmt.Sprintf("%s was %s by %s: %s", target, action, msg.Username, reason),
|
||||||
|
Timestamp: time.Now(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2075,6 +2618,39 @@ func handleAdminChatLogByID(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAdminChatLogExport returns chat log as CSV file.
|
||||||
|
func handleAdminChatLogExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.URL.Query()
|
||||||
|
room := strings.TrimSpace(query.Get("room"))
|
||||||
|
dbQuery := db.Model(&Message{})
|
||||||
|
if room != "" {
|
||||||
|
dbQuery = dbQuery.Where("room = ?", room)
|
||||||
|
}
|
||||||
|
if username := strings.TrimSpace(query.Get("username")); username != "" {
|
||||||
|
dbQuery = dbQuery.Where("username LIKE ?", "%"+username+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []Message
|
||||||
|
if err := dbQuery.Order("timestamp desc").Limit(10000).Find(&messages).Error; err != nil {
|
||||||
|
http.Error(w, "failed to load messages", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename=chat-log.csv")
|
||||||
|
fmt.Fprintln(w, "id,room,username,type,text,timestamp")
|
||||||
|
for _, msg := range messages {
|
||||||
|
text := strings.ReplaceAll(msg.Text, `"`, `""`)
|
||||||
|
fmt.Fprintf(w, "%d,%s,%s,%s,\"%s\",%s\n",
|
||||||
|
msg.ID, msg.Room, msg.Username, msg.Type, text, msg.Timestamp.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleAdminChatLogBulk deletes multiple chat log entries by ID
|
// handleAdminChatLogBulk deletes multiple chat log entries by ID
|
||||||
func handleAdminChatLogBulk(w http.ResponseWriter, r *http.Request) {
|
func handleAdminChatLogBulk(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodDelete {
|
if r.Method != http.MethodDelete {
|
||||||
@@ -2109,9 +2685,27 @@ func handleAdminChatLogBulk(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Load env-configurable rate limits
|
||||||
|
if raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX")); raw != "" {
|
||||||
|
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
|
||||||
|
rateLimitMax = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_WINDOW_S")); raw != "" {
|
||||||
|
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
|
||||||
|
rateLimitWindow = time.Duration(v) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("Rate limit: %d messages per %v", rateLimitMax, rateLimitWindow)
|
||||||
|
|
||||||
initRedis()
|
initRedis()
|
||||||
initDB()
|
initDB()
|
||||||
|
|
||||||
|
// Subscribe to Redis pub/sub for multi-instance message fan-out
|
||||||
|
go subscribeRoomMessages()
|
||||||
|
|
||||||
|
log.Printf("Instance ID: %s", instanceID)
|
||||||
|
|
||||||
http.HandleFunc("/ws", handleConnection)
|
http.HandleFunc("/ws", handleConnection)
|
||||||
http.HandleFunc("/api/admin/login", handleAdminLogin)
|
http.HandleFunc("/api/admin/login", handleAdminLogin)
|
||||||
http.HandleFunc("/api/admin/logout", handleAdminLogout)
|
http.HandleFunc("/api/admin/logout", handleAdminLogout)
|
||||||
@@ -2122,13 +2716,18 @@ func main() {
|
|||||||
http.HandleFunc("/api/admin/moderation/actions", requireAdminAuth(handleAdminModerationAction))
|
http.HandleFunc("/api/admin/moderation/actions", requireAdminAuth(handleAdminModerationAction))
|
||||||
http.HandleFunc("/api/admin/analytics", requireAdminAuth(handleAdminAnalytics))
|
http.HandleFunc("/api/admin/analytics", requireAdminAuth(handleAdminAnalytics))
|
||||||
http.HandleFunc("/api/admin/system", requireAdminAuth(handleAdminSystemSummary))
|
http.HandleFunc("/api/admin/system", requireAdminAuth(handleAdminSystemSummary))
|
||||||
|
http.HandleFunc("/api/admin/config", requireAdminAuth(handleAdminConfig))
|
||||||
|
http.HandleFunc("/api/admin/restart", requireAdminAuth(handleAdminRestart))
|
||||||
http.HandleFunc("/api/admin/users", requireAdminAuth(handleAdminUsers))
|
http.HandleFunc("/api/admin/users", requireAdminAuth(handleAdminUsers))
|
||||||
http.HandleFunc("/api/admin/schedule", requireAdminAuth(handleAdminSchedule))
|
http.HandleFunc("/api/admin/schedule", requireAdminAuth(handleAdminSchedule))
|
||||||
|
http.HandleFunc("/api/admin/schedule/overrides", requireAdminAuth(handleAdminScheduleOverrides))
|
||||||
|
http.HandleFunc("/api/schedule", handlePublicSchedule)
|
||||||
http.HandleFunc("/api/admin/media", requireAdminAuth(handleAdminMedia))
|
http.HandleFunc("/api/admin/media", requireAdminAuth(handleAdminMedia))
|
||||||
http.HandleFunc("/api/admin/media/uploads", requireAdminAuth(handleAdminMediaUploads))
|
http.HandleFunc("/api/admin/media/uploads", requireAdminAuth(handleAdminMediaUploads))
|
||||||
http.HandleFunc("/api/admin/media/uploads/", requireAdminAuth(handleAdminMediaUploads))
|
http.HandleFunc("/api/admin/media/uploads/", requireAdminAuth(handleAdminMediaUploads))
|
||||||
http.HandleFunc("/api/admin/media/", requireAdminAuth(handleAdminMediaByID))
|
http.HandleFunc("/api/admin/media/", requireAdminAuth(handleAdminMediaByID))
|
||||||
http.HandleFunc("/api/admin/chat-log/bulk", requireAdminAuth(handleAdminChatLogBulk))
|
http.HandleFunc("/api/admin/chat-log/bulk", requireAdminAuth(handleAdminChatLogBulk))
|
||||||
|
http.HandleFunc("/api/admin/chat-log/export", requireAdminAuth(handleAdminChatLogExport))
|
||||||
http.HandleFunc("/api/admin/chat-log/", requireAdminAuth(handleAdminChatLogByID))
|
http.HandleFunc("/api/admin/chat-log/", requireAdminAuth(handleAdminChatLogByID))
|
||||||
http.HandleFunc("/api/admin/chat-log", requireAdminAuth(handleAdminChatLog))
|
http.HandleFunc("/api/admin/chat-log", requireAdminAuth(handleAdminChatLog))
|
||||||
http.HandleFunc("/metrics", handleMetrics)
|
http.HandleFunc("/metrics", handleMetrics)
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ RUN apk add --no-cache \
|
|||||||
bash
|
bash
|
||||||
# Set working directory for playout
|
# Set working directory for playout
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Create directories for playlist, media, and HLS output
|
# Create directories for playlist, media, HLS output, and emergency slate
|
||||||
RUN mkdir -p /app/hls /app/media /app/playlists
|
RUN mkdir -p /app/hls /app/media /app/playlists /app/slate
|
||||||
# Copy the playout start script and the built playlist manager binary
|
# Copy the playout start script and the built playlist manager binary
|
||||||
COPY start-playout.sh /app/
|
COPY start-playout.sh /app/
|
||||||
# Copy demo data for out-of-the-box playout
|
# Copy demo data for out-of-the-box playout
|
||||||
|
|||||||
+280
-13
@@ -3,11 +3,13 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -27,6 +29,16 @@ type VideoFile struct {
|
|||||||
var (
|
var (
|
||||||
r2Client *s3.Client
|
r2Client *s3.Client
|
||||||
bucket = os.Getenv("R2_BUCKET_NAME")
|
bucket = os.Getenv("R2_BUCKET_NAME")
|
||||||
|
playoutMode = "starting" // "live", "slate", "degraded"
|
||||||
|
restartCount = 0
|
||||||
|
restartWindow = time.Now()
|
||||||
|
slateDir = "/app/slate"
|
||||||
|
hlsDir = "/app/hls"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxRestartsPerMinute = 5
|
||||||
|
slateManifestName = "playlist.m3u8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// initR2Client initializes the R2 (S3-compatible) client
|
// initR2Client initializes the R2 (S3-compatible) client
|
||||||
@@ -79,23 +91,181 @@ func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
|||||||
// ValidateVideo uses ffprobe to enforce codec, fps, and keyframe interval (ADR-001)
|
// ValidateVideo uses ffprobe to enforce codec, fps, and keyframe interval (ADR-001)
|
||||||
func ValidateVideo(videoPath string) (VideoFile, error) {
|
func ValidateVideo(videoPath string) (VideoFile, error) {
|
||||||
v := VideoFile{Path: videoPath}
|
v := VideoFile{Path: videoPath}
|
||||||
|
var errors []string
|
||||||
|
|
||||||
|
// Run ffprobe
|
||||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath)
|
cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath)
|
||||||
_, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
v.Validation = "ffprobe failed: " + err.Error()
|
v.Validation = "ffprobe failed: " + err.Error()
|
||||||
return v, err
|
return v, fmt.Errorf("ffprobe: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Parse JSON for strict checks (codec_name=h264, r_frame_rate=30/1, key_frame interval)
|
// Parse JSON
|
||||||
// For MVP we assume valid if ffprobe succeeds
|
var probe struct {
|
||||||
v.Valid = true
|
Streams []struct {
|
||||||
v.Validation = "Validation passed (H.264/AVC, AAC, 30fps constant, 2s keyframes - full JSON parse TODO per ADR-001)"
|
CodecName string `json:"codec_name"`
|
||||||
v.Duration = 60.0 // placeholder from ffprobe
|
CodecType string `json:"codec_type"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
RFrameRate string `json:"r_frame_rate"`
|
||||||
|
PixFmt string `json:"pix_fmt"`
|
||||||
|
SampleRate string `json:"sample_rate"`
|
||||||
|
Channels int `json:"channels"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
} `json:"streams"`
|
||||||
|
Format struct {
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
} `json:"format"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &probe); err != nil {
|
||||||
|
v.Validation = "ffprobe JSON parse failed: " + err.Error()
|
||||||
|
return v, fmt.Errorf("ffprobe json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(probe.Streams) == 0 {
|
||||||
|
errors = append(errors, "no streams detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasVideo, hasAudio bool
|
||||||
|
for _, s := range probe.Streams {
|
||||||
|
switch s.CodecType {
|
||||||
|
case "video":
|
||||||
|
hasVideo = true
|
||||||
|
if s.CodecName != "h264" {
|
||||||
|
errors = append(errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
|
||||||
|
}
|
||||||
|
if s.RFrameRate != "30/1" {
|
||||||
|
errors = append(errors, fmt.Sprintf("framerate must be 30/1, got %s", s.RFrameRate))
|
||||||
|
}
|
||||||
|
if s.Width != 1280 && s.Width != 1920 {
|
||||||
|
errors = append(errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
|
||||||
|
}
|
||||||
|
if s.Height != 720 && s.Height != 1080 {
|
||||||
|
errors = append(errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
|
||||||
|
}
|
||||||
|
if s.PixFmt != "yuv420p" {
|
||||||
|
errors = append(errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
|
||||||
|
}
|
||||||
|
case "audio":
|
||||||
|
hasAudio = true
|
||||||
|
if s.CodecName != "aac" {
|
||||||
|
errors = append(errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
|
||||||
|
}
|
||||||
|
if s.SampleRate != "48000" {
|
||||||
|
errors = append(errors, fmt.Sprintf("audio sample rate must be 48000, got %s", s.SampleRate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasVideo {
|
||||||
|
errors = append(errors, "no video stream found")
|
||||||
|
}
|
||||||
|
if !hasAudio {
|
||||||
|
errors = append(errors, "no audio stream found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse duration from format or stream
|
||||||
|
durationSec := 0.0
|
||||||
|
if probe.Format.Duration != "" {
|
||||||
|
if d, err := strconv.ParseFloat(probe.Format.Duration, 64); err == nil {
|
||||||
|
durationSec = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
v.Valid = false
|
||||||
|
v.Validation = strings.Join(errors, "; ")
|
||||||
|
return v, fmt.Errorf("validation failed: %s", v.Validation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate keyframe interval <=2s (60 frames at 30fps)
|
||||||
|
keyframeErr := validateKeyframeInterval(videoPath)
|
||||||
|
if keyframeErr != nil {
|
||||||
|
errors = append(errors, keyframeErr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
v.Valid = false
|
||||||
|
v.Validation = strings.Join(errors, "; ")
|
||||||
|
return v, fmt.Errorf("validation failed: %s", v.Validation)
|
||||||
|
}
|
||||||
|
|
||||||
|
v.Valid = true
|
||||||
|
v.Duration = durationSec
|
||||||
|
v.Validation = fmt.Sprintf("OK: H.264/AAC, 30fps, yuv420p, 48kHz, %.1fs, keyframes OK", durationSec)
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateKeyframeInterval checks that max gap between keyframes <=2s (60 frames at 30fps).
|
||||||
|
func validateKeyframeInterval(videoPath string) error {
|
||||||
|
cmd := exec.Command("ffprobe",
|
||||||
|
"-v", "quiet",
|
||||||
|
"-select_streams", "v:0",
|
||||||
|
"-show_frames",
|
||||||
|
"-show_entries", "frame=key_frame,pkt_pts_time",
|
||||||
|
"-of", "csv=p=0",
|
||||||
|
videoPath)
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("keyframe probe failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type kfEntry struct{ pts float64 }
|
||||||
|
var keyframes []kfEntry
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(line, ",")
|
||||||
|
if len(parts) < 2 || parts[0] != "1" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pts, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keyframes = append(keyframes, kfEntry{pts})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keyframes) < 2 {
|
||||||
|
return fmt.Errorf("insufficient keyframes for interval analysis (need >=2, got %d)", len(keyframes))
|
||||||
|
}
|
||||||
|
|
||||||
|
maxGap := 0.0
|
||||||
|
for i := 1; i < len(keyframes); i++ {
|
||||||
|
gap := keyframes[i].pts - keyframes[i-1].pts
|
||||||
|
if gap > maxGap {
|
||||||
|
maxGap = gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxGap > 2.1 { // small tolerance for floating point
|
||||||
|
return fmt.Errorf("keyframe interval too large: max %.2fs (must be <=2s)", maxGap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateVideoStrict runs ValidateVideo and returns a structured JSON result.
|
||||||
|
func ValidateVideoStrict(videoPath string) map[string]any {
|
||||||
|
v, err := ValidateVideo(videoPath)
|
||||||
|
result := map[string]any{
|
||||||
|
"path": videoPath,
|
||||||
|
"valid": v.Valid,
|
||||||
|
"errors": []string{},
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
result["errors"] = strings.Split(v.Validation, "; ")
|
||||||
|
}
|
||||||
|
if v.Valid {
|
||||||
|
result["duration_seconds"] = v.Duration
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// ValidatePlaylist runs normalization validation on all entries
|
// ValidatePlaylist runs normalization validation on all entries
|
||||||
func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
|
func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
|
||||||
videos, err := LoadPlaylist(playlistPath)
|
videos, err := LoadPlaylist(playlistPath)
|
||||||
@@ -139,8 +309,65 @@ func SyncToR2(localPath, r2Key string) error {
|
|||||||
// HealthHandler provides a simple health check endpoint
|
// HealthHandler provides a simple health check endpoint
|
||||||
func HealthHandler(w http.ResponseWriter, r *http.Request) {
|
func HealthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
status := "healthy"
|
||||||
|
if playoutMode == "slate" {
|
||||||
|
status = "degraded"
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
fmt.Fprint(w, `{"status":"healthy","service":"playout","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
|
fmt.Fprintf(w, `{"status":"%s","service":"playout","mode":"%s","restarts":%d,"timestamp":"%s"}`,
|
||||||
|
status, playoutMode, restartCount, time.Now().UTC().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
// activateSlate copies pre-rendered slate HLS into the live output directory.
|
||||||
|
// Writes a static manifest referencing slate segments. Returns error if slate not found.
|
||||||
|
func activateSlate() error {
|
||||||
|
slateHLS := filepath.Join(slateDir, "hls")
|
||||||
|
if _, err := os.Stat(filepath.Join(slateHLS, "slate.m3u8")); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("slate HLS not found at %s — run scripts/generate-slate.sh first", slateHLS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing live segments
|
||||||
|
if err := resetHLSOutput(hlsDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to clear HLS output for slate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy slate segments into live HLS dir
|
||||||
|
slateFiles, err := filepath.Glob(filepath.Join(slateHLS, "*"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, src := range slateFiles {
|
||||||
|
dst := filepath.Join(hlsDir, filepath.Base(src))
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playoutMode = "slate"
|
||||||
|
fmt.Printf("[%s] EMERGENCY: Switched to slate fallback. %d restarts in window.\n",
|
||||||
|
time.Now().Format(time.RFC3339), restartCount)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deactivateSlate clears slate state and prepares for live playout resumption.
|
||||||
|
func deactivateSlate() {
|
||||||
|
playoutMode = "live"
|
||||||
|
fmt.Printf("[%s] RECOVERY: Slate deactivated, resuming live playout.\n",
|
||||||
|
time.Now().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldEscalateToSlate checks if restart count exceeds threshold within the window.
|
||||||
|
func shouldEscalateToSlate() bool {
|
||||||
|
if time.Since(restartWindow) > 60*time.Second {
|
||||||
|
restartCount = 0
|
||||||
|
restartWindow = time.Now()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return restartCount >= maxRestartsPerMinute
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetHLSOutput(hlsDir string) error {
|
func resetHLSOutput(hlsDir string) error {
|
||||||
@@ -207,6 +434,21 @@ func main() {
|
|||||||
|
|
||||||
// Start HTTP health check server
|
// Start HTTP health check server
|
||||||
http.HandleFunc("/health", HealthHandler)
|
http.HandleFunc("/health", HealthHandler)
|
||||||
|
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_playout_restarts_total Total FFmpeg restarts.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_playout_restarts_total counter")
|
||||||
|
fmt.Fprintf(w, "linearvoid_playout_restarts_total %d\n", restartCount)
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_playout_mode Current playout mode (0=live, 1=slate, 2=degraded).")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_playout_mode gauge")
|
||||||
|
modeVal := 0
|
||||||
|
if playoutMode == "slate" {
|
||||||
|
modeVal = 1
|
||||||
|
} else if playoutMode == "degraded" {
|
||||||
|
modeVal = 2
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "linearvoid_playout_mode %d\n", modeVal)
|
||||||
|
})
|
||||||
go func() {
|
go func() {
|
||||||
fmt.Println("Health check server listening on :8081/health")
|
fmt.Println("Health check server listening on :8081/health")
|
||||||
if err := http.ListenAndServe(":8081", nil); err != nil {
|
if err := http.ListenAndServe(":8081", nil); err != nil {
|
||||||
@@ -214,9 +456,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Run FFmpeg in a loop for continuous playback with R2 sync hook
|
// Run FFmpeg in a loop for continuous playback with slate fallback
|
||||||
for {
|
for {
|
||||||
if err := resetHLSOutput("/app/hls"); err != nil {
|
if playoutMode == "slate" {
|
||||||
|
// During slate: attempt recovery by restarting FFmpeg
|
||||||
|
fmt.Printf("[%s] Slate active — attempting live playout recovery...\n", time.Now().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := resetHLSOutput(hlsDir); err != nil {
|
||||||
fmt.Println("Failed to reset HLS output:", err)
|
fmt.Println("Failed to reset HLS output:", err)
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
continue
|
continue
|
||||||
@@ -241,12 +488,32 @@ func main() {
|
|||||||
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.m4s",
|
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.m4s",
|
||||||
"/app/hls/playlist.m3u8")
|
"/app/hls/playlist.m3u8")
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
runErr := cmd.Run()
|
||||||
fmt.Println("FFmpeg exited with error. Restarting in 5s (per ADR-001 resilience)...")
|
|
||||||
|
// Track restart and check for slate escalation
|
||||||
|
restartCount++
|
||||||
|
fmt.Printf("[%s] FFmpeg exited (restart #%d in window): %v\n",
|
||||||
|
time.Now().Format(time.RFC3339), restartCount, runErr)
|
||||||
|
|
||||||
|
if shouldEscalateToSlate() {
|
||||||
|
fmt.Printf("[%s] CRITICAL: %d restarts in 60s — escalating to slate fallback\n",
|
||||||
|
time.Now().Format(time.RFC3339), restartCount)
|
||||||
|
if err := activateSlate(); err != nil {
|
||||||
|
fmt.Printf("[%s] Slate activation failed: %v — will retry\n",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
}
|
||||||
|
// Stay in slate mode, retry recovery after delay
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we were in slate but FFmpeg ran successfully, we recovered
|
||||||
|
if playoutMode == "slate" {
|
||||||
|
deactivateSlate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// After FFmpeg run, sync latest segments and manifest to R2
|
// After FFmpeg run, sync latest segments and manifest to R2
|
||||||
if err := syncLatestToR2("/app/hls"); err != nil {
|
if err := syncLatestToR2(hlsDir); err != nil {
|
||||||
fmt.Println("R2 sync warning:", err)
|
fmt.Println("R2 sync warning:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+59
-2
@@ -1,3 +1,9 @@
|
|||||||
|
# docker-compose.prod.yml — TEST LAB DEPLOYMENT
|
||||||
|
# Target: test lab host 192.168.88.40 (NOT Hetzner production).
|
||||||
|
# Production Hetzner VPS not yet provisioned — see DEPLOYMENT.md Production Deployment section.
|
||||||
|
# Uses pre-built images from Gitea Container Registry (no local build).
|
||||||
|
# Override DOCKER_HOST in CD workflow to switch target.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
playout:
|
playout:
|
||||||
image: git.allucanget.biz/zwitschi/linearvoid/playout:latest
|
image: git.allucanget.biz/zwitschi/linearvoid/playout:latest
|
||||||
@@ -9,6 +15,7 @@ services:
|
|||||||
- ./data/playlist:/app/playlist:rw
|
- ./data/playlist:/app/playlist:rw
|
||||||
- ./data/media:/app/media:ro
|
- ./data/media:/app/media:ro
|
||||||
- ./data/hls:/app/hls:rw
|
- ./data/hls:/app/hls:rw
|
||||||
|
- ./data/slate:/app/slate:ro
|
||||||
networks:
|
networks:
|
||||||
- linearvoid-net
|
- linearvoid-net
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -68,7 +75,7 @@ services:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: linearvoid-redis
|
container_name: linearvoid-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: redis-server --appendonly yes
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
networks:
|
networks:
|
||||||
@@ -97,6 +104,56 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
nginx-exporter:
|
||||||
|
image: nginx/nginx-prometheus-exporter:latest
|
||||||
|
container_name: linearvoid-nginx-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- "-nginx.scrape-uri=http://nginx/nginx-status"
|
||||||
|
networks:
|
||||||
|
- linearvoid-net
|
||||||
|
depends_on:
|
||||||
|
- nginx
|
||||||
|
|
||||||
|
redis-exporter:
|
||||||
|
image: oliver006/redis_exporter:latest
|
||||||
|
container_name: linearvoid-redis-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- "-redis.addr=redis://redis:6379"
|
||||||
|
networks:
|
||||||
|
- linearvoid-net
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
postgres-exporter:
|
||||||
|
image: prometheuscommunity/postgres-exporter:latest
|
||||||
|
container_name: linearvoid-postgres-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATA_SOURCE_NAME: "postgresql://linearvoid:${POSTGRES_PASSWORD:-linearvoid}@db:5432/linearvoid?sslmode=disable"
|
||||||
|
networks:
|
||||||
|
- linearvoid-net
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:latest
|
||||||
|
container_name: linearvoid-node-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
pid: host
|
||||||
|
volumes:
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /:/rootfs:ro
|
||||||
|
command:
|
||||||
|
- "--path.procfs=/host/proc"
|
||||||
|
- "--path.sysfs=/host/sys"
|
||||||
|
- "--path.rootfs=/rootfs"
|
||||||
|
- "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
|
||||||
|
networks:
|
||||||
|
- linearvoid-net
|
||||||
|
|
||||||
prometheus:
|
prometheus:
|
||||||
image: prom/prometheus:latest
|
image: prom/prometheus:latest
|
||||||
container_name: linearvoid-prometheus
|
container_name: linearvoid-prometheus
|
||||||
@@ -141,6 +198,6 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
redis-data:
|
redis-data:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
prometheus-data:
|
|
||||||
prometheus-config:
|
prometheus-config:
|
||||||
|
prometheus-data:
|
||||||
grafana-data:
|
grafana-data:
|
||||||
+58
-1
@@ -1,3 +1,8 @@
|
|||||||
|
# docker-compose.yml — LOCAL DEVELOPMENT
|
||||||
|
# Target: local workstation or test lab (192.168.88.0/24).
|
||||||
|
# NOT for production. Use docker-compose.prod.yml for deployment.
|
||||||
|
# Services build from source; ports exposed for direct dev access.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
playout:
|
playout:
|
||||||
image: ${CI_REGISTRY_URL:-git.allucanget.biz}/${CI_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/playout:latest
|
image: ${CI_REGISTRY_URL:-git.allucanget.biz}/${CI_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/playout:latest
|
||||||
@@ -12,6 +17,7 @@ services:
|
|||||||
- ./data/playlist:/app/playlist:ro
|
- ./data/playlist:/app/playlist:ro
|
||||||
- ./data/media:/app/media:ro
|
- ./data/media:/app/media:ro
|
||||||
- ./data/hls:/app/hls:rw
|
- ./data/hls:/app/hls:rw
|
||||||
|
- ./data/slate:/app/slate:ro
|
||||||
networks:
|
networks:
|
||||||
- linearvoid-net
|
- linearvoid-net
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -72,7 +78,7 @@ services:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: linearvoid-redis
|
container_name: linearvoid-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: redis-server --appendonly yes
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
networks:
|
networks:
|
||||||
@@ -91,12 +97,63 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- linearvoid-net
|
- linearvoid-net
|
||||||
|
|
||||||
|
nginx-exporter:
|
||||||
|
image: nginx/nginx-prometheus-exporter:latest
|
||||||
|
container_name: linearvoid-nginx-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- "-nginx.scrape-uri=http://nginx/nginx-status"
|
||||||
|
networks:
|
||||||
|
- linearvoid-net
|
||||||
|
depends_on:
|
||||||
|
- nginx
|
||||||
|
|
||||||
|
redis-exporter:
|
||||||
|
image: oliver006/redis_exporter:latest
|
||||||
|
container_name: linearvoid-redis-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- "-redis.addr=redis://redis:6379"
|
||||||
|
networks:
|
||||||
|
- linearvoid-net
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
postgres-exporter:
|
||||||
|
image: prometheuscommunity/postgres-exporter:latest
|
||||||
|
container_name: linearvoid-postgres-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATA_SOURCE_NAME: "postgresql://linearvoid:${POSTGRES_PASSWORD:-linearvoid}@db:5432/linearvoid?sslmode=disable"
|
||||||
|
networks:
|
||||||
|
- linearvoid-net
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:latest
|
||||||
|
container_name: linearvoid-node-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
pid: host
|
||||||
|
volumes:
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /:/rootfs:ro
|
||||||
|
command:
|
||||||
|
- "--path.procfs=/host/proc"
|
||||||
|
- "--path.sysfs=/host/sys"
|
||||||
|
- "--path.rootfs=/rootfs"
|
||||||
|
- "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
|
||||||
|
networks:
|
||||||
|
- linearvoid-net
|
||||||
|
|
||||||
prometheus:
|
prometheus:
|
||||||
image: prom/prometheus:latest
|
image: prom/prometheus:latest
|
||||||
container_name: linearvoid-prometheus
|
container_name: linearvoid-prometheus
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
|
- ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
ports:
|
ports:
|
||||||
- "9090:9090"
|
- "9090:9090"
|
||||||
|
|||||||
+116
-6
@@ -20,7 +20,14 @@
|
|||||||
- [Building Block View](#building-block-view)
|
- [Building Block View](#building-block-view)
|
||||||
- [Whitebox Overall System](#whitebox-overall-system)
|
- [Whitebox Overall System](#whitebox-overall-system)
|
||||||
- [Level 2](#level-2)
|
- [Level 2](#level-2)
|
||||||
|
- [Playout Service (`backend/playout/src/playlist.go`)](#playout-service-backendplayoutsrcplaylistgo)
|
||||||
|
- [Chat Service (`backend/chat/src/main.go`)](#chat-service-backendchatsrcmaingo)
|
||||||
|
- [Frontend SPA (`frontend/`)](#frontend-spa-frontend)
|
||||||
|
- [Reverse Proxy (`infrastructure/nginx/nginx.conf`)](#reverse-proxy-infrastructurenginxnginxconf)
|
||||||
- [Level 3](#level-3)
|
- [Level 3](#level-3)
|
||||||
|
- [Chat Service Modules](#chat-service-modules)
|
||||||
|
- [Playout Service Modules](#playout-service-modules)
|
||||||
|
- [Frontend Modules](#frontend-modules)
|
||||||
- [Runtime View](#runtime-view)
|
- [Runtime View](#runtime-view)
|
||||||
- [Playout \& Delivery Scenario](#playout--delivery-scenario)
|
- [Playout \& Delivery Scenario](#playout--delivery-scenario)
|
||||||
- [Deployment View](#deployment-view)
|
- [Deployment View](#deployment-view)
|
||||||
@@ -259,11 +266,114 @@ Detailed black-box descriptions, level-2/3 views, and code locations are in `adr
|
|||||||
|
|
||||||
### Level 2
|
### Level 2
|
||||||
|
|
||||||
Deferred to implementation (see ADR for component responsibilities, interfaces, quality attributes, and risks). Playout enforces normalization; Delivery layer focuses on cache rules and immutable naming.
|
Detailed component diagram with code locations and persistence.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Cloudflare Edge"
|
||||||
|
CF[CDN + WAF<br/>Cache Rules]
|
||||||
|
R2[R2 Bucket<br/>media + HLS]
|
||||||
|
PAGES[Pages<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 using FFmpeg concat demuxer
|
||||||
|
- **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths
|
||||||
|
- **Output**: HLS segments (`.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2
|
||||||
|
- **Resilience**: Restart counter (>5/60s → activate slate), Prometheus metrics on port 8081
|
||||||
|
- **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval)
|
||||||
|
|
||||||
|
#### Chat Service (`backend/chat/src/main.go`)
|
||||||
|
|
||||||
|
- **Role**: WebSocket chat with rooms, DMs, moderation, admin API
|
||||||
|
- **Persistence**: PostgreSQL 15 via GORM (messages, moderation logs, media metadata). SQLite for dev fallback
|
||||||
|
- **Redis**: Presence tracking, rate limiting, pub/sub for multi-instance fan-out
|
||||||
|
- **Auth**: Session-cookie admin auth with Redis-backed expiry
|
||||||
|
- **Metrics**: Connected clients, stored messages, DMs, moderation actions on `/metrics`
|
||||||
|
|
||||||
|
#### Frontend SPA (`frontend/`)
|
||||||
|
|
||||||
|
- **Viewer**: `src/main.ts` — hls.js player, WebSocket chat, presence indicators, schedule overlay
|
||||||
|
- **Admin**: `src/admin.ts` — 9 dynamic tabs (dashboard, upload, playlist, schedule, moderation, chat-log, users, analytics, system)
|
||||||
|
- **Build**: Vite → static output in `frontend/dist/`
|
||||||
|
|
||||||
|
#### Reverse Proxy (`infrastructure/nginx/nginx.conf`)
|
||||||
|
|
||||||
|
- auth_request gate on `/admin` and `/api/admin`
|
||||||
|
- WebSocket upgrade for `/ws`
|
||||||
|
- HLS CORS headers + OPTIONS preflight
|
||||||
|
- `/health` JSON endpoint, `/nginx-status` stub_status
|
||||||
|
|
||||||
### Level 3
|
### Level 3
|
||||||
|
|
||||||
Deferred to implementation (see ADR for component responsibilities, interfaces, quality attributes, and risks). Playout enforces normalization; Delivery layer focuses on cache rules and immutable naming.
|
Module-level breakdown per service.
|
||||||
|
|
||||||
|
#### Chat Service Modules
|
||||||
|
|
||||||
|
| Module | File/Lines | Responsibility |
|
||||||
|
| ---------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| **Connection Manager** | `main.go` (hub, clients map) | Track active WebSocket connections per room, fan-out messages |
|
||||||
|
| **Message Parser** | `main.go` (readPump, handleMessage) | Parse JSON envelope `{type, room?, ...}`, validate fields, route to handler |
|
||||||
|
| **Room Registry** | `main.go` (rooms map, room broadcasts) | Map room name → client set, handle join/leave/close |
|
||||||
|
| **DM Router** | `main.go` (handleDirectMessage) | Resolve target user in clients map, deliver private message |
|
||||||
|
| **Persistence Layer** | `main.go` (GORM models: Message, DirectMessage, ModerationLog) | Store/retrieve chat messages, DMs, moderation events |
|
||||||
|
| **Redis Subsystem** | `main.go` (redisClient, rate limiting, pub/sub, presence) | INCR+EXPIRE rate limit, SETEX presence, PSUBSCRIBE room fan-out |
|
||||||
|
| **Auth Handler** | `main.go` (handleAdminLogin, adminAuthMiddleware) | Session cookie creation/validation, login rate limiting |
|
||||||
|
| **Admin API Router** | `main.go` (handleAdmin\*) | Upload, playlist CRUD, schedule overrides, moderation actions, analytics, system summary |
|
||||||
|
| **Metrics Exporter** | `main.go` (prometheus.MustRegister) | Expose connected_clients, messages_total, dms_total, moderation_actions, redis_up |
|
||||||
|
|
||||||
|
#### Playout Service Modules
|
||||||
|
|
||||||
|
| Module | File/Lines | Responsibility |
|
||||||
|
| -------------------- | ---------------------------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| **Playlist Parser** | `playlist.go` (parsePlaylist) | Read `playlist.txt`, skip comments/blank lines, resolve file paths |
|
||||||
|
| **FFmpeg Manager** | `playlist.go` (runPlayout, restart logic) | Spawn/monitor FFmpeg concat process, restart on crash |
|
||||||
|
| **Slate Controller** | `playlist.go` (activateSlate, deactivateSlate) | Switch output to slate loop on excessive failures |
|
||||||
|
| **R2 Sync** | `playlist.go` (syncToR2) | Upload HLS segments + manifest to R2 bucket |
|
||||||
|
| **Health Endpoint** | `playlist.go` (healthHandler) | `/health` → JSON with mode, segment count, uptime |
|
||||||
|
| **Metrics Exporter** | `playlist.go` (prometheus) | Expose restarts_total, mode (live/slate) |
|
||||||
|
|
||||||
|
#### Frontend Modules
|
||||||
|
|
||||||
|
| Module | File | Responsibility |
|
||||||
|
| ----------------------- | ----------------------------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| **HLS Player** | `src/main.ts` | hls.js initialization, attach to video element, error recovery |
|
||||||
|
| **WebSocket Client** | `src/main.ts` | Connect/disconnect, parse envelope, dispatch to UI handlers |
|
||||||
|
| **Chat UI** | `src/main.ts` | Message list rendering, room tabs, DM panel, presence dots |
|
||||||
|
| **Schedule Overlay** | `src/main.ts` | Poll `/api/schedule`, render current/next program |
|
||||||
|
| **Admin Router** | `src/admin.ts` | Tab-based navigation, dynamic page loading |
|
||||||
|
| **Upload Manager** | `src/admin.ts` | Multipart upload with chunk progress, ffprobe validation feedback |
|
||||||
|
| **Playlist Editor** | `src/admin.ts` | Drag-reorder, add/remove items, save to `/api/admin/playlist` |
|
||||||
|
| **Moderation Panel** | `src/admin.ts` + `public/admin/moderation.html` | Inline mute/ban, action log viewer |
|
||||||
|
| **Analytics Dashboard** | `src/admin.ts` + `public/admin/analytics.html` | Message counts, top rooms, moderation stats |
|
||||||
|
| **System Config** | `src/admin.ts` + `public/admin/system.html` | Service health, config metadata, restart buttons |
|
||||||
|
|
||||||
## Runtime View
|
## Runtime View
|
||||||
|
|
||||||
@@ -284,7 +394,7 @@ Notable aspects: Manifest freshness ensures all viewers stay on same timeline; c
|
|||||||
|
|
||||||
### CI/CD Pipeline Architecture
|
### CI/CD Pipeline Architecture
|
||||||
|
|
||||||
The deployment follows a two-stage pipeline: **CI builds and pushes images**, then **CD pulls and deploys** them to the production host.
|
The deployment follows a two-stage pipeline: **CI builds and pushes images**, then **CD pulls and deploys** them. Currently targeting the **test lab** (192.168.88.40). Production Hetzner VPS is planned but not yet provisioned — the same pipeline will deploy there by changing `DOCKER_HOST`.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -295,7 +405,7 @@ flowchart LR
|
|||||||
subgraph "Gitea Container Registry"
|
subgraph "Gitea Container Registry"
|
||||||
REGISTRY[git.allucanget.biz<br/>playout:latest<br/>chat:latest<br/>nginx:latest]
|
REGISTRY[git.allucanget.biz<br/>playout:latest<br/>chat:latest<br/>nginx:latest]
|
||||||
end
|
end
|
||||||
subgraph "Production Host (192.168.88.40)"
|
subgraph "Test Lab Host (192.168.88.40) — Production Hetzner TBD"
|
||||||
DOCKER[Docker Engine<br/>via TCP :2375]
|
DOCKER[Docker Engine<br/>via TCP :2375]
|
||||||
PLAYOUT[linearvoid-playout]
|
PLAYOUT[linearvoid-playout]
|
||||||
CHAT[linearvoid-chat]
|
CHAT[linearvoid-chat]
|
||||||
@@ -331,7 +441,7 @@ flowchart LR
|
|||||||
- Pulls latest images from registry
|
- Pulls latest images from registry
|
||||||
- Generates `.env` from secrets
|
- Generates `.env` from secrets
|
||||||
- Seeds Prometheus config into named volume
|
- Seeds Prometheus config into named volume
|
||||||
- Deploys via `docker compose -f docker-compose.prod.yml up -d` to remote host (`192.168.88.40:2375`)
|
- Deploys via `docker compose -f docker-compose.prod.yml up -d` to remote host (`192.168.88.40:2375` — test lab; switch to Hetzner IP for production)
|
||||||
- Runs health checks with retry logic for playout initialization
|
- Runs health checks with retry logic for playout initialization
|
||||||
|
|
||||||
### Infrastructure Level 1
|
### Infrastructure Level 1
|
||||||
@@ -350,7 +460,7 @@ flowchart LR
|
|||||||
|
|
||||||
### Infrastructure Level 2
|
### Infrastructure Level 2
|
||||||
|
|
||||||
**Hetzner VPS**: Runs playout (FFmpeg/MediaMTX), chat backend, Nginx. Monitoring via dashboards + UptimeRobot. Backups to R2.
|
**Hetzner VPS (planned):** Will run playout (FFmpeg/MediaMTX), chat backend, Nginx. Monitoring via dashboards + UptimeRobot. Backups to R2. Currently test lab (`192.168.88.40`) serves this role. Production Hetzner instance not yet provisioned — see Production Deployment section in [`DEPLOYMENT.md`](../../DEPLOYMENT.md).
|
||||||
|
|
||||||
**Cloudflare Layer**:
|
**Cloudflare Layer**:
|
||||||
|
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ Create a dedicated **admin web interface** in a new `frontend/admin/` directory
|
|||||||
- Integration with existing playout service and R2 keeps the architecture consistent.
|
- Integration with existing playout service and R2 keeps the architecture consistent.
|
||||||
- Admin actions will be fully auditable, paving the way for ADR-005 (moderation and user management).
|
- Admin actions will be fully auditable, paving the way for ADR-005 (moderation and user management).
|
||||||
|
|
||||||
This decision was planned in `TODO.md` before implementation, per AGENTS.md.
|
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
**Positive**:
|
**Positive**:
|
||||||
|
|||||||
@@ -59,8 +59,6 @@ Extend the admin interface started in ADR-004 with additional protected sections
|
|||||||
- Leveraging Prometheus/Grafana for analytics avoids duplicating metrics collection.
|
- Leveraging Prometheus/Grafana for analytics avoids duplicating metrics collection.
|
||||||
- Immutable audit logging supports future compliance and moderation review (key requirement for a public chat system).
|
- Immutable audit logging supports future compliance and moderation review (key requirement for a public chat system).
|
||||||
|
|
||||||
This ADR was planned in `TODO.md` before any implementation, per AGENTS.md.
|
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
**Positive**:
|
**Positive**:
|
||||||
@@ -92,8 +90,6 @@ This ADR was planned in `TODO.md` before any implementation, per AGENTS.md.
|
|||||||
7. Update authentication middleware and tests.
|
7. Update authentication middleware and tests.
|
||||||
8. Document API contracts and update `docker-compose.yml` to expose the admin interface securely.
|
8. Document API contracts and update `docker-compose.yml` to expose the admin interface securely.
|
||||||
|
|
||||||
**Next Steps**: After both admin ADRs are complete, move to implementation of the Playout Service (highest priority in the core modules section of TODO.md).
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- ADR-001: Chat features, moderation requirements, monitoring needs.
|
- ADR-001: Chat features, moderation requirements, monitoring needs.
|
||||||
|
|||||||
@@ -95,8 +95,6 @@ We must explicitly document:
|
|||||||
6. Add monitoring (Prometheus exporter) and admin audit logging for configuration changes.
|
6. Add monitoring (Prometheus exporter) and admin audit logging for configuration changes.
|
||||||
7. Write tests for rate limiting, presence, and admin configuration flows.
|
7. Write tests for rate limiting, presence, and admin configuration flows.
|
||||||
|
|
||||||
**Next Steps**: After ADR-006 and ADR-007 are reviewed, mark the "Create ADRs for Data Storage, Caching, and Admin Capabilities" section as completed in TODO.md and move it to DONE.md. Then proceed with implementation of the Playout Service.
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- ADR-001: Chat features requiring presence, rate limiting, and pub/sub.
|
- ADR-001: Chat features requiring presence, rate limiting, and pub/sub.
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Incident Runbook
|
||||||
|
|
||||||
|
Triage order for Linear Void incidents. Work top-to-bottom — each tier depends on the one above.
|
||||||
|
|
||||||
|
## Triage Order
|
||||||
|
|
||||||
|
1. Edge Reachability (Cloudflare)
|
||||||
|
2. Origin Health (VPS/Docker)
|
||||||
|
3. Playout Progression (HLS)
|
||||||
|
4. Chat Connectivity (WebSocket)
|
||||||
|
5. Data-Plane (Redis/DB)
|
||||||
|
|
||||||
|
## 1. Edge Reachability (Cloudflare)
|
||||||
|
|
||||||
|
**Symptoms:** `linearvoid.tv` not resolving, SSL errors, 5xx from Cloudflare.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check DNS
|
||||||
|
dig linearvoid.tv
|
||||||
|
dig www.linearvoid.tv
|
||||||
|
|
||||||
|
# Check Cloudflare status
|
||||||
|
curl -I https://linearvoid.tv/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common causes:**
|
||||||
|
|
||||||
|
- DNS misconfiguration (A record pointing to wrong IP)
|
||||||
|
- Cloudflare proxy disabled (grey cloud → origin exposed directly)
|
||||||
|
- SSL certificate expired (check Cloudflare dashboard → SSL/TLS)
|
||||||
|
- Cloudflare outage → check [https://www.cloudflarestatus.com](https://www.cloudflarestatus.com)
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
|
||||||
|
- Verify A record: `linearvoid.tv` → origin IP (proxied, orange cloud)
|
||||||
|
- Bypass Cloudflare temporarily: `curl -H "Host: linearvoid.tv" http://<origin-ip>/health`
|
||||||
|
|
||||||
|
## 2. Origin Health (VPS/Docker)
|
||||||
|
|
||||||
|
**Symptoms:** Cloudflare returns 502/504, containers down.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remote Docker (test lab)
|
||||||
|
docker -H tcp://192.168.88.40:2375 ps
|
||||||
|
docker -H tcp://192.168.88.40:2375 logs linearvoid-nginx --tail 50
|
||||||
|
|
||||||
|
# Direct health check
|
||||||
|
curl http://192.168.88.40/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common causes:**
|
||||||
|
|
||||||
|
- Host out of disk space: `df -h`
|
||||||
|
- Docker daemon stopped: `systemctl status docker`
|
||||||
|
- Container restart loop: `docker ps -a --filter "status=exited"`
|
||||||
|
- Resource exhaustion (OOM): `dmesg | grep -i oom`
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
|
||||||
|
- Restart stack: `docker compose -f docker-compose.prod.yml up -d`
|
||||||
|
- Free disk: `docker system prune -f`
|
||||||
|
- Check logs per service: `docker logs linearvoid-<service> --tail 100`
|
||||||
|
|
||||||
|
## 3. Playout Progression (HLS)
|
||||||
|
|
||||||
|
**Symptoms:** Viewer sees slate or buffering, playlist not advancing.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check playout health
|
||||||
|
curl http://192.168.88.40:8081/health
|
||||||
|
# Response includes "mode": "live" or "slate"
|
||||||
|
|
||||||
|
# Check HLS output
|
||||||
|
curl -I http://192.168.88.40/hls/playlist.m3u8
|
||||||
|
ls -la data/hls/
|
||||||
|
|
||||||
|
# Check playout logs
|
||||||
|
docker logs linearvoid-playout --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common causes:**
|
||||||
|
|
||||||
|
- FFmpeg crash → slate fallback active (health shows `degraded`)
|
||||||
|
- Playlist file empty/missing: `cat data/playlist/playlist.txt`
|
||||||
|
- Media file missing from playlist: `ls data/media/`
|
||||||
|
- Disk full → HLS segments can't write
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
|
||||||
|
- Verify playlist has valid entries
|
||||||
|
- Check media files exist at paths in playlist
|
||||||
|
- Restart playout: `docker compose restart playout`
|
||||||
|
- Generate demo playlist if empty: see `backend/playout/start-playout.sh`
|
||||||
|
|
||||||
|
## 4. Chat Connectivity (WebSocket)
|
||||||
|
|
||||||
|
**Symptoms:** Chat not loading, messages not sending, presence broken.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check chat health
|
||||||
|
curl http://192.168.88.40:3000/health
|
||||||
|
|
||||||
|
# Test WebSocket
|
||||||
|
wscat -c ws://192.168.88.40/ws?username=test&room=main
|
||||||
|
|
||||||
|
# Check chat logs
|
||||||
|
docker logs linearvoid-chat --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common causes:**
|
||||||
|
|
||||||
|
- Redis down → presence/rate-limiting degraded (chat still works)
|
||||||
|
- Database down → messages not persisting
|
||||||
|
- Port not exposed: `docker port linearvoid-chat`
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
|
||||||
|
- Restart chat: `docker compose restart chat`
|
||||||
|
- Check Redis: `docker exec linearvoid-redis redis-cli ping`
|
||||||
|
- Check DB: `docker exec linearvoid-db pg_isready -U linearvoid`
|
||||||
|
- Chat works without Redis (graceful degradation) — check logs for "Redis connection warning"
|
||||||
|
|
||||||
|
## 5. Data-Plane (Redis/DB)
|
||||||
|
|
||||||
|
**Symptoms:** Chat works but no persistence/presence, admin stats empty.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Redis
|
||||||
|
docker exec linearvoid-redis redis-cli ping
|
||||||
|
docker exec linearvoid-redis redis-cli info memory
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
docker exec linearvoid-db pg_isready -U linearvoid
|
||||||
|
docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common causes:**
|
||||||
|
|
||||||
|
- Redis OOM (check `maxmemory` setting)
|
||||||
|
- PostgreSQL connection limit reached
|
||||||
|
- Disk full on volume mount
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
|
||||||
|
- Redis: `docker compose restart redis` (AOF persist enabled)
|
||||||
|
- PostgreSQL: `docker compose restart db`
|
||||||
|
- Restore from backup: see `scripts/verify-backups.sh` for procedure
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
| Severity | When | Action |
|
||||||
|
| -------- | ------------------------------------ | -------------------------------------------------- |
|
||||||
|
| Critical | Slate active >30min, complete outage | Trigger DR plan, provision fallback host |
|
||||||
|
| Warning | Redis down, high CPU >30min | Investigate during business hours, restart service |
|
||||||
|
| Info | Single service blip | Monitor, no action unless recurring |
|
||||||
|
|
||||||
|
## Post-Incident
|
||||||
|
|
||||||
|
1. Document timeline in incident channel
|
||||||
|
2. Identify root cause
|
||||||
|
3. Add prevention to docs if actionable
|
||||||
|
4. Update this runbook if new failure mode discovered
|
||||||
|
|
||||||
|
**Recovery targets:** RPO ≤24h, RTO ≤60min. Quarterly DR drill: simulate host loss, restore from backup.
|
||||||
@@ -24,6 +24,10 @@
|
|||||||
<section class="viewer-content" aria-label="Live stream and chat layout">
|
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||||
<section class="player-stage" aria-label="Video stage">
|
<section class="player-stage" aria-label="Video stage">
|
||||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||||
|
<div id="now-playing" class="now-playing-overlay" aria-live="polite">
|
||||||
|
<span id="np-title">Loading...</span>
|
||||||
|
<span id="np-next" class="np-next"></span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||||
@@ -37,6 +41,10 @@
|
|||||||
|
|
||||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
||||||
|
|
||||||
|
<section id="presence-panel" class="presence-panel" aria-label="Online users">
|
||||||
|
<div id="presence-list" class="presence-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="chat-input-area">
|
<div class="chat-input-area">
|
||||||
<div class="chat-input-row">
|
<div class="chat-input-row">
|
||||||
<label for="message-input" class="sr-only">Message input</label>
|
<label for="message-input" class="sr-only">Message input</label>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.6",
|
"version": "1.0.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
<h1>Analytics</h1>
|
<h1>Analytics</h1>
|
||||||
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<select id="analytics-range" class="text-input" style="width:auto">
|
||||||
|
<option value="1h">Last hour</option>
|
||||||
|
<option value="6h">Last 6 hours</option>
|
||||||
|
<option value="24h" selected>Last 24 hours</option>
|
||||||
|
<option value="7d">Last 7 days</option>
|
||||||
|
</select>
|
||||||
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
|
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
<h1>Moderation</h1>
|
<h1>Moderation</h1>
|
||||||
<p>Create moderation actions and review the recent immutable audit log.</p>
|
<p>Create moderation actions and review the recent immutable audit log.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
<button id="reload-moderation-btn" type="button" class="btn btn-secondary">Reload logs</button>
|
<button id="reload-moderation-btn" type="button" class="btn btn-secondary">Reload logs</button>
|
||||||
|
<button id="export-chat-log-btn" type="button" class="btn btn-secondary">Export CSV</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="moderation-grid">
|
<div class="moderation-grid">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="panel-header-row">
|
<div class="panel-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h1>Playlist</h1>
|
<h1>Playlist</h1>
|
||||||
<p>Edit the active concat playlist used by the playout service.</p>
|
<p>Edit the active concat playlist. Drag entries to reorder.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
|
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<label for="playlist-content" class="sr-only">Playlist content</label>
|
<div id="playlist-warnings" class="status-warnings" hidden></div>
|
||||||
<textarea id="playlist-content" class="text-input text-area" spellcheck="false"></textarea>
|
<ul id="playlist-sortable" class="sortable-list" aria-label="Playlist entries"></ul>
|
||||||
|
<label for="playlist-content" class="sr-only">Playlist content (raw)</label>
|
||||||
|
<textarea id="playlist-content" class="text-input text-area" spellcheck="false" rows="6"></textarea>
|
||||||
<p id="playlist-status" class="status-line" aria-live="polite">Loading playlist...</p>
|
<p id="playlist-status" class="status-line" aria-live="polite">Loading playlist...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,3 +24,30 @@
|
|||||||
</div>
|
</div>
|
||||||
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
|
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<h2>Schedule Overrides</h2>
|
||||||
|
<p>Insert scheduled overrides to play specific videos at specific times.</p>
|
||||||
|
<form id="override-form" class="form-stack" style="max-width:500px">
|
||||||
|
<label>Media ID <input id="override-media-id" type="number" class="text-input" required /></label>
|
||||||
|
<label>Start Time <input id="override-start" type="datetime-local" class="text-input" required /></label>
|
||||||
|
<label>End Time <input id="override-end" type="datetime-local" class="text-input" required /></label>
|
||||||
|
<label>Reason <input id="override-reason" type="text" class="text-input" placeholder="e.g. special event" /></label>
|
||||||
|
<button type="submit" class="btn btn-primary">Add Override</button>
|
||||||
|
</form>
|
||||||
|
<p id="override-status" class="status-line" aria-live="polite"></p>
|
||||||
|
<div class="table-wrap" style="margin-top:1rem">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Media ID</th>
|
||||||
|
<th>Start</th>
|
||||||
|
<th>End</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="override-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -52,3 +52,24 @@
|
|||||||
<div id="system-services" class="service-list"></div>
|
<div id="system-services" class="service-list"></div>
|
||||||
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
|
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<h2>Runtime Configuration</h2>
|
||||||
|
<form id="config-form" class="form-stack" style="max-width:400px">
|
||||||
|
<label>Rate Limit Max <input id="config-rate-max" type="number" class="text-input" /></label>
|
||||||
|
<label>Rate Limit Window (seconds) <input id="config-rate-window" type="number" class="text-input" /></label>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Config</button>
|
||||||
|
</form>
|
||||||
|
<p id="config-status" class="status-line" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<h2>Service Control</h2>
|
||||||
|
<p class="small-muted">Restart individual services (manual — container restart commands returned by API).</p>
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="btn btn-secondary restart-btn" data-service="playout">Restart Playout</button>
|
||||||
|
<button class="btn btn-secondary restart-btn" data-service="chat">Restart Chat</button>
|
||||||
|
<button class="btn btn-secondary restart-btn" data-service="nginx">Restart Nginx</button>
|
||||||
|
</div>
|
||||||
|
<p id="restart-status" class="status-line" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
<th>Room</th>
|
<th>Room</th>
|
||||||
<th>Online</th>
|
<th>Online</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="users-table-body"></tbody>
|
<tbody id="users-table-body"></tbody>
|
||||||
|
|||||||
+235
-3
@@ -259,10 +259,13 @@ async function refreshPlaylist() {
|
|||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
|
const content: string = payload.content || "";
|
||||||
const textarea = document.getElementById(
|
const textarea = document.getElementById(
|
||||||
"playlist-content",
|
"playlist-content",
|
||||||
) as HTMLTextAreaElement | null;
|
) as HTMLTextAreaElement | null;
|
||||||
if (textarea) textarea.value = payload.content || "";
|
if (textarea) textarea.value = content;
|
||||||
|
renderPlaylistSortable(content);
|
||||||
|
await showPlaylistWarnings(content);
|
||||||
setStatus("playlist-status", "Playlist loaded.");
|
setStatus("playlist-status", "Playlist loaded.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load playlist", error);
|
console.error("Failed to load playlist", error);
|
||||||
@@ -270,6 +273,95 @@ async function refreshPlaylist() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPlaylistSortable(content: string) {
|
||||||
|
const list = document.getElementById("playlist-sortable");
|
||||||
|
if (!list) return;
|
||||||
|
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||||
|
list.innerHTML = lines
|
||||||
|
.map((line, i) => {
|
||||||
|
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
|
||||||
|
return `<li class="sortable-item" draggable="true" data-index="${i}">
|
||||||
|
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
|
||||||
|
<span class="sortable-index">${i + 1}</span>
|
||||||
|
<span class="sortable-path">${path}</span>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-remove="${i}">✕</button>
|
||||||
|
</li>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
let dragSrc: number | null = null;
|
||||||
|
list.querySelectorAll<HTMLElement>(".sortable-item").forEach((item) => {
|
||||||
|
item.addEventListener("dragstart", () => {
|
||||||
|
dragSrc = Number(item.dataset.index);
|
||||||
|
item.classList.add("dragging");
|
||||||
|
});
|
||||||
|
item.addEventListener("dragend", () => {
|
||||||
|
item.classList.remove("dragging");
|
||||||
|
dragSrc = null;
|
||||||
|
});
|
||||||
|
item.addEventListener("dragover", (e) => e.preventDefault());
|
||||||
|
item.addEventListener("drop", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const dst = Number(item.dataset.index);
|
||||||
|
if (dragSrc !== null && dragSrc !== dst) reorderPlaylistEntry(dragSrc, dst);
|
||||||
|
});
|
||||||
|
item.querySelector("[data-remove]")?.addEventListener("click", () => {
|
||||||
|
removePlaylistEntry(Number(item.dataset.index));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlaylistLines(): string[] {
|
||||||
|
const ta = document.getElementById("playlist-content") as HTMLTextAreaElement;
|
||||||
|
return ta?.value.split("\n").filter((l) => l.trim() && !l.startsWith("#")) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPlaylistTextarea(lines: string[]) {
|
||||||
|
const ta = document.getElementById("playlist-content") as HTMLTextAreaElement;
|
||||||
|
if (ta) ta.value = lines.join("\n") + "\n";
|
||||||
|
renderPlaylistSortable(lines.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderPlaylistEntry(from: number, to: number) {
|
||||||
|
const lines = getPlaylistLines();
|
||||||
|
const [moved] = lines.splice(from, 1);
|
||||||
|
lines.splice(to, 0, moved);
|
||||||
|
syncPlaylistTextarea(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePlaylistEntry(index: number) {
|
||||||
|
const lines = getPlaylistLines();
|
||||||
|
lines.splice(index, 1);
|
||||||
|
syncPlaylistTextarea(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showPlaylistWarnings(content: string) {
|
||||||
|
const warnings = document.getElementById("playlist-warnings");
|
||||||
|
if (!warnings) return;
|
||||||
|
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||||
|
const msgs: string[] = [];
|
||||||
|
if (lines.length === 0) msgs.push("Playlist is empty. Add files from the media library.");
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/admin/media");
|
||||||
|
if (resp.ok) {
|
||||||
|
const media = await resp.json();
|
||||||
|
const mediaPaths = new Set((media.files || []).map((f: { path: string }) => f.path));
|
||||||
|
const nonNorm = (media.files || []).filter((f: { status: string }) => f.status === "needs_normalization");
|
||||||
|
for (const line of lines) {
|
||||||
|
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
|
||||||
|
if (path && !mediaPaths.has(path)) msgs.push(`Missing from media library: ${path}`);
|
||||||
|
}
|
||||||
|
if (nonNorm.length > 0) msgs.push(`${nonNorm.length} file(s) need normalization. Run scripts/normalize.sh.`);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
if (msgs.length > 0) {
|
||||||
|
warnings.innerHTML = msgs.map((m) => `<div class="warning-msg">⚠ ${m}</div>`).join("");
|
||||||
|
warnings.hidden = false;
|
||||||
|
} else {
|
||||||
|
warnings.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function savePlaylist() {
|
async function savePlaylist() {
|
||||||
if (state.playlistSaving) return;
|
if (state.playlistSaving) return;
|
||||||
state.playlistSaving = true;
|
state.playlistSaving = true;
|
||||||
@@ -333,6 +425,55 @@ async function refreshSchedule() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshOverrides() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/admin/schedule/overrides");
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const overrides = await resp.json();
|
||||||
|
const tbody = document.getElementById("override-table-body");
|
||||||
|
if (!tbody) return;
|
||||||
|
if (!overrides.length) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="5">No overrides.</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = overrides
|
||||||
|
.map(
|
||||||
|
(o: { id: number; mediaId: number; startTime: string; endTime: string; reason: string }) =>
|
||||||
|
`<tr><td>${o.mediaId}</td><td>${new Date(o.startTime).toLocaleString()}</td><td>${new Date(o.endTime).toLocaleString()}</td><td>${o.reason || "-"}</td><td><button class="btn btn-secondary btn-sm" data-delete-override="${o.id}">✕</button></td></tr>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
tbody.querySelectorAll("[data-delete-override]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => deleteOverride(Number((btn as HTMLElement).dataset.deleteOverride)));
|
||||||
|
});
|
||||||
|
} catch { /* non-critical */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitOverride(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
const status = document.getElementById("override-status");
|
||||||
|
if (status) status.textContent = "Saving...";
|
||||||
|
const mediaId = (document.getElementById("override-media-id") as HTMLInputElement).value;
|
||||||
|
const startTime = (document.getElementById("override-start") as HTMLInputElement).value;
|
||||||
|
const endTime = (document.getElementById("override-end") as HTMLInputElement).value;
|
||||||
|
const reason = (document.getElementById("override-reason") as HTMLInputElement).value;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/admin/schedule/overrides", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mediaId: Number(mediaId), startTime: new Date(startTime).toISOString(), endTime: new Date(endTime).toISOString(), reason }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) { const text = await resp.text(); throw new Error(text); }
|
||||||
|
if (status) { status.textContent = "Override added."; status.className = "status-line"; }
|
||||||
|
refreshOverrides();
|
||||||
|
} catch (err) {
|
||||||
|
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteOverride(id: number) {
|
||||||
|
try { await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" }); refreshOverrides(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshSystemSummary() {
|
async function refreshSystemSummary() {
|
||||||
setStatus("system-status", "Loading system summary...");
|
setStatus("system-status", "Loading system summary...");
|
||||||
try {
|
try {
|
||||||
@@ -756,9 +897,17 @@ function renderChatLogTable() {
|
|||||||
<td>${entry.type}</td>
|
<td>${entry.type}</td>
|
||||||
<td>${entry.timestamp}</td>
|
<td>${entry.timestamp}</td>
|
||||||
<td class="action-cell">
|
<td class="action-cell">
|
||||||
<button type="button" class="btn btn-danger btn-small" data-action="delete-chat" data-id="${entry.id}">Delete</button>
|
<button type="button" class="btn btn-secondary btn-sm" data-action="chat-mute" data-user="${entry.username}">Mute</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-action="chat-ban" data-user="${entry.username}">Ban</button>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm" data-action="delete-chat" data-id="${entry.id}">Del</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
|
tr.querySelector('[data-action="chat-mute"]')?.addEventListener("click", () => {
|
||||||
|
userAction("mute", entry.username);
|
||||||
|
});
|
||||||
|
tr.querySelector('[data-action="chat-ban"]')?.addEventListener("click", () => {
|
||||||
|
if (confirm(`Ban ${entry.username}?`)) userAction("ban", entry.username);
|
||||||
|
});
|
||||||
tr.querySelector('[data-action="delete-chat"]')?.addEventListener(
|
tr.querySelector('[data-action="delete-chat"]')?.addEventListener(
|
||||||
"click",
|
"click",
|
||||||
() => deleteChatEntry(entry.id),
|
() => deleteChatEntry(entry.id),
|
||||||
@@ -930,9 +1079,19 @@ async function refreshUsers() {
|
|||||||
} else {
|
} else {
|
||||||
users.forEach((user: any) => {
|
users.forEach((user: any) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.innerHTML = `<td>${user.username}</td><td>${user.room}</td><td>${user.online ? "yes" : "no"}</td>`;
|
tr.innerHTML = `<td>${user.username}</td><td>${user.room}</td><td>${user.online ? "yes" : "no"}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-secondary btn-sm user-mute" data-user="${user.username}">Mute 10m</button>
|
||||||
|
<button class="btn btn-secondary btn-sm user-ban" data-user="${user.username}">Ban</button>
|
||||||
|
<button class="btn btn-secondary btn-sm user-unmute" data-user="${user.username}">Unmute</button>
|
||||||
|
<button class="btn btn-secondary btn-sm user-unban" data-user="${user.username}">Unban</button>
|
||||||
|
</td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
tbody.querySelectorAll(".user-mute").forEach(b => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!)));
|
||||||
|
tbody.querySelectorAll(".user-ban").forEach(b => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!)));
|
||||||
|
tbody.querySelectorAll(".user-unmute").forEach(b => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!)));
|
||||||
|
tbody.querySelectorAll(".user-unban").forEach(b => b.addEventListener("click", () => userAction("unban", (b as HTMLElement).dataset.user!)));
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(
|
setStatus(
|
||||||
@@ -947,6 +1106,63 @@ async function refreshUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function userAction(action: string, username: string) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action, username, duration: 10 }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
setStatus("users-status", `${action} ${username} OK.`);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("users-status", `Failed: ${err}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/admin/config");
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const cfg = await resp.json();
|
||||||
|
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
|
||||||
|
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
|
||||||
|
} catch { /* non-critical */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
const status = document.getElementById("config-status");
|
||||||
|
if (status) status.textContent = "Saving...";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/admin/config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
rateLimitMax: Number((document.getElementById("config-rate-max") as HTMLInputElement).value),
|
||||||
|
rateLimitWindowSec: Number((document.getElementById("config-rate-window") as HTMLInputElement).value),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
if (status) { status.textContent = "Config saved."; status.className = "status-line"; }
|
||||||
|
} catch (err) {
|
||||||
|
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartService(service: string) {
|
||||||
|
if (!confirm(`Restart ${service}? This may cause brief interruption.`)) return;
|
||||||
|
const status = document.getElementById("restart-status");
|
||||||
|
if (status) status.textContent = `Requesting restart of ${service}...`;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/admin/restart?service=${service}`, { method: "POST" });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (status) status.textContent = data.message;
|
||||||
|
} catch (err) {
|
||||||
|
if (status) status.textContent = `Failed: ${err}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
document
|
document
|
||||||
.querySelectorAll<HTMLButtonElement>(".tab-trigger")
|
.querySelectorAll<HTMLButtonElement>(".tab-trigger")
|
||||||
@@ -1017,12 +1233,21 @@ function bindPageEvents(tabName: string) {
|
|||||||
document
|
document
|
||||||
.getElementById("reload-schedule-btn")
|
.getElementById("reload-schedule-btn")
|
||||||
?.addEventListener("click", refreshSchedule);
|
?.addEventListener("click", refreshSchedule);
|
||||||
|
document
|
||||||
|
.getElementById("override-form")
|
||||||
|
?.addEventListener("submit", submitOverride);
|
||||||
|
refreshOverrides();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "moderation":
|
case "moderation":
|
||||||
document
|
document
|
||||||
.getElementById("reload-moderation-btn")
|
.getElementById("reload-moderation-btn")
|
||||||
?.addEventListener("click", refreshModerationLogs);
|
?.addEventListener("click", refreshModerationLogs);
|
||||||
|
document
|
||||||
|
.getElementById("export-chat-log-btn")
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
window.open("/api/admin/chat-log/export", "_blank");
|
||||||
|
});
|
||||||
document
|
document
|
||||||
.getElementById("moderation-form")
|
.getElementById("moderation-form")
|
||||||
?.addEventListener("submit", submitModerationAction);
|
?.addEventListener("submit", submitModerationAction);
|
||||||
@@ -1083,6 +1308,13 @@ function bindPageEvents(tabName: string) {
|
|||||||
document
|
document
|
||||||
.getElementById("reload-system-btn")
|
.getElementById("reload-system-btn")
|
||||||
?.addEventListener("click", refreshSystemSummary);
|
?.addEventListener("click", refreshSystemSummary);
|
||||||
|
document
|
||||||
|
.getElementById("config-form")
|
||||||
|
?.addEventListener("submit", saveConfig);
|
||||||
|
document.querySelectorAll(".restart-btn").forEach((btn) =>
|
||||||
|
btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!))
|
||||||
|
);
|
||||||
|
loadConfig();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ let ws: WebSocket | null = null;
|
|||||||
let currentRoom = "main";
|
let currentRoom = "main";
|
||||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||||
let chatVisible = true;
|
let chatVisible = true;
|
||||||
|
const onlineUsers = new Set<string>();
|
||||||
|
|
||||||
function resolveWebSocketBaseUrl() {
|
function resolveWebSocketBaseUrl() {
|
||||||
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
||||||
@@ -74,6 +75,19 @@ function connectWebSocket(room: string) {
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
||||||
|
|
||||||
|
// Track presence from system join/leave messages
|
||||||
|
if (msg.type === "system" && msg.username === "system") {
|
||||||
|
const joined = msg.text?.match(/^(\S+) joined /);
|
||||||
|
const left = msg.text?.match(/^(\S+) left /);
|
||||||
|
if (joined) {
|
||||||
|
onlineUsers.add(joined[1]);
|
||||||
|
renderPresence();
|
||||||
|
} else if (left) {
|
||||||
|
onlineUsers.delete(left[1]);
|
||||||
|
renderPresence();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
@@ -112,6 +126,37 @@ function addMessage(
|
|||||||
messages.scrollTop = messages.scrollHeight;
|
messages.scrollTop = messages.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPresence() {
|
||||||
|
const list = document.getElementById("presence-list");
|
||||||
|
if (!list) return;
|
||||||
|
const sorted = [...onlineUsers].sort();
|
||||||
|
list.innerHTML = sorted
|
||||||
|
.map(
|
||||||
|
(u) =>
|
||||||
|
`<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSchedule() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/schedule");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const entries = data.entries as Array<{ title: string; status: string }>;
|
||||||
|
if (entries.length > 0) {
|
||||||
|
const np = document.getElementById("np-title");
|
||||||
|
const nn = document.getElementById("np-next");
|
||||||
|
if (np) np.textContent = `Now: ${entries[0].title}`;
|
||||||
|
if (nn && entries.length > 1) {
|
||||||
|
nn.textContent = `Up next: ${entries[1].title}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Schedule unavailable — non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function initPlayerAndChat() {
|
async function initPlayerAndChat() {
|
||||||
const video = document.getElementById("player") as HTMLVideoElement;
|
const video = document.getElementById("player") as HTMLVideoElement;
|
||||||
const manifestUrl = resolveManifestUrl();
|
const manifestUrl = resolveManifestUrl();
|
||||||
@@ -136,6 +181,10 @@ async function initPlayerAndChat() {
|
|||||||
|
|
||||||
connectWebSocket("main");
|
connectWebSocket("main");
|
||||||
|
|
||||||
|
// Start schedule polling
|
||||||
|
refreshSchedule();
|
||||||
|
setInterval(refreshSchedule, 30000);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -191,6 +191,74 @@ select {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sortable-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
cursor: grab;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.sortable-item:hover {
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
.sortable-item.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-index {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
min-width: 1.5rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-path {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ink-0);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warnings {
|
||||||
|
background: rgba(212, 150, 36, 0.1);
|
||||||
|
border: 1px solid var(--warn);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-msg {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--warn);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-1);
|
border-radius: var(--radius-1);
|
||||||
@@ -454,6 +522,21 @@ select {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.now-playing-overlay {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ink-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.np-next {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.now-playing-overlay {
|
.now-playing-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: calc(var(--space-5) + 0.7rem);
|
right: calc(var(--space-5) + 0.7rem);
|
||||||
@@ -524,6 +607,35 @@ select {
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.presence-panel {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-dot {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-indicator {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ok);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.message-row {
|
.message-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,18 +160,36 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location = /hls/playlist.m3u8 {
|
location = /hls/playlist.m3u8 {
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||||
|
add_header Access-Control-Max-Age 86400;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
alias /srv/hls/playlist.m3u8;
|
alias /srv/hls/playlist.m3u8;
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
|
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
|
||||||
add_header Access-Control-Allow-Origin "*";
|
add_header Access-Control-Allow-Origin "*";
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /hls/init.mp4 {
|
location = /hls/init.mp4 {
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||||
|
add_header Access-Control-Max-Age 86400;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
alias /srv/hls/init.mp4;
|
alias /srv/hls/init.mp4;
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
add_header Access-Control-Allow-Origin "*";
|
add_header Access-Control-Allow-Origin "*";
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* ^/hls/(.+\.(ts|m4s))$ {
|
location ~* ^/hls/(.+\.(ts|m4s))$ {
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||||
|
add_header Access-Control-Max-Age 86400;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
alias /srv/hls/$1;
|
alias /srv/hls/$1;
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
add_header Access-Control-Allow-Origin "*";
|
add_header Access-Control-Allow-Origin "*";
|
||||||
@@ -179,15 +197,29 @@ http {
|
|||||||
|
|
||||||
# Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN.
|
# Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN.
|
||||||
location /hls/ {
|
location /hls/ {
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||||
|
add_header Access-Control-Max-Age 86400;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
alias /srv/hls/;
|
alias /srv/hls/;
|
||||||
add_header Cache-Control "public, max-age=60";
|
add_header Cache-Control "public, max-age=60";
|
||||||
add_header Access-Control-Allow-Origin "*";
|
add_header Access-Control-Allow-Origin "*";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Health checks
|
# Health checks — aggregated from upstream services
|
||||||
location /health {
|
location /health {
|
||||||
return 200 "healthy";
|
default_type application/json;
|
||||||
add_header Content-Type text/plain;
|
return 200 '{"status":"ok","service":"nginx","timestamp":"$time_iso8601"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
location /nginx-status {
|
||||||
|
stub_status on;
|
||||||
|
allow 127.0.0.1;
|
||||||
|
allow 172.16.0.0/12;
|
||||||
|
allow 192.168.0.0/16;
|
||||||
|
deny all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: "default"
|
||||||
|
orgId: 1
|
||||||
|
folder: ""
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 10
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/provisioning/dashboards
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: true
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
groups:
|
||||||
|
- name: linearvoid
|
||||||
|
rules:
|
||||||
|
- alert: ChatDown
|
||||||
|
expr: up{job="chat"} == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Chat service is down"
|
||||||
|
description: "Chat service has been unreachable for 2 minutes."
|
||||||
|
|
||||||
|
- alert: PlayoutDown
|
||||||
|
expr: up{job="playout"} == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Playout service is down"
|
||||||
|
description: "Playout service has been unreachable for 2 minutes."
|
||||||
|
|
||||||
|
- alert: RedisDown
|
||||||
|
expr: linearvoid_chat_redis_up == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Redis is unreachable"
|
||||||
|
description: "Chat service cannot reach Redis. Rate limiting and presence tracking degraded."
|
||||||
|
|
||||||
|
- alert: PlayoutSlateActive
|
||||||
|
expr: linearvoid_playout_mode == 1
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Playout in slate fallback mode"
|
||||||
|
description: "Playout has switched to emergency slate. Check playout logs."
|
||||||
|
|
||||||
|
- alert: HighChatLatency
|
||||||
|
expr: rate(linearvoid_chat_messages_total[5m]) < 0.01
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Unusually low chat message rate"
|
||||||
|
description: "Message rate below 0.01/s for 5 minutes. Check chat service health."
|
||||||
|
|
||||||
|
- alert: PrometheusWALFailing
|
||||||
|
expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Prometheus WAL writes failing"
|
||||||
|
description: "Prometheus TSDB WAL write failures detected."
|
||||||
|
|
||||||
|
- alert: HighCPU
|
||||||
|
expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "High CPU usage"
|
||||||
|
description: "CPU usage above 80% for 10 minutes on {{ $labels.instance }}."
|
||||||
|
|
||||||
|
- alert: LowDiskSpace
|
||||||
|
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Low disk space"
|
||||||
|
description: "Less than 10% disk space available on {{ $labels.instance }}."
|
||||||
@@ -1,10 +1,28 @@
|
|||||||
global:
|
global:
|
||||||
scrape_interval: 15s
|
scrape_interval: 15s
|
||||||
|
|
||||||
|
rule_files:
|
||||||
|
- "/etc/prometheus/alerts.yml"
|
||||||
|
|
||||||
scrape_configs:
|
scrape_configs:
|
||||||
- job_name: "chat"
|
- job_name: "chat"
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ["chat:3000"]
|
- targets: ["chat:3000"]
|
||||||
|
- job_name: "playout"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["playout:8081"]
|
||||||
|
- job_name: "nginx"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["nginx-exporter:9113"]
|
||||||
|
- job_name: "redis"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["redis-exporter:9121"]
|
||||||
|
- job_name: "postgres"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["postgres-exporter:9187"]
|
||||||
|
- job_name: "node"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["node-exporter:9100"]
|
||||||
- job_name: "prometheus"
|
- job_name: "prometheus"
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ["prometheus:9090"]
|
- targets: ["prometheus:9090"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Daily PostgreSQL backup to R2 (14-day retention)
|
||||||
|
# Schedule: cron daily. Requires: pg_dump, aws CLI or rclone for R2.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/tmp/linearvoid-backups}"
|
||||||
|
RETENTION_DAYS="${RETENTION_DAYS:-14}"
|
||||||
|
R2_BUCKET="${R2_BUCKET:-linearvoid-backups}"
|
||||||
|
DB_URL="${DATABASE_URL:-postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable}"
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +%Y%m%d)
|
||||||
|
BACKUP_FILE="$BACKUP_DIR/daily/db_${TIMESTAMP}.dump"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$BACKUP_FILE")"
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Starting daily backup..."
|
||||||
|
|
||||||
|
# Extract connection details from DATABASE_URL
|
||||||
|
DB_HOST=$(echo "$DB_URL" | sed -n 's/.*@\([^:]*\).*/\1/p')
|
||||||
|
DB_PORT=$(echo "$DB_URL" | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
|
||||||
|
DB_USER=$(echo "$DB_URL" | sed -n 's/.*:\/\/\([^:]*\).*/\1/p')
|
||||||
|
DB_NAME=$(echo "$DB_URL" | sed -n 's/.*\/\([^?]*\).*/\1/p')
|
||||||
|
DB_PASS=$(echo "$DB_URL" | sed -n 's/.*:\/\/[^:]*:\([^@]*\).*/\1/p')
|
||||||
|
|
||||||
|
export PGPASSWORD="${DB_PASS}"
|
||||||
|
|
||||||
|
# Dump
|
||||||
|
pg_dump -h "${DB_HOST:-db}" -p "${DB_PORT:-5432}" -U "${DB_USER:-linearvoid}" \
|
||||||
|
-Fc -f "$BACKUP_FILE" "${DB_NAME:-linearvoid}"
|
||||||
|
|
||||||
|
echo " Backup: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
|
||||||
|
|
||||||
|
# Upload to R2
|
||||||
|
if [ -n "$R2_ENDPOINT" ] && [ -n "$R2_ACCESS_KEY_ID" ]; then
|
||||||
|
aws s3 cp "$BACKUP_FILE" "s3://${R2_BUCKET}/daily/db_${TIMESTAMP}.dump" \
|
||||||
|
--endpoint-url "$R2_ENDPOINT" --no-verify-ssl 2>/dev/null && \
|
||||||
|
echo " Uploaded to R2: daily/db_${TIMESTAMP}.dump" || \
|
||||||
|
echo " WARNING: R2 upload failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup old backups
|
||||||
|
find "$BACKUP_DIR/daily" -name "db_*.dump" -mtime "+${RETENTION_DAYS}" -delete 2>/dev/null || true
|
||||||
|
echo " Cleaned up backups older than ${RETENTION_DAYS} days"
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Daily backup complete."
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Weekly full PostgreSQL backup to R2 (8-week retention)
|
||||||
|
# Schedule: cron weekly (runs backup-db.sh with weekly prefix)
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/tmp/linearvoid-backups}"
|
||||||
|
RETENTION_WEEKS="${RETENTION_WEEKS:-8}"
|
||||||
|
R2_BUCKET="${R2_BUCKET:-linearvoid-backups}"
|
||||||
|
DB_URL="${DATABASE_URL:-postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable}"
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +%Y%m%d)
|
||||||
|
BACKUP_FILE="$BACKUP_DIR/weekly/db_${TIMESTAMP}.dump"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$BACKUP_FILE")"
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Starting weekly full backup..."
|
||||||
|
|
||||||
|
DB_HOST=$(echo "$DB_URL" | sed -n 's/.*@\([^:]*\).*/\1/p')
|
||||||
|
DB_PORT=$(echo "$DB_URL" | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
|
||||||
|
DB_USER=$(echo "$DB_URL" | sed -n 's/.*:\/\/\([^:]*\).*/\1/p')
|
||||||
|
DB_NAME=$(echo "$DB_URL" | sed -n 's/.*\/\([^?]*\).*/\1/p')
|
||||||
|
DB_PASS=$(echo "$DB_URL" | sed -n 's/.*:\/\/[^:]*:\([^@]*\).*/\1/p')
|
||||||
|
|
||||||
|
export PGPASSWORD="${DB_PASS}"
|
||||||
|
|
||||||
|
pg_dump -h "${DB_HOST:-db}" -p "${DB_PORT:-5432}" -U "${DB_USER:-linearvoid}" \
|
||||||
|
-Fc -f "$BACKUP_FILE" "${DB_NAME:-linearvoid}"
|
||||||
|
|
||||||
|
echo " Backup: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
|
||||||
|
|
||||||
|
# Verify integrity
|
||||||
|
pg_restore --list "$BACKUP_FILE" > /dev/null 2>&1 && \
|
||||||
|
echo " Integrity check: PASSED" || \
|
||||||
|
echo " WARNING: Integrity check FAILED"
|
||||||
|
|
||||||
|
# Upload
|
||||||
|
if [ -n "$R2_ENDPOINT" ] && [ -n "$R2_ACCESS_KEY_ID" ]; then
|
||||||
|
aws s3 cp "$BACKUP_FILE" "s3://${R2_BUCKET}/weekly/db_${TIMESTAMP}.dump" \
|
||||||
|
--endpoint-url "$R2_ENDPOINT" --no-verify-ssl 2>/dev/null && \
|
||||||
|
echo " Uploaded to R2: weekly/db_${TIMESTAMP}.dump" || \
|
||||||
|
echo " WARNING: R2 upload failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
find "$BACKUP_DIR/weekly" -name "db_*.dump" -mtime "+$((RETENTION_WEEKS * 7))" -delete 2>/dev/null || true
|
||||||
|
echo " Cleaned up backups older than ${RETENTION_WEEKS} weeks"
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Weekly backup complete."
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Generate emergency slate video + pre-rendered HLS segments for failover.
|
||||||
|
# Output: data/slate/ (slate MP4 + HLS segments + manifest)
|
||||||
|
# Requires: ffmpeg
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SLATE_DIR="${1:-data/slate}"
|
||||||
|
mkdir -p "$SLATE_DIR"
|
||||||
|
|
||||||
|
SLATE_MP4="$SLATE_DIR/slate.mp4"
|
||||||
|
SLATE_HLS="$SLATE_DIR/hls"
|
||||||
|
mkdir -p "$SLATE_HLS"
|
||||||
|
|
||||||
|
echo "=== Generating Emergency Slate ==="
|
||||||
|
|
||||||
|
# 1. Generate 10s slate video: color bars + "We'll Be Right Back" text
|
||||||
|
echo "[1/3] Generating slate video (10s, H.264/AAC, 30fps, 2s keyframes)..."
|
||||||
|
ffmpeg -y -f lavfi -i "color=c=#1a1a2e:s=1280x720:d=10:r=30" \
|
||||||
|
-f lavfi -i "sine=frequency=440:duration=10" \
|
||||||
|
-filter_complex "[0]drawtext=text='Linear Void':fontcolor=#587792:fontsize=60:x=(w-text_w)/2:y=(h-text_h)/2-40,drawtext=text='We'\''ll Be Right Back':fontcolor=#ede8e6:fontsize=36:x=(w-text_w)/2:y=(h-text_h)/2+30[bg];[1]volume=0.15[a]" \
|
||||||
|
-map "[bg]" -map "[a]" \
|
||||||
|
-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 128k -ar 48000 -ac 2 \
|
||||||
|
-movflags +faststart \
|
||||||
|
-t 10 \
|
||||||
|
"$SLATE_MP4"
|
||||||
|
|
||||||
|
echo " Slate MP4: $(du -h "$SLATE_MP4" | cut -f1)"
|
||||||
|
|
||||||
|
# 2. Verify slate meets normalization profile
|
||||||
|
echo "[2/3] Verifying slate normalization..."
|
||||||
|
ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate,pix_fmt,sample_rate \
|
||||||
|
-of default=noprint_wrappers=1 "$SLATE_MP4" 2>&1 | head -10
|
||||||
|
|
||||||
|
# 3. Package as pre-rendered HLS (fMP4, 6s segments)
|
||||||
|
echo "[3/3] Packaging as HLS..."
|
||||||
|
ffmpeg -y -i "$SLATE_MP4" \
|
||||||
|
-c copy \
|
||||||
|
-f hls \
|
||||||
|
-hls_time 6 \
|
||||||
|
-hls_list_size 5 \
|
||||||
|
-hls_segment_type fmp4 \
|
||||||
|
-hls_fmp4_init_filename "init.mp4" \
|
||||||
|
-hls_flags "independent_segments" \
|
||||||
|
-hls_segment_filename "$SLATE_HLS/slate_segment_%03d.m4s" \
|
||||||
|
"$SLATE_HLS/slate.m3u8"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Slate Generated ==="
|
||||||
|
echo " MP4: $SLATE_MP4"
|
||||||
|
echo " Manifest: $SLATE_HLS/slate.m3u8"
|
||||||
|
echo " Segments: $(find "$SLATE_HLS" -name '*.m4s' | wc -l) files"
|
||||||
|
echo " Init: $SLATE_HLS/init.mp4"
|
||||||
|
echo ""
|
||||||
|
echo "To use: mount $SLATE_HLS as /app/slate in playout container"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Normalize video to Linear Void required profile.
|
||||||
|
# Output: H.264/AAC, 30fps constant, 2s keyframes, yuv420p, 48kHz audio.
|
||||||
|
# Usage: ./normalize.sh <input> [output]
|
||||||
|
set -e
|
||||||
|
|
||||||
|
INPUT="$1"
|
||||||
|
if [ -z "$INPUT" ]; then
|
||||||
|
echo "Usage: $0 <input-file> [output-file]"
|
||||||
|
echo " Normalizes video to Linear Void profile: H.264/AAC, 30fps, 2s keyframes"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$INPUT" ]; then
|
||||||
|
echo "Error: input file not found: $INPUT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUTPUT="${2:-${INPUT%.*}-normalized.mp4}"
|
||||||
|
CRF="${NORMALIZE_CRF:-21}"
|
||||||
|
PRESET="${NORMALIZE_PRESET:-medium}"
|
||||||
|
AUDIO_BITRATE="${NORMALIZE_AUDIO_BITRATE:-160k}"
|
||||||
|
|
||||||
|
echo "=== Linear Void Video Normalization ==="
|
||||||
|
echo " Input: $INPUT ($(du -h "$INPUT" | cut -f1))"
|
||||||
|
echo " Output: $OUTPUT"
|
||||||
|
echo " CRF: $CRF"
|
||||||
|
echo " Preset: $PRESET"
|
||||||
|
echo " Audio rate: $AUDIO_BITRATE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Probe input
|
||||||
|
echo "--- Input Properties ---"
|
||||||
|
ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate,pix_fmt,sample_rate,channels \
|
||||||
|
-of default=noprint_wrappers=1 "$INPUT" 2>&1
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Normalize
|
||||||
|
echo "--- Normalizing ---"
|
||||||
|
ffmpeg -y -i "$INPUT" \
|
||||||
|
-c:v libx264 -preset "$PRESET" -crf "$CRF" \
|
||||||
|
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
||||||
|
-pix_fmt yuv420p \
|
||||||
|
-c:a aac -b:a "$AUDIO_BITRATE" -ar 48000 -ac 2 \
|
||||||
|
-movflags +faststart \
|
||||||
|
"$OUTPUT"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- Output Properties ---"
|
||||||
|
ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate,pix_fmt,sample_rate,channels \
|
||||||
|
-of default=noprint_wrappers=1 "$OUTPUT" 2>&1
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Normalization Complete ==="
|
||||||
|
echo " Output: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))"
|
||||||
|
echo ""
|
||||||
|
echo "Verify with: ffprobe -v error -show_entries stream=codec_name,r_frame_rate,pix_fmt -of default=noprint_wrappers=1 $OUTPUT"
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# End-to-end playout test: single file → 3-file playlist → verify seamless transition
|
||||||
|
# Requires: Docker, ffprobe, curl. Run from repo root.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Playout End-to-End Test ==="
|
||||||
|
|
||||||
|
# 1. Build playout image
|
||||||
|
echo "[1/7] Building playout image..."
|
||||||
|
docker compose build playout
|
||||||
|
|
||||||
|
# 2. Create test playlist with 3 demo files (reuse demodata if exists)
|
||||||
|
TEST_PLAYLIST="data/playlist/playlist.txt"
|
||||||
|
mkdir -p data/playlist data/hls
|
||||||
|
|
||||||
|
echo "[2/7] Creating 3-file test playlist..."
|
||||||
|
cat > "$TEST_PLAYLIST" << 'EOF'
|
||||||
|
file '/app/media/demo1.mp4'
|
||||||
|
file '/app/media/demo2.mp4'
|
||||||
|
file '/app/media/demo1.mp4'
|
||||||
|
EOF
|
||||||
|
echo "Playlist created with 3 entries:"
|
||||||
|
cat "$TEST_PLAYLIST"
|
||||||
|
|
||||||
|
# 3. Validate playlist parses correctly
|
||||||
|
echo "[3/7] Validating playlist parse..."
|
||||||
|
LINE_COUNT=$(grep -c "^file " "$TEST_PLAYLIST" || true)
|
||||||
|
if [ "$LINE_COUNT" -ne 3 ]; then
|
||||||
|
echo "FAIL: Expected 3 entries, got $LINE_COUNT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: $LINE_COUNT entries parsed"
|
||||||
|
|
||||||
|
# 4. Start playout container
|
||||||
|
echo "[4/7] Starting playout container..."
|
||||||
|
docker compose up -d playout
|
||||||
|
sleep 8
|
||||||
|
|
||||||
|
# 5. Verify health endpoint
|
||||||
|
echo "[5/7] Checking playout health..."
|
||||||
|
HEALTH=$(curl -sf http://localhost:8081/health || echo "FAIL")
|
||||||
|
if echo "$HEALTH" | grep -q "healthy"; then
|
||||||
|
echo "OK: Health endpoint: $HEALTH"
|
||||||
|
else
|
||||||
|
echo "FAIL: Health check returned: $HEALTH"
|
||||||
|
docker compose logs playout --tail 30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. Verify HLS output
|
||||||
|
echo "[6/7] Verifying HLS output..."
|
||||||
|
sleep 10 # Let FFmpeg produce some segments
|
||||||
|
|
||||||
|
if [ -f "data/hls/playlist.m3u8" ]; then
|
||||||
|
echo "OK: playlist.m3u8 exists"
|
||||||
|
MANIFEST_LINES=$(wc -l < data/hls/playlist.m3u8)
|
||||||
|
echo " Manifest lines: $MANIFEST_LINES"
|
||||||
|
else
|
||||||
|
echo "FAIL: No playlist.m3u8 generated"
|
||||||
|
docker compose logs playout --tail 30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SEGMENT_COUNT=$(find data/hls -name "segment_*.m4s" | wc -l)
|
||||||
|
if [ "$SEGMENT_COUNT" -ge 1 ]; then
|
||||||
|
echo "OK: $SEGMENT_COUNT segment(s) generated"
|
||||||
|
else
|
||||||
|
echo "FAIL: No segments generated"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. Check for seamless transition markers (no FFmpeg errors in logs)
|
||||||
|
echo "[7/7] Checking for seamless transitions..."
|
||||||
|
ERRORS=$(docker compose logs playout 2>&1 | grep -ci "error" || true)
|
||||||
|
if [ "$ERRORS" -eq 0 ]; then
|
||||||
|
echo "OK: No FFmpeg errors in logs"
|
||||||
|
else
|
||||||
|
echo "WARN: $ERRORS error(s) in playout logs (may be R2 connection — check manually)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Playout E2E Test PASSED ==="
|
||||||
|
echo "Verified: playlist parse → HLS generation → health endpoint → segment output"
|
||||||
|
echo ""
|
||||||
|
echo "Cleanup: docker compose down playout"
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Weekly backup integrity verification.
|
||||||
|
# Downloads latest backup from R2, restores into temp container, runs sanity checks.
|
||||||
|
# Schedule: cron weekly (after weekly backup).
|
||||||
|
set -e
|
||||||
|
|
||||||
|
R2_BUCKET="${R2_BUCKET:-linearvoid-backups}"
|
||||||
|
TEMP_DIR=$(mktemp -d)
|
||||||
|
trap "rm -rf $TEMP_DIR" EXIT
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Starting backup integrity verification..."
|
||||||
|
|
||||||
|
# Download latest weekly backup from R2
|
||||||
|
if [ -z "$R2_ENDPOINT" ] || [ -z "$R2_ACCESS_KEY_ID" ]; then
|
||||||
|
echo " SKIP: R2 credentials not configured (R2_ENDPOINT, R2_ACCESS_KEY_ID)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
LATEST=$(aws s3 ls "s3://${R2_BUCKET}/weekly/" --endpoint-url "$R2_ENDPOINT" --no-verify-ssl 2>/dev/null | sort | tail -1 | awk '{print $4}')
|
||||||
|
if [ -z "$LATEST" ]; then
|
||||||
|
echo " SKIP: No weekly backups found in R2"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Downloading: $LATEST"
|
||||||
|
aws s3 cp "s3://${R2_BUCKET}/weekly/${LATEST}" "$TEMP_DIR/backup.dump" \
|
||||||
|
--endpoint-url "$R2_ENDPOINT" --no-verify-ssl 2>/dev/null
|
||||||
|
|
||||||
|
echo " File size: $(du -h "$TEMP_DIR/backup.dump" | cut -f1)"
|
||||||
|
|
||||||
|
# Restore into temp PostgreSQL container
|
||||||
|
echo " Starting temp PostgreSQL..."
|
||||||
|
docker run -d --name lv-backup-test-$$ \
|
||||||
|
-e POSTGRES_PASSWORD=test \
|
||||||
|
-e POSTGRES_DB=linearvoid \
|
||||||
|
-p 15432:5432 \
|
||||||
|
postgres:15-alpine 2>/dev/null
|
||||||
|
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Wait for PG to be ready
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
if docker exec lv-backup-test-$$ pg_isready -U postgres 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
echo " Restoring backup..."
|
||||||
|
docker cp "$TEMP_DIR/backup.dump" "lv-backup-test-$$:/tmp/backup.dump"
|
||||||
|
docker exec lv-backup-test-$$ pg_restore -U postgres -d linearvoid /tmp/backup.dump 2>/dev/null && \
|
||||||
|
echo " Restore: OK" || { echo " Restore: FAILED"; docker rm -f lv-backup-test-$$ 2>/dev/null; exit 1; }
|
||||||
|
|
||||||
|
# Sanity checks
|
||||||
|
MSG_COUNT=$(docker exec lv-backup-test-$$ psql -U postgres -d linearvoid -tAc "SELECT COUNT(*) FROM messages;" 2>/dev/null || echo "0")
|
||||||
|
MOD_COUNT=$(docker exec lv-backup-test-$$ psql -U postgres -d linearvoid -tAc "SELECT COUNT(*) FROM moderation_logs;" 2>/dev/null || echo "0")
|
||||||
|
echo " Messages: $MSG_COUNT, Moderation: $MOD_COUNT"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
docker rm -f lv-backup-test-$$ 2>/dev/null
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Backup verification complete. OK."
|
||||||
Reference in new issue
Block a user