Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8cf853842 | ||
|
|
8c5e70959b | ||
|
|
5f1f796a20 | ||
|
|
11d48c8e0d | ||
|
|
242fc8da67 | ||
|
|
33b6296c5e | ||
|
|
9f60087a4c | ||
|
|
474da7418e | ||
|
|
d267173e30 | ||
|
|
e7bff8b9c0 | ||
|
|
9a0511b36b | ||
|
|
c2f9f9ed62 | ||
|
|
bde3dc3a46 | ||
|
|
21a70dcf0f | ||
|
|
4fe09a0a3d | ||
|
|
85454610f4 | ||
|
|
5585a6ecc3 | ||
|
|
53a0c3c825 | ||
|
|
9e56ff7859 | ||
|
|
9ac1a8b79d | ||
|
|
6950bba85d | ||
|
|
06b277e9c1 | ||
|
|
1cc44282bc | ||
|
|
b6705e6dbd | ||
|
|
cef002862e | ||
|
|
4eb88a4594 | ||
|
|
9cf3956c28 | ||
|
|
a15a9f2df0 | ||
|
|
76397d52d7 | ||
|
|
70a5159f81 | ||
|
|
568112b85a | ||
|
|
02ff53229c | ||
|
|
68a4d35334 | ||
|
|
7afab6658b | ||
|
|
121fdbacdc | ||
|
|
06ab5b4655 | ||
|
|
e15d89edfe | ||
|
|
044851a43e | ||
|
|
926d70dc3c | ||
|
|
cca8d77325 | ||
|
|
528a7fffc0 | ||
|
|
3742859489 | ||
|
|
27c74f1663 | ||
|
|
2a718e4652 | ||
|
|
1019972e25 | ||
|
|
7be66a703c | ||
|
|
4d627e13b9 | ||
|
|
c0356a26f6 | ||
|
|
4aac5f9009 | ||
|
|
af2e94aa46 | ||
|
|
187d288e2e | ||
|
|
377dc69859 | ||
|
|
5eb5edcac4 | ||
|
|
55a128dc3a | ||
|
|
1f11baa949 | ||
|
|
89ddfb3528 | ||
|
|
99f75eb9ed | ||
|
|
d823763dc3 | ||
|
|
9178b7b295 | ||
|
|
18459a9811 | ||
|
|
f6ca89747e | ||
|
|
98fafcd93c | ||
|
|
8b8f2335e1 | ||
|
|
80f4d9f198 | ||
|
|
87e9eff1bd | ||
|
|
24273b5513 | ||
|
|
31882f6b73 | ||
|
|
f73928103a | ||
|
|
b427353672 | ||
|
|
f9aba11b90 | ||
|
|
926a663c23 | ||
|
|
6b9830de05 | ||
|
|
914a518f20 | ||
|
|
8064e459f2 | ||
|
|
587561f9fc | ||
|
|
887023a491 | ||
|
|
994c433fc8 | ||
|
|
6632df0bc7 | ||
|
|
e4ffa0b356 | ||
|
|
e8a43b8a79 | ||
|
|
b3a10ba3ef | ||
|
|
46e878bef6 | ||
|
|
39cca5d235 | ||
|
|
2ed89760d6 | ||
|
|
a8df164cc2 | ||
|
|
0a8221cf83 | ||
|
|
e7fc921d6a | ||
|
|
04bb783a12 | ||
|
|
68bd241f2c | ||
|
|
4c02ab9d55 | ||
|
|
74bf7e19db | ||
|
|
92e074b6d0 | ||
|
|
a70bad1528 | ||
|
|
b47f2621e1 | ||
|
|
7244c1d658 | ||
|
|
d991d6b6c7 | ||
|
|
db3bf96847 | ||
|
|
0f81a81029 | ||
|
|
b5710548b1 | ||
|
|
d2e5b64ece | ||
|
|
5b5cdc4cd8 | ||
|
|
17ac04856b | ||
|
|
69df0e51b8 | ||
|
|
9a06dc389d | ||
|
|
51f3e98816 | ||
|
|
68ba3a25b9 | ||
|
|
7e9308c48a | ||
|
|
21a34a4bd5 | ||
|
|
ee16441b79 | ||
|
|
74133ec589 | ||
|
|
f650a3a94e | ||
|
|
c10d98f3b4 | ||
|
|
207401b5fb | ||
|
|
d4d7806189 | ||
|
|
497f7743f2 | ||
|
|
2c06265d11 | ||
|
|
930cdfdff9 | ||
|
|
ab3fab3e68 | ||
|
|
dd5789fcd9 | ||
|
|
fca4286d87 | ||
|
|
7cfddfc7ef | ||
|
|
47740e3f4a | ||
|
|
693b4a42f6 |
No files matched your search
@@ -0,0 +1,55 @@
|
|||||||
|
# EditorConfig is awesome: https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# General settings – apply to all files
|
||||||
|
# -------------------------------------------------
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Markdown (*.md)
|
||||||
|
# -------------------------------------------------
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off # Markdown lines can be long
|
||||||
|
trim_trailing_whitespace = false # Preserve intentional spaces
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Go (*.go)
|
||||||
|
# -------------------------------------------------
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
max_line_length = 100
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# JavaScript & TypeScript (*.js, *.jsx, *.ts, *.tsx)
|
||||||
|
# -------------------------------------------------
|
||||||
|
[*.{js,jsx,ts,tsx}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
max_line_length = 120
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# HTML (*.html, *.htm)
|
||||||
|
# -------------------------------------------------
|
||||||
|
[*.{html,htm}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
max_line_length = 120
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# CSS & SCSS (*.css, *.scss)
|
||||||
|
# -------------------------------------------------
|
||||||
|
[*.{css,scss}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
max_line_length = 120
|
||||||
|
trim_trailing_whitespace = true
|
||||||
+12
-4
@@ -8,6 +8,8 @@ R2_SECRET_ACCESS_KEY=your-r2-secret-key
|
|||||||
R2_BUCKET_NAME=linearvoid-media
|
R2_BUCKET_NAME=linearvoid-media
|
||||||
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
||||||
R2_REGION=auto
|
R2_REGION=auto
|
||||||
|
R2_KEEP_RUNS=2
|
||||||
|
R2_MAX_RETENTION_BYTES=10737418240
|
||||||
|
|
||||||
# Cloudflare (for custom domain, cache rules, Workers if used)
|
# Cloudflare (for custom domain, cache rules, Workers if used)
|
||||||
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
||||||
@@ -47,14 +49,20 @@ DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
|
|||||||
REDIS_URL=redis://redis:6379/0
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
# Service Ports (for local docker-compose)
|
# Service Ports (for local docker-compose)
|
||||||
PLAYOUT_PORT=8080
|
# Coolify env: 80/443 (Traefik proxy), 8000 (dashboard/Portainer), 8080 taken.
|
||||||
|
PLAYOUT_PORT=8081
|
||||||
CHAT_PORT=3000
|
CHAT_PORT=3000
|
||||||
NGINX_PORT=80
|
NGINX_PORT=8082
|
||||||
|
|
||||||
# Playout configuration
|
# Playout configuration
|
||||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||||
HLS_SEGMENT_DURATION=6
|
HLS_SEGMENT_DURATION=2
|
||||||
HLS_WINDOW_SIZE=24
|
HLS_WINDOW_SIZE=12
|
||||||
|
|
||||||
|
# Shared runtime config (written by chat admin via /api/admin/config, read by playout).
|
||||||
|
# Both resolve to the same host file through the ./data/playlist volume.
|
||||||
|
CONFIG_PATH=/app/playlist/config.json
|
||||||
|
PLAYOUT_CONFIG_PATH=/app/playlist/config.json
|
||||||
|
|
||||||
# Logging / Monitoring
|
# Logging / Monitoring
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
+225
-34
@@ -1,18 +1,20 @@
|
|||||||
|
# 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:
|
workflow_run:
|
||||||
workflows: ["CI"]
|
workflows: [CI]
|
||||||
types:
|
types: [completed]
|
||||||
- completed
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
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,74 +24,263 @@ 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: |
|
||||||
cat > .env << 'EOF'
|
cat > .env << 'EOF'
|
||||||
|
# Database & Redis
|
||||||
DATABASE_URL="${{ secrets.DATABASE_URL }}"
|
DATABASE_URL="${{ secrets.DATABASE_URL }}"
|
||||||
REDIS_URL=redis://redis:6379/0
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# Playout
|
||||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||||
HLS_SEGMENT_DURATION=6
|
HLS_SEGMENT_DURATION=2
|
||||||
HLS_WINDOW_SIZE=24
|
HLS_WINDOW_SIZE=12
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Cloudflare R2 (required for HLS segment sync)
|
||||||
|
R2_ACCESS_KEY_ID="${{ secrets.R2_ACCESS_KEY_ID }}"
|
||||||
|
R2_SECRET_ACCESS_KEY="${{ secrets.R2_SECRET_ACCESS_KEY }}"
|
||||||
|
R2_BUCKET_NAME="${{ secrets.R2_BUCKET_NAME }}"
|
||||||
|
R2_ENDPOINT="${{ secrets.R2_ENDPOINT }}"
|
||||||
|
R2_REGION=auto
|
||||||
|
|
||||||
|
# Cloudflare
|
||||||
|
CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}"
|
||||||
|
CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"
|
||||||
|
MEDIA_DOMAIN=${{ secrets.MEDIA_DOMAIN }}
|
||||||
|
|
||||||
|
# Frontend public URLs
|
||||||
|
VITE_PUBLIC_APP_URL=${{ secrets.VITE_PUBLIC_APP_URL }}
|
||||||
|
VITE_PUBLIC_WS_URL=${{ secrets.VITE_PUBLIC_WS_URL }}
|
||||||
|
VITE_PUBLIC_HLS_URL=${{ secrets.VITE_PUBLIC_HLS_URL }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_ROOM=${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }}
|
||||||
|
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50
|
||||||
|
|
||||||
|
# Admin auth
|
||||||
ADMIN_USERNAME="${{ secrets.ADMIN_USERNAME }}"
|
ADMIN_USERNAME="${{ secrets.ADMIN_USERNAME }}"
|
||||||
ADMIN_PASSWORD="${{ secrets.ADMIN_PASSWORD }}"
|
ADMIN_PASSWORD="${{ secrets.ADMIN_PASSWORD }}"
|
||||||
ADMIN_SESSION_TTL_HOURS=12
|
ADMIN_SESSION_TTL_HOURS=12
|
||||||
NGINX_PORT=80
|
|
||||||
|
# Nginx
|
||||||
|
# Host port 80/443 (Coolify Traefik) and 8080 are taken; nginx publishes on 8082.
|
||||||
|
NGINX_PORT=8082
|
||||||
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 linearvoid_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: HighChatLatency
|
||||||
|
expr: rate(linearvoid_chat_messages_total[5m]) < 0.01
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Unusually low chat message rate"
|
||||||
|
- alert: PrometheusWALFailing
|
||||||
|
expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Prometheus WAL writes failing"
|
||||||
|
- 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: Seed Grafana provisioning on remote host
|
||||||
|
env:
|
||||||
|
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v linearvoid_grafana-provisioning:/etc/grafana/provisioning \
|
||||||
|
alpine:latest sh -c '
|
||||||
|
mkdir -p /etc/grafana/provisioning/datasources /etc/grafana/provisioning/dashboards
|
||||||
|
cat > /etc/grafana/provisioning/datasources/datasource.yml << "DSEOF"
|
||||||
|
apiVersion: 1
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: true
|
||||||
|
DSEOF
|
||||||
|
cat > /etc/grafana/provisioning/dashboards/dashboard.yml << "PROVEOF"
|
||||||
|
apiVersion: 1
|
||||||
|
providers:
|
||||||
|
- name: "default"
|
||||||
|
orgId: 1
|
||||||
|
folder: ""
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 10
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/provisioning/dashboards
|
||||||
|
PROVEOF
|
||||||
|
cat > /etc/grafana/provisioning/dashboards/linearvoid-app.json << "DBEOF"
|
||||||
|
{
|
||||||
|
"title": "Linear Void Monitoring",
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"title": "Concurrent Viewers",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [{ "expr": "linearvoid_chat_connected_clients" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Stored Messages",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [{ "expr": "linearvoid_chat_messages_total" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Redis Reachable",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [{ "expr": "linearvoid_chat_redis_up" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Playout Mode",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [{ "expr": "linearvoid_playout_mode" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
DBEOF
|
||||||
|
'
|
||||||
|
|
||||||
- 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:
|
||||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||||
run: |
|
run: |
|
||||||
# Wait for playout to stabilize (it may restart during init)
|
# Wait for services to stabilize
|
||||||
sleep 20
|
sleep 30
|
||||||
curl -f http://192.168.88.40:3000/health || exit 1
|
|
||||||
|
# Check chat health (via docker exec — IP-agnostic, works on any DOCKER_HOST)
|
||||||
|
echo "Checking chat health..."
|
||||||
|
if ! docker exec linearvoid-chat curl -sf http://localhost:3000/health; then
|
||||||
|
echo "Chat health check failed"
|
||||||
|
docker logs linearvoid-chat --tail 50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check nginx health
|
||||||
|
echo "Checking nginx health..."
|
||||||
|
if ! docker exec linearvoid-nginx curl -sf http://localhost/health; then
|
||||||
|
echo "Nginx health check failed"
|
||||||
|
docker logs linearvoid-nginx --tail 50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Retry playout health check since it may still be initializing
|
# Retry playout health check since it may still be initializing
|
||||||
|
echo "Checking playout health..."
|
||||||
for i in $(seq 1 6); do
|
for i in $(seq 1 6); do
|
||||||
if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
||||||
echo "Playout is healthy"
|
echo "Playout is healthy"
|
||||||
exit 0
|
break
|
||||||
fi
|
fi
|
||||||
echo "Playout not ready yet (attempt $i/6), waiting 5s..."
|
echo "Playout not ready yet (attempt $i/6), waiting 20s..."
|
||||||
sleep 5
|
sleep 20
|
||||||
done
|
done
|
||||||
|
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
||||||
echo "Playout failed to become healthy after 6 attempts"
|
echo "Playout failed to become healthy after 6 attempts"
|
||||||
docker logs linearvoid-playout --tail 50
|
docker logs linearvoid-playout --tail 50
|
||||||
exit 1
|
exit 1
|
||||||
curl -f http://192.168.88.40:3000/health || exit 1
|
fi
|
||||||
docker exec linearvoid-playout curl -sf http://localhost:8081/health || exit 1
|
|
||||||
|
echo "All services healthy"
|
||||||
|
|
||||||
- name: Cleanup old images
|
- name: Cleanup old images
|
||||||
env:
|
env:
|
||||||
|
|||||||
+35
-44
@@ -18,15 +18,16 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
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: Test Frontend with Coverage
|
||||||
|
run: cd frontend && npm run test:coverage
|
||||||
|
|
||||||
- name: Build Frontend
|
- name: Build Frontend
|
||||||
run: cd frontend && npm run build
|
run: cd frontend && npm run build
|
||||||
@@ -34,17 +35,28 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.25"
|
||||||
cache: true
|
env:
|
||||||
|
GOTOOLCHAIN: local
|
||||||
|
|
||||||
- name: Validate Playlist Normalization
|
- name: Download Go modules
|
||||||
run: cd backend/playout && go run src/playlist.go playlist.txt || echo "Validation script not yet complete"
|
run: cd backend/chat && GOTOOLCHAIN=local go mod download
|
||||||
|
|
||||||
- name: Build Docker Images
|
- name: Validate Go
|
||||||
run: docker compose build --no-cache
|
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: cd backend/chat && GOTOOLCHAIN=local go test ./... -v -cover -coverprofile=coverage.out -race
|
||||||
|
|
||||||
|
- name: Check Coverage
|
||||||
|
run: |
|
||||||
|
cd backend/chat
|
||||||
|
COVERAGE=$(GOTOOLCHAIN=local go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
|
||||||
|
echo "Coverage: $COVERAGE%"
|
||||||
|
if [ "$(echo "$COVERAGE < 50" | bc)" -eq 1 ]; then
|
||||||
|
echo "Coverage $COVERAGE% below 50% threshold"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
needs: test
|
needs: test
|
||||||
@@ -86,6 +98,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-playout.outputs.tags }}
|
tags: ${{ steps.meta-playout.outputs.tags }}
|
||||||
labels: ${{ steps.meta-playout.outputs.labels }}
|
labels: ${{ steps.meta-playout.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout
|
||||||
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout,mode=max
|
||||||
|
|
||||||
- name: Extract metadata for Chat
|
- name: Extract metadata for Chat
|
||||||
id: meta-chat
|
id: meta-chat
|
||||||
@@ -106,6 +120,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-chat.outputs.tags }}
|
tags: ${{ steps.meta-chat.outputs.tags }}
|
||||||
labels: ${{ steps.meta-chat.outputs.labels }}
|
labels: ${{ steps.meta-chat.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat
|
||||||
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat,mode=max
|
||||||
|
|
||||||
- name: Extract metadata for Nginx
|
- name: Extract metadata for Nginx
|
||||||
id: meta-nginx
|
id: meta-nginx
|
||||||
@@ -120,37 +136,12 @@ jobs:
|
|||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Build and push Nginx image
|
- name: Build and push Nginx image
|
||||||
run: |
|
uses: docker/build-push-action@v5
|
||||||
TAGS="${{ steps.meta-nginx.outputs.tags }}"
|
|
||||||
LABELS="${{ steps.meta-nginx.outputs.labels }}"
|
|
||||||
TAG_ARGS=""
|
|
||||||
for tag in $TAGS; do
|
|
||||||
TAG_ARGS="$TAG_ARGS --tag $tag"
|
|
||||||
done
|
|
||||||
LABEL_ARGS=""
|
|
||||||
while IFS= read -r label; do
|
|
||||||
[ -n "$label" ] && LABEL_ARGS="$LABEL_ARGS --label $label"
|
|
||||||
done <<< "$LABELS"
|
|
||||||
docker buildx build \
|
|
||||||
--push \
|
|
||||||
--file infrastructure/nginx/Dockerfile \
|
|
||||||
$TAG_ARGS \
|
|
||||||
$LABEL_ARGS \
|
|
||||||
.
|
|
||||||
|
|
||||||
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:
|
with:
|
||||||
fetch-depth: 0
|
context: .
|
||||||
|
file: infrastructure/nginx/Dockerfile
|
||||||
- name: Create Release
|
push: true
|
||||||
uses: softprops/action-gh-release@v1
|
tags: ${{ steps.meta-nginx.outputs.tags }}
|
||||||
with:
|
labels: ${{ steps.meta-nginx.outputs.labels }}
|
||||||
generate_release_notes: true
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx
|
||||||
env:
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max
|
||||||
GITHUB_TOKEN: ${{ secrets.CI_REGISTRY_TOKEN }}
|
|
||||||
@@ -13,7 +13,6 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.CI_REGISTRY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -31,28 +30,48 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.25"
|
||||||
cache: true
|
cache: true
|
||||||
|
env:
|
||||||
|
GOTOOLCHAIN: local
|
||||||
|
|
||||||
- name: Update Go Dependencies
|
- name: Update Go Dependencies
|
||||||
run: |
|
run: |
|
||||||
cd backend/playout && go get -u ./... && go mod tidy
|
cd backend/playout && GOTOOLCHAIN=local go get -u ./... && go mod tidy
|
||||||
cd ../chat && go get -u ./... && go mod tidy
|
cd ../chat && GOTOOLCHAIN=local go get -u ./... && go mod tidy
|
||||||
|
|
||||||
- name: Create Pull Request
|
- name: Commit and push changes
|
||||||
uses: peter-evans/create-pull-request@v5
|
run: |
|
||||||
with:
|
git config user.name "linearvoid-bot"
|
||||||
token: ${{ secrets.CI_REGISTRY_TOKEN }}
|
git config user.email "bot@linearvoid.local"
|
||||||
commit-message: "chore: update dependencies"
|
git add frontend/package*.json backend/playout/go.* backend/chat/go.*
|
||||||
title: "chore: weekly dependency updates"
|
if git diff --cached --quiet; then
|
||||||
body: |
|
echo "No dependency changes detected."
|
||||||
Automated weekly dependency updates.
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "chore: update dependencies"
|
||||||
|
git push origin HEAD:dependency-updates --force
|
||||||
|
|
||||||
This PR updates:
|
- name: Create Pull Request via Gitea API
|
||||||
- Frontend npm dependencies
|
run: |
|
||||||
- Go module dependencies
|
RESPONSE=$(curl -s -o /tmp/pr-response.json -w "%{http_code}" \
|
||||||
|
-X POST "$CI_SERVER_URL/api/v1/repos/zwitschi/linearvoid/pulls" \
|
||||||
Please review the changes and ensure tests pass before merging.
|
-H "Authorization: token ${{ secrets.CI_REGISTRY_TOKEN }}" \
|
||||||
branch: dependency-updates
|
-H "Content-Type: application/json" \
|
||||||
delete-branch: true
|
-d '{
|
||||||
labels: dependencies, automated
|
"title": "chore: weekly dependency updates",
|
||||||
|
"head": "dependency-updates",
|
||||||
|
"base": "main",
|
||||||
|
"body": "Automated weekly dependency updates.\n\nThis PR updates:\n- Frontend npm dependencies\n- Go module dependencies\n\nPlease review the changes and ensure tests pass before merging."
|
||||||
|
}')
|
||||||
|
if [ "$RESPONSE" -eq 201 ]; then
|
||||||
|
PR_URL=$(cat /tmp/pr-response.json | jq -r '.html_url')
|
||||||
|
echo "PR created: $PR_URL"
|
||||||
|
elif [ "$RESPONSE" -eq 409 ]; then
|
||||||
|
echo "PR already exists. Pushing updated commits..."
|
||||||
|
git push origin dependency-updates --force
|
||||||
|
else
|
||||||
|
echo "Error creating PR (HTTP $RESPONSE):"
|
||||||
|
cat /tmp/pr-response.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -3,11 +3,11 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "*.*.*" # Trigger on any semantic version tag (e.g., 1.0.0, 2.1.3)
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to release (e.g., v1.0.0)"
|
description: "Version to release (e.g., 1.0.0)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
@@ -34,10 +34,25 @@ jobs:
|
|||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
VERSION="${{ github.event.inputs.version }}"
|
||||||
else
|
else
|
||||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
fi
|
fi
|
||||||
|
if [[ -z "$VERSION" ]]; then
|
||||||
|
echo "::error::VERSION is empty. Provide a version via workflow_dispatch input or push a tag."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Releasing version: $VERSION"
|
||||||
|
|
||||||
|
- name: Print version to gitea runner log
|
||||||
|
run: |
|
||||||
|
echo "Releasing version: ${{ steps.version.outputs.version }}"
|
||||||
|
echo "===DEBUG==="
|
||||||
|
echo "VERSION: $VERSION"
|
||||||
|
echo "GITHUB_REF: $GITHUB_REF"
|
||||||
|
echo "steps.version.outputs.version: ${{ steps.version.outputs.version }}"
|
||||||
|
echo "github.event_name: ${{ github.event_name }}"
|
||||||
|
|
||||||
- name: Extract metadata for Playout
|
- name: Extract metadata for Playout
|
||||||
id: meta-playout
|
id: meta-playout
|
||||||
@@ -56,6 +71,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-playout.outputs.tags }}
|
tags: ${{ steps.meta-playout.outputs.tags }}
|
||||||
labels: ${{ steps.meta-playout.outputs.labels }}
|
labels: ${{ steps.meta-playout.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout
|
||||||
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout,mode=max
|
||||||
|
|
||||||
- name: Extract metadata for Chat
|
- name: Extract metadata for Chat
|
||||||
id: meta-chat
|
id: meta-chat
|
||||||
@@ -74,6 +91,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-chat.outputs.tags }}
|
tags: ${{ steps.meta-chat.outputs.tags }}
|
||||||
labels: ${{ steps.meta-chat.outputs.labels }}
|
labels: ${{ steps.meta-chat.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat
|
||||||
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat,mode=max
|
||||||
|
|
||||||
- name: Extract metadata for Nginx
|
- name: Extract metadata for Nginx
|
||||||
id: meta-nginx
|
id: meta-nginx
|
||||||
@@ -93,10 +112,12 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-nginx.outputs.tags }}
|
tags: ${{ steps.meta-nginx.outputs.tags }}
|
||||||
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-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max
|
||||||
|
|
||||||
- name: Create Release on Gitea
|
- name: Create Release on Gitea
|
||||||
run: |
|
run: |
|
||||||
curl -X POST "$CI_SERVER_URL/api/v1/repos/$CI_REPO_OWNER/$CI_REPO_NAME/releases" \
|
curl -X POST "https://git.allucanget.biz/api/v1/repos/zwitschi/linearvoid/releases" \
|
||||||
-H "Authorization: token ${{ secrets.CI_REGISTRY_TOKEN }}" \
|
-H "Authorization: token ${{ secrets.CI_REGISTRY_TOKEN }}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\":\"${{ steps.version.outputs.version }}\",\"name\":\"${{ steps.version.outputs.version }}\",\"body\":\"Release ${{ steps.version.outputs.version }}\n\nSee [CHANGELOG.md](https://git.allucanget.biz/zwitschi/linearvoid/src/branch/main/CHANGELOG.md) for details.\",\"draft\":false,\"prerelease\":false}"
|
-d "{\"tag_name\":\"${{ steps.version.outputs.version }}\",\"name\":\"${{ steps.version.outputs.version }}\",\"body\":\"Release ${{ steps.version.outputs.version }}\n\nSee [CHANGELOG.md](https://git.allucanget.biz/zwitschi/linearvoid/src/branch/main/CHANGELOG.md) for details.\",\"draft\":false,\"prerelease\":false}"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Deploy to Cloudflare Workers
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
VITE_PUBLIC_APP_URL: ${{ secrets.VITE_PUBLIC_APP_URL }}
|
||||||
|
VITE_PUBLIC_WS_URL: ${{ secrets.VITE_PUBLIC_WS_URL }}
|
||||||
|
VITE_PUBLIC_HLS_URL: ${{ secrets.VITE_PUBLIC_HLS_URL }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_ROOM: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }}
|
||||||
|
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT: "50"
|
||||||
|
run: cd frontend && npm run build
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Workers
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npx wrangler deploy
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Deploy Media Worker to Cloudflare
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
# Production deploy: only on pushes to main (via CI success) or manual main runs.
|
||||||
|
if: ${{ (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd infrastructure/cloudflare && npm ci
|
||||||
|
|
||||||
|
- name: Test worker
|
||||||
|
run: cd infrastructure/cloudflare && npm test
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Workers (production)
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: |
|
||||||
|
cd infrastructure/cloudflare
|
||||||
|
npx wrangler deploy --env production
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Agents instructions for the linearvoid project.
|
||||||
|
|
||||||
|
## General Guidelines
|
||||||
|
|
||||||
|
- Use the architecture documentation (arc42 template and ADRs in `docs`) as the source of truth for design decisions.
|
||||||
|
- Follow the project folder structure in the root README.
|
||||||
|
- Update documentation first before implementing changes to ensure traceability of decisions.
|
||||||
|
|
||||||
|
## Task Management
|
||||||
|
|
||||||
|
- Plan any task in the `.github/instructions/TODO.md` file with clear steps and mark them as completed when done. Move completed sections to `.github/instructions/DONE.md`. Write entry in root `CHANGELOG.md` for any significant changes. Avoid using in-memory todo lists or update them frequently to sync from the `TODO.md` file.
|
||||||
|
- Before taking any action except for planning and writing `TODO.md`, always ask user for confirmation, accept `go`, `ok`, `yes`, `next`, or `continue` to proceed. If the user says `no`, `stop`, or `cancel`, abort the action and return to planning.
|
||||||
|
|
||||||
|
### Required tracking files — update at every development step
|
||||||
|
|
||||||
|
These files must be kept current with every change. Do not let them drift.
|
||||||
|
|
||||||
|
| File | Purpose | When to update |
|
||||||
|
| --------------------------------- | --------------------------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| `.github/instructions/TODO.md` | Active implementation plan with checkboxes | Mark items done as they complete; add new items when scope expands |
|
||||||
|
| `.github/instructions/BACKLOG.md` | Deferred, manual, and operational tasks | Sync whenever TODO items complete or new gaps are identified |
|
||||||
|
| `.github/instructions/DONE.md` | Archive of completed TODO sections | Move completed sections here from TODO.md |
|
||||||
|
| `.github/MEMORY.md` | Session memory — what was done, when, and why | Append dated entry after each meaningful batch of changes |
|
||||||
|
| `CHANGELOG.md` | User-facing release notes | Add entry when a phase or significant feature completes |
|
||||||
|
| `docs/STREAMING.md` | Streaming pipeline contract | Update when playout, HLS, or caching behavior changes |
|
||||||
|
| `docs/DEPLOYMENT.md` | Deploy and infrastructure procedures | Update when pipeline, topology, or provisioning changes |
|
||||||
|
| `docs/SETUP.md` | Environment and onboarding guide | Update when prerequisites, `.env`, or setup steps change |
|
||||||
|
| `docs/MONITORING.md` | Observability reference | Update when metrics, alerts, or dashboards change |
|
||||||
|
| `docs/API.md` | API contract | Update when endpoints, status codes, or rate limits change |
|
||||||
|
| `docs/SECURITY.md` | Security posture | Update when auth, TLS, or rate limiting changes |
|
||||||
|
| `docs/operations/*.md` | Runbooks, checklists, how-to guides | Keep aligned with actual procedures and dashboard URLs |
|
||||||
|
|
||||||
|
## Security & Secrets Protection
|
||||||
|
|
||||||
|
- **Never commit raw secrets or credentials** to the repository.
|
||||||
|
- **Never print or log raw secrets** in terminal commands or output files.
|
||||||
|
- **Use Gitea Actions Secrets** for all production credentials and environment variables.
|
||||||
|
- **Avoid hardcoding sensitive values** in workflow files (`.yml`). Instead, reference them using `${{ secrets.SECRET_NAME }}`.
|
||||||
|
- **Do not use the `GITEA_` prefix** for custom secrets or environment variables in Gitea, as it is reserved internally and will be silently ignored or returned as empty. Use the `CI_` prefix instead (e.g., `CI_REGISTRY_USER`, `CI_REGISTRY_TOKEN`).
|
||||||
|
- **Keep `.env` gitignored** and use `.env.example` as the template for local development.
|
||||||
|
- **For CD deployments**, generate the `.env` file dynamically on the runner using a secure step that populates values from Gitea Actions Secrets.
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# MEMORY
|
||||||
|
|
||||||
|
## 2026-08-07
|
||||||
|
|
||||||
|
- Completed runtime validation section from TODO.
|
||||||
|
- Runtime video normalization tests executed in running `linearvoid-playout` container using ffmpeg/ffprobe.
|
||||||
|
- Verified normalized outputs for MOV, MKV, VFR, and 5.1 inputs: `h264`, `yuv420p`, `30/1`, `aac`, `48000`, `2 channels`.
|
||||||
|
- Concat continuity check passed with `CONCAT_OK`; output duration `25.344667` seconds.
|
||||||
|
- Responsive viewport test on live viewer (`390x844`, `412x915`) detected 8px horizontal overflow from `100vw` usage in viewer container styles.
|
||||||
|
- Fixed overflow in source by changing `.lv-body` and `.viewer-shell` width from `100vw` to `100%` in `frontend/src/style.css`.
|
||||||
|
- Redis restart test:
|
||||||
|
- Admin session endpoint returned 200 before and after Redis restart.
|
||||||
|
- Presence key survived Redis restart in test-lab configuration.
|
||||||
|
- Redis health returned to `healthy` post-restart.
|
||||||
|
- Files changed:
|
||||||
|
- `.github/instructions/TODO.md`
|
||||||
|
- `.github/instructions/DONE.md`
|
||||||
|
- `frontend/src/style.css`
|
||||||
|
- `CHANGELOG.md`
|
||||||
|
|
||||||
|
## 2026-08-07 (continued)
|
||||||
|
|
||||||
|
- Completed documentation half of recovery runbook TODO item.
|
||||||
|
- Expanded `docs/incident-runbook.md` with quarterly DR drill: prerequisites, outage simulation, restore validation, pass/fail criteria, and evidence template.
|
||||||
|
- Split TODO item into:
|
||||||
|
- documented drill (`[x]`)
|
||||||
|
- manual rehearsal execution (`[ ]`)
|
||||||
|
- Updated `CHANGELOG.md` with incident runbook drill documentation entry.
|
||||||
|
|
||||||
|
## 2026-08-07 (continued 2)
|
||||||
|
|
||||||
|
- Added manual operations checklist `docs/operations/cloudflare-uptimerobot-checklist.md` for Cloudflare + UptimeRobot task execution and evidence capture.
|
||||||
|
- Linked checklist from `SETUP.md` Cloudflare section and TODO manual sections.
|
||||||
|
- Did not mark manual dashboard items complete; checklist is preparation only.
|
||||||
|
|
||||||
|
## 2026-08-07 (continued 3)
|
||||||
|
|
||||||
|
- Added `docs/operations/scaling-performance-playbook.md` covering manifest TTL tuning, TTFB baseline method, WebSocket/API benchmarking approach, cache ratio tracking, capacity triggers, and Phase 2/3 plans.
|
||||||
|
- Updated TODO and BACKLOG to mark playbook creation done while keeping execution/measurement tasks pending.
|
||||||
|
- Linked scaling playbook in `README.md` resources.
|
||||||
|
|
||||||
|
## 2026-08-09
|
||||||
|
|
||||||
|
- Added WebSocket benchmark summary helper `scripts/loadtest/summarize-artillery.ps1`.
|
||||||
|
- Updated `scripts/loadtest/run-loadtests.ps1` to auto-generate `ws-chat-summary.txt` for `ws` and `all` scenarios.
|
||||||
|
- Updated `scripts/loadtest/summarize-k6.ps1` to produce pipeline-friendly output and integrated automatic `admin-api-endpoint-summary.txt` generation.
|
||||||
|
- Expanded `scripts/loadtest/README.md` with PowerShell-first artifact capture and helper usage for both ws and admin scenarios.
|
||||||
|
- Updated `.gitignore` to ignore loadtest artifacts (`artifacts/` and `scripts/loadtest/artifacts/`).
|
||||||
|
- Added artifact-to-worksheet field mapping section in `docs/operations/scaling-performance-playbook.md` so ws/admin worksheet entries map directly to generated summary files.
|
||||||
|
- Updated `docs/DEVELOPMENT.md` and `.github/instructions/TODO.md` to mark ws/admin benchmark scaffolding complete while live benchmark execution remains pending.
|
||||||
|
- Added `CHANGELOG.md` entry `1.0.10` summarizing loadtest helper automation and playbook mapping updates.
|
||||||
|
|
||||||
|
## 2026-08-09 (continued)
|
||||||
|
|
||||||
|
- Completed Phase 1 (test fixtures): documented ffmpeg fixture-generation commands in `playlist_test.go` header comments (valid_720p30, invalid_h265, invalid_24fps).
|
||||||
|
- Completed Phase 4 (R2 integration):
|
||||||
|
- Enhanced `SyncToR2` in `playlist.go` to set CacheControl + ContentType based on file extension:
|
||||||
|
- `.m4s`, `.ts`, `.mp4`, `.m4a` -> `public, max-age=31536000, immutable` + `video/MP2T`
|
||||||
|
- `.m3u8` -> `public, max-age=2` + `application/vnd.apple.mpegurl`
|
||||||
|
- unknown extension -> no cache headers
|
||||||
|
- Added three tests: `TestSyncToR2` (segment upload with cache verification), `TestSyncToR2_ManifestCacheHeaders` (manifest upload), `TestSyncToR2_NoCacheHeadersForUnknownExt` (unknown extension).
|
||||||
|
- All 17 playout tests pass (16 pass, 1 pre-existing skip).
|
||||||
|
- Updated TODO.md: Phase 1 marked complete, Phase 4 partially complete (edge cache hit/miss remains manual).
|
||||||
|
- Updated DEVELOPMENT.md: Test Fixtures marked complete, R2 Integration Test marked partially complete.
|
||||||
|
|
||||||
|
## 2026-08-09 (continued 2)
|
||||||
|
|
||||||
|
- Completed Phase 2 chat tests in `frontend/src/main.test.ts`:
|
||||||
|
- Presence updates: join/leave system messages add and remove users from presence list, sorted rendering.
|
||||||
|
- Private DM flow: togglePrivateDM creates correct dm: room name from sorted usernames.
|
||||||
|
- Message rendering: system messages, self messages (message-self), and other-user messages (message-other) all render correctly.
|
||||||
|
- Total frontend tests: 27 passing (2 test files).
|
||||||
|
- Marked Phase 4 "Document stubs/mocks" done (putObject injection pattern).
|
||||||
|
- Updated TODO.md: Phase 2 chat tests checked, Phase 4 stubs checked.
|
||||||
|
- Updated DEVELOPMENT.md: User Frontend Tests marked partially complete, R2 stubs checked.
|
||||||
|
- Updated CHANGELOG.md with Phase 2 presence/DM/message tests.
|
||||||
|
|
||||||
|
## 2026-08-09 (continued 3)
|
||||||
|
|
||||||
|
- Completed Phase 3 admin frontend tests in `frontend/src/admin.test.ts`:
|
||||||
|
- Exported `savePlaylist` and `renderPlaylistSortable` from `admin.ts`.
|
||||||
|
- Added playlist save test (verifies PUT /api/admin/playlist with JSON body).
|
||||||
|
- Added playlist save error handling test (verifies failure status).
|
||||||
|
- Added sortable rendering test (verifies index, paths, remove buttons, comment/blank line skip).
|
||||||
|
- Total frontend tests: 31 passing (2 test files).
|
||||||
|
- Updated TODO.md: Phase 3 playlist/dashboard/API integration all checked.
|
||||||
|
- Updated DEVELOPMENT.md: Admin Frontend Tests marked partially complete.
|
||||||
|
|
||||||
|
## 2026-08-10 (continued 2)
|
||||||
|
|
||||||
|
- Implemented STREAMING.md production-readiness gaps from plan `.hermes/plans/2026-08-10_164500-production-readiness.md`:
|
||||||
|
- **Task 1-2**: Added `runID` (ISO timestamp format) and monotonic `segSequence` for globally unique segment naming (`r-{ISOTIME}/seg-{seq}.m4s`). Replaced `strftime` pattern with run ID-based path.
|
||||||
|
- **Task 2**: Added `watchAndPublishSegments` using `fsnotify` for real-time per-segment R2 upload (~7s delay). Previously `syncLatestToR2()` only ran after FFmpeg exit.
|
||||||
|
- **Task 3**: Added `publishManifest` with atomic `CopyObject` (temp key → final key) to prevent manifest referencing unconfirmed segments.
|
||||||
|
- **Task 4**: Updated `manifestCacheControl` from `"public, max-age=2"` to `"public, max-age=1, s-maxage=2, must-revalidate"` per STREAMING.md.
|
||||||
|
- **Task 5**: Added `hls.on(Hls.Events.ERROR)` handler in `main.ts` with bounded retries (5x), exponential backoff (2s→32s), reconnecting overlay (spinner + text). Added `showReconnecting`/`hideReconnecting` functions.
|
||||||
|
- **Task 6**: Enriched `HealthHandler` with `run_id`, `active_item`, `playlist_revision`, `last_segment_seq`, `last_r2_pub`, `consecutive_failures`. Added "unhealthy" status when R2 publish is stale (>12s).
|
||||||
|
- Files changed: `backend/playout/src/playlist.go`, `playlist_test.go`, `go.mod`, `frontend/src/main.ts`, `main.test.ts`, `style.css`, `index.html`.
|
||||||
|
- Updated `.github/instructions/TODO.md`, `BACKLOG.md`, `MEMORY.md`, `DONE.md`.
|
||||||
|
- Remaining: CORS on media.linearvoid.tv (Cloudflare R2 config, infrastructure).
|
||||||
|
|
||||||
|
## 2026-08-10 (continued 3)
|
||||||
|
|
||||||
|
- Completed Phase 2 (viewer) + Phase 3 (admin) responsive/accessibility tests:
|
||||||
|
- 5 viewer accessibility tests: video controls/attributes, chat panel aria-labels, chat toggle aria-expanded, message input label, reconnecting overlay aria-live/role.
|
||||||
|
- 3 viewer responsive tests: chat-collapsed toggle, CSS grid layout detection, now-playing aria-live.
|
||||||
|
- 3 admin accessibility tests: nav aria-label, active tab aria-current, nav button focusability.
|
||||||
|
- 2 admin responsive tests: tab content visibility, essential tab presence.
|
||||||
|
- Total: 45 frontend tests passing (2 test files).
|
||||||
|
- Added coverage reporting: `vitest.config.ts` with v8 provider, thresholds at 20/15/15/20 (statements/branches/functions/lines).
|
||||||
|
- Added `test:coverage` script to `package.json`.
|
||||||
|
- Fixed CI: bumped Node from 20 to 22 in `ci.yml` and `workers-deploy.yml` (jsdom@30 requires Node 22+). Added `engines` to `package.json`.
|
||||||
|
- Updated AGENTS.md: added Required tracking files table with 13 docs and update cadence.
|
||||||
|
- All Go vet, Go tests, frontend test:coverage passing.
|
||||||
|
- CORS on media.linearvoid.tv remains the only streaming pipeline gap (Cloudflare R2 config, not code).
|
||||||
|
|
||||||
|
## 2026-08-10 (continued 4)
|
||||||
|
|
||||||
|
- Verified all test suites pass after streaming pipeline implementation:
|
||||||
|
- Playout: `go mod tidy` (added fsnotify v1.8.0) → all tests pass
|
||||||
|
- Chat: 7 packages pass (go vet + go test)
|
||||||
|
- Frontend: 45 tests pass (2 files), test:coverage meets thresholds (22.92% / 18.44% / 19.01% / 25.42%)
|
||||||
|
- Edge cache validation script `scripts/validate-edge-cache.sh` exists.
|
||||||
|
- Remaining: CORS on media.linearvoid.tv (Cloudflare R2 bucket CORS config, not a code change). Phases 5-7 are manual/infrastructure only (Cloudflare dashboard, Hetzner provisioning, live benchmarks).
|
||||||
|
|
||||||
|
## 2026-08-11
|
||||||
|
|
||||||
|
- Consolidated all development tasks from `DEVELOPMENT.md` and `.github/instructions/TODO.md` into `.github/instructions/BACKLOG.md` as the single source of truth. `DEVELOPMENT.md` now obsolete and removed. `TODO.md` references `BACKLOG.md` and `DONE.md`.
|
||||||
|
- Fixed R2 endpoint validation in playout service (`backend/playout/src/playlist.go`):
|
||||||
|
- Added `isR2Configured()` checking all four required env vars (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`).
|
||||||
|
- `initR2Client()` now returns a clear error if R2 is not configured instead of creating an S3 client with an empty endpoint.
|
||||||
|
- Segment watcher (`watchAndPublishSegments`), `syncLatestToR2()`, and `publishManifest()` are now guarded by `r2Client != nil` — no error spam when R2 is unavailable.
|
||||||
|
- Playout gracefully degrades: local HLS via nginx works without R2.
|
||||||
|
- Updated `.gitea/workflows/cd.yml` "Prepare environment" step to include all R2* variables sourced from Gitea secrets (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN*\*`), Cloudflare variables (`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`), and frontend public URL variables.
|
||||||
|
- Updated `docs/DEPLOYMENT.md` "Environment File" section: replaced vague "Cloudflare/R2 variables" bullet with explicit variable lists and added a new "Gitea Secrets" table documenting all 15 required secrets.
|
||||||
|
- All playout tests pass (20 tests), `go vet` clean.
|
||||||
|
|
||||||
|
## 2026-08-13
|
||||||
|
|
||||||
|
- Completed all remaining enhancement tasks (only manual/infrastructure phases left):
|
||||||
|
- **Playout**: automatic media normalization on upload (`media.NormalizeVideo`, `*.normalized.mp4`, statuses `ready`/`needs_normalization`/`normalizing`/`normalization_failed`, `POST /api/admin/media/{id}/normalize`); R2 segment retention (`cleanupOldRuns`, keep last `R2_KEEP_RUNS` runs, `R2_MAX_RETENTION_BYTES` cap); seamless looping (playlist SHA-256 fingerprint + `-start_number`); single `start-playout.sh` entrypoint + `-validate` mode; loop/cleanup metrics.
|
||||||
|
- **Application configuration**: thread-safe JSON runtime config store (`backend/chat/src/config/runtime.go`) shared with playout via `/app/playlist/config.json`; `GET/PUT /api/admin/config`; admin UI config form.
|
||||||
|
- **Matomo analytics**: public `GET /api/matomo` + viewer pageview snippet in `index.html`.
|
||||||
|
- **Chat user accounts**: register/login/logout/session endpoints, bcrypt hashing, `linearvoid_user_session` cookie, viewer sign-in UI.
|
||||||
|
- **DM history & search**: `GET /api/dm/history` (paginated) + `GET /api/dm/search` + DM search UI.
|
||||||
|
- **Chat UI**: typing indicator, user-list toggle, media status badges, "Add to playlist" gated to `ready`.
|
||||||
|
- **Prometheus TSDB retention**: `--storage.tsdb.retention.time=30d` + `--storage.tsdb.retention.size=15GB` in both compose files; alert rule count corrected 8 → 10.
|
||||||
|
- Updated `docs/architecture/README.md` to reflect all `[Unreleased]` changes (normalization, looping, retention, runtime config, Matomo, user accounts, DM history/search, new API endpoints, module tables).
|
||||||
|
- Released **1.0.13**: promoted changelog, bumped version, tagged and pushed.
|
||||||
|
- Extended frontend test coverage: added `counter.test.ts`, reconnecting/typing/auth/websocket tests, admin failure + status-badge tests → 56 tests; statements 19.89% → 28.58% (CI coverage threshold passing).
|
||||||
|
- Fixed Go test data race in `TestHandleAdminMediaByID_Normalize` (waits for async normalization goroutine) + shared in-memory SQLite via `SetMaxOpenConns(1)`.
|
||||||
|
- Fixed viewer crash: `index.html` was missing the `#messages` chat container (stray `</section>`); restored structure + made `addMessage` defensive.
|
||||||
|
- Set `Doto` as the body font (`--font-body`) and added Google Fonts links to admin + login pages.
|
||||||
|
- Files changed: across `backend/chat`, `backend/playout`, `frontend`, `docs`, `docker-compose*.yml`, `.github/instructions/*`.
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
Agents instructions for the linearvoid project.
|
|
||||||
|
|
||||||
## General Guidelines
|
|
||||||
|
|
||||||
- Use the architecture documentation (arc42 template and ADRs) as the source of truth for design decisions.
|
|
||||||
- Follow the project folder structure defined in ADR-002 and reflected in the root README.
|
|
||||||
- Update documentation first before implementing changes to ensure traceability of decisions.
|
|
||||||
- Plan any task in the `TODO.md` file with clear steps and mark them as completed when done. Move completed sections to DONE.md. Write entry in root `CHANGELOG.md` for any significant changes. Avoid using in-memory todo lists or update them frequently to sync from the `TODO.md` file.
|
|
||||||
- Before taking any action except for planning and writing `TODO.md`, always ask user for confirmation, accept `go`, `ok`, `yes`, `next`, or `continue` to proceed. If the user says `no`, `stop`, or `cancel`, abort the action and return to planning.
|
|
||||||
|
|
||||||
## Security & Secrets Protection
|
|
||||||
|
|
||||||
- **Never commit raw secrets or credentials** to the repository.
|
|
||||||
- **Never print or log raw secrets** in terminal commands or output files.
|
|
||||||
- **Use Gitea Actions Secrets** for all production credentials and environment variables.
|
|
||||||
- **Avoid hardcoding sensitive values** in workflow files (`.yml`). Instead, reference them using `${{ secrets.SECRET_NAME }}`.
|
|
||||||
- **Do not use the `GITEA_` prefix** for custom secrets or environment variables in Gitea, as it is reserved internally and will be silently ignored or returned as empty. Use the `CI_` prefix instead (e.g., `CI_REGISTRY_USER`, `CI_REGISTRY_TOKEN`).
|
|
||||||
- **Keep `.env` gitignored** and use `.env.example` as the template for local development.
|
|
||||||
- **For CD deployments**, generate the `.env` file dynamically on the runner using a secure step that populates values from Gitea Actions Secrets.
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# BACKLOG
|
||||||
|
|
||||||
|
> All current work is planned in [TODO](./TODO.md) (2026-08-13). This file lists backlog items; new unplanned work is added here.
|
||||||
|
|
||||||
|
## Setup phases remaining
|
||||||
|
|
||||||
|
### Phase 5: complete manual Cloudflare and external-service setup
|
||||||
|
|
||||||
|
- [ ] Apply Cloudflare Workers, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
|
||||||
|
- [ ] Cloudflare Workers: link custom domain and cut over frontend hosting
|
||||||
|
- [ ] Enable Smart Tiered Cache; validate `Cf-Cache-Status: HIT` from upper tier
|
||||||
|
- [ ] WAF: enable OWASP Managed Ruleset (Simulate → review 48h → Block), enable Bot Fight Mode
|
||||||
|
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
||||||
|
- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2; enforce at edge and origin
|
||||||
|
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
|
||||||
|
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5min)
|
||||||
|
- [ ] UptimeRobot: alert on 2 consecutive failures via email + Discord/Slack webhook
|
||||||
|
- [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||||
|
|
||||||
|
### Phase 6: rehearse production deployment end to end
|
||||||
|
|
||||||
|
- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04
|
||||||
|
- [ ] Install Docker Engine + Compose plugin; open firewall: 80/443 public, 2375 runner-only
|
||||||
|
- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs)
|
||||||
|
- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories
|
||||||
|
- [ ] Add `CI_PROD_*` secrets to Gitea (DOCKER_HOST, R2 keys, admin password, DB password, Grafana password)
|
||||||
|
- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP
|
||||||
|
- [ ] Verify all 11 containers healthy: `/health`, `/hls/playlist.m3u8`, WebSocket
|
||||||
|
- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict); test HTTPS + WSS
|
||||||
|
- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout
|
||||||
|
- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL
|
||||||
|
- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host
|
||||||
|
- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds
|
||||||
|
|
||||||
|
### Phase 7: baseline scaling and performance
|
||||||
|
|
||||||
|
- [ ] Execute WebSocket load tests — ramp 10→100→500→1000 connections; measure p95 latency + errors
|
||||||
|
- [ ] Execute admin API benchmarks — `wrk`/`k6` against `/api/admin/*`; target p95 <1s
|
||||||
|
- [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions (WebPageTest 3+ regions, <4s warm cache)
|
||||||
|
- [ ] Tune manifest TTL to 0.5× segment duration; validate `Cf-Cache-Status` distribution
|
||||||
|
- [ ] Track cache-hit ratio and R2 ops in production (Cloudflare GraphQL → Grafana panel, alert <90%)
|
||||||
|
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
|
||||||
|
- [ ] Plan Phase 2 service separation (playout CPU, chat latency, DB state, monitoring)
|
||||||
|
- [ ] Plan Phase 3 multi-origin resilience (second Hetzner location, warm standby, DNS failover, DB/Redis replication)
|
||||||
|
|
||||||
|
### Phase 8: close out remaining test and validation gaps
|
||||||
|
|
||||||
|
- [ ] Admin frontend: user moderation tools tests (mute/ban/report, view moderation logs)
|
||||||
|
- [ ] Frontend: coverage for all critical user + admin components and interactions
|
||||||
|
- [ ] Frontend: document test cases and expected outcomes
|
||||||
|
- [ ] Backend R2: test retrieval of segments/manifests via Cloudflare edge
|
||||||
|
- [ ] Backend R2: validate edge cache hit/miss behavior with repeat requests (`scripts/validate-edge-cache.sh`)
|
||||||
|
|
||||||
|
### Playout
|
||||||
|
|
||||||
|
- [ ] Configure an R2 lifecycle rule on the `hls/` prefix as defense-in-depth (manual)
|
||||||
|
- [ ] Full-loop E2E (2-file playlist, assert `Looping...` with no `CRASHED` across the boundary) — manual via `scripts/test-playout-e2e.sh`
|
||||||
|
|
||||||
|
## Cloudflare Configuration
|
||||||
|
|
||||||
|
### DNS & Workers
|
||||||
|
|
||||||
|
- [ ] Cloudflare Workers: link custom domain (manual)
|
||||||
|
- [ ] Create Cloudflare Worker and cut over frontend hosting (manual)
|
||||||
|
|
||||||
|
### Security & TLS
|
||||||
|
|
||||||
|
- [ ] Enable Tiered Cache, WAF, rate limiting (manual)
|
||||||
|
- [ ] Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier
|
||||||
|
- [ ] WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode
|
||||||
|
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
||||||
|
- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2
|
||||||
|
- [ ] HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS
|
||||||
|
|
||||||
|
## Monitoring & Observability
|
||||||
|
|
||||||
|
### External Monitoring
|
||||||
|
|
||||||
|
- [ ] Set up UptimeRobot external monitoring
|
||||||
|
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
|
||||||
|
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
|
||||||
|
- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
|
||||||
|
|
||||||
|
## External Services
|
||||||
|
|
||||||
|
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||||
|
|
||||||
|
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
|
||||||
|
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
|
||||||
|
- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Host Provisioning (Hetzner — not provisioned)
|
||||||
|
|
||||||
|
- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04
|
||||||
|
- [ ] Install Docker Engine + Compose plugin, open firewall: 80/443 (public), 2375 (runner IP only)
|
||||||
|
- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs)
|
||||||
|
- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories
|
||||||
|
|
||||||
|
### CI/CD & Secrets
|
||||||
|
|
||||||
|
- [ ] Add `CI_PROD_*` secrets to Gitea: DOCKER_HOST, R2 keys, admin password, DB password, Grafana password
|
||||||
|
- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP
|
||||||
|
|
||||||
|
### Smoke Test & Cutover
|
||||||
|
|
||||||
|
- [ ] Verify all 11 containers start healthy, check `/health`, `/hls/playlist.m3u8`, WebSocket
|
||||||
|
- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict), test HTTPS + WSS
|
||||||
|
- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout
|
||||||
|
|
||||||
|
### Production Operations
|
||||||
|
|
||||||
|
- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL
|
||||||
|
- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host
|
||||||
|
- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds
|
||||||
|
|
||||||
|
## Scaling & Performance
|
||||||
|
|
||||||
|
### Load Testing
|
||||||
|
|
||||||
|
- [ ] Execute WebSocket load tests with real infrastructure — ramp 10→100→500→1000 connections, measure p95 latency + errors
|
||||||
|
- [ ] Execute admin API benchmarks with real infrastructure — `wrk`/`k6` against `/api/admin/*`, target p95 <1s
|
||||||
|
|
||||||
|
### Cache & TTFB Optimization
|
||||||
|
|
||||||
|
- [ ] Optimize segment immutability and manifest TTL for cache efficiency
|
||||||
|
- [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration
|
||||||
|
- [ ] Measure playback TTFB from multiple US regions — WebPageTest from 3+ regions, target <4s warm cache
|
||||||
|
- [ ] Track cache hit ratio and R2 ops in production — Cloudflare GraphQL → Grafana panel, alert <90%
|
||||||
|
|
||||||
|
### Capacity & Future Planning
|
||||||
|
|
||||||
|
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
|
||||||
|
- [ ] Plan Phase 2 service separation — document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources
|
||||||
|
- [ ] Plan Phase 3 multi-origin resilience — research second Hetzner location, design warm standby + DNS failover + DB/Redis replication
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# DONE
|
||||||
|
|
||||||
|
> Completed tasks from [TODO](./TODO.md). Product, feature, and release history lives in [CHANGELOG](./CHANGELOG.md); this file keeps only engineering and ops notes not captured there.
|
||||||
|
|
||||||
|
## Manifest run-scoped rewrite — candidate (a) (2026-09-03)
|
||||||
|
|
||||||
|
- Playout now publishes the live R2 manifest with run-scoped media URIs. `rewriteManifestToRun` in `backend/playout/src/playlist.go` prefixes each bare relative URI (`seg-*.m4s` / `init.mp4`, plus `#EXT-X-MAP:URI`) with `r-{runID}/` before `publishManifest`'s atomic `.tmp` → `CopyObject` commit; skipped in slate mode. Added a stubbable `copyObject` hook for tests (consistent with `putObject`/`listObjects`/`deleteObjects`).
|
||||||
|
- Tests: `playlist_rewrite_test.go` — `TestRewriteManifestToRun`, `TestRewriteManifestToRun_AlreadyPrefixedUnchanged`, `TestRewriteManifestToRun_HandlesTSAndComments`, `TestRewriteManifestToRun_EmptyRunID`, `TestIsMediaURI`, `TestPublishManifest_RewritesToRunScopedURIs`, `TestPublishManifest_SlateNotRewritten`.
|
||||||
|
- Docs updated: `STREAMING.md`, `operations/cloudflare-workers-setup.md`, `architecture/README.md` (incl. Level-2 diagram showing the R2-bound `linearvoid-media` Worker and run-scoped layout).
|
||||||
|
- **Still pending (in TODO): live Validation** — deploy the updated playout to the test lab and confirm manifest/segment URIs resolve via `media.linearvoid.tv`.
|
||||||
|
|
||||||
|
## Test infrastructure & refactors (2026-08-07)
|
||||||
|
|
||||||
|
- Chat service `main.go` split into 8 packages (config, auth, db, handlers, ws, media, ratelimit, metrics); `db.RedisClient` + `db.PubSub` interfaces added for mocking.
|
||||||
|
- 78 tests total (Go + frontend), all passing. Coverage: auth 97.8%, config 100%, metrics 100%, ratelimit 90.3%, ws 79.6%, handlers 47.2%, media 36.8%, db 14.3%.
|
||||||
|
- Playout: 12 tests. Frontend: vitest + jsdom, 6 user + 5 admin smoke tests.
|
||||||
|
- CI: `go test -race -cover` with a 50% coverage threshold; SQLite switched to pure-Go `github.com/glebarez/sqlite` (CGO-free, works on Windows).
|
||||||
|
- Frontend coverage enforcement: vitest v8 provider, 40% statements/lines + 30% branches/functions thresholds, `test:coverage` script + CI step.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
- `scripts/backup-db.sh` (daily pg_dump → R2, 14-day retention), `scripts/backup-weekly.sh` (weekly full dump + integrity check, 8-week retention), `scripts/verify-backups.sh` (download → temp Postgres → restore + row counts).
|
||||||
|
|
||||||
|
## Gitea self-hosting migration
|
||||||
|
|
||||||
|
- Migrated repo + CI/CD from GitHub to Gitea Actions (`git.allucanget.biz/zwitschi/linearvoid`): self-hosted runner, Container Registry convention `git.allucanget.biz/zwitschi/linearvoid/<service>:<tag>`, secrets renamed `GITEA_*` → `CI_*`.
|
||||||
|
|
||||||
|
## Streaming pipeline gaps (2026-08-10)
|
||||||
|
|
||||||
|
- Continuous R2 sync via `fsnotify` (`watchAndPublishSegments`, ~7s delay); run-ID + monotonic segment naming (`r-{ISOTIME}/seg-{seq}.m4s`); atomic manifest publication (upload `.tmp` → `CopyObject`); enriched `/health` (run_id, active_item, playlist_revision, last_segment_seq, last_r2_pub, consecutive_failures); player error recovery (5 retries, 2s→32s exponential backoff, reconnecting overlay).
|
||||||
|
- CORS on `media.linearvoid.tv` remains infrastructure-only (Cloudflare R2 config, not code).
|
||||||
|
|
||||||
|
## Bug fixes not in CHANGELOG
|
||||||
|
|
||||||
|
- **2026-08-13 viewer crash**: `index.html` was missing the `#messages` container, so `addMessage` threw `Cannot read properties of null (reading 'appendChild')` on WebSocket open/message. Restored the chat panel structure and made `addMessage` defensive.
|
||||||
|
- **2026-08-15 chat reconnect loop**: after a registered user logged in, the chat panel repeatedly filled with old messages. `connectWebSocket` left the replaced socket's `onclose` handler attached, so each intentional reconnect scheduled another reconnect and every connection re-pulled the room's 50-message history on a 3s loop. Fixed by detaching the old socket's `onclose` before closing and guarding the handler against stale sockets. Regression test added; `npm --prefix frontend test` → 58 tests passing.
|
||||||
|
|
||||||
|
## Chat improvements
|
||||||
|
|
||||||
|
- Manual smoke: viewer join/leave shows a 3s flash (not a chat row); reconnect <5s shows no duplicate; admin chat-log table has no system rows
|
||||||
|
|
||||||
|
## Playout improvements
|
||||||
|
|
||||||
|
- Test in Firefox: live playback, rebuffer/recovery, chat sync; record quirks in `docs/STREAMING.md`
|
||||||
|
- Rebuild + run playout; confirm `playlist.m3u8` lists ~12 segments at 2s each
|
||||||
|
- Manual: verify viewer latency and cache-hit behavior after the change
|
||||||
|
|
||||||
|
## Chat sidebar (2026-08-17)
|
||||||
|
|
||||||
|
- [x] remove search bar from chat sidebar (not needed for single-room)
|
||||||
|
- [x] remove "user" toggle button from chat sidebar (hide for now, add back when user interface is stable)
|
||||||
|
- [x] make login/registration form a modal and only toggle it when user is not logged in and clicks "Login" or "Register"
|
||||||
|
- [x] when user is logged in, do not show login/registration form
|
||||||
|
- [x] make sure each message is only rendered once in the chat log (no duplicates on reconnect or initial load)
|
||||||
|
|
||||||
|
### Plan
|
||||||
|
|
||||||
|
Files: `frontend/index.html`, `frontend/src/main.ts`, `frontend/src/style.css`.
|
||||||
|
|
||||||
|
1. **Remove search bar** (single-room)
|
||||||
|
- `index.html`: delete the `<div id="dm-search-row">` block.
|
||||||
|
- `main.ts`: remove `searchDM()` and the `dm-search-btn` listener; drop the `#dm-search-row` show/hide logic in `switchRoom()` (the `searchRow.hidden = !isDM` lines). Keep `currentDMTarget` only if the "New DM" flow stays.
|
||||||
|
|
||||||
|
2. **Remove "Users" toggle button**
|
||||||
|
- `index.html`: delete the `<button id="users-toggle-btn">` button.
|
||||||
|
- `main.ts`: remove `toggleUserList()` and the `users-toggle-btn` listener; drop the now-unused `usersVisible` variable.
|
||||||
|
|
||||||
|
3. **Login/registration form as a modal**
|
||||||
|
- `index.html`: wrap the `#auth-panel` form in a modal structure (`.auth-modal` + backdrop) with a close button.
|
||||||
|
- `style.css`: add `.auth-modal` / `.auth-modal-backdrop` styles.
|
||||||
|
- `main.ts`: add `openAuthModal()` / `closeAuthModal()`; change the `auth-toggle-btn` handler to open the modal (only when not authenticated) instead of toggling `#auth-panel` inline; wire a close/backdrop-click to `closeAuthModal()`.
|
||||||
|
|
||||||
|
4. **Hide form when logged in**
|
||||||
|
- `main.ts` `updateAuthUI()`: when `authenticated` → hide the "Sign in" button and call `closeAuthModal()`; show the user label + "Sign out" button. When not authenticated → show "Sign in" and hide "Sign out".
|
||||||
|
|
||||||
|
5. **No duplicate messages**
|
||||||
|
- `main.ts`: add `clearMessages()` (empties `#messages`) and call it at the top of `connectWebSocket()` so each (re)connect renders history fresh instead of appending.
|
||||||
|
- Fix the initial double-connect: make the initial `connectWebSocket("main")` wait for `refreshUserSession()` (or connect once with the resolved username) so history isn't pushed twice on load.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
|
||||||
|
|
||||||
|
## Playout / R2 manifest–segment layout mismatch
|
||||||
|
|
||||||
|
**Chosen approach: candidate (a)** — rewrite the live manifest so its media URIs point into the current run-scoped folder before the atomic R2 publish. Go change in `backend/playout/src/playlist.go`. The `linearvoid-media` Worker stays a faithful path→R2 mapper (no serve-time rewrite).
|
||||||
|
|
||||||
|
### Remaining — Validation
|
||||||
|
|
||||||
|
- [ ] Run playout (local or test lab), then:
|
||||||
|
- `curl https://media.linearvoid.tv/hls/playlist.m3u8` → 200, short TTL.
|
||||||
|
- Every URI in the manifest resolves `200` with `Cache-Control: public, max-age=31536000, immutable` (via the `linearvoid-media` Worker).
|
||||||
|
- Confirm manifest refresh + `CF-Cache-Status` progression and no 404 on segment fetch across a run loop.
|
||||||
|
- [ ] Verify slate fallback still serves correctly after the rewrite change.
|
||||||
|
|
||||||
|
> Implementation (candidate a), Go unit tests, and documentation are complete — see [DONE.md](./DONE.md).
|
||||||
|
|
||||||
|
## Frontend Tests
|
||||||
|
|
||||||
|
### User Frontend
|
||||||
|
|
||||||
|
- [ ] Test coverage for all critical frontend components and interactions
|
||||||
|
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||||
|
- [ ] Document test cases and expected outcomes
|
||||||
|
|
||||||
|
### Admin Frontend
|
||||||
|
|
||||||
|
- [ ] Test coverage for all critical admin UI components and interactions
|
||||||
|
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||||
|
- [ ] Document test cases and expected outcomes
|
||||||
|
|
||||||
|
## Backend Integration Tests
|
||||||
|
|
||||||
|
### Cloudflare R2
|
||||||
|
|
||||||
|
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
|
||||||
|
- [ ] Validate cache hit/miss behavior and performance metrics
|
||||||
|
- [ ] Validate edge cache hit/miss behavior with repeat requests — script `scripts/validate-edge-cache.sh` created
|
||||||
+9
-4
@@ -7,6 +7,10 @@
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage
|
||||||
|
coverage.out
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
@@ -15,6 +19,10 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Benchmark artifacts
|
||||||
|
artifacts/
|
||||||
|
scripts/loadtest/artifacts/
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
pids
|
pids
|
||||||
*.pid
|
*.pid
|
||||||
@@ -41,6 +49,7 @@ out/
|
|||||||
# FFmpeg temporary files
|
# FFmpeg temporary files
|
||||||
*.ts.tmp
|
*.ts.tmp
|
||||||
*.m3u8.tmp
|
*.m3u8.tmp
|
||||||
|
*.m4s.tmp
|
||||||
hls/
|
hls/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
@@ -78,10 +87,6 @@ Thumbs.db
|
|||||||
*.tfstate.backup
|
*.tfstate.backup
|
||||||
.terraform/
|
.terraform/
|
||||||
|
|
||||||
# Agent / Tool files
|
|
||||||
.github/instructions/TODO.md
|
|
||||||
.github/instructions/DONE.md
|
|
||||||
# (keep AGENTS.md, MEMORY.md, and other instruction files)
|
|
||||||
|
|
||||||
# Temporary architecture artifacts
|
# Temporary architecture artifacts
|
||||||
**/*.tmp
|
**/*.tmp
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# =============================================
|
||||||
|
# Linear Void .prettierignore
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
*.min.js
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
coverage.out
|
||||||
|
|
||||||
|
# Go source (handled by gofmt, not prettier)
|
||||||
|
*.go
|
||||||
|
go.mod
|
||||||
|
go.sum
|
||||||
|
|
||||||
|
# Binary / compiled
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Media files
|
||||||
|
*.m4s
|
||||||
|
*.m3u8
|
||||||
|
*.ts.tmp # FFmpeg temp, not TypeScript
|
||||||
|
*.m3u8.tmp
|
||||||
|
*.png
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.gif
|
||||||
|
*.svg
|
||||||
|
*.ico
|
||||||
|
*.webp
|
||||||
|
*.mp4
|
||||||
|
*.webm
|
||||||
|
*.mp3
|
||||||
|
*.ogg
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
data/
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Databases
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Lockfile (auto-generated, don't reformat)
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Shell scripts (prettier doesn't handle sh)
|
||||||
|
*.sh
|
||||||
|
|
||||||
|
# Environment & secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Editor & IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
|
||||||
|
# Terraform / IaC
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.backup
|
||||||
|
.terraform/
|
||||||
|
|
||||||
|
# Temp & backup files
|
||||||
|
**/*.tmp
|
||||||
|
**/*.bak
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"proseWrap": "preserve"
|
||||||
|
}
|
||||||
+229
-1
@@ -2,6 +2,234 @@
|
|||||||
|
|
||||||
<!-- markdownlint-disable MD024 -->
|
<!-- markdownlint-disable MD024 -->
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-09-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Cloudflare media edge worker (`linearvoid-media`)**: new self-contained project under `infrastructure/cloudflare/` — an R2-bound Worker owning `media.linearvoid.tv` that enforces immutable segment caching (`public, max-age=31536000, immutable`) and short-TTL live `.m3u8`, with HLS CORS/OPTIONS, byte-Range, and a `/health` probe. Includes Vitest unit tests + Miniflare/workerd R2 integration tests and production deploy via `.gitea/workflows/workers-media-deploy.yml`. Replaces the ad-hoc `worker.js`.
|
||||||
|
- **Bash script ports**: Linux/macOS equivalents of the Windows ops/load-test scripts (`cache-hit-ratio`, `check-env`, `check-tokens`, loadtest run/summarize); `.ps1` files retained.
|
||||||
|
- **Transient presence flash**: join/leave events show as a 3-second `#chat-flash` instead of permanent chat rows.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Cloudflare Workers hosting**: frontend moved from Cloudflare Pages to the `linearvoid-frontend` static-assets Worker; docs now describe `linearvoid-frontend` (SPA) and `linearvoid-media` (edge HLS), correcting stale `worker.js` and R2 custom-domain wording.
|
||||||
|
- **Docs de-Windows'd & condensed**: bash-first guides for `SETUP`, `DEPLOYMENT`, `API`, `MONITORING`, `STREAMING`, `COOLIFY`, incident runbook, scaling playbook, architecture README, and Prometheus metrics inventory.
|
||||||
|
- **Compose port de-collision**: playout on host port `8081`, nginx moved to `8082` (`NGINX_PORT` default updated) to avoid Coolify-reserved ports.
|
||||||
|
- **Playout**: R2 retention now by run count + byte cap (`R2_KEEP_RUNS` default 2, `R2_MAX_RETENTION_BYTES` default 10 GiB, new gauges) replacing the 24h time-based cleanup; default `HLS_SEGMENT_DURATION` 2s / `HLS_WINDOW_SIZE` 12; deduplicated, concurrent startup validation.
|
||||||
|
- **Chat/UX**: 5s reconnect grace window (no join/leave spam); `system` messages no longer persisted; smaller `.message-text`; sidebar cleanup (removed DM search + Users toggle, login/register now a modal).
|
||||||
|
- **Auth**: viewer session cookie uses `SameSite=None; Secure` over HTTPS (cross-site SPA) and `Lax` over plain HTTP.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Run-scoped R2 manifest URIs (playout)**: `publishManifest` now rewrites bare relative media URIs to the current run folder (`r-{runID}/…`) via `rewriteManifestToRun` before the atomic `.tmp` → `CopyObject` publish, so the live `hls/playlist.m3u8` references resolve against the run-scoped segment keys on `media.linearvoid.tv`. Skipped in slate mode. (Live validation pending — see `TODO.md`.)
|
||||||
|
- **Cross-origin auth**: login/register API calls now prefix `VITE_PUBLIC_APP_URL` so the Workers-served SPA reaches the app origin.
|
||||||
|
- **Duplicate chat history**: `connectWebSocket` clears messages on each (re)connect and the initial connect waits for session resolution.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- WebSocket (`TestHub_Broadcast_SystemNotPersisted`, `TestHub_ReconnectGracePeriod`), handlers (`TestHandleAdminChatLog_ExcludesSystem`), frontend presence flash.
|
||||||
|
- Playout: `TestCleanupOldRuns(_SizeCap)`, `TestR2KeepRuns`, `TestR2MaxRetentionBytes`, `TestR2RunFromKey`, `TestValidateWorkersConfig`, `TestValidatePlaylist_Dedup`.
|
||||||
|
- Playout (run-scoped manifest rewrite): `TestRewriteManifestToRun`, `TestRewriteManifestToRun_AlreadyPrefixedUnchanged`, `TestRewriteManifestToRun_HandlesTSAndComments`, `TestRewriteManifestToRun_EmptyRunID`, `TestIsMediaURI`, `TestPublishManifest_RewritesToRunScopedURIs`, `TestPublishManifest_SlateNotRewritten`.
|
||||||
|
- Cloudflare media worker: Vitest unit (`cacheControlForPath`, `contentTypeForPath`, `parseByteRange`) + Miniflare R2 integration (immutable segment, short-TTL manifest, Range `206`, `404`, OPTIONS, method guard).
|
||||||
|
|
||||||
|
## [1.0.13] - 2026-08-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Automatic media normalization on upload**: Chat backend auto-transcodes non-conforming uploads. New `media.NormalizeVideo` wraps ffmpeg with the Linear Void profile (H.264/AAC, 30fps, 2s keyframes, yuv420p, 48kHz stereo), re-verifies output with ffprobe, and writes `*.normalized.mp4`. Media records gain `normalizing` / `normalization_failed` statuses; `POST /api/admin/media/{id}/normalize` re-triggers manually. Admin UI shows a "Normalize" action with status polling.
|
||||||
|
- **R2 segment retention**: Playout `cleanupOldSegments` deletes `hls/r-*/*` objects older than `R2_SEGMENT_RETENTION_HOURS` (default 24h), run hourly + at startup, never touching the live manifest. Exposes `linearvoid_playout_r2_cleanup_objects_total` metric. `.env.example` adds `R2_SEGMENT_RETENTION_HOURS`.
|
||||||
|
- **Playout loop metrics**: `linearvoid_playout_loops_total` (clean completions) and `linearvoid_playout_run_duration_seconds` (last run duration).
|
||||||
|
- **Runtime configuration store**: Thread-safe, JSON-persisted runtime config (`HLS_SEGMENT_DURATION`, `HLS_WINDOW_SIZE`, `MATOMO_URL`, `MATOMO_SITE_ID`) with typed getters/setters + validation, editable via `GET/PUT /api/admin/config`. Playout reads the shared `config.json` as env fallback.
|
||||||
|
- **Matomo analytics**: Viewer pageview snippet in `index.html` driven by the public `GET /api/matomo` endpoint.
|
||||||
|
- **Chat user accounts**: Register/login/logout (`POST /api/auth/register`, `/login`, `/logout`, `GET /api/auth/session`), bcrypt password hashing, `linearvoid_user_session` cookie, and a viewer sign-in UI.
|
||||||
|
- **Private DM history & search**: `GET /api/dm/history` (paginated) and `GET /api/dm/search`, with a DM search UI in the viewer.
|
||||||
|
- **Chat UI polish**: Typing indicator, user-list toggle, media status badges, and an "Add to playlist" action gated to `ready` media.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Playout seamless looping**: `main()` now only resets HLS output when the playlist changed (SHA-256 `playlistRevision`), on crash/slate recovery, or first run; otherwise it continues segment numbering via `-start_number` and prunes stale segments. Eliminates the viewer gap on clean playlist loops.
|
||||||
|
- **Single playout entrypoint**: `start-playout.sh` now runs `/app/playlist-manager -validate` then `exec`s the Go binary, removing the duplicate shell ffmpeg loop. Added a `-validate` mode to `main()`.
|
||||||
|
- **Admin media library**: Shows status badges (`ready`/`needs_normalization`/`normalizing`/`failed`) and only offers "Add to playlist" for `ready` media; playlist entries show per-item status badges.
|
||||||
|
- **Prometheus TSDB retention**: The `prometheus` service now sets `--storage.tsdb.retention.time=30d` and `--storage.tsdb.retention.size=15GB` (in both `docker-compose.yml` and `docker-compose.prod.yml`), so metrics older than 30 days are deleted.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Media: `TestNormalizationArgs`, `TestNormalizeVideo_FFmpegError`.
|
||||||
|
- Handlers: `TestNormalizeMediaRecord_Success`, `TestNormalizeMediaRecord_Failure`, `TestHandleAdminMediaByID_Normalize`.
|
||||||
|
- Playout: `TestCleanupOldSegments`, `TestR2SegmentRetentionHours`, `TestPlaylistFingerprint`, `TestHLSNextStartNumber`, `TestLoadSharedHLSConfig`.
|
||||||
|
- Config: `runtime_test.go` (get/set, persist/reload, env fallback, validation).
|
||||||
|
- Auth: `TestUserSessionStore`.
|
||||||
|
- Handlers: config GET/PUT + validation, DM history/search, user register/login/session.
|
||||||
|
|
||||||
|
## [1.0.12] - 2026-08-11
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Task tracking consolidated**: Removed `DEVELOPMENT.md` and moved all development tasks to `.github/instructions/BACKLOG.md` as the single source of truth. `TODO.md` updated to reference `BACKLOG.md` and `DONE.md`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Playout R2 endpoint validation**: `initR2Client()` now validates all required R2 environment variables (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`) before creating the S3 client. Previously, an empty `R2_ENDPOINT` caused cryptic `Custom endpoint '' was not a valid URI` errors on every segment upload attempt. Playout now gracefully degrades when R2 is not configured — local HLS via nginx continues to work.
|
||||||
|
- **CD workflow missing R2 secrets**: `.gitea/workflows/cd.yml` "Prepare environment" step now injects all R2\_ variables (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, plus admin variants) from Gitea secrets. Previously the generated `.env` was missing R2 entirely, causing the playout container to start with an empty endpoint.
|
||||||
|
- **Deployment docs**: `docs/DEPLOYMENT.md` now includes explicit R2 and Cloudflare variable lists plus a "Gitea Secrets" table documenting all 15 required secrets.
|
||||||
|
|
||||||
|
## [1.0.11] - 2026-08-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Phase 1 test fixtures**: Media and playlist test fixtures in `backend/playout/testdata/` with ffmpeg generation commands documented in test headers.
|
||||||
|
- **Phase 4 R2 integration**: `SyncToR2` now sets `CacheControl` + `ContentType` based on file extension (immutable segments, short-lived manifests). Three integration tests added.
|
||||||
|
- **Phase 2 viewer tests**: Presence updates (join/leave/sorted), private DM flow, message rendering (system/self/other) in `main.test.ts` (27 total tests).
|
||||||
|
- **Phase 3 admin tests**: Playlist management (save via PUT, error handling, sortable rendering) in `admin.test.ts` (31 total tests passing).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Exported `savePlaylist` and `renderPlaylistSortable` from `admin.ts` for testing.
|
||||||
|
|
||||||
|
## [1.0.10] - 2026-08-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **WebSocket summary helper**: Added `scripts/loadtest/summarize-artillery.ps1` to extract counters, rates, and available percentile stats from Artillery JSON output.
|
||||||
|
- **Playbook worksheet mapping**: Added artifact-to-worksheet mapping section in `docs/operations/scaling-performance-playbook.md` for WebSocket and Admin API benchmark artifacts.
|
||||||
|
- **Test fixture generation docs**: Added ffmpeg commands for generating valid and invalid media fixtures as comments in `backend/playout/src/playlist_test.go`. Phase 1 test fixtures now complete.
|
||||||
|
- **R2 cache header support**: `SyncToR2` now sets `CacheControl` and `ContentType` based on file extension:
|
||||||
|
- `.m4s`/`.ts`/`.mp4`/`.m4a` → immutable (max-age=31536000)
|
||||||
|
- `.m3u8` → short-lived (max-age=2s)
|
||||||
|
- unknown → no cache headers
|
||||||
|
- **R2 integration tests**: Added `TestSyncToR2_ManifestCacheHeaders` and `TestSyncToR2_NoCacheHeadersForUnknownExt` verifying cache header behavior for segment, manifest, and unknown file types.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Loadtest runner**: `scripts/loadtest/run-loadtests.ps1` now generates `ws-chat-summary.txt` automatically for `ws` and `all` scenarios.
|
||||||
|
- **Loadtest docs**: `scripts/loadtest/README.md` now documents `summarize-artillery.ps1` usage and the new `ws-chat-summary.txt` artifact.
|
||||||
|
- **Planning status wording**: Updated `docs/DEVELOPMENT.md` and `.github/instructions/TODO.md` to mark ws/admin benchmark scaffolding complete while benchmark execution remains pending.
|
||||||
|
- **Phase 1 complete**: Test fixture creation and ffmpeg documentation marked done.
|
||||||
|
- **Phase 4 partially complete**: R2 upload + cache header validation done; edge cache hit/miss validation remains manual.
|
||||||
|
- **Phase 2 frontend tests**: Added presence updates (join/leave rendering), private DM flow, and message rendering tests in `main.test.ts` (27 total tests passing).
|
||||||
|
- **Phase 3 admin frontend tests**: Added playlist management tests (save via PUT, error handling, sortable rendering) in `admin.test.ts` (31 total tests passing).
|
||||||
|
|
||||||
|
## [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.
|
||||||
|
- **Runtime checks**: Executed normalization edge-case tests (MOV, MKV, VFR, 5.1 input) and concat continuity verification in running playout environment.
|
||||||
|
- **Incident runbook**: Added quarterly disaster recovery drill procedure with prerequisites, outage simulation, restore validation, and evidence template.
|
||||||
|
- **Manual ops checklist**: Added `docs/operations/cloudflare-uptimerobot-checklist.md` to execute and record Cloudflare + UptimeRobot dashboard tasks with evidence capture.
|
||||||
|
- **Scaling playbook**: Added `docs/operations/scaling-performance-playbook.md` with benchmark methods, trigger thresholds, and Phase 2/3 scaling decision flow.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- **Viewer mobile overflow**: Replaced `100vw` with `100%` on full-screen viewer containers to remove horizontal overflow on mobile viewports.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `GET /api/admin/chat-log` endpoint with `?room=`, `?limit=`, `?before=`, `?username=`, `?text=` filter/pagination support.
|
||||||
|
- `GET /api/admin/chat-log/:id` endpoint returning a single chat log entry as JSON.
|
||||||
|
- `DELETE /api/admin/chat-log/:id` endpoint returning 204 No Content on success.
|
||||||
|
- `DELETE /api/admin/chat-log/bulk` endpoint accepting `{"ids": [...]}` for bulk deletion (max 500).
|
||||||
|
- Chat Log admin tab with table, individual delete with confirmation dialog, pagination (prev/next), search/filter by room/username/text, select-all checkbox, and bulk delete button.
|
||||||
|
- Added `"lint": "tsc --noEmit"` script to `frontend/package.json`.
|
||||||
|
- `/api/admin/media/{id}` GET/PUT/DELETE handlers for media metadata retrieval, updates, and file+record deletion.
|
||||||
|
- Resumable chunked admin media upload flow with upload-session endpoints and client progress indicators.
|
||||||
|
- Prometheus-format `/metrics` endpoint for chat runtime and admin-session monitoring.
|
||||||
|
- Dedicated operational documentation: `API.md`, `DEPLOYMENT.md`, `MONITORING.md`, `SECURITY.md`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Split `frontend/admin/index.html` into a template shell plus 9 per-tab page HTML files loaded dynamically via `loadPageContent` / `switchTab` / `bindPageEvents`.
|
||||||
|
- Rebuilt viewer, admin, and admin-login pages around custom semantic class structures with accessibility-focused landmarks, tab semantics, and keyboard-focus behavior.
|
||||||
|
- Replaced legacy and Tailwind-based stylesheet rules with a custom design-token CSS system covering responsive layouts, forms, tables, and reduced-motion behavior.
|
||||||
|
- Switched frontend theme to dark-mode-first token defaults with light-scheme override.
|
||||||
|
- Applied new color palette (`#32292f` dark, `#b4c4ae` light, `#587792` brand, `#2ebfa5` focus, `#04650d` ok) across all 22 CSS custom properties in dark and light themes.
|
||||||
|
- Replaced hardcoded admin sidebar and tab-trigger colors with CSS custom properties.
|
||||||
|
- Optimized CI pipeline: removed duplicate `docker compose build --no-cache` from test job, removed broken playlist validation step, fixed Go module cache path, added Docker Buildx registry cache to docker/release jobs.
|
||||||
|
- Bumped chat healthcheck `start_period` from 10s to 30s in `docker-compose.prod.yml`.
|
||||||
|
- CD workflow now verifies nginx (port 8080) + chat (3000) + playout (8081); dumps container logs for all failing services.
|
||||||
|
- Updated Prometheus scrape targets and Grafana dashboard queries to use live chat metrics.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Go module cache in CI now correctly references only `backend/chat/go.sum` (playout go.sum did not exist); added `go mod download` step to seed cache.
|
||||||
|
- Removed broken playlist validation step (`go run src/playlist.go` without go.mod) from CI test job.
|
||||||
|
- Converted nginx CI build from custom shell `docker buildx` to `docker/build-push-action@v5` for cache consistency.
|
||||||
|
- Enforced 2GB upload ceiling plus server-side media file type/size validation for admin uploads.
|
||||||
|
- Increased nginx `client_max_body_size` to allow resumable admin upload chunks without 413 errors.
|
||||||
|
- Removed unused `data-admin-*` Vite env var attributes from admin HTML template.
|
||||||
|
- Removed Tailwind and Alpine runtime/build dependencies; eliminated Tailwind directives/warnings during production build.
|
||||||
|
|
||||||
## [1.0.3] - 2026-08-03
|
## [1.0.3] - 2026-08-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -46,7 +274,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.
|
||||||
|
|||||||
+120
-5
@@ -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
|
||||||
|
|
||||||
@@ -20,8 +19,8 @@ Thank you for considering contributing to Linear Void!
|
|||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
- Go: Use standard formatting (`go fmt`).
|
- Go: Use standard formatting (`go fmt`).
|
||||||
- TypeScript: ESLint and Prettier.
|
- TypeScript: `tsc --noEmit` for type-checking (see `frontend/package.json`).
|
||||||
- Tailwind: Follow the utility-first approach.
|
- CSS: Design-token system via CSS custom properties in `frontend/src/style.css`. No utility framework.
|
||||||
|
|
||||||
## Pull Requests
|
## Pull Requests
|
||||||
|
|
||||||
@@ -30,4 +29,120 @@ Thank you for considering contributing to Linear Void!
|
|||||||
- Ensure all tests pass and documentation is updated.
|
- Ensure all tests pass and documentation is updated.
|
||||||
- The CI workflow will validate normalization, linting, and Docker builds.
|
- The CI workflow will validate normalization, linting, and Docker builds.
|
||||||
|
|
||||||
|
## Versioning and Releases
|
||||||
|
|
||||||
|
### Version Scheme
|
||||||
|
|
||||||
|
Linear Void follows **Semantic Versioning** ([SemVer](https://semver.org)): `MAJOR.MINOR.PATCH`.
|
||||||
|
|
||||||
|
- **MAJOR** — breaking changes to the viewer, API, or deployment model.
|
||||||
|
- **MINOR** — new features (endpoints, admin tabs, UI capabilities) that are backward-compatible.
|
||||||
|
- **PATCH** — bug fixes, optimizations, documentation polish.
|
||||||
|
|
||||||
|
Git tags use no prefix: `1.0.2`, `1.1.0`, `2.0.0`.
|
||||||
|
|
||||||
|
### Where Versions Are Declared
|
||||||
|
|
||||||
|
| File | Token | Example |
|
||||||
|
| ----------------------- | ------------- | ------------------------- |
|
||||||
|
| `frontend/package.json` | `"version"` | `"1.0.2"` |
|
||||||
|
| `CHANGELOG.md` | `## [X.Y.Z]` | `## [1.0.2] - 2026-08-05` |
|
||||||
|
| `README.md` | Version badge | `version-1.0.2-blue` |
|
||||||
|
|
||||||
|
### Tag Triggers
|
||||||
|
|
||||||
|
| Tag Pattern | Workflow | What Happens |
|
||||||
|
| ---------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `*.*.*` (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 |
|
||||||
|
|
||||||
|
### Full Release Procedure
|
||||||
|
|
||||||
|
#### Step 1: Bump version numbers
|
||||||
|
|
||||||
|
Update these files to the new version (replace `X.Y.Z`):
|
||||||
|
|
||||||
|
- [frontend/package.json](frontend/package.json)
|
||||||
|
- [CHANGELOG.md](CHANGELOG.md) — add new section at top
|
||||||
|
- [README.md](README.md) — version badge
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# frontend/package.json
|
||||||
|
"version": "X.Y.Z"
|
||||||
|
|
||||||
|
# CHANGELOG.md — add new section at top
|
||||||
|
## [X.Y.Z] - YYYY-MM-DD
|
||||||
|
|
||||||
|
# README.md — version badge
|
||||||
|
version-X.Y.Z-blue
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: Write changelog
|
||||||
|
|
||||||
|
Add entries under `## [X.Y.Z]` in `CHANGELOG.md` following [Keep a Changelog](https://keepachangelog.com) format (`Added`, `Changed`, `Fixed`, `Removed`).
|
||||||
|
|
||||||
|
#### Step 3: Commit and tag
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stage all version bumps + changelog
|
||||||
|
git add frontend/package.json CHANGELOG.md README.md
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
git commit -m "chore: bump version to X.Y.Z"
|
||||||
|
|
||||||
|
# Create annotated tag
|
||||||
|
git tag -a X.Y.Z -m "Release X.Y.Z"
|
||||||
|
|
||||||
|
# Push commit and tag
|
||||||
|
git push origin main
|
||||||
|
git push origin X.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4: Verify CI/CD
|
||||||
|
|
||||||
|
- CI workflow (`ci.yml`) runs on push to `main` → builds & pushes `main`/`latest` images.
|
||||||
|
- Release workflow (`release.yml`) triggers on `X.Y.Z` tag → builds images with semver tags (`X.Y.Z`, `X.Y`, `latest`), creates Gitea Release.
|
||||||
|
- CD workflow (`cd.yml`) triggers after CI completes → deploys to production.
|
||||||
|
|
||||||
|
Monitor at: [git.allucanget.biz/zwitschi/linearvoid/actions](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||||
|
|
||||||
|
#### Step 5: Verify release
|
||||||
|
|
||||||
|
Check the Gitea Releases page for the new tag:
|
||||||
|
[git.allucanget.biz/zwitschi/linearvoid/releases](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
|
|
||||||
|
### Manual Release (workflow_dispatch)
|
||||||
|
|
||||||
|
If a tag push fails or a re-release is needed:
|
||||||
|
|
||||||
|
1. Go to [Actions → Release workflow](https://git.allucanget.biz/zwitschi/linearvoid/actions?workflow=release.yml)
|
||||||
|
2. Click "Run workflow"
|
||||||
|
3. Enter version (e.g. `1.0.2`)
|
||||||
|
4. The workflow builds images, pushes semver tags, and creates the Gitea Release
|
||||||
|
|
||||||
|
### Docker Image Tags
|
||||||
|
|
||||||
|
Every release produces these tags in the Gitea Container Registry:
|
||||||
|
|
||||||
|
| Image | Tags |
|
||||||
|
| --------- | ------------------------ |
|
||||||
|
| `playout` | `X.Y.Z`, `X.Y`, `latest` |
|
||||||
|
| `chat` | `X.Y.Z`, `X.Y`, `latest` |
|
||||||
|
| `nginx` | `X.Y.Z`, `X.Y`, `latest` |
|
||||||
|
|
||||||
|
Example for `1.0.2`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
git.allucanget.biz/zwitschi/linearvoid/chat:1.0.2
|
||||||
|
git.allucanget.biz/zwitschi/linearvoid/chat:1.0
|
||||||
|
git.allucanget.biz/zwitschi/linearvoid/chat:latest
|
||||||
|
git.allucanget.biz/zwitschi/linearvoid/playout:1.0.2
|
||||||
|
git.allucanget.biz/zwitschi/linearvoid/playout:1.0
|
||||||
|
git.allucanget.biz/zwitschi/linearvoid/playout:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Moving Completed Tasks
|
||||||
|
|
||||||
|
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!
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Copyright © 2026 linearvoid.tv (Corey Pellizzi, Georg Sinn)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -5,15 +5,17 @@
|
|||||||
**Repository**: [git.allucanget.biz/zwitschi/linearvoid](https://git.allucanget.biz/zwitschi/linearvoid)
|
**Repository**: [git.allucanget.biz/zwitschi/linearvoid](https://git.allucanget.biz/zwitschi/linearvoid)
|
||||||
|
|
||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||||
[](LICENSE)
|
|
||||||
[](https://git.allucanget.biz)
|
[](https://git.allucanget.biz)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
|
[](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 `.m4s` 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.
|
||||||
|
|
||||||
@@ -27,79 +29,216 @@ See [`docs/architecture/adr/`](docs/architecture/adr/) for the full set of decis
|
|||||||
- Video normalization, monitoring, fallback slate, and easy playlist management.
|
- Video normalization, monitoring, fallback slate, and easy playlist management.
|
||||||
- US-centric performance (Ashburn origin + Cloudflare POPs).
|
- US-centric performance (Ashburn origin + Cloudflare POPs).
|
||||||
|
|
||||||
## Project Structure
|
## Architecture Diagram
|
||||||
|
|
||||||
```text
|
```mermaid
|
||||||
linearvoid/
|
flowchart TD
|
||||||
├── frontend/ # Static Single-Page Application (HTML/CSS/JS)
|
V[US Viewers] -->|HTTPS| CF[Cloudflare CDN]
|
||||||
│ ├── src/
|
CF -->|Cache HIT| R2[Cloudflare R2]
|
||||||
│ │ ├── components/ # Video player, chat sidebar, now-playing overlay
|
CF -->|Cache MISS| N[Nginx :80]
|
||||||
│ │ ├── js/ # hls.js integration, WebSocket client, UI logic
|
N -->|/ws| CH[Chat :3000]
|
||||||
│ │ └── styles/ # Tailwind / custom CSS
|
N -->|/admin| CH
|
||||||
│ ├── index.html
|
N -->|/hls| P[Playout :8081]
|
||||||
│ └── package.json # Build tools (Vite recommended)
|
N -->|/*| FE[Frontend SPA]
|
||||||
├── backend/
|
CH --> R[(Redis)]
|
||||||
│ ├── playout/ # Continuous HLS generation from playlist
|
CH --> DB[(PostgreSQL)]
|
||||||
│ │ ├── src/ # FFmpeg orchestration, playlist manager
|
P -->|ffmpeg| HLS[/data/hls/]
|
||||||
│ │ ├── config/
|
P -->|sync| R2
|
||||||
│ │ └── Dockerfile
|
|
||||||
│ ├── chat/ # Real-time WebSocket service
|
|
||||||
│ │ ├── src/ # Rooms, DMs, moderation, presence (Node/Go)
|
|
||||||
│ │ ├── config/
|
|
||||||
│ │ └── Dockerfile
|
|
||||||
│ └── shared/ # Common utilities, types, normalization helpers
|
|
||||||
├── infrastructure/ # Deployment, IaC, Cloudflare configs
|
|
||||||
│ ├── docker-compose.yml # Local/dev orchestration
|
|
||||||
│ ├── nginx/ # Proxy, static serving, HLS delivery
|
|
||||||
│ ├── cloudflare/ # Cache rule examples, Workers
|
|
||||||
│ └── terraform/ # (optional) provisioning
|
|
||||||
├── scripts/ # Automation scripts
|
|
||||||
│ ├── normalize-video.sh # Pre-process MP4s (codec, keyframes, CRF)
|
|
||||||
│ ├── sync-to-r2.sh # Upload segments/manifests to R2
|
|
||||||
│ └── backup.sh
|
|
||||||
├── monitoring/ # Dashboards, alerts, logging
|
|
||||||
│ ├── grafana/
|
|
||||||
│ └── prometheus/
|
|
||||||
├── docs/
|
|
||||||
│ └── architecture/
|
|
||||||
│ ├── README.md # arc42 template (fully populated)
|
|
||||||
│ └── adr/
|
|
||||||
│ ├── ADR-001.md # Consolidated decision record
|
|
||||||
│ ├── ADR-002-module-structure.md
|
|
||||||
│ ├── ADR-003-tech-stack.md
|
|
||||||
│ ├── ADR-004-admin-video.md
|
|
||||||
│ ├── ADR-005-admin-moderation-analytics.md
|
|
||||||
│ ├── ADR-006-data-storage.md
|
|
||||||
│ └── ADR-007-redis-usage.md
|
|
||||||
├── .env.example
|
|
||||||
├── .gitignore
|
|
||||||
├── docker-compose.yml # Top-level for dev (playout + chat + nginx)
|
|
||||||
├── README.md
|
|
||||||
└── LICENSE
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Folder Explanations
|
A detailed description of the Streaming architecture, playout, and delivery is available in [`docs/STREAMING.md`](docs/STREAMING.md).
|
||||||
|
|
||||||
- **`frontend/`**: The viewer experience — minimal, modern SPA with Tailwind + Alpine.js (or vanilla). Uses `hls.js` for playback and a WebSocket client for chat.
|
## Project Structure
|
||||||
- **`backend/playout/`**: Core linear engine. FFmpeg consumes a normalized playlist and outputs continuous HLS. Includes normalization validation.
|
|
||||||
- **`backend/chat/`**: Independent WebSocket service (Node.js/Socket.io or Go). Handles public rooms, private DMs, presence (Redis), persistence (PostgreSQL/SQLite), rate limiting, and moderation.
|
|
||||||
- **`backend/shared/`**: Reusable code (types, utils, video validation).
|
|
||||||
- **`infrastructure/`**: Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
|
||||||
- **`scripts/`**: One-off and CI-friendly scripts for video preparation, R2 sync, backups, and playlist management.
|
|
||||||
- **`monitoring/`**: Grafana dashboards, Prometheus rules, logging configuration.
|
|
||||||
- **`docs/architecture/`**: Complete arc42 documentation + ADRs 001-007 (the single source of truth for all architectural decisions).
|
|
||||||
|
|
||||||
This structure directly reflects the building blocks, runtime flows, deployment view, and cross-cutting concepts defined in the arc42 documentation.
|
This structure directly reflects the building blocks, runtime flows, deployment view, and cross-cutting concepts defined in the arc42 documentation.
|
||||||
|
|
||||||
|
```text
|
||||||
|
linearvoid/
|
||||||
|
├── docs/ # see Documentation section below
|
||||||
|
├── backend/ # see Backend section below
|
||||||
|
├── frontend/ # see Frontend section below
|
||||||
|
├── infrastructure/ # see Infrastructure section below
|
||||||
|
├── monitoring/ # see Monitoring section below
|
||||||
|
├── scripts/ # see Scripts section below
|
||||||
|
├── .editorconfig # EditorConfig for consistent coding styles
|
||||||
|
├── .env.example # Example environment variables for local development
|
||||||
|
├── .gitignore # Ignore node_modules, build artifacts, etc.
|
||||||
|
├── .prettierignore # Ignore files for Prettier formatting
|
||||||
|
├── CHANGELOG.md # Release notes and version history
|
||||||
|
├── CONTRIBUTING.md # Contribution guidelines
|
||||||
|
├── docker-compose.prod.yml # Production deployment (Hetzner + Cloudflare)
|
||||||
|
├── docker-compose.yml # Top-level for dev (playout + chat + nginx)
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `docs/`: Folder containing all documentation for the project, including architecture, ADRs, operational procedures, and setup guides.
|
||||||
|
- `docs/architecture/`: Architecture documentation, including high-level system architecture, component interactions, and decision records (ADRs).
|
||||||
|
|
||||||
|
see [README.md](docs/README.md) for full architecture, ADRs, and operational documentation.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- `backend/playout/`: Core linear engine. FFmpeg consumes a normalized playlist and outputs continuous HLS. Includes normalization validation.
|
||||||
|
- `backend/chat/`: Independent WebSocket service (Node.js/Socket.io or Go). Handles public rooms, private DMs, presence (Redis), persistence (PostgreSQL/SQLite), rate limiting, and moderation.
|
||||||
|
- `backend/shared/`: Reusable code (types, utils, video validation).
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
├── chat/ # WebSocket chat service (Node.js or Go)
|
||||||
|
│ ├── src/ # Chat service source code (rooms, DMs, moderation)
|
||||||
|
| | ├── auth/ # Admin authentication and session management
|
||||||
|
| | | ├── auth_test.go # Unit tests for authentication
|
||||||
|
| | | └── auth.go # Authentication logic (login, session validation)
|
||||||
|
| | ├── config/ # Configuration files for chat service
|
||||||
|
| | | ├── config_test.go # Unit tests for configuration
|
||||||
|
| | | └── config.go # Configuration loading and validation
|
||||||
|
| | ├── db/ # Database access and models
|
||||||
|
| | | ├── db_test.go # Unit tests for database access
|
||||||
|
| | | ├── db.go # Database connection and query logic
|
||||||
|
| | | ├── models.go # Database models (users, messages, rooms)
|
||||||
|
| | | └── redis.go # Redis connection and presence management
|
||||||
|
| | ├── handlers/ # WebSocket message handlers
|
||||||
|
| | | ├── handlers_test.go # Unit tests for WebSocket message handlers
|
||||||
|
| | | └── handlers.go # WebSocket message handling logic
|
||||||
|
| | ├── media/ # Media-related utilities (e.g., video validation)
|
||||||
|
| | | ├── media_test.go # Unit tests for media-related utilities
|
||||||
|
| | | └── media.go # Media-related utility functions
|
||||||
|
| | ├── metrics/ # Prometheus metrics endpoints
|
||||||
|
| | | ├── metrics_test.go # Unit tests for Prometheus metrics
|
||||||
|
| | | └── metrics.go # Prometheus metrics collection and exposition
|
||||||
|
| | ├── ratelimit/ # Rate limiting logic (Redis-based)
|
||||||
|
| | | ├── ratelimit_test.go # Unit tests for rate limiting
|
||||||
|
| | | └── ratelimit.go # Rate limiting logic using Redis
|
||||||
|
| | ├── ws/ # WebSocket handlers
|
||||||
|
| | | ├── ws_test.go # Unit tests for WebSocket handlers
|
||||||
|
| | | └── ws.go # WebSocket connection and message handling
|
||||||
|
| | └── main.go # Entry point for chat service
|
||||||
|
| ├── Dockerfile # Dockerfile for chat service
|
||||||
|
| ├── go.mod # Go module file for chat service
|
||||||
|
| └── go.sum # Go checksum file for chat service
|
||||||
|
└── playout/ # Continuous HLS generation from playlist (Go)
|
||||||
|
├── demodata/ # Sample media and playlist files for testing
|
||||||
|
| ├── media/ # Sample media files for testing
|
||||||
|
| └── playlist/ # Sample playlist files for testing
|
||||||
|
├── src/ # Playout service source code (FFmpeg orchestration)
|
||||||
|
| ├── go.mod # Go module file for playout service
|
||||||
|
| ├── go.sum # Go checksum file for playout service
|
||||||
|
| ├── playlist_test.go # Unit tests for playlist management
|
||||||
|
| └── playlist.go # Playlist management logic
|
||||||
|
└── testdata/ # Test data for playout service (sample playlists, media)
|
||||||
|
| ├── comments_only.txt # Sample playlist with comments only
|
||||||
|
| ├── empty_playlist.txt # Sample empty playlist
|
||||||
|
| ├── invalid_24fps.mp4 # Sample invalid 24fps video for testing
|
||||||
|
| ├── invalid_h265.mp4 # Sample invalid H.265 video for testing
|
||||||
|
| ├── mixed_valid_invalid.txt # Sample playlist with mixed valid and invalid entries
|
||||||
|
| ├── valid_720p30.mp4 # Sample valid 720p30 video for testing
|
||||||
|
| └── valid_playlist.txt # Sample valid playlist for testing
|
||||||
|
├── Dockerfile # Dockerfile for the playout service
|
||||||
|
└── start-playout.sh # Script to start the playout service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
The viewer experience — minimal, modern SPA with custom CSS + vanilla JS. Uses `hls.js` for playback and a WebSocket client for chat.
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/
|
||||||
|
├── admin/ # Admin SPA (playlist management, moderation, analytics)
|
||||||
|
│ ├── login/ # Login page for admin authentication
|
||||||
|
| | └── index.html # Admin login page
|
||||||
|
| └── index.html # Admin SPA
|
||||||
|
├── public/ # Static assets (favicon, images, etc.)
|
||||||
|
│ ├── admin/ # Admin static assets
|
||||||
|
| | ├── analytics.html # Analytics dashboard page
|
||||||
|
| | ├── chat-log.html # Chat log viewer page
|
||||||
|
| | ├── dashboard.html # Admin dashboard page
|
||||||
|
| | ├── moderation.html # User moderation page
|
||||||
|
| | ├── playlist.html # Playlist management page
|
||||||
|
| | ├── schedule.html # Schedule overrides page
|
||||||
|
| | ├── system.html # System configuration page
|
||||||
|
| | ├── upload.html # Video upload page
|
||||||
|
| | └── users.html # User management page
|
||||||
|
│ ├── favicon.ico # Favicon for the SPA
|
||||||
|
| └── icons.svg # SVG icons for the SPA
|
||||||
|
├── src/ # Source code for the SPA
|
||||||
|
│ ├── assets/ # Images, fonts, and other static assets
|
||||||
|
| | ├── hero.png # Hero image for the SPA
|
||||||
|
| | ├── typescript.svg # TypeScript logo for the SPA
|
||||||
|
| | └── vite.svg # Vite logo for the SPA
|
||||||
|
│ ├── admin.test.ts # Admin frontend tests (Vitest)
|
||||||
|
│ ├── admin.ts # Admin SPA logic (playlist, moderation, analytics)
|
||||||
|
│ ├── counter.ts # Counter component for testing
|
||||||
|
│ ├── main.test.ts # Main frontend tests (Vitest)
|
||||||
|
│ ├── main.ts # Main SPA logic (hls.js player, WebSocket chat)
|
||||||
|
│ └── style.css # Custom CSS for the SPA
|
||||||
|
├── .gitignore # Ignore node_modules, build artifacts, etc.
|
||||||
|
├── index.html # Main SPA entry point
|
||||||
|
├── package-lock.json # Lockfile for npm dependencies
|
||||||
|
├── package.json # Build tools (Vite recommended)
|
||||||
|
├── tsconfig.json # TypeScript configuration
|
||||||
|
├── vite.config.ts # Vite configuration
|
||||||
|
└── vitest.config.ts # Vitest configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
||||||
|
|
||||||
|
- Cloudflare
|
||||||
|
- [linearvoid-media worker](infrastructure/cloudflare/) — R2-bound edge Worker serving HLS on `media.linearvoid.tv` (immutable segment caching, CORS, Range)
|
||||||
|
- [linearvoid-frontend worker](frontend/wrangler.toml) — serves the static SPA on `linearvoid.tv`
|
||||||
|
- Nginx
|
||||||
|
- [Dockerfile](infrastructure/nginx/Dockerfile) — builds Nginx with custom config
|
||||||
|
- [nginx.conf](infrastructure/nginx/nginx.conf) — reverse proxy, static SPA, HLS delivery
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
Grafana dashboards, Prometheus rules, logging configuration.
|
||||||
|
|
||||||
|
- Grafana
|
||||||
|
- dashboards
|
||||||
|
- [dashboard.json](monitoring//grafana/dashboards/dashboard.json) — operational dashboards for playout, chat, and Redis
|
||||||
|
- [dashboard.yml](monitoring//grafana/dashboards/dashboard.yml) — operational dashboards for playout, chat, and Redis
|
||||||
|
- datasources
|
||||||
|
- [datasource.yml](monitoring/grafana/datasources/datasource.yml) — Prometheus datasource configuration
|
||||||
|
- [dashboard.json](monitoring/grafana/dashboard.json) — operational dashboards for playout, chat, and Redis
|
||||||
|
- Prometheus
|
||||||
|
- [alerts.yml](monitoring/prometheus/alerts.yml) — alerting rules for playout, chat, Redis, and system health
|
||||||
|
- [prometheus.yml](monitoring/prometheus/prometheus.yml) — scrape targets for playout, chat, Redis, and system metrics
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
One-off and CI-friendly scripts for video preparation, R2 sync, backups, and playlist management.
|
||||||
|
|
||||||
|
- loadtest/
|
||||||
|
- [admin-api.js](scripts/loadtest/admin-api.js) — admin API load test script
|
||||||
|
- [run-loadtests.sh](scripts/loadtest/run-loadtests.sh) — bash script to run ws and/or admin load tests
|
||||||
|
- [summarize-artillery.sh](scripts/loadtest/summarize-artillery.sh) — summarize Artillery JSON results
|
||||||
|
- [summarize-k6.sh](scripts/loadtest/summarize-k6.sh) — summarize k6 JSON results
|
||||||
|
- [ws-chat.yml](scripts/loadtest/ws-chat.yml) — WebSocket chat load test configuration
|
||||||
|
|
||||||
|
PowerShell equivalents (`*.ps1`) of the three load-test scripts are kept alongside them.
|
||||||
|
|
||||||
## 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
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
# Edit .env with your R2 credentials and domain settings
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
- **Viewer**: [https://linearvoid.tv](https://linearvoid.tv) (or `http://localhost`)
|
||||||
|
- **Admin**: [https://linearvoid.tv/admin](https://linearvoid.tv/admin) (or `http://localhost/admin`)
|
||||||
|
- **Metrics**: [https://linearvoid.tv/metrics](https://linearvoid.tv/metrics)
|
||||||
|
|
||||||
Full setup guide: [`SETUP.md`](SETUP.md).
|
Full setup guide: [`SETUP.md`](SETUP.md).
|
||||||
|
|
||||||
See the [architecture docs](docs/architecture/README.md) for detailed architecture and ADRs.
|
See the [architecture docs](docs/architecture/README.md) for detailed architecture and ADRs.
|
||||||
@@ -107,10 +246,55 @@ 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.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ---------------------- | ----------------------------------------------- | -------------------------------- |
|
||||||
|
| `R2_ACCESS_KEY_ID` | — | Cloudflare R2 access key |
|
||||||
|
| `R2_SECRET_ACCESS_KEY` | — | Cloudflare R2 secret key |
|
||||||
|
| `R2_BUCKET_NAME` | `linearvoid-media` | R2 bucket for HLS segments |
|
||||||
|
| `DATABASE_URL` | `postgres://...@db:5432/...` | Chat DB (Postgres or SQLite) |
|
||||||
|
| `REDIS_URL` | `redis://redis:6379/0` | Redis for presence/rate limiting |
|
||||||
|
| `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` | Public viewer URL |
|
||||||
|
| `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` | WebSocket endpoint |
|
||||||
|
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` | HLS playlist URL |
|
||||||
|
| `ADMIN_USERNAME` | `admin` | Admin panel username |
|
||||||
|
| `ADMIN_PASSWORD` | — | Admin panel password |
|
||||||
|
| `HLS_SEGMENT_DURATION` | `2` | HLS segment duration in seconds |
|
||||||
|
| `HLS_WINDOW_SIZE` | `12` | HLS playlist window size |
|
||||||
|
|
||||||
|
Full reference: [`.env.example`](.env.example).
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Check |
|
||||||
|
| ------------------------ | ------------------------------------------------------------------------------------- |
|
||||||
|
| **Playout not starting** | Verify playlist exists in `data/playlist/playlist.txt` and media files are accessible |
|
||||||
|
| **HLS not playing** | Check `docker compose logs playout` for ffmpeg errors; verify `data/hls/` permissions |
|
||||||
|
| **Chat not connecting** | Ensure Redis is running: `docker compose logs redis`; check `DATABASE_URL` |
|
||||||
|
| **Admin panel 401** | Verify `ADMIN_USERNAME`/`ADMIN_PASSWORD` in `.env` match login credentials |
|
||||||
|
| **Docker build fails** | Run `docker compose build --no-cache`; check `backend/playout/.dockerignore` |
|
||||||
|
| **R2 upload failures** | Verify `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY`; check bucket CORS settings |
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Service | Endpoint | Description |
|
||||||
|
| ----------- | ------------------------ | -------------------------- |
|
||||||
|
| **Chat** | `GET /health` | Health check |
|
||||||
|
| **Chat** | `GET /metrics` | Prometheus metrics |
|
||||||
|
| **Chat** | `GET /ws` | WebSocket upgrade for chat |
|
||||||
|
| **Chat** | `GET /api/admin/*` | Admin API (auth required) |
|
||||||
|
| **Chat** | `POST /api/admin/login` | Admin login |
|
||||||
|
| **Playout** | `GET /health` | Health check (port 8081) |
|
||||||
|
| **Nginx** | `GET /` | Viewer SPA |
|
||||||
|
| **Nginx** | `GET /admin/` | Admin panel |
|
||||||
|
| **Nginx** | `GET /hls/playlist.m3u8` | HLS manifest |
|
||||||
|
|
||||||
## Architecture Documentation
|
## Architecture Documentation
|
||||||
|
|
||||||
The architecture is fully documented using the [arc42 template](docs/architecture/README.md) (with populated sections, Mermaid context and building-block diagrams, deployment view, cross-cutting concepts, quality goals, risks, and glossary).
|
The architecture is fully documented using the [arc42 template](docs/architecture/README.md) (with populated sections, Mermaid context and building-block diagrams, deployment view, cross-cutting concepts, quality goals, risks, and glossary).
|
||||||
@@ -133,7 +317,12 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
|
|||||||
|
|
||||||
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
||||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
|
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
|
||||||
- [SETUP.md](SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
- [SETUP.md](docs/SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
||||||
|
- [API.md](docs/API.md) - Implemented WebSocket and REST API reference.
|
||||||
|
- [DEPLOYMENT.md](docs/DEPLOYMENT.md) - Production deployment and operations guide.
|
||||||
|
- [MONITORING.md](docs/MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
||||||
|
- [SECURITY.md](docs/SECURITY.md) - Security controls, hardening checklist, and incident baseline.
|
||||||
|
- [Scaling Playbook](docs/operations/scaling-performance-playbook.md) - Performance test method, thresholds, and scaling decision flow.
|
||||||
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
||||||
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
||||||
|
|
||||||
@@ -141,4 +330,17 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
|
|||||||
|
|
||||||
**Container Registry**: `git.allucanget.biz/zwitschi/linearvoid/<service>:latest`
|
**Container Registry**: `git.allucanget.biz/zwitschi/linearvoid/<service>:latest`
|
||||||
|
|
||||||
The project is ready for use. See [`SETUP.md`](SETUP.md) for detailed installation instructions.
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines. Quick overview:
|
||||||
|
|
||||||
|
1. Read the [architecture docs](docs/architecture/README.md) and ADRs first.
|
||||||
|
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).
|
||||||
|
4. CI validates builds and tests automatically.
|
||||||
|
|
||||||
|
Since this is 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 use semantic version tags in `*.*.*` format (for example `1.0.9`) and are automatically built/pushed to the container registry. Version numbers are declared in `frontend/package.json`, `CHANGELOG.md`, and the version badge in this README.
|
||||||
|
|
||||||
|
[MIT License](LICENSE)
|
||||||
|
|
||||||
|
The project is ready for use. See [`SETUP.md`](docs/SETUP.md) for detailed installation instructions.
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
# Linear Void Setup Guide
|
|
||||||
|
|
||||||
This guide covers prerequisites, environment setup, running the application locally and in production, and troubleshooting. It references the [architecture documentation](./docs/architecture/README.md)(`docs/architecture/README.md`).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- **Docker & Docker Compose**: Required for running the full stack (playout, chat, nginx, redis, db, prometheus, grafana). Install from [docker.com](https://www.docker.com).
|
|
||||||
- **Node.js 20+**: Required for frontend development and production builds (`cd frontend && npm install && npm run build`).
|
|
||||||
- **npm**: Installed with Node.js and used for the frontend dependency installation and Vite workflow.
|
|
||||||
- **Go 1.23+**: Recommended when working on the Go services outside Docker (`backend/chat`, `backend/playout`).
|
|
||||||
- **FFmpeg**: Used by the playout service for HLS generation. Install via `apt install ffmpeg` (Linux), Homebrew (macOS), or Winget (Windows).
|
|
||||||
- **Git**: For cloning and version control.
|
|
||||||
- **Cloudflare Account**: For R2 storage and CDN (free tier sufficient for most use cases).
|
|
||||||
- **Gitea Account**: Required if you want to push images to the self-hosted registry or use the repository's CI/CD flow.
|
|
||||||
- Set up the required Actions secrets in Gitea at: [https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets](https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets)
|
|
||||||
- Add the following secrets:
|
|
||||||
- `CI_REGISTRY_USER`: Your Gitea username (e.g., `zwitschi`)
|
|
||||||
- `CI_REGISTRY_TOKEN`: A Gitea Personal Access Token with `write:packages` scope
|
|
||||||
- `ADMIN_USERNAME`: The administrator username for the production admin panel (e.g., `admin`)
|
|
||||||
- `ADMIN_PASSWORD`: A strong password for the production admin panel
|
|
||||||
- `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).
|
|
||||||
|
|
||||||
## Development Prerequisites
|
|
||||||
|
|
||||||
- **Frontend dependencies**: Run `cd frontend && npm install` before `npm run dev` or `npm run build`.
|
|
||||||
- **Docker build support**: Local development expects Docker to be able to build the `chat`, `playout`, and `nginx` images referenced in `docker-compose.yml`.
|
|
||||||
- **Data directories**: Create `data/playlist`, `data/media`, `data/hls`, and `data/chat` before the first run so bind mounts resolve cleanly.
|
|
||||||
- **Database choice**: The stack defaults to PostgreSQL in `docker-compose.yml`. If you want SQLite for local-only development, update `DATABASE_URL` in `.env` accordingly.
|
|
||||||
- **Redis availability**: Chat presence, rate limiting, and pub/sub require Redis. The compose stack provides it automatically.
|
|
||||||
- **Video inputs**: Source videos should be normalized before playout. If the playlist contains invalid files, the playout container will fail its validation flow.
|
|
||||||
|
|
||||||
## Environment Setup
|
|
||||||
|
|
||||||
1. **Clone the Repository** (if not already done):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
|
||||||
cd linearvoid
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Copy Environment File**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Configure `.env`** (edit with your values):
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Cloudflare R2 (required for video storage and $0 egress)
|
|
||||||
R2_ACCESS_KEY_ID=your-r2-access-key
|
|
||||||
R2_SECRET_ACCESS_KEY=your-r2-secret-key
|
|
||||||
R2_BUCKET_NAME=linearvoid-media
|
|
||||||
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
|
||||||
R2_REGION=auto
|
|
||||||
|
|
||||||
# Cloudflare (for custom domain and cache rules)
|
|
||||||
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
|
||||||
CLOUDFLARE_ACCOUNT_ID=your-account-id
|
|
||||||
MEDIA_DOMAIN=media.linearvoid.tv
|
|
||||||
|
|
||||||
# Public frontend URLs
|
|
||||||
# Preferred local setup uses the real public domains through Cloudflare and Nginx Proxy Manager.
|
|
||||||
VITE_PUBLIC_APP_URL=https://linearvoid.tv
|
|
||||||
VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
|
|
||||||
VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8
|
|
||||||
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=admin
|
|
||||||
VITE_PUBLIC_ADMIN_DEFAULT_ROOM=main
|
|
||||||
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_PASSWORD=change-me
|
|
||||||
ADMIN_SESSION_TTL_HOURS=12
|
|
||||||
# Optional direct-local fallback when bypassing the public proxy:
|
|
||||||
# VITE_PUBLIC_APP_URL=http://localhost
|
|
||||||
# VITE_PUBLIC_WS_URL=ws://localhost/ws
|
|
||||||
# VITE_PUBLIC_HLS_URL=http://localhost/hls/playlist.m3u8
|
|
||||||
|
|
||||||
# Gitea Container Registry (for self-hosted Docker images)
|
|
||||||
CI_REGISTRY_URL=git.allucanget.biz
|
|
||||||
CI_REGISTRY_USER=your-gitea-username
|
|
||||||
CI_REGISTRY_TOKEN=your-gitea-token
|
|
||||||
CI_REGISTRY_NAMESPACE=zwitschi/linearvoid
|
|
||||||
# Gitea API access (optional, for CI/release automation)
|
|
||||||
CI_SERVER_URL=https://git.allucanget.biz
|
|
||||||
|
|
||||||
# Chat / Database
|
|
||||||
DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
|
|
||||||
# or for SQLite-only local development:
|
|
||||||
# DATABASE_URL=sqlite://./data/chat.db
|
|
||||||
REDIS_URL=redis://redis:6379/0
|
|
||||||
|
|
||||||
# Ports (for local development)
|
|
||||||
PLAYOUT_PORT=8080
|
|
||||||
CHAT_PORT=3000
|
|
||||||
NGINX_PORT=80
|
|
||||||
|
|
||||||
# Playout Configuration
|
|
||||||
PLAYLIST_PATH=/app/playlist.txt
|
|
||||||
HLS_SEGMENT_DURATION=6
|
|
||||||
HLS_WINDOW_SIZE=24
|
|
||||||
|
|
||||||
# Logging / Monitoring
|
|
||||||
LOG_LEVEL=info
|
|
||||||
PROMETHEUS_PORT=9090
|
|
||||||
GRAFANA_PORT=3001
|
|
||||||
|
|
||||||
# Development flags
|
|
||||||
NODE_ENV=development
|
|
||||||
GO_ENV=development
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Check local value overrides for Docker Compose**:
|
|
||||||
- `docker-compose.yml` builds images locally and maps the frontend build output from `frontend/dist` into nginx.
|
|
||||||
- `docker-compose.prod.yml` pulls prebuilt images from the Gitea registry.
|
|
||||||
- Internal container-to-container URLs should use compose hostnames, for example `REDIS_URL=redis://redis:6379/0` and `DATABASE_URL=postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`.
|
|
||||||
- For production public routing, set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
|
|
||||||
|
|
||||||
5. **Create Data Directories**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p data/playlist data/media data/hls data/chat
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Prepare Sample Videos** (optional):
|
|
||||||
- Place normalized MP4 files in `data/media/`.
|
|
||||||
- Create a `data/playlist/playlist.txt` with paths (see ADR-001 for normalization requirements).
|
|
||||||
|
|
||||||
## Cloudflare R2 Setup
|
|
||||||
|
|
||||||
1. **Create an R2 bucket**:
|
|
||||||
- Create a bucket such as `linearvoid-media` in Cloudflare R2.
|
|
||||||
- Keep the bucket name aligned with `R2_BUCKET_NAME` in `.env`.
|
|
||||||
|
|
||||||
2. **Generate R2 API credentials**:
|
|
||||||
- Create an access key pair with read/write permissions for the bucket.
|
|
||||||
- Set `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_REGION=auto` in `.env`.
|
|
||||||
|
|
||||||
3. **Find the S3 endpoint**:
|
|
||||||
- Use the account-scoped endpoint format `https://<account-id>.r2.cloudflarestorage.com`.
|
|
||||||
- Store the account ID in both `R2_ENDPOINT` and `CLOUDFLARE_ACCOUNT_ID` where applicable.
|
|
||||||
|
|
||||||
4. **Bind a custom media domain**:
|
|
||||||
- Attach a domain such as `media.linearvoid.tv` to the bucket.
|
|
||||||
- Set `MEDIA_DOMAIN` to that hostname.
|
|
||||||
|
|
||||||
5. **Configure app-domain DNS and TLS**:
|
|
||||||
- Point `linearvoid.tv` to the public entrypoint serving nginx.
|
|
||||||
- Point `www.linearvoid.tv` to the same target and redirect it to `https://linearvoid.tv`.
|
|
||||||
- Keep `media.linearvoid.tv` on the R2 custom domain path so HLS delivery stays split from the app origin.
|
|
||||||
- Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`.
|
|
||||||
|
|
||||||
6. **Apply cache behavior**:
|
|
||||||
- Configure the `.ts` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
|
||||||
- Enable Smart Tiered Cache for the R2-backed hostname.
|
|
||||||
- If you need header normalization for immutable segment caching, deploy `infrastructure/cloudflare/worker.js` to the same zone.
|
|
||||||
|
|
||||||
7. **Verify access**:
|
|
||||||
- Confirm uploads with an S3-compatible client such as `aws s3 ls --endpoint-url <R2_ENDPOINT>`.
|
|
||||||
- After deployment, check Cloudflare analytics for cache hit ratio and origin request volume.
|
|
||||||
|
|
||||||
## Gitea Setup
|
|
||||||
|
|
||||||
1. **Create or verify a Gitea user**:
|
|
||||||
- The user should have access to the repository and permission to read from the container registry.
|
|
||||||
|
|
||||||
2. **Create an access token**:
|
|
||||||
- Generate a token with the scopes needed for package/registry access and, if applicable, repository automation.
|
|
||||||
- Store it as `CI_REGISTRY_TOKEN` in `.env`.
|
|
||||||
|
|
||||||
3. **Configure Gitea Actions Secrets**:
|
|
||||||
- Go to the repository settings page: [https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets](https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets)
|
|
||||||
- Add the following secrets to enable automated CI/CD and production deployment:
|
|
||||||
- `CI_REGISTRY_USER`: Your Gitea username (e.g., `zwitschi`).
|
|
||||||
- `CI_REGISTRY_TOKEN`: Your Gitea Personal Access Token with `write:packages` scope.
|
|
||||||
- `ADMIN_USERNAME`: The administrator username for the production admin panel (e.g., `admin`).
|
|
||||||
- `ADMIN_PASSWORD`: A strong password for the production admin panel.
|
|
||||||
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`).
|
|
||||||
|
|
||||||
4. **Configure registry variables**:
|
|
||||||
- Set `CI_REGISTRY_URL` to the Gitea host.
|
|
||||||
- Set `CI_REGISTRY_NAMESPACE` to the image namespace, for example `zwitschi/linearvoid`.
|
|
||||||
- Set `CI_REGISTRY_USER` to the account that will push or pull images.
|
|
||||||
|
|
||||||
5. **Authenticate Docker to the registry**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker login git.allucanget.biz
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Build and publish images when needed**:
|
|
||||||
- Local development uses `docker-compose.yml`, which builds from source.
|
|
||||||
- Production uses `docker-compose.prod.yml`, which expects the `playout`, `chat`, and `nginx` images to exist in the configured Gitea registry namespace.
|
|
||||||
|
|
||||||
7. **Validate image availability**:
|
|
||||||
- Before a production rollout, verify that the expected `:latest` tags can be pulled from the registry on the target server.
|
|
||||||
|
|
||||||
## Running the Application
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
|
|
||||||
1. **Start All Services**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Access the Application**:
|
|
||||||
- Preferred local access with the documented proxy setup: <https://linearvoid.tv>
|
|
||||||
- Admin Interface: <https://linearvoid.tv/admin> (redirects to `/admin/login/` until authenticated)
|
|
||||||
- Chat WebSocket: `wss://linearvoid.tv/ws`
|
|
||||||
- Direct-local fallback when bypassing Cloudflare/Nginx Proxy Manager: <http://localhost>, <http://localhost/admin>, and `ws://localhost/ws`
|
|
||||||
- Health Checks:
|
|
||||||
- Playout: <http://localhost:8081/health>
|
|
||||||
- Chat: <http://localhost:3000/health>
|
|
||||||
- Monitoring:
|
|
||||||
- Prometheus: <http://localhost:9090>
|
|
||||||
- Grafana: <http://localhost:3001> (default login: admin/admin)
|
|
||||||
|
|
||||||
3. **Frontend Development** (optional, for UI changes):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The dev server runs on <http://localhost:5173>. It proxies API calls to the backend services.
|
|
||||||
|
|
||||||
4. **Build frontend assets for nginx-backed local runs**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
The local nginx container serves `frontend/dist`, so rebuilding the frontend is required before validating production-like static output.
|
|
||||||
|
|
||||||
5. **Stop Services**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Local Test Environment Deployment (Remote Docker Host)
|
|
||||||
|
|
||||||
For testing deployments on a local network server without setting up a full production VPS, you can deploy directly to a remote Docker host (e.g., a home lab server or test machine) using Docker's TCP socket.
|
|
||||||
|
|
||||||
#### Test Environment Prerequisites
|
|
||||||
|
|
||||||
- A remote server running Docker at IP `192.168.88.40` (running Alpine Linux).
|
|
||||||
- Docker TCP socket enabled and listening on port `2375` on the remote server.
|
|
||||||
|
|
||||||
##### Enabling Docker TCP Socket on Alpine Linux
|
|
||||||
|
|
||||||
To allow the Gitea Actions runner (or your local machine) to deploy to the remote Alpine host, you must configure the Docker daemon to listen on the TCP socket:
|
|
||||||
|
|
||||||
1. Edit `/etc/conf.d/docker` on the Alpine host:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
vi /etc/conf.d/docker
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Update the `DOCKER_OPTS` line to include both the unix socket and the TCP socket:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Restart the Docker service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rc-service docker restart
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Verify that Docker is listening on port `2375`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
netstat -tlnp | grep 2375
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Ensure any firewall (like `awall` or `iptables`) on the Alpine host allows incoming traffic on port `2375` from your local network subnet (e.g., `192.168.88.0/24`).
|
|
||||||
|
|
||||||
#### Deployment Steps
|
|
||||||
|
|
||||||
1. **Configure Docker CLI to use the Remote Host**:
|
|
||||||
You can point your local Docker CLI to the remote server by setting the `DOCKER_HOST` environment variable:
|
|
||||||
- **PowerShell (Windows)**:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:DOCKER_HOST="tcp://192.168.88.40:2375"
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Bash (Linux/macOS)**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export DOCKER_HOST="tcp://192.168.88.40:2375"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Verify Connection**:
|
|
||||||
Run `docker info` to confirm your local CLI is communicating with the remote server at `192.168.88.40`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker info
|
|
||||||
```
|
|
||||||
|
|
||||||
The output should show the remote server's hostname and system details.
|
|
||||||
|
|
||||||
3. **Deploy the Stack**:
|
|
||||||
With `DOCKER_HOST` set, any `docker` or `docker compose` commands you run locally will execute directly on the remote server.
|
|
||||||
- **Deploy from Registry (Production-like)**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.prod.yml pull
|
|
||||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Build and Deploy from Source**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Access the Test Environment**:
|
|
||||||
- Playout Health: `http://192.168.88.40:8081/health`
|
|
||||||
- Chat Health: `http://192.168.88.40:3000/health`
|
|
||||||
- Nginx Entrypoint: `http://192.168.88.40:8080` (or the port mapped to `NGINX_PORT` in `.env`)
|
|
||||||
|
|
||||||
5. **Reset to Local Docker Host**:
|
|
||||||
Once testing is complete, clear the `DOCKER_HOST` variable to point back to your local machine:
|
|
||||||
- **PowerShell**:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Remove-Item env:DOCKER_HOST
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Bash**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
unset DOCKER_HOST
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Setup Guide
|
|
||||||
|
|
||||||
1. **Prepare the target host**:
|
|
||||||
- Provision a Linux VPS with Docker Engine, Docker Compose, Git, and enough disk for media, HLS output, PostgreSQL, Redis persistence, and monitoring data.
|
|
||||||
- Open the ports required for HTTP/HTTPS and any restricted monitoring access you intend to expose.
|
|
||||||
|
|
||||||
2. **Clone the repository and create the environment file**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
|
||||||
cd linearvoid
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Set production-safe `.env` values**:
|
|
||||||
- Replace placeholder secrets.
|
|
||||||
- Use compose-network hostnames for internal services, for example `redis` and `db`.
|
|
||||||
- Set strong values for `POSTGRES_PASSWORD` and `GRAFANA_PASSWORD`.
|
|
||||||
- Keep `MEDIA_DOMAIN`, R2 credentials, and Gitea registry values consistent with the external infrastructure.
|
|
||||||
- Set `MEDIA_DOMAIN=media.linearvoid.tv`.
|
|
||||||
- Set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
|
|
||||||
- If you intentionally bypass the public proxy for local-only testing, switch those values to `http://localhost`, `ws://localhost/ws`, and `http://localhost/hls/playlist.m3u8`.
|
|
||||||
|
|
||||||
4. **Create runtime directories on the server**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p data/playlist data/media data/hls data/chat
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Publish or verify required images in Gitea**:
|
|
||||||
- Ensure the `playout`, `chat`, and `nginx` images exist under `${CI_REGISTRY_URL}/${CI_REGISTRY_NAMESPACE}`.
|
|
||||||
- Log in to the registry from the VPS before pulling.
|
|
||||||
|
|
||||||
6. **Deploy the production stack**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.prod.yml pull
|
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Run post-deploy verification**:
|
|
||||||
- Check `docker compose -f docker-compose.prod.yml ps`.
|
|
||||||
- Confirm nginx, chat, and playout health endpoints respond.
|
|
||||||
- Verify that HLS output is being written to `data/hls` and that the media domain serves manifests and segments through Cloudflare.
|
|
||||||
|
|
||||||
8. **Harden and operate the deployment**:
|
|
||||||
- Put HTTPS in front of nginx using Cloudflare or another reverse proxy, with `linearvoid.tv` as canonical app host and `www.linearvoid.tv` redirected.
|
|
||||||
- Restrict Grafana and Prometheus exposure to trusted IPs or a private network.
|
|
||||||
- Run the stack under systemd or another service manager if you need auto-start behavior outside Docker restart policies.
|
|
||||||
|
|
||||||
### Production Deployment (Hetzner VPS + Cloudflare)
|
|
||||||
|
|
||||||
1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001).
|
|
||||||
2. **Configure Cloudflare**:
|
|
||||||
- Create R2 bucket with `enam` location hint.
|
|
||||||
- Set up custom media domain (`media.linearvoid.tv`) with the cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
|
||||||
- Point `linearvoid.tv` and `www.linearvoid.tv` at the app entrypoint, then redirect `www` to the apex domain.
|
|
||||||
- Use Full (strict) TLS for both app and media hostnames.
|
|
||||||
- Apply the Worker from `infrastructure/cloudflare/worker.js` for advanced header management.
|
|
||||||
3. **Deploy**:
|
|
||||||
- Copy the project to the VPS.
|
|
||||||
- Use production compose: `docker compose -f docker-compose.prod.yml up -d`
|
|
||||||
- Or pull from Gitea registry: `docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d`
|
|
||||||
- Configure systemd to manage the Docker Compose stack.
|
|
||||||
4. **Monitoring**: Access Grafana at the exposed port for dashboards (included in `monitoring/grafana/dashboard.json`).
|
|
||||||
|
|
||||||
See `docs/architecture/README.md` for the full arc42 documentation and the ADRs for architectural decisions.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- **R2 Upload Fails**: Check credentials in `.env` and ensure the bucket exists. Verify with `aws s3 ls --endpoint-url=$R2_ENDPOINT`.
|
|
||||||
- **Normalization Errors**: Run `backend/playout/start-playout.sh` manually to see ffprobe output. All videos must be H.264/AAC, 30 fps constant, 2s keyframes (ADR-001).
|
|
||||||
- **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200.
|
|
||||||
- **Cache Not Working**: Verify Cloudflare cache rules (`.ts` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard.
|
|
||||||
- **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use.
|
|
||||||
- **Admin Interface**: Ensure `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `ADMIN_SESSION_TTL_HOURS` are set. `/admin/` and `/api/admin/*` are protected by a session-based login flow.
|
|
||||||
- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist.
|
|
||||||
|
|
||||||
## Further Reading
|
|
||||||
|
|
||||||
- Architecture: `docs/architecture/README.md` (arc42 template with diagrams).
|
|
||||||
- Decisions: `docs/architecture/adr/` (ADR-001 to ADR-007).
|
|
||||||
- Contributing: `CONTRIBUTING.md`.
|
|
||||||
- Project Structure: Root `README.md`.
|
|
||||||
|
|
||||||
The system is ready for use. For production, review ADR-001 for cost estimates and scaling guidance.
|
|
||||||
|
|
||||||
**Last Updated**: 2026-08-03
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache gcc musl-dev
|
RUN apk add --no-cache gcc musl-dev
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ RUN go mod tidy
|
|||||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
|
RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
|
||||||
|
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
RUN apk --no-cache add ca-certificates curl curl
|
RUN apk --no-cache add ca-certificates curl ffmpeg
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /chat-server /usr/local/bin/chat-server
|
COPY --from=builder /chat-server /usr/local/bin/chat-server
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -1,26 +1,35 @@
|
|||||||
module chat
|
module chat
|
||||||
|
|
||||||
go 1.23.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/redis/go-redis/v9 v9.7.0
|
github.com/redis/go-redis/v9 v9.7.0
|
||||||
|
golang.org/x/crypto v0.37.0
|
||||||
gorm.io/driver/postgres v1.5.11
|
gorm.io/driver/postgres v1.5.11
|
||||||
gorm.io/driver/sqlite v1.5.7
|
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||||
golang.org/x/crypto v0.37.0 // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/sync v0.13.0 // indirect
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/text v0.24.0 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
|
modernc.org/libc v1.22.5 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
modernc.org/sqlite v1.23.1 // indirect
|
||||||
)
|
)
|
||||||
+26
-4
@@ -9,6 +9,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
@@ -23,12 +33,15 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
|||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -38,6 +51,9 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
|||||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -46,7 +62,13 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
|
||||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
|
||||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionStore manages admin authentication sessions.
|
||||||
|
type SessionStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionStore creates a new session store.
|
||||||
|
func NewSessionStore() *SessionStore {
|
||||||
|
return &SessionStore{
|
||||||
|
sessions: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken creates a cryptographically random 64-char hex session token.
|
||||||
|
func GenerateToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if a session token is valid and not expired.
|
||||||
|
// Returns true if valid, false otherwise. Expired sessions are cleaned up.
|
||||||
|
func (s *SessionStore) Validate(token string) bool {
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
expiresAt, ok := s.sessions[token]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
delete(s.sessions, token)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stores a session token with expiry.
|
||||||
|
func (s *SessionStore) Add(token string, expiresAt time.Time) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sessions[token] = expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deletes a session token.
|
||||||
|
func (s *SessionStore) Remove(token string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.sessions, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountActive returns the number of unexpired sessions.
|
||||||
|
func (s *SessionStore) CountActive() int {
|
||||||
|
now := time.Now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for token, expiresAt := range s.sessions {
|
||||||
|
if now.After(expiresAt) {
|
||||||
|
delete(s.sessions, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(s.sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CookieSecure determines if the session cookie should have Secure flag.
|
||||||
|
func CookieSecure(r *http.Request) bool {
|
||||||
|
if forwardedProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" {
|
||||||
|
return strings.EqualFold(forwardedProto, "https")
|
||||||
|
}
|
||||||
|
return r.TLS != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameSiteForUserCookie returns the SameSite policy for the viewer session cookie.
|
||||||
|
// Over HTTPS it returns SameSite=None so the cookie is sent cross-site from the
|
||||||
|
// Workers-served SPA; over plain HTTP (local dev) it falls back to Lax.
|
||||||
|
func SameSiteForUserCookie(r *http.Request) http.SameSite {
|
||||||
|
if CookieSecure(r) {
|
||||||
|
return http.SameSiteNoneMode
|
||||||
|
}
|
||||||
|
return http.SameSiteLaxMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCookie writes a session cookie with the given SameSite policy.
|
||||||
|
// SameSite=None is only valid together with Secure, which is enforced here.
|
||||||
|
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time, sameSite http.SameSite) {
|
||||||
|
secure := CookieSecure(r)
|
||||||
|
if sameSite == http.SameSiteNoneMode {
|
||||||
|
secure = true
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: sameSite,
|
||||||
|
Secure: secure,
|
||||||
|
Expires: expiresAt,
|
||||||
|
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearCookie removes a session cookie with the given SameSite policy.
|
||||||
|
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string, sameSite http.SameSite) {
|
||||||
|
secure := CookieSecure(r)
|
||||||
|
if sameSite == http.SameSiteNoneMode {
|
||||||
|
secure = true
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: sameSite,
|
||||||
|
Secure: secure,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenFromCookie extracts the session token from the request cookie.
|
||||||
|
func GetTokenFromCookie(r *http.Request, cookieName string) (string, bool) {
|
||||||
|
cookie, err := r.Cookie(cookieName)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return cookie.Value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAdmin returns middleware that checks for a valid admin session.
|
||||||
|
func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := GetTokenFromCookie(r, cookieName)
|
||||||
|
if !ok || !store.Validate(token) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSessionStore maps user session tokens to usernames with expiry.
|
||||||
|
type UserSessionStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]userSession
|
||||||
|
}
|
||||||
|
|
||||||
|
type userSession struct {
|
||||||
|
Username string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserSessionStore creates a new user session store.
|
||||||
|
func NewUserSessionStore() *UserSessionStore {
|
||||||
|
return &UserSessionStore{sessions: make(map[string]userSession)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stores a user session token with username and expiry.
|
||||||
|
func (s *UserSessionStore) Add(token, username string, expiresAt time.Time) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sessions[token] = userSession{Username: username, ExpiresAt: expiresAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username returns the username for a valid, unexpired token.
|
||||||
|
func (s *UserSessionStore) Username(token string) (string, bool) {
|
||||||
|
if token == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
sess, ok := s.sessions[token]
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if time.Now().After(sess.ExpiresAt) {
|
||||||
|
delete(s.sessions, token)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return sess.Username, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deletes a user session token.
|
||||||
|
func (s *UserSessionStore) Remove(token string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.sessions, token)
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateToken(t *testing.T) {
|
||||||
|
token, err := GenerateToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(token) != 64 {
|
||||||
|
t.Errorf("GenerateToken() length = %d, want 64", len(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uniqueness check
|
||||||
|
tokens := make(map[string]bool)
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
tok, err := GenerateToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateToken() iteration %d error = %v", i, err)
|
||||||
|
}
|
||||||
|
if tokens[tok] {
|
||||||
|
t.Errorf("GenerateToken() produced duplicate token at iteration %d", i)
|
||||||
|
}
|
||||||
|
tokens[tok] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionStore_Validate(t *testing.T) {
|
||||||
|
store := NewSessionStore()
|
||||||
|
|
||||||
|
// Empty token
|
||||||
|
if store.Validate("") {
|
||||||
|
t.Error("Validate(\"\") = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing token
|
||||||
|
if store.Validate("nonexistent") {
|
||||||
|
t.Error("Validate(\"nonexistent\") = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid token
|
||||||
|
token, _ := GenerateToken()
|
||||||
|
store.Add(token, time.Now().Add(1*time.Hour))
|
||||||
|
if !store.Validate(token) {
|
||||||
|
t.Error("Validate(valid) = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired token
|
||||||
|
expiredToken, _ := GenerateToken()
|
||||||
|
store.Add(expiredToken, time.Now().Add(-1*time.Hour))
|
||||||
|
if store.Validate(expiredToken) {
|
||||||
|
t.Error("Validate(expired) = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired token should be cleaned up
|
||||||
|
if store.Validate(expiredToken) {
|
||||||
|
t.Error("Validate(expired) second call = true, want false (should be cleaned)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionStore_AddRemove(t *testing.T) {
|
||||||
|
store := NewSessionStore()
|
||||||
|
token, _ := GenerateToken()
|
||||||
|
|
||||||
|
store.Add(token, time.Now().Add(1*time.Hour))
|
||||||
|
if !store.Validate(token) {
|
||||||
|
t.Error("Validate after Add = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Remove(token)
|
||||||
|
if store.Validate(token) {
|
||||||
|
t.Error("Validate after Remove = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionStore_CountActive(t *testing.T) {
|
||||||
|
store := NewSessionStore()
|
||||||
|
|
||||||
|
if count := store.CountActive(); count != 0 {
|
||||||
|
t.Errorf("CountActive() = %d, want 0", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
t1, _ := GenerateToken()
|
||||||
|
t2, _ := GenerateToken()
|
||||||
|
t3, _ := GenerateToken()
|
||||||
|
|
||||||
|
store.Add(t1, time.Now().Add(1*time.Hour))
|
||||||
|
store.Add(t2, time.Now().Add(1*time.Hour))
|
||||||
|
store.Add(t3, time.Now().Add(-1*time.Hour)) // expired
|
||||||
|
|
||||||
|
if count := store.CountActive(); count != 2 {
|
||||||
|
t.Errorf("CountActive() = %d, want 2 (expired should be cleaned)", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCookieSecure(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
forwardedProto string
|
||||||
|
tls bool
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"https forwarded", "https", false, true},
|
||||||
|
{"http forwarded", "http", false, false},
|
||||||
|
{"empty forwarded, no TLS", "", false, false},
|
||||||
|
{"empty forwarded, TLS", "", true, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
if tt.forwardedProto != "" {
|
||||||
|
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
|
||||||
|
}
|
||||||
|
if tt.tls {
|
||||||
|
req.TLS = &tls.ConnectionState{}
|
||||||
|
}
|
||||||
|
if got := CookieSecure(req); got != tt.expected {
|
||||||
|
t.Errorf("CookieSecure() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetCookie(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(1 * time.Hour)
|
||||||
|
SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteStrictMode)
|
||||||
|
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
if len(cookies) != 1 {
|
||||||
|
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||||
|
}
|
||||||
|
c := cookies[0]
|
||||||
|
if c.Name != "test_cookie" {
|
||||||
|
t.Errorf("cookie name = %q, want test_cookie", c.Name)
|
||||||
|
}
|
||||||
|
if c.Value != "abc123" {
|
||||||
|
t.Errorf("cookie value = %q, want abc123", c.Value)
|
||||||
|
}
|
||||||
|
if !c.HttpOnly {
|
||||||
|
t.Error("cookie HttpOnly = false, want true")
|
||||||
|
}
|
||||||
|
if c.SameSite != http.SameSiteStrictMode {
|
||||||
|
t.Errorf("cookie SameSite = %v, want Strict", c.SameSite)
|
||||||
|
}
|
||||||
|
if !c.Secure {
|
||||||
|
t.Error("cookie Secure = false, want true (X-Forwarded-Proto: https)")
|
||||||
|
}
|
||||||
|
if c.Path != "/" {
|
||||||
|
t.Errorf("cookie Path = %q, want /", c.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearCookie(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ClearCookie(w, req, "test_cookie", http.SameSiteStrictMode)
|
||||||
|
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
if len(cookies) != 1 {
|
||||||
|
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||||
|
}
|
||||||
|
c := cookies[0]
|
||||||
|
if c.Name != "test_cookie" {
|
||||||
|
t.Errorf("cookie name = %q, want test_cookie", c.Name)
|
||||||
|
}
|
||||||
|
if c.Value != "" {
|
||||||
|
t.Errorf("cookie value = %q, want empty", c.Value)
|
||||||
|
}
|
||||||
|
if c.MaxAge != -1 {
|
||||||
|
t.Errorf("cookie MaxAge = %d, want -1", c.MaxAge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameSiteForUserCookie(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
if got := SameSiteForUserCookie(req); got != http.SameSiteNoneMode {
|
||||||
|
t.Errorf("SameSiteForUserCookie(https) = %v, want None", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
req2 := httptest.NewRequest("GET", "/", nil)
|
||||||
|
if got := SameSiteForUserCookie(req2); got != http.SameSiteLaxMode {
|
||||||
|
t.Errorf("SameSiteForUserCookie(http) = %v, want Lax", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetCookie_NoneForcesSecure(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
expiresAt := time.Now().Add(1 * time.Hour)
|
||||||
|
SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteNoneMode)
|
||||||
|
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
if len(cookies) != 1 {
|
||||||
|
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||||
|
}
|
||||||
|
c := cookies[0]
|
||||||
|
if c.SameSite != http.SameSiteNoneMode {
|
||||||
|
t.Errorf("cookie SameSite = %v, want None", c.SameSite)
|
||||||
|
}
|
||||||
|
if !c.Secure {
|
||||||
|
t.Error("cookie Secure = false, want true (SameSite=None requires Secure)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTokenFromCookie(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
|
||||||
|
|
||||||
|
token, ok := GetTokenFromCookie(req, "session")
|
||||||
|
if !ok {
|
||||||
|
t.Error("GetTokenFromCookie() ok = false, want true")
|
||||||
|
}
|
||||||
|
if token != "token123" {
|
||||||
|
t.Errorf("GetTokenFromCookie() = %q, want token123", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing cookie
|
||||||
|
_, ok = GetTokenFromCookie(req, "nonexistent")
|
||||||
|
if ok {
|
||||||
|
t.Error("GetTokenFromCookie() for missing cookie ok = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty value
|
||||||
|
req2 := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req2.AddCookie(&http.Cookie{Name: "session", Value: ""})
|
||||||
|
_, ok = GetTokenFromCookie(req2, "session")
|
||||||
|
if ok {
|
||||||
|
t.Error("GetTokenFromCookie() for empty value ok = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAdmin(t *testing.T) {
|
||||||
|
store := NewSessionStore()
|
||||||
|
token, _ := GenerateToken()
|
||||||
|
store.Add(token, time.Now().Add(1*time.Hour))
|
||||||
|
|
||||||
|
handler := RequireAdmin(store, "session")(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Valid session
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "session", Value: token})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cookie
|
||||||
|
req2 := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
handler(w2, req2)
|
||||||
|
if w2.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", w2.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid token
|
||||||
|
req3 := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req3.AddCookie(&http.Cookie{Name: "session", Value: "invalid"})
|
||||||
|
w3 := httptest.NewRecorder()
|
||||||
|
handler(w3, req3)
|
||||||
|
if w3.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", w3.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserSessionStore(t *testing.T) {
|
||||||
|
store := NewUserSessionStore()
|
||||||
|
|
||||||
|
if _, ok := store.Username(""); ok {
|
||||||
|
t.Error("Username(\"\") = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, _ := GenerateToken()
|
||||||
|
store.Add(token, "alice", time.Now().Add(1*time.Hour))
|
||||||
|
|
||||||
|
username, ok := store.Username(token)
|
||||||
|
if !ok || username != "alice" {
|
||||||
|
t.Errorf("Username() = %q, %v; want alice, true", username, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired session
|
||||||
|
expired, _ := GenerateToken()
|
||||||
|
store.Add(expired, "bob", time.Now().Add(-1*time.Hour))
|
||||||
|
if _, ok := store.Username(expired); ok {
|
||||||
|
t.Error("Username(expired) = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
store.Remove(token)
|
||||||
|
if _, ok := store.Username(token); ok {
|
||||||
|
t.Error("Username after Remove = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defaults and constants
|
||||||
|
const (
|
||||||
|
AdminSessionCookieName = "linearvoid_admin_session"
|
||||||
|
MaxAdminUploadBytes int64 = 2 * 1024 * 1024 * 1024
|
||||||
|
DefaultAdminChunkSize int64 = 8 * 1024 * 1024
|
||||||
|
MaxAdminChunkSize int64 = 32 * 1024 * 1024
|
||||||
|
DefaultRateLimitMax = 10
|
||||||
|
DefaultRateLimitWindow = 10 * time.Second
|
||||||
|
DefaultAdminSessionTTL = 12 * time.Hour
|
||||||
|
DefaultUserSessionTTL = 30 * 24 * time.Hour
|
||||||
|
DefaultChatPort = "3000"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlaylistPath returns the playlist file path from env or default.
|
||||||
|
func PlaylistPath() string {
|
||||||
|
p := strings.TrimSpace(os.Getenv("PLAYLIST_PATH"))
|
||||||
|
if p == "" {
|
||||||
|
return "/app/playlist/playlist.txt"
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaDir returns the media directory from env or default.
|
||||||
|
func MediaDir() string {
|
||||||
|
d := strings.TrimSpace(os.Getenv("MEDIA_DIR"))
|
||||||
|
if d == "" {
|
||||||
|
return "/app/media"
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUsername returns the admin username from env or default.
|
||||||
|
func AdminUsername() string {
|
||||||
|
u := strings.TrimSpace(os.Getenv("ADMIN_USERNAME"))
|
||||||
|
if u == "" {
|
||||||
|
return "admin"
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPassword returns the admin password from env or default.
|
||||||
|
func AdminPassword() string {
|
||||||
|
p := strings.TrimSpace(os.Getenv("ADMIN_PASSWORD"))
|
||||||
|
if p == "" {
|
||||||
|
return "admin"
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSessionTTL returns the admin session TTL from env or default.
|
||||||
|
func AdminSessionTTL() time.Duration {
|
||||||
|
raw := strings.TrimSpace(os.Getenv("ADMIN_SESSION_TTL_HOURS"))
|
||||||
|
if raw == "" {
|
||||||
|
return DefaultAdminSessionTTL
|
||||||
|
}
|
||||||
|
hours, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || hours <= 0 {
|
||||||
|
return DefaultAdminSessionTTL
|
||||||
|
}
|
||||||
|
return time.Duration(hours) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSessionTTL returns the user session TTL from env or default (30 days).
|
||||||
|
func UserSessionTTL() time.Duration {
|
||||||
|
raw := strings.TrimSpace(os.Getenv("USER_SESSION_TTL_HOURS"))
|
||||||
|
if raw == "" {
|
||||||
|
return DefaultUserSessionTTL
|
||||||
|
}
|
||||||
|
hours, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || hours <= 0 {
|
||||||
|
return DefaultUserSessionTTL
|
||||||
|
}
|
||||||
|
return time.Duration(hours) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitMax returns the chat rate limit max messages per window.
|
||||||
|
func RateLimitMax() int {
|
||||||
|
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
|
||||||
|
if raw == "" {
|
||||||
|
return DefaultRateLimitMax
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || v <= 0 {
|
||||||
|
return DefaultRateLimitMax
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitWindow returns the chat rate limit window duration.
|
||||||
|
func RateLimitWindow() time.Duration {
|
||||||
|
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_WINDOW_S"))
|
||||||
|
if raw == "" {
|
||||||
|
return DefaultRateLimitWindow
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || v <= 0 {
|
||||||
|
return DefaultRateLimitWindow
|
||||||
|
}
|
||||||
|
return time.Duration(v) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatPort returns the chat service port from env or default.
|
||||||
|
func ChatPort() string {
|
||||||
|
p := strings.TrimSpace(os.Getenv("CHAT_PORT"))
|
||||||
|
if p == "" {
|
||||||
|
return DefaultChatPort
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisAddress returns the Redis address from env or default.
|
||||||
|
func RedisAddress() string {
|
||||||
|
a := strings.TrimSpace(os.Getenv("REDIS_URL"))
|
||||||
|
if a == "" {
|
||||||
|
return "redis:6379"
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseURL returns the database URL from env (empty = SQLite default).
|
||||||
|
func DatabaseURL() string {
|
||||||
|
return strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlaylistPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"default", "", "/app/playlist/playlist.txt"},
|
||||||
|
{"custom", "/custom/playlist.txt", "/custom/playlist.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.env != "" {
|
||||||
|
os.Setenv("PLAYLIST_PATH", tt.env)
|
||||||
|
defer os.Unsetenv("PLAYLIST_PATH")
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("PLAYLIST_PATH")
|
||||||
|
}
|
||||||
|
if got := PlaylistPath(); got != tt.expected {
|
||||||
|
t.Errorf("PlaylistPath() = %q, want %q", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaDir(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"default", "", "/app/media"},
|
||||||
|
{"custom", "/data/media", "/data/media"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.env != "" {
|
||||||
|
os.Setenv("MEDIA_DIR", tt.env)
|
||||||
|
defer os.Unsetenv("MEDIA_DIR")
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("MEDIA_DIR")
|
||||||
|
}
|
||||||
|
if got := MediaDir(); got != tt.expected {
|
||||||
|
t.Errorf("MediaDir() = %q, want %q", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUsername(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"default", "", "admin"},
|
||||||
|
{"custom", "superadmin", "superadmin"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.env != "" {
|
||||||
|
os.Setenv("ADMIN_USERNAME", tt.env)
|
||||||
|
defer os.Unsetenv("ADMIN_USERNAME")
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("ADMIN_USERNAME")
|
||||||
|
}
|
||||||
|
if got := AdminUsername(); got != tt.expected {
|
||||||
|
t.Errorf("AdminUsername() = %q, want %q", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminPassword(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"default", "", "admin"},
|
||||||
|
{"custom", "secret123", "secret123"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.env != "" {
|
||||||
|
os.Setenv("ADMIN_PASSWORD", tt.env)
|
||||||
|
defer os.Unsetenv("ADMIN_PASSWORD")
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("ADMIN_PASSWORD")
|
||||||
|
}
|
||||||
|
if got := AdminPassword(); got != tt.expected {
|
||||||
|
t.Errorf("AdminPassword() = %q, want %q", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSessionTTL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{"default", "", 12 * time.Hour},
|
||||||
|
{"custom", "24", 24 * time.Hour},
|
||||||
|
{"zero", "0", 12 * time.Hour},
|
||||||
|
{"negative", "-5", 12 * time.Hour},
|
||||||
|
{"invalid", "abc", 12 * time.Hour},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.env != "" {
|
||||||
|
os.Setenv("ADMIN_SESSION_TTL_HOURS", tt.env)
|
||||||
|
defer os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
|
||||||
|
}
|
||||||
|
if got := AdminSessionTTL(); got != tt.expected {
|
||||||
|
t.Errorf("AdminSessionTTL() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitMax(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{"default", "", 10},
|
||||||
|
{"custom", "20", 20},
|
||||||
|
{"zero", "0", 10},
|
||||||
|
{"negative", "-5", 10},
|
||||||
|
{"invalid", "abc", 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.env != "" {
|
||||||
|
os.Setenv("CHAT_RATE_LIMIT_MAX", tt.env)
|
||||||
|
defer os.Unsetenv("CHAT_RATE_LIMIT_MAX")
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CHAT_RATE_LIMIT_MAX")
|
||||||
|
}
|
||||||
|
if got := RateLimitMax(); got != tt.expected {
|
||||||
|
t.Errorf("RateLimitMax() = %d, want %d", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitWindow(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{"default", "", 10 * time.Second},
|
||||||
|
{"custom", "30", 30 * time.Second},
|
||||||
|
{"zero", "0", 10 * time.Second},
|
||||||
|
{"negative", "-5", 10 * time.Second},
|
||||||
|
{"invalid", "abc", 10 * time.Second},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.env != "" {
|
||||||
|
os.Setenv("CHAT_RATE_LIMIT_WINDOW_S", tt.env)
|
||||||
|
defer os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
|
||||||
|
}
|
||||||
|
if got := RateLimitWindow(); got != tt.expected {
|
||||||
|
t.Errorf("RateLimitWindow() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatPort(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"default", "", "3000"},
|
||||||
|
{"custom", "8080", "8080"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.env != "" {
|
||||||
|
os.Setenv("CHAT_PORT", tt.env)
|
||||||
|
defer os.Unsetenv("CHAT_PORT")
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CHAT_PORT")
|
||||||
|
}
|
||||||
|
if got := ChatPort(); got != tt.expected {
|
||||||
|
t.Errorf("ChatPort() = %q, want %q", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedisAddress(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"default", "", "redis:6379"},
|
||||||
|
{"custom", "redis.example.com:6380", "redis.example.com:6380"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.env != "" {
|
||||||
|
os.Setenv("REDIS_URL", tt.env)
|
||||||
|
defer os.Unsetenv("REDIS_URL")
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("REDIS_URL")
|
||||||
|
}
|
||||||
|
if got := RedisAddress(); got != tt.expected {
|
||||||
|
t.Errorf("RedisAddress() = %q, want %q", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatabaseURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"default", "", ""},
|
||||||
|
{"sqlite", "sqlite://test.db", "sqlite://test.db"},
|
||||||
|
{"postgres", "postgres://user:pass@localhost/db", "postgres://user:pass@localhost/db"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.env != "" {
|
||||||
|
os.Setenv("DATABASE_URL", tt.env)
|
||||||
|
defer os.Unsetenv("DATABASE_URL")
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("DATABASE_URL")
|
||||||
|
}
|
||||||
|
if got := DatabaseURL(); got != tt.expected {
|
||||||
|
t.Errorf("DatabaseURL() = %q, want %q", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Runtime configuration keys stored in the runtime config file.
|
||||||
|
const (
|
||||||
|
keyMatomoURL = "matomo_url"
|
||||||
|
keyMatomoSiteID = "matomo_site_id"
|
||||||
|
keyHLSSegmentDur = "hls_segment_duration"
|
||||||
|
keyHLSWindowSize = "hls_window_size"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default HLS values used when neither env nor persisted config is set.
|
||||||
|
const (
|
||||||
|
defaultHLSSegmentDuration = 2
|
||||||
|
defaultHLSWindowSize = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
runtimeMu sync.RWMutex
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigPath returns the runtime config JSON file path from env or default.
|
||||||
|
// The default lives in the shared playlist volume so the playout service can
|
||||||
|
// read the same file (chat has read-write, playout read-only).
|
||||||
|
func ConfigPath() string {
|
||||||
|
p := strings.TrimSpace(os.Getenv("CONFIG_PATH"))
|
||||||
|
if p == "" {
|
||||||
|
return "/app/playlist/config.json"
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRuntime returns a value from the runtime store and whether it is present.
|
||||||
|
func GetRuntime(key string) (string, bool) {
|
||||||
|
runtimeMu.RLock()
|
||||||
|
defer runtimeMu.RUnlock()
|
||||||
|
v, ok := runtimeStore[key]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRuntime stores a value in the runtime store (in-memory only).
|
||||||
|
func SetRuntime(key, value string) {
|
||||||
|
runtimeMu.Lock()
|
||||||
|
defer runtimeMu.Unlock()
|
||||||
|
runtimeStore[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadRuntimeConfig loads the persisted runtime config from disk. If the file does
|
||||||
|
// not exist, it seeds the store from environment variables.
|
||||||
|
func LoadRuntimeConfig() error {
|
||||||
|
data, err := os.ReadFile(ConfigPath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
runtimeMu.Lock()
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
runtimeMu.Unlock()
|
||||||
|
seedRuntimeDefaults()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read runtime config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := map[string]string{}
|
||||||
|
if err := json.Unmarshal(data, &values); err != nil {
|
||||||
|
return fmt.Errorf("parse runtime config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeMu.Lock()
|
||||||
|
defer runtimeMu.Unlock()
|
||||||
|
runtimeStore = values
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRuntimeConfig persists the runtime store to disk as JSON.
|
||||||
|
func SaveRuntimeConfig() error {
|
||||||
|
runtimeMu.RLock()
|
||||||
|
data, err := json.MarshalIndent(runtimeStore, "", " ")
|
||||||
|
runtimeMu.RUnlock()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal runtime config: %w", err)
|
||||||
|
}
|
||||||
|
if dir := filepath.Dir(ConfigPath()); dir != "" && dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create runtime config dir: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(ConfigPath(), append(data, '\n'), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write runtime config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedRuntimeDefaults populates the store from environment variables.
|
||||||
|
func seedRuntimeDefaults() {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("MATOMO_URL")); v != "" {
|
||||||
|
SetRuntime(keyMatomoURL, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("MATOMO_SITE_ID")); v != "" {
|
||||||
|
SetRuntime(keyMatomoSiteID, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION")); v != "" {
|
||||||
|
SetRuntime(keyHLSSegmentDur, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE")); v != "" {
|
||||||
|
SetRuntime(keyHLSWindowSize, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatomoURL returns the Matomo analytics URL.
|
||||||
|
func MatomoURL() string {
|
||||||
|
if v, ok := GetRuntime(keyMatomoURL); ok && strings.TrimSpace(v) != "" {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(os.Getenv("MATOMO_URL"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatomoSiteID returns the Matomo site ID.
|
||||||
|
func MatomoSiteID() string {
|
||||||
|
if v, ok := GetRuntime(keyMatomoSiteID); ok && strings.TrimSpace(v) != "" {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(os.Getenv("MATOMO_SITE_ID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLSSegmentDuration returns the HLS segment duration in seconds.
|
||||||
|
func HLSSegmentDuration() int {
|
||||||
|
raw := ""
|
||||||
|
if v, ok := GetRuntime(keyHLSSegmentDur); ok {
|
||||||
|
raw = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
raw = strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION"))
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return defaultHLSSegmentDuration
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLSWindowSize returns the HLS playlist window size in segments.
|
||||||
|
func HLSWindowSize() int {
|
||||||
|
raw := ""
|
||||||
|
if v, ok := GetRuntime(keyHLSWindowSize); ok {
|
||||||
|
raw = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
raw = strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE"))
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return defaultHLSWindowSize
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMatomoURL validates and stores the Matomo URL.
|
||||||
|
func SetMatomoURL(raw string) error {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
SetRuntime(keyMatomoURL, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return fmt.Errorf("matomo URL must be an absolute http(s) URL")
|
||||||
|
}
|
||||||
|
SetRuntime(keyMatomoURL, raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMatomoSiteID validates and stores the Matomo site ID.
|
||||||
|
func SetMatomoSiteID(raw string) error {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw != "" {
|
||||||
|
if _, err := strconv.Atoi(raw); err != nil {
|
||||||
|
return fmt.Errorf("matomo site ID must be numeric")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SetRuntime(keyMatomoSiteID, raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHLSSegmentDuration validates (2–15s) and stores the HLS segment duration.
|
||||||
|
func SetHLSSegmentDuration(seconds int) error {
|
||||||
|
if seconds < 2 || seconds > 15 {
|
||||||
|
return fmt.Errorf("HLS segment duration must be between 2 and 15 seconds")
|
||||||
|
}
|
||||||
|
SetRuntime(keyHLSSegmentDur, strconv.Itoa(seconds))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHLSWindowSize validates (3–100) and stores the HLS window size.
|
||||||
|
func SetHLSWindowSize(size int) error {
|
||||||
|
if size < 3 || size > 100 {
|
||||||
|
return fmt.Errorf("HLS window size must be between 3 and 100 segments")
|
||||||
|
}
|
||||||
|
SetRuntime(keyHLSWindowSize, strconv.Itoa(size))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeConfigSnapshot returns the resolved runtime configuration values.
|
||||||
|
func RuntimeConfigSnapshot() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"matomoUrl": MatomoURL(),
|
||||||
|
"matomoSiteId": MatomoSiteID(),
|
||||||
|
"hlsSegmentDuration": HLSSegmentDuration(),
|
||||||
|
"hlsWindowSize": HLSWindowSize(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resetRuntime(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
runtimeMu.Lock()
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
runtimeMu.Unlock()
|
||||||
|
os.Unsetenv("CONFIG_PATH")
|
||||||
|
os.Unsetenv("MATOMO_URL")
|
||||||
|
os.Unsetenv("MATOMO_SITE_ID")
|
||||||
|
os.Unsetenv("HLS_SEGMENT_DURATION")
|
||||||
|
os.Unsetenv("HLS_WINDOW_SIZE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigPath(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
if got := ConfigPath(); got != "/app/playlist/config.json" {
|
||||||
|
t.Errorf("ConfigPath() = %q, want default /app/playlist/config.json", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("CONFIG_PATH", "/tmp/custom.json")
|
||||||
|
defer os.Unsetenv("CONFIG_PATH")
|
||||||
|
if got := ConfigPath(); got != "/tmp/custom.json" {
|
||||||
|
t.Errorf("ConfigPath() = %q, want /tmp/custom.json", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSetRuntime(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
if _, ok := GetRuntime("foo"); ok {
|
||||||
|
t.Errorf("expected missing key")
|
||||||
|
}
|
||||||
|
SetRuntime("foo", "bar")
|
||||||
|
v, ok := GetRuntime("foo")
|
||||||
|
if !ok || v != "bar" {
|
||||||
|
t.Errorf("GetRuntime() = %q, %v; want bar, true", v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGettersDefaults(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
if got := MatomoURL(); got != "" {
|
||||||
|
t.Errorf("MatomoURL() = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := MatomoSiteID(); got != "" {
|
||||||
|
t.Errorf("MatomoSiteID() = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := HLSSegmentDuration(); got != 2 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 2", got)
|
||||||
|
}
|
||||||
|
if got := HLSWindowSize(); got != 12 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 12", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGettersEnvFallback(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
os.Setenv("HLS_SEGMENT_DURATION", "4")
|
||||||
|
os.Setenv("HLS_WINDOW_SIZE", "30")
|
||||||
|
os.Setenv("MATOMO_URL", "https://analytics.example.com")
|
||||||
|
os.Setenv("MATOMO_SITE_ID", "7")
|
||||||
|
|
||||||
|
if got := HLSSegmentDuration(); got != 4 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 4", got)
|
||||||
|
}
|
||||||
|
if got := HLSWindowSize(); got != 30 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 30", got)
|
||||||
|
}
|
||||||
|
if got := MatomoURL(); got != "https://analytics.example.com" {
|
||||||
|
t.Errorf("MatomoURL() = %q", got)
|
||||||
|
}
|
||||||
|
if got := MatomoSiteID(); got != "7" {
|
||||||
|
t.Errorf("MatomoSiteID() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRuntimeConfigSeedsFromEnv(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json"))
|
||||||
|
os.Setenv("HLS_SEGMENT_DURATION", "8")
|
||||||
|
|
||||||
|
if err := LoadRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("LoadRuntimeConfig error: %v", err)
|
||||||
|
}
|
||||||
|
if got := HLSSegmentDuration(); got != 8 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 8 (seeded from env)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAndLoadRuntimeConfig(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json"))
|
||||||
|
|
||||||
|
if err := SetHLSSegmentDuration(5); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SetHLSWindowSize(30); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SetMatomoURL("https://stats.example.com"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SetMatomoSiteID("42"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("SaveRuntimeConfig error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the store and reload from disk.
|
||||||
|
runtimeMu.Lock()
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
runtimeMu.Unlock()
|
||||||
|
|
||||||
|
if err := LoadRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("LoadRuntimeConfig error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := HLSSegmentDuration(); got != 5 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 5", got)
|
||||||
|
}
|
||||||
|
if got := HLSWindowSize(); got != 30 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 30", got)
|
||||||
|
}
|
||||||
|
if got := MatomoURL(); got != "https://stats.example.com" {
|
||||||
|
t.Errorf("MatomoURL() = %q", got)
|
||||||
|
}
|
||||||
|
if got := MatomoSiteID(); got != "42" {
|
||||||
|
t.Errorf("MatomoSiteID() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettersValidation(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
|
||||||
|
if err := SetHLSSegmentDuration(1); err == nil {
|
||||||
|
t.Error("expected error for duration 1")
|
||||||
|
}
|
||||||
|
if err := SetHLSSegmentDuration(20); err == nil {
|
||||||
|
t.Error("expected error for duration 20")
|
||||||
|
}
|
||||||
|
if err := SetHLSSegmentDuration(6); err != nil {
|
||||||
|
t.Errorf("unexpected error for duration 6: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetHLSWindowSize(2); err == nil {
|
||||||
|
t.Error("expected error for window 2")
|
||||||
|
}
|
||||||
|
if err := SetHLSWindowSize(101); err == nil {
|
||||||
|
t.Error("expected error for window 101")
|
||||||
|
}
|
||||||
|
if err := SetHLSWindowSize(24); err != nil {
|
||||||
|
t.Errorf("unexpected error for window 24: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetMatomoURL("not-a-url"); err == nil {
|
||||||
|
t.Error("expected error for invalid URL")
|
||||||
|
}
|
||||||
|
if err := SetMatomoURL("ftp://example.com"); err == nil {
|
||||||
|
t.Error("expected error for non-http URL")
|
||||||
|
}
|
||||||
|
if err := SetMatomoURL("https://stats.example.com"); err != nil {
|
||||||
|
t.Errorf("unexpected error for valid URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetMatomoSiteID("abc"); err == nil {
|
||||||
|
t.Error("expected error for non-numeric site ID")
|
||||||
|
}
|
||||||
|
if err := SetMatomoSiteID("99"); err != nil {
|
||||||
|
t.Errorf("unexpected error for numeric site ID: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DBClient wraps gorm.DB for testability.
|
||||||
|
type DBClient struct {
|
||||||
|
*gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006).
|
||||||
|
// Returns the client and the driver name.
|
||||||
|
func InitDB(databaseURL string) (*DBClient, string, error) {
|
||||||
|
var (
|
||||||
|
gormDB *gorm.DB
|
||||||
|
err error
|
||||||
|
driver = "sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case databaseURL == "":
|
||||||
|
gormDB, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
|
||||||
|
case strings.HasPrefix(databaseURL, "sqlite://"):
|
||||||
|
sqlitePath := strings.TrimPrefix(databaseURL, "sqlite://")
|
||||||
|
if sqlitePath == "" {
|
||||||
|
sqlitePath = "chat.db"
|
||||||
|
}
|
||||||
|
gormDB, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
|
||||||
|
case strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://"):
|
||||||
|
driver = "postgres"
|
||||||
|
gormDB, err = gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||||
|
default:
|
||||||
|
log.Fatalf("Unsupported DATABASE_URL scheme: %s", databaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, driver, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{}, &User{})
|
||||||
|
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
|
||||||
|
|
||||||
|
return &DBClient{DB: gormDB}, driver, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisClient wraps the Redis client for testability.
|
||||||
|
// Interface defined for mocking in tests.
|
||||||
|
type RedisClient interface {
|
||||||
|
Ping(ctx context.Context) error
|
||||||
|
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
|
||||||
|
Get(ctx context.Context, key string) (string, error)
|
||||||
|
Del(ctx context.Context, keys ...string) error
|
||||||
|
Incr(ctx context.Context, key string) (int64, error)
|
||||||
|
Decr(ctx context.Context, key string) (int64, error)
|
||||||
|
Expire(ctx context.Context, key string, expiration time.Duration) error
|
||||||
|
Exists(ctx context.Context, keys ...string) (int64, error)
|
||||||
|
Publish(ctx context.Context, channel string, message interface{}) error
|
||||||
|
PSubscribe(ctx context.Context, channels ...string) PubSub
|
||||||
|
Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error)
|
||||||
|
Info(ctx context.Context, sections ...string) (string, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PubSub wraps Redis pub/sub subscription.
|
||||||
|
type PubSub interface {
|
||||||
|
ReceiveMessage(ctx context.Context) (*PubSubMessage, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PubSubMessage represents a received pub/sub message.
|
||||||
|
type PubSubMessage struct {
|
||||||
|
Channel string
|
||||||
|
Payload string
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitDB_SQLite(t *testing.T) {
|
||||||
|
client, driver, err := InitDB("sqlite://:memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitDB() error = %v", err)
|
||||||
|
}
|
||||||
|
if driver != "sqlite" {
|
||||||
|
t.Errorf("InitDB() driver = %q, want sqlite", driver)
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
t.Fatal("InitDB() client = nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tables exist by running AutoMigrate (idempotent)
|
||||||
|
err = client.DB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("AutoMigrate error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify CRUD on Message
|
||||||
|
msg := Message{
|
||||||
|
Room: "test",
|
||||||
|
Username: "user1",
|
||||||
|
Text: "hello",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Type: "public",
|
||||||
|
}
|
||||||
|
if err := client.DB.Create(&msg).Error; err != nil {
|
||||||
|
t.Fatalf("Create message error = %v", err)
|
||||||
|
}
|
||||||
|
if msg.ID == 0 {
|
||||||
|
t.Error("Create message did not set ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
client.DB.Model(&Message{}).Count(&count)
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("Message count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify read
|
||||||
|
var read Message
|
||||||
|
if err := client.DB.First(&read, msg.ID).Error; err != nil {
|
||||||
|
t.Fatalf("First message error = %v", err)
|
||||||
|
}
|
||||||
|
if read.Text != "hello" {
|
||||||
|
t.Errorf("read.Text = %q, want hello", read.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify delete
|
||||||
|
client.DB.Delete(&Message{}, msg.ID)
|
||||||
|
client.DB.Model(&Message{}).Count(&count)
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("Message count after delete = %d, want 0", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitDB_ModerationLog(t *testing.T) {
|
||||||
|
client, _, err := InitDB("sqlite://:memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitDB() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log := ModerationLog{
|
||||||
|
Action: "mute",
|
||||||
|
Target: "user1",
|
||||||
|
Moderator: "admin",
|
||||||
|
Reason: "spam",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
if err := client.DB.Create(&log).Error; err != nil {
|
||||||
|
t.Fatalf("Create moderation log error = %v", err)
|
||||||
|
}
|
||||||
|
if log.ID == 0 {
|
||||||
|
t.Error("Create moderation log did not set ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
client.DB.Model(&ModerationLog{}).Count(&count)
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("ModerationLog count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitDB_Media(t *testing.T) {
|
||||||
|
client, _, err := InitDB("sqlite://:memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitDB() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
media := Media{
|
||||||
|
Filename: "test.mp4",
|
||||||
|
OriginalName: "test.mp4",
|
||||||
|
Path: "/media/test.mp4",
|
||||||
|
SizeBytes: 1024,
|
||||||
|
DurationSeconds: 60,
|
||||||
|
Status: "ready",
|
||||||
|
Title: "Test Video",
|
||||||
|
Tags: "tag1,tag2",
|
||||||
|
}
|
||||||
|
if err := client.DB.Create(&media).Error; err != nil {
|
||||||
|
t.Fatalf("Create media error = %v", err)
|
||||||
|
}
|
||||||
|
if media.ID == 0 {
|
||||||
|
t.Error("Create media did not set ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify update
|
||||||
|
client.DB.Model(&media).Update("status", "needs_normalization")
|
||||||
|
var updated Media
|
||||||
|
client.DB.First(&updated, media.ID)
|
||||||
|
if updated.Status != "needs_normalization" {
|
||||||
|
t.Errorf("updated.Status = %q, want needs_normalization", updated.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitDB_ScheduleOverride(t *testing.T) {
|
||||||
|
client, _, err := InitDB("sqlite://:memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitDB() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
ov := ScheduleOverride{
|
||||||
|
MediaID: 1,
|
||||||
|
StartTime: now,
|
||||||
|
EndTime: now.Add(1 * time.Hour),
|
||||||
|
Reason: "special event",
|
||||||
|
}
|
||||||
|
if err := client.DB.Create(&ov).Error; err != nil {
|
||||||
|
t.Fatalf("Create schedule override error = %v", err)
|
||||||
|
}
|
||||||
|
if ov.ID == 0 {
|
||||||
|
t.Error("Create schedule override did not set ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflict detection
|
||||||
|
var conflict int64
|
||||||
|
client.DB.Model(&ScheduleOverride{}).
|
||||||
|
Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime).
|
||||||
|
Count(&conflict)
|
||||||
|
if conflict != 1 {
|
||||||
|
t.Errorf("conflict count = %d, want 1", conflict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitDB_EmptyURL(t *testing.T) {
|
||||||
|
// Empty URL should default to SQLite chat.db
|
||||||
|
// This creates a file, clean up after
|
||||||
|
client, driver, err := InitDB("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitDB(\"\") error = %v", err)
|
||||||
|
}
|
||||||
|
if driver != "sqlite" {
|
||||||
|
t.Errorf("InitDB(\"\") driver = %q, want sqlite", driver)
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
t.Fatal("InitDB(\"\") client = nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitDB_SQLitePath(t *testing.T) {
|
||||||
|
client, driver, err := InitDB("sqlite://:memory:?cache=shared")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitDB() error = %v", err)
|
||||||
|
}
|
||||||
|
if driver != "sqlite" {
|
||||||
|
t.Errorf("InitDB() driver = %q, want sqlite", driver)
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
t.Fatal("InitDB() client = nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message represents a chat message (persisted in SQL DB per ADR-006)
|
||||||
|
type Message struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Room string `json:"room"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Type string `json:"type"` // "public", "private", "system", "moderation"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationLog for audit trail (immutable per ADR-005)
|
||||||
|
type ModerationLog struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Action string `json:"action"` // "mute", "ban", "delete", "timeout"
|
||||||
|
Target string `json:"target"`
|
||||||
|
Moderator string `json:"moderator"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media stores admin media library metadata (filesystem + DB model per ADR-006)
|
||||||
|
type Media struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Filename string `gorm:"index" json:"filename"`
|
||||||
|
OriginalName string `json:"originalName"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
SizeBytes int64 `json:"sizeBytes"`
|
||||||
|
DurationSeconds int `json:"durationSeconds"`
|
||||||
|
Status string `gorm:"index" json:"status"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// User represents a registered chat user account (ADR-006).
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Username string `gorm:"uniqueIndex;size:32" json:"username"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
type Presence struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Room string `json:"room"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RedisWrapper wraps go-redis client to implement RedisClient interface.
|
||||||
|
type RedisWrapper struct {
|
||||||
|
client *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRedisWrapper creates a RedisWrapper from a go-redis client.
|
||||||
|
func NewRedisWrapper(client *redis.Client) *RedisWrapper {
|
||||||
|
return &RedisWrapper{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007).
|
||||||
|
// Returns nil if Redis is unavailable (non-fatal).
|
||||||
|
func InitRedis(redisAddress string) *RedisWrapper {
|
||||||
|
var options *redis.Options
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if strings.Contains(redisAddress, "://") {
|
||||||
|
options, err = redis.ParseURL(redisAddress)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Redis configuration warning: %v (rate limiting and presence will be disabled)", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
options = &redis.Options{Addr: redisAddress}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := redis.NewClient(options)
|
||||||
|
if err := client.Ping(context.Background()).Err(); err != nil {
|
||||||
|
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
|
||||||
|
client.Close()
|
||||||
|
// Start auto-reconnect goroutine
|
||||||
|
go redisReconnectLoop(redisAddress, options)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)")
|
||||||
|
return &RedisWrapper{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
// Reconnected — caller must replace their reference.
|
||||||
|
// This is a best-effort background reconnect; the wrapper
|
||||||
|
// returned by InitRedis remains nil until a new InitRedis call.
|
||||||
|
log.Println("Redis reconnected successfully (background)")
|
||||||
|
testClient.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping checks Redis connectivity.
|
||||||
|
func (r *RedisWrapper) Ping(ctx context.Context) error {
|
||||||
|
if r == nil || r.client == nil {
|
||||||
|
return redis.ErrClosed
|
||||||
|
}
|
||||||
|
return r.client.Ping(ctx).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a key with TTL.
|
||||||
|
func (r *RedisWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||||
|
return r.client.Set(ctx, key, value, expiration).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a key.
|
||||||
|
func (r *RedisWrapper) Get(ctx context.Context, key string) (string, error) {
|
||||||
|
return r.client.Get(ctx, key).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Del removes keys.
|
||||||
|
func (r *RedisWrapper) Del(ctx context.Context, keys ...string) error {
|
||||||
|
return r.client.Del(ctx, keys...).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incr increments a key.
|
||||||
|
func (r *RedisWrapper) Incr(ctx context.Context, key string) (int64, error) {
|
||||||
|
return r.client.Incr(ctx, key).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decr decrements a key.
|
||||||
|
func (r *RedisWrapper) Decr(ctx context.Context, key string) (int64, error) {
|
||||||
|
return r.client.Decr(ctx, key).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expire sets TTL on a key.
|
||||||
|
func (r *RedisWrapper) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||||
|
return r.client.Expire(ctx, key, expiration).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if keys exist.
|
||||||
|
func (r *RedisWrapper) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||||
|
return r.client.Exists(ctx, keys...).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish publishes a message to a channel.
|
||||||
|
func (r *RedisWrapper) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||||
|
return r.client.Publish(ctx, channel, message).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PSubscribe subscribes to channels by pattern.
|
||||||
|
func (r *RedisWrapper) PSubscribe(ctx context.Context, channels ...string) PubSub {
|
||||||
|
ps := r.client.PSubscribe(ctx, channels...)
|
||||||
|
return &redisPubSubWrapper{ps: ps}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan scans keys matching a pattern.
|
||||||
|
func (r *RedisWrapper) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||||
|
return r.client.Scan(ctx, cursor, match, count).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info returns Redis server info.
|
||||||
|
func (r *RedisWrapper) Info(ctx context.Context, sections ...string) (string, error) {
|
||||||
|
return r.client.Info(ctx, sections...).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the Redis connection.
|
||||||
|
func (r *RedisWrapper) Close() error {
|
||||||
|
if r == nil || r.client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.client.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// redisPubSubWrapper wraps go-redis PubSub.
|
||||||
|
type redisPubSubWrapper struct {
|
||||||
|
ps *redis.PubSub
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisPubSubWrapper) ReceiveMessage(ctx context.Context) (*PubSubMessage, error) {
|
||||||
|
msg, err := w.ps.ReceiveMessage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PubSubMessage{Channel: msg.Channel, Payload: msg.Payload}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisPubSubWrapper) Close() error {
|
||||||
|
return w.ps.Close()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+92
-1120
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,450 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatDuration converts seconds to HH:MM:SS string.
|
||||||
|
func FormatDuration(seconds int) string {
|
||||||
|
if seconds < 0 {
|
||||||
|
seconds = 0
|
||||||
|
}
|
||||||
|
hours := seconds / 3600
|
||||||
|
minutes := (seconds % 3600) / 60
|
||||||
|
remainingSeconds := seconds % 60
|
||||||
|
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizationResult holds ffprobe validation output for admin uploads.
|
||||||
|
type NormalizationResult struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateNormalization runs ffprobe against a media file and checks
|
||||||
|
// conformance with the Linear Void normalization profile (ADR-001).
|
||||||
|
func ValidateNormalization(mediaPath string) NormalizationResult {
|
||||||
|
result := NormalizationResult{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 NormalizationResult{Valid: false, Errors: []string{"ffprobe timed out"}}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return NormalizationResult{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 NormalizationResult{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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeDurationSeconds returns the duration of a media file in seconds.
|
||||||
|
func ProbeDurationSeconds(mediaPath string) (int, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"ffprobe",
|
||||||
|
"-v", "error",
|
||||||
|
"-show_entries", "format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||||
|
mediaPath,
|
||||||
|
).CombinedOutput()
|
||||||
|
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return 0, fmt.Errorf("ffprobe timed out")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
rawDuration := strings.TrimSpace(string(out))
|
||||||
|
if rawDuration == "" {
|
||||||
|
return 0, fmt.Errorf("ffprobe returned empty duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
secondsFloat, err := strconv.ParseFloat(rawDuration, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err)
|
||||||
|
}
|
||||||
|
if secondsFloat < 0 {
|
||||||
|
secondsFloat = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds := int(secondsFloat + 0.5)
|
||||||
|
if secondsFloat > 0 && seconds == 0 {
|
||||||
|
seconds = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return seconds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media status values (ADR-006 media library states).
|
||||||
|
const (
|
||||||
|
StatusReady = "ready"
|
||||||
|
StatusNeedsNormalization = "needs_normalization"
|
||||||
|
StatusNormalizing = "normalizing"
|
||||||
|
StatusNormalizationFailed = "normalization_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runCommand executes an external command and returns its combined output on failure.
|
||||||
|
// It is a package variable so tests can substitute a stub.
|
||||||
|
var runCommand = func(ctx context.Context, name string, args ...string) error {
|
||||||
|
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s failed: %v (%s)", name, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizationArgs builds the ffmpeg arguments for the Linear Void normalization profile.
|
||||||
|
func normalizationArgs(srcPath, dstPath string) []string {
|
||||||
|
return []string{
|
||||||
|
"-y",
|
||||||
|
"-i", srcPath,
|
||||||
|
"-map", "0:v:0", "-map", "0:a:0?",
|
||||||
|
"-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p",
|
||||||
|
"-c:v", "libx264", "-preset", "medium", "-crf", "21",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
||||||
|
"-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-ac", "2",
|
||||||
|
"-avoid_negative_ts", "make_zero", "-max_muxing_queue_size", "1024",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
dstPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeVideo transcodes srcPath to the Linear Void normalization profile,
|
||||||
|
// re-verifies the output with ffprobe, and returns its duration (seconds) and size (bytes).
|
||||||
|
func normalizeVideo(srcPath, dstPath string) (int, int64, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := runCommand(ctx, "ffmpeg", normalizationArgs(srcPath, dstPath)...); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if res := ValidateNormalization(dstPath); !res.Valid {
|
||||||
|
return 0, 0, fmt.Errorf("normalized output failed validation: %s", strings.Join(res.Errors, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
duration, err := ProbeDurationSeconds(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("probe duration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("stat output failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return duration, info.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeVideo is the injectable normalization entry point.
|
||||||
|
var NormalizeVideo = normalizeVideo
|
||||||
|
|
||||||
|
// DetectMime reads the first 512 bytes of a file to detect its MIME type.
|
||||||
|
func DetectMime(filePath string) (string, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
head := make([]byte, 512)
|
||||||
|
n, err := f.Read(head)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.SplitN(http.DetectContentType(head[:n]), ";", 2)[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAllowedExtension checks if the file extension is a supported video format.
|
||||||
|
func IsAllowedExtension(fileName string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(fileName))
|
||||||
|
switch ext {
|
||||||
|
case ".mp4", ".mov", ".mkv", ".webm", ".m4v", ".ts":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAllowedMime checks if the MIME type is a supported video type.
|
||||||
|
func IsAllowedMime(mime string) bool {
|
||||||
|
mime = strings.TrimSpace(strings.ToLower(mime))
|
||||||
|
if mime == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(mime, "video/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return mime == "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniqueTargetPath returns a unique file path in mediaDir, appending -N if needed.
|
||||||
|
func UniqueTargetPath(mediaDir, fileName string) string {
|
||||||
|
baseName := filepath.Base(fileName)
|
||||||
|
ext := filepath.Ext(baseName)
|
||||||
|
nameOnly := strings.TrimSuffix(baseName, ext)
|
||||||
|
candidate := filepath.Join(mediaDir, baseName)
|
||||||
|
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
candidate = filepath.Join(mediaDir, fmt.Sprintf("%s-%d%s", nameOnly, i, ext))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileEntry represents a media file for API responses.
|
||||||
|
type FileEntry struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
OriginalName string `json:"originalName"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
DurationSeconds int `json:"durationSeconds"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
UploadedAt string `json:"uploadedAt"`
|
||||||
|
ModifiedAt string `json:"modifiedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaRecord is the interface for media DB records.
|
||||||
|
type MediaRecord interface {
|
||||||
|
GetID() uint
|
||||||
|
GetFilename() string
|
||||||
|
GetOriginalName() string
|
||||||
|
GetPath() string
|
||||||
|
GetSizeBytes() int64
|
||||||
|
GetDurationSeconds() int
|
||||||
|
GetStatus() string
|
||||||
|
GetTitle() string
|
||||||
|
GetTags() string
|
||||||
|
GetUploadedAt() time.Time
|
||||||
|
GetUpdatedAt() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFiles returns all media files sorted by upload date descending.
|
||||||
|
func ListFiles(db *gorm.DB) ([]FileEntry, error) {
|
||||||
|
type record struct {
|
||||||
|
ID uint
|
||||||
|
Filename string
|
||||||
|
OriginalName string
|
||||||
|
Path string
|
||||||
|
SizeBytes int64
|
||||||
|
DurationSeconds int
|
||||||
|
Status string
|
||||||
|
Title string
|
||||||
|
Tags string
|
||||||
|
UploadedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []record
|
||||||
|
if err := db.Table("media").Order("uploaded_at desc").Find(&records).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
files := make([]FileEntry, 0, len(records))
|
||||||
|
for _, r := range records {
|
||||||
|
files = append(files, FileEntry{
|
||||||
|
ID: r.ID,
|
||||||
|
Name: r.Filename,
|
||||||
|
OriginalName: r.OriginalName,
|
||||||
|
Path: r.Path,
|
||||||
|
Size: r.SizeBytes,
|
||||||
|
DurationSeconds: r.DurationSeconds,
|
||||||
|
Runtime: FormatDuration(r.DurationSeconds),
|
||||||
|
Status: r.Status,
|
||||||
|
Title: r.Title,
|
||||||
|
Tags: r.Tags,
|
||||||
|
UploadedAt: r.UploadedAt.UTC().Format(time.RFC3339),
|
||||||
|
ModifiedAt: r.UpdatedAt.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
if files[i].UploadedAt == files[j].UploadedAt {
|
||||||
|
return files[i].Name < files[j].Name
|
||||||
|
}
|
||||||
|
return files[i].UploadedAt > files[j].UploadedAt
|
||||||
|
})
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePlaylistEntries parses playlist content into schedule entries.
|
||||||
|
type ScheduleEntry struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
DurationSeconds int `json:"durationSeconds"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePlaylist parses playlist text content into entries.
|
||||||
|
func ParsePlaylist(content string) []ScheduleEntry {
|
||||||
|
entries := []ScheduleEntry{}
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
for _, rawLine := range lines {
|
||||||
|
line := strings.TrimSpace(rawLine)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "file ") {
|
||||||
|
line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
|
||||||
|
line = strings.Trim(line, "'\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = append(entries, ScheduleEntry{
|
||||||
|
Index: len(entries) + 1,
|
||||||
|
Path: line,
|
||||||
|
Label: filepath.Base(line),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadSessionDir returns the directory for an upload session's chunks.
|
||||||
|
func UploadSessionDir(mediaDir, uploadID string) string {
|
||||||
|
return filepath.Join(mediaDir, ".uploads", uploadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadSessionMetaPath returns the path to the session metadata file.
|
||||||
|
func UploadSessionMetaPath(mediaDir, uploadID string) string {
|
||||||
|
return filepath.Join(UploadSessionDir(mediaDir, uploadID), "session.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadChunkPath returns the path for a specific chunk file.
|
||||||
|
func UploadChunkPath(mediaDir, uploadID string, chunkIndex int) string {
|
||||||
|
return filepath.Join(UploadSessionDir(mediaDir, uploadID), fmt.Sprintf("chunk-%06d.part", chunkIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUploadedChunks counts how many chunks exist for a session.
|
||||||
|
func CountUploadedChunks(mediaDir, uploadID string, totalChunks int) int {
|
||||||
|
uploaded := 0
|
||||||
|
for i := 0; i < totalChunks; i++ {
|
||||||
|
if _, err := os.Stat(UploadChunkPath(mediaDir, uploadID, i)); err == nil {
|
||||||
|
uploaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uploaded
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateUploadID checks that an upload ID is a valid hex string of appropriate length.
|
||||||
|
func ValidateUploadID(uploadID string) bool {
|
||||||
|
if len(uploadID) < 16 || len(uploadID) > 128 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range uploadID {
|
||||||
|
if (r < 'a' || r > 'f') && (r < '0' || r > '9') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMediaIDFromPath extracts a media ID from a URL path like /api/admin/media/123.
|
||||||
|
func GetMediaIDFromPath(path, prefix string) (uint, error) {
|
||||||
|
if !strings.HasPrefix(path, prefix) {
|
||||||
|
return 0, fmt.Errorf("invalid media path")
|
||||||
|
}
|
||||||
|
idPart := strings.TrimPrefix(path, prefix)
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
if idPart == "" || strings.Contains(idPart, "/") {
|
||||||
|
return 0, fmt.Errorf("invalid media id")
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseUint(idPart, 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return 0, fmt.Errorf("invalid media id")
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint(id), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatDuration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
seconds int
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"zero", 0, "00:00:00"},
|
||||||
|
{"one second", 1, "00:00:01"},
|
||||||
|
{"one minute", 60, "00:01:00"},
|
||||||
|
{"one hour", 3600, "01:00:00"},
|
||||||
|
{"mixed", 3661, "01:01:01"},
|
||||||
|
{"large", 86399, "23:59:59"},
|
||||||
|
{"negative", -5, "00:00:00"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := FormatDuration(tt.seconds); got != tt.expected {
|
||||||
|
t.Errorf("FormatDuration(%d) = %q, want %q", tt.seconds, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAllowedExtension(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"video.mp4", true},
|
||||||
|
{"video.mov", true},
|
||||||
|
{"video.mkv", true},
|
||||||
|
{"video.webm", true},
|
||||||
|
{"video.m4v", true},
|
||||||
|
{"video.ts", true},
|
||||||
|
{"video.MP4", true},
|
||||||
|
{"video.avi", false},
|
||||||
|
{"video.flv", false},
|
||||||
|
{"video.wmv", false},
|
||||||
|
{"video", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := IsAllowedExtension(tt.name); got != tt.expected {
|
||||||
|
t.Errorf("IsAllowedExtension(%q) = %v, want %v", tt.name, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAllowedMime(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
mime string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"video/mp4", true},
|
||||||
|
{"video/quicktime", true},
|
||||||
|
{"video/x-matroska", true},
|
||||||
|
{"video/webm", true},
|
||||||
|
{"application/octet-stream", true},
|
||||||
|
{"", true},
|
||||||
|
{"image/jpeg", false},
|
||||||
|
{"audio/mpeg", false},
|
||||||
|
{"text/plain", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.mime, func(t *testing.T) {
|
||||||
|
if got := IsAllowedMime(tt.mime); got != tt.expected {
|
||||||
|
t.Errorf("IsAllowedMime(%q) = %v, want %v", tt.mime, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateUploadID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
id string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"abcdef0123456789", true}, // 16 hex chars
|
||||||
|
{"abcdef0123456789abcdef0123456789", true}, // 32 hex chars
|
||||||
|
{"abc", false}, // too short
|
||||||
|
{"abcdef012345678", false}, // 15 chars (too short)
|
||||||
|
{"abcdef0123456789g", false}, // non-hex char
|
||||||
|
{"ABCDEF0123456789", false}, // uppercase (not allowed)
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.id, func(t *testing.T) {
|
||||||
|
if got := ValidateUploadID(tt.id); got != tt.expected {
|
||||||
|
t.Errorf("ValidateUploadID(%q) = %v, want %v", tt.id, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePlaylist(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
expected int
|
||||||
|
first string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple",
|
||||||
|
content: "/videos/video1.mp4\n/videos/video2.mp4\n",
|
||||||
|
expected: 2,
|
||||||
|
first: "/videos/video1.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with file prefix",
|
||||||
|
content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n",
|
||||||
|
expected: 2,
|
||||||
|
first: "/videos/video1.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with comments and blanks",
|
||||||
|
content: "# comment\n\n/videos/video1.mp4\n\n# another comment\n/videos/video2.mp4\n",
|
||||||
|
expected: 2,
|
||||||
|
first: "/videos/video1.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
content: "",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only comments",
|
||||||
|
content: "# comment\n# another\n",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
entries := ParsePlaylist(tt.content)
|
||||||
|
if len(entries) != tt.expected {
|
||||||
|
t.Errorf("ParsePlaylist() len = %d, want %d", len(entries), tt.expected)
|
||||||
|
}
|
||||||
|
if tt.expected > 0 && entries[0].Path != tt.first {
|
||||||
|
t.Errorf("ParsePlaylist()[0].Path = %q, want %q", entries[0].Path, tt.first)
|
||||||
|
}
|
||||||
|
// Check indices
|
||||||
|
for i, e := range entries {
|
||||||
|
if e.Index != i+1 {
|
||||||
|
t.Errorf("ParsePlaylist()[%d].Index = %d, want %d", i, e.Index, i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMediaIDFromPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
prefix string
|
||||||
|
expected uint
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{"/api/admin/media/123", "/api/admin/media/", 123, false},
|
||||||
|
{"/api/admin/media/1", "/api/admin/media/", 1, false},
|
||||||
|
{"/api/admin/media/", "/api/admin/media/", 0, true},
|
||||||
|
{"/api/admin/media/abc", "/api/admin/media/", 0, true},
|
||||||
|
{"/api/admin/media/0", "/api/admin/media/", 0, true},
|
||||||
|
{"/other/path", "/api/admin/media/", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.path, func(t *testing.T) {
|
||||||
|
id, err := GetMediaIDFromPath(tt.path, tt.prefix)
|
||||||
|
if tt.expectErr && err == nil {
|
||||||
|
t.Errorf("GetMediaIDFromPath(%q) expected error, got nil", tt.path)
|
||||||
|
}
|
||||||
|
if !tt.expectErr && err != nil {
|
||||||
|
t.Errorf("GetMediaIDFromPath(%q) error = %v", tt.path, err)
|
||||||
|
}
|
||||||
|
if id != tt.expected {
|
||||||
|
t.Errorf("GetMediaIDFromPath(%q) = %d, want %d", tt.path, id, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUniqueTargetPath(t *testing.T) {
|
||||||
|
// Test that the function returns a path with the mediaDir prefix
|
||||||
|
// and the correct filename. We can't easily test collision behavior
|
||||||
|
// without filesystem setup, but we can verify the basic path.
|
||||||
|
expected := filepath.Join("/tmp/media", "video.mp4")
|
||||||
|
path := UniqueTargetPath("/tmp/media", "video.mp4")
|
||||||
|
if path != expected {
|
||||||
|
t.Errorf("UniqueTargetPath() = %q, want %q", path, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizationArgs(t *testing.T) {
|
||||||
|
args := normalizationArgs("/in/movie.mov", "/out/movie.normalized.mp4")
|
||||||
|
joined := strings.Join(args, " ")
|
||||||
|
for _, want := range []string{
|
||||||
|
"-map 0:v:0",
|
||||||
|
"-map 0:a:0?",
|
||||||
|
"-c:v libx264",
|
||||||
|
"-r 30",
|
||||||
|
"-g 60",
|
||||||
|
"-keyint_min 60",
|
||||||
|
"-pix_fmt yuv420p",
|
||||||
|
"-c:a aac",
|
||||||
|
"-ar 48000",
|
||||||
|
"-ac 2",
|
||||||
|
"-avoid_negative_ts make_zero",
|
||||||
|
"/out/movie.normalized.mp4",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Errorf("normalization args missing %q: %v", want, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeVideo_FFmpegError(t *testing.T) {
|
||||||
|
origRun := runCommand
|
||||||
|
t.Cleanup(func() { runCommand = origRun })
|
||||||
|
|
||||||
|
runCommand = func(ctx context.Context, name string, args ...string) error {
|
||||||
|
if name != "ffmpeg" {
|
||||||
|
t.Errorf("runCommand name = %q, want ffmpeg", name)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("simulated ffmpeg failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := normalizeVideo("/in/a.mov", "/out/a.normalized.mp4"); err == nil {
|
||||||
|
t.Fatal("expected error when ffmpeg fails")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"chat/src/auth"
|
||||||
|
"chat/src/db"
|
||||||
|
"chat/src/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
var requestDurationBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2, 5}
|
||||||
|
|
||||||
|
var chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
|
||||||
|
|
||||||
|
type requestDurationHistogram struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buckets []float64
|
||||||
|
counts []uint64
|
||||||
|
sum float64
|
||||||
|
total uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRequestDurationHistogram(buckets []float64) *requestDurationHistogram {
|
||||||
|
return &requestDurationHistogram{
|
||||||
|
buckets: append([]float64(nil), buckets...),
|
||||||
|
counts: make([]uint64, len(buckets)+1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *requestDurationHistogram) observe(durationSeconds float64) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
h.total++
|
||||||
|
h.sum += durationSeconds
|
||||||
|
for index, bucket := range h.buckets {
|
||||||
|
if durationSeconds <= bucket {
|
||||||
|
h.counts[index]++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.counts[len(h.counts)-1]++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *requestDurationHistogram) snapshot() (counts []uint64, sum float64, total uint64) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
counts = append([]uint64(nil), h.counts...)
|
||||||
|
return counts, h.sum, h.total
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObserveRequestDuration records a chat HTTP request duration in seconds.
|
||||||
|
func ObserveRequestDuration(duration time.Duration) {
|
||||||
|
chatRequestDurationHistogram.observe(duration.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapRequestTiming records request duration for the provided handler.
|
||||||
|
func WrapRequestTiming(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startedAt := time.Now()
|
||||||
|
defer func() { ObserveRequestDuration(time.Since(startedAt)) }()
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMetrics returns Prometheus-format metrics for the chat service.
|
||||||
|
func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionStore *auth.SessionStore, dbDriver, instanceID string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var storedMessages int64
|
||||||
|
if gormDB != nil {
|
||||||
|
_ = gormDB.Model(&db.Message{}).Count(&storedMessages).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
var moderationActions int64
|
||||||
|
if gormDB != nil {
|
||||||
|
_ = gormDB.Model(&db.ModerationLog{}).Count(&moderationActions).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
redisEnabled := 0
|
||||||
|
redisUp := 0
|
||||||
|
redisClients := 0
|
||||||
|
redisMemoryUsedBytes := int64(0)
|
||||||
|
redisMemoryMaxBytes := int64(0)
|
||||||
|
redisMemoryUsageRatio := 0.0
|
||||||
|
if redis != nil {
|
||||||
|
redisEnabled = 1
|
||||||
|
if err := redis.Ping(context.Background()); err == nil {
|
||||||
|
redisUp = 1
|
||||||
|
if info, err := redis.Info(context.Background(), "clients"); err == nil {
|
||||||
|
for _, line := range strings.Split(info, "\n") {
|
||||||
|
if strings.HasPrefix(line, "connected_clients:") {
|
||||||
|
if value, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "connected_clients:"))); err == nil {
|
||||||
|
redisClients = value
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info, err := redis.Info(context.Background(), "memory"); err == nil {
|
||||||
|
if value := parseRedisMemoryBytes(info, "used_memory_human"); value > 0 {
|
||||||
|
redisMemoryUsedBytes = value
|
||||||
|
}
|
||||||
|
if value := parseRedisMemoryBytes(info, "maxmemory_human"); value > 0 {
|
||||||
|
redisMemoryMaxBytes = value
|
||||||
|
} else if value := parseRedisMemoryBytes(info, "maxmemory"); value > 0 {
|
||||||
|
redisMemoryMaxBytes = value
|
||||||
|
}
|
||||||
|
if redisMemoryMaxBytes > 0 {
|
||||||
|
redisMemoryUsageRatio = math.Min(float64(redisMemoryUsedBytes)/float64(redisMemoryMaxBytes), 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedClients := 0
|
||||||
|
dmsSent := int64(0)
|
||||||
|
if hub != nil {
|
||||||
|
connectedClients = hub.ConnectedClients()
|
||||||
|
dmsSent = hub.DMsSent
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSessions := 0
|
||||||
|
if sessionStore != nil {
|
||||||
|
activeSessions = sessionStore.CountActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
|
|
||||||
|
writeLine := func(name string, value any) {
|
||||||
|
fmt.Fprintf(w, "%s %v\n", name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_connected_clients Current connected WebSocket clients.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_connected_clients gauge")
|
||||||
|
writeLine("linearvoid_chat_connected_clients", connectedClients)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_messages_total Total stored chat messages.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
|
||||||
|
writeLine("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")
|
||||||
|
writeLine("linearvoid_chat_dms_total", dmsSent)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
|
||||||
|
writeLine("linearvoid_chat_moderation_actions_total", moderationActions)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_enabled Redis configured for chat service.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_enabled gauge")
|
||||||
|
writeLine("linearvoid_chat_redis_enabled", redisEnabled)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_up Redis reachable from chat service.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_up gauge")
|
||||||
|
writeLine("linearvoid_chat_redis_up", redisUp)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_connected_clients Redis connected client count seen by chat service.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge")
|
||||||
|
writeLine("linearvoid_chat_redis_connected_clients", redisClients)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_used_bytes Redis used memory reported by INFO memory.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_used_bytes gauge")
|
||||||
|
writeLine("linearvoid_chat_redis_memory_used_bytes", redisMemoryUsedBytes)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_max_bytes Redis maxmemory limit reported by INFO memory.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_max_bytes gauge")
|
||||||
|
writeLine("linearvoid_chat_redis_memory_max_bytes", redisMemoryMaxBytes)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_usage_ratio Redis memory usage ratio (used / max).")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_usage_ratio gauge")
|
||||||
|
writeLine("linearvoid_chat_redis_memory_usage_ratio", redisMemoryUsageRatio)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge")
|
||||||
|
writeLine("linearvoid_chat_admin_sessions_active", activeSessions)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
|
||||||
|
fmt.Fprintf(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\"%s\",instance_id=\"%s\"} 1\n", dbDriver, instanceID)
|
||||||
|
|
||||||
|
counts, sum, total := chatRequestDurationHistogram.snapshot()
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_request_duration_seconds HTTP request duration for chat API handlers.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_request_duration_seconds histogram")
|
||||||
|
cumulative := uint64(0)
|
||||||
|
for index, bucket := range requestDurationBuckets {
|
||||||
|
cumulative += counts[index]
|
||||||
|
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"%g\"} %d\n", bucket, cumulative)
|
||||||
|
}
|
||||||
|
cumulative += counts[len(counts)-1]
|
||||||
|
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"+Inf\"} %d\n", cumulative)
|
||||||
|
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_sum %g\n", sum)
|
||||||
|
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_count %d\n", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHealth returns a simple health check endpoint.
|
||||||
|
func HandleHealth() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `{"status":"healthy","service":"chat","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleNotFound returns a 404 JSON response for all unhandled paths.
|
||||||
|
func HandleNotFound() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"not found"}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRedisMemoryBytes(info, key string) int64 {
|
||||||
|
for _, line := range strings.Split(info, "\n") {
|
||||||
|
if !strings.HasPrefix(line, key+":") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(strings.TrimPrefix(line, key+":"))
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if raw, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return parseHumanBytes(value)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHumanBytes(value string) int64 {
|
||||||
|
value = strings.TrimSpace(strings.ToUpper(value))
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
multiplier := float64(1)
|
||||||
|
suffixes := map[string]float64{
|
||||||
|
"B": 1,
|
||||||
|
"K": 1024,
|
||||||
|
"KB": 1024,
|
||||||
|
"M": 1024 * 1024,
|
||||||
|
"MB": 1024 * 1024,
|
||||||
|
"G": 1024 * 1024 * 1024,
|
||||||
|
"GB": 1024 * 1024 * 1024,
|
||||||
|
"T": 1024 * 1024 * 1024 * 1024,
|
||||||
|
"TB": 1024 * 1024 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
for suffix, factor := range suffixes {
|
||||||
|
if strings.HasSuffix(value, suffix) {
|
||||||
|
multiplier = factor
|
||||||
|
value = strings.TrimSpace(strings.TrimSuffix(value, suffix))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64(parsed * multiplier)
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"chat/src/auth"
|
||||||
|
"chat/src/db"
|
||||||
|
"chat/src/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleHealth(t *testing.T) {
|
||||||
|
handler := HandleHealth()
|
||||||
|
req := httptest.NewRequest("GET", "/health", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, `"healthy"`) {
|
||||||
|
t.Errorf("body missing healthy: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `"chat"`) {
|
||||||
|
t.Errorf("body missing service name: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleNotFound(t *testing.T) {
|
||||||
|
handler := HandleNotFound()
|
||||||
|
req := httptest.NewRequest("GET", "/nonexistent", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, `"not found"`) {
|
||||||
|
t.Errorf("body missing not found: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMetrics(t *testing.T) {
|
||||||
|
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
|
||||||
|
ObserveRequestDuration(100 * time.Millisecond)
|
||||||
|
ObserveRequestDuration(750 * time.Millisecond)
|
||||||
|
|
||||||
|
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open test DB: %v", err)
|
||||||
|
}
|
||||||
|
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
||||||
|
|
||||||
|
// Add some test data
|
||||||
|
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
|
||||||
|
gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u2", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
|
||||||
|
|
||||||
|
sessionStore := auth.NewSessionStore()
|
||||||
|
hub := ws.NewHub(gormDB, nil, "test-instance", 10, 10*time.Second, "admin")
|
||||||
|
hub.DMsSent = 5
|
||||||
|
|
||||||
|
handler := HandleMetrics(gormDB, nil, hub, sessionStore, "sqlite", "test-instance")
|
||||||
|
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
// Check required metric names
|
||||||
|
requiredMetrics := []string{
|
||||||
|
"linearvoid_chat_connected_clients",
|
||||||
|
"linearvoid_chat_messages_total",
|
||||||
|
"linearvoid_chat_dms_total",
|
||||||
|
"linearvoid_chat_moderation_actions_total",
|
||||||
|
"linearvoid_chat_redis_enabled",
|
||||||
|
"linearvoid_chat_redis_up",
|
||||||
|
"linearvoid_chat_redis_connected_clients",
|
||||||
|
"linearvoid_chat_redis_memory_used_bytes",
|
||||||
|
"linearvoid_chat_redis_memory_max_bytes",
|
||||||
|
"linearvoid_chat_redis_memory_usage_ratio",
|
||||||
|
"linearvoid_chat_admin_sessions_active",
|
||||||
|
"linearvoid_chat_build_info",
|
||||||
|
"linearvoid_chat_request_duration_seconds",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, metric := range requiredMetrics {
|
||||||
|
if !strings.Contains(body, metric) {
|
||||||
|
t.Errorf("body missing metric %s", metric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check build_info has instance_id label
|
||||||
|
if !strings.Contains(body, `instance_id="test-instance"`) {
|
||||||
|
t.Error("build_info missing instance_id label")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check values
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_messages_total 1") {
|
||||||
|
t.Error("messages_total should be 1")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_moderation_actions_total 1") {
|
||||||
|
t.Error("moderation_actions_total should be 1")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_dms_total 5") {
|
||||||
|
t.Error("dms_total should be 5")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_redis_enabled 0") {
|
||||||
|
t.Error("redis_enabled should be 0 (nil redis)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_bucket{le=\"1\"} 2") {
|
||||||
|
t.Error("request duration histogram should include both observed samples by the 1s bucket")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 2") {
|
||||||
|
t.Error("request duration count should be 2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMetrics_WrongMethod(t *testing.T) {
|
||||||
|
handler := HandleMetrics(nil, nil, nil, nil, "", "")
|
||||||
|
req := httptest.NewRequest("POST", "/metrics", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("status = %d, want 405", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMetrics_WithRedis(t *testing.T) {
|
||||||
|
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
|
||||||
|
ObserveRequestDuration(250 * time.Millisecond)
|
||||||
|
|
||||||
|
gormDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
||||||
|
|
||||||
|
// mockRedis for metrics test
|
||||||
|
mr := &metricsMockRedis{}
|
||||||
|
sessionStore := auth.NewSessionStore()
|
||||||
|
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
|
||||||
|
|
||||||
|
handler := HandleMetrics(gormDB, mr, hub, sessionStore, "sqlite", "inst-123")
|
||||||
|
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_redis_enabled 1") {
|
||||||
|
t.Error("redis_enabled should be 1")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_redis_up 1") {
|
||||||
|
t.Error("redis_up should be 1")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_redis_connected_clients 5") {
|
||||||
|
t.Error("redis_connected_clients should be 5")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_redis_memory_used_bytes 1048576") {
|
||||||
|
t.Error("redis_memory_used_bytes should be 1048576")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_redis_memory_usage_ratio 1") {
|
||||||
|
t.Error("redis_memory_usage_ratio should be 1 when maxmemory is unavailable or equal to used memory in tests")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 1") {
|
||||||
|
t.Error("request duration count should be 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// metricsMockRedis is a minimal mock for metrics tests.
|
||||||
|
type metricsMockRedis struct{}
|
||||||
|
|
||||||
|
func (m *metricsMockRedis) Ping(ctx context.Context) error { return nil }
|
||||||
|
func (m *metricsMockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *metricsMockRedis) Get(ctx context.Context, key string) (string, error) { return "", nil }
|
||||||
|
func (m *metricsMockRedis) Del(ctx context.Context, keys ...string) error { return nil }
|
||||||
|
func (m *metricsMockRedis) Incr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||||
|
func (m *metricsMockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||||
|
func (m *metricsMockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *metricsMockRedis) Exists(ctx context.Context, keys ...string) (int64, error) { return 0, nil }
|
||||||
|
func (m *metricsMockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *metricsMockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub { return nil }
|
||||||
|
func (m *metricsMockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
func (m *metricsMockRedis) Info(ctx context.Context, sections ...string) (string, error) {
|
||||||
|
return "connected_clients:5\nused_memory_human:1M\nmaxmemory_human:1M\n", nil
|
||||||
|
}
|
||||||
|
func (m *metricsMockRedis) Close() error { return nil }
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RedisClient is the subset of Redis operations needed for rate limiting.
|
||||||
|
type RedisClient interface {
|
||||||
|
Incr(ctx context.Context, key string) (int64, error)
|
||||||
|
Expire(ctx context.Context, key string, expiration time.Duration) error
|
||||||
|
Decr(ctx context.Context, key string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckMessageRate checks if a user has exceeded the message rate limit.
|
||||||
|
// Returns true if allowed, false if rate limited.
|
||||||
|
func CheckMessageRate(ctx context.Context, redis RedisClient, username string, max int, window time.Duration) (bool, error) {
|
||||||
|
if redis == nil {
|
||||||
|
return true, nil // No Redis = no rate limiting
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("rate:%s", username)
|
||||||
|
count, err := redis.Incr(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return true, err // Fail open on Redis error
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
_ = redis.Expire(ctx, key, window)
|
||||||
|
}
|
||||||
|
return count <= int64(max), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckLoginRate checks if an IP has exceeded the login attempt limit.
|
||||||
|
// Returns true if allowed, false if rate limited.
|
||||||
|
func CheckLoginRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
|
||||||
|
if redis == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("login_attempts:%s", clientIP)
|
||||||
|
count, err := redis.Incr(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
_ = redis.Expire(ctx, key, 5*time.Minute)
|
||||||
|
}
|
||||||
|
return count <= 5, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckWSConnRate checks if an IP has exceeded the WebSocket connection limit.
|
||||||
|
// Returns true if allowed, false if rate limited.
|
||||||
|
// Caller must call Decr on disconnect.
|
||||||
|
func CheckWSConnRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
|
||||||
|
if redis == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("ws_conn:%s", clientIP)
|
||||||
|
count, err := redis.Incr(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
_ = redis.Expire(ctx, key, 60*time.Second)
|
||||||
|
}
|
||||||
|
return count <= 5, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecrWSConn decrements the WebSocket connection counter for an IP.
|
||||||
|
func DecrWSConn(ctx context.Context, redis RedisClient, clientIP string) {
|
||||||
|
if redis == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("ws_conn:%s", clientIP)
|
||||||
|
_, _ = redis.Decr(ctx, key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockRedis implements RedisClient for testing.
|
||||||
|
type mockRedis struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
store map[string]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockRedis() *mockRedis {
|
||||||
|
return &mockRedis{store: make(map[string]int64)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.store[key]++
|
||||||
|
return m.store[key], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.store[key]--
|
||||||
|
return m.store[key], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckMessageRate(t *testing.T) {
|
||||||
|
redis := newMockRedis()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// First 10 messages allowed
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckMessageRate #%d error = %v", i+1, err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Errorf("CheckMessageRate #%d = false, want true", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11th message blocked
|
||||||
|
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckMessageRate #11 error = %v", err)
|
||||||
|
}
|
||||||
|
if allowed {
|
||||||
|
t.Error("CheckMessageRate #11 = true, want false (rate limited)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different user not affected
|
||||||
|
allowed, err = CheckMessageRate(ctx, redis, "user2", 10, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckMessageRate user2 error = %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Error("CheckMessageRate user2 = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckMessageRate_NilRedis(t *testing.T) {
|
||||||
|
allowed, err := CheckMessageRate(context.Background(), nil, "user1", 10, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckMessageRate nil redis error = %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Error("CheckMessageRate nil redis = false, want true (fail open)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckLoginRate(t *testing.T) {
|
||||||
|
redis := newMockRedis()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// First 5 attempts allowed
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckLoginRate #%d error = %v", i+1, err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Errorf("CheckLoginRate #%d = false, want true", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6th attempt blocked
|
||||||
|
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckLoginRate #6 error = %v", err)
|
||||||
|
}
|
||||||
|
if allowed {
|
||||||
|
t.Error("CheckLoginRate #6 = true, want false (rate limited)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different IP not affected
|
||||||
|
allowed, err = CheckLoginRate(ctx, redis, "10.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckLoginRate other IP error = %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Error("CheckLoginRate other IP = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckLoginRate_NilRedis(t *testing.T) {
|
||||||
|
allowed, err := CheckLoginRate(context.Background(), nil, "192.168.1.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckLoginRate nil redis error = %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Error("CheckLoginRate nil redis = false, want true (fail open)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckWSConnRate(t *testing.T) {
|
||||||
|
redis := newMockRedis()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// First 5 connections allowed
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckWSConnRate #%d error = %v", i+1, err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Errorf("CheckWSConnRate #%d = false, want true", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6th connection blocked
|
||||||
|
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckWSConnRate #6 error = %v", err)
|
||||||
|
}
|
||||||
|
if allowed {
|
||||||
|
t.Error("CheckWSConnRate #6 = true, want false (rate limited)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckWSConnRate_NilRedis(t *testing.T) {
|
||||||
|
allowed, err := CheckWSConnRate(context.Background(), nil, "192.168.1.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckWSConnRate nil redis error = %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Error("CheckWSConnRate nil redis = false, want true (fail open)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecrWSConn(t *testing.T) {
|
||||||
|
redis := newMockRedis()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Increment to 5
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6th should be blocked
|
||||||
|
allowed, _ := CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||||
|
if allowed {
|
||||||
|
t.Error("6th connection should be blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrement twice (simulate 2 disconnects)
|
||||||
|
DecrWSConn(ctx, redis, "192.168.1.1")
|
||||||
|
DecrWSConn(ctx, redis, "192.168.1.1")
|
||||||
|
|
||||||
|
// Now should be allowed again (counter 6→4, then incr→5)
|
||||||
|
allowed, _ = CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||||
|
if !allowed {
|
||||||
|
t.Error("after 2x DecrWSConn, connection should be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecrWSConn_NilRedis(t *testing.T) {
|
||||||
|
// Should not panic
|
||||||
|
DecrWSConn(context.Background(), nil, "192.168.1.1")
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"chat/src/db"
|
||||||
|
"chat/src/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Upgrader is the WebSocket upgrader configuration.
|
||||||
|
var Upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientInfo stores metadata about a connected WebSocket client.
|
||||||
|
type ClientInfo struct {
|
||||||
|
Username string
|
||||||
|
Room string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub manages all WebSocket connections and message routing.
|
||||||
|
type Hub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
clients map[*websocket.Conn]bool
|
||||||
|
clientMeta map[*websocket.Conn]ClientInfo
|
||||||
|
DB *gorm.DB
|
||||||
|
Redis db.RedisClient
|
||||||
|
InstanceID string
|
||||||
|
RateLimitMax int
|
||||||
|
RateLimitWindow time.Duration
|
||||||
|
AdminUsername string
|
||||||
|
DMsSent int64
|
||||||
|
|
||||||
|
presenceMu sync.Mutex
|
||||||
|
presenceTimers map[string]*time.Timer
|
||||||
|
PresenceGracePeriod time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHub creates a new WebSocket hub.
|
||||||
|
func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax int, rateWindow time.Duration, adminUser string) *Hub {
|
||||||
|
return &Hub{
|
||||||
|
clients: make(map[*websocket.Conn]bool),
|
||||||
|
clientMeta: make(map[*websocket.Conn]ClientInfo),
|
||||||
|
DB: gormDB,
|
||||||
|
Redis: redis,
|
||||||
|
InstanceID: instanceID,
|
||||||
|
RateLimitMax: rateMax,
|
||||||
|
RateLimitWindow: rateWindow,
|
||||||
|
AdminUsername: adminUser,
|
||||||
|
presenceTimers: make(map[string]*time.Timer),
|
||||||
|
PresenceGracePeriod: 5 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectedClients returns the number of currently connected clients.
|
||||||
|
func (h *Hub) ConnectedClients() int {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return len(h.clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast sends a message to all connected clients and persists to DB.
|
||||||
|
// System messages (join/leave announcements, moderation notices) fan out but
|
||||||
|
// are not persisted, so the chat-log table only holds real chat messages.
|
||||||
|
func (h *Hub) Broadcast(msg db.Message) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
// Persist to configured SQL backend (chat messages only).
|
||||||
|
if h.DB != nil && msg.Type != "system" {
|
||||||
|
h.DB.Create(&msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local fan-out
|
||||||
|
for client := range h.clients {
|
||||||
|
if err := client.WriteJSON(msg); err != nil {
|
||||||
|
client.Close()
|
||||||
|
delete(h.clients, client)
|
||||||
|
delete(h.clientMeta, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis pub/sub for multi-instance fan-out
|
||||||
|
if h.Redis != nil && msg.Room != "" {
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"instance": h.InstanceID,
|
||||||
|
"msg": msg,
|
||||||
|
})
|
||||||
|
_ = h.Redis.Publish(context.Background(), fmt.Sprintf("room:%s:messages", msg.Room), payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleLeaveAnnouncement broadcasts a "left" message after a short grace
|
||||||
|
// period so quick reconnects don't produce join/leave spam.
|
||||||
|
func (h *Hub) scheduleLeaveAnnouncement(room, username string) {
|
||||||
|
key := room + "\x00" + username
|
||||||
|
h.presenceMu.Lock()
|
||||||
|
if h.presenceTimers == nil {
|
||||||
|
h.presenceTimers = make(map[string]*time.Timer)
|
||||||
|
}
|
||||||
|
grace := h.PresenceGracePeriod
|
||||||
|
if grace <= 0 {
|
||||||
|
grace = 5 * time.Second
|
||||||
|
}
|
||||||
|
h.presenceTimers[key] = time.AfterFunc(grace, func() {
|
||||||
|
h.presenceMu.Lock()
|
||||||
|
delete(h.presenceTimers, key)
|
||||||
|
h.presenceMu.Unlock()
|
||||||
|
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()})
|
||||||
|
})
|
||||||
|
h.presenceMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancelLeaveAnnouncement cancels a pending "left" announcement for a
|
||||||
|
// room+user and reports whether one existed (i.e. a reconnect within grace).
|
||||||
|
func (h *Hub) cancelLeaveAnnouncement(room, username string) bool {
|
||||||
|
key := room + "\x00" + username
|
||||||
|
h.presenceMu.Lock()
|
||||||
|
defer h.presenceMu.Unlock()
|
||||||
|
if timer, ok := h.presenceTimers[key]; ok {
|
||||||
|
timer.Stop()
|
||||||
|
delete(h.presenceTimers, key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastTyping notifies other users in the room that a user is typing.
|
||||||
|
func (h *Hub) BroadcastTyping(username, room string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
for conn, info := range h.clientMeta {
|
||||||
|
if info.Room == room && info.Username != username {
|
||||||
|
_ = conn.WriteJSON(db.Message{Room: room, Username: username, Text: "", Type: "typing", Timestamp: time.Now()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushChatHistory sends the last N messages for a room via WebSocket.
|
||||||
|
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
|
||||||
|
if h.DB == nil || room == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var messages []db.Message
|
||||||
|
h.DB.Where("room = ? AND type <> ?", room, "system").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]
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
for _, msg := range messages {
|
||||||
|
conn.WriteJSON(msg)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleConnection handles a new WebSocket connection.
|
||||||
|
func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clientIP := r.Header.Get("X-Forwarded-For")
|
||||||
|
if clientIP == "" {
|
||||||
|
clientIP = r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP-based connection rate limiting
|
||||||
|
allowed, err := ratelimit.CheckWSConnRate(context.Background(), h.Redis, clientIP)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WS rate limit check error: %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
http.Error(w, "too many connections from this IP", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ratelimit.DecrWSConn(context.Background(), h.Redis, clientIP)
|
||||||
|
|
||||||
|
conn, err := Upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Upgrade error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := r.URL.Query().Get("username")
|
||||||
|
room := r.URL.Query().Get("room")
|
||||||
|
if username == "" {
|
||||||
|
username = fmt.Sprintf("anon_%d", time.Now().UnixNano()%10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[conn] = true
|
||||||
|
h.clientMeta[conn] = ClientInfo{Username: username, Room: room}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
// Announce presence (ADR-007). A reconnect within the grace window cancels
|
||||||
|
// the pending "left" announcement and skips a redundant "joined".
|
||||||
|
rejoined := h.cancelLeaveAnnouncement(room, username)
|
||||||
|
if h.Redis != nil && room != "" {
|
||||||
|
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||||
|
if !rejoined {
|
||||||
|
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push chat history on join (last 50 messages)
|
||||||
|
h.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 h.Redis != nil && room != "" {
|
||||||
|
_ = h.Redis.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() {
|
||||||
|
close(heartbeatStop)
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.clients, conn)
|
||||||
|
delete(h.clientMeta, conn)
|
||||||
|
h.mu.Unlock()
|
||||||
|
if h.Redis != nil && room != "" {
|
||||||
|
_ = h.Redis.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
|
||||||
|
h.scheduleLeaveAnnouncement(room, username)
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
var msg db.Message
|
||||||
|
if err := conn.ReadJSON(&msg); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Timestamp = time.Now()
|
||||||
|
msg.Username = username // enforce from query param for security
|
||||||
|
|
||||||
|
// Check if banned before processing
|
||||||
|
if h.Redis != nil {
|
||||||
|
if isBanned, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("banned:%s", username)); isBanned > 0 {
|
||||||
|
conn.WriteJSON(db.Message{Type: "system", Text: "You are banned from chat.", Timestamp: time.Now()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isMuted, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("muted:%s", username)); isMuted > 0 {
|
||||||
|
conn.WriteJSON(db.Message{Type: "system", Text: "You are muted.", Timestamp: time.Now()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting check
|
||||||
|
allowed, _ := ratelimit.CheckMessageRate(context.Background(), h.Redis, msg.Username, h.RateLimitMax, h.RateLimitWindow)
|
||||||
|
if !allowed {
|
||||||
|
conn.WriteJSON(db.Message{Type: "system", Text: "Rate limit exceeded. Please slow down.", Timestamp: time.Now()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle moderation commands
|
||||||
|
if strings.HasPrefix(msg.Text, "/mod ") || strings.HasPrefix(msg.Text, "/mute ") || strings.HasPrefix(msg.Text, "/ban ") {
|
||||||
|
h.handleModerationCommand(msg, conn)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle typing indicator (ephemeral — not persisted, not rate-limited)
|
||||||
|
if msg.Type == "typing" {
|
||||||
|
h.BroadcastTyping(msg.Username, msg.Room)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle private DMs
|
||||||
|
if strings.HasPrefix(msg.Room, "dm:") {
|
||||||
|
h.handlePrivateDM(msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Type = "public"
|
||||||
|
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
|
||||||
|
h.Broadcast(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePrivateDM routes private messages to the specific user pair.
|
||||||
|
func (h *Hub) handlePrivateDM(msg db.Message) {
|
||||||
|
msg.Type = "private"
|
||||||
|
h.DMsSent++
|
||||||
|
|
||||||
|
// Persist for history
|
||||||
|
if h.DB != nil {
|
||||||
|
h.DB.Create(&msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver to target if online
|
||||||
|
h.mu.Lock()
|
||||||
|
targetOnline := false
|
||||||
|
for conn, info := range h.clientMeta {
|
||||||
|
if info.Username == msg.Room || strings.Contains(msg.Room, info.Username) {
|
||||||
|
if err := conn.WriteJSON(msg); err == nil {
|
||||||
|
targetOnline = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if !targetOnline {
|
||||||
|
log.Printf("DM stored for offline user: %s -> room %s", msg.Username, msg.Room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also deliver to sender for confirmation
|
||||||
|
h.mu.Lock()
|
||||||
|
for conn, info := range h.clientMeta {
|
||||||
|
if info.Username == msg.Username {
|
||||||
|
conn.WriteJSON(msg)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("Private DM from %s to room %s: %s", msg.Username, msg.Room, msg.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logModerationAction logs an action to the immutable ModerationLog table.
|
||||||
|
func (h *Hub) logModerationAction(action, target, moderator, reason string) {
|
||||||
|
logEntry := db.ModerationLog{
|
||||||
|
Action: action,
|
||||||
|
Target: target,
|
||||||
|
Moderator: moderator,
|
||||||
|
Reason: reason,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
if h.DB != nil {
|
||||||
|
h.DB.Create(&logEntry)
|
||||||
|
}
|
||||||
|
log.Printf("Moderation action logged: %s %s by %s - %s", action, target, moderator, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleModerationCommand processes /mod, /mute, /ban commands.
|
||||||
|
func (h *Hub) handleModerationCommand(msg db.Message, conn *websocket.Conn) {
|
||||||
|
parts := strings.Fields(msg.Text)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
conn.WriteJSON(db.Message{Type: "system", Text: "Usage: /mute <user> <duration_m> <reason> or /ban <user> <reason>", Timestamp: time.Now()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Username != h.AdminUsername {
|
||||||
|
conn.WriteJSON(db.Message{Type: "system", Text: "Only moderators can use moderation commands.", Timestamp: time.Now()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action := strings.TrimPrefix(parts[0], "/")
|
||||||
|
target := parts[1]
|
||||||
|
reason := strings.Join(parts[2:], " ")
|
||||||
|
|
||||||
|
if h.Redis != 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = h.Redis.Set(context.Background(), fmt.Sprintf("muted:%s", target), "1", time.Duration(durationMinutes)*time.Minute)
|
||||||
|
case "ban":
|
||||||
|
_ = h.Redis.Set(context.Background(), fmt.Sprintf("banned:%s", target), "1", 0) // no expiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logModerationAction(action, target, msg.Username, reason)
|
||||||
|
|
||||||
|
h.Broadcast(db.Message{
|
||||||
|
Room: msg.Room,
|
||||||
|
Username: "system",
|
||||||
|
Type: "system",
|
||||||
|
Text: fmt.Sprintf("%s was %s by %s: %s", target, action, msg.Username, reason),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeRoomMessages listens for Redis pub/sub messages and fans out locally.
|
||||||
|
func (h *Hub) SubscribeRoomMessages() {
|
||||||
|
if h.Redis == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pubsub := h.Redis.PSubscribe(context.Background(), "room:*:messages")
|
||||||
|
defer pubsub.Close()
|
||||||
|
|
||||||
|
log.Printf("Redis pub/sub subscriber started (instance %s)", h.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 = h.Redis.PSubscribe(context.Background(), "room:*:messages")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelope struct {
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
Msg db.Message `json:"msg"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip messages originating from this instance
|
||||||
|
if envelope.Instance == h.InstanceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan out to local clients
|
||||||
|
h.mu.Lock()
|
||||||
|
for client := range h.clients {
|
||||||
|
client.WriteJSON(envelope.Msg)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanPresence returns all active presence entries from Redis.
|
||||||
|
func (h *Hub) ScanPresence(ctx context.Context) ([]db.Presence, error) {
|
||||||
|
if h.Redis == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
keys, _, err := h.Redis.Scan(ctx, 0, "presence:*", 200)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var presences []db.Presence
|
||||||
|
for _, key := range keys {
|
||||||
|
parts := strings.SplitN(key, ":", 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
presences = append(presences, db.Presence{
|
||||||
|
Room: parts[1],
|
||||||
|
Username: parts[2],
|
||||||
|
Online: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return presences, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MuteUser mutes a user for a duration.
|
||||||
|
func (h *Hub) MuteUser(ctx context.Context, username string, duration time.Duration) error {
|
||||||
|
if h.Redis == nil {
|
||||||
|
return fmt.Errorf("redis unavailable")
|
||||||
|
}
|
||||||
|
return h.Redis.Set(ctx, fmt.Sprintf("muted:%s", username), "1", duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmuteUser removes a mute.
|
||||||
|
func (h *Hub) UnmuteUser(ctx context.Context, username string) error {
|
||||||
|
if h.Redis == nil {
|
||||||
|
return fmt.Errorf("redis unavailable")
|
||||||
|
}
|
||||||
|
return h.Redis.Del(ctx, fmt.Sprintf("muted:%s", username))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BanUser bans a user permanently.
|
||||||
|
func (h *Hub) BanUser(ctx context.Context, username string) error {
|
||||||
|
if h.Redis == nil {
|
||||||
|
return fmt.Errorf("redis unavailable")
|
||||||
|
}
|
||||||
|
return h.Redis.Set(ctx, fmt.Sprintf("banned:%s", username), "1", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnbanUser removes a ban.
|
||||||
|
func (h *Hub) UnbanUser(ctx context.Context, username string) error {
|
||||||
|
if h.Redis == nil {
|
||||||
|
return fmt.Errorf("redis unavailable")
|
||||||
|
}
|
||||||
|
return h.Redis.Del(ctx, fmt.Sprintf("banned:%s", username))
|
||||||
|
}
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"chat/src/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockRedis implements db.RedisClient for testing.
|
||||||
|
type mockRedis struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
store map[string]string
|
||||||
|
pubsubs []*mockPubSub
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockRedis() *mockRedis {
|
||||||
|
return &mockRedis{store: make(map[string]string)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRedis) Ping(ctx context.Context) error { return nil }
|
||||||
|
func (m *mockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.store[key] = value.(string)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockRedis) Get(ctx context.Context, key string) (string, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.store[key], nil
|
||||||
|
}
|
||||||
|
func (m *mockRedis) Del(ctx context.Context, keys ...string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, k := range keys {
|
||||||
|
delete(m.store, k)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||||
|
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||||
|
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockRedis) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, k := range keys {
|
||||||
|
if _, ok := m.store[k]; ok {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
func (m *mockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
payload, _ := json.Marshal(message)
|
||||||
|
for _, ps := range m.pubsubs {
|
||||||
|
ps.messages <- &db.PubSubMessage{Channel: channel, Payload: string(payload)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub {
|
||||||
|
ps := &mockPubSub{messages: make(chan *db.PubSubMessage, 100)}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.pubsubs = append(m.pubsubs, ps)
|
||||||
|
m.mu.Unlock()
|
||||||
|
return ps
|
||||||
|
}
|
||||||
|
func (m *mockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
var keys []string
|
||||||
|
for k := range m.store {
|
||||||
|
if strings.Contains(k, strings.TrimSuffix(match, "*")) {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys, 0, nil
|
||||||
|
}
|
||||||
|
func (m *mockRedis) Info(ctx context.Context, sections ...string) (string, error) { return "", nil }
|
||||||
|
func (m *mockRedis) Close() error { return nil }
|
||||||
|
|
||||||
|
type mockPubSub struct {
|
||||||
|
messages chan *db.PubSubMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPubSub) ReceiveMessage(ctx context.Context) (*db.PubSubMessage, error) {
|
||||||
|
select {
|
||||||
|
case msg := <-m.messages:
|
||||||
|
return msg, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (m *mockPubSub) Close() error { return nil }
|
||||||
|
|
||||||
|
func setupTestHub(t *testing.T) (*Hub, *gorm.DB, *mockRedis) {
|
||||||
|
t.Helper()
|
||||||
|
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open test DB: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := gormDB.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get sql.DB: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
||||||
|
mr := newMockRedis()
|
||||||
|
hub := NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
|
||||||
|
return hub, gormDB, mr
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupWSServer(hub *Hub) *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(hub.HandleConnection))
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsClient wraps a websocket.Conn with a channel-based read helper.
|
||||||
|
type wsClient struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
msgs chan db.Message
|
||||||
|
errors chan error
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWSClient(t *testing.T, url string) *wsClient {
|
||||||
|
t.Helper()
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dial error: %v", err)
|
||||||
|
}
|
||||||
|
wc := &wsClient{
|
||||||
|
conn: conn,
|
||||||
|
msgs: make(chan db.Message, 100),
|
||||||
|
errors: make(chan error, 1),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go wc.readLoop()
|
||||||
|
return wc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *wsClient) readLoop() {
|
||||||
|
defer close(wc.msgs)
|
||||||
|
for {
|
||||||
|
var msg db.Message
|
||||||
|
if err := wc.conn.ReadJSON(&msg); err != nil {
|
||||||
|
wc.errors <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case wc.msgs <- msg:
|
||||||
|
case <-wc.done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *wsClient) close() {
|
||||||
|
close(wc.done)
|
||||||
|
wc.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// recvMsg reads the next message with a timeout.
|
||||||
|
func (wc *wsClient) recvMsg(timeout time.Duration) (*db.Message, error) {
|
||||||
|
select {
|
||||||
|
case msg := <-wc.msgs:
|
||||||
|
return &msg, nil
|
||||||
|
case err := <-wc.errors:
|
||||||
|
return nil, err
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return nil, context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipSystem reads and discards system messages.
|
||||||
|
func (wc *wsClient) skipSystem(timeout time.Duration) {
|
||||||
|
for {
|
||||||
|
msg, err := wc.recvMsg(timeout)
|
||||||
|
if err != nil || msg.Type != "system" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *wsClient) writeJSON(v interface{}) error {
|
||||||
|
return wc.conn.WriteJSON(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_Broadcast(t *testing.T) {
|
||||||
|
hub, _, _ := setupTestHub(t)
|
||||||
|
|
||||||
|
msg := db.Message{
|
||||||
|
Room: "main",
|
||||||
|
Username: "user1",
|
||||||
|
Text: "hello",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Type: "public",
|
||||||
|
}
|
||||||
|
hub.Broadcast(msg)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
hub.DB.Model(&db.Message{}).Count(&count)
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("message count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_ConnectedClients(t *testing.T) {
|
||||||
|
hub, _, _ := setupTestHub(t)
|
||||||
|
if hub.ConnectedClients() != 0 {
|
||||||
|
t.Errorf("ConnectedClients() = %d, want 0", hub.ConnectedClients())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_WebSocketConnection(t *testing.T) {
|
||||||
|
hub, _, _ := setupTestHub(t)
|
||||||
|
srv := setupWSServer(hub)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||||
|
wc := newWSClient(t, wsURL)
|
||||||
|
defer wc.close()
|
||||||
|
|
||||||
|
msg, err := wc.recvMsg(2 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recvMsg error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.Text, "joined") {
|
||||||
|
t.Errorf("expected join message, got text=%s", msg.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hub.ConnectedClients() != 1 {
|
||||||
|
t.Errorf("ConnectedClients() = %d, want 1", hub.ConnectedClients())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_SendMessage(t *testing.T) {
|
||||||
|
hub, _, _ := setupTestHub(t)
|
||||||
|
srv := setupWSServer(hub)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||||
|
wc := newWSClient(t, wsURL)
|
||||||
|
defer wc.close()
|
||||||
|
|
||||||
|
wc.skipSystem(2 * time.Second)
|
||||||
|
|
||||||
|
if err := wc.writeJSON(db.Message{Room: "main", Text: "hello world"}); err != nil {
|
||||||
|
t.Fatalf("writeJSON error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recvMsg error: %v", err)
|
||||||
|
}
|
||||||
|
if recvMsg.Text != "hello world" {
|
||||||
|
t.Errorf("recvMsg.Text = %q, want hello world", recvMsg.Text)
|
||||||
|
}
|
||||||
|
if recvMsg.Username != "testuser" {
|
||||||
|
t.Errorf("recvMsg.Username = %q, want testuser", recvMsg.Username)
|
||||||
|
}
|
||||||
|
if recvMsg.Type != "public" {
|
||||||
|
t.Errorf("recvMsg.Type = %q, want public", recvMsg.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_Disconnect(t *testing.T) {
|
||||||
|
hub, _, _ := setupTestHub(t)
|
||||||
|
srv := setupWSServer(hub)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||||
|
wc := newWSClient(t, wsURL)
|
||||||
|
wc.skipSystem(2 * time.Second)
|
||||||
|
|
||||||
|
if hub.ConnectedClients() != 1 {
|
||||||
|
t.Errorf("ConnectedClients() = %d, want 1", hub.ConnectedClients())
|
||||||
|
}
|
||||||
|
|
||||||
|
wc.close()
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
if hub.ConnectedClients() != 0 {
|
||||||
|
t.Errorf("ConnectedClients() after close = %d, want 0", hub.ConnectedClients())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_BannedUser(t *testing.T) {
|
||||||
|
hub, _, mr := setupTestHub(t)
|
||||||
|
srv := setupWSServer(hub)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
mr.Set(context.Background(), "banned:testuser", "1", 0)
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||||
|
wc := newWSClient(t, wsURL)
|
||||||
|
defer wc.close()
|
||||||
|
|
||||||
|
wc.skipSystem(2 * time.Second)
|
||||||
|
wc.writeJSON(db.Message{Room: "main", Text: "hello"})
|
||||||
|
|
||||||
|
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recvMsg error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(recvMsg.Text, "banned") {
|
||||||
|
t.Errorf("expected banned message, got %q", recvMsg.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_MutedUser(t *testing.T) {
|
||||||
|
hub, _, mr := setupTestHub(t)
|
||||||
|
srv := setupWSServer(hub)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
mr.Set(context.Background(), "muted:testuser", "1", 0)
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||||
|
wc := newWSClient(t, wsURL)
|
||||||
|
defer wc.close()
|
||||||
|
|
||||||
|
wc.skipSystem(2 * time.Second)
|
||||||
|
wc.writeJSON(db.Message{Room: "main", Text: "hello"})
|
||||||
|
|
||||||
|
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recvMsg error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(recvMsg.Text, "muted") {
|
||||||
|
t.Errorf("expected muted message, got %q", recvMsg.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_ModerationCommand(t *testing.T) {
|
||||||
|
hub, _, _ := setupTestHub(t)
|
||||||
|
srv := setupWSServer(hub)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=admin&room=main"
|
||||||
|
wc := newWSClient(t, wsURL)
|
||||||
|
defer wc.close()
|
||||||
|
|
||||||
|
wc.skipSystem(2 * time.Second)
|
||||||
|
wc.writeJSON(db.Message{Room: "main", Text: "/mute baduser 5 spamming"})
|
||||||
|
|
||||||
|
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recvMsg error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(recvMsg.Text, "mute") {
|
||||||
|
t.Errorf("expected mute broadcast, got text=%s", recvMsg.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
hub.DB.Model(&db.ModerationLog{}).Count(&count)
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("moderation log count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_NonAdminModerationCommand(t *testing.T) {
|
||||||
|
hub, _, _ := setupTestHub(t)
|
||||||
|
srv := setupWSServer(hub)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=regularuser&room=main"
|
||||||
|
wc := newWSClient(t, wsURL)
|
||||||
|
defer wc.close()
|
||||||
|
|
||||||
|
wc.skipSystem(2 * time.Second)
|
||||||
|
wc.writeJSON(db.Message{Room: "main", Text: "/mute baduser 5 spamming"})
|
||||||
|
|
||||||
|
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recvMsg error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(recvMsg.Text, "Only moderators") {
|
||||||
|
t.Errorf("expected moderator-only message, got %q", recvMsg.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_PrivateDM(t *testing.T) {
|
||||||
|
hub, _, _ := setupTestHub(t)
|
||||||
|
srv := setupWSServer(hub)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||||
|
wc1 := newWSClient(t, wsURL+"?username=alice&room=main")
|
||||||
|
defer wc1.close()
|
||||||
|
wc2 := newWSClient(t, wsURL+"?username=bob&room=main")
|
||||||
|
defer wc2.close()
|
||||||
|
|
||||||
|
wc1.skipSystem(2 * time.Second)
|
||||||
|
wc2.skipSystem(2 * time.Second)
|
||||||
|
|
||||||
|
wc1.writeJSON(db.Message{Room: "dm:bob", Text: "hey bob"})
|
||||||
|
|
||||||
|
recvMsg, err := wc2.recvMsg(2 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bob recvMsg error: %v", err)
|
||||||
|
}
|
||||||
|
if recvMsg.Type != "private" {
|
||||||
|
t.Errorf("expected private type, got %s", recvMsg.Type)
|
||||||
|
}
|
||||||
|
if recvMsg.Text != "hey bob" {
|
||||||
|
t.Errorf("expected 'hey bob', got %q", recvMsg.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
recvMsg2, err := wc1.recvMsg(2 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("alice recvMsg error: %v", err)
|
||||||
|
}
|
||||||
|
if recvMsg2.Type != "private" {
|
||||||
|
t.Errorf("expected private confirmation, got %s", recvMsg2.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_PushChatHistory(t *testing.T) {
|
||||||
|
hub, gormDB, _ := setupTestHub(t)
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
gormDB.Create(&db.Message{
|
||||||
|
Room: "main",
|
||||||
|
Username: "user1",
|
||||||
|
Text: "msg",
|
||||||
|
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
|
||||||
|
Type: "public",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := setupWSServer(hub)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=newuser&room=main"
|
||||||
|
wc := newWSClient(t, wsURL)
|
||||||
|
defer wc.close()
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
msg, err := wc.recvMsg(2 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if msg.Type == "public" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 5 {
|
||||||
|
t.Errorf("history message count = %d, want 5", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_MuteUser_UnmuteUser(t *testing.T) {
|
||||||
|
hub, _, mr := setupTestHub(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := hub.MuteUser(ctx, "user1", 10*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MuteUser error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, _ := mr.Exists(ctx, "muted:user1")
|
||||||
|
if exists != 1 {
|
||||||
|
t.Error("muted key not found after MuteUser")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = hub.UnmuteUser(ctx, "user1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnmuteUser error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, _ = mr.Exists(ctx, "muted:user1")
|
||||||
|
if exists != 0 {
|
||||||
|
t.Error("muted key still exists after UnmuteUser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_BanUser_UnbanUser(t *testing.T) {
|
||||||
|
hub, _, mr := setupTestHub(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := hub.BanUser(ctx, "user1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BanUser error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, _ := mr.Exists(ctx, "banned:user1")
|
||||||
|
if exists != 1 {
|
||||||
|
t.Error("banned key not found after BanUser")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = hub.UnbanUser(ctx, "user1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnbanUser error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, _ = mr.Exists(ctx, "banned:user1")
|
||||||
|
if exists != 0 {
|
||||||
|
t.Error("banned key still exists after UnbanUser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_ScanPresence(t *testing.T) {
|
||||||
|
hub, _, mr := setupTestHub(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mr.Set(ctx, "presence:main:user1", "online", 0)
|
||||||
|
mr.Set(ctx, "presence:main:user2", "online", 0)
|
||||||
|
mr.Set(ctx, "presence:lobby:user3", "online", 0)
|
||||||
|
|
||||||
|
presences, err := hub.ScanPresence(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ScanPresence error = %v", err)
|
||||||
|
}
|
||||||
|
if len(presences) != 3 {
|
||||||
|
t.Errorf("ScanPresence len = %d, want 3", len(presences))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_SubscribeRoomMessages(t *testing.T) {
|
||||||
|
hub, _, mr := setupTestHub(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
hub.SubscribeRoomMessages()
|
||||||
|
}()
|
||||||
|
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"instance": "other-instance",
|
||||||
|
"msg": db.Message{
|
||||||
|
Room: "main",
|
||||||
|
Username: "remoteuser",
|
||||||
|
Text: "remote hello",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Type: "public",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mr.Publish(ctx, "room:main:messages", string(payload))
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
ownPayload, _ := json.Marshal(map[string]any{
|
||||||
|
"instance": "test-instance",
|
||||||
|
"msg": db.Message{
|
||||||
|
Room: "main",
|
||||||
|
Username: "localuser",
|
||||||
|
Text: "local hello",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Type: "public",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mr.Publish(ctx, "room:main:messages", string(ownPayload))
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_Broadcast_SystemNotPersisted(t *testing.T) {
|
||||||
|
hub, _, _ := setupTestHub(t)
|
||||||
|
|
||||||
|
hub.Broadcast(db.Message{Room: "main", Username: "system", Text: "bob joined main", Type: "system", Timestamp: time.Now()})
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
hub.DB.Model(&db.Message{}).Where("type = ?", "system").Count(&count)
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("system message count = %d, want 0 (system messages must not persist)", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.Broadcast(db.Message{Room: "main", Username: "bob", Text: "hi", Type: "public", Timestamp: time.Now()})
|
||||||
|
hub.DB.Model(&db.Message{}).Where("type = ?", "public").Count(&count)
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("public message count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_ReconnectGracePeriod(t *testing.T) {
|
||||||
|
hub, _, _ := setupTestHub(t)
|
||||||
|
hub.PresenceGracePeriod = 50 * time.Millisecond
|
||||||
|
|
||||||
|
// Disconnect schedules a delayed "left"; reconnect cancels it.
|
||||||
|
hub.scheduleLeaveAnnouncement("main", "bob")
|
||||||
|
if !hub.cancelLeaveAnnouncement("main", "bob") {
|
||||||
|
t.Error("expected a pending leave announcement to be cancelled on reconnect")
|
||||||
|
}
|
||||||
|
if hub.cancelLeaveAnnouncement("main", "bob") {
|
||||||
|
t.Error("expected no pending leave announcement after cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh disconnect that is never reconnected fires after the grace period
|
||||||
|
// and cleans up its timer.
|
||||||
|
hub.scheduleLeaveAnnouncement("main", "alice")
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
hub.presenceMu.Lock()
|
||||||
|
_, exists := hub.presenceTimers["main\x00alice"]
|
||||||
|
hub.presenceMu.Unlock()
|
||||||
|
if exists {
|
||||||
|
t.Error("expected the leave announcement timer to clean itself up after firing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,11 @@ FROM golang:1.25-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
|
COPY src/go.mod src/go.sum ./
|
||||||
|
|
||||||
# Initialize Go module if not present
|
# Download dependencies and build
|
||||||
RUN go mod init playout || true
|
RUN go mod download
|
||||||
RUN go mod tidy
|
RUN CGO_ENABLED=0 GOOS=linux GOFLAGS=-mod=mod go build -o /playlist-manager ./src/playlist.go
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /playlist-manager ./src/playlist.go
|
|
||||||
|
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
|
|
||||||
@@ -18,14 +17,15 @@ 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, HLS output, and emergency slate
|
||||||
|
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
|
||||||
COPY demodata/ /app/
|
COPY demodata/ /app/
|
||||||
# Copy the built playlist manager binary from the builder stage
|
# Copy the built playlist manager binary from the builder stage
|
||||||
COPY --from=builder /playlist-manager /app/playlist-manager
|
COPY --from=builder /playlist-manager /app/playlist-manager
|
||||||
# Create directories for playlist, media, and HLS output
|
# Set execute permissions for the start script and playlist manager binary
|
||||||
RUN mkdir -p /app/hls
|
|
||||||
RUN chmod +x /app/start-playout.sh /app/playlist-manager
|
RUN chmod +x /app/start-playout.sh /app/playlist-manager
|
||||||
# Expose HLS directory (will be served by nginx)
|
# Expose HLS directory (will be served by nginx)
|
||||||
VOLUME ["/app/hls"]
|
VOLUME ["/app/hls"]
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
module playout
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.43.4
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.35
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.34 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect
|
||||||
|
github.com/aws/smithy-go v1.27.6 // indirect
|
||||||
|
golang.org/x/sys v0.13.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16/go.mod h1:nG/LOlmox9BDe9HvQnXWzgcK8uKbgBMZ/Hp5pVt/21I=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 h1:Q1TF1J9jVD+vFo0LzNnmNdQ9EAt52TS+MQlq9Ir+Yxo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28/go.mod h1:4KqXXC/p1hrotmouDFbrRoWaLy962b9PMUReCG6+uWo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 h1:EUIwBoN+q7UmhAejxgD27APiRjh1vwCFo53gSqdT0BM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36/go.mod h1:6u00gmlTGR6W0b2k9NBrld7MnOEmf1Spqx0VVt6AqyE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0 h1:OkYV+1171za+ab9otU1tGxMXhx6uZvwVEtVddjLuYTg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0/go.mod h1:5FTZoQxhmLEiCAtYVk6V+t0iS/B5yGZVLZ3Wq5FDJZI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc=
|
||||||
|
github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA=
|
||||||
|
github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||||
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
+1062
-52
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleManifest = `#EXTM3U
|
||||||
|
#EXT-X-VERSION:7
|
||||||
|
#EXT-X-TARGETDURATION:2
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXT-X-INDEPENDENT-SEGMENTS
|
||||||
|
#EXT-X-MAP:URI="init.mp4"
|
||||||
|
#EXTINF:2.000000,
|
||||||
|
seg-000000.m4s
|
||||||
|
#EXTINF:2.000000,
|
||||||
|
seg-000001.m4s
|
||||||
|
#EXT-X-ENDLIST
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun(t *testing.T) {
|
||||||
|
runID := "r-20260903T120000Z"
|
||||||
|
out := string(rewriteManifestToRun(runID, []byte(sampleManifest)))
|
||||||
|
|
||||||
|
wants := []string{
|
||||||
|
`#EXT-X-MAP:URI="r-20260903T120000Z/init.mp4"`,
|
||||||
|
"r-20260903T120000Z/seg-000000.m4s",
|
||||||
|
"r-20260903T120000Z/seg-000001.m4s",
|
||||||
|
"#EXTM3U",
|
||||||
|
"#EXT-X-VERSION:7",
|
||||||
|
"#EXT-X-TARGETDURATION:2",
|
||||||
|
"#EXT-X-MEDIA-SEQUENCE:0",
|
||||||
|
"#EXT-X-INDEPENDENT-SEGMENTS",
|
||||||
|
"#EXT-X-ENDLIST",
|
||||||
|
"#EXTINF:2.000000,",
|
||||||
|
}
|
||||||
|
for _, want := range wants {
|
||||||
|
if !contains(out, want) {
|
||||||
|
t.Errorf("output missing %q\noutput:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media URIs must be prefixed (no bare segments left).
|
||||||
|
for _, bare := range []string{"\nseg-000000.m4s\n", "\nseg-000001.m4s\n", `URI="init.mp4"`} {
|
||||||
|
if contains(out, bare) {
|
||||||
|
t.Errorf("output still contains bare %q\noutput:\n%s", bare, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun_AlreadyPrefixedUnchanged(t *testing.T) {
|
||||||
|
manifest := "#EXTM3U\n#EXT-X-MAP:URI=\"r-old/init.mp4\"\nseg-000000.m4s\nr-old/seg-000001.m4s\n#EXT-X-ENDLIST\n"
|
||||||
|
out := string(rewriteManifestToRun("r-new", []byte(manifest)))
|
||||||
|
|
||||||
|
if !contains(out, `#EXT-X-MAP:URI="r-old/init.mp4"`) {
|
||||||
|
t.Errorf("EXT-X-MAP already-prefixed URI was rewritten: %s", out)
|
||||||
|
}
|
||||||
|
// A URI line that already contains a path is left alone.
|
||||||
|
if !contains(out, "r-old/seg-000001.m4s") {
|
||||||
|
t.Errorf("already-prefixed segment URI was changed:\n%s", out)
|
||||||
|
}
|
||||||
|
// Bare URI still gets the run prefix.
|
||||||
|
if !contains(out, "r-new/seg-000000.m4s") {
|
||||||
|
t.Errorf("bare segment not prefixed:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun_HandlesTSAndComments(t *testing.T) {
|
||||||
|
manifest := "#EXTM3U\n# a comment\nvideo-1.ts\ninit.mp4\n#EXT-X-ENDLIST\n"
|
||||||
|
out := string(rewriteManifestToRun("r-x", []byte(manifest)))
|
||||||
|
|
||||||
|
for _, want := range []string{"r-x/video-1.ts", "r-x/init.mp4", "# a comment", "#EXTM3U", "#EXT-X-ENDLIST"} {
|
||||||
|
if !contains(out, want) {
|
||||||
|
t.Errorf("output missing %q\noutput:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun_EmptyRunID(t *testing.T) {
|
||||||
|
out := rewriteManifestToRun("", []byte(sampleManifest))
|
||||||
|
if string(out) != sampleManifest {
|
||||||
|
t.Errorf("empty runID should return input unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsMediaURI(t *testing.T) {
|
||||||
|
for _, ok := range []string{"seg-000001.m4s", "init.mp4", "a.ts", "x.m4a"} {
|
||||||
|
if !isMediaURI(ok) {
|
||||||
|
t.Errorf("isMediaURI(%q) = false, want true", ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, notOK := range []string{"playlist.m3u8", "readme.txt", "seg-000001.m4s?x=1"} {
|
||||||
|
// Note: query strings are stripped; '?x=1' on m4s should still be media.
|
||||||
|
if notOK == "seg-000001.m4s?x=1" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isMediaURI(notOK) {
|
||||||
|
t.Errorf("isMediaURI(%q) = true, want false", notOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isMediaURI("seg-000001.m4s?token=abc") {
|
||||||
|
t.Errorf("isMediaURI with query should strip query and be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishManifest_RewritesToRunScopedURIs(t *testing.T) {
|
||||||
|
prevClient, prevBucket := r2Client, bucket
|
||||||
|
prevRunID, prevMode := runID, playoutMode
|
||||||
|
prevPut, prevCopy := putObject, copyObject
|
||||||
|
defer func() {
|
||||||
|
r2Client, bucket = prevClient, prevBucket
|
||||||
|
runID, playoutMode = prevRunID, prevMode
|
||||||
|
putObject, copyObject = prevPut, prevCopy
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mp := filepath.Join(tmpDir, "playlist.m3u8")
|
||||||
|
if err := os.WriteFile(mp, []byte(sampleManifest), 0o644); err != nil {
|
||||||
|
t.Fatalf("write manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
runID = "r-20260903T120000Z"
|
||||||
|
playoutMode = "live"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
var gotKey, gotCopySource, gotCopyKey string
|
||||||
|
var gotBody []byte
|
||||||
|
putObject = func(_ *s3.Client, in *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
gotKey = aws.ToString(in.Key)
|
||||||
|
b, err := io.ReadAll(in.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gotBody = b
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
copyObject = func(_ *s3.Client, in *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||||
|
gotCopySource = aws.ToString(in.CopySource)
|
||||||
|
gotCopyKey = aws.ToString(in.Key)
|
||||||
|
return &s3.CopyObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := publishManifest(mp); err != nil {
|
||||||
|
t.Fatalf("publishManifest error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotKey != "hls/playlist.m3u8.tmp" {
|
||||||
|
t.Errorf("temp upload key = %q, want hls/playlist.m3u8.tmp", gotKey)
|
||||||
|
}
|
||||||
|
if gotCopySource != "linearvoid-media/hls/playlist.m3u8.tmp" {
|
||||||
|
t.Errorf("CopySource = %q, want linearvoid-media/hls/playlist.m3u8.tmp", gotCopySource)
|
||||||
|
}
|
||||||
|
if gotCopyKey != "hls/playlist.m3u8" {
|
||||||
|
t.Errorf("final key = %q, want hls/playlist.m3u8", gotCopyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := string(gotBody)
|
||||||
|
for _, want := range []string{
|
||||||
|
"r-20260903T120000Z/seg-000000.m4s",
|
||||||
|
"r-20260903T120000Z/seg-000001.m4s",
|
||||||
|
`#EXT-X-MAP:URI="r-20260903T120000Z/init.mp4"`,
|
||||||
|
} {
|
||||||
|
if !contains(body, want) {
|
||||||
|
t.Errorf("published manifest missing %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bare := range []string{"\nseg-000000.m4s\n", "\nseg-000001.m4s\n"} {
|
||||||
|
if contains(body, bare) {
|
||||||
|
t.Errorf("published manifest still contains bare %q:\n%s", bare, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishManifest_SlateNotRewritten(t *testing.T) {
|
||||||
|
prevClient, prevBucket := r2Client, bucket
|
||||||
|
prevRunID, prevMode := runID, playoutMode
|
||||||
|
prevPut, prevCopy := putObject, copyObject
|
||||||
|
defer func() {
|
||||||
|
r2Client, bucket = prevClient, prevBucket
|
||||||
|
runID, playoutMode = prevRunID, prevMode
|
||||||
|
putObject, copyObject = prevPut, prevCopy
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mp := filepath.Join(tmpDir, "playlist.m3u8")
|
||||||
|
if err := os.WriteFile(mp, []byte(sampleManifest), 0o644); err != nil {
|
||||||
|
t.Fatalf("write manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
runID = "r-20260903T120000Z"
|
||||||
|
playoutMode = "slate"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
var gotBody []byte
|
||||||
|
putObject = func(_ *s3.Client, in *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
b, _ := io.ReadAll(in.Body)
|
||||||
|
gotBody = b
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
copyObject = func(_ *s3.Client, _ *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||||
|
return &s3.CopyObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := publishManifest(mp); err != nil {
|
||||||
|
t.Fatalf("publishManifest error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(gotBody) != sampleManifest {
|
||||||
|
t.Errorf("slate manifest should be published unmodified; got:\n%s", string(gotBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,947 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Test fixture generation commands (run from repository root):
|
||||||
|
//
|
||||||
|
// Valid 720p30 fixture (1.5s, H.264/AAC, yuv420p, 48000Hz, keyframe every 30 frames):
|
||||||
|
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \
|
||||||
|
// -f lavfi -i sine=frequency=440:duration=1.5 \
|
||||||
|
// -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 30 -keyint_min 30 \
|
||||||
|
// -c:a aac -ar 48000 -ac 2 -shortest \
|
||||||
|
// backend/playout/testdata/valid_720p30.mp4
|
||||||
|
//
|
||||||
|
// Invalid h265 fixture (1.5s, HEVC, otherwise compliant):
|
||||||
|
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \
|
||||||
|
// -f lavfi -i sine=frequency=440:duration=1.5 \
|
||||||
|
// -c:v libx265 -preset ultrafast -crf 28 -pix_fmt yuv420p -g 30 -keyint_min 30 \
|
||||||
|
// -c:a aac -ar 48000 -ac 2 -shortest \
|
||||||
|
// -tag:v hvc1 backend/playout/testdata/invalid_h265.mp4
|
||||||
|
//
|
||||||
|
// Invalid 24fps fixture (1.5s, correct codec but wrong framerate):
|
||||||
|
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=24 \
|
||||||
|
// -f lavfi -i sine=frequency=440:duration=1.5 \
|
||||||
|
// -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 24 -keyint_min 24 \
|
||||||
|
// -c:a aac -ar 48000 -ac 2 -shortest \
|
||||||
|
// backend/playout/testdata/invalid_24fps.mp4
|
||||||
|
//
|
||||||
|
// Test playlist fixtures are plain text files with paths relative to /app/media.
|
||||||
|
// See backend/playout/testdata/*.txt.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ensureFFProbeInPath(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("ffprobe"); err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
windowsFFProbe := `C:\Users\zwits\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0-full_build\bin\ffprobe.exe`
|
||||||
|
if _, err := os.Stat(windowsFFProbe); err == nil {
|
||||||
|
t.Setenv("PATH", filepath.Dir(windowsFFProbe)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Skip("ffprobe not available on PATH and Windows WinGet install path not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPlaylist(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
file string
|
||||||
|
wantLen int
|
||||||
|
wantErr bool
|
||||||
|
first string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid playlist",
|
||||||
|
file: "valid_playlist.txt",
|
||||||
|
wantLen: 2,
|
||||||
|
first: "/app/media/demo1.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty playlist",
|
||||||
|
file: "empty_playlist.txt",
|
||||||
|
wantLen: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comments only",
|
||||||
|
file: "comments_only.txt",
|
||||||
|
wantLen: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed valid invalid",
|
||||||
|
file: "mixed_valid_invalid.txt",
|
||||||
|
wantLen: 3,
|
||||||
|
first: "/app/media/demo1.mp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join("..", "testdata", tt.file)
|
||||||
|
|
||||||
|
videos, err := LoadPlaylist(path)
|
||||||
|
if tt.wantErr && err == nil {
|
||||||
|
t.Error("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tt.wantErr && err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(videos) != tt.wantLen {
|
||||||
|
t.Errorf("len = %d, want %d", len(videos), tt.wantLen)
|
||||||
|
}
|
||||||
|
if tt.wantLen > 0 && videos[0].Path != tt.first {
|
||||||
|
t.Errorf("videos[0].Path = %q, want %q", videos[0].Path, tt.first)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPlaylist_FileNotFound(t *testing.T) {
|
||||||
|
_, err := LoadPlaylist("/nonexistent/playlist.txt")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateKeyframeInterval(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
csvData string
|
||||||
|
wantErr bool
|
||||||
|
errText string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid keyframes (gaps <=2s)",
|
||||||
|
csvData: "1,0.00\n0,0.50\n1,1.00\n0,1.50\n1,2.00\n0,2.50\n1,3.00\n",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gap too large >2s",
|
||||||
|
csvData: "1,0.00\n0,1.00\n1,3.50\n0,4.00\n1,6.00\n",
|
||||||
|
wantErr: true,
|
||||||
|
errText: "keyframe interval too large",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "insufficient keyframes (<2)",
|
||||||
|
csvData: "1,0.00\n",
|
||||||
|
wantErr: true,
|
||||||
|
errText: "insufficient keyframes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty output",
|
||||||
|
csvData: "",
|
||||||
|
wantErr: true,
|
||||||
|
errText: "keyframe probe failed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// create temp ffprobe that outputs csvData
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mockProbe := filepath.Join(tmpDir, "ffprobe")
|
||||||
|
var script string
|
||||||
|
if tt.csvData == "" {
|
||||||
|
script = "@echo off\necho\nexit /b 1\n"
|
||||||
|
} else {
|
||||||
|
script = "@echo off\necho " + strings.ReplaceAll(tt.csvData, "\n", "& echo ") + "\nexit /b 0\n"
|
||||||
|
}
|
||||||
|
os.WriteFile(mockProbe+".bat", []byte(script), 0755)
|
||||||
|
os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
defer os.Unsetenv("PATH")
|
||||||
|
|
||||||
|
err := validateKeyframeInterval("test.mp4")
|
||||||
|
if tt.wantErr && err == nil {
|
||||||
|
t.Error("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tt.wantErr && err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if tt.wantErr && tt.errText != "" && err != nil {
|
||||||
|
if !strings.Contains(err.Error(), tt.errText) {
|
||||||
|
t.Errorf("error = %v, want containing %q", err, tt.errText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateVideoStrict(t *testing.T) {
|
||||||
|
ensureFFProbeInPath(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
file string
|
||||||
|
wantValid bool
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid h264 fixture",
|
||||||
|
file: "valid_720p30.mp4",
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid h265 fixture",
|
||||||
|
file: "invalid_h265.mp4",
|
||||||
|
wantErr: "video codec must be h264",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid framerate fixture",
|
||||||
|
file: "invalid_24fps.mp4",
|
||||||
|
wantErr: "framerate must be 30/1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join("..", "testdata", tt.file)
|
||||||
|
result := ValidateVideoStrict(path)
|
||||||
|
|
||||||
|
valid, _ := result["valid"].(bool)
|
||||||
|
if valid != tt.wantValid {
|
||||||
|
t.Fatalf("valid = %v, want %v", valid, tt.wantValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
errs, _ := result["errors"].([]string)
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("errors = %v, want none", errs)
|
||||||
|
}
|
||||||
|
if got, ok := result["duration_seconds"].(float64); !ok || got <= 0 {
|
||||||
|
t.Fatalf("duration_seconds = %v, want positive duration", result["duration_seconds"])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) == 0 {
|
||||||
|
t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(errs, "; "), tt.wantErr) {
|
||||||
|
t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthHandler(t *testing.T) {
|
||||||
|
// Save and restore globals
|
||||||
|
prevMode := playoutMode
|
||||||
|
prevCount := restartCount
|
||||||
|
prevRunID := runID
|
||||||
|
playoutMode = "live"
|
||||||
|
restartCount = 3
|
||||||
|
runID = "r-20260810T120000Z"
|
||||||
|
lastR2PubTime = time.Now()
|
||||||
|
defer func() {
|
||||||
|
playoutMode = prevMode
|
||||||
|
restartCount = prevCount
|
||||||
|
runID = prevRunID
|
||||||
|
}()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/health", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
HealthHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`"status":"healthy"`,
|
||||||
|
`"service":"playout"`,
|
||||||
|
`"mode":"live"`,
|
||||||
|
`"crash_count":3`,
|
||||||
|
`"run_id":"r-20260810T120000Z"`,
|
||||||
|
`"last_segment_seq"`,
|
||||||
|
`"consecutive_failures"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthHandler_Degraded(t *testing.T) {
|
||||||
|
prevMode := playoutMode
|
||||||
|
prevR2Pub := lastR2PubTime
|
||||||
|
playoutMode = "slate"
|
||||||
|
lastR2PubTime = time.Now() // fresh R2 pub = not unhealthy
|
||||||
|
defer func() {
|
||||||
|
playoutMode = prevMode
|
||||||
|
lastR2PubTime = prevR2Pub
|
||||||
|
}()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/health", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
HealthHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, `"degraded"`) {
|
||||||
|
t.Errorf("body missing degraded: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthHandler_Unhealthy(t *testing.T) {
|
||||||
|
prevMode := playoutMode
|
||||||
|
prevR2Pub := lastR2PubTime
|
||||||
|
playoutMode = "live"
|
||||||
|
lastR2PubTime = time.Now().Add(-30 * time.Second) // stale R2 = unhealthy
|
||||||
|
defer func() {
|
||||||
|
playoutMode = prevMode
|
||||||
|
lastR2PubTime = prevR2Pub
|
||||||
|
}()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/health", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
HealthHandler(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, `"unhealthy"`) {
|
||||||
|
t.Errorf("body missing unhealthy (stale R2 pub should trigger unhealthy): %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldEscalateToSlate(t *testing.T) {
|
||||||
|
// Save globals
|
||||||
|
prevCount := restartCount
|
||||||
|
prevWindow := restartWindow
|
||||||
|
defer func() {
|
||||||
|
restartCount = prevCount
|
||||||
|
restartWindow = prevWindow
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Under threshold
|
||||||
|
restartWindow = time.Now()
|
||||||
|
restartCount = 3
|
||||||
|
if shouldEscalateToSlate() {
|
||||||
|
t.Error("shouldEscalateToSlate() = true, want false (under threshold)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// At threshold
|
||||||
|
restartCount = maxRestartsPerMinute
|
||||||
|
if !shouldEscalateToSlate() {
|
||||||
|
t.Error("shouldEscalateToSlate() = false, want true (at threshold)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Over threshold
|
||||||
|
restartCount = 7
|
||||||
|
if !shouldEscalateToSlate() {
|
||||||
|
t.Error("shouldEscalateToSlate() = false, want true (over threshold)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window expired
|
||||||
|
restartWindow = time.Now().Add(-61 * time.Second)
|
||||||
|
restartCount = 10
|
||||||
|
if shouldEscalateToSlate() {
|
||||||
|
t.Error("shouldEscalateToSlate() = true, want false (window expired, should reset)")
|
||||||
|
}
|
||||||
|
if restartCount != 0 {
|
||||||
|
t.Errorf("restartCount after window reset = %d, want 0", restartCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetHLSOutput(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create some segment files
|
||||||
|
os.MkdirAll(tmpDir, 0755)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "segment_test.ts"), []byte("data"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "segment_test.m4s"), []byte("data"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "playlist.m3u8"), []byte("#EXTM3U"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "init.mp4"), []byte("init"), 0644)
|
||||||
|
|
||||||
|
err := resetHLSOutput(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resetHLSOutput error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all cleaned
|
||||||
|
for _, name := range []string{"segment_test.ts", "segment_test.m4s", "playlist.m3u8", "init.mp4"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, name)); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("file %s still exists after reset", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetHLSOutput_EmptyDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
err := resetHLSOutput(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resetHLSOutput on empty dir error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivateSlate_NoSlate(t *testing.T) {
|
||||||
|
prevSlateDir := slateDir
|
||||||
|
prevHLSDir := hlsDir
|
||||||
|
prevMode := playoutMode
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
slateDir = filepath.Join(tmpDir, "slate")
|
||||||
|
hlsDir = filepath.Join(tmpDir, "hls")
|
||||||
|
os.MkdirAll(hlsDir, 0755)
|
||||||
|
defer func() {
|
||||||
|
slateDir = prevSlateDir
|
||||||
|
hlsDir = prevHLSDir
|
||||||
|
playoutMode = prevMode
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := activateSlate()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when slate HLS missing, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivateSlate_Success(t *testing.T) {
|
||||||
|
prevSlateDir := slateDir
|
||||||
|
prevHLSDir := hlsDir
|
||||||
|
prevMode := playoutMode
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
slateDir = filepath.Join(tmpDir, "slate")
|
||||||
|
hlsDir = filepath.Join(tmpDir, "hls")
|
||||||
|
|
||||||
|
// Create slate HLS structure
|
||||||
|
slateHLS := filepath.Join(slateDir, "hls")
|
||||||
|
os.MkdirAll(slateHLS, 0755)
|
||||||
|
os.MkdirAll(hlsDir, 0755)
|
||||||
|
os.WriteFile(filepath.Join(slateHLS, "slate.m3u8"), []byte("#EXTM3U\n#EXT-X-ENDLIST\n"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(slateHLS, "slate_segment.ts"), []byte("slate-data"), 0644)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
slateDir = prevSlateDir
|
||||||
|
hlsDir = prevHLSDir
|
||||||
|
playoutMode = prevMode
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := activateSlate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("activateSlate error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if playoutMode != "slate" {
|
||||||
|
t.Errorf("playoutMode = %q, want slate", playoutMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify files copied
|
||||||
|
if _, err := os.Stat(filepath.Join(hlsDir, "slate.m3u8")); os.IsNotExist(err) {
|
||||||
|
t.Error("slate.m3u8 not copied to hls dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeactivateSlate(t *testing.T) {
|
||||||
|
prevMode := playoutMode
|
||||||
|
playoutMode = "slate"
|
||||||
|
defer func() { playoutMode = prevMode }()
|
||||||
|
|
||||||
|
deactivateSlate()
|
||||||
|
|
||||||
|
if playoutMode != "live" {
|
||||||
|
t.Errorf("playoutMode = %q, want live", playoutMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePlaylist(t *testing.T) {
|
||||||
|
// ValidatePlaylist calls ValidateVideo which needs ffprobe
|
||||||
|
t.Skip("requires ffprobe mock or test media fixtures")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateWorkersConfig(t *testing.T) {
|
||||||
|
os.Unsetenv("PLAYOUT_VALIDATE_WORKERS")
|
||||||
|
if got := validateWorkers(); got < 1 {
|
||||||
|
t.Errorf("validateWorkers() = %d, want >= 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("PLAYOUT_VALIDATE_WORKERS", "3")
|
||||||
|
if got := validateWorkers(); got != 3 {
|
||||||
|
t.Errorf("validateWorkers() = %d, want 3", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("PLAYOUT_VALIDATE_WORKERS", "invalid")
|
||||||
|
if got := validateWorkers(); got < 1 {
|
||||||
|
t.Errorf("validateWorkers() = %d, want fallback >= 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Unsetenv("PLAYOUT_VALIDATE_WORKERS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePlaylist_Dedup(t *testing.T) {
|
||||||
|
ensureFFProbeInPath(t)
|
||||||
|
|
||||||
|
valid := filepath.Join("..", "testdata", "valid_720p30.mp4")
|
||||||
|
playlistDir := t.TempDir()
|
||||||
|
p := filepath.Join(playlistDir, "playlist.txt")
|
||||||
|
// Same file listed three times to exercise dedup + order preservation.
|
||||||
|
content := fmt.Sprintf("file '%s'\nfile '%s'\nfile '%s'\n", valid, valid, valid)
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write playlist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
videos, err := ValidatePlaylist(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidatePlaylist error: %v", err)
|
||||||
|
}
|
||||||
|
if len(videos) != 3 {
|
||||||
|
t.Fatalf("len = %d, want 3", len(videos))
|
||||||
|
}
|
||||||
|
for i, v := range videos {
|
||||||
|
if !v.Valid {
|
||||||
|
t.Errorf("entry %d invalid: %s", i, v.Validation)
|
||||||
|
}
|
||||||
|
if v.Path != valid {
|
||||||
|
t.Errorf("entry %d path = %q, want %q", i, v.Path, valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncToR2(t *testing.T) {
|
||||||
|
prevClient := r2Client
|
||||||
|
prevBucket := bucket
|
||||||
|
prevPutObject := putObject
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
putObject = prevPutObject
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
localPath := filepath.Join(tmpDir, "segment.m4s")
|
||||||
|
if err := os.WriteFile(localPath, []byte("segment-bytes"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
var gotBucket, gotKey, gotCacheControl, gotContentType string
|
||||||
|
var gotBody []byte
|
||||||
|
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
gotBucket = aws.ToString(input.Bucket)
|
||||||
|
gotKey = aws.ToString(input.Key)
|
||||||
|
gotCacheControl = aws.ToString(input.CacheControl)
|
||||||
|
gotContentType = aws.ToString(input.ContentType)
|
||||||
|
body, err := io.ReadAll(input.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gotBody = body
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SyncToR2(localPath, "hls/segment.m4s"); err != nil {
|
||||||
|
t.Fatalf("SyncToR2 error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotBucket != "linearvoid-media" {
|
||||||
|
t.Fatalf("bucket = %q, want %q", gotBucket, "linearvoid-media")
|
||||||
|
}
|
||||||
|
if gotKey != "hls/segment.m4s" {
|
||||||
|
t.Fatalf("key = %q, want %q", gotKey, "hls/segment.m4s")
|
||||||
|
}
|
||||||
|
if string(gotBody) != "segment-bytes" {
|
||||||
|
t.Fatalf("body = %q, want %q", string(gotBody), "segment-bytes")
|
||||||
|
}
|
||||||
|
if gotCacheControl != segmentCacheControl {
|
||||||
|
t.Fatalf("CacheControl = %q, want %q", gotCacheControl, segmentCacheControl)
|
||||||
|
}
|
||||||
|
if gotContentType != "video/MP2T" {
|
||||||
|
t.Fatalf("ContentType = %q, want %q", gotContentType, "video/MP2T")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncToR2_ManifestCacheHeaders(t *testing.T) {
|
||||||
|
prevClient := r2Client
|
||||||
|
prevBucket := bucket
|
||||||
|
prevPutObject := putObject
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
putObject = prevPutObject
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
localPath := filepath.Join(tmpDir, "playlist.m3u8")
|
||||||
|
if err := os.WriteFile(localPath, []byte("#EXTM3U\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
var gotCacheControl, gotContentType string
|
||||||
|
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
gotCacheControl = aws.ToString(input.CacheControl)
|
||||||
|
gotContentType = aws.ToString(input.ContentType)
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SyncToR2(localPath, "hls/playlist.m3u8"); err != nil {
|
||||||
|
t.Fatalf("SyncToR2 error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotCacheControl != manifestCacheControl {
|
||||||
|
t.Fatalf("CacheControl = %q, want %q", gotCacheControl, manifestCacheControl)
|
||||||
|
}
|
||||||
|
if gotContentType != "application/vnd.apple.mpegurl" {
|
||||||
|
t.Fatalf("ContentType = %q, want %q", gotContentType, "application/vnd.apple.mpegurl")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
|
||||||
|
prevClient := r2Client
|
||||||
|
prevBucket := bucket
|
||||||
|
prevPutObject := putObject
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
putObject = prevPutObject
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
localPath := filepath.Join(tmpDir, "data.bin")
|
||||||
|
if err := os.WriteFile(localPath, []byte("raw"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
var gotCacheControl, gotContentType string
|
||||||
|
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
gotCacheControl = aws.ToString(input.CacheControl)
|
||||||
|
gotContentType = aws.ToString(input.ContentType)
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SyncToR2(localPath, "misc/data.bin"); err != nil {
|
||||||
|
t.Fatalf("SyncToR2 error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotCacheControl != "" {
|
||||||
|
t.Fatalf("CacheControl = %q, want empty", gotCacheControl)
|
||||||
|
}
|
||||||
|
if gotContentType != "" {
|
||||||
|
t.Fatalf("ContentType = %q, want empty", gotContentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2KeepRuns(t *testing.T) {
|
||||||
|
os.Setenv("R2_KEEP_RUNS", "")
|
||||||
|
if got := r2KeepRuns(); got != 2 {
|
||||||
|
t.Errorf("default = %d, want 2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_KEEP_RUNS", "5")
|
||||||
|
if got := r2KeepRuns(); got != 5 {
|
||||||
|
t.Errorf("override = %d, want 5", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_KEEP_RUNS", "0")
|
||||||
|
if got := r2KeepRuns(); got != 2 {
|
||||||
|
t.Errorf("invalid = %d, want 2 fallback", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Unsetenv("R2_KEEP_RUNS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2MaxRetentionBytes(t *testing.T) {
|
||||||
|
os.Setenv("R2_MAX_RETENTION_BYTES", "")
|
||||||
|
if got := r2MaxRetentionBytes(); got != 10*1024*1024*1024 {
|
||||||
|
t.Errorf("default = %d, want 10GiB", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_MAX_RETENTION_BYTES", "100")
|
||||||
|
if got := r2MaxRetentionBytes(); got != 100 {
|
||||||
|
t.Errorf("override = %d, want 100", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_MAX_RETENTION_BYTES", "invalid")
|
||||||
|
if got := r2MaxRetentionBytes(); got != 10*1024*1024*1024 {
|
||||||
|
t.Errorf("invalid = %d, want 10GiB fallback", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Unsetenv("R2_MAX_RETENTION_BYTES")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2RunFromKey(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
key string
|
||||||
|
run string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"hls/r-20260815T120000Z/seg-1.m4s", "r-20260815T120000Z", true},
|
||||||
|
{"hls/r-20260815T120000Z/init.mp4", "r-20260815T120000Z", true},
|
||||||
|
{"hls/playlist.m3u8", "", false},
|
||||||
|
{"hls/playlist.m3u8.tmp", "", false},
|
||||||
|
{"other/r-x/seg.m4s", "", false},
|
||||||
|
{"hls/r-noslash", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
run, ok := r2RunFromKey(c.key)
|
||||||
|
if run != c.run || ok != c.ok {
|
||||||
|
t.Errorf("r2RunFromKey(%q) = (%q, %v), want (%q, %v)", c.key, run, ok, c.run, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupOldRuns(t *testing.T) {
|
||||||
|
prevClient := r2Client
|
||||||
|
prevBucket := bucket
|
||||||
|
prevList := listObjects
|
||||||
|
prevDelete := deleteObjects
|
||||||
|
prevTotal := r2CleanupObjectsTotal
|
||||||
|
prevRuns := r2RunsRetained
|
||||||
|
prevBytes := r2BytesRetained
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
listObjects = prevList
|
||||||
|
deleteObjects = prevDelete
|
||||||
|
r2CleanupObjectsTotal = prevTotal
|
||||||
|
r2RunsRetained = prevRuns
|
||||||
|
r2BytesRetained = prevBytes
|
||||||
|
}()
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
size := int64(1024)
|
||||||
|
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||||
|
return &s3.ListObjectsV2Output{
|
||||||
|
Contents: []types.Object{
|
||||||
|
{Key: aws.String("hls/r-20260813T000000Z/seg-1.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/r-20260814T000000Z/seg-2.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/r-20260815T000000Z/seg-3.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/playlist.m3u8"), Size: &size},
|
||||||
|
{Key: aws.String("hls/playlist.m3u8.tmp"), Size: &size},
|
||||||
|
},
|
||||||
|
IsTruncated: aws.Bool(false),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedKeys []string
|
||||||
|
deleteObjects = func(_ *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||||
|
for _, obj := range input.Delete.Objects {
|
||||||
|
deletedKeys = append(deletedKeys, aws.ToString(obj.Key))
|
||||||
|
}
|
||||||
|
return &s3.DeleteObjectsOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := cleanupOldRuns(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cleanupOldRuns error: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("deleted = %d, want 1 (oldest run)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.Join(deletedKeys, ",")
|
||||||
|
if !strings.Contains(joined, "hls/r-20260813T000000Z/seg-1.m4s") {
|
||||||
|
t.Fatalf("expected oldest run deleted, got: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if strings.Contains(joined, "playlist.m3u8") {
|
||||||
|
t.Fatalf("manifest must not be deleted: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if r2RunsRetained != 2 {
|
||||||
|
t.Fatalf("r2RunsRetained = %d, want 2", r2RunsRetained)
|
||||||
|
}
|
||||||
|
if r2BytesRetained != 2*size {
|
||||||
|
t.Fatalf("r2BytesRetained = %d, want %d", r2BytesRetained, 2*size)
|
||||||
|
}
|
||||||
|
if r2CleanupObjectsTotal != 1 {
|
||||||
|
t.Fatalf("r2CleanupObjectsTotal = %d, want 1", r2CleanupObjectsTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupOldRuns_SizeCap(t *testing.T) {
|
||||||
|
prevClient := r2Client
|
||||||
|
prevBucket := bucket
|
||||||
|
prevList := listObjects
|
||||||
|
prevDelete := deleteObjects
|
||||||
|
prevTotal := r2CleanupObjectsTotal
|
||||||
|
prevRuns := r2RunsRetained
|
||||||
|
prevBytes := r2BytesRetained
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
listObjects = prevList
|
||||||
|
deleteObjects = prevDelete
|
||||||
|
r2CleanupObjectsTotal = prevTotal
|
||||||
|
r2RunsRetained = prevRuns
|
||||||
|
r2BytesRetained = prevBytes
|
||||||
|
}()
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
// Force a small cap so the size policy trims even the last 2 runs.
|
||||||
|
os.Setenv("R2_KEEP_RUNS", "2")
|
||||||
|
os.Setenv("R2_MAX_RETENTION_BYTES", "1024")
|
||||||
|
defer os.Unsetenv("R2_KEEP_RUNS")
|
||||||
|
defer os.Unsetenv("R2_MAX_RETENTION_BYTES")
|
||||||
|
|
||||||
|
size := int64(1024)
|
||||||
|
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||||
|
return &s3.ListObjectsV2Output{
|
||||||
|
Contents: []types.Object{
|
||||||
|
{Key: aws.String("hls/r-20260813T000000Z/seg-1.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/r-20260814T000000Z/seg-2.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/r-20260815T000000Z/seg-3.m4s"), Size: &size},
|
||||||
|
},
|
||||||
|
IsTruncated: aws.Bool(false),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedKeys []string
|
||||||
|
deleteObjects = func(_ *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||||
|
for _, obj := range input.Delete.Objects {
|
||||||
|
deletedKeys = append(deletedKeys, aws.ToString(obj.Key))
|
||||||
|
}
|
||||||
|
return &s3.DeleteObjectsOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := cleanupOldRuns(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cleanupOldRuns error: %v", err)
|
||||||
|
}
|
||||||
|
// 3 runs -> keep 2 -> oldest deleted by count; remaining 2 bytes (2048) exceed
|
||||||
|
// the 1024 cap -> the older of the kept two is deleted, leaving only the newest.
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("deleted = %d, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.Join(deletedKeys, ",")
|
||||||
|
if !strings.Contains(joined, "hls/r-20260813T000000Z/seg-1.m4s") {
|
||||||
|
t.Fatalf("expected oldest run deleted, got: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, "hls/r-20260814T000000Z/seg-2.m4s") {
|
||||||
|
t.Fatalf("expected second-oldest run deleted by size cap, got: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if strings.Contains(joined, "hls/r-20260815T000000Z/seg-3.m4s") {
|
||||||
|
t.Fatalf("newest run must be kept, got: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if r2RunsRetained != 1 {
|
||||||
|
t.Fatalf("r2RunsRetained = %d, want 1", r2RunsRetained)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaylistFingerprint(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
p := filepath.Join(tmpDir, "playlist.txt")
|
||||||
|
if err := os.WriteFile(p, []byte("file '/app/media/a.mp4'\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fp1, err := playlistFingerprint(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playlistFingerprint error: %v", err)
|
||||||
|
}
|
||||||
|
fp2, err := playlistFingerprint(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playlistFingerprint error: %v", err)
|
||||||
|
}
|
||||||
|
if fp1 != fp2 {
|
||||||
|
t.Errorf("fingerprint not stable: %q vs %q", fp1, fp2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(p, []byte("file '/app/media/b.mp4'\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fp3, err := playlistFingerprint(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playlistFingerprint error: %v", err)
|
||||||
|
}
|
||||||
|
if fp1 == fp3 {
|
||||||
|
t.Errorf("fingerprint should change when playlist content changes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSNextStartNumber(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
// Create segments 0..29 with a live window of 10.
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i)), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next, err := hlsNextStartNumber(tmpDir, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hlsNextStartNumber error: %v", err)
|
||||||
|
}
|
||||||
|
if next != 30 {
|
||||||
|
t.Errorf("next = %d, want 30", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segments < 20 should be pruned; 20..29 kept (the live window).
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("seg-%06d should have been pruned", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 20; i < 30; i++ {
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); err != nil {
|
||||||
|
t.Errorf("seg-%06d should be kept: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSharedHLSConfig(t *testing.T) {
|
||||||
|
prevDur := hlsSegmentDuration
|
||||||
|
prevWin := hlsWindowSize
|
||||||
|
defer func() {
|
||||||
|
hlsSegmentDuration = prevDur
|
||||||
|
hlsWindowSize = prevWin
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(`{"hls_segment_duration":"4","hls_window_size":"12"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.Setenv("PLAYOUT_CONFIG_PATH", cfgPath)
|
||||||
|
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
|
||||||
|
|
||||||
|
loadSharedHLSConfig()
|
||||||
|
|
||||||
|
if hlsSegmentDuration != "4" {
|
||||||
|
t.Errorf("hlsSegmentDuration = %q, want 4", hlsSegmentDuration)
|
||||||
|
}
|
||||||
|
if hlsWindowSize != "12" {
|
||||||
|
t.Errorf("hlsWindowSize = %q, want 12", hlsWindowSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSharedHLSConfig_MissingFile(t *testing.T) {
|
||||||
|
prevDur := hlsSegmentDuration
|
||||||
|
prevWin := hlsWindowSize
|
||||||
|
defer func() {
|
||||||
|
hlsSegmentDuration = prevDur
|
||||||
|
hlsWindowSize = prevWin
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("PLAYOUT_CONFIG_PATH", filepath.Join(t.TempDir(), "nope.json"))
|
||||||
|
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
|
||||||
|
|
||||||
|
loadSharedHLSConfig() // should be a no-op without erroring
|
||||||
|
|
||||||
|
if hlsSegmentDuration != prevDur {
|
||||||
|
t.Errorf("hlsSegmentDuration changed to %q, want unchanged %q", hlsSegmentDuration, prevDur)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ set -e
|
|||||||
|
|
||||||
echo "=== Linear Void Playout Service Starting ==="
|
echo "=== Linear Void Playout Service Starting ==="
|
||||||
echo "Playlist path: ${PLAYLIST_PATH:=/app/playlist/playlist.txt}"
|
echo "Playlist path: ${PLAYLIST_PATH:=/app/playlist/playlist.txt}"
|
||||||
echo "HLS segment duration: ${HLS_SEGMENT_DURATION:-6}s"
|
echo "HLS segment duration: ${HLS_SEGMENT_DURATION:-2}s"
|
||||||
echo "HLS window size: ${HLS_WINDOW_SIZE:-24}"
|
echo "HLS window size: ${HLS_WINDOW_SIZE:-12}"
|
||||||
|
|
||||||
# Ensure playlist directory and file exist
|
# Ensure playlist directory and file exist
|
||||||
mkdir -p "$(dirname "$PLAYLIST_PATH")"
|
mkdir -p "$(dirname "$PLAYLIST_PATH")"
|
||||||
@@ -17,38 +17,7 @@ file '/app/media/demo2.mp4'
|
|||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run normalization validation using the Go tool
|
# Single source of truth: the Go binary validates the playlist (ADR-001/ADR-003
|
||||||
echo "Validating playlist and enforcing normalization (ADR-001/ADR-003)..."
|
# normalization enforcement) and runs the full loop (HLS, R2 sync, slate
|
||||||
if ! /app/playlist-manager "$PLAYLIST_PATH"; then
|
# fallback, health server). Validation runs once inside the binary.
|
||||||
echo "ERROR: Playlist validation failed. Check logs."
|
exec /app/playlist-manager "$PLAYLIST_PATH"
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Playlist validated successfully. Starting continuous HLS playout..."
|
|
||||||
|
|
||||||
mkdir -p /app/hls
|
|
||||||
rm -f /app/hls/playlist.m3u8 /app/hls/init.mp4 /app/hls/segment_*.ts /app/hls/segment_*.m4s
|
|
||||||
|
|
||||||
# Run FFmpeg in a loop for continuous playback
|
|
||||||
while true; do
|
|
||||||
echo "[$(date)] Starting FFmpeg playout with browser-safe HLS encoding..."
|
|
||||||
ffmpeg -re -f concat -safe 0 -i "$PLAYLIST_PATH" \
|
|
||||||
-map 0:v:0 -map 0:a:0? \
|
|
||||||
-vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p" \
|
|
||||||
-c:v libx264 -preset veryfast -pix_fmt yuv420p \
|
|
||||||
-profile:v high -level 4.0 \
|
|
||||||
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
|
||||||
-c:a aac -ar 48000 -ac 2 -b:a 128k \
|
|
||||||
-f hls \
|
|
||||||
-hls_time "${HLS_SEGMENT_DURATION}" \
|
|
||||||
-hls_list_size "${HLS_WINDOW_SIZE}" \
|
|
||||||
-hls_segment_type fmp4 \
|
|
||||||
-hls_fmp4_init_filename init.mp4 \
|
|
||||||
-strftime 1 \
|
|
||||||
-hls_flags delete_segments+independent_segments \
|
|
||||||
-hls_segment_filename "/app/hls/segment_%Y%m%d_%H%M%S.m4s" \
|
|
||||||
"/app/hls/playlist.m3u8" || echo "FFmpeg exited with error (will restart in 5s per ADR-001 resilience requirements)..."
|
|
||||||
|
|
||||||
echo "Playout loop crashed or ended. Restarting in 5 seconds..."
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
# Linear Void test playlist
|
||||||
|
# No media entries here
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
# Valid entries should still parse
|
||||||
|
file '/app/media/demo1.mp4'
|
||||||
|
|
||||||
|
# Invalid or malformed lines are preserved as literal paths by the parser
|
||||||
|
not-a-file-entry
|
||||||
|
file '/app/media/demo2.mp4'
|
||||||
BIN
Binary file not shown.
+2
@@ -0,0 +1,2 @@
|
|||||||
|
file '/app/media/demo1.mp4'
|
||||||
|
file '/app/media/demo2.mp4'
|
||||||
+67
-6
@@ -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
|
||||||
@@ -5,10 +11,13 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
ports:
|
||||||
|
- "${PLAYOUT_PORT:-8081}:8081"
|
||||||
volumes:
|
volumes:
|
||||||
- ./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:
|
||||||
@@ -42,14 +51,14 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 30s
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: git.allucanget.biz/zwitschi/linearvoid/nginx:latest
|
image: git.allucanget.biz/zwitschi/linearvoid/nginx:latest
|
||||||
container_name: linearvoid-nginx
|
container_name: linearvoid-nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${NGINX_PORT:-8080}:80"
|
- "${NGINX_PORT:-8082}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/hls:/srv/hls:ro
|
- ./data/hls:/srv/hls:ro
|
||||||
networks:
|
networks:
|
||||||
@@ -68,7 +77,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 +106,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
|
||||||
@@ -111,6 +170,8 @@ services:
|
|||||||
command:
|
command:
|
||||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||||
- "--storage.tsdb.path=/prometheus"
|
- "--storage.tsdb.path=/prometheus"
|
||||||
|
- "--storage.tsdb.retention.time=30d"
|
||||||
|
- "--storage.tsdb.retention.size=15GB"
|
||||||
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
||||||
- "--web.console.templates=/etc/prometheus/consoles"
|
- "--web.console.templates=/etc/prometheus/consoles"
|
||||||
- "--web.enable-lifecycle"
|
- "--web.enable-lifecycle"
|
||||||
@@ -123,8 +184,7 @@ services:
|
|||||||
- "3001:3000"
|
- "3001:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- grafana-data:/var/lib/grafana
|
- grafana-data:/var/lib/grafana
|
||||||
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
|
- grafana-provisioning:/etc/grafana/provisioning
|
||||||
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources
|
|
||||||
environment:
|
environment:
|
||||||
- GF_SECURITY_ADMIN_USER=admin
|
- GF_SECURITY_ADMIN_USER=admin
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
|
||||||
@@ -141,6 +201,7 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
redis-data:
|
redis-data:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
prometheus-data:
|
|
||||||
prometheus-config:
|
prometheus-config:
|
||||||
|
prometheus-data:
|
||||||
grafana-data:
|
grafana-data:
|
||||||
|
grafana-provisioning:
|
||||||
+63
-2
@@ -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
|
||||||
@@ -8,10 +13,13 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
ports:
|
||||||
|
- "${PLAYOUT_PORT:-8081}:8081"
|
||||||
volumes:
|
volumes:
|
||||||
- ./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:
|
||||||
@@ -58,7 +66,7 @@ services:
|
|||||||
container_name: linearvoid-nginx
|
container_name: linearvoid-nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${NGINX_PORT:-8080}:80"
|
- "${NGINX_PORT:-8082}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/dist:/srv/frontend:ro
|
- ./frontend/dist:/srv/frontend:ro
|
||||||
- ./data/hls:/srv/hls:ro
|
- ./data/hls:/srv/hls:ro
|
||||||
@@ -72,7 +80,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 +99,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"
|
||||||
@@ -105,6 +164,8 @@ services:
|
|||||||
command:
|
command:
|
||||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||||
- "--storage.tsdb.path=/prometheus"
|
- "--storage.tsdb.path=/prometheus"
|
||||||
|
- "--storage.tsdb.retention.time=30d"
|
||||||
|
- "--storage.tsdb.retention.size=15GB"
|
||||||
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
||||||
- "--web.console.templates=/etc/prometheus/consoles"
|
- "--web.console.templates=/etc/prometheus/consoles"
|
||||||
- "--web.enable-lifecycle"
|
- "--web.enable-lifecycle"
|
||||||
|
|||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
# Linear Void API Reference
|
||||||
|
|
||||||
|
Base URLs: Public/admin app `https://linearvoid.tv` · WebSocket `wss://linearvoid.tv/ws` · Local fallback `http://localhost` / `ws://localhost/ws`
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Admin REST endpoints use a cookie session:
|
||||||
|
|
||||||
|
- Cookie: `linearvoid_admin_session` — `HttpOnly`, `SameSite=Strict`, `Secure` when request is HTTPS (or `X-Forwarded-Proto=https`)
|
||||||
|
- Session TTL: `ADMIN_SESSION_TTL_HOURS` (default 12h)
|
||||||
|
|
||||||
|
## Chat WebSocket API
|
||||||
|
|
||||||
|
Connect: `GET /ws?username={name}&room={room}` — client/server exchange JSON messages.
|
||||||
|
|
||||||
|
| Message (client→server) | Field | Notes |
|
||||||
|
| ----------------------- | -------------------------------------------------------------------------------- | ----- |
|
||||||
|
| `type` | `public` for room chat, `private` for DM routing; `system` for moderation/events | |
|
||||||
|
| `room` | `main`; private DMs use `dm:userA-userB` (usernames sorted) | |
|
||||||
|
| `username` | sender | |
|
||||||
|
| `text` | message body | |
|
||||||
|
|
||||||
|
Server→client envelope adds `"timestamp"` (ISO-8601, e.g. `2026-08-04T12:34:56Z`).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "room": "main", "username": "viewer123", "text": "hello world", "type": "public" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin Session API
|
||||||
|
|
||||||
|
| Method & Path | Auth | Description |
|
||||||
|
| ------------------------ | ----------------------------------- | ----------------------------- |
|
||||||
|
| `POST /api/admin/login` | none | Authenticate |
|
||||||
|
| `POST /api/admin/logout` | optional (clears cookie either way) | Logout |
|
||||||
|
| `GET /api/admin/session` | cookie required | Session check; 401 if invalid |
|
||||||
|
|
||||||
|
Login request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "username": "admin", "password": "your-password" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Login/logout/session success response (session returns 401 on failure):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "authenticated": true, "username": "admin" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Logout response: `{ "authenticated": false }`
|
||||||
|
|
||||||
|
## Admin Data API (auth required)
|
||||||
|
|
||||||
|
| Method & Path | Returns |
|
||||||
|
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||||
|
| `GET /api/admin/overview` | Aggregate counters + runtime/system snapshot |
|
||||||
|
| `GET /api/admin/playlist` | Playlist |
|
||||||
|
| `PUT /api/admin/playlist` | Set playlist (body: `{ "content": "file '/app/media/video1.mp4'\nfile '/app/media/video2.mp4'\n" }`) |
|
||||||
|
| `GET /api/admin/schedule` | Parsed playlist entries + matched media metadata/runtime |
|
||||||
|
| `GET /api/admin/moderation/logs?limit=50` | Moderation logs |
|
||||||
|
| `POST /api/admin/moderation/actions` | Perform moderation action |
|
||||||
|
| `GET /api/admin/analytics` | Totals (messages, moderation actions, rooms) + top room summary |
|
||||||
|
| `GET /api/admin/system` | Health snapshot (chat/playout/redis/database) + config metadata |
|
||||||
|
| `GET /api/admin/users` | Presence users (room + username) when Redis available |
|
||||||
|
|
||||||
|
Moderation action request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "action": "mute", "target": "username-or-id", "moderator": "admin", "reason": "spam", "room": "main" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Media Library API
|
||||||
|
|
||||||
|
| Method & Path | Description |
|
||||||
|
| ------------------------------ | ------------------------------------------------- |
|
||||||
|
| `GET /api/admin/media` | List media |
|
||||||
|
| `POST /api/admin/media` | Multipart upload (legacy), form field name `file` |
|
||||||
|
| `GET /api/admin/media/{id}` | Get item |
|
||||||
|
| `PUT /api/admin/media/{id}` | Update item |
|
||||||
|
| `DELETE /api/admin/media/{id}` | Delete item |
|
||||||
|
|
||||||
|
PUT media body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ready", "title": "My Clip", "tags": "music,live" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resumable Upload API
|
||||||
|
|
||||||
|
Max file size: **2GB** · Default chunk size: **8MB**
|
||||||
|
|
||||||
|
| Method & Path | Description |
|
||||||
|
| ------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||||
|
| `POST /api/admin/media/uploads` | Initialize or resume session |
|
||||||
|
| `GET /api/admin/media/uploads/{uploadId}` | Check upload status |
|
||||||
|
| `PUT /api/admin/media/uploads/{uploadId}/chunk?index={chunkIndex}` | Upload chunk (`Content-Type: application/octet-stream`) |
|
||||||
|
| `POST /api/admin/media/uploads/{uploadId}/complete` | Complete upload → returns media listing (`files` + `generatedAt`) |
|
||||||
|
|
||||||
|
Initialize request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uploadId": "optional-existing-id",
|
||||||
|
"fileName": "clip.mp4",
|
||||||
|
"fileSize": 123456789,
|
||||||
|
"mimeType": "video/mp4",
|
||||||
|
"chunkSize": 8388608,
|
||||||
|
"totalChunks": 15
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Status envelope (returned by init, status check, and chunk upload):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uploadId": "hex-token",
|
||||||
|
"fileName": "clip.mp4",
|
||||||
|
"fileSize": 123456789,
|
||||||
|
"chunkSize": 8388608,
|
||||||
|
"totalChunks": 15,
|
||||||
|
"uploadedChunks": 3,
|
||||||
|
"progress": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Metrics & Health
|
||||||
|
|
||||||
|
- `GET /metrics` (chat) — Prometheus: connected clients, stored messages, moderation actions, Redis indicators, active admin sessions, build info
|
||||||
|
- `GET /health` — chat service, playout service, and nginx reverse proxy each expose it
|
||||||
|
|
||||||
|
## HTTP Status Conventions
|
||||||
|
|
||||||
|
| Code | Meaning | When |
|
||||||
|
| ---- | --------------------- | ----------------------------------------------------------- |
|
||||||
|
| 200 | OK | Successful GET, PUT, action |
|
||||||
|
| 201 | Created | Successful POST (upload init, override created) |
|
||||||
|
| 204 | No Content | Successful DELETE, CORS preflight |
|
||||||
|
| 400 | Bad Request | Malformed JSON, missing required fields, invalid input |
|
||||||
|
| 401 | Unauthorized | Missing/expired admin session |
|
||||||
|
| 403 | Forbidden | Valid session, insufficient permissions |
|
||||||
|
| 404 | Not Found | Resource doesn't exist |
|
||||||
|
| 405 | Method Not Allowed | Wrong 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, WS, login) |
|
||||||
|
| 500 | Internal Server Error | Unexpected server failure |
|
||||||
|
| 503 | Service Unavailable | Redis unavailable for user actions, playout health degraded |
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
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 (applicable responses): `X-RateLimit-Limit` (max requests/window), `Retry-After` (seconds until reset). 413 = payload too large; 500 = internal server error.
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
# Coolify Deployment
|
||||||
|
|
||||||
|
*Last Updated: 2026-08-28*
|
||||||
|
|
||||||
|
Alternative deployment path for Linear Void using [Coolify](https://github.com/coollabsio/coolify/tree/v4.3.14) (v4.3.14), an open-source self-hostable PaaS that manages servers, apps, and databases over SSH. Drop-in alternative to the flows in [SETUP.md](./SETUP.md): local dev (`docker-compose.yml`) and test-lab/prod (`docker-compose.prod.yml` via Gitea Actions CD). Coolify deploys the same Compose stack through a web UI and its own Traefik reverse proxy for automatic HTTPS, domain routing, and one-click redeploys (no manual `DOCKER_HOST`).
|
||||||
|
|
||||||
|
## Why Coolify
|
||||||
|
|
||||||
|
- **Single control plane**: manage playout, chat, nginx, redis, db (plus exporters/monitoring) from one dashboard.
|
||||||
|
- **Automatic TLS**: Let's Encrypt provisioning/renewal per app/domain, replacing the manual Cloudflare + nginx origin TLS setup for the app hostname.
|
||||||
|
- **No vendor lock-in**: config lives on your server; containers keep running if you stop using Coolify — you only lose automation.
|
||||||
|
- **Simpler cutover**: on Hetzner VPS provisioning, point Coolify at the new server and redeploy instead of editing CD `DOCKER_HOST`.
|
||||||
|
|
||||||
|
> Replaces the *deployment orchestration* layer only. Cloudflare DNS, R2 media bucket, cache rules, and the edge Worker from [SETUP.md](./SETUP.md) are unchanged.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Linux server with **Docker Engine** (any supported distro).
|
||||||
|
- **SSH access** (root or passwordless `sudo`) from the Coolify install machine — Coolify provisions the target over SSH.
|
||||||
|
- **2 GB RAM / 20 GB disk** minimum for Coolify itself (plus resources for the stack).
|
||||||
|
- A domain pointed at the server for HTTPS (e.g. `linearvoid.tv`).
|
||||||
|
- Ports `80` and `443` reachable for Traefik.
|
||||||
|
- Gitea registry credentials (`CI_REGISTRY_USER` / `CI_REGISTRY_TOKEN`) if pulling prebuilt `playout`, `chat`, `nginx` images.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Recommended on a dedicated server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer boots a Docker-based Coolify instance and prints the URL and default credentials. Complete first-run setup in the dashboard. For non-interactive/offline installs, see the [installation docs](https://coolify.io/docs/installation) and [install script source](https://github.com/coollabsio/coolify/blob/v4.3.14/scripts/install.sh).
|
||||||
|
|
||||||
|
## Add the Linear Void server
|
||||||
|
|
||||||
|
1. **Servers → Add**.
|
||||||
|
2. Choose your provider (or **Localhost** if Coolify runs on the same host as the stack).
|
||||||
|
3. Provide SSH details; for a remote host Coolify validates and installs its agent.
|
||||||
|
4. Confirm the server shows **Reachable** before creating resources.
|
||||||
|
|
||||||
|
### Current topology (verified 2026-08-30)
|
||||||
|
|
||||||
|
Active test-lab setup is **two-tier**:
|
||||||
|
|
||||||
|
| Component | Host | Exposes | Docker TCP | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Coolify dashboard** | `192.168.88.18` | `https://coolify.allucanget.biz` via Cloudflare tunnel (`coolify-cloudflared`); dashboard at `8000` | No | Manages target servers over SSH |
|
||||||
|
| **Deployment target** | `192.168.88.40` | `coolify-proxy` = Traefik on 80/443; port `8000` bound by Portainer, not Coolify | Yes | Per-server helpers (`coolify-proxy`, `coolify-sentinel`, `coolify-cloudflared`) + the stack via `docker-compose.prod.yml` (`linearvoid` compose project, 11 containers) |
|
||||||
|
|
||||||
|
The main `coolify` app container runs only on the dashboard host `192.168.88.18`; Compose-resource services land on the target `192.168.88.40`. The dashboard host exposes no Docker TCP socket, so run the checks below against the **target** `192.168.88.40`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check-env.sh # env/deployment health on target (containers, helpers, health)
|
||||||
|
./scripts/check-tokens.sh # Gitea registry, Cloudflare, R2, admin credential access
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy the stack
|
||||||
|
|
||||||
|
Deploy as a **Docker Compose** resource (not individual apps):
|
||||||
|
|
||||||
|
1. **Projects → Add → Docker Compose**.
|
||||||
|
2. Select the target server.
|
||||||
|
3. In **Build Settings**, set the compose file source:
|
||||||
|
- **Git Repository** → point at `https://git.allucanget.biz/zwitschi/linearvoid.git`, compose file `docker-compose.prod.yml` (Coolify pulls on each redeploy).
|
||||||
|
- **Raw Compose** → paste `docker-compose.prod.yml` directly to manage manually.
|
||||||
|
4. For the Git source, add the Gitea registry login as a **Deployment Key / environment secret** so `playout`, `chat`, `nginx` images can be pulled. Alternatively switch those services to `build:` blocks (as in `docker-compose.yml`) to build from source on the server.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
Map Linear Void's `.env` values into Coolify's **Environment Variables** tab:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `DATABASE_URL` | `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable` |
|
||||||
|
| `REDIS_URL` | `redis://redis:6379/0` |
|
||||||
|
| `POSTGRES_PASSWORD` | DB password (used by `db` service) |
|
||||||
|
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | Admin panel credentials |
|
||||||
|
| `ADMIN_SESSION_TTL_HOURS` | Admin session lifetime |
|
||||||
|
| `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` | Cloudflare R2 credentials |
|
||||||
|
| `R2_BUCKET_NAME` / `R2_ENDPOINT` / `R2_REGION` | R2 bucket config (`R2_REGION=auto`) |
|
||||||
|
| `MEDIA_DOMAIN` | `media.linearvoid.tv` |
|
||||||
|
| `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` |
|
||||||
|
| `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` |
|
||||||
|
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` |
|
||||||
|
|
||||||
|
Keep internal service URLs on compose hostnames (`redis`, `db`) exactly as in `docker-compose.prod.yml`.
|
||||||
|
|
||||||
|
### Persistent storage
|
||||||
|
|
||||||
|
Named volumes (`redis-data`, `postgres-data`) and bind mounts under `./data` must be configured under **Persistent Storage** to survive redeploys:
|
||||||
|
|
||||||
|
| Mount | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `data/playlist` | Playout playlist file |
|
||||||
|
| `data/media` | Normalized source videos (read-only to playout) |
|
||||||
|
| `data/hls` | Generated HLS manifests/segments |
|
||||||
|
| `data/chat` | Chat service working data |
|
||||||
|
| `data/slate` | Slate media |
|
||||||
|
| `redis-data` | Redis append-only persistence |
|
||||||
|
| `postgres-data` | PostgreSQL data directory |
|
||||||
|
|
||||||
|
If Coolify doesn't auto-create the dirs, create them before first deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data/playlist data/media data/hls data/chat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domains and proxy
|
||||||
|
|
||||||
|
1. Assign the **nginx** service a public domain (e.g. `linearvoid.tv`); Traefik terminates TLS and forwards to `nginx`.
|
||||||
|
2. Set **Ports Exposes** so Traefik targets the nginx port (`80` inside container, mapped from `${NGINX_PORT:-8082}:80`).
|
||||||
|
3. Do **not** publicly expose playout (`8081`) or chat (`3000`) health ports — keep them internal and route through nginx.
|
||||||
|
4. For `media.linearvoid.tv`, keep serving HLS from the R2 custom domain (per SETUP.md) rather than routing through Coolify, so segments stay on Cloudflare's edge cache.
|
||||||
|
|
||||||
|
## Post-deploy verification
|
||||||
|
|
||||||
|
- `docker compose ps` on the server shows all services healthy.
|
||||||
|
- Playout health: `https://<host>/health` via nginx (internal `:8081/health`).
|
||||||
|
- Chat health: internal `:3000/health`.
|
||||||
|
- HLS written to `data/hls` and served from `media.linearvoid.tv`.
|
||||||
|
- Chat WebSocket connects over `wss://linearvoid.tv/ws`.
|
||||||
|
- Admin login works at `/admin` with configured credentials.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Coolify exposes container logs/basic metrics per resource. The stack ships Prometheus + Grafana (`monitoring/`) plus nginx/redis exporters. To reach them via Coolify, expose Grafana on a restricted subdomain (e.g. `grafana.linearvoid.tv`) and keep Prometheus internal, mirroring SETUP.md access guidance.
|
||||||
|
|
||||||
|
## Cutover from Gitea CD
|
||||||
|
|
||||||
|
1. Deploy the stack as a Coolify compose resource (above).
|
||||||
|
2. Point Cloudflare DNS for `linearvoid.tv` / `www` at the server IP (proxied).
|
||||||
|
3. Disable (or keep as fallback) the `cd` job in the Gitea workflow so the two orchestrators don't fight over the same `DOCKER_HOST`.
|
||||||
|
4. Keep the Gitea **CI** (build + push images) job so Coolify keeps pulling `:latest` from the registry.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Image pull fails**: set Gitea registry credentials in the compose secret store, or switch services to `build:` blocks.
|
||||||
|
- **Healthchecks fail**: confirm `curl` exists in the built images (healthchecks rely on it).
|
||||||
|
- **TLS errors**: check the domain's DNS points at the Coolify server and ports `80`/`443` are open.
|
||||||
|
- **HLS not updating**: verify `data/hls` is writable and playout restarts cleanly; see SETUP.md normalization troubleshooting.
|
||||||
|
- **Ports conflict**: Coolify's Traefik binds `80`/`443`; do not also map those to the host in the compose file.
|
||||||
|
|
||||||
|
## Further reading
|
||||||
|
|
||||||
|
- Coolify repo (v4.3.14): <https://github.com/coollabsio/coolify/tree/v4.3.14>
|
||||||
|
- Coolify docs: <https://coolify.io/docs>
|
||||||
|
- [DEPLOYMENT.md](./DEPLOYMENT.md) · [SETUP.md](./SETUP.md)
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
**Current state (2026-08-06):** Test lab is active and receives automated CD deploys. Production Hetzner VPS is **not yet provisioned**. `cd.yml` and `docker-compose.prod.yml` target the test lab; switching to production only requires changing `DOCKER_HOST`.
|
||||||
|
|
||||||
|
## Production Target (Hetzner)
|
||||||
|
|
||||||
|
**Status: Not provisioned.** Plan documented and pipeline ready; only the VPS needs creation.
|
||||||
|
|
||||||
|
### Planned Infrastructure
|
||||||
|
|
||||||
|
- 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
|
||||||
|
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
|
||||||
|
|
||||||
|
### Production Preflight (minimum readiness gate)
|
||||||
|
|
||||||
|
1. Verify Hetzner host reachable over SSH and Docker remote API TLS works for the Gitea runner
|
||||||
|
2. Confirm all production secrets exist under `CI_PROD_*` prefix
|
||||||
|
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, `media.linearvoid.tv` DNS targets match intended phase
|
||||||
|
4. Complete manual Cloudflare/UptimeRobot checklist (see `docs/operations/cloudflare-uptimerobot-checklist.md`)
|
||||||
|
5. Run production smoke path end-to-end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, playout health
|
||||||
|
6. Verify rollback possible before final DNS switch
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
| Workflow | Trigger | Purpose |
|
||||||
|
| -------------------- | ----------------- | --------------------------------------------------------------------------------- |
|
||||||
|
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push 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 images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
|
||||||
|
| `workers-deploy.yml` | After CI succeeds | Deploy frontend to Cloudflare Workers |
|
||||||
|
|
||||||
|
CI and CD run simultaneously on every push to `main`. Deploy pulls whatever `:latest` images are available at that moment. `cd.yml` uses `DOCKER_HOST` to target the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test lab (current)
|
||||||
|
DOCKER_HOST=tcp://192.168.88.40:2375
|
||||||
|
|
||||||
|
# Production (when provisioned)
|
||||||
|
DOCKER_HOST=tcp://<hetzner-public-ip>:2375
|
||||||
|
```
|
||||||
|
|
||||||
|
No pipeline code changes needed to switch targets.
|
||||||
|
|
||||||
|
## Target Topology
|
||||||
|
|
||||||
|
- Host: Linux server with Docker Engine
|
||||||
|
- Reverse proxy: nginx container (linearvoid-nginx)
|
||||||
|
- Core services: playout, chat, redis, postgres
|
||||||
|
- Observability: prometheus, grafana
|
||||||
|
- CDN/edge: Cloudflare
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Engine + Docker Compose plugin
|
||||||
|
- Network access to git.allucanget.biz
|
||||||
|
- Valid `.env` file with production values
|
||||||
|
- DNS configured for `linearvoid.tv` (and `media.linearvoid.tv` if split media domain used)
|
||||||
|
|
||||||
|
## Environment File
|
||||||
|
|
||||||
|
`.env` requires at minimum:
|
||||||
|
|
||||||
|
- `DATABASE_URL` — PostgreSQL connection string
|
||||||
|
- `REDIS_URL` — Redis connection string
|
||||||
|
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` — admin dashboard credentials
|
||||||
|
- `ADMIN_SESSION_TTL_HOURS` — session expiry (default 12)
|
||||||
|
- `VITE_PUBLIC_APP_URL` / `VITE_PUBLIC_WS_URL` / `VITE_PUBLIC_HLS_URL` — frontend URLs
|
||||||
|
|
||||||
|
**Cloudflare R2 (required for HLS segment sync to edge):**
|
||||||
|
|
||||||
|
- `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY`
|
||||||
|
- `R2_BUCKET_NAME` (e.g. `linearvoid-media`)
|
||||||
|
- `R2_ENDPOINT` — S3-compatible endpoint URL
|
||||||
|
- `R2_REGION` — set to `auto`
|
||||||
|
|
||||||
|
**Cloudflare (optional, for Workers):**
|
||||||
|
|
||||||
|
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`
|
||||||
|
- `MEDIA_DOMAIN` — split media domain (e.g. `media.linearvoid.tv`)
|
||||||
|
|
||||||
|
See `.env.example` and `SETUP.md` for full variable reference.
|
||||||
|
|
||||||
|
### Gitea Secrets
|
||||||
|
|
||||||
|
`cd.yml` injects sensitive values from Gitea secrets into `.env` at deploy time. Configure in the Gitea repository:
|
||||||
|
|
||||||
|
`DATABASE_URL`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN_ACCESS_KEY_ID` (optional), `R2_ADMIN_SECRET_ACCESS_KEY` (optional), `R2_ADMIN_ENDPOINT` (optional), `CLOUDFLARE_API_TOKEN` (optional), `CLOUDFLARE_ACCOUNT_ID` (optional), `CI_REGISTRY_USER`, `CI_REGISTRY_TOKEN`
|
||||||
|
|
||||||
|
## Pull-and-Run Deployment
|
||||||
|
|
||||||
|
Images are built/pushed automatically by CI and release workflows. Every push to `main` triggers CI (`:main`, `:latest`) and CD (deploys `:latest` to test lab) in parallel. Semantic version tags (`*.*.*`, e.g. `1.0.9`) trigger the release workflow (`:X.Y.Z`, `:X.Y`, `:latest` + Gitea Release).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.allucanget.biz
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||||
|
```
|
||||||
|
|
||||||
|
## First-Time or Recovery Provisioning
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data/chat data/playlist data/media data/hls
|
||||||
|
docker volume ls
|
||||||
|
docker network ls
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint checks:
|
||||||
|
|
||||||
|
- App: `http://{host}:8080/`
|
||||||
|
- Nginx health: `http://{host}:8080/health`
|
||||||
|
- Chat health: `http://{host}:3000/health`
|
||||||
|
- Playout health: `http://{host}:8081/health`
|
||||||
|
- Prometheus: `http://{host}:9090`
|
||||||
|
- Grafana: `http://{host}:3001`
|
||||||
|
|
||||||
|
Domain checks:
|
||||||
|
|
||||||
|
- `https://linearvoid.tv`
|
||||||
|
- `https://linearvoid.tv/admin`
|
||||||
|
- `https://linearvoid.tv/metrics`
|
||||||
|
|
||||||
|
## Zero/Low-Downtime Update Pattern
|
||||||
|
|
||||||
|
1. Pull newest images.
|
||||||
|
2. Recreate changed services only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Re-run health checks.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If new images are unhealthy:
|
||||||
|
|
||||||
|
1. Pin previous tags in `docker-compose.prod.yml`.
|
||||||
|
2. Pull pinned tags.
|
||||||
|
3. Restart impacted services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operational Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View logs
|
||||||
|
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
|
||||||
|
|
||||||
|
# Restart one service
|
||||||
|
docker compose -f docker-compose.prod.yml restart chat
|
||||||
|
|
||||||
|
# Stop stack (keep data)
|
||||||
|
docker compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# Stop stack — destructive (remove data)
|
||||||
|
docker compose -f docker-compose.prod.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nginx and Proxy Considerations
|
||||||
|
|
||||||
|
- If an external proxy (e.g. Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx (see `infrastructure/nginx/nginx.conf`).
|
||||||
|
- For resumable admin uploads, ensure `client_max_body_size` supports chunk requests in both proxy layers.
|
||||||
|
|
||||||
|
## Security Baseline
|
||||||
|
|
||||||
|
- Use a strong `ADMIN_PASSWORD` and rotate regularly.
|
||||||
|
- Keep Docker host access restricted.
|
||||||
|
- Prefer HTTPS/WSS through Cloudflare.
|
||||||
|
- Restrict exposed ports if only proxy access is needed.
|
||||||
|
|
||||||
|
## Common Deployment Pitfalls
|
||||||
|
|
||||||
|
- Missing frontend build artifacts in local-only compose workflows.
|
||||||
|
- Wrong registry credentials or missing login.
|
||||||
|
- Misaligned domain env vars (app/ws/hls) causing mixed-origin errors.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
Additional notes: proxy-layer body size limits can return 413 on upload; health checks may fail before service warmup completes.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Monitoring Guide
|
||||||
|
|
||||||
|
Prometheus + Grafana monitoring for Linear Void.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌───────────┐
|
||||||
|
│ Exporters │───> │ Prometheus │───>│ Grafana │
|
||||||
|
│ (7 targets) │ │ :9090 │ │ :3001 │
|
||||||
|
└──────────────┘ └───────┬──────┘ └───────────┘
|
||||||
|
│
|
||||||
|
alerts.yml (8 rules)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scrape Targets
|
||||||
|
|
||||||
|
| Job | Target | Port | Metrics |
|
||||||
|
| ---------- | ----------------- | ---- | ------------------------------------------------------------- |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
Config: `monitoring/prometheus/prometheus.yml` (15s scrape interval)
|
||||||
|
|
||||||
|
## Alerting Rules
|
||||||
|
|
||||||
|
8 rules in `monitoring/prometheus/alerts.yml`:
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
- Prometheus: `http://<host>:9090` (targets, graph, alerts)
|
||||||
|
- Grafana: `http://<host>:3001` (default: admin/admin, change via `GRAFANA_PASSWORD`)
|
||||||
|
|
||||||
|
## Dashboards
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
## Metrics Checking
|
||||||
|
|
||||||
|
See detailed scripts for querying Prometheus scrape targets:
|
||||||
|
|
||||||
|
- [Linux/macOS](./infrastructure/monitoring/prometheus_curl.md)
|
||||||
|
|
||||||
|
## Key Metrics
|
||||||
|
|
||||||
|
- `linearvoid_chat_connected_clients` — active WebSocket connections
|
||||||
|
- `linearvoid_chat_messages_total` — total stored messages
|
||||||
|
- `linearvoid_chat_dms_total` — private messages sent
|
||||||
|
- `linearvoid_chat_redis_up` — Redis reachable (0/1)
|
||||||
|
- `linearvoid_playout_restarts_total` — FFmpeg restart counter
|
||||||
|
- `linearvoid_playout_mode` — 0=live, 1=slate, 2=degraded
|
||||||
|
- `linearvoid_chat_admin_sessions_active` — active admin logins
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Check |
|
||||||
|
| ----------------------- | -------------------------------------------------------------------- |
|
||||||
|
| No chat metrics | `curl http://chat:3000/metrics` from Prometheus container |
|
||||||
|
| No exporter metrics | Verify exporter containers running: `docker ps \| grep exporter` |
|
||||||
|
| 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 no data**: ensure datasource points to `http://prometheus:9090`; confirm Prometheus is healthy; check dashboard query labels match active jobs.
|
||||||
|
|
||||||
|
**High cardinality / slow queries**: reduce unneeded labels; limit expensive panels; tune retention and query windows.
|
||||||
|
|
||||||
|
## Operational Recommendations
|
||||||
|
|
||||||
|
- Keep alert thresholds tied to actual user impact.
|
||||||
|
- Review dashboards after each release that changes endpoints or metrics names.
|
||||||
|
- Periodically test alert routing and incident response flow.
|
||||||
|
- Retention: **30 days** time-based plus **15GB** size cap (`--storage.tsdb.retention.time=30d --storage.tsdb.retention.size=15GB` on the `prometheus` service in `docker-compose.yml` and `docker-compose.prod.yml`). Increase the size cap before extending the time window so the TSDB does not hit the disk limit first.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Docs Folder
|
||||||
|
|
||||||
|
This folder contains the documentation for the Linear Void project. The documentation is organized into several markdown files, each covering different aspects of the project.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- [Architecture](architecture/README.md) - High-level system architecture and component interactions.
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- Cloudflare
|
||||||
|
- [Cache Rules](./infrastructure/cloudflare/cache-rules.md) - Cache rules for Cloudflare Workers.
|
||||||
|
- [Account Troubleshooting](./infrastructure/cloudflare/cloudflare-account-troubleshooting.md) - Troubleshooting Cloudflare account issues.
|
||||||
|
- Monitoring
|
||||||
|
- [Prometheus CURL](./infrastructure/monitoring/prometheus_curl.md) - Prometheus metrics endpoint testing with curl and jq. Doocumentation of available metrics and their meanings.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [API](API.md) - Implemented WebSocket and REST API reference.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- [Deployment](DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
- [Monitoring](MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- [Security](SECURITY.md) - Security model, controls, and hardening baseline.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- [Setup](SETUP.md) - Test lab setup, local development, and CI/CD workflow.
|
||||||
|
|
||||||
|
## Streaming
|
||||||
|
|
||||||
|
- [Streaming](STREAMING.md) - Streaming architecture, playout, and delivery details.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Marketing](marketing/README.md) - Marketing materials and resources.
|
||||||
|
- [Operations](operations/README.md) - Operational procedures and checklists.
|
||||||
|
- [Incident Runbook](incident-runbook.md) - Incident response and disaster recovery procedures.
|
||||||
|
|
||||||
|
## Other Documents
|
||||||
|
|
||||||
|
- [CHANGELOG](../CHANGELOG.md) - Release notes and version history.
|
||||||
|
- [CONTRIBUTING](../CONTRIBUTING.md) - Contribution guidelines and code of conduct.
|
||||||
|
- [TODO](../.github/instructions/TODO.md) - Active implementation plan with checkboxes.
|
||||||
|
- [Backlog](../.github/instructions/BACKLOG.md) - Consolidated list of all development tasks.
|
||||||
|
- [Agents](../.github/AGENTS.md) - Instructions for agents and task management.
|
||||||
|
- [Memory](../.github/MEMORY.md) - Session memory and development progress tracking.
|
||||||
|
- [README](../README.md) - Project overview and high-level information.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Security Guide
|
||||||
|
|
||||||
|
Layered security model for Linear Void.
|
||||||
|
|
||||||
|
## Threat Model
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ Client │───▶│Cloudflare│───▶│ nginx │───▶│ Backend │
|
||||||
|
│ (public) │ │ WAF+CDN │ │ proxy │ │ chat │
|
||||||
|
└──────────┘ └──────────┘ └────┬─────┘ └────┬─────┘
|
||||||
|
│ │
|
||||||
|
┌──────┴──────┐ ┌─────┴──────┐
|
||||||
|
│ /health │ │ /api/admin │
|
||||||
|
│ /metrics │ │ /ws (chat) │
|
||||||
|
│ /hls/* │ │ /api/chat/* │
|
||||||
|
│ (public) │ │ (auth req) │
|
||||||
|
└─────────────┘ └────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication & 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
|
||||||
|
|
||||||
|
## Transport Security (TLS/HSTS)
|
||||||
|
|
||||||
|
- Cloudflare TLS mode: **Full (strict)** — valid origin cert required
|
||||||
|
- nginx listens on 80 internally; Cloudflare handles TLS termination
|
||||||
|
- HSTS header set by Cloudflare: `max-age=31536000; includeSubDomains; preload`
|
||||||
|
- WSS upgrade path: `/ws` through nginx → chat:3000
|
||||||
|
|
||||||
|
## Network Isolation
|
||||||
|
|
||||||
|
- All backend services on internal Docker network (`linearvoid-net`)
|
||||||
|
- Only nginx port exposed to host (443/80)
|
||||||
|
- Database, Redis, exporters inaccessible from outside
|
||||||
|
- `docker-compose.prod.yml` keeps ports internal via `expose:` not `ports:`
|
||||||
|
|
||||||
|
## Reverse Proxy Controls
|
||||||
|
|
||||||
|
- `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
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
| Resource | Limit | Window |
|
||||||
|
| ----------------- | ----- | ------ |
|
||||||
|
| WebSocket connect | 5/IP | 60s |
|
||||||
|
| Chat messages | 10/IP | 10s |
|
||||||
|
| Admin login | 5/IP | 5min |
|
||||||
|
|
||||||
|
Implemented via Redis `INCR` + `EXPIRE`.
|
||||||
|
|
||||||
|
## Secrets Management
|
||||||
|
|
||||||
|
- `.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
|
||||||
|
|
||||||
|
## Data Protection
|
||||||
|
|
||||||
|
- PostgreSQL: chat history, moderation logs, media metadata
|
||||||
|
- Redis: append-only persistence for presence, rate limits, sessions
|
||||||
|
- HLS segments: ephemeral, regenerated by playout
|
||||||
|
- Backups: `scripts/backup-db.sh` (daily), `scripts/backup-weekly.sh`, `scripts/verify-backups.sh`
|
||||||
|
|
||||||
|
## Hardening Checklist
|
||||||
|
|
||||||
|
- [ ] HTTPS/WSS enforced end-to-end (Cloudflare Full strict)
|
||||||
|
- [ ] Strong admin credentials configured
|
||||||
|
- [ ] Secrets only in env/secrets stores (never committed)
|
||||||
|
- [ ] Proxy auth and websocket headers validated
|
||||||
|
- [ ] Upload limits and MIME validation active
|
||||||
|
- [ ] Rate limiting active (WS, messages, login)
|
||||||
|
- [ ] Cloudflare WAF/rate-limit rules active
|
||||||
|
- [ ] Backup/restore drills completed
|
||||||
|
- [ ] Security alerts routed (Prometheus alertmanager)
|
||||||
|
|
||||||
|
## Incident Response
|
||||||
|
|
||||||
|
1. Detect — alerts (ChatDown, RedisDown, HighChatLatency) or user reports
|
||||||
|
2. Triage — edge (Cloudflare), proxy (nginx), app (chat/playout), data (Redis/Postgres)
|
||||||
|
3. Contain — rate limit, credential rotate, service restart, slate mode activation
|
||||||
|
4. Recover — verify health endpoints, check Prometheus targets, validate HLS output
|
||||||
|
5. Record — timeline + corrective actions in `docs/incident-runbook.md`
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
# Linear Void Setup Guide
|
||||||
|
|
||||||
|
**Last Updated**: 2026-08-03
|
||||||
|
|
||||||
|
Prerequisites, environment setup, and running the app locally and in production. Architecture: [`docs/architecture/README.md`](./architecture/README.md).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Docker + Docker Compose** — runs the full stack (playout, chat, nginx, redis, db, prometheus, grafana).
|
||||||
|
- **Node.js 22+ / npm** — frontend dev and builds (`cd frontend && npm install && npm run build`).
|
||||||
|
- **Go 1.25+** — working on the Go services outside Docker.
|
||||||
|
- **FFmpeg** — playout HLS generation (`apt install ffmpeg`).
|
||||||
|
- **Git**.
|
||||||
|
- **Cloudflare account** — R2 storage + CDN.
|
||||||
|
- **Gitea account** — image registry + CI/CD. Set Actions secrets at `https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets`: `CI_REGISTRY_USER`, `CI_REGISTRY_TOKEN` (scope `write:packages`), `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `DATABASE_URL`.
|
||||||
|
- **Hetzner Cloud (optional, prod)** — US-East VPS (Ashburn, VA per ADR-001).
|
||||||
|
|
||||||
|
## Network Environments
|
||||||
|
|
||||||
|
Two distinct targets; know which one you're using.
|
||||||
|
|
||||||
|
```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 (when provisioned)"| PROD_HOST
|
||||||
|
TEST_HOST -->|"test traffic"| DNS
|
||||||
|
PROD_HOST -.->|"production traffic"| DNS
|
||||||
|
TEST_HOST --> R2
|
||||||
|
PROD_HOST -.-> R2
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Test Lab (active):** internal `192.168.88.0/24`, host `192.168.88.40`; receives automated CD deploys from Gitea Actions. Used for staging validation.
|
||||||
|
- **Production (pending):** Hetzner CX22, Ashburn VA — not yet provisioned. Compose/CD are ready; only `DOCKER_HOST` needs changing. See [DEPLOYMENT.md](./DEPLOYMENT.md).
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
1. `git clone https://git.allucanget.biz/zwitschi/linearvoid.git && cd linearvoid`
|
||||||
|
2. `cp .env.example .env` then replace placeholders.
|
||||||
|
3. Compose overrides: `docker-compose.yml` builds images locally and mounts `frontend/dist` into nginx; `docker-compose.prod.yml` pulls prebuilt images from Gitea.
|
||||||
|
- Internal URLs use compose hostnames: `REDIS_URL=redis://redis:6379/0`, `DATABASE_URL=postgres://linearvoid:<pw>@db:5432/linearvoid?sslmode=disable`.
|
||||||
|
- Public routing: `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
|
||||||
|
4. Create data dirs: `mkdir -p data/playlist data/media data/hls data/chat`
|
||||||
|
5. Optional: place normalized MP4s in `data/media/` and create `data/playlist/playlist.txt` (see ADR-001 for normalization).
|
||||||
|
|
||||||
|
## Gitea Setup
|
||||||
|
|
||||||
|
1. User with repo + registry access; generate a token (scope `write:packages`) stored as `CI_REGISTRY_TOKEN`.
|
||||||
|
2. Set Actions secrets (see Prerequisites) to enable automated CI/CD and deploys.
|
||||||
|
3. Set registry vars: `CI_REGISTRY_URL`, `CI_REGISTRY_NAMESPACE` (e.g. `zwitschi/linearvoid`), `CI_REGISTRY_USER`.
|
||||||
|
4. Auth: `docker login git.allucanget.biz`.
|
||||||
|
5. Local dev builds from source; prod pulls `playout`, `chat`, `nginx` images from the registry namespace. Verify `:latest` tags exist before rollout.
|
||||||
|
|
||||||
|
## Cloudflare R2 Setup
|
||||||
|
|
||||||
|
1. Create R2 bucket (e.g. `linearvoid-media`, matching `R2_BUCKET_NAME`); generate an access key pair → `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_REGION=auto`.
|
||||||
|
2. S3 endpoint: `https://<account-id>.r2.cloudflarestorage.com` → `R2_ENDPOINT`.
|
||||||
|
3. Attach `media.linearvoid.tv` as custom media domain → `MEDIA_DOMAIN`; add CORS allowing `https://linearvoid.tv` GET for HLS delivery.
|
||||||
|
4. Point `linearvoid.tv` (+ `www` redirect) at the app entrypoint; keep `media.*` on R2. Proxied DNS, TLS **Full (strict)**.
|
||||||
|
5. Apply the `.m4s`/`.m3u8` cache rules from [`cache-rules.md`](./infrastructure/cloudflare/cache-rules.md); enable Smart Tiered Cache; deploy the [`linearvoid-media` worker](../infrastructure/cloudflare/) on `media.linearvoid.tv` (replace the R2 bucket's direct custom-domain binding on that host) to enforce immutable segment caching + HLS CORS.
|
||||||
|
6. Verify: `aws s3 ls --endpoint-url $R2_ENDPOINT`; check cache hit ratio after serving traffic.
|
||||||
|
|
||||||
|
## Running the Application
|
||||||
|
|
||||||
|
### Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build # start all services
|
||||||
|
docker compose down # stop
|
||||||
|
```
|
||||||
|
|
||||||
|
Access: app/admin `https://linearvoid.tv` (via proxy) or `http://localhost`; WS `wss://linearvoid.tv/ws` / `ws://localhost/ws`. Health: playout `:8081/health`, chat `:3000/health`. Monitoring: Prometheus `:9090`, Grafana `:3001` (default `admin/admin`).
|
||||||
|
|
||||||
|
Frontend dev (optional): `cd frontend && npm install && npm run dev` (http://localhost:5173, proxies API). For nginx-backed local runs: `npm run build` (nginx serves `frontend/dist`).
|
||||||
|
|
||||||
|
### Test lab (remote Docker host)
|
||||||
|
|
||||||
|
Deploy to `192.168.88.40` over Docker's TCP socket (port `2375`).
|
||||||
|
|
||||||
|
Enable TCP on Alpine host — edit `/etc/conf.d/docker`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `rc-service docker restart`; verify with `netstat -tlnp | grep 2375`; allow port `2375` from `192.168.88.0/24` in the firewall.
|
||||||
|
|
||||||
|
Deploy from a machine with the Docker CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DOCKER_HOST="tcp://192.168.88.40:2375"
|
||||||
|
docker info # confirm remote host
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --remove-orphans # registry images
|
||||||
|
# or build from source: docker compose up -d --build
|
||||||
|
unset DOCKER_HOST # back to local
|
||||||
|
```
|
||||||
|
|
||||||
|
Health: `http://192.168.88.40:8081/health` (playout), `:3000/health` (chat), nginx entrypoint `:8080`.
|
||||||
|
|
||||||
|
### Coolify deployment
|
||||||
|
|
||||||
|
Coolify replaces the Gitea CD `DOCKER_HOST` orchestration (Cloudflare DNS/R2/cache unchanged). Install (`curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash`), add the target server via SSH, create a Docker Compose resource from `docker-compose.prod.yml` (or the repo), configure env vars in Coolify's store, map persistent `data/` and named volumes, and assign `linearvoid.tv` to nginx for Traefik TLS. Full walkthrough: [`COOLIFY.md`](./COOLIFY.md).
|
||||||
|
|
||||||
|
### Production setup
|
||||||
|
|
||||||
|
1. Provision a Linux VPS (Docker, Compose, Git); open HTTP/HTTPS ports.
|
||||||
|
2. `git clone … && cd linearvoid && cp .env.example .env`.
|
||||||
|
3. Set production `.env`: strong `POSTGRES_PASSWORD`/`GRAFANA_PASSWORD`, compose-hostname internal URLs, `MEDIA_DOMAIN=media.linearvoid.tv`, public `VITE_PUBLIC_*` URLs. For local-only testing bypassing the proxy, use `http://localhost`/`ws://localhost/ws`/`http://localhost/hls/playlist.m3u8`.
|
||||||
|
4. `mkdir -p data/playlist data/media data/hls data/chat`.
|
||||||
|
5. Ensure `playout`, `chat`, `nginx` images exist in the registry; `docker login` from the VPS.
|
||||||
|
6. Deploy: `docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d`.
|
||||||
|
7. Verify: `docker compose -f docker-compose.prod.yml ps`, health endpoints, HLS written to `data/hls`, media domain serves via Cloudflare.
|
||||||
|
8. Harden: HTTPS in front of nginx (Cloudflare, `www` → apex), restrict Grafana/Prometheus to trusted IPs, run under systemd for auto-start.
|
||||||
|
|
||||||
|
### Production (Hetzner VPS + Cloudflare)
|
||||||
|
|
||||||
|
1. Provision CX22 in Ashburn, VA (ADR-001).
|
||||||
|
2. Cloudflare: R2 bucket (`enam` hint), deploy the [`linearvoid-media` worker](../infrastructure/cloudflare/) on `media.linearvoid.tv` + cache rules, `linearvoid.tv`/`www` at the app entrypoint with `www`→apex redirect, Full (strict) TLS.
|
||||||
|
3. Deploy from the project: `docker compose -f docker-compose.prod.yml up -d` (or pull from Gitea registry); manage under systemd.
|
||||||
|
4. Monitoring: Grafana dashboards from `monitoring/grafana/`.
|
||||||
|
|
||||||
|
See `docs/architecture/README.md` (arc42) and the ADRs for decisions and cost/scaling guidance (ADR-001).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **R2 upload fails** — check `.env` creds/bucket; `aws s3 ls --endpoint-url=$R2_ENDPOINT`.
|
||||||
|
- **Normalization errors** — run `backend/playout/start-playout.sh` to see ffprobe output. Video must be H.264/AAC, 30 fps constant, 2s keyframes (ADR-001).
|
||||||
|
- **Chat won't connect** — check Redis and WS URL; `/health` should return 200.
|
||||||
|
- **Cache not working** — verify `.m4s` long-TTL / `.m3u8` short-TTL cache rules and hit ratio.
|
||||||
|
- **Docker issues** — `docker compose logs`; check port conflicts.
|
||||||
|
- **Admin interface** — `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `ADMIN_SESSION_TTL_HOURS` set; session-based login protects `/admin/` and `/api/admin/*`.
|
||||||
|
- **Prod pull fails** — `docker login` to Gitea succeeded and image tags exist.
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
Architecture: `docs/architecture/README.md` · Decisions: `docs/architecture/adr/` (ADR-001…007) · Contributing: `CONTRIBUTING.md` · Overview: root `README.md`.
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Streaming Pipeline
|
||||||
|
|
||||||
|
Operational contract for Linear Void video ingest, playout, publication, caching, and browser playback. Deployment, chat, admin details live in `DEPLOYMENT.md`, `SETUP.md`, `API.md`.
|
||||||
|
|
||||||
|
## Production flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["Normalized MP4 assets"] --> B["Playlist and schedule"]
|
||||||
|
B --> C["Playout service and FFmpeg"]
|
||||||
|
C --> D["Local HLS staging"]
|
||||||
|
D --> E["R2 object storage"]
|
||||||
|
E --> F["Cloudflare edge"]
|
||||||
|
F --> G["hls.js or native HLS"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The playout service is the timeline authority. It reads the ordered playlist, runs FFmpeg continuously, stages a live HLS window under `data/hls/`, and publishes complete objects to R2. Viewers read only the public R2 custom domain through Cloudflare:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://media.linearvoid.tv/hls/playlist.m3u8
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx `/hls/` is a local/test fallback, not the production origin. The SPA, admin API, and chat use `linearvoid.tv`; media uses `media.linearvoid.tv`.
|
||||||
|
|
||||||
|
## Source-media contract
|
||||||
|
|
||||||
|
Only validated files may enter the production playlist.
|
||||||
|
|
||||||
|
| Property | Required profile |
|
||||||
|
| ---------- | ----------------------------------------------------------------------- |
|
||||||
|
| Container | MP4 |
|
||||||
|
| Video | H.264/AVC, `yuv420p` |
|
||||||
|
| Resolution | 1280x720 or 1920x1080; avoid resolution changes within a playlist block |
|
||||||
|
| Frame rate | Constant 30 fps |
|
||||||
|
| GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` |
|
||||||
|
| Audio | AAC-LC, stereo, 48 kHz, 128–192 kbit/s |
|
||||||
|
|
||||||
|
The ingest gate uses `ffprobe` to reject incompatible codecs, variable frame rate, missing audio, invalid dimensions, or wrong keyframe cadence. Assets are immutable after acceptance; corrections create a new asset ID/object key.
|
||||||
|
|
||||||
|
Reference normalization:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-c:v libx264 -preset medium -crf 21 \
|
||||||
|
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
||||||
|
-pix_fmt yuv420p \
|
||||||
|
-c:a aac -b:a 160k -ar 48000 -ac 2 \
|
||||||
|
-movflags +faststart \
|
||||||
|
output-normalized.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Playlist and timeline
|
||||||
|
|
||||||
|
- `data/playlist/playlist.txt`: one local media path per line; blank lines and `#` comments ignored.
|
||||||
|
- Service validates the complete playlist before activating a revision.
|
||||||
|
- Playlist edits are atomic and get a revision ID. The active FFmpeg run keeps its current item and adopts the new revision at the next item boundary.
|
||||||
|
- All viewers follow the same live manifest; clients never calculate the schedule independently.
|
||||||
|
- Schedule metadata derives from the same active revision/timeline so "now/next" agrees with the video.
|
||||||
|
- When the last item ends, playout returns to the first unless an active schedule override says otherwise.
|
||||||
|
|
||||||
|
## Playout and HLS packaging
|
||||||
|
|
||||||
|
The playout service wraps FFmpeg, tracks the active item/revision, restarts failed processes, and exposes `/health` plus Prometheus metrics.
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
| ---------------- | ------------------------------------------------------------------------ |
|
||||||
|
| Protocol | Standard live HLS, not LL-HLS |
|
||||||
|
| Segment format | fMP4 (`.m4s`) with init segment; `.ts` may be supported during migration |
|
||||||
|
| Segment target | 2 seconds (`HLS_SEGMENT_DURATION=2`) |
|
||||||
|
| Live window | 12 segments (`HLS_WINDOW_SIZE=12`), ~24 seconds |
|
||||||
|
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
|
||||||
|
| Expected latency | 15–35 seconds |
|
||||||
|
|
||||||
|
Segment boundaries must align with keyframes; timestamps must stay monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new init segment.
|
||||||
|
|
||||||
|
**Segment duration rationale**: 2s lowers latency and keeps the window small (12 × 2s = 24s) while staying above the manifest TTL (1–2s). The 2s GOP (30fps, `-g 60`) already matches the target, so segments cut cleanly on keyframes. Longer (4–6s) raises latency and window depth; shorter than 2s inflates segment/request overhead and R2 Class B ops with little gain.
|
||||||
|
|
||||||
|
Every segment name is globally unique and never reused, including after restart or playlist loop — use a run/session ID plus a monotonically increasing media sequence:
|
||||||
|
|
||||||
|
```text
|
||||||
|
hls/r-20260807T170412Z/init-000001.mp4
|
||||||
|
hls/r-20260807T170412Z/seg-000001234.m4s
|
||||||
|
```
|
||||||
|
|
||||||
|
A URL advertised as immutable must never later contain different bytes.
|
||||||
|
|
||||||
|
## Safe publication to R2
|
||||||
|
|
||||||
|
Order prevents a manifest referencing an unavailable segment:
|
||||||
|
|
||||||
|
1. FFmpeg writes a segment to a temp local name and closes it.
|
||||||
|
2. Publisher uploads the init segment (if new) and media segment to final immutable R2 keys.
|
||||||
|
3. Verify upload success; record object size/checksum.
|
||||||
|
4. Render the next manifest referencing only confirmed objects.
|
||||||
|
5. Upload the manifest to a temp key, then replace `hls/playlist.m3u8` as the final commit step.
|
||||||
|
|
||||||
|
Never upload an actively written segment or publish the manifest first. Use a single publisher (or fencing/leader election) so two workers can't update the live manifest concurrently.
|
||||||
|
|
||||||
|
**Run-scoped manifest URIs.** Segments and the init file are stored run-scoped under `hls/r-{runID}/…`, so the live `hls/playlist.m3u8` must reference them with that run prefix. Before the final commit step, the publisher rewrites each bare relative media URI (segments plus `#EXT-X-MAP:URI`) to `r-{runID}/<name>`. `publishManifest` in `backend/playout/src/playlist.go` does this via `rewriteManifestToRun`, using the same `runID` under which the segments were uploaded; the rewrite is skipped in slate mode (its manifest is served as-is). The published manifest therefore contains, e.g.:
|
||||||
|
|
||||||
|
```m3u8
|
||||||
|
#EXT-X-MAP:URI="r-20260807T170412Z/init.mp4"
|
||||||
|
#EXTINF:2.000000,
|
||||||
|
r-20260807T170412Z/seg-000001234.m4s
|
||||||
|
```
|
||||||
|
|
||||||
|
The `linearvoid-media` Worker on `media.linearvoid.tv` is a faithful path→R2 mapper and serves these run-scoped paths directly; it does **not** rewrite URIs at serve time.
|
||||||
|
|
||||||
|
`data/hls/` is regenerable staging. Source media, playlist revisions, and schedule state are authoritative; generated HLS is not backed up as application state.
|
||||||
|
|
||||||
|
## Cache contract
|
||||||
|
|
||||||
|
| Objects | Edge policy | Browser policy | Required headers/behavior |
|
||||||
|
| -------------------------- | --------------------------- | --------------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| `.m4s`, `.ts`, init `.mp4` | Cache eligible; TTL 1 year | TTL 1 year | `Cache-Control: public, max-age=31536000, immutable`; full path in cache key |
|
||||||
|
| Live `.m3u8` | Cache eligible; edge TTL 2s | Do not retain beyond freshness interval | `Cache-Control: public, max-age=1, s-maxage=2, must-revalidate` |
|
||||||
|
| Versioned JS/CSS | Cache eligible; TTL 1 year | TTL 1 year | Content-hashed filenames and `immutable` |
|
||||||
|
|
||||||
|
Further rules:
|
||||||
|
|
||||||
|
- No query-string cache busters on segment URLs; uniqueness belongs in the path.
|
||||||
|
- Cache keys preserve hostname and full path; ignore only explicitly approved tracking params.
|
||||||
|
- HLS responses need `Access-Control-Allow-Origin: https://linearvoid.tv` (or reviewed allowlist), `GET`/`HEAD`/`OPTIONS`, and correct MIME types.
|
||||||
|
- Enable Smart Tiered Cache for `media.linearvoid.tv` to cut R2 reads on regional misses.
|
||||||
|
- Purge manifests when necessary, never immutable segments during normal operation.
|
||||||
|
- Retain expired-window segments at least max manifest TTL + retry/grace time; practical minimum 1 hour; lifecycle deletion may run later per storage policy.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
|
||||||
|
curl -I https://media.linearvoid.tv/hls/<segment>.m4s
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest must refresh within seconds; repeated segment requests should progress `CF-Cache-Status: MISS` → `HIT`, keep the same ETag/content length, and carry the immutable cache header.
|
||||||
|
|
||||||
|
## Player behavior
|
||||||
|
|
||||||
|
SPA uses native HLS where supported, hls.js elsewhere.
|
||||||
|
|
||||||
|
- Firefox has no native HLS → hls.js/MSE path; fMP4 (`.m4s` + init) is supported. Autoplay needs `muted` + `playsinline`, already set on the `<video>` element.
|
||||||
|
- Start at the live edge with a conservative buffer; do not seek to playlist start.
|
||||||
|
- Treat the manifest as the sync source; target ~3 segments behind the newest unless testing supports a smaller margin.
|
||||||
|
- Recover transient network/media errors with bounded retries + exponential backoff.
|
||||||
|
- On fatal hls.js network error, reload the manifest; on fatal media error, attempt media recovery once before recreating the player.
|
||||||
|
- If tab suspension/drift leaves playback materially behind the target, seek forward rather than play the backlog.
|
||||||
|
- Surface a clear reconnecting state; never silently loop stale media.
|
||||||
|
- Schedule/chat timestamps should account for measured player latency, not wall-clock playout.
|
||||||
|
|
||||||
|
## Failure and recovery
|
||||||
|
|
||||||
|
The service retries an item failure with bounded backoff. Past the failure threshold it switches to a pre-normalized looping slate and reports `mode=slate`, periodically retrying normal playout, returning only after a segment and committed manifest publish successfully.
|
||||||
|
|
||||||
|
On process/host restart:
|
||||||
|
|
||||||
|
- create a new run/session ID and continue with unique segment paths;
|
||||||
|
- resume from schedule-derived current position when reliable state exists, else start the current playlist item;
|
||||||
|
- never overwrite old segment objects;
|
||||||
|
- keep the last valid manifest available until the replacement is playable;
|
||||||
|
- emit a discontinuity when timeline continuity isn't guaranteed.
|
||||||
|
|
||||||
|
`/health` must report more than "process running": at least mode (`live`/`slate`/`starting`/`degraded`), active item, playlist revision, run ID, last segment sequence/time, last successful R2 publication, consecutive failures, uptime. Mark unhealthy if the manifest or media sequence hasn't advanced for more than two segment durations.
|
||||||
|
|
||||||
|
## Monitoring and acceptance
|
||||||
|
|
||||||
|
Monitor/alert on: manifest age and media-sequence advancement; time since last published segment; FFmpeg restarts and consecutive failures; live/slate mode; R2 upload latency/errors; segment 404/5xx and manifest/segment latency; Cloudflare cache-hit ratio, origin requests, R2 Class B ops; player startup, fatal errors, rebuffer ratio, live-edge drift; local staging disk usage.
|
||||||
|
|
||||||
|
A release is streaming-ready only when: one full playlist boundary plays without timestamp errors; the manifest never references a missing object; a second viewer joins the same timeline; cache headers match the table; a forced FFmpeg failure activates slate; and normal playout recovers without reusing an object URL.
|
||||||
|
|
||||||
|
## Required configuration
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| ------------------------------------------ | ------------------------------------- |
|
||||||
|
| `PLAYLIST_PATH` | Active playlist file |
|
||||||
|
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `2` |
|
||||||
|
| `HLS_WINDOW_SIZE` | Manifest window; baseline `12` |
|
||||||
|
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
|
||||||
|
| `R2_BUCKET_NAME` | Media bucket |
|
||||||
|
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, publisher only |
|
||||||
|
| `R2_KEEP_RUNS` | Run folders to retain (default `2`) |
|
||||||
|
| `R2_MAX_RETENTION_BYTES` | Max bytes across run folders (10 GiB) |
|
||||||
|
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
||||||
|
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
|
||||||
|
|
||||||
|
Production values: `MEDIA_DOMAIN=media.linearvoid.tv`, `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`. Local fallback may use `http://localhost/hls/playlist.m3u8`.
|
||||||
|
|
||||||
|
## Explicitly deferred
|
||||||
|
|
||||||
|
ABR ladders, LL-HLS, DVR/catch-up, multi-origin playout, and regional chat affinity are outside the v1 contract. Adding any requires an ADR and an update to this document.
|
||||||
+320
-357
@@ -1,9 +1,10 @@
|
|||||||
# Architecture Documentation for Linear Void
|
# Architecture Documentation for Linear Void
|
||||||
|
|
||||||
> The architecture documentation for the Linear Void project is structured according to the [arc42 template](https://arc42.org/). This template provides a comprehensive framework for documenting software architecture, ensuring that all relevant aspects of the system are covered.
|
Linear Void architecture is documented using the [arc42 template](https://arc42.org/).
|
||||||
|
|
||||||
## Table of Contents
|
> Companion docs: see [Documentation README](../README.md) and `adr/` for full decision rationale, alternatives, and consequences.
|
||||||
|
|
||||||
|
- [Architecture Documentation for Linear Void](#architecture-documentation-for-linear-void)
|
||||||
- [Introduction and Goals](#introduction-and-goals)
|
- [Introduction and Goals](#introduction-and-goals)
|
||||||
- [Architecture Constraints](#architecture-constraints)
|
- [Architecture Constraints](#architecture-constraints)
|
||||||
- [Context and Scope](#context-and-scope)
|
- [Context and Scope](#context-and-scope)
|
||||||
@@ -19,65 +20,49 @@
|
|||||||
|
|
||||||
## Introduction and Goals
|
## Introduction and Goals
|
||||||
|
|
||||||
### Requirements Overview
|
**Requirements Overview** — Linear Void is an independent **24/7 linear video channel** broadcasting self-owned pre-recorded videos to a primarily US audience via a custom lightweight HTML/CSS/JS SPA with synchronized HLS playback, schedule metadata, public chat rooms, and private 1:1 messaging. Fully independent of third-party video platforms (YouTube, Twitch, etc.).
|
||||||
|
|
||||||
Linear Void is an independent **24/7 linear video channel** broadcasting a playlist of self-owned pre-recorded videos. Viewers access it via a custom lightweight HTML/CSS/JavaScript SPA with synchronized HLS playback, schedule metadata, public chat rooms, and private 1:1 messaging. Complete independence from third-party video platforms (YouTube, Twitch, etc.).
|
|
||||||
|
|
||||||
**Core Requirements** (merged from all ADRs):
|
**Core Requirements** (merged from all ADRs):
|
||||||
|
|
||||||
- Continuous synchronized playback from playlist (live-style HLS, not VOD).
|
- Continuous synchronized playlist playback (live-style HLS, not VOD).
|
||||||
- Self-hosted source media, frontend, schedule, and chat data.
|
- Self-hosted source media, frontend, schedule, and chat data.
|
||||||
- Primary US audience/origin (East Coast preferred for low latency/peering).
|
- Primary US audience/origin (East Coast preferred for low latency/peering).
|
||||||
- Extreme cost efficiency (bandwidth dominant; target near-zero egress via Cloudflare R2 + edge caching).
|
- Extreme cost efficiency (near-zero egress via Cloudflare R2 + edge caching).
|
||||||
- Custom chat with rooms, DMs, presence, moderation, persistence.
|
- Custom chat with rooms, DMs, presence, moderation, persistence.
|
||||||
- Storage planning for 48h of 720p30/1080p30 content.
|
- Storage for 48h of 720p30/1080p30 content.
|
||||||
- Browser-compatible playback (`hls.js` + native HLS).
|
- Browser-compatible playback (`hls.js` + native HLS).
|
||||||
- Operational simplicity and scalability.
|
- Operational simplicity and scalability.
|
||||||
|
|
||||||
Full decision rationale, alternatives, and consequences in [`adr/ADR-001.md`](adr/ADR-001.md).
|
Full rationale in [`adr/ADR-001.md`](adr/ADR-001.md).
|
||||||
|
|
||||||
### Quality Goals
|
**Quality Goals**
|
||||||
|
|
||||||
| Priority | Quality Attribute | Goal | Scenario |
|
| # | Attribute | Goal | Scenario |
|
||||||
| -------- | ------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
| --- | ------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------ |
|
||||||
| 1 | **Cost Efficiency** | <$25/month for moderate scale (100s concurrent) | R2 $0 egress + immutable `.ts` edge caching eliminates per-viewer bandwidth cost |
|
| 1 | Cost Efficiency | <$25/month at moderate scale (100s concurrent) | R2 $0 egress + immutable `.m4s`/`.ts` edge caching removes per-viewer bandwidth cost |
|
||||||
| 2 | **Performance / Latency** | Low US latency; acceptable HLS delay | Hetzner Ashburn origin + Cloudflare US POPs (15–35s end-to-end) |
|
| 2 | Performance/Latency | Low US latency; acceptable HLS delay | Hetzner Ashburn origin + Cloudflare US POPs (15–35s end-to-end) |
|
||||||
| 3 | **Availability** | 24/7 with minimal downtime | FFmpeg/MediaMTX auto-restart loop, fallback slate, monitoring |
|
| 3 | Availability | 24/7 with minimal downtime | FFmpeg/MediaMTX auto-restart, fallback slate, monitoring |
|
||||||
| 4 | **Scalability** | Grow from 10s to 1000s viewers without proportional cost | Edge caching + R2; only request volume limits |
|
| 4 | Scalability | Grow 10s→1000s viewers without proportional cost | Edge caching + R2; only request volume limits |
|
||||||
| 5 | **Maintainability** | Lightweight, clear configs, low ops overhead | Static SPA, Go/Node chat, SQLite/PostgreSQL+Redis |
|
| 5 | Maintainability | Lightweight, clear configs, low ops | Static SPA, Go/Node chat, SQLite/PostgreSQL+Redis |
|
||||||
| 6 | **Security / Usability** | Secure transport, moderation tools, good UX | HTTPS/WSS, Cloudflare WAF, rate limits, intuitive chat/player |
|
| 6 | Security/Usability | Secure transport, moderation, good UX | HTTPS/WSS, Cloudflare WAF, rate limits, intuitive chat/player |
|
||||||
|
|
||||||
### Stakeholders
|
**Stakeholders**: Product Owner, Engineering Team, US Viewers, Moderators/Admins, Operations — all expecting a cost-effective, reliable, low-latency, low-TCO independent channel with rich chat and easy management.
|
||||||
|
|
||||||
| Role/Name | Contact | Expectations |
|
|
||||||
| ------------------- | ------- | -------------------------------------------------------------------------------- |
|
|
||||||
| Product Owner | - | Cost-effective independent 24/7 channel with synchronized viewing and rich chat |
|
|
||||||
| Engineering Team | - | Simple, evolvable stack (FFmpeg, HLS, Cloudflare R2, lightweight WebSocket chat) |
|
|
||||||
| US Viewers | - | Reliable low-latency playback, responsive synchronized chat experience |
|
|
||||||
| Moderators / Admins | - | Easy playlist management, moderation dashboard, monitoring/alerting |
|
|
||||||
| Operations | - | Low TCO, clear cost model, automated failover, backups |
|
|
||||||
|
|
||||||
(See `adr/ADR-001.md` for detailed forces, decision drivers, and rationale.)
|
|
||||||
|
|
||||||
## Architecture Constraints
|
## Architecture Constraints
|
||||||
|
|
||||||
- Independence from video hosting platforms (no YouTube, Twitch, paid Cloudflare Stream).
|
- No YouTube/Twitch/paid Cloudflare Stream dependency.
|
||||||
- US-centric (origin + audience).
|
- US-centric (origin + audience).
|
||||||
- Cost must remain low even at scale (bandwidth dominant).
|
- Cost stays low even at scale (bandwidth dominant).
|
||||||
- Standard browser playback only (no plugins).
|
- Standard browser playback only (no plugins).
|
||||||
- Cloudflare ToS compliance for video delivery (use R2 as origin for large immutable files).
|
- Cloudflare ToS compliance for video (R2 as origin for large immutable files).
|
||||||
- HLS as primary protocol (standard, not LL-HLS initially).
|
- HLS primary (standard, not LL-HLS initially).
|
||||||
- Self-hosted playlist normalization and continuous playout required.
|
- Self-hosted playlist normalization and continuous playout.
|
||||||
|
|
||||||
(See `adr/ADR-001.md` for full forces and trade-offs.)
|
|
||||||
|
|
||||||
## Context and Scope
|
## Context and Scope
|
||||||
|
|
||||||
### Business Context
|
**Business Context** — Linear Void runs a continuous scheduled, playlist-driven program to a US audience. The custom viewer site replaces traditional platforms, giving full control over content, schedule, UI, and real-time chat (public rooms + DMs).
|
||||||
|
|
||||||
Linear Void provides a continuously running scheduled video program (playlist-driven) to a primarily US audience. The custom viewer site replaces traditional platforms, giving full control over content, schedule, UI, and real-time chat (public rooms + private DMs).
|
**High-Level Context Diagram**
|
||||||
|
|
||||||
### High-Level Context Diagram (Mermaid)
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
@@ -88,7 +73,7 @@ flowchart TD
|
|||||||
|
|
||||||
subgraph "Cloudflare Global Edge"
|
subgraph "Cloudflare Global Edge"
|
||||||
CDN[Cloudflare CDN<br/>Cache Rules + WAF + TLS]
|
CDN[Cloudflare CDN<br/>Cache Rules + WAF + TLS]
|
||||||
Pages[Cloudflare Pages<br/>Static SPA Hosting]
|
Workers[Cloudflare Workers<br/>Static SPA Hosting]
|
||||||
R2[Cloudflare R2<br/>HLS Segments + Source MP4s<br/>enam location hint]
|
R2[Cloudflare R2<br/>HLS Segments + Source MP4s<br/>enam location hint]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -101,70 +86,47 @@ flowchart TD
|
|||||||
Admins -->|HTTPS - Schedule/Playlist| Playout
|
Admins -->|HTTPS - Schedule/Playlist| Playout
|
||||||
Playout -->|S3-compatible Upload| R2
|
Playout -->|S3-compatible Upload| R2
|
||||||
R2 -->|Cached HLS| CDN
|
R2 -->|Cached HLS| CDN
|
||||||
Viewers -->|HTTPS| Pages
|
Viewers -->|HTTPS| Workers
|
||||||
Viewers -->|HLS Requests| CDN
|
Viewers -->|HLS Requests| CDN
|
||||||
Viewers -->|WSS Chat| Chat
|
Viewers -->|WSS Chat| Chat
|
||||||
Chat --> VPS
|
Chat --> VPS
|
||||||
VPS -->|Proxy / Static| Pages
|
VPS -->|Proxy / Static| Workers
|
||||||
CDN -->|Origin Pull on Miss| R2
|
CDN -->|Origin Pull on Miss| R2
|
||||||
|
|
||||||
classDef cloud fill:#f0f0ff,stroke:#4263eb
|
|
||||||
classDef vps fill:#f0fff0,stroke:#2e7d32
|
|
||||||
classDef user fill:#fff0f0,stroke:#c62828
|
|
||||||
class Cloudflare,CDN,Pages,R2 cloud
|
|
||||||
class VPS,Playout,Chat vps
|
|
||||||
class Viewers,Admins user
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**External Interfaces**:
|
**External Interfaces**: Cloudflare (CDN cache rules for `.m4s`/`.ts` vs `.m3u8`, R2 $0 egress, Workers static hosting, WAF/TLS); Hetzner VPS (US-East compute, good peering); Browsers (native HLS + WebSocket); Source (pre-normalized MP4s in R2).
|
||||||
|
|
||||||
- **Cloudflare**: CDN (cache rules for `.ts` vs `.m3u8`), R2 (object storage, $0 egress), Pages (static hosting), WAF/TLS.
|
**Technical Context**
|
||||||
- **Hetzner VPS**: Compute for playout and chat (low-cost US-East with good peering).
|
|
||||||
- **Browsers**: Native HLS (`<video>` + hls.js) and WebSocket APIs.
|
|
||||||
- **Source**: Pre-normalized MP4 files uploaded to R2.
|
|
||||||
|
|
||||||
This diagram synthesizes the context views from all original ADRs. Full decision details, cache rules, storage calculations, and rationale are in [`adr/ADR-001.md`](adr/ADR-001.md).
|
|
||||||
|
|
||||||
### Technical Context
|
|
||||||
|
|
||||||
- **Playout**: FFmpeg consuming normalized playlist → continuous HLS.
|
- **Playout**: FFmpeg consuming normalized playlist → continuous HLS.
|
||||||
- **Packaging/Delivery**: MediaMTX/SRS/Nginx → immutable `.ts` segments to R2; Cloudflare edge cache.
|
- **Packaging/Delivery**: MediaMTX/SRS/Nginx → immutable `.m4s`/`.ts` to R2; edge cache.
|
||||||
- **Frontend**: Static SPA with hls.js.
|
- **Frontend**: Static SPA with hls.js.
|
||||||
- **Chat**: Lightweight WebSocket service (Node/Go) + Redis (presence/rate limits) + PostgreSQL/SQLite (history/moderation).
|
- **Chat**: Lightweight WebSocket service + Redis (presence/rate limits) + PostgreSQL/SQLite (history/moderation).
|
||||||
- **Normalization**: All source videos must be H.264/AAC, 30 fps constant, 2s keyframes.
|
- **Normalization**: All sources H.264/AAC, constant 30 fps, 2s keyframes.
|
||||||
|
|
||||||
**Mapping**:
|
**Mapping**: Input = normalized MP4s in R2 + playlist; Output = live `master.m3u8` + segments via Cloudflare, chat via WSS.
|
||||||
|
|
||||||
- Input: Normalized MP4s in R2 + playlist.
|
|
||||||
- Output: Live `master.m3u8` + segments served via Cloudflare; chat via WSS.
|
|
||||||
|
|
||||||
## Solution Strategy
|
## Solution Strategy
|
||||||
|
|
||||||
**Core Strategy** (synthesized & accepted from all ADRs):
|
1. **US-East Playout on Hetzner Ashburn VPS** (CX22+): FFmpeg + MediaMTX/SRS continuous playlist→HLS (`-c copy` where possible).
|
||||||
|
2. **Immutable HLS to Cloudflare R2**: unique segment names; R2 origin with $0 egress.
|
||||||
|
3. **Aggressive Edge Caching**: long TTL for immutable `.m4s`/`.ts`; short (1–10s) for live `.m3u8`; Tiered Cache enabled — viewer delivery effectively free.
|
||||||
|
4. **Lightweight Custom Frontend**: static SPA + hls.js + chat sidebar.
|
||||||
|
5. **Separate co-located Chat Service**: WebSockets with persistence/moderation (low bandwidth).
|
||||||
|
6. **Normalization & Monitoring**: pre-process all videos; dashboards + alerts for playout health, cache hit ratio, R2 ops.
|
||||||
|
|
||||||
1. **US-East Playout on Hetzner Ashburn VPS** (`CX22` or equivalent): FFmpeg + MediaMTX/SRS for continuous playlist-to-HLS conversion (`-c copy` where possible).
|
**Accepted Trade-off**: standard HLS latency (15–35s) acceptable for linear programming/chat; LL-HLS/WebRTC deferred.
|
||||||
2. **Immutable HLS to Cloudflare R2**: Segments use unique names; R2 acts as origin with $0 egress.
|
|
||||||
3. **Aggressive Edge Caching**: Long TTL for `.ts` (immutable), short TTL (1–10s) for live `.m3u8`. Tiered Cache enabled. This makes viewer delivery essentially free.
|
|
||||||
4. **Lightweight Custom Frontend**: Static HTML/JS SPA + hls.js player + embedded chat sidebar.
|
|
||||||
5. **Separate but co-located Chat Service**: WebSockets with persistence and moderation (low bandwidth).
|
|
||||||
6. **Normalization & Monitoring**: Pre-process all videos; use dashboards + alerts for playout health, cache hit ratio, R2 ops.
|
|
||||||
|
|
||||||
This eliminates egress economics, ensures synchronization via live playlist, keeps ops simple, and scales well. See `adr/ADR-001.md` for detailed cache rules, storage tables, cost model, configs (FFmpeg/Nginx examples), and phase-by-phase implementation plan.
|
|
||||||
|
|
||||||
**Key Trade-off Accepted**: Standard HLS latency (15–35s) is acceptable for linear programming/chat. LL-HLS/WebRTC deferred.
|
|
||||||
|
|
||||||
## Building Block View
|
## Building Block View
|
||||||
|
|
||||||
### Whitebox Overall System
|
**Whitebox Overall System**
|
||||||
|
|
||||||
Overview (Mermaid diagram):
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
subgraph Cloudflare
|
subgraph Cloudflare
|
||||||
CDN[CDN + Cache Rules<br/>.ts immutable long TTL<br/>.m3u8 short TTL]
|
CDN[CDN + Cache Rules<br/>.m4s and .ts immutable long TTL<br/>.m3u8 short TTL]
|
||||||
R2[R2 - HLS + Source]
|
R2[R2 - HLS + Source]
|
||||||
Pages[Pages - Static SPA]
|
Workers[Workers - Static SPA]
|
||||||
end
|
end
|
||||||
VPS[VPS Hetzner Ashburn]
|
VPS[VPS Hetzner Ashburn]
|
||||||
Playout[FFmpeg + MediaMTX/SRS<br/>Playlist → HLS]
|
Playout[FFmpeg + MediaMTX/SRS<br/>Playlist → HLS]
|
||||||
@@ -175,51 +137,149 @@ graph TD
|
|||||||
Admin --> Playout
|
Admin --> Playout
|
||||||
Playout --> R2
|
Playout --> R2
|
||||||
R2 --> CDN
|
R2 --> CDN
|
||||||
Viewer --> Pages
|
Viewer --> Workers
|
||||||
Viewer --> CDN
|
Viewer --> CDN
|
||||||
Viewer --> Chat
|
Viewer --> Chat
|
||||||
Chat --> VPS
|
Chat --> VPS
|
||||||
VPS --> Nginx[Nginx Proxy]
|
VPS --> Nginx[Nginx Proxy]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Motivation**: Decouple playout (continuous linear), delivery (cached immutable segments), and interaction (chat). Cloudflare R2 + edge caching solves the bandwidth cost problem while maintaining synchronization and independence.
|
**Motivation**: decouple playout (continuous linear), delivery (cached immutable segments), interaction (chat). R2 + edge caching solves bandwidth cost while keeping synchronization and independence.
|
||||||
|
|
||||||
**Contained Building Blocks** (high-level):
|
**Contained blocks**: Playout (FFmpeg + MediaMTX/SRS); Storage/Delivery (R2 + CDN); Frontend (SPA); Chat Service (WebSocket); Proxy/Static (Nginx).
|
||||||
|
|
||||||
- **Playout**: FFmpeg + MediaMTX/SRS (continuous HLS from playlist).
|
**Important interfaces**: HTTP(S) for HLS, WSS for chat, S3-compatible for R2 uploads.
|
||||||
- **Storage/Delivery**: Cloudflare R2 + CDN (immutable segments).
|
|
||||||
- **Frontend**: Static SPA (hls.js player + chat UI).
|
|
||||||
- **Chat Service**: WebSocket backend with persistence/moderation.
|
|
||||||
- **Proxy/Static**: Nginx on VPS.
|
|
||||||
|
|
||||||
**Important Interfaces**: HTTP(S) for HLS, WSS for chat, S3-compatible for R2 uploads.
|
**Level 2 — components with code locations and persistence**
|
||||||
|
|
||||||
Detailed black-box descriptions, level-2/3 views, and code locations are in `adr/ADR-001.md` and future implementation ADRs.
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Cloudflare Edge"
|
||||||
|
CF[CDN + WAF<br/>Cache Rules]
|
||||||
|
R2["R2 Bucket<br/>media + HLS<br/>hls/r-{runID}/… run-scoped"]
|
||||||
|
MEDIA[linearvoid-media Worker<br/>R2-bound · media.linearvoid.tv]
|
||||||
|
WORKERS[linearvoid-frontend Worker<br/>SPA hosting]
|
||||||
|
end
|
||||||
|
|
||||||
### Level 2 & 3
|
subgraph "Docker Host"
|
||||||
|
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
|
||||||
|
|
||||||
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.
|
subgraph "External"
|
||||||
|
VIEWER[Viewer Browser<br/>hls.js + WebSocket]
|
||||||
|
ADMIN[Admin Browser<br/>SPA + uploads]
|
||||||
|
end
|
||||||
|
|
||||||
|
VIEWER -->|HTTPS SPA| CF
|
||||||
|
VIEWER -->|WSS Chat| NGINX
|
||||||
|
VIEWER -->|HTTPS /hls| MEDIA
|
||||||
|
ADMIN -->|HTTPS| CF
|
||||||
|
CF --> NGINX
|
||||||
|
MEDIA --> R2
|
||||||
|
NGINX --> CHAT
|
||||||
|
NGINX --> PLAYOUT
|
||||||
|
CHAT --> REDIS
|
||||||
|
CHAT --> DB
|
||||||
|
PLAYOUT --> R2
|
||||||
|
PLAYOUT -->|reads| R2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Playout Service** (`backend/playout/src/playlist.go`)
|
||||||
|
|
||||||
|
- Role: continuous HLS encoding loop via FFmpeg concat demuxer.
|
||||||
|
- Input: `data/playlist/playlist.txt` (ordered MP4 paths); Output: HLS segments (`.m4s`, `.ts`) + live `.m3u8` to `data/hls/`, mirrored to R2 run-scoped under `hls/r-{runID}/…`.
|
||||||
|
- Run-scoped manifest: before publishing the live `hls/playlist.m3u8`, `publishManifest` rewrites bare media URIs to the current run folder (`rewriteManifestToRun`), so manifest → segment links resolve on `media.linearvoid.tv`. See [`STREAMING.md`](../STREAMING.md).
|
||||||
|
- Resilience: restart counter (>5/60s → slate); Prometheus on 8081.
|
||||||
|
- Validation: ffprobe gate (codec/fps/resolution/keyframe); `-validate` mode checks playlist without running.
|
||||||
|
- Seamless looping: resets HLS only when playlist fingerprint (SHA-256) changes, crash/slate recovery, or first run; else continues via `-start_number`.
|
||||||
|
- R2 retention: `cleanupOldRuns` keeps newest `R2_KEEP_RUNS` (default 2) capped at `R2_MAX_RETENTION_BYTES` (default 10 GiB), after each run + hourly backstop.
|
||||||
|
- Shared config: reads runtime `config.json` (`loadSharedHLSConfig`) as env fallback for HLS segment duration/window.
|
||||||
|
|
||||||
|
**Chat Service** (`backend/chat/src/main.go`)
|
||||||
|
|
||||||
|
- Role: WebSocket chat (rooms, DMs, moderation, admin API).
|
||||||
|
- Persistence: PostgreSQL 15 via GORM (messages, moderation, media metadata); SQLite dev fallback.
|
||||||
|
- Redis: presence, rate limiting, pub/sub multi-instance fan-out.
|
||||||
|
- Auth: session-cookie admin auth, Redis-backed expiry.
|
||||||
|
- Metrics on `/metrics`: connected clients, stored messages, DMs, moderation actions.
|
||||||
|
|
||||||
|
**Frontend SPA** (`frontend/`)
|
||||||
|
|
||||||
|
- Viewer `src/main.ts`: hls.js player, WS chat, presence, schedule overlay.
|
||||||
|
- Admin `src/admin.ts`: 9 tabs (dashboard, upload, playlist, schedule, moderation, chat-log, users, analytics, system).
|
||||||
|
- Build: Vite → `frontend/dist/`.
|
||||||
|
|
||||||
|
**Reverse Proxy** (`infrastructure/nginx/nginx.conf`)
|
||||||
|
|
||||||
|
- `auth_request` gate on `/admin` and `/api/admin`; WS upgrade for `/ws`; HLS CORS + OPTIONS preflight; `/health` JSON + `/nginx-status` stub_status.
|
||||||
|
|
||||||
|
**Level 3 — Chat Service Modules**
|
||||||
|
|
||||||
|
| Module | Location | Responsibility |
|
||||||
|
| ------------------ | ------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||||
|
| Connection Manager | `main.go` (hub, clients map) | Track active WS connections per room, fan-out |
|
||||||
|
| Message Parser | `main.go` (readPump, handleMessage) | Parse JSON envelope `{type, room?, ...}`, validate, route |
|
||||||
|
| Room Registry | `main.go` (rooms map) | Room name → client set, join/leave/close |
|
||||||
|
| DM Router | `main.go` (handleDirectMessage) | Resolve user, deliver private message |
|
||||||
|
| Persistence Layer | `main.go` (GORM: Message, DirectMessage, ModerationLog) | Store/retrieve messages, DMs, moderation |
|
||||||
|
| Redis Subsystem | `main.go` (redisClient) | INCR+EXPIRE rate limit, SETEX presence, PSUBSCRIBE fan-out |
|
||||||
|
| Auth Handler | `main.go` (handleAdminLogin, adminAuthMiddleware) | Session cookie create/validate, login rate limit |
|
||||||
|
| User Auth Handler | `handlers/handlers.go` | Viewer register/login/logout/session (bcrypt, user cookie) |
|
||||||
|
| DM History/Search | `handlers/handlers.go` | Paginated DM history + search |
|
||||||
|
| Runtime Config | `config/runtime.go` | Thread-safe JSON runtime config (HLS, Matomo) |
|
||||||
|
| Admin API Router | `main.go` (handleAdmin\*) | Upload, playlist CRUD, schedule overrides, moderation, analytics, system |
|
||||||
|
| Metrics Exporter | `main.go` | connected_clients, messages_total, dms_total, moderation_actions, redis_up |
|
||||||
|
|
||||||
|
**Level 3 — Playout Service Modules**
|
||||||
|
|
||||||
|
| Module | Location | Responsibility |
|
||||||
|
| ---------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
|
| Playlist Parser | `playlist.go` (parsePlaylist) | Read playlist, skip comments/blanks, resolve paths |
|
||||||
|
| FFmpeg Manager | `playlist.go` (runPlayout) | Spawn/monitor FFmpeg concat, restart on crash |
|
||||||
|
| Loop Controller | `playlist.go` (playlistFingerprint, hlsNextStartNumber) | Detect changes (SHA-256), continue numbering |
|
||||||
|
| Slate Controller | `playlist.go` (activateSlate/deactivateSlate) | Switch to slate on excessive failures |
|
||||||
|
| R2 Sync | `playlist.go` (syncToR2) | Upload segments + manifest to R2 |
|
||||||
|
| R2 Cleanup | `playlist.go` (cleanupOldRuns) | Keep last N run folders under size cap; delete `hls/r-*/*` |
|
||||||
|
| Health Endpoint | `playlist.go` (healthHandler) | `/health` JSON: mode, segment count, uptime |
|
||||||
|
| Metrics Exporter | `playlist.go` | restarts_total, mode, loops_total, run_duration_seconds, r2_cleanup_objects_total |
|
||||||
|
|
||||||
|
**Level 3 — Frontend Modules**
|
||||||
|
|
||||||
|
| Module | File | Responsibility |
|
||||||
|
| ------------------- | ----------------------------------------------- | ------------------------------------------------------------------- |
|
||||||
|
| HLS Player | `src/main.ts` | hls.js init, attach, error recovery |
|
||||||
|
| WebSocket Client | `src/main.ts` | Connect/parse envelope/dispatch |
|
||||||
|
| Chat UI | `src/main.ts` | Message list, room tabs, DM panel, presence dots, typing, user-list |
|
||||||
|
| DM Search | `src/main.ts` | `/api/dm/search` + render |
|
||||||
|
| Auth UI | `src/main.ts` + `index.html` | Viewer login/register/logout, session, user label |
|
||||||
|
| Schedule Overlay | `src/main.ts` | Poll `/api/schedule`, render current/next |
|
||||||
|
| Admin Router | `src/admin.ts` | Tab nav, dynamic pages |
|
||||||
|
| Upload Manager | `src/admin.ts` | Multipart chunk upload, normalization badges |
|
||||||
|
| Playlist Editor | `src/admin.ts` | Drag-reorder (gated to `ready` media), save |
|
||||||
|
| Moderation Panel | `src/admin.ts` + `public/admin/moderation.html` | Mute/ban, action log |
|
||||||
|
| Analytics Dashboard | `src/admin.ts` + `public/admin/analytics.html` | Message counts, top rooms, moderation stats |
|
||||||
|
| System Config | `src/admin.ts` + `public/admin/system.html` | Health, runtime config form, restart |
|
||||||
|
|
||||||
## Runtime View
|
## Runtime View
|
||||||
|
|
||||||
### Playout & Delivery Scenario
|
**Playout & Delivery Scenario**
|
||||||
|
|
||||||
1. Admin adds normalized MP4 to R2 + updates playlist.
|
1. Admin adds normalized MP4 to R2 + updates playlist.
|
||||||
2. Playout worker (FFmpeg loop with concat demuxer) reads next file, packages as HLS segments (4-6s).
|
2. Playout worker (FFmpeg concat loop) reads next file, packages HLS segments (2s).
|
||||||
3. Segments uploaded to R2 with unique immutable names; live `.m3u8` updated.
|
3. Segments uploaded to R2 with unique immutable, run-scoped names (`hls/r-{runID}/…`); the live `.m3u8` is published referencing those run-prefixed URIs (see `STREAMING.md`).
|
||||||
4. Viewer loads SPA → requests master/live manifest → hls.js player follows current edge.
|
4. Viewer loads SPA → requests manifest → hls.js follows current edge.
|
||||||
5. Cloudflare serves `.ts` from nearest edge cache (origin/R2 hit only once per POP/region).
|
5. Cloudflare serves `.m4s`/`.ts` from nearest edge cache (origin hit once per POP/region).
|
||||||
6. Parallel WebSocket connection to chat service for synchronized interaction.
|
6. Parallel WebSocket to chat service for synchronized interaction.
|
||||||
|
|
||||||
Notable aspects: Manifest freshness ensures all viewers stay on same timeline; cache hit ratio directly impacts cost and latency. Fallback slate activates on playout failure. Full cache policy, example configs (FFmpeg bash loop, Nginx RTMP HLS), and storage tables in `adr/ADR-001.md`.
|
Manifest freshness keeps all viewers on the same timeline; cache hit ratio drives cost/latency. Fallback slate activates on failure. (Moderation events, scale spikes, failover in ADR.)
|
||||||
|
|
||||||
(Additional scenarios: moderation events, scale spikes, failover covered in the ADR.)
|
|
||||||
|
|
||||||
## Deployment View
|
## Deployment View
|
||||||
|
|
||||||
### CI/CD Pipeline Architecture
|
**CI/CD Pipeline** — Parallel workflows on each push to `main`: **CI builds/pushes images** while **CD deploys** to the test lab (`192.168.88.40`); a dedicated release workflow handles semantic version tags. Production Hetzner VPS planned but not yet provisioned — same pipeline deploys there by changing `DOCKER_HOST`.
|
||||||
|
|
||||||
The deployment follows a two-stage pipeline: **CI builds and pushes images**, then **CD pulls and deploys** them to the production host.
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -230,7 +290,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]
|
||||||
@@ -251,292 +311,195 @@ flowchart LR
|
|||||||
DOCKER --> DB
|
DOCKER --> DB
|
||||||
DOCKER --> PROM
|
DOCKER --> PROM
|
||||||
DOCKER --> GRAF
|
DOCKER --> GRAF
|
||||||
|
|
||||||
classDef ci fill:#e1f5fe,stroke:#0288d1
|
|
||||||
classDef reg fill:#fff3e0,stroke:#f57c00
|
|
||||||
classDef prod fill:#e8f5e9,stroke:#388e3c
|
|
||||||
class CI,CD ci
|
|
||||||
class REGISTRY reg
|
|
||||||
class DOCKER,PLAYOUT,CHAT,NGINX,REDIS,DB,PROM,GRAF prod
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pipeline Sequence:**
|
**Pipeline sequence**
|
||||||
|
|
||||||
1. **CI Workflow** (`.gitea/workflows/ci.yml`):
|
1. **CI** (`.gitea/workflows/ci.yml`): on `push` to `main` + `pull_request`; `test` job (frontend lint/build, Go validation); `docker` job (after test, main only) builds `playout`/`chat`/`nginx` with Buildx, pushes `main` + `latest` tags to Gitea registry.
|
||||||
- Triggers on `push` to `main` or `pull_request`
|
2. **CD** (`.gitea/workflows/cd.yml`): on `push` to `main` (parallel) or `workflow_dispatch`; logs into remote Docker host, generates `.env` from secrets, seeds Prometheus/Grafana volumes, deploys via `docker compose -f docker-compose.prod.yml up -d --remove-orphans --force-recreate` to `192.168.88.40:2375` (switch to Hetzner IP for prod); health checks with retry for playout init.
|
||||||
- Runs `test` job: frontend lint/build, Go validation, `docker compose build`
|
3. **Release** (`.gitea/workflows/release.yml`): on tags `*.*.*` (e.g. `1.0.9`); pushes `X.Y.Z`, `X.Y`, `latest`; creates Gitea release.
|
||||||
- Runs `docker` job (after `test` passes, only on `main` push):
|
|
||||||
- Builds `playout`, `chat`, `nginx` images with Buildx
|
|
||||||
- Pushes to Gitea Container Registry with `main` and `latest` tags
|
|
||||||
2. **CD Workflow** (`.gitea/workflows/cd.yml`):
|
|
||||||
- Triggers on `workflow_run` when CI completes successfully, or manually via `workflow_dispatch`
|
|
||||||
- Pulls latest images from registry
|
|
||||||
- Generates `.env` from secrets
|
|
||||||
- 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`)
|
|
||||||
- Runs health checks with retry logic for playout initialization
|
|
||||||
|
|
||||||
### Infrastructure Level 1
|
**Infrastructure Level 1** — Single low-cost US-East origin (Hetzner) with global edge caching. High cache-hit ratio, low US latency, edge-scale WAF/DDoS. Mapping: Playout + Chat + Nginx → Hetzner CX22 (Ashburn, VA); HLS storage/caching/static SPA → Cloudflare R2 + CDN + Workers (`enam` location hint); all public traffic through Cloudflare.
|
||||||
|
|
||||||
**Overview Diagram**: See Mermaid in Building Block section (VPS + Cloudflare R2/Pages/CDN).
|
**Infrastructure Level 2**
|
||||||
|
|
||||||
**Motivation**: Low-cost US-East origin with global edge caching. Hetzner provides excellent price/performance and included traffic; Cloudflare eliminates egress fees.
|
- **Hetzner VPS (planned)**: playout (FFmpeg/MediaMTX), chat backend, Nginx; monitoring dashboards + UptimeRobot; backups to R2. Test lab currently fills this role (see `DEPLOYMENT.md`).
|
||||||
|
- **Cloudflare**: R2 bucket; Cache Rules (`.m4s`/`.ts` Cache Everything/immutable/long TTL; `.m3u8` short TTL/must-revalidate); WAF, rate limiting, Tiered Cache; Workers for SPA.
|
||||||
**Quality/Performance Features**: High cache-hit ratio for video, low US latency, automatic scaling at edge, built-in WAF/DDoS protection.
|
- Future: Docker Compose, managed DB, secondary regions (later ADRs).
|
||||||
|
|
||||||
**Mapping of Building Blocks to Infrastructure**:
|
|
||||||
|
|
||||||
- Playout + Chat + Nginx → Hetzner Cloud CX22 (Ashburn, VA).
|
|
||||||
- HLS storage, caching, static SPA → Cloudflare R2 + CDN + Pages (custom domains, `enam` location hint).
|
|
||||||
- All public traffic routed through Cloudflare.
|
|
||||||
|
|
||||||
### Infrastructure Level 2
|
|
||||||
|
|
||||||
**Hetzner VPS**: Runs playout (FFmpeg/MediaMTX), chat backend, Nginx. Monitoring via dashboards + UptimeRobot. Backups to R2.
|
|
||||||
|
|
||||||
**Cloudflare Layer**:
|
|
||||||
|
|
||||||
- R2 bucket for media.
|
|
||||||
- Cache Rules (`.ts`: Cache Everything, immutable, long TTL; `.m3u8`: short TTL/must-revalidate).
|
|
||||||
- WAF, rate limiting, Tiered Cache.
|
|
||||||
- Pages for SPA hosting.
|
|
||||||
|
|
||||||
Future evolution: Docker Compose, managed DB, secondary regions (detailed in subsequent ADRs).
|
|
||||||
|
|
||||||
## Cross-cutting Concepts
|
## Cross-cutting Concepts
|
||||||
|
|
||||||
### Caching & Cost Optimization
|
**Caching & Cost Optimization** — Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = near-zero viewer bandwidth. Long TTL on `.m4s`/`.ts`, short on live manifests. Monitor hit ratio and R2 Class B ops.
|
||||||
|
|
||||||
Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = near-zero viewer bandwidth cost. Long TTL on `.ts`, short on live manifests. Monitor hit ratio and R2 Class B ops.
|
**Video Normalization & Playout** — All sources must meet the Linear Void source profile before production playlist acceptance. The chat backend **auto-normalizes** non-conforming uploads on ingest (`media.NormalizeVideo`), removing manual pre-processing. Prevents decode resets, timestamp drift, AV discontinuities during concat playout.
|
||||||
|
|
||||||
### Video Normalization & Playout
|
_Required Source Profile (v1)_
|
||||||
|
|
||||||
All source assets must be pre-normalized (H.264/AVC, AAC-LC, constant 30 fps, 2s keyframe interval, CRF). Prevents glitches during continuous concat. FFmpeg `-c copy` preferred for efficiency.
|
| Field | Required Value | Notes |
|
||||||
|
| ----------------- | --------------------------- | ------------------------------------------------ |
|
||||||
|
| Container | MP4 | Single-video-asset input |
|
||||||
|
| Video codec | H.264/AVC (`libx264`) | Baseline delivery codec |
|
||||||
|
| Audio codec | AAC-LC | Stereo preferred |
|
||||||
|
| Resolution | 1280x720 or 1920x1080 | Keep single res per playlist block when possible |
|
||||||
|
| Framerate | Constant 30 fps (`-r 30`) | VFR must be normalized |
|
||||||
|
| GOP/keyframe | 2s (`-g 60 -keyint_min 60`) | Matches HLS segment boundaries |
|
||||||
|
| Pixel format | `yuv420p` | Browser baseline |
|
||||||
|
| Audio sample rate | 48 kHz | Stable concat timing |
|
||||||
|
| Audio bitrate | 128k–192k | Predictable envelope |
|
||||||
|
|
||||||
### Monitoring & Resilience
|
_Reference normalization command_
|
||||||
|
|
||||||
Alert on no new segment (>30–60s), low cache hits, high R2 ops, WebSocket errors. Maintain emergency slate, auto-restart, weekly backups, incident runbook.
|
```bash
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-c:v libx264 -preset medium -crf 21 \
|
||||||
|
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
||||||
|
-pix_fmt yuv420p \
|
||||||
|
-c:a aac -b:a 160k -ar 48000 -ac 2 \
|
||||||
|
-movflags +faststart \
|
||||||
|
output-normalized.mp4
|
||||||
|
```
|
||||||
|
|
||||||
### Security & Moderation
|
_Automatic normalization & verification (ffprobe)_
|
||||||
|
|
||||||
HTTPS/WSS only, strict CORS, Cloudflare WAF, authenticated admin paths, rate limiting, immutable moderation log. Private DMs not E2EE by default.
|
- `media.NormalizeVideo` runs ffmpeg with the profile, writes `*.normalized.mp4`, re-verifies with ffprobe.
|
||||||
|
- Media status: `ready`, `needs_normalization`, `normalizing`, `normalization_failed`; admin UI "Normalize" action with polling.
|
||||||
|
- `POST /api/admin/media/{id}/normalize` re-triggers manually.
|
||||||
|
- Keep verification aligned with runtime playout assumptions to preserve `-c copy` efficiency.
|
||||||
|
|
||||||
### Chat
|
**Runtime Configuration Store** — Thread-safe, JSON-persisted store in `backend/chat/src/config/runtime.go`, shared with playout via the playlist volume (`/app/playlist/config.json`).
|
||||||
|
|
||||||
WebSockets + Redis (presence, pub/sub, rate limits) + DB (history, audit). Public rooms + on-demand private chats. Scales independently of video.
|
- Editable via `GET/PUT /api/admin/config`.
|
||||||
|
- Fields: `hlsSegmentDuration` (2–15s, default 2), `hlsWindowSize` (3–100, default 12), `matomoUrl`, `matomoSiteId`.
|
||||||
|
- Playout reads same file (`loadSharedHLSConfig`) as env fallback.
|
||||||
|
- Typed setters validate; invalid → `400`.
|
||||||
|
|
||||||
Full details, cache rule table, bitrate/storage calculations, cost model, example configs, ToS notes, and implementation phases are in [`adr/ADR-001.md`](adr/ADR-001.md).
|
**Analytics (Matomo)** — Viewer sends pageviews via public `GET /api/matomo`, returning `matomoUrl` + `matomoSiteId` from the runtime config. Optional per deployment (empty `matomoUrl` disables).
|
||||||
|
|
||||||
|
**Monitoring & Resilience** — Two-layer model: (1) application layer (chat + platform health) via Prometheus + lightweight Grafana; (2) infrastructure/runtime layer (Prometheus self-observability, TSDB, Go runtime) via detailed Grafana dashboard.
|
||||||
|
|
||||||
|
_Prometheus baseline_ (`monitoring/prometheus/prometheus.yml`): 15s scrape; 7 jobs — `chat`→`chat:3000`, `playout`→`playout:8081`, `nginx`→`nginx-exporter:9113`, `redis`→`redis-exporter:9121`, `postgres`→`postgres-exporter:9187`, `node`→`node-exporter:9100`, `prometheus`→`localhost:9090`. TSDB retention `30d` + `15GB` disk cap (both compose files).
|
||||||
|
|
||||||
|
_Grafana dashboards_: `monitoring/grafana/dashboard.json` (app metrics: connected clients, messages, moderation, Redis reachability, admin sessions, scrape health); `monitoring/grafana/dashboards/dashboard.json` (Prometheus ops: status, uptime, CPU/mem, TSDB ingestion/churn/errors, HTTP latencies, query load, GC/scheduler). Use app dashboard for service behavior; Prometheus overview for platform diagnostics/capacity.
|
||||||
|
|
||||||
|
_Alerting rules_ (`monitoring/prometheus/alerts.yml`, loaded via `rule_files`): 10 rules — `ChatDown`, `PlayoutDown`, `RedisDown`, `PlayoutSlateActive`, `HighChatLatency`, `PrometheusWALFailing`, `HighCPU`, `LowCacheHitRatio`, `HighRedisMemoryUsage`, `LowDiskSpace`. Route warning/critical to ops channel; attach runbook links.
|
||||||
|
|
||||||
|
_Resilience baseline_: emergency slate fallback; auto-restart for playout/chat/nginx; weekly backup/restore drills for stateful data; incident runbook triage: edge reachability → origin health → playout progression → chat connectivity → data-plane dependencies.
|
||||||
|
|
||||||
|
**API Reference** — Three operational API surfaces (canonical contract in `API.md`).
|
||||||
|
|
||||||
|
_Chat WebSocket API_: endpoint `/ws`; `wss://` prod / `ws://` local; query params `username`, `room`; envelope `{room, username, text, type: public|private|system}`; responsibilities — room fan-out, DM routing, moderation commands, rate limiting/presence.
|
||||||
|
|
||||||
|
_Playout & Delivery REST_: playout `/health`; edge/origin `/health` via nginx; HLS manifest/segment namespace under `/hls/`. Liveness for orchestration + stable HLS path contract.
|
||||||
|
|
||||||
|
_Admin REST_: auth/session `/api/admin/login|logout|session`; domains overview/analytics/users/moderation/schedule/playlist/media; runtime config `GET/PUT /api/admin/config`; normalization `POST /api/admin/media/{id}/normalize`; uploads `/api/admin/media/uploads` (init/resume), `/uploads/{uploadId}/chunk`, `/complete`. Protected control plane; media metadata + upload orchestration; moderation/audit + system status.
|
||||||
|
|
||||||
|
_Viewer REST_: auth `POST /api/auth/register|login|logout`, `GET /api/auth/session`; DMs `GET /api/dm/history` (paginated), `GET /api/dm/search`; analytics `GET /api/matomo` (public).
|
||||||
|
|
||||||
|
**Cloudflare Configuration Reference** — Cloudflare is part of the production control plane, not only a CDN.
|
||||||
|
|
||||||
|
_DNS & zone routing_: `linearvoid.tv` → app origin (nginx); `www.linearvoid.tv` → redirect apex; `media.linearvoid.tv` → media domain for HLS/object delivery when split-domain enabled.
|
||||||
|
|
||||||
|
_Cache rules baseline_: `.ts`/`.m4s` Cache Everything, long TTL, immutable; `.m3u8` short TTL (1–10s), revalidation-friendly.
|
||||||
|
|
||||||
|
_Edge security/controls_: enforce HTTPS/WSS + redirect HTTP; WAF baseline; rate limits on abusive patterns (auth/admin); minimize origin exposure.
|
||||||
|
|
||||||
|
_Worker & header policy_: the `linearvoid-media` Worker ([`infrastructure/cloudflare/`](../../infrastructure/cloudflare/), R2-bound) owns `media.linearvoid.tv` and enforces immutable caching for segments, short TTL for live manifests, plus HLS CORS/Range; it is a faithful path→R2 mapper and does **not** rewrite manifest URIs at serve time (playout rewrites them to run-scoped paths at publish — see [`STREAMING.md`](../STREAMING.md)). The `linearvoid-frontend` Worker ([`frontend/wrangler.toml`](../../frontend/wrangler.toml)) serves the static SPA on `linearvoid.tv`. Automated deploys: `workers-deploy.yml` (SPA) and `workers-media-deploy.yml` (media). Ops detail: [`cloudflare-workers-setup.md`](../operations/cloudflare-workers-setup.md).
|
||||||
|
|
||||||
|
_Operations & validation_: verify cache status progression (MISS→HIT), manifest freshness under active playout; track hit ratio, request/error rates, ToS/cost posture.
|
||||||
|
|
||||||
|
**Security & Moderation** — Split across edge (Cloudflare), proxy (nginx), and application (chat/admin).
|
||||||
|
|
||||||
|
- _Transport/edge_: HTTPS/WSS; WAF + rate limiting; Worker enforces immutable headers.
|
||||||
|
- _nginx_: `auth_request` on `/admin/`, `/api/admin/`; admin UI → redirect `/admin/login/`; admin API → JSON `401`; WS upgrade forwarded for `/ws`.
|
||||||
|
- _Admin auth_: cookie session (`linearvoid_admin_session`); `HttpOnly`, `SameSite=Strict`, `Secure` when HTTPS (from TLS or `X-Forwarded-Proto`); server-side expiry.
|
||||||
|
- _Viewer accounts_: register/login/logout; bcrypt passwords; `linearvoid_user_session` cookie, 30-day default TTL (`USER_SESSION_TTL_HOURS`); `SameSite=None; Secure` over HTTPS (cross-site from Workers SPA), `SameSite=Lax` over plain HTTP (local dev); sign-in UI in `frontend/index.html`.
|
||||||
|
- _Rate limiting/abuse_: Redis `INCR + EXPIRE`; baseline `>10` messages in `10s` → throttle. Redis down → presence/rate-limit degrade gracefully (warn logged; core chat available).
|
||||||
|
- _CORS_: HLS endpoints `Access-Control-Allow-Origin: *`; API routes reflect allowlisted origin + `Access-Control-Allow-Credentials: true`; viewer cookie `SameSite=None; Secure` cross-site; admin cookie stays `SameSite=Strict`.
|
||||||
|
- _Moderation/privacy_: commands `/mod`, `/mute`, `/ban` handled in chat flow, persisted. DMs are application-level private, **not** end-to-end encrypted. Formalize moderation/audit retention in runbook/ops docs for production hardening.
|
||||||
|
|
||||||
|
**Chat** — WebSockets + Redis (presence, pub/sub, rate limits) + DB (history, audit). Public rooms + on-demand private chats; scales independently of video. DM history/search persisted; viewer UI has typing indicator, user-list toggle, DM search. (Details/cache tables/bitrate/cost model/configs in ADR-001.)
|
||||||
|
|
||||||
## Architecture Decisions
|
## Architecture Decisions
|
||||||
|
|
||||||
The primary decisions are captured in the following consolidated ADRs (all located in `adr/`):
|
Consolidated ADRs (in `adr/`):
|
||||||
|
|
||||||
- **[ADR-001](./adr/ADR-001.md)**: Overall architecture, HLS + Cloudflare R2 caching strategy, storage calculations, cost model, risks.
|
- **ADR-001**: overall architecture, HLS + R2 caching, storage calcs, cost model, risks.
|
||||||
- **[ADR-002](./adr/ADR-002-module-structure.md)**: Chosen project folder/module structure (frontend, backend/playout, backend/chat, infrastructure, scripts, monitoring) and rationale.
|
- **ADR-002**: project folder/module structure and rationale.
|
||||||
- **[ADR-003](./adr/ADR-003-tech-stack.md)**: Technology stack, programming language choices per module (TypeScript frontend, Go for chat/playout wrapper, Bash/Python scripts), Docker-first approach, and explicit documentation of all implicit assumptions.
|
- **ADR-003**: tech stack (TypeScript frontend; Go for chat/playout wrapper; Bash/Python scripts), Docker-first, documented assumptions.
|
||||||
- **[ADR-004](./adr/ADR-004-admin-video.md)**: Video admin web interface (upload, playlist management, schedule overrides, normalization enforcement).
|
- **ADR-004**: video admin web interface (upload, playlist, schedule overrides, normalization).
|
||||||
- **[ADR-005](./adr/ADR-005-admin-moderation-analytics.md)**: Web admin interfaces for user management, moderation dashboard, analytics, and system configuration.
|
- **ADR-005**: admin UI for users, moderation dashboard, analytics, system config.
|
||||||
- **[ADR-006](./adr/ADR-006-data-storage.md)**: Data storage strategy (SQLite vs PostgreSQL for v1, migration path, admin interface capabilities).
|
- **ADR-006**: data storage (SQLite vs PostgreSQL for v1, migration path).
|
||||||
- **[ADR-007](./adr/ADR-007-redis-usage.md)**: Redis usage (exact use cases, TTL policies, persistence, fallback behavior, admin configuration).
|
- **ADR-007**: Redis usage (use cases, TTL, persistence, fallback, admin config).
|
||||||
|
|
||||||
These ADRs together form the canonical decision log.
|
**Key decisions**: self-managed US-East playout (Hetzner Ashburn) FFmpeg + MediaMTX/SRS → HLS → R2 + edge cache; module structure mirroring arc42 blocks; Go for perf-critical services, TypeScript frontend, Docker everywhere; admin interfaces (HTMX + custom CSS); SQLite primary for v1 with PostgreSQL migration path; Redis for rate limiting/presence/pub/sub/session with defined TTL and fallback.
|
||||||
|
|
||||||
**Summary of Key Decisions** (from ADR-001 through ADR-007):
|
|
||||||
|
|
||||||
- Self-managed US-East playout (Hetzner Ashburn) with FFmpeg + MediaMTX/SRS → HLS → Cloudflare R2 + edge caching.
|
|
||||||
- Module structure that directly reflects the arc42 building blocks and context diagram.
|
|
||||||
- Go preferred for performance-critical services; TypeScript for frontend; Docker for all deployment.
|
|
||||||
- Admin interfaces for video management, moderation, analytics, and system configuration using HTMX + Tailwind.
|
|
||||||
- SQLite as primary database for v1 with PostgreSQL migration path if needed.
|
|
||||||
- Redis for rate limiting, presence, pub/sub, session storage with defined TTL policies and fallback behavior.
|
|
||||||
|
|
||||||
## Quality Requirements
|
## Quality Requirements
|
||||||
|
|
||||||
See **Quality Goals** table in the Introduction section. Primary scenarios focus on cost (near-zero egress), availability (auto-restart + fallback), scalability (edge caching), and usability (synchronized player + responsive chat).
|
See **Quality Goals**. Primary: cost (near-zero egress), availability (auto-restart + fallback), scalability (edge caching), usability (synchronized player + responsive chat).
|
||||||
|
|
||||||
|
**Performance Benchmarks & Capacity Planning**
|
||||||
|
|
||||||
|
_Baseline SLO targets (v1)_: playback startup < 4s after first manifest request on warm cache; no segment starvation in steady state; end-to-end HLS 15–35s; chat delivery p95 < 500ms same region; admin read APIs p95 < 1s.
|
||||||
|
|
||||||
|
_Workload assumptions_: US audience, US-East origin; edge serves most segments, origin handles manifest churn + cache misses; chat low-bandwidth vs video.
|
||||||
|
|
||||||
|
_Initial capacity model_: bitrates — 720p30 ~2.6–4.2 Mbps, 1080p30 ~5.1–8.2 Mbps (with audio). 48h library — 720p30 ~57–90 GB raw (alloc ~80–110 GB); 1080p30 ~110–175 GB raw (alloc ~130–180 GB). Cost driven by cache efficiency + request volume, not direct origin egress.
|
||||||
|
|
||||||
|
_Benchmark plan_: playback (time-to-first-frame, rebuffers across US regions; manifest freshness/segment availability); chat (concurrent WS + fan-out; p50/p95 latency, Redis fallback); admin/API (p95 latency, error rate); platform (TSDB ingestion, query p99, GC in Grafana).
|
||||||
|
|
||||||
|
_Capacity triggers/actions_: viewer growth + rising cache-miss → tighten cache, enable/verify Tiered Cache, review manifest TTL; chat p95 drift/WS errors → scale chat, tune Redis, isolate moderation; Prometheus WAL/query saturation → tune retention/cardinality, add resources, split queries; origin CPU/mem pressure → optimize FFmpeg, isolate services, add secondary origin.
|
||||||
|
|
||||||
|
**Scaling Strategy** — preserve cost efficiency first, then throughput/resilience in phases:
|
||||||
|
|
||||||
|
- **Phase 1 — Vertical + Cache-First (current)**: single US-East origin, strong edge caching; optimize segment immutability + manifest TTL before adding infra; scale compute vertically on pressure.
|
||||||
|
- **Phase 2 — Service Separation**: split playout/chat/observability across nodes; isolate noisy workloads from latency-sensitive chat; introduce managed PostgreSQL/Redis or dedicated state nodes where justified.
|
||||||
|
- **Phase 3 — Multi-Origin Resilience**: warm standby or active-secondary origin; health-aware DNS/origin failover; keep HLS/cache contract stable.
|
||||||
|
- **Phase 4 — Regional/Throughput Expansion**: add origin region only when audience/latency justify; explicit chat affinity strategy; evaluate cache-miss economics vs origin complexity.
|
||||||
|
- _Scaling triggers_: rising cache-miss with sustained growth; chat p95/p99 drift or WS errors; persistent origin saturation; increased blast-radius from single-host dependency.
|
||||||
|
|
||||||
|
**Disaster Recovery & Backup**
|
||||||
|
|
||||||
|
_Recovery targets_: RPO ≤ 24h (chat/moderation/admin metadata); RTO ≤ 60 min (restore baseline streaming + chat).
|
||||||
|
|
||||||
|
_Backup scope_: persistent volumes — Postgres (`postgres-data`), Redis append-only (`redis-data`) where enabled, Prometheus TSDB (`prometheus-data`) if retention required, Grafana state (`grafana-data`); host-mounted dirs — `data/chat`, `data/playlist`, `data/media` (authoritative may be R2), `data/hls` (optional, regenerable); source of truth — compose, nginx, monitoring configs, env template.
|
||||||
|
|
||||||
|
_Cadence_: daily logical Postgres backups (+ weekly full snapshot); daily sync of chat/playlist dirs; weekly restore integrity check in isolated env. Retention: daily 14 days, weekly 8 weeks, monthly 6 months.
|
||||||
|
|
||||||
|
_Recovery runbook (minimum)_:
|
||||||
|
|
||||||
|
1. Restore infra config (compose, nginx, env, monitoring).
|
||||||
|
2. Restore Postgres + Redis state.
|
||||||
|
3. Restore `data/playlist` + chat persistence dirs.
|
||||||
|
4. Rehydrate media from authoritative object storage if missing.
|
||||||
|
5. Start stack; validate in order: playout `/health`, chat `/health`, nginx `/health`, HLS availability, WS chat connectivity.
|
||||||
|
6. Verify admin auth + moderation audit visibility.
|
||||||
|
7. Announce recovery; elevated monitoring ≥ one playlist cycle.
|
||||||
|
|
||||||
|
_Failure modes_: single-host loss → redeploy on standby, restore backups, repoint DNS/edge; storage corruption → restore last good snapshot + replay logical backups; partial failure → replace only impacted service, restore scoped data. Rehearse quarterly.
|
||||||
|
|
||||||
## Risks and Technical Debts
|
## Risks and Technical Debts
|
||||||
|
|
||||||
- Cloudflare ToS risk for heavy video on free tier (use R2 path; fallback to BunnyCDN if challenged).
|
- Cloudflare ToS risk for heavy video on free tier (use R2; fallback to BunnyCDN).
|
||||||
- HLS latency (15–35s) may affect perceived chat synchronization.
|
- HLS latency (15–35s) may affect perceived chat sync.
|
||||||
- Single VPS as SPOF (mitigate with DNS failover in Phase 3).
|
- Single VPS = SPOF (mitigate with DNS failover, Phase 3).
|
||||||
- Upfront effort for video normalization and cache tuning.
|
- Upfront effort for normalization + cache tuning.
|
||||||
- Moderation/abuse prevention operational load at scale.
|
- Moderation/abuse operational load at scale.
|
||||||
- R2 request costs on cache misses (optimize with Tiered Cache; measure in prod).
|
- R2 request costs on cache misses (optimize Tiered Cache; measure in prod).
|
||||||
- Deferred items: LL-HLS, ABR, E2EE, paid access controls, full Docker Compose.
|
- Deferred: LL-HLS, ABR, E2EE, paid access controls, full Docker Compose.
|
||||||
|
- _Debt_: detailed service configs, CI/CD, advanced resilience left for implementation ADRs.
|
||||||
**Technical Debt**: Detailed service configs, CI/CD, and advanced resilience are left for implementation ADRs.
|
|
||||||
|
|
||||||
## Glossary
|
## Glossary
|
||||||
|
|
||||||
| Term | Definition |
|
| Term | Definition |
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------ |
|
| ----------------- | -------------------------------------------------------------------------------- |
|
||||||
| HLS | HTTP Live Streaming – protocol using `.m3u8` manifests and media segments (`.ts` or fMP4). |
|
| HLS | HTTP Live Streaming — `.m3u8` manifests + media segments (fMP4). |
|
||||||
| Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. |
|
| Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. |
|
||||||
| R2 | Cloudflare object storage ($0 egress, ideal origin for cached HLS). |
|
| R2 | Cloudflare object storage ($0 egress; ideal origin for cached HLS). |
|
||||||
| Edge Cache / POP | Cloudflare points-of-presence that cache and serve content close to viewers. |
|
| Edge Cache / POP | Cloudflare points-of-presence caching/serving content near viewers. |
|
||||||
| Playout | Continuous generation of a scheduled linear program from a playlist. |
|
| Playout | Continuous generation of a scheduled linear program from a playlist. |
|
||||||
| Normalization | Pre-processing videos to consistent codec/fps/keyframe settings for seamless playback. |
|
| Normalization | Pre-processing to consistent codec/fps/keyframe settings for seamless playback. |
|
||||||
| Cache Rule | Cloudflare configuration defining caching behavior, TTL, and headers per URL pattern. |
|
| Cache Rule | Cloudflare config for caching behavior, TTL, headers per URL pattern. |
|
||||||
| SPOF | Single Point of Failure (current single VPS origin). |
|
| SPOF | Single Point of Failure (current single VPS origin). |
|
||||||
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
|
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
|
||||||
|
|
||||||
**References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js documentation, standard video bitrate calculators. All original root `ADR-001-*.md` files have been consolidated and can be archived/deleted.
|
**References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js docs, video bitrate calculators. Original root `ADR-001-*.md` files consolidated and can be archived/deleted.
|
||||||
|
|
||||||
### Whitebox Overall System
|
|
||||||
|
|
||||||
**_\<Overview Diagram\>_**
|
|
||||||
|
|
||||||
Motivation
|
|
||||||
|
|
||||||
: _\<text explanation\>_
|
|
||||||
|
|
||||||
Contained Building Blocks
|
|
||||||
|
|
||||||
: _\<Description of contained building block (black boxes)\>_
|
|
||||||
|
|
||||||
Important Interfaces
|
|
||||||
|
|
||||||
: _\<Description of important interfaces\>_
|
|
||||||
|
|
||||||
#### \<Name black box 1\>
|
|
||||||
|
|
||||||
_\<Purpose/Responsibility\>_
|
|
||||||
|
|
||||||
_\<Interface(s)\>_
|
|
||||||
|
|
||||||
_\<(Optional) Quality/Performance Characteristics\>_
|
|
||||||
|
|
||||||
_\<(Optional) Directory/File Location\>_
|
|
||||||
|
|
||||||
_\<(Optional) Fulfilled Requirements\>_
|
|
||||||
|
|
||||||
_\<(optional) Open Issues/Problems/Risks\>_
|
|
||||||
|
|
||||||
#### \<Name black box 2\>
|
|
||||||
|
|
||||||
_\<black box template\>_
|
|
||||||
|
|
||||||
#### \<Name black box n\>
|
|
||||||
|
|
||||||
_\<black box template\>_
|
|
||||||
|
|
||||||
#### \<Name interface 1\>
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
#### \<Name interface m\>
|
|
||||||
|
|
||||||
### Level 2
|
|
||||||
|
|
||||||
#### White Box _\<building block 1\>_
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
#### White Box _\<building block 2\>_
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
#### White Box _\<building block m\>_
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
### Level 3
|
|
||||||
|
|
||||||
#### White Box \<\_building block x.1\_\>
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
#### White Box \<\_building block x.2\_\>
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
#### White Box \<\_building block y.1\_\>
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
## Runtime View
|
|
||||||
|
|
||||||
### \<Runtime Scenario 1\>
|
|
||||||
|
|
||||||
- _\<insert runtime diagram or textual description of the scenario\>_
|
|
||||||
|
|
||||||
- _\<insert description of the notable aspects of the interactions
|
|
||||||
between the building block instances depicted in this diagram.\>_
|
|
||||||
|
|
||||||
### \<Runtime Scenario 2\>
|
|
||||||
|
|
||||||
### ...
|
|
||||||
|
|
||||||
### \<Runtime Scenario n\>
|
|
||||||
|
|
||||||
## Deployment View
|
|
||||||
|
|
||||||
### Infrastructure Level 1
|
|
||||||
|
|
||||||
**_\<Overview Diagram\>_**
|
|
||||||
|
|
||||||
Motivation
|
|
||||||
|
|
||||||
: _\<explanation in text form\>_
|
|
||||||
|
|
||||||
Quality and/or Performance Features
|
|
||||||
|
|
||||||
: _\<explanation in text form\>_
|
|
||||||
|
|
||||||
Mapping of Building Blocks to Infrastructure
|
|
||||||
|
|
||||||
: _\<description of the mapping\>_
|
|
||||||
|
|
||||||
### Infrastructure Level 2
|
|
||||||
|
|
||||||
#### _\<Infrastructure Element 1\>_
|
|
||||||
|
|
||||||
_\<diagram + explanation\>_
|
|
||||||
|
|
||||||
#### _\<Infrastructure Element 2\>_
|
|
||||||
|
|
||||||
_\<diagram + explanation\>_
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
#### _\<Infrastructure Element n\>_
|
|
||||||
|
|
||||||
_\<diagram + explanation\>_
|
|
||||||
|
|
||||||
## Cross-cutting Concepts
|
|
||||||
|
|
||||||
### _\<Concept 1\>_
|
|
||||||
|
|
||||||
_\<explanation\>_
|
|
||||||
|
|
||||||
### _\<Concept 2\>_
|
|
||||||
|
|
||||||
_\<explanation\>_
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
### _\<Concept n\>_
|
|
||||||
|
|
||||||
_\<explanation\>_
|
|
||||||
|
|
||||||
## Architecture Decisions
|
|
||||||
|
|
||||||
## Quality Requirements
|
|
||||||
|
|
||||||
### Quality Requirements Overview
|
|
||||||
|
|
||||||
### Quality Scenarios
|
|
||||||
|
|
||||||
## Risks and Technical Debts
|
|
||||||
|
|
||||||
## Glossary
|
|
||||||
|
|
||||||
+----------------------+-----------------------------------------------+
|
|
||||||
| Term | Definition |
|
|
||||||
+======================+===============================================+
|
|
||||||
| _\<Term-1\>_ | _\<definition-1\>_ |
|
|
||||||
+----------------------+-----------------------------------------------+
|
|
||||||
| _\<Term-2\>_ | _\<definition-2\>_ |
|
|
||||||
+----------------------+-----------------------------------------------+
|
|
||||||
@@ -19,11 +19,11 @@ We require a completely independent 24/7 linear video channel that broadcasts a
|
|||||||
**Key distinctions**:
|
**Key distinctions**:
|
||||||
|
|
||||||
- **Playout**: Continuous scheduled program from playlist.
|
- **Playout**: Continuous scheduled program from playlist.
|
||||||
- **Packaging**: FFmpeg → standard HLS (`.m3u8` + `.ts` segments, 4–6s duration).
|
- **Packaging**: FFmpeg → standard HLS (`.m3u8` + `.m4s` segments, 4–6s duration).
|
||||||
- **Delivery**: Immutable segments served via Cloudflare edge caching + R2 (zero egress cost).
|
- **Delivery**: Immutable segments served via Cloudflare edge caching + R2 (zero egress cost).
|
||||||
- **Interaction**: Separate lightweight WebSocket service for chat.
|
- **Interaction**: Separate lightweight WebSocket service for chat.
|
||||||
|
|
||||||
Bandwidth (not storage or compute) is the dominant cost driver. A single viewer at ~4 Mbps consumes ~1.3–1.8 TB/month; 100 concurrent viewers = ~130–180 TB/month. Direct VPS delivery is only viable for PoC/small audiences.
|
Bandwidth (not storage or compute) is the dominant cost driver. A single viewer at ~4 Mbps consumes ~1.3–1.8 TB/month; 100 concurrent viewers = ~130–180 TB/month. Direct VPS delivery is only viable for PoC/small audiences. HLS segments are typically `.m4s` files, with `.m4s` being the default for fMP4 segments.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ Bandwidth (not storage or compute) is the dominant cost driver. A single viewer
|
|||||||
2. Full independence and ownership (no video platform lock-in).
|
2. Full independence and ownership (no video platform lock-in).
|
||||||
3. Strong US performance and low latency.
|
3. Strong US performance and low latency.
|
||||||
4. True/near-true synchronization via live-style HLS (not pure VOD).
|
4. True/near-true synchronization via live-style HLS (not pure VOD).
|
||||||
5. Aggressive immutable caching of `.ts` segments to achieve near-zero egress.
|
5. Aggressive immutable caching of `.m4s` segments to achieve near-zero egress.
|
||||||
6. Browser-native playback (`hls.js` + native HLS).
|
6. Browser-native playback (`hls.js` + native HLS).
|
||||||
7. Lightweight, maintainable stack with modest operational complexity.
|
7. Lightweight, maintainable stack with modest operational complexity.
|
||||||
8. Support for custom multi-room + private chat with persistence and moderation.
|
8. Support for custom multi-room + private chat with persistence and moderation.
|
||||||
@@ -58,9 +58,9 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
|||||||
|
|
||||||
### 2. Storage & Delivery (Cloudflare R2 + CDN)
|
### 2. Storage & Delivery (Cloudflare R2 + CDN)
|
||||||
|
|
||||||
- HLS segments (`.ts`) and manifests uploaded to **Cloudflare R2** (Eastern North America hint `enam`).
|
- HLS segments (`.m4s`) and manifests uploaded to **Cloudflare R2** (Eastern North America hint `enam`).
|
||||||
- Custom domain (`media.example.com`) fronted by Cloudflare with **Cache Rules**:
|
- Custom domain (`media.linearvoid.tv`) fronted by Cloudflare with **Cache Rules**:
|
||||||
- `*.ts` (and init `.mp4` if using fMP4 later): `Cache Everything`, immutable, Edge TTL 24h–1yr (`Cache-Control: public, max-age=31536000, immutable`).
|
- `*.m4s` (and init `.mp4` if using fMP4 later): `Cache Everything`, immutable, Edge TTL 24h–1yr (`Cache-Control: public, max-age=31536000, immutable`).
|
||||||
- `*.m3u8`: Short TTL (1–10s) or `must-revalidate` (`Cache-Control: public, max-age=1..10, must-revalidate`).
|
- `*.m3u8`: Short TTL (1–10s) or `must-revalidate` (`Cache-Control: public, max-age=1..10, must-revalidate`).
|
||||||
- Master playlist: TTL ~30s.
|
- Master playlist: TTL ~30s.
|
||||||
- Tiered Cache enabled where possible.
|
- Tiered Cache enabled where possible.
|
||||||
@@ -69,7 +69,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
|||||||
|
|
||||||
### 3. Frontend
|
### 3. Frontend
|
||||||
|
|
||||||
- Static SPA (HTML/CSS/JS + Tailwind + Alpine.js or vanilla) hosted on Cloudflare Pages or same VPS/Nginx.
|
- Static SPA (HTML + custom CSS + vanilla JS) hosted on Cloudflare Workers or same VPS/Nginx.
|
||||||
- Video player: `hls.js` (Chromium/Firefox) or native HLS (Safari). Single live `master.m3u8`.
|
- Video player: `hls.js` (Chromium/Firefox) or native HLS (Safari). Single live `master.m3u8`.
|
||||||
- Embedded chat sidebar with room switcher, private DMs, now-playing overlay.
|
- Embedded chat sidebar with room switcher, private DMs, now-playing overlay.
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
|||||||
|
|
||||||
### 5. Infrastructure Summary & Cost Model
|
### 5. Infrastructure Summary & Cost Model
|
||||||
|
|
||||||
- **Primary**: Hetzner Ashburn VPS (playout + chat + Nginx) + Cloudflare R2 + Pages.
|
- **Primary**: Hetzner Ashburn VPS (playout + chat + Nginx) + Cloudflare R2 + Workers.
|
||||||
- **Monitoring**: MediaMTX/SRS dashboard, UptimeRobot, systemd logs, Cloudflare analytics.
|
- **Monitoring**: MediaMTX/SRS dashboard, UptimeRobot, systemd logs, Cloudflare analytics.
|
||||||
- **Backup**: Weekly R2/S3-compatible snapshots of playlist, DB, config.
|
- **Backup**: Weekly R2/S3-compatible snapshots of playlist, DB, config.
|
||||||
- **Fallback**: Emergency slate/loop playlist, automated restart.
|
- **Fallback**: Emergency slate/loop playlist, automated restart.
|
||||||
@@ -90,7 +90,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
|||||||
|
|
||||||
**Initial Targets**:
|
**Initial Targets**:
|
||||||
|
|
||||||
- Protocol: Standard HLS (`.ts` segments). Defer LL-HLS/CMAF/WebRTC.
|
- Protocol: Standard HLS (`.m4s` segments). Defer LL-HLS/CMAF/WebRTC.
|
||||||
- Max rendition: 720p30 initially (upgrade to 1080p30 or ABR later).
|
- Max rendition: 720p30 initially (upgrade to 1080p30 or ABR later).
|
||||||
- Latency: 15–35s (acceptable for linear programming/chat).
|
- Latency: 15–35s (acceptable for linear programming/chat).
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ We must explicitly document language/runtime choices per module and the assumpti
|
|||||||
|
|
||||||
| Layer | Technology | Language | Rationale |
|
| Layer | Technology | Language | Rationale |
|
||||||
| ------------------- | ------------------------------------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ------------------------------------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **Frontend SPA** | HTML + Tailwind + Alpine.js + hls.js | TypeScript / JavaScript | Minimal bundle, excellent HLS support, fast iteration. Vite for build. |
|
| **Frontend SPA** | HTML + custom CSS + vanilla JS + hls.js | TypeScript / JavaScript | Minimal bundle, excellent HLS support, fast iteration. Vite for build. |
|
||||||
| **Playout Service** | FFmpeg + MediaMTX (or SRS) | Bash + Go (wrapper) | FFmpeg is the proven tool for continuous playlist HLS. Go wrapper for reliability and monitoring. |
|
| **Playout Service** | FFmpeg + MediaMTX (or SRS) | Bash + Go (wrapper) | FFmpeg is the proven tool for continuous playlist HLS. Go wrapper for reliability and monitoring. |
|
||||||
| **Chat Backend** | Go (preferred) or Node.js + Socket.io | Go | High-performance WebSockets, low memory, excellent Redis/PostgreSQL clients. Node.js acceptable for rapid prototyping. |
|
| **Chat Backend** | Go (preferred) or Node.js + Socket.io | Go | High-performance WebSockets, low memory, excellent Redis/PostgreSQL clients. Node.js acceptable for rapid prototyping. |
|
||||||
| **Shared Code** | TypeScript definitions + Go structs | TS + Go | Clear contracts between frontend and backend. |
|
| **Shared Code** | TypeScript definitions + Go structs | TS + Go | Clear contracts between frontend and backend. |
|
||||||
@@ -38,7 +38,7 @@ We must explicitly document language/runtime choices per module and the assumpti
|
|||||||
|
|
||||||
1. All video assets **must** be normalized before being added to any playlist (H.264, AAC, 30 fps constant, 2s keyframes, CRF encoding).
|
1. All video assets **must** be normalized before being added to any playlist (H.264, AAC, 30 fps constant, 2s keyframes, CRF encoding).
|
||||||
2. Deployment is **Docker-first**; services will be containerized.
|
2. Deployment is **Docker-first**; services will be containerized.
|
||||||
3. Cloudflare is the primary CDN and will be configured with specific Cache Rules (`.ts` immutable long TTL, `.m3u8` short TTL).
|
3. Cloudflare is the primary CDN and will be configured with specific [Cache Rules](../../infrastructure/cloudflare/cache-rules.md) for HLS segments and manifests.
|
||||||
4. Chat does **not** require end-to-end encryption in v1 (separate ADR if needed).
|
4. Chat does **not** require end-to-end encryption in v1 (separate ADR if needed).
|
||||||
5. Single VPS origin initially; multi-region is a later evolution.
|
5. Single VPS origin initially; multi-region is a later evolution.
|
||||||
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
|
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ This interface must be lightweight, secure, and integrated with the playout serv
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
1. **Lightweight & Maintainable** – Prefer server-rendered or minimal JS (HTMX + Tailwind) over heavy SPA frameworks to align with ADR-003's "lightweight" principle.
|
1. **Lightweight & Maintainable** – Prefer server-rendered or minimal JS (HTMX + custom CSS) over heavy SPA frameworks to align with ADR-003's "lightweight" principle.
|
||||||
2. **Security** – Strict authentication, file type/size validation, and R2 upload permissions.
|
2. **Security** – Strict authentication, file type/size validation, and R2 upload permissions.
|
||||||
3. **Usability** – Drag-and-drop playlist editor, progress indicators for normalization and upload, real-time playout status.
|
3. **Usability** – Drag-and-drop playlist editor, progress indicators for normalization and upload, real-time playout status.
|
||||||
4. **Integration** – Must call playout service APIs for validation and playlist updates; upload directly to R2 with metadata.
|
4. **Integration** – Must call playout service APIs for validation and playlist updates; upload directly to R2 with metadata.
|
||||||
@@ -31,7 +31,7 @@ Create a dedicated **admin web interface** in a new `frontend/admin/` directory
|
|||||||
|
|
||||||
**Tech Stack (per ADR-003)**:
|
**Tech Stack (per ADR-003)**:
|
||||||
|
|
||||||
- **Frontend**: HTMX + Tailwind CSS + Alpine.js (minimal JS, server-rendered HTML). Alternative: React if complex interactions are needed later.
|
- **Frontend**: HTMX + custom CSS + minimal JS (server-rendered HTML). Alternative: React if complex interactions are needed later.
|
||||||
- **Backend**: Go (or Node.js) API endpoints exposed by the playout service or a dedicated `backend/admin/` service.
|
- **Backend**: Go (or Node.js) API endpoints exposed by the playout service or a dedicated `backend/admin/` service.
|
||||||
- **Authentication**: JWT or session-based (admin roles only). Use Cloudflare Access or simple OAuth for production.
|
- **Authentication**: JWT or session-based (admin roles only). Use Cloudflare Access or simple OAuth for production.
|
||||||
- **File Handling**: Upload → validate with ffprobe (codec, fps, keyframes, duration) → normalize if needed → store in R2 with metadata.
|
- **File Handling**: Upload → validate with ffprobe (codec, fps, keyframes, duration) → normalize if needed → store in R2 with metadata.
|
||||||
@@ -48,7 +48,7 @@ Create a dedicated **admin web interface** in a new `frontend/admin/` directory
|
|||||||
|
|
||||||
**Folder Placement** (per ADR-002):
|
**Folder Placement** (per ADR-002):
|
||||||
|
|
||||||
- `frontend/admin/` – Admin-specific HTML/JS components (HTMX + Tailwind).
|
- `frontend/admin/` – Admin-specific HTML/JS components (HTMX + custom CSS).
|
||||||
- `backend/admin/` – Dedicated API handlers for validation, playlist updates, and configuration (or extend playout/chat services).
|
- `backend/admin/` – Dedicated API handlers for validation, playlist updates, and configuration (or extend playout/chat services).
|
||||||
- Protected route in nginx with authentication (JWT or Cloudflare Access).
|
- Protected route in nginx with authentication (JWT or Cloudflare Access).
|
||||||
- Documentation and API specs added to `docs/architecture/`.
|
- Documentation and API specs added to `docs/architecture/`.
|
||||||
@@ -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**:
|
||||||
@@ -97,7 +95,7 @@ This decision was planned in `TODO.md` before implementation, per AGENTS.md.
|
|||||||
|
|
||||||
- ADR-001: Normalization requirements, HLS playout, R2 usage.
|
- ADR-001: Normalization requirements, HLS playout, R2 usage.
|
||||||
- ADR-002: Folder structure (admin UI belongs under frontend or a protected route).
|
- ADR-002: Folder structure (admin UI belongs under frontend or a protected route).
|
||||||
- ADR-003: Lightweight frontend preference (HTMX/Tailwind over heavy frameworks).
|
- ADR-003: Lightweight frontend preference (HTMX/custom CSS over heavy frameworks).
|
||||||
- arc42 documentation: Building Block View (admin as part of the overall system).
|
- arc42 documentation: Building Block View (admin as part of the overall system).
|
||||||
|
|
||||||
This ADR ensures all admin interface decisions are documented before code is written.
|
This ADR ensures all admin interface decisions are documented before code is written.
|
||||||
@@ -17,7 +17,7 @@ These interfaces must be secure, auditable, and integrated with the chat backend
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
1. **Consistency** – Use the same lightweight tech stack decided in ADR-004 (HTMX + Tailwind + Alpine.js) to avoid introducing new frameworks.
|
1. **Consistency** – Use the same lightweight tech stack decided in ADR-004 (HTMX + custom CSS + vanilla JS) to avoid introducing new frameworks.
|
||||||
2. **Security & Auditability** – All actions must require admin authentication and be logged immutably.
|
2. **Security & Auditability** – All actions must require admin authentication and be logged immutably.
|
||||||
3. **Real-time Needs** – Moderation dashboard should show live chat activity (WebSocket integration).
|
3. **Real-time Needs** – Moderation dashboard should show live chat activity (WebSocket integration).
|
||||||
4. **Simplicity** – Analytics should use existing Prometheus/Grafana data where possible rather than building a duplicate system.
|
4. **Simplicity** – Analytics should use existing Prometheus/Grafana data where possible rather than building a duplicate system.
|
||||||
@@ -29,7 +29,7 @@ Extend the admin interface started in ADR-004 with additional protected sections
|
|||||||
|
|
||||||
**Tech Stack (consistent with ADR-003 and ADR-004)**:
|
**Tech Stack (consistent with ADR-003 and ADR-004)**:
|
||||||
|
|
||||||
- **Frontend**: HTMX + Tailwind + Alpine.js for all admin pages. Shared layout with sidebar navigation.
|
- **Frontend**: HTMX + custom CSS + vanilla JS for all admin pages. Shared layout with sidebar navigation.
|
||||||
- **Backend**: Go API endpoints (protected by JWT/session middleware) in `backend/admin/` or extended from the chat/playout services.
|
- **Backend**: Go API endpoints (protected by JWT/session middleware) in `backend/admin/` or extended from the chat/playout services.
|
||||||
- **Authentication**: Role-based (admin, moderator, viewer). Use Cloudflare Access or JWT with short expiry for production.
|
- **Authentication**: Role-based (admin, moderator, viewer). Use Cloudflare Access or JWT with short expiry for production.
|
||||||
- **Data Sources**:
|
- **Data Sources**:
|
||||||
@@ -54,13 +54,11 @@ Extend the admin interface started in ADR-004 with additional protected sections
|
|||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
- Reusing HTMX/Tailwind from ADR-004 keeps the admin interface lightweight and consistent.
|
- Reusing HTMX/custom CSS from ADR-004 keeps the admin interface lightweight and consistent.
|
||||||
- Centralizing admin functionality under `/admin/` reduces maintenance overhead.
|
- Centralizing admin functionality under `/admin/` reduces maintenance overhead.
|
||||||
- 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,13 +90,11 @@ 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.
|
||||||
- ADR-002: Folder structure – admin UI belongs under `frontend/admin/`.
|
- ADR-002: Folder structure – admin UI belongs under `frontend/admin/`.
|
||||||
- ADR-003: Lightweight frontend (HTMX/Tailwind) and Go backend preference.
|
- ADR-003: Lightweight frontend (HTMX/custom CSS) and Go backend preference.
|
||||||
- ADR-004: Video admin interface (shared layout and authentication approach).
|
- ADR-004: Video admin interface (shared layout and authentication approach).
|
||||||
- arc42 documentation: Cross-cutting Concepts (security, monitoring) and Building Block View (admin as extension of the system).
|
- arc42 documentation: Cross-cutting Concepts (security, monitoring) and Building Block View (admin as extension of the system).
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ADR-006: Data Storage Strategy (SQLite vs PostgreSQL)
|
# ADR-006: Data Storage Strategy (SQLite vs PostgreSQL)
|
||||||
|
|
||||||
**Status:** Proposed
|
**Status:** Accepted
|
||||||
**Date:** 2026-08-02
|
**Date:** 2026-08-04
|
||||||
**Author:** AI Assistant (following AGENTS.md, ADR-002, ADR-003, ADR-004, ADR-005)
|
**Author:** AI Assistant (following AGENTS.md, ADR-002, ADR-003, ADR-004, ADR-005)
|
||||||
**Related ADRs:** [ADR-001](./ADR-001.md), [ADR-003](./ADR-003-tech-stack.md), [ADR-005](./ADR-005-admin-moderation-analytics.md), [ADR-007](./ADR-007-redis-usage.md)
|
**Related ADRs:** [ADR-001](./ADR-001.md), [ADR-003](./ADR-003-tech-stack.md), [ADR-005](./ADR-005-admin-moderation-analytics.md), [ADR-007](./ADR-007-redis-usage.md)
|
||||||
|
|
||||||
@@ -12,89 +12,78 @@ The system needs persistent storage for:
|
|||||||
- Chat messages and history (public rooms and private DMs).
|
- Chat messages and history (public rooms and private DMs).
|
||||||
- User accounts, roles, and ban lists.
|
- User accounts, roles, and ban lists.
|
||||||
- Moderation audit log (immutable record of admin and moderator actions).
|
- Moderation audit log (immutable record of admin and moderator actions).
|
||||||
- Playlist metadata and schedule overrides.
|
- Playlist and media metadata used by admin flows.
|
||||||
- System configuration (including Redis settings adjustable via admin UI per ADR-005/ADR-007).
|
- System configuration visibility in admin APIs.
|
||||||
|
|
||||||
From ADR-003 we prefer operational simplicity. ADR-005 requires a robust audit trail. We must decide between **SQLite** (embedded, zero-config) and **PostgreSQL** (full-featured, scalable) and document how the admin interface can view and adjust storage-related configuration.
|
The codebase now ships with a PostgreSQL service in Docker Compose (`db`) and chat is configured with a PostgreSQL `DATABASE_URL` by default. The backend still supports SQLite for optional local fallback, but the deployed and compose-default path is PostgreSQL.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
1. **Operational Simplicity** – Minimize infrastructure (no separate DB server for SQLite).
|
1. **Implemented Reality** - Docker Compose already provisions PostgreSQL and chat depends on it.
|
||||||
2. **Performance & Concurrency** – Chat and moderation may have high write volume.
|
2. **Concurrency & Throughput** - Chat, moderation, and media metadata writes require stronger concurrent write behavior.
|
||||||
3. **Backup and Recovery** – Easy point-in-time recovery and replication.
|
3. **Operational Traceability** - Structured backups, service health checks, and persistent volume management are already in place for PostgreSQL.
|
||||||
4. **Admin Capabilities** – Admin UI must be able to view storage stats, run vacuum/analyze, and switch between SQLite and PostgreSQL (with migration path).
|
4. **Admin Growth** - Upcoming admin features (media CRUD, analytics, moderation tooling) benefit from relational querying and indexing.
|
||||||
5. **Auditability** – Moderation log must be immutable and queryable.
|
5. **Fallback Simplicity** - Keep SQLite support for lightweight local or recovery scenarios without blocking developer workflows.
|
||||||
6. **Cost** – Avoid managed database costs during early stages.
|
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
**Primary storage for v1: SQLite** (embedded in the chat and admin services).
|
**Primary storage for current implementation: PostgreSQL (Docker Compose `db` service).**
|
||||||
|
|
||||||
**Migration Path**: Provide a clear upgrade path to PostgreSQL when concurrency or replication needs exceed SQLite capabilities (target v2 or when concurrent users > 500).
|
**Secondary/Fallback mode:** SQLite remains supported via `DATABASE_URL=sqlite://...` for local-only scenarios.
|
||||||
|
|
||||||
**Admin Interface Capabilities** (extension of ADR-005):
|
**Current Backend Behavior**:
|
||||||
|
|
||||||
- "System Configuration" tab showing current storage engine, database size, connection stats, and vacuum status.
|
- Chat backend (`backend/chat/src/main.go`) resolves `DATABASE_URL` at startup.
|
||||||
- Buttons to run `VACUUM`, `ANALYZE`, or trigger a backup.
|
- `postgres://` or `postgresql://` selects PostgreSQL driver.
|
||||||
- Configuration form to switch connection string (SQLite file path vs PostgreSQL URL) and restart services gracefully.
|
- `sqlite://` selects SQLite driver.
|
||||||
- Audit log of all configuration changes.
|
- Empty `DATABASE_URL` falls back to local `chat.db` for developer convenience.
|
||||||
|
- GORM AutoMigrate is used for chat/admin data models.
|
||||||
|
|
||||||
**Technical Details**:
|
**Media Model Decision (Admin UI Feature Track)**:
|
||||||
|
|
||||||
- Use SQLite with WAL mode for better concurrency.
|
- Media metadata is persisted in the same database as messages and moderation logs.
|
||||||
- Use GORM or sqlx (Go) or Prisma (if Node.js fallback) for ORM/migrations.
|
- Physical media files remain on the shared filesystem (`/app/media`, mapped from `data/media`).
|
||||||
- Store large chat history in a separate table with retention policy (e.g., 30 days).
|
- The database is the source of truth for media library metadata and future runtime analytics.
|
||||||
- Moderation log as an append-only table with triggers for immutability.
|
|
||||||
|
|
||||||
**Folder Placement** (per ADR-002):
|
|
||||||
|
|
||||||
- Database models and migrations in `backend/shared/db/` or `backend/chat/internal/db/`.
|
|
||||||
- Admin UI pages for storage management in `frontend/admin/system/`.
|
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
- SQLite aligns with ADR-003's emphasis on operational simplicity and low resource usage for the initial deployment on a single Hetzner VPS.
|
- The repository already operationalizes PostgreSQL in both `docker-compose.yml` and `docker-compose.prod.yml` with persistent storage.
|
||||||
- It eliminates the need for a separate database container in `docker-compose.yml` during early development.
|
- Default environment documentation (`.env.example`, setup docs) already points to PostgreSQL.
|
||||||
- The admin interface can provide visibility and basic maintenance operations, fulfilling the requirement from the previous TODO plan.
|
- This aligns architecture documentation with implemented behavior and avoids drift.
|
||||||
- PostgreSQL can be introduced later with a migration script when scale demands it (replication, advanced querying, full-text search on chat).
|
- Keeping SQLite support preserves local flexibility while avoiding a hard breaking change.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
**Positive**:
|
**Positive**:
|
||||||
|
|
||||||
- Zero-config database for quick local development and small-scale production.
|
- Better write concurrency and query flexibility for chat + admin workloads.
|
||||||
- Simple backups (just copy the `.db` file).
|
- Clear operational model for containerized deployments (health checks, named volume, service dependency).
|
||||||
- Admin UI can directly manage and monitor storage without external tools.
|
- Easier extension for admin analytics and media metadata features.
|
||||||
|
|
||||||
**Negative / Risks**:
|
**Negative / Risks**:
|
||||||
|
|
||||||
- SQLite has limited concurrency compared to PostgreSQL (WAL mode helps but is not a full replacement).
|
- Additional service to operate versus embedded-only SQLite.
|
||||||
- Large chat history or analytics queries may become slow – plan retention policy and archiving.
|
- Misconfigured `DATABASE_URL` can still route to SQLite fallback unintentionally if left empty outside compose.
|
||||||
- Migration to PostgreSQL will require downtime or dual-write strategy (documented in this ADR).
|
- Requires backup/restore procedures for PostgreSQL volume in production runbooks.
|
||||||
|
|
||||||
**Alternatives Considered**:
|
## Alternatives Considered
|
||||||
|
|
||||||
- PostgreSQL from day one – rejected for added operational complexity (separate service, connection pooling, managed service cost).
|
- SQLite-first in production: rejected for current implementation because compose and deployment already run PostgreSQL.
|
||||||
- Pure key-value store (e.g., BoltDB) – rejected because SQL is better for audit logs and complex moderation queries.
|
- PostgreSQL-only with no fallback: rejected to preserve local dev and troubleshooting flexibility.
|
||||||
- Managed database (Neon, Supabase) – rejected to maintain full self-hosted independence per ADR-001.
|
- Managed external PostgreSQL: deferred; self-hosted compose remains the preferred operational model.
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Status
|
||||||
|
|
||||||
1. Create this ADR-006 (done).
|
1. ADR updated to match implemented state. Done.
|
||||||
2. Add SQLite support in `backend/shared/db/` with GORM migrations.
|
2. Compose includes PostgreSQL service and persistent volume. Done.
|
||||||
3. Implement admin UI pages for storage monitoring and configuration (linked to ADR-005).
|
3. Chat backend supports PostgreSQL and SQLite based on `DATABASE_URL`. Done.
|
||||||
4. Add configuration reload capability without full restart where possible.
|
4. Media metadata persistence model documented here for Admin UI track. In progress (implementation ongoing).
|
||||||
5. Write migration script from SQLite to PostgreSQL.
|
5. Admin storage observability and operations UI (vacuum/analyze/backup flows). Planned.
|
||||||
6. Update `docker-compose.yml` to optionally include PostgreSQL.
|
|
||||||
7. Add tests for data access patterns and admin configuration changes.
|
|
||||||
|
|
||||||
**Next ADR**: ADR-007 (Redis usage and admin configuration capabilities).
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- ADR-001: Chat persistence, moderation log, monitoring requirements.
|
- ADR-001: Chat persistence, moderation log, monitoring requirements.
|
||||||
- ADR-003: Preference for operational simplicity and Go backend.
|
- ADR-003: Preference for operational simplicity and Go backend.
|
||||||
- ADR-005: Admin interface for user management, moderation, and analytics – this ADR extends it with storage and Redis config capabilities.
|
- ADR-005: Admin interface for user management, moderation, and analytics.
|
||||||
- arc42 documentation: Cross-cutting Concepts (data management, monitoring) and Risks & Technical Debts.
|
- ADR-007: Redis usage for presence, rate limiting, pub/sub.
|
||||||
|
- `docker-compose.yml` and `docker-compose.prod.yml` database service definitions.
|
||||||
This ADR ensures all data storage decisions are documented before code is written, maintaining full traceability.
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ contributors. See <https://arc42.org>.
|
|||||||
+-------------+---------------------------+---------------------------+
|
+-------------+---------------------------+---------------------------+
|
||||||
| Role/Name | Contact | Expectations |
|
| Role/Name | Contact | Expectations |
|
||||||
+=============+===========================+===========================+
|
+=============+===========================+===========================+
|
||||||
| * | *\<Contact-1\>* | *\<Expectation-1\>* |
|
| * | _\<Contact-1\>_ | _\<Expectation-1\>_ |
|
||||||
| \<Role-1\>* | | |
|
| \<Role-1\>* | | |
|
||||||
+-------------+---------------------------+---------------------------+
|
+-------------+---------------------------+---------------------------+
|
||||||
| * | *\<Contact-2\>* | *\<Expectation-2\>* |
|
| * | _\<Contact-2\>_ | _\<Expectation-2\>_ |
|
||||||
| \<Role-2\>* | | |
|
| \<Role-2\>* | | |
|
||||||
+-------------+---------------------------+---------------------------+
|
+-------------+---------------------------+---------------------------+
|
||||||
|
|
||||||
@@ -57,41 +57,41 @@ contributors. See <https://arc42.org>.
|
|||||||
|
|
||||||
## Whitebox Overall System {#_whitebox_overall_system}
|
## Whitebox Overall System {#_whitebox_overall_system}
|
||||||
|
|
||||||
***\<Overview Diagram\>***
|
_**\<Overview Diagram\>**_
|
||||||
|
|
||||||
Motivation
|
Motivation
|
||||||
|
|
||||||
: *\<text explanation\>*
|
: _\<text explanation\>_
|
||||||
|
|
||||||
Contained Building Blocks
|
Contained Building Blocks
|
||||||
|
|
||||||
: *\<Description of contained building block (black boxes)\>*
|
: _\<Description of contained building block (black boxes)\>_
|
||||||
|
|
||||||
Important Interfaces
|
Important Interfaces
|
||||||
|
|
||||||
: *\<Description of important interfaces\>*
|
: _\<Description of important interfaces\>_
|
||||||
|
|
||||||
### \<Name black box 1\> {#_name_black_box_1}
|
### \<Name black box 1\> {#_name_black_box_1}
|
||||||
|
|
||||||
*\<Purpose/Responsibility\>*
|
_\<Purpose/Responsibility\>_
|
||||||
|
|
||||||
*\<Interface(s)\>*
|
_\<Interface(s)\>_
|
||||||
|
|
||||||
*\<(Optional) Quality/Performance Characteristics\>*
|
_\<(Optional) Quality/Performance Characteristics\>_
|
||||||
|
|
||||||
*\<(Optional) Directory/File Location\>*
|
_\<(Optional) Directory/File Location\>_
|
||||||
|
|
||||||
*\<(Optional) Fulfilled Requirements\>*
|
_\<(Optional) Fulfilled Requirements\>_
|
||||||
|
|
||||||
*\<(optional) Open Issues/Problems/Risks\>*
|
_\<(optional) Open Issues/Problems/Risks\>_
|
||||||
|
|
||||||
### \<Name black box 2\> {#_name_black_box_2}
|
### \<Name black box 2\> {#_name_black_box_2}
|
||||||
|
|
||||||
*\<black box template\>*
|
_\<black box template\>_
|
||||||
|
|
||||||
### \<Name black box n\> {#_name_black_box_n}
|
### \<Name black box n\> {#_name_black_box_n}
|
||||||
|
|
||||||
*\<black box template\>*
|
_\<black box template\>_
|
||||||
|
|
||||||
### \<Name interface 1\> {#_name_interface_1}
|
### \<Name interface 1\> {#_name_interface_1}
|
||||||
|
|
||||||
@@ -101,42 +101,42 @@ Important Interfaces
|
|||||||
|
|
||||||
## Level 2 {#_level_2}
|
## Level 2 {#_level_2}
|
||||||
|
|
||||||
### White Box *\<building block 1\>* {#_white_box_building_block_1}
|
### White Box _\<building block 1\>_ {#_white_box_building_block_1}
|
||||||
|
|
||||||
*\<white box template\>*
|
_\<white box template\>_
|
||||||
|
|
||||||
### White Box *\<building block 2\>* {#_white_box_building_block_2}
|
### White Box _\<building block 2\>_ {#_white_box_building_block_2}
|
||||||
|
|
||||||
*\<white box template\>*
|
_\<white box template\>_
|
||||||
|
|
||||||
...
|
...
|
||||||
|
|
||||||
### White Box *\<building block m\>* {#_white_box_building_block_m}
|
### White Box _\<building block m\>_ {#_white_box_building_block_m}
|
||||||
|
|
||||||
*\<white box template\>*
|
_\<white box template\>_
|
||||||
|
|
||||||
## Level 3 {#_level_3}
|
## Level 3 {#_level_3}
|
||||||
|
|
||||||
### White Box \<\_building block x.1\_\> {#_white_box_building_block_x_1}
|
### White Box \<\_building block x.1\_\> {#_white_box_building_block_x_1}
|
||||||
|
|
||||||
*\<white box template\>*
|
_\<white box template\>_
|
||||||
|
|
||||||
### White Box \<\_building block x.2\_\> {#_white_box_building_block_x_2}
|
### White Box \<\_building block x.2\_\> {#_white_box_building_block_x_2}
|
||||||
|
|
||||||
*\<white box template\>*
|
_\<white box template\>_
|
||||||
|
|
||||||
### White Box \<\_building block y.1\_\> {#_white_box_building_block_y_1}
|
### White Box \<\_building block y.1\_\> {#_white_box_building_block_y_1}
|
||||||
|
|
||||||
*\<white box template\>*
|
_\<white box template\>_
|
||||||
|
|
||||||
# Runtime View {#section-runtime-view}
|
# Runtime View {#section-runtime-view}
|
||||||
|
|
||||||
## \<Runtime Scenario 1\> {#_runtime_scenario_1}
|
## \<Runtime Scenario 1\> {#_runtime_scenario_1}
|
||||||
|
|
||||||
- *\<insert runtime diagram or textual description of the scenario\>*
|
- _\<insert runtime diagram or textual description of the scenario\>_
|
||||||
|
|
||||||
- *\<insert description of the notable aspects of the interactions
|
- _\<insert description of the notable aspects of the interactions
|
||||||
between the building block instances depicted in this diagram.\>*
|
between the building block instances depicted in this diagram.\>_
|
||||||
|
|
||||||
## \<Runtime Scenario 2\> {#_runtime_scenario_2}
|
## \<Runtime Scenario 2\> {#_runtime_scenario_2}
|
||||||
|
|
||||||
@@ -148,51 +148,51 @@ Important Interfaces
|
|||||||
|
|
||||||
## Infrastructure Level 1 {#_infrastructure_level_1}
|
## Infrastructure Level 1 {#_infrastructure_level_1}
|
||||||
|
|
||||||
***\<Overview Diagram\>***
|
_**\<Overview Diagram\>**_
|
||||||
|
|
||||||
Motivation
|
Motivation
|
||||||
|
|
||||||
: *\<explanation in text form\>*
|
: _\<explanation in text form\>_
|
||||||
|
|
||||||
Quality and/or Performance Features
|
Quality and/or Performance Features
|
||||||
|
|
||||||
: *\<explanation in text form\>*
|
: _\<explanation in text form\>_
|
||||||
|
|
||||||
Mapping of Building Blocks to Infrastructure
|
Mapping of Building Blocks to Infrastructure
|
||||||
|
|
||||||
: *\<description of the mapping\>*
|
: _\<description of the mapping\>_
|
||||||
|
|
||||||
## Infrastructure Level 2 {#_infrastructure_level_2}
|
## Infrastructure Level 2 {#_infrastructure_level_2}
|
||||||
|
|
||||||
### *\<Infrastructure Element 1\>* {#_infrastructure_element_1}
|
### _\<Infrastructure Element 1\>_ {#_infrastructure_element_1}
|
||||||
|
|
||||||
*\<diagram + explanation\>*
|
_\<diagram + explanation\>_
|
||||||
|
|
||||||
### *\<Infrastructure Element 2\>* {#_infrastructure_element_2}
|
### _\<Infrastructure Element 2\>_ {#_infrastructure_element_2}
|
||||||
|
|
||||||
*\<diagram + explanation\>*
|
_\<diagram + explanation\>_
|
||||||
|
|
||||||
...
|
...
|
||||||
|
|
||||||
### *\<Infrastructure Element n\>* {#_infrastructure_element_n}
|
### _\<Infrastructure Element n\>_ {#_infrastructure_element_n}
|
||||||
|
|
||||||
*\<diagram + explanation\>*
|
_\<diagram + explanation\>_
|
||||||
|
|
||||||
# Cross-cutting Concepts {#section-concepts}
|
# Cross-cutting Concepts {#section-concepts}
|
||||||
|
|
||||||
## *\<Concept 1\>* {#_concept_1}
|
## _\<Concept 1\>_ {#_concept_1}
|
||||||
|
|
||||||
*\<explanation\>*
|
_\<explanation\>_
|
||||||
|
|
||||||
## *\<Concept 2\>* {#_concept_2}
|
## _\<Concept 2\>_ {#_concept_2}
|
||||||
|
|
||||||
*\<explanation\>*
|
_\<explanation\>_
|
||||||
|
|
||||||
...
|
...
|
||||||
|
|
||||||
## *\<Concept n\>* {#_concept_n}
|
## _\<Concept n\>_ {#_concept_n}
|
||||||
|
|
||||||
*\<explanation\>*
|
_\<explanation\>_
|
||||||
|
|
||||||
# Architecture Decisions {#section-design-decisions}
|
# Architecture Decisions {#section-design-decisions}
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ Mapping of Building Blocks to Infrastructure
|
|||||||
+----------------------+-----------------------------------------------+
|
+----------------------+-----------------------------------------------+
|
||||||
| Term | Definition |
|
| Term | Definition |
|
||||||
+======================+===============================================+
|
+======================+===============================================+
|
||||||
| *\<Term-1\>* | *\<definition-1\>* |
|
| _\<Term-1\>_ | _\<definition-1\>_ |
|
||||||
+----------------------+-----------------------------------------------+
|
+----------------------+-----------------------------------------------+
|
||||||
| *\<Term-2\>* | *\<definition-2\>* |
|
| _\<Term-2\>_ | _\<definition-2\>_ |
|
||||||
+----------------------+-----------------------------------------------+
|
+----------------------+-----------------------------------------------+
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 284 KiB |
@@ -0,0 +1,160 @@
|
|||||||
|
# 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
|
||||||
|
dig linearvoid.tv
|
||||||
|
dig www.linearvoid.tv
|
||||||
|
curl -I https://linearvoid.tv/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common causes:** DNS misconfiguration (A record → wrong IP); Cloudflare proxy disabled (grey cloud exposes origin); SSL certificate expired (Cloudflare → SSL/TLS); Cloudflare outage ([cloudflarestatus.com](https://www.cloudflarestatus.com)).
|
||||||
|
|
||||||
|
**Mitigation:** Verify A record points `linearvoid.tv` → origin IP (proxied, orange cloud). Bypass Cloudflare temporarily:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Host: linearvoid.tv" http://<origin-ip>/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Origin Health (VPS/Docker)
|
||||||
|
|
||||||
|
**Symptoms:** Cloudflare 502/504, containers down.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker -H tcp://192.168.88.40:2375 ps
|
||||||
|
docker -H tcp://192.168.88.40:2375 logs linearvoid-nginx --tail 50
|
||||||
|
curl http://192.168.88.40/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common causes:** Host disk full (`df -h`); Docker daemon stopped (`systemctl status docker`); container restart loop (`docker ps -a --filter "status=exited"`); OOM (`dmesg | grep -i oom`).
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up -d # restart stack
|
||||||
|
docker system prune -f # free disk
|
||||||
|
docker logs linearvoid-<service> --tail 100 # per-service logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Playout Progression (HLS)
|
||||||
|
|
||||||
|
**Symptoms:** Viewer sees slate or buffering, playlist not advancing.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://192.168.88.40:8081/health # "mode": "live" or "slate"
|
||||||
|
curl -I http://192.168.88.40/hls/playlist.m3u8
|
||||||
|
ls -la data/hls/
|
||||||
|
docker logs linearvoid-playout --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common causes:** FFmpeg crash → slate fallback (health `degraded`); playlist empty/missing (`cat data/playlist/playlist.txt`); media file missing (`ls data/media/`); disk full → HLS segments can't write.
|
||||||
|
|
||||||
|
**Mitigation:** Validate playlist entries and media paths; restart playout (`docker compose restart playout`); regenerate demo playlist if empty (see `backend/playout/start-playout.sh`).
|
||||||
|
|
||||||
|
## 4. Chat Connectivity (WebSocket)
|
||||||
|
|
||||||
|
**Symptoms:** Chat not loading, messages not sending, presence broken.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://192.168.88.40:3000/health
|
||||||
|
wscat -c ws://192.168.88.40/ws?username=test&room=main
|
||||||
|
docker logs linearvoid-chat --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common causes:** Redis down → presence/rate-limiting degraded (chat still works); DB 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 degrades gracefully without Redis — look for "Redis connection warning" in logs.
|
||||||
|
|
||||||
|
## 5. Data-Plane (Redis/DB)
|
||||||
|
|
||||||
|
**Symptoms:** Chat works but no persistence/presence, admin stats empty.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec linearvoid-redis redis-cli ping
|
||||||
|
docker exec linearvoid-redis redis-cli info memory
|
||||||
|
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`); PostgreSQL connection limit; disk full on volume mount.
|
||||||
|
|
||||||
|
**Mitigation:** Restart redis (`docker compose restart redis`, AOF persist enabled); restart db (`docker compose restart db`); restore from backup (see `scripts/verify-backups.sh`).
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
| Severity | When | Action |
|
||||||
|
| -------- | ------------------------------------ | ------------------------------------------- |
|
||||||
|
| Critical | Slate active >30min, complete outage | Trigger DR plan, provision fallback host |
|
||||||
|
| Warning | Redis down, high CPU >30min | Investigate 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
|
||||||
|
|
||||||
|
## Quarterly Disaster Recovery Drill
|
||||||
|
|
||||||
|
Validate recovery targets under controlled host-loss simulation: **RPO ≤ 24h, RTO ≤ 60min**.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Latest backups from `scripts/backup-db.sh` and `scripts/backup-weekly.sh`
|
||||||
|
- Operator access to Docker host and DNS controls
|
||||||
|
- Current deployment variables (`.env`, compose file, image tags)
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- Simulate complete origin loss (all app containers unavailable)
|
||||||
|
- Rebuild stack on clean host
|
||||||
|
- Restore DB and media/playlist mounts
|
||||||
|
- Verify viewer playback, chat, admin auth, monitoring targets
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Record drill start timestamp and on-call participants.
|
||||||
|
2. Capture pre-drill baseline:
|
||||||
|
```bash
|
||||||
|
curl -fsS http://<origin>/health
|
||||||
|
curl -fsS http://<origin>:3000/health
|
||||||
|
curl -fsS http://<origin>:8081/health
|
||||||
|
```
|
||||||
|
3. Simulate outage: `docker compose -f docker-compose.prod.yml down`
|
||||||
|
4. Provision clean recovery host (or clean test-lab VM); copy deployment bundle.
|
||||||
|
5. Restore data paths: `data/playlist/`, `data/media/`, `data/chat/`, `data/hls/` (optional warm cache).
|
||||||
|
6. Restore database from latest backup dump.
|
||||||
|
7. Start stack: `docker compose -f docker-compose.prod.yml up -d`
|
||||||
|
8. Validate health and runtime:
|
||||||
|
```bash
|
||||||
|
docker ps --format "table {{.Names}}\t{{.Status}}"
|
||||||
|
curl -fsS http://<origin>/health
|
||||||
|
curl -fsS http://<origin>/hls/playlist.m3u8 | head -n 5
|
||||||
|
curl -fsS http://<origin>:9090/-/ready
|
||||||
|
```
|
||||||
|
9. Validate app behavior: viewer SPA loads and HLS plays; WebSocket chat connects with message round-trip; admin login/session endpoint succeeds.
|
||||||
|
10. Run backup integrity check: `scripts/verify-backups.sh`
|
||||||
|
11. Record drill end timestamp; compute measured RTO/RPO.
|
||||||
|
|
||||||
|
### Pass/Fail Criteria
|
||||||
|
|
||||||
|
- **Pass:** all core services healthy and functional in ≤ 60 min.
|
||||||
|
- **Pass:** latest recoverable data point ≤ 24 h old.
|
||||||
|
- **Fail:** any critical path remains degraded (playout, chat, admin auth, monitoring).
|
||||||
|
|
||||||
|
### Evidence Template
|
||||||
|
|
||||||
|
Record in incident tracker: date/time window; environment (test-lab, staging, production); backup artifact IDs and timestamps; measured RTO and RPO; issues found and remediation owner; follow-up ticket links.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Cloudflare Cache Rules for Linear Void (per ADR-001)
|
||||||
|
|
||||||
|
## Recommended Cache Rules for media.linearvoid.tv
|
||||||
|
|
||||||
|
1. **.m4s and .ts segments (immutable)**
|
||||||
|
- Expression: (http.host eq "media.linearvoid.tv" and (http.request.uri.path contains ".ts" or http.request.uri.path contains ".m4s"))
|
||||||
|
- Cache status: Eligible for cache
|
||||||
|
- Edge Cache TTL: 1 month (or `Cache-Control: public, max-age=31536000, immutable` from origin)
|
||||||
|
- Cache Everything: Yes
|
||||||
|
|
||||||
|
2. **.m3u8 manifests (live)**
|
||||||
|
- Expression: (http.host eq "media.linearvoid.tv" and http.request.uri.path contains ".m3u8")
|
||||||
|
- Cache status: Eligible for cache
|
||||||
|
- Edge Cache TTL: 2 seconds
|
||||||
|
- Cache-Control: public, max-age=2, must-revalidate
|
||||||
|
|
||||||
|
3. **Static assets**
|
||||||
|
- Expression: (http.host eq "linearvoid.tv" and http.request.uri.path contains ".js" or http.request.uri.path contains ".css")
|
||||||
|
- Edge Cache TTL: 1 year (with hash in filename for immutable caching)
|
||||||
|
|
||||||
|
**Tiered Cache**: Enable Smart Tiered Cache for R2 custom domain to reduce origin requests.
|
||||||
|
|
||||||
|
**WAF**: Enable basic rate limiting and bot management for /ws and admin routes.
|
||||||
|
|
||||||
|
These rules ensure high cache hit ratio for .m4s and .ts segments while keeping live manifests fresh. Monitor cache hit ratio in Cloudflare analytics.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Cloudflare Account Troubleshooting
|
||||||
|
|
||||||
|
Reference for Cloudflare account and API token troubleshooting.
|
||||||
|
|
||||||
|
Use `wrangler whoami` to verify the account ID and API token are valid, or query the Workers API at `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts`.
|
||||||
|
|
||||||
|
## Validate Cloudflare account and API token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Validate Cloudflare account and API token in .env
|
||||||
|
cd /path/to/linearvoid
|
||||||
|
grep -E 'CLOUDFLARE_API_TOKEN|CLOUDFLARE_ACCOUNT_ID' .env
|
||||||
|
|
||||||
|
# Test API token with wrangler
|
||||||
|
npx wrangler whoami
|
||||||
|
|
||||||
|
# Or verify with the Workers API directly
|
||||||
|
export CLOUDFLARE_API_TOKEN="$(grep '^CLOUDFLARE_API_TOKEN=' .env | cut -d= -f2-)"
|
||||||
|
export CLOUDFLARE_ACCOUNT_ID="$(grep '^CLOUDFLARE_ACCOUNT_ID=' .env | cut -d= -f2-)"
|
||||||
|
|
||||||
|
# List all Workers for the account
|
||||||
|
curl -X GET "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts" \
|
||||||
|
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json"
|
||||||
|
```
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Prometheus scrape target metric names
|
||||||
|
|
||||||
|
Here are ready‑to‑run **cURL** commands you can use to query each Prometheus scrape target and list the metric names they expose.
|
||||||
|
The Prometheus HTTP API endpoint `http://localhost:9090/api/v1/label/__name__/values` returns all metric names currently known for the target’s job/instance.
|
||||||
|
|
||||||
|
## 1. Chat (`http://chat:3000/metrics`)
|
||||||
|
|
||||||
|
[Browse `chat`](http://192.168.88.40:9090/api/v1/label/__name__/values?match[]={job=%22chat%22})
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="chat",instance="chat:3000"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#chat-metrics) for full output of all 20+ metric names exposed by the Chat service.
|
||||||
|
|
||||||
|
## 2. NGINX exporter (`http://nginx-exporter:9113/metrics`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="nginx",instance="nginx-exporter:9113"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#nginx-metrics) for full output of all 40+ metric names exposed by the NGINX exporter.
|
||||||
|
|
||||||
|
## 3. Node exporter (`http://node-exporter:9100/metrics`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="node",instance="node-exporter:9100"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#node-metrics) for full output of all 200+ metric names exposed by the Node exporter.
|
||||||
|
|
||||||
|
## 4. Playout (`http://playout:8081/metrics`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="playout",instance="playout:8081"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#playout-metrics) for full output of all 10+ metric names exposed by the Playout service.
|
||||||
|
|
||||||
|
## 5. Postgres exporter (`http://postgres-exporter:9187/metrics`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="postgres",instance="postgres-exporter:9187"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#postgres-metrics) for full output of all 100+ metric names exposed by the Postgres exporter.
|
||||||
|
|
||||||
|
## 6. Prometheus itself (`http://prometheus:9090/metrics`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="prometheus",instance="prometheus:9090"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#prometheus-metrics) for full output of all 100+ metric names exposed by Prometheus itself.
|
||||||
|
|
||||||
|
## 7. Redis exporter (`http://redis-exporter:9121/metrics`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="redis",instance="redis-exporter:9121"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#redis-metrics) for full output of all 100+ metric names exposed by the Redis exporter.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Prometheus Metrics — Linear Void
|
||||||
|
|
||||||
|
Raw scrape-target metric inventories captured for the monitoring setup. This file
|
||||||
|
lists which metric names are exposed by each target. No threshold values, alert
|
||||||
|
rules, or query results are present beyond the metric inventory itself.
|
||||||
|
|
||||||
|
Standard boilerplate metrics are exposed by every Go-based target and are omitted
|
||||||
|
below where noted: `go_*` (gc/memstats/sched/goroutines), `process_*` (cpu, fds,
|
||||||
|
memory, network), `promhttp_metric_handler_*`, and Prometheus scrape internals
|
||||||
|
(`scrape_duration_seconds`, `scrape_samples_scraped`, `scrape_samples_post_metric_relabeling`,
|
||||||
|
`scrape_series_added`, `up`).
|
||||||
|
|
||||||
|
## Chat Target (`linearvoid_chat_*`)
|
||||||
|
Application metrics for the chat service:
|
||||||
|
- `linearvoid_chat_admin_sessions_active`
|
||||||
|
- `linearvoid_chat_build_info`
|
||||||
|
- `linearvoid_chat_connected_clients`
|
||||||
|
- `linearvoid_chat_dms_total`
|
||||||
|
- `linearvoid_chat_messages_total`
|
||||||
|
- `linearvoid_chat_moderation_actions_total`
|
||||||
|
- `linearvoid_chat_redis_connected_clients`
|
||||||
|
- `linearvoid_chat_redis_enabled`
|
||||||
|
- `linearvoid_chat_redis_memory_max_bytes`
|
||||||
|
- `linearvoid_chat_redis_memory_usage_ratio`
|
||||||
|
- `linearvoid_chat_redis_memory_used_bytes`
|
||||||
|
- `linearvoid_chat_redis_up`
|
||||||
|
- `linearvoid_chat_request_duration_seconds_bucket/_count/_sum` (histogram)
|
||||||
|
- Plus `ALERTS`, `ALERTS_FOR_STATE` (alert state series).
|
||||||
|
|
||||||
|
## Playout Target (`linearvoid_playout_*`)
|
||||||
|
- `linearvoid_playout_mode`
|
||||||
|
- `linearvoid_playout_restarts_total`
|
||||||
|
- Plus `ALERTS`, `ALERTS_FOR_STATE`.
|
||||||
|
|
||||||
|
## Nginx Target (`nginx_*`)
|
||||||
|
Nginx (Go-based nginx exporter) metrics. Key operational series:
|
||||||
|
- `nginx_connections_accepted`, `nginx_connections_active`, `nginx_connections_handled`
|
||||||
|
- `nginx_connections_reading`, `nginx_connections_waiting`, `nginx_connections_writing`
|
||||||
|
- `nginx_http_requests_total`
|
||||||
|
- `nginx_exporter_build_info`, `nginx_up`
|
||||||
|
- Standard Go/process/promhttp/scrape boilerplate.
|
||||||
|
|
||||||
|
## Node Target (node_exporter, ZFS host)
|
||||||
|
Exposes the full standard node_exporter metric set plus ZFS (`node_zfs_*`) and
|
||||||
|
hwmon (`node_hwmon_*`) collectors. Operationally meaningful series include:
|
||||||
|
- **CPU/load:** `node_load1/5/15`, `node_cpu_seconds_total`, `node_cpu_scaling_frequency_hertz`,
|
||||||
|
`node_procs_running`, `node_procs_blocked`, `node_context_switches_total`
|
||||||
|
- **Memory:** `node_memory_MemTotal_bytes`, `node_memory_MemAvailable_bytes`,
|
||||||
|
`node_memory_MemFree_bytes`, `node_memory_SwapTotal_bytes`, `node_memory_SwapFree_bytes`,
|
||||||
|
`node_vmstat_oom_kill`
|
||||||
|
- **Filesystem/disk:** `node_filesystem_avail_bytes`, `node_filesystem_free_bytes`,
|
||||||
|
`node_filesystem_size_bytes`, `node_filesystem_readonly`, `node_disk_read_bytes_total`,
|
||||||
|
`node_disk_written_bytes_total`, `node_disk_io_time_seconds_total`
|
||||||
|
- **Network:** `node_network_receive_bytes_total`, `node_network_transmit_bytes_total`,
|
||||||
|
`node_network_up`, plus full `node_netstat_*` TCP/UDP/ICMP series
|
||||||
|
- **Files/fds:** `node_filefd_allocated`, `node_filefd_maximum`
|
||||||
|
- **Time:** `node_time_seconds`, `node_time_zone_offset_seconds`, `node_timex_sync_status`
|
||||||
|
- **ZFS ARC/L2ARC:** extensive `node_zfs_arc_*`/`node_zfs_l2_*` hit/miss/size series,
|
||||||
|
`node_zfs_zil_*`, `node_zfs_dbuf_*`, `node_zfs_dnode_*`, `node_zfs_zfetch_*`
|
||||||
|
- **Thermals:** `node_hwmon_temp_celsius`, `node_hwmon_temp_crit_celsius`
|
||||||
|
- `node_exporter_build_info`, `node_uname_info`, `node_os_info`, `node_boot_time_seconds`.
|
||||||
|
|
||||||
|
## Postgres Target (`pg_*` / postgres_exporter)
|
||||||
|
Go-based postgres exporter. Key operational series:
|
||||||
|
- **Health/replication:** `pg_up`, `pg_database_size_bytes`, `pg_database_connection_limit`,
|
||||||
|
`pg_replication_is_replica`, `pg_replication_lag_seconds`, `pg_replication_last_replay_seconds`,
|
||||||
|
`pg_locks_count`, `pg_wal_segments`, `pg_wal_size_bytes`
|
||||||
|
- **Settings (config-as-metric):** `pg_settings_*` (autovacuum, bgwriter, checkpoints,
|
||||||
|
max_connections, shared_buffers, work_mem, wal_*, etc.)
|
||||||
|
- **Activity/statistics:** `pg_stat_activity_count`, `pg_stat_activity_max_tx_duration`,
|
||||||
|
`pg_stat_bgwriter_*` (checkpoints, buffers, fsync), `pg_stat_database_*` (commits,
|
||||||
|
rollbacks, deadlocks, temp files, cache hit/read, tuple ops), `pg_stat_archiver_*`
|
||||||
|
- **Tables:** `pg_stat_user_tables_*` (live/dead tuples, seq/index scans, vacuum/analyze
|
||||||
|
counts), `pg_statio_user_tables_*` (heap/index block hits & reads, incl. TOAST)
|
||||||
|
- `pg_exporter_*` scrape health series, `postgres_exporter_build_info`, and Go/process boilerplate.
|
||||||
|
|
||||||
|
## Prometheus Target (self-scrape)
|
||||||
|
Prometheus' own metrics. Operationally meaningful series include:
|
||||||
|
- **Health/status:** `prometheus_build_info`, `prometheus_ready`,
|
||||||
|
`prometheus_config_last_reload_successful` (+ timestamp), `prometheus_tsdb_clean_start`
|
||||||
|
- **TSDB:** `prometheus_tsdb_head_series`, `prometheus_tsdb_head_samples_appended_total`,
|
||||||
|
`prometheus_tsdb_blocks_loaded`, `prometheus_tsdb_retention_limit_seconds`,
|
||||||
|
`prometheus_tsdb_storage_blocks_bytes`, `prometheus_tsdb_wal_storage_size_bytes`,
|
||||||
|
`prometheus_tsdb_lowest_timestamp`/`_seconds`, `prometheus_tsdb_head_max_time`
|
||||||
|
- **Scraping:** `prometheus_sd_discovered_targets`, `prometheus_sd_failed_configs`,
|
||||||
|
`prometheus_sd_last_update_timestamp_seconds`, `prometheus_target_scrape_pool_targets`,
|
||||||
|
`prometheus_target_scrape_pool_target_limit`, `prometheus_target_interval_length_seconds`
|
||||||
|
- **Rules/alerting:** `prometheus_rule_group_*` (evaluation duration/failures/interval/
|
||||||
|
rules), `prometheus_rule_evaluation_failures_total`, `prometheus_evaluations_total`,
|
||||||
|
`prometheus_notifications_alertmanagers_discovered`, `prometheus_notifications_queue_capacity`
|
||||||
|
- **Engine/HTTP:** `prometheus_engine_queries`, `prometheus_http_requests_total`,
|
||||||
|
`prometheus_http_request_duration_seconds_*`, `prometheus_remote_storage_*` (if remote write enabled)
|
||||||
|
- Standard Go/process/promhttp/scrape boilerplate.
|
||||||
|
|
||||||
|
## Redis Target (redis_exporter)
|
||||||
|
Go-based redis exporter. Key operational series:
|
||||||
|
- **Health:** `redis_up`, `redis_instance_info`, `redis_uptime_in_seconds`,
|
||||||
|
`redis_start_time_seconds`, `redis_exporter_last_scrape_error`
|
||||||
|
- **Memory:** `redis_memory_used_bytes`, `redis_memory_used_peak_bytes`,
|
||||||
|
`redis_memory_max_bytes`, `redis_memory_used_rss_bytes`, `redis_mem_fragmentation_ratio`,
|
||||||
|
`redis_mem_fragmentation_bytes`
|
||||||
|
- **Clients/connections:** `redis_connected_clients`, `redis_max_clients`,
|
||||||
|
`redis_connections_received_total`, `redis_rejected_connections_total`,
|
||||||
|
`redis_blocked_clients`
|
||||||
|
- **Commands/latency:** `redis_commands_processed_total`, `redis_commands_failed_calls_total`,
|
||||||
|
`redis_commands_duration_seconds_total`, `redis_commands_latencies_usec_*`,
|
||||||
|
`redis_latency_percentiles_usec`, `redis_errors_total`
|
||||||
|
- **Keys/eviction/expiry:** `redis_db_keys`, `redis_db_keys_expiring`, `redis_db_avg_ttl_seconds`,
|
||||||
|
`redis_keyspace_hits_total`, `redis_keyspace_misses_total`, `redis_evicted_keys_total`,
|
||||||
|
`redis_expired_keys_total`
|
||||||
|
- **Persistence:** `redis_rdb_last_bgsave_status`, `redis_rdb_last_bgsave_duration_sec`,
|
||||||
|
`redis_aof_enabled`, `redis_aof_last_write_status`, `redis_aof_current_size_bytes`
|
||||||
|
- **Replication/pubsub:** `redis_connected_slaves`, `redis_master_repl_offset`,
|
||||||
|
`redis_replication_backlog_bytes`, `redis_pubsub_channels`, `redis_pubsub_clients`
|
||||||
|
- **Config:** `redis_config_maxmemory`, `redis_config_maxclients`
|
||||||
|
- Standard process/promhttp/scrape boilerplate.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The only alert-related series present are Prometheus' standard `ALERTS` and
|
||||||
|
`ALERTS_FOR_STATE` on the Chat and Playout targets; no concrete rule thresholds
|
||||||
|
are recorded in this file.
|
||||||
|
- No dashboard definitions, alert thresholds, curl outputs, or numeric sample
|
||||||
|
values are captured here — this document is purely the exposed-metric inventory
|
||||||
|
per target.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Cloudflare and UptimeRobot Manual Completion Checklist
|
||||||
|
|
||||||
|
Purpose: execute and record all manual dashboard tasks that cannot be automated from repository workflows.
|
||||||
|
|
||||||
|
Recommended execution order:
|
||||||
|
|
||||||
|
1. Bind `media.linearvoid.tv` and confirm the HLS origin is separate from the app origin.
|
||||||
|
2. Enable Workers, Tiered Cache, and cache behavior for the media zone.
|
||||||
|
3. Apply WAF, bot, rate limiting, HTTPS, and HSTS settings.
|
||||||
|
4. Verify monitor targets and alert delivery in UptimeRobot.
|
||||||
|
5. Capture evidence and fill in the sign-off block.
|
||||||
|
|
||||||
|
## Status Legend
|
||||||
|
|
||||||
|
- [ ] Pending
|
||||||
|
- [x] Done
|
||||||
|
|
||||||
|
## 1) Cloudflare Workers Deploy and Custom Domain
|
||||||
|
|
||||||
|
- [ ] Create a Cloudflare Worker for the SPA and bind the build output
|
||||||
|
- [ ] Build command set to: `cd frontend && npm run build`
|
||||||
|
- [ ] Output directory set to: `frontend/dist`
|
||||||
|
- [ ] Production domain attached to `linearvoid.tv`
|
||||||
|
|
||||||
|
Evidence to capture:
|
||||||
|
|
||||||
|
- Screenshot of Worker deployment settings
|
||||||
|
- Deployment URL and latest deployment timestamp
|
||||||
|
- Confirmed production build output path in the Worker
|
||||||
|
|
||||||
|
## 2) Tiered Cache Enablement and Validation
|
||||||
|
|
||||||
|
- [ ] Smart Tiered Cache enabled for zone
|
||||||
|
- [ ] Warm request made to hot HLS segment
|
||||||
|
- [ ] Warm request made to live manifest
|
||||||
|
- [ ] `Cf-Cache-Status: HIT` observed on repeated requests
|
||||||
|
|
||||||
|
Validation commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
|
||||||
|
curl -I https://media.linearvoid.tv/hls/<segment>.m4s
|
||||||
|
```
|
||||||
|
|
||||||
|
Evidence to capture:
|
||||||
|
|
||||||
|
- Headers from first request (expected MISS)
|
||||||
|
- Headers from repeated request (expected HIT)
|
||||||
|
- Screenshot or export showing Smart Tiered Cache enabled
|
||||||
|
|
||||||
|
## 3) WAF and Bot Protection
|
||||||
|
|
||||||
|
- [ ] OWASP Managed Ruleset enabled
|
||||||
|
- [ ] Mode set to Simulate
|
||||||
|
- [ ] Simulate mode reviewed for 48h
|
||||||
|
- [ ] Mode switched to Block after false-positive review
|
||||||
|
- [ ] Bot Fight Mode enabled
|
||||||
|
|
||||||
|
Evidence to capture:
|
||||||
|
|
||||||
|
- WAF ruleset status screenshot
|
||||||
|
- Count of blocked/challenged events after cutover
|
||||||
|
- Note on any false positives and the final allow/exception rules applied
|
||||||
|
|
||||||
|
## 4) Rate Limiting Rules
|
||||||
|
|
||||||
|
- [ ] Rule created for `/api/admin/login`
|
||||||
|
- [ ] Rule created for `/api/admin/*`
|
||||||
|
- [ ] Threshold set to `10 requests / 10 seconds / IP`
|
||||||
|
- [ ] Action set to Managed Challenge
|
||||||
|
- [ ] Rule order validated
|
||||||
|
|
||||||
|
Validation approach:
|
||||||
|
|
||||||
|
- Use burst requests from a test client and verify challenge behavior without blocking legitimate admin flow.
|
||||||
|
|
||||||
|
Evidence to capture:
|
||||||
|
|
||||||
|
- Rule definitions screenshot
|
||||||
|
- Test result notes (challenge seen / no lockout)
|
||||||
|
- Keep a copy of the test source IP and timestamps used during validation
|
||||||
|
|
||||||
|
## 5) HTTPS and WSS Hardening
|
||||||
|
|
||||||
|
- [ ] Always Use HTTPS enabled
|
||||||
|
- [ ] HSTS enabled with max-age 31536000
|
||||||
|
- [ ] HSTS includeSubDomains enabled
|
||||||
|
- [ ] HSTS preload enabled
|
||||||
|
- [ ] Minimum TLS version set to 1.2+
|
||||||
|
- [ ] SSL mode verified as Full (strict)
|
||||||
|
- [ ] WSS endpoint validated through proxy (`wss://linearvoid.tv/ws`)
|
||||||
|
|
||||||
|
Validation commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://linearvoid.tv
|
||||||
|
curl -I https://linearvoid.tv/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Evidence to capture:
|
||||||
|
|
||||||
|
- Response headers showing HSTS
|
||||||
|
- Successful browser WebSocket connect in devtools
|
||||||
|
- TLS mode screenshot from Cloudflare showing Full (strict)
|
||||||
|
|
||||||
|
## 6) UptimeRobot Monitors
|
||||||
|
|
||||||
|
- [ ] HTTP monitor added for `https://linearvoid.tv/health` (5m)
|
||||||
|
- [ ] Keyword monitor added for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5m)
|
||||||
|
- [ ] Alert trigger set to 2 consecutive failures
|
||||||
|
- [ ] Notification channels configured (email + Discord/Slack webhook)
|
||||||
|
|
||||||
|
Evidence to capture:
|
||||||
|
|
||||||
|
- Monitor dashboard screenshot
|
||||||
|
- Test alert delivery confirmation
|
||||||
|
- List of recipients/webhook destinations verified during the alert test
|
||||||
|
|
||||||
|
## 7) Sign-Off
|
||||||
|
|
||||||
|
- [ ] Cloudflare manual tasks complete
|
||||||
|
- [ ] UptimeRobot tasks complete
|
||||||
|
- [ ] TODO items updated to done
|
||||||
|
- [ ] Changelog entry added for completion batch
|
||||||
|
|
||||||
|
Completion summary:
|
||||||
|
|
||||||
|
- Worker build URL:
|
||||||
|
- Cache validation result:
|
||||||
|
- WAF/rate-limit status:
|
||||||
|
- HTTPS/HSTS status:
|
||||||
|
- UptimeRobot monitor status:
|
||||||
|
- Evidence folder or note link:
|
||||||
|
|
||||||
|
Sign-off fields:
|
||||||
|
|
||||||
|
- Owner:
|
||||||
|
- Date:
|
||||||
|
- Environment (test-lab or production):
|
||||||
|
- Notes:
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Cloudflare Workers Setup — Linear Void
|
||||||
|
|
||||||
|
Linear Void uses Cloudflare Workers for two purposes:
|
||||||
|
|
||||||
|
| Product | Purpose | What |
|
||||||
|
| ----------- | ------------------ | -------------------------------------------------------------------------------------------- |
|
||||||
|
| **Workers** | Static SPA hosting | `linearvoid-frontend` (`frontend/wrangler.toml`) — Vite SPA (`frontend/dist`) static assets |
|
||||||
|
| **Workers** | Edge HLS delivery | `linearvoid-media` (`infrastructure/cloudflare/`) — R2-bound Worker on `media.linearvoid.tv` |
|
||||||
|
|
||||||
|
Both are managed under **Workers & Pages** in the Cloudflare dashboard.
|
||||||
|
|
||||||
|
## Dashboard URLs
|
||||||
|
|
||||||
|
| Resource | URL |
|
||||||
|
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Workers & Pages overview | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers-and-pages` |
|
||||||
|
| Worker: linearvoid-media | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid-media/production/settings` |
|
||||||
|
| API tokens | <https://dash.cloudflare.com/profile/api-tokens> |
|
||||||
|
|
||||||
|
## Worker: `linearvoid-media` (edge HLS delivery)
|
||||||
|
|
||||||
|
The Worker in [`infrastructure/cloudflare/`](../../infrastructure/cloudflare/) runs on the `linearvoid-media` Workers service. It owns the `media.linearvoid.tv` host, serves HLS objects from the R2 bucket (bound as `MEDIA_BUCKET`), and stamps immutable `Cache-Control` on segments plus a short TTL on live manifests (CORS + Range supported). See its [README](../../infrastructure/cloudflare/README.md).
|
||||||
|
|
||||||
|
**Run-scoped layout.** R2 objects are stored run-scoped (`hls/r-{runID}/seg-*.m4s`, `…/init.mp4`), and the live `hls/playlist.m3u8` references them with the run prefix (the playout rewrites bare URIs before publishing — see [`docs/STREAMING.md`](../STREAMING.md)). The Worker is a faithful path→R2 mapper: it serves whatever key the request path resolves to (e.g. `/hls/r-…/seg-*.m4s`), adds the cache headers by file type, and does not rewrite manifest URIs at serve time.
|
||||||
|
|
||||||
|
Test / deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infrastructure/cloudflare
|
||||||
|
npm install && npm test # unit + Miniflare R2 tests
|
||||||
|
npx wrangler deploy # preview/test target
|
||||||
|
npx wrangler deploy --env production # attach media.linearvoid.tv
|
||||||
|
```
|
||||||
|
|
||||||
|
Attach the production custom domain only after removing `media.linearvoid.tv` from the R2 bucket's direct custom-domain binding. Automated production deploy: [`.gitea/workflows/workers-media-deploy.yml`](../../.gitea/workflows/workers-media-deploy.yml).
|
||||||
|
|
||||||
|
## Worker: static frontend
|
||||||
|
|
||||||
|
Serve the Vite-built SPA (`frontend/dist`) as Worker static assets:
|
||||||
|
|
||||||
|
1. Build the frontend: `cd frontend && npm run build`.
|
||||||
|
2. Bind `frontend/dist` to the Worker as static assets (Workers Static Assets / `assets` in `wrangler.toml`).
|
||||||
|
3. Attach the `linearvoid.tv` custom domain to the Worker.
|
||||||
|
|
||||||
|
See [gitea-cloudflare-workers.md](./gitea-cloudflare-workers.md) for the automated deployment workflow.
|
||||||
|
|
||||||
|
### Build-time variables
|
||||||
|
|
||||||
|
The `VITE_PUBLIC_*` values are baked into the bundle at build time by Vite (`vite.config.ts` uses `envDir: ".."` to read them from the repo root). They are supplied by the Gitea Actions build environment from Gitea secrets — not as Worker runtime variables. The static-assets Worker has no runtime JavaScript, so it reads no `env` at request time.
|
||||||
|
|
||||||
|
| Name | Value |
|
||||||
|
| ---------------------------------------- | ----------------------------------------------- |
|
||||||
|
| `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` |
|
||||||
|
| `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` |
|
||||||
|
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` |
|
||||||
|
| `VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR` | `boogers` |
|
||||||
|
| `VITE_PUBLIC_ADMIN_DEFAULT_ROOM` | `main` |
|
||||||
|
| `VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT` | `50` |
|
||||||
|
|
||||||
|
Backend services (playout, chat, nginx) read their own runtime variables (`ADMIN_*`, `HLS_*`, `PLAYLIST_PATH`, `R2_*`, `DATABASE_URL`, `REDIS_URL`) from the container environment — see [`.env.example`](../../.env.example).
|
||||||
|
|
||||||
|
## Custom domains
|
||||||
|
|
||||||
|
- `linearvoid.tv` → Worker (static SPA, `linearvoid-frontend`)
|
||||||
|
- `www.linearvoid.tv` → redirect to `linearvoid.tv`
|
||||||
|
- `media.linearvoid.tv` → Worker (`linearvoid-media`, R2-bound) serving the `linearvoid-media` bucket
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Gitea to Cloudflare Workers deployment guide](./gitea-cloudflare-workers.md)
|
||||||
|
- [Cloudflare & UptimeRobot checklist](./cloudflare-uptimerobot-checklist.md)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Gitea to Cloudflare Workers — Linear Void
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Deploy the Linear Void frontend (Vite + TypeScript SPA) from Gitea to Cloudflare Workers using Gitea Actions and Wrangler.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Cloudflare Worker** for the SPA:
|
||||||
|
- Dashboard → Workers & Pages → Create → Worker (or Workers Static Assets).
|
||||||
|
- Name it `linearvoid-frontend` (the edge-cache Worker already uses the `linearvoid` name).
|
||||||
|
2. **API token** with Workers permissions:
|
||||||
|
- Create at <https://dash.cloudflare.com/profile/api-tokens>
|
||||||
|
- Permission: `Account → Workers Scripts → Edit` and `Account → Workers Routes → Edit`
|
||||||
|
- Store in Gitea → Settings → Secrets as `CLOUDFLARE_API_TOKEN`
|
||||||
|
3. **Account ID** from Cloudflare dashboard overview → store as `CLOUDFLARE_ACCOUNT_ID`
|
||||||
|
4. **Wrangler config** (`frontend/wrangler.toml`) binding the `frontend/dist` output as static assets.
|
||||||
|
|
||||||
|
> **Naming:** Workers share a namespace under "Workers & Pages". The media edge Worker is `linearvoid-media` ([`infrastructure/cloudflare/`](../../infrastructure/cloudflare/wrangler.toml), deployed via `.gitea/workflows/workers-media-deploy.yml`) and the SPA Worker is `linearvoid-frontend` (`frontend/wrangler.toml`, deployed via `.gitea/workflows/workers-deploy.yml`).
|
||||||
|
|
||||||
|
## Project-specific values
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
| ------------------ | ---------------------------------------------------------- |
|
||||||
|
| Framework | Vite + TypeScript |
|
||||||
|
| Build command | `cd frontend && npm install && npm run build` |
|
||||||
|
| Output directory | `frontend/dist` |
|
||||||
|
| Node version | 22 |
|
||||||
|
| Multi-page entries | `index.html`, `admin/index.html`, `admin/login/index.html` |
|
||||||
|
|
||||||
|
## Gitea Actions workflow
|
||||||
|
|
||||||
|
Build the SPA and deploy with Wrangler:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Deploy to Cloudflare Workers
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
VITE_PUBLIC_APP_URL: ${{ secrets.VITE_PUBLIC_APP_URL }}
|
||||||
|
VITE_PUBLIC_WS_URL: ${{ secrets.VITE_PUBLIC_WS_URL }}
|
||||||
|
VITE_PUBLIC_HLS_URL: ${{ secrets.VITE_PUBLIC_HLS_URL }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_ROOM: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }}
|
||||||
|
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT: "50"
|
||||||
|
run: cd frontend && npm run build
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Workers
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npx wrangler deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secrets to add in Gitea
|
||||||
|
|
||||||
|
| Secret | Value |
|
||||||
|
| ------------------------------------- | ------------------------------------ |
|
||||||
|
| `CLOUDFLARE_API_TOKEN` | API token with Workers permissions |
|
||||||
|
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID from dashboard |
|
||||||
|
| `VITE_PUBLIC_APP_URL` | Public viewer URL |
|
||||||
|
| `VITE_PUBLIC_WS_URL` | WebSocket endpoint |
|
||||||
|
| `VITE_PUBLIC_HLS_URL` | HLS playlist URL |
|
||||||
|
| `VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR` | Default moderator username |
|
||||||
|
| `VITE_PUBLIC_ADMIN_DEFAULT_ROOM` | Default chat room |
|
||||||
|
|
||||||
|
## Verify deployment
|
||||||
|
|
||||||
|
1. Push to `main` or trigger manually from Gitea Actions.
|
||||||
|
2. Check **Gitea Actions** → job logs for the deploy step.
|
||||||
|
3. Check **Cloudflare Workers** → `linearvoid-frontend` → Deployments for the new version.
|
||||||
|
4. Visit `https://linearvoid-frontend.zwitschi82.workers.dev`, then attach `linearvoid.tv` when ready.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The existing `ci.yml` already builds the frontend; the Workers deploy workflow is additive and independent.
|
||||||
|
- Multi-page SPA (`vite.config.ts`) produces three entry points — Workers static assets serve them correctly.
|
||||||
|
- For SPA fallback routing, set Workers static assets `not_found_handling` to `single-page-application` (serve `index.html` for unknown paths).
|
||||||
|
- The deploy workflow lives in `.gitea/workflows/workers-deploy.yml` (triggered after CI succeeds).
|
||||||
|
|
||||||
|
## Related resources
|
||||||
|
|
||||||
|
- Cloudflare Workers dashboard: `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid/production/settings` — for managing the `infrastructure/cloudflare/worker.js` deployment and the SPA Worker
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# Scaling and Performance Playbook
|
||||||
|
|
||||||
|
Purpose: define a repeatable method to measure performance, capture baselines, and decide scaling actions for the "Linear Void" streaming app. Scope: planning/execution for the "Scaling & Performance (planning only)" TODO items.
|
||||||
|
|
||||||
|
## 1) Manifest TTL Tuning Plan
|
||||||
|
|
||||||
|
Goal: tune the `.m3u8` TTL to ~`0.5x` segment duration while keeping playback stable.
|
||||||
|
|
||||||
|
Inputs: segment duration (`HLS_SEGMENT_DURATION`), current manifest TTL (Cloudflare cache rule), cache outcome distribution (`Cf-Cache-Status`).
|
||||||
|
|
||||||
|
Procedure:
|
||||||
|
1. Start from current manifest TTL (2s).
|
||||||
|
2. Capture 24h baseline headers from repeated `playlist.m3u8` requests.
|
||||||
|
3. Test candidate TTLs: 1s, 2s, 3s.
|
||||||
|
4. Compare rebuffer events (player logs), origin request increase/decrease, and HIT ratio changes.
|
||||||
|
5. Select the lowest TTL that keeps playback stable and origin load acceptable.
|
||||||
|
|
||||||
|
Acceptance: no material increase in rebuffer rate; manifest staleness not observable by users.
|
||||||
|
|
||||||
|
## 2) TTFB Baseline Plan
|
||||||
|
|
||||||
|
Goal: measure warm-cache TTFB from 3+ US regions with target `< 4s`.
|
||||||
|
|
||||||
|
Regions: US East (Virginia), US Central, US West (California/Oregon).
|
||||||
|
|
||||||
|
Method:
|
||||||
|
1. Use WebPageTest with repeat view enabled.
|
||||||
|
2. Run at least 5 samples per region.
|
||||||
|
3. Record median and p95 TTFB.
|
||||||
|
4. Store results in a table (date, region, median, p95, test URL).
|
||||||
|
|
||||||
|
Acceptance: warm-cache median < 4s in all regions; outliers documented with likely cause.
|
||||||
|
|
||||||
|
### TTFB Baseline Worksheet
|
||||||
|
Use one row per region; keep the raw WebPageTest result URL with the summary for comparison.
|
||||||
|
|
||||||
|
- US East (Virginia) / US Central / US West (California/Oregon): Date/time, Test URL `https://linearvoid.tv`, WebPageTest result URL, Median TTFB, p95 TTFB, Notes.
|
||||||
|
|
||||||
|
Capture notes: use repeat view for warm-cache; keep the same browser profile and test URL across regions; note any CDN cache-rule changes or deploys between samples.
|
||||||
|
|
||||||
|
## 3) WebSocket Load-Test Plan
|
||||||
|
|
||||||
|
Goal: characterize chat behavior across 10 → 100 → 500 → 1000 concurrent clients.
|
||||||
|
|
||||||
|
Tooling option A (Artillery):
|
||||||
|
```bash
|
||||||
|
npx artillery run scripts/loadtest/ws-chat.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Tooling option B (custom Go harness): open N concurrent WS clients, send a message every X seconds, measure round-trip latency and failures.
|
||||||
|
|
||||||
|
Metrics: p50/p95 message latency; error rate (connect/send/receive); dropped connections; CPU/memory for `chat`, `redis`, `nginx`.
|
||||||
|
|
||||||
|
Acceptance: p95 chat latency ≤ 1s at target concurrency tier; error rate ≤ 1% during steady state.
|
||||||
|
|
||||||
|
### WebSocket Load-Test Worksheet
|
||||||
|
Date/time, Environment, Commit SHA/image tag, Test URL, Tool used, Scenario duration, Concurrency tiers reached, p50 message latency, p95 message latency, Error rate, Dropped connections, CPU/memory notes for chat/redis/nginx, Raw results location, Conclusion, Next action.
|
||||||
|
|
||||||
|
## 4) Admin API Benchmark Plan
|
||||||
|
|
||||||
|
Goal: measure admin API p95 and identify slow endpoints.
|
||||||
|
|
||||||
|
Endpoints: `/api/admin/overview`, `/api/admin/analytics`, `/api/admin/chat-log`, `/api/admin/users`.
|
||||||
|
|
||||||
|
Tooling option A (`k6`):
|
||||||
|
```bash
|
||||||
|
k6 run scripts/loadtest/admin-api.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Tooling option B (`wrk`):
|
||||||
|
```bash
|
||||||
|
wrk -t4 -c100 -d60s http://<host>/api/admin/overview
|
||||||
|
```
|
||||||
|
|
||||||
|
Acceptance: p95 < 1s for primary admin endpoints; slow endpoint list with remediation notes.
|
||||||
|
|
||||||
|
### Admin API Benchmark Worksheet
|
||||||
|
Date/time, Environment, Commit SHA/image tag, Test URL, Tool used, Scenario duration, Concurrency/virtual users, `/api/admin/overview|analytics|chat-log|users` p95, Error rate, Slowest endpoint, Cache or DB notes, Raw results location, Conclusion, Next action.
|
||||||
|
|
||||||
|
## 5) Cache Hit Ratio Tracking Plan
|
||||||
|
|
||||||
|
Goal: track Cloudflare cache hit ratio and alert when below threshold.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
1. Pull Cloudflare GraphQL analytics on schedule.
|
||||||
|
2. Export to a Prometheus-compatible metric path.
|
||||||
|
3. Add a Grafana panel with 5m/1h/24h windows.
|
||||||
|
4. Alert when hit ratio < 90% for 15m.
|
||||||
|
|
||||||
|
Reference exporter:
|
||||||
|
- `scripts/cache-hit-ratio.js`
|
||||||
|
- `scripts/cache-hit-ratio.sh` for scheduled tasks / manual runs
|
||||||
|
- Example run: `export CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_TAG=... CLOUDFLARE_HOSTNAME=media.linearvoid.tv; node scripts/cache-hit-ratio.js`
|
||||||
|
- Intended output: Prometheus text format on stdout, suitable for cron, a Node wrapper, or a small scrape endpoint.
|
||||||
|
|
||||||
|
Metric target: `cache_hit_ratio >= 0.90`.
|
||||||
|
|
||||||
|
### Cache Hit Ratio Worksheet
|
||||||
|
Date/time, Environment, Commit SHA/image tag, Hostname, Window minutes, Cloudflare zone tag, Export command, Raw exporter output location, Hit requests, Total requests, Cache hit ratio, Threshold met, Notes.
|
||||||
|
|
||||||
|
## 6) Capacity Trigger Rules
|
||||||
|
|
||||||
|
Operational triggers:
|
||||||
|
- Cache miss ratio > 10% for 15m
|
||||||
|
- Chat p95 latency > 1s for 5m
|
||||||
|
- Host CPU > 80% for 10m
|
||||||
|
- Redis memory usage ratio (`linearvoid_chat_redis_memory_usage_ratio`) > 0.85 for 10m
|
||||||
|
|
||||||
|
Action mapping:
|
||||||
|
- Level 1: investigate + restart non-critical components
|
||||||
|
- Level 2: scale service resources (CPU/RAM)
|
||||||
|
- Level 3: activate standby planning path
|
||||||
|
|
||||||
|
### Capacity Trigger Review
|
||||||
|
Date/time, Environment, Commit SHA/image tag, Trigger hit, Threshold value, Observed value, Duration above threshold, Level selected, Immediate action taken, Services involved, Follow-up owner, Raw metrics/alert link, Notes.
|
||||||
|
|
||||||
|
## 7) Phase 2 Service Separation Plan
|
||||||
|
|
||||||
|
Objective: separate bottlenecked services while keeping ops simple.
|
||||||
|
|
||||||
|
Boundaries:
|
||||||
|
- `playout` isolated first (CPU-heavy)
|
||||||
|
- `chat` + `redis` grouped for latency
|
||||||
|
- `db` isolated for IO stability
|
||||||
|
- monitoring stack optionally separated after sustained load
|
||||||
|
|
||||||
|
Initial sizing:
|
||||||
|
- playout host: 4 vCPU / 8 GB
|
||||||
|
- chat host: 2–4 vCPU / 4–8 GB
|
||||||
|
- db host: 2 vCPU / 4 GB + fast disk
|
||||||
|
|
||||||
|
Exit criteria: repeated trigger events over 7 days; capacity saturation despite tuning.
|
||||||
|
|
||||||
|
### Phase 2 Boundary Worksheet
|
||||||
|
Date/time, Environment, Commit SHA/image tag, Trigger history summary, Repeated trigger count over 7 days, Primary bottleneck, Proposed split, `playout`/`chat`/`db` target host size, Monitoring separation needed, Data/state migration notes, Implementation owner, Review decision, Next action.
|
||||||
|
|
||||||
|
## 8) Phase 3 Warm Standby Plan
|
||||||
|
|
||||||
|
Objective: second-region resilience with DNS failover.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
- Primary: Hetzner Ashburn (US-East)
|
||||||
|
- Standby: Hetzner Hillsboro (US-West)
|
||||||
|
|
||||||
|
Design: warm standby stack deployed and health-checked; Redis replication or fallback mode documented; Postgres replication strategy selected (streaming replica or periodic snapshot restore); Cloudflare DNS failover policy defined (health-based or manual emergency switch).
|
||||||
|
|
||||||
|
### Phase 3 Warm Standby Worksheet
|
||||||
|
Date/time, Environment, Commit SHA/image tag, Primary region, Standby region, DNS failover mode, Redis strategy, Postgres strategy, Health check endpoints, Simulated cutover performed, Measured RTO, Measured RPO, Open risks, Implementation owner, Review decision, Notes.
|
||||||
|
|
||||||
|
## 9) Data Capture Template
|
||||||
|
For each benchmark run capture: Date/time, Environment (test-lab/staging/production), Commit SHA/image tag, Test scenario and duration, Raw metrics (latency, errors, resource usage), Conclusion and next action.
|
||||||
|
|
||||||
|
## 10) Execution Order
|
||||||
|
1. TTFB baseline
|
||||||
|
2. WebSocket load profile
|
||||||
|
3. Admin API benchmark
|
||||||
|
4. Cache ratio panel + alerting
|
||||||
|
5. Trigger tuning
|
||||||
|
6. Phase 2 boundary decision
|
||||||
|
7. Phase 3 standby design review
|
||||||
|
|
||||||
|
## 11) Benchmark Run Sheet
|
||||||
|
Use the same worksheet for every baseline/re-test so results stay comparable.
|
||||||
|
|
||||||
|
Preconditions: confirm target environment and commit SHA/image tag; confirm Cloudflare cache rules are active when testing TTFB or manifest TTL; confirm chat, admin, and playout services are healthy before load testing.
|
||||||
|
|
||||||
|
Run template:
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
| -------------------------- | ----- |
|
||||||
|
| Date/time | |
|
||||||
|
| Environment | |
|
||||||
|
| Commit SHA / image tag | |
|
||||||
|
| Test scenario | |
|
||||||
|
| Concurrency / sample count | |
|
||||||
|
| Command or tool | |
|
||||||
|
| Raw results location | |
|
||||||
|
| Conclusion | |
|
||||||
|
| Next action | |
|
||||||
|
|
||||||
|
Suggested command mapping:
|
||||||
|
- TTFB baseline: WebPageTest repeat-view run against `https://linearvoid.tv`.
|
||||||
|
- WebSocket profile: `npx artillery run scripts/loadtest/ws-chat.yml` or the Go harness referenced above.
|
||||||
|
- Admin API benchmark: `k6 run scripts/loadtest/admin-api.js` or `wrk -t4 -c100 -d60s http://<host>/api/admin/overview`.
|
||||||
|
|
||||||
|
## 12) Scaling Decision Gate
|
||||||
|
|
||||||
|
Default review gate after any benchmark batch:
|
||||||
|
1. If warm-cache TTFB meets target but cache hit ratio falls below 90%, tune cache behavior before increasing capacity.
|
||||||
|
2. If chat or admin latency exceeds target while CPU and memory remain low, prioritize query or handler optimization.
|
||||||
|
3. If CPU, memory, or Redis saturation appears first, treat the Phase 2 service boundary guidance as the next action.
|
||||||
|
4. If the same threshold trips on multiple runs across 7 days, open the Phase 2 or Phase 3 planning path instead of tuning in place.
|
||||||
|
|
||||||
|
Decision note: keep the smallest change that restores the target metric and record the reason in the run sheet.
|
||||||
|
|
||||||
|
## 13) Artifact-to-Worksheet Field Mapping
|
||||||
|
Use when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
|
||||||
|
|
||||||
|
### WebSocket worksheet mapping
|
||||||
|
Artifact sources: `artifacts/<timestamp>/ws-chat-report.json`, `artifacts/<timestamp>/ws-chat-summary.txt`.
|
||||||
|
|
||||||
|
Field mapping:
|
||||||
|
- `Tool used` → `artillery` (or `scripts/loadtest/run-loadtests.sh -s ws`)
|
||||||
|
- `Raw results location` → artifact folder path for this run
|
||||||
|
- `Concurrency tiers reached` → `ws-chat.yml` phases (`warmup-10`, `sustain-100`, `sustain-500`, `sustain-1000`)
|
||||||
|
- `Error rate` → `vusers.failure_rate_percent` from `ws-chat-summary.txt`
|
||||||
|
- `Dropped connections` → derive from `vusers.created - vusers.completed` (use `vusers.failed` as failure signal)
|
||||||
|
- `p95 message latency` → first latency line under `summary.*` in `ws-chat-summary.txt` containing `p95=...`
|
||||||
|
- `p50 message latency` → first latency line under `summary.*` in `ws-chat-summary.txt` containing `median=...`
|
||||||
|
|
||||||
|
Notes: the exact `summary.*` metric names in Artillery output can vary by engine/plugins; if no latency summary field is emitted, record this explicitly and use server-side latency metrics as fallback evidence.
|
||||||
|
|
||||||
|
### Admin API worksheet mapping
|
||||||
|
Artifact sources: `artifacts/<timestamp>/admin-api-summary.json`, `artifacts/<timestamp>/admin-api-output.txt`, `artifacts/<timestamp>/admin-api-endpoint-summary.txt`.
|
||||||
|
|
||||||
|
Field mapping:
|
||||||
|
- `Tool used` → `k6` (or `scripts/loadtest/run-loadtests.sh -s admin`)
|
||||||
|
- `Raw results location` → artifact folder path for this run
|
||||||
|
- `/api/admin/overview|analytics|chat-log|users` p95 → `overview|analytics|chat_log|users` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||||
|
- `Error rate` → `Global failure rate` in `admin-api-endpoint-summary.txt`
|
||||||
|
- `Slowest endpoint` → endpoint row with highest `P95Ms`
|
||||||
|
|
||||||
|
Notes: keep the raw `admin-api-summary.json` file for audits and re-parsing; if an endpoint row is missing, treat it as a test/data issue and re-run with full endpoint coverage.
|
||||||
|
|
||||||
|
Summary helper (bash): run `scripts/loadtest/summarize-artillery.sh <report.json>` after an Artillery WS run, and `scripts/loadtest/summarize-k6.sh <summary.json>` after a k6 admin run, to produce the artifact files used in the mappings above.
|
||||||
+46
-790
@@ -5,803 +5,59 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Linear Void Admin</title>
|
<title>Linear Void Admin</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="../src/style.css" />
|
<link rel="stylesheet" href="../src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-slate-950 text-slate-200" data-admin-default-moderator="%VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR%" data-admin-default-room="%VITE_PUBLIC_ADMIN_DEFAULT_ROOM%" data-admin-moderation-log-limit="%VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT%">
|
<body class="admin-body">
|
||||||
<div class="flex h-screen" x-data="admin()">
|
<div class="admin-shell">
|
||||||
<!-- Sidebar -->
|
<aside class="admin-sidebar" aria-label="Primary">
|
||||||
<div class="w-64 bg-slate-900 p-6">
|
<div class="brand-block">
|
||||||
<div class="flex items-center gap-3 mb-8">
|
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div>
|
<div class="brand-text">Admin</div>
|
||||||
<div class="text-xl font-bold">Admin</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
|
||||||
<button @click="logout()" class="w-full bg-slate-800 hover:bg-slate-700 border border-slate-700 px-4 py-2 rounded-xl text-sm text-left">Sign out</button>
|
<button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button>
|
||||||
</div>
|
|
||||||
<nav class="space-y-2">
|
<nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical">
|
||||||
<a href="#" @click="currentTab = 'dashboard'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'dashboard' }">Dashboard</a>
|
<button type="button" id="tab-trigger-dashboard" role="tab" class="tab-trigger is-active" data-tab="dashboard" aria-controls="tab-dashboard" aria-selected="true">
|
||||||
<a href="#" @click="currentTab = 'upload'; refreshMediaLibrary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'upload' }">Upload Video</a>
|
Dashboard
|
||||||
<a href="#" @click="currentTab = 'playlist'; refreshPlaylist()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'playlist' }">Playlist</a>
|
</button>
|
||||||
<a href="#" @click="currentTab = 'schedule'; refreshSchedule()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'schedule' }">Schedule</a>
|
<button type="button" id="tab-trigger-upload" role="tab" class="tab-trigger" data-tab="upload" aria-controls="tab-upload" aria-selected="false">
|
||||||
<a href="#" @click="currentTab = 'moderation'; refreshModerationLogs()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'moderation' }">Moderation</a>
|
Upload Video
|
||||||
<a href="#" @click="currentTab = 'users'; refreshUsers()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'users' }">Users</a>
|
</button>
|
||||||
<a href="#" @click="currentTab = 'analytics'; refreshAnalytics()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'analytics' }">Analytics</a>
|
<button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">
|
||||||
<a href="#" @click="currentTab = 'system'; refreshSystemSummary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'system' }">System (Redis)</a>
|
Playlist
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">
|
||||||
|
Schedule
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">
|
||||||
|
Moderation
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-chat-log" role="tab" class="tab-trigger" data-tab="chat-log" aria-controls="tab-chat-log" aria-selected="false">
|
||||||
|
Chat Log
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">
|
||||||
|
Users
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">
|
||||||
|
Analytics
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">
|
||||||
|
System
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="admin-main" id="admin-main" tabindex="-1">
|
||||||
|
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<script type="module" src="/src/admin.ts"></script>
|
||||||
<div class="flex-1 p-8 overflow-auto">
|
|
||||||
<template x-if="currentTab === 'dashboard'">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold mb-8">Dashboard</h1>
|
|
||||||
<div class="grid grid-cols-3 gap-6">
|
|
||||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
|
||||||
<div class="text-emerald-400 text-sm">Concurrent Viewers</div>
|
|
||||||
<div class="text-5xl font-bold mt-2" x-text="stats.viewers"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
|
||||||
<div class="text-blue-400 text-sm">Stored Messages</div>
|
|
||||||
<div class="text-5xl font-bold mt-2" x-text="stats.storedMessages"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
|
||||||
<div class="text-amber-400 text-sm">Moderation Actions</div>
|
|
||||||
<div class="text-5xl font-bold mt-2" x-text="stats.moderationActions"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-6 text-sm text-slate-400" x-text="statusMessage"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="currentTab === 'upload'">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-start justify-between gap-6 mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold">Upload Video</h1>
|
|
||||||
<p class="text-sm text-slate-400 mt-2">Upload media files into the local media library mounted for playout.</p>
|
|
||||||
</div>
|
|
||||||
<button @click="refreshMediaLibrary()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload library</button>
|
|
||||||
</div>
|
|
||||||
<div class="border-2 border-dashed border-slate-600 rounded-3xl p-12 text-center">
|
|
||||||
<input type="file" id="video-upload" class="hidden" accept="video/*,.mp4,.mov,.mkv,.webm" @change="uploadVideo">
|
|
||||||
<button onclick="document.getElementById('video-upload').click()" class="bg-blue-600 hover:bg-blue-500 px-8 py-4 rounded-2xl text-lg font-medium" :disabled="media.uploading">
|
|
||||||
Drag & Drop or Click to Upload
|
|
||||||
</button>
|
|
||||||
<div class="mt-6 text-sm" :class="media.error ? 'text-rose-400' : 'text-slate-400'" x-text="media.status"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-800 rounded-3xl p-6 mt-6">
|
|
||||||
<div class="flex items-center justify-between gap-4 mb-4">
|
|
||||||
<h2 class="text-lg font-semibold">Media Library</h2>
|
|
||||||
<div class="text-xs text-slate-400" x-text="media.generatedAt"></div>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead class="text-slate-400 border-b border-slate-700">
|
|
||||||
<tr>
|
|
||||||
<th class="text-left py-3 pr-4 font-medium">File</th>
|
|
||||||
<th class="text-left py-3 pr-4 font-medium">Size</th>
|
|
||||||
<th class="text-left py-3 font-medium">Modified</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template x-if="media.files.length === 0">
|
|
||||||
<tr>
|
|
||||||
<td colspan="3" class="py-6 text-slate-500">No media files available.</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
<template x-for="file in media.files" :key="file.name">
|
|
||||||
<tr class="border-b border-slate-800">
|
|
||||||
<td class="py-3 pr-4" x-text="file.name"></td>
|
|
||||||
<td class="py-3 pr-4" x-text="file.size"></td>
|
|
||||||
<td class="py-3 font-mono text-xs" x-text="file.modifiedAt"></td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="currentTab === 'schedule'">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-start justify-between gap-6 mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold">Schedule</h1>
|
|
||||||
<p class="text-sm text-slate-400 mt-2">Read-only view of the currently configured playout order.</p>
|
|
||||||
</div>
|
|
||||||
<button @click="refreshSchedule()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload schedule</button>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-800 rounded-3xl p-6">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead class="text-slate-400 border-b border-slate-700">
|
|
||||||
<tr>
|
|
||||||
<th class="text-left py-3 pr-4 font-medium">#</th>
|
|
||||||
<th class="text-left py-3 pr-4 font-medium">Label</th>
|
|
||||||
<th class="text-left py-3 font-medium">Path</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template x-if="schedule.entries.length === 0">
|
|
||||||
<tr>
|
|
||||||
<td colspan="3" class="py-6 text-slate-500">No schedule entries found.</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
<template x-for="entry in schedule.entries" :key="entry.index + ':' + entry.path">
|
|
||||||
<tr class="border-b border-slate-800">
|
|
||||||
<td class="py-3 pr-4" x-text="entry.index"></td>
|
|
||||||
<td class="py-3 pr-4" x-text="entry.label"></td>
|
|
||||||
<td class="py-3 font-mono text-xs" x-text="entry.path"></td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 text-sm" :class="schedule.error ? 'text-rose-400' : 'text-slate-400'" x-text="schedule.status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="currentTab === 'playlist'">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-start justify-between gap-6 mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold">Playlist</h1>
|
|
||||||
<p class="text-sm text-slate-400 mt-2">Edit the active concat playlist used by the playout service.</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button @click="refreshPlaylist()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload</button>
|
|
||||||
<button @click="savePlaylist()" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-xl text-sm font-medium" :disabled="playlist.saving">
|
|
||||||
<span x-text="playlist.saving ? 'Saving...' : 'Save playlist'"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-800 rounded-3xl p-6">
|
|
||||||
<textarea x-model="playlist.content" class="w-full min-h-[24rem] bg-slate-950 border border-slate-700 rounded-2xl p-4 font-mono text-sm focus:outline-none focus:border-blue-500" spellcheck="false"></textarea>
|
|
||||||
<div class="mt-4 text-sm" :class="playlist.error ? 'text-rose-400' : 'text-slate-400'" x-text="playlist.status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="currentTab === 'moderation'">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-start justify-between gap-6 mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold">Moderation</h1>
|
|
||||||
<p class="text-sm text-slate-400 mt-2">Create moderation actions and review the recent immutable audit log.</p>
|
|
||||||
</div>
|
|
||||||
<button @click="refreshModerationLogs()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload logs</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-[24rem,1fr] gap-6">
|
|
||||||
<div class="bg-slate-800 rounded-3xl p-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-4">New Action</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<label class="block text-sm">
|
|
||||||
<span class="block text-slate-400 mb-2">Action</span>
|
|
||||||
<select x-model="moderation.form.action" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500">
|
|
||||||
<option value="mute">mute</option>
|
|
||||||
<option value="ban">ban</option>
|
|
||||||
<option value="delete">delete</option>
|
|
||||||
<option value="timeout">timeout</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="block text-sm">
|
|
||||||
<span class="block text-slate-400 mb-2">Target</span>
|
|
||||||
<input x-model="moderation.form.target" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="username or message id">
|
|
||||||
</label>
|
|
||||||
<label class="block text-sm">
|
|
||||||
<span class="block text-slate-400 mb-2">Moderator</span>
|
|
||||||
<input x-model="moderation.form.moderator" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="admin">
|
|
||||||
</label>
|
|
||||||
<label class="block text-sm">
|
|
||||||
<span class="block text-slate-400 mb-2">Room</span>
|
|
||||||
<input x-model="moderation.form.room" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="main">
|
|
||||||
</label>
|
|
||||||
<label class="block text-sm">
|
|
||||||
<span class="block text-slate-400 mb-2">Reason</span>
|
|
||||||
<textarea x-model="moderation.form.reason" class="w-full min-h-28 bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="Explain why this action is needed"></textarea>
|
|
||||||
</label>
|
|
||||||
<button @click="submitModerationAction()" class="w-full bg-blue-600 hover:bg-blue-500 px-4 py-3 rounded-xl font-medium" :disabled="moderation.submitting">
|
|
||||||
<span x-text="moderation.submitting ? 'Submitting...' : 'Submit action'"></span>
|
|
||||||
</button>
|
|
||||||
<div class="text-sm" :class="moderation.error ? 'text-rose-400' : 'text-slate-400'" x-text="moderation.status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-slate-800 rounded-3xl p-6 overflow-hidden">
|
|
||||||
<div class="flex items-center justify-between gap-4 mb-4">
|
|
||||||
<h2 class="text-lg font-semibold">Recent Audit Log</h2>
|
|
||||||
<div class="text-xs text-slate-400">Latest 50 actions</div>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead class="text-slate-400 border-b border-slate-700">
|
|
||||||
<tr>
|
|
||||||
<th class="text-left py-3 pr-4 font-medium">When</th>
|
|
||||||
<th class="text-left py-3 pr-4 font-medium">Action</th>
|
|
||||||
<th class="text-left py-3 pr-4 font-medium">Target</th>
|
|
||||||
<th class="text-left py-3 pr-4 font-medium">Moderator</th>
|
|
||||||
<th class="text-left py-3 font-medium">Reason</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template x-if="moderation.logs.length === 0">
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="py-6 text-slate-500">No moderation actions logged yet.</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
<template x-for="entry in moderation.logs" :key="entry.id">
|
|
||||||
<tr class="border-b border-slate-800 align-top">
|
|
||||||
<td class="py-3 pr-4 font-mono text-xs" x-text="entry.timestamp"></td>
|
|
||||||
<td class="py-3 pr-4" x-text="entry.action"></td>
|
|
||||||
<td class="py-3 pr-4" x-text="entry.target"></td>
|
|
||||||
<td class="py-3 pr-4" x-text="entry.moderator"></td>
|
|
||||||
<td class="py-3" x-text="entry.reason"></td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="currentTab === 'analytics'">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-start justify-between gap-6 mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold">Analytics</h1>
|
|
||||||
<p class="text-sm text-slate-400 mt-2">Basic activity counters from chat history and moderation audit logs.</p>
|
|
||||||
</div>
|
|
||||||
<button @click="refreshAnalytics()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload analytics</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
|
||||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
|
||||||
<div class="text-slate-400 text-sm">Total Messages</div>
|
|
||||||
<div class="text-4xl font-bold mt-2" x-text="analytics.totalMessages"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
|
||||||
<div class="text-slate-400 text-sm">Last 24 Hours</div>
|
|
||||||
<div class="text-4xl font-bold mt-2" x-text="analytics.messagesLast24Hours"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
|
||||||
<div class="text-slate-400 text-sm">Distinct Rooms</div>
|
|
||||||
<div class="text-4xl font-bold mt-2" x-text="analytics.distinctRooms"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
|
||||||
<div class="text-slate-400 text-sm">Moderation Actions</div>
|
|
||||||
<div class="text-4xl font-bold mt-2" x-text="analytics.moderationActions"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-slate-800 rounded-3xl p-6">
|
|
||||||
<div class="flex items-center justify-between gap-4 mb-4">
|
|
||||||
<h2 class="text-lg font-semibold">Top Rooms</h2>
|
|
||||||
<div class="text-xs text-slate-400" x-text="analytics.generatedAt"></div>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead class="text-slate-400 border-b border-slate-700">
|
|
||||||
<tr>
|
|
||||||
<th class="text-left py-3 pr-4 font-medium">Room</th>
|
|
||||||
<th class="text-left py-3 font-medium">Messages</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template x-if="analytics.topRooms.length === 0">
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" class="py-6 text-slate-500">No room analytics available yet.</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
<template x-for="room in analytics.topRooms" :key="room.room">
|
|
||||||
<tr class="border-b border-slate-800">
|
|
||||||
<td class="py-3 pr-4" x-text="room.room"></td>
|
|
||||||
<td class="py-3" x-text="room.messageCount"></td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 text-sm" :class="analytics.error ? 'text-rose-400' : 'text-slate-400'" x-text="analytics.status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="currentTab === 'system'">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold mb-8">System Configuration (Redis & Storage)</h1>
|
|
||||||
<div class="bg-slate-800 p-8 rounded-3xl">
|
|
||||||
<div class="grid grid-cols-2 gap-8">
|
|
||||||
<div>
|
|
||||||
<h3 class="font-medium mb-4">Redis Stats</h3>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span>Memory Used</span>
|
|
||||||
<span class="font-mono" x-text="system.redisMemoryUsed"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span>Connected Redis Clients</span>
|
|
||||||
<span class="font-mono" x-text="system.redisClients"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span>Redis Enabled</span>
|
|
||||||
<span class="font-mono" x-text="system.redisEnabled ? 'yes' : 'no'"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-medium mb-4">Storage</h3>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span>Database Driver</span>
|
|
||||||
<span class="font-mono" x-text="system.databaseDriver"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span>Stored Messages</span>
|
|
||||||
<span class="font-mono" x-text="stats.storedMessages"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span>Last Refresh</span>
|
|
||||||
<span class="font-mono" x-text="system.generatedAt"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span>Playlist Path</span>
|
|
||||||
<span class="font-mono text-right" x-text="system.playlistPath"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span>Playlist Present</span>
|
|
||||||
<span class="font-mono" x-text="system.playlistExists ? 'yes' : 'no'"></span>
|
|
||||||
</div>
|
|
||||||
<button onclick="runVacuum()" class="bg-slate-700 hover:bg-slate-600 px-6 py-2 rounded-xl">Run VACUUM</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-800 p-8 rounded-3xl mt-6">
|
|
||||||
<div class="flex items-center justify-between gap-4 mb-4">
|
|
||||||
<h2 class="text-lg font-semibold">Service Health</h2>
|
|
||||||
<button @click="refreshSystemSummary()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload health</button>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<template x-for="service in system.services" :key="service.name">
|
|
||||||
<div class="flex items-start justify-between gap-6 border-b border-slate-700 pb-4">
|
|
||||||
<div>
|
|
||||||
<div class="font-medium" x-text="service.name"></div>
|
|
||||||
<div class="text-xs text-slate-400 mt-1" x-text="service.checked"></div>
|
|
||||||
</div>
|
|
||||||
<div class="text-right max-w-xl">
|
|
||||||
<div class="uppercase tracking-wide text-xs" :class="service.status === 'healthy' ? 'text-emerald-400' : service.status === 'disabled' ? 'text-amber-400' : 'text-rose-400'" x-text="service.status"></div>
|
|
||||||
<div class="text-sm text-slate-300 mt-1 break-all" x-text="service.detail"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 text-sm" :class="system.error ? 'text-rose-400' : 'text-slate-400'" x-text="system.status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="currentTab === 'users'">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-start justify-between gap-6 mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold">Users</h1>
|
|
||||||
<p class="text-sm text-slate-400 mt-2">Current room presence derived from Redis presence keys.</p>
|
|
||||||
</div>
|
|
||||||
<button @click="refreshUsers()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload users</button>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-800 rounded-3xl p-6">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead class="text-slate-400 border-b border-slate-700">
|
|
||||||
<tr>
|
|
||||||
<th class="text-left py-3 pr-4 font-medium">Username</th>
|
|
||||||
<th class="text-left py-3 pr-4 font-medium">Room</th>
|
|
||||||
<th class="text-left py-3 font-medium">Online</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template x-if="users.entries.length === 0">
|
|
||||||
<tr>
|
|
||||||
<td colspan="3" class="py-6 text-slate-500">No active users reported.</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
<template x-for="entry in users.entries" :key="entry.room + ':' + entry.username">
|
|
||||||
<tr class="border-b border-slate-800">
|
|
||||||
<td class="py-3 pr-4" x-text="entry.username"></td>
|
|
||||||
<td class="py-3 pr-4" x-text="entry.room"></td>
|
|
||||||
<td class="py-3" x-text="entry.online ? 'yes' : 'no'"></td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 text-sm" :class="users.error ? 'text-rose-400' : 'text-slate-400'" x-text="users.status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const adminConfig = (() => {
|
|
||||||
const { dataset } = document.body;
|
|
||||||
const moderationLogLimit = Number.parseInt(dataset.adminModerationLogLimit || '', 10);
|
|
||||||
|
|
||||||
return {
|
|
||||||
defaultModerator: dataset.adminDefaultModerator || 'admin',
|
|
||||||
defaultRoom: dataset.adminDefaultRoom || 'main',
|
|
||||||
moderationLogLimit: Number.isFinite(moderationLogLimit) && moderationLogLimit > 0 ? moderationLogLimit : 50
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
function admin() {
|
|
||||||
return {
|
|
||||||
currentTab: 'dashboard',
|
|
||||||
stats: {
|
|
||||||
viewers: 0,
|
|
||||||
storedMessages: 0,
|
|
||||||
moderationActions: 0
|
|
||||||
},
|
|
||||||
system: {
|
|
||||||
redisEnabled: false,
|
|
||||||
redisClients: 0,
|
|
||||||
redisMemoryUsed: 'loading...',
|
|
||||||
databaseDriver: 'loading...',
|
|
||||||
generatedAt: 'loading...',
|
|
||||||
playlistPath: 'loading...',
|
|
||||||
playlistExists: false,
|
|
||||||
services: [],
|
|
||||||
status: 'Loading system summary...',
|
|
||||||
error: false
|
|
||||||
},
|
|
||||||
analytics: {
|
|
||||||
totalMessages: 0,
|
|
||||||
messagesLast24Hours: 0,
|
|
||||||
distinctRooms: 0,
|
|
||||||
moderationActions: 0,
|
|
||||||
topRooms: [],
|
|
||||||
generatedAt: 'loading...',
|
|
||||||
status: 'Loading analytics...',
|
|
||||||
error: false
|
|
||||||
},
|
|
||||||
schedule: {
|
|
||||||
entries: [],
|
|
||||||
status: 'Loading schedule...',
|
|
||||||
error: false
|
|
||||||
},
|
|
||||||
playlist: {
|
|
||||||
content: '',
|
|
||||||
status: 'Loading playlist...',
|
|
||||||
saving: false,
|
|
||||||
error: false
|
|
||||||
},
|
|
||||||
media: {
|
|
||||||
files: [],
|
|
||||||
generatedAt: 'loading...',
|
|
||||||
uploading: false,
|
|
||||||
status: 'Loading media library...',
|
|
||||||
error: false
|
|
||||||
},
|
|
||||||
moderation: {
|
|
||||||
logs: [],
|
|
||||||
submitting: false,
|
|
||||||
error: false,
|
|
||||||
status: 'Loading moderation logs...',
|
|
||||||
form: {
|
|
||||||
action: 'mute',
|
|
||||||
target: '',
|
|
||||||
moderator: adminConfig.defaultModerator,
|
|
||||||
reason: '',
|
|
||||||
room: adminConfig.defaultRoom
|
|
||||||
}
|
|
||||||
},
|
|
||||||
users: {
|
|
||||||
entries: [],
|
|
||||||
status: 'Loading active users...',
|
|
||||||
error: false
|
|
||||||
},
|
|
||||||
statusMessage: 'Loading live admin overview...',
|
|
||||||
async refreshOverview() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/overview');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const overview = await response.json();
|
|
||||||
this.stats.viewers = overview.viewers;
|
|
||||||
this.stats.storedMessages = overview.storedMessages;
|
|
||||||
this.stats.moderationActions = overview.moderationActions;
|
|
||||||
this.system.redisEnabled = overview.redisEnabled;
|
|
||||||
this.system.redisClients = overview.redisClients;
|
|
||||||
this.system.redisMemoryUsed = overview.redisMemoryUsed;
|
|
||||||
this.system.databaseDriver = overview.databaseDriver;
|
|
||||||
this.system.generatedAt = overview.generatedAt;
|
|
||||||
this.statusMessage = `Live data loaded at ${overview.generatedAt}`;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load admin overview', error);
|
|
||||||
this.statusMessage = 'Failed to load live admin data.';
|
|
||||||
this.system.redisMemoryUsed = 'unavailable';
|
|
||||||
this.system.databaseDriver = 'unavailable';
|
|
||||||
this.system.generatedAt = 'unavailable';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async refreshAnalytics() {
|
|
||||||
this.analytics.status = 'Loading analytics...';
|
|
||||||
this.analytics.error = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/analytics');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
this.analytics.totalMessages = payload.totalMessages;
|
|
||||||
this.analytics.messagesLast24Hours = payload.messagesLast24Hours;
|
|
||||||
this.analytics.distinctRooms = payload.distinctRooms;
|
|
||||||
this.analytics.moderationActions = payload.moderationActions;
|
|
||||||
this.analytics.topRooms = payload.topRooms || [];
|
|
||||||
this.analytics.generatedAt = payload.generatedAt;
|
|
||||||
this.analytics.status = 'Analytics loaded.';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load analytics', error);
|
|
||||||
this.analytics.status = 'Failed to load analytics.';
|
|
||||||
this.analytics.error = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async refreshPlaylist() {
|
|
||||||
this.playlist.status = 'Loading playlist...';
|
|
||||||
this.playlist.error = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/playlist');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
this.playlist.content = payload.content;
|
|
||||||
this.playlist.status = 'Playlist loaded.';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load playlist', error);
|
|
||||||
this.playlist.status = 'Failed to load playlist.';
|
|
||||||
this.playlist.error = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async refreshSchedule() {
|
|
||||||
this.schedule.status = 'Loading schedule...';
|
|
||||||
this.schedule.error = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/schedule');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
this.schedule.entries = payload.entries || [];
|
|
||||||
this.schedule.status = 'Schedule loaded.';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load schedule', error);
|
|
||||||
this.schedule.status = 'Failed to load schedule.';
|
|
||||||
this.schedule.error = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async refreshSystemSummary() {
|
|
||||||
this.system.status = 'Loading system summary...';
|
|
||||||
this.system.error = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/system');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
this.system.databaseDriver = payload.databaseDriver;
|
|
||||||
this.system.playlistPath = payload.playlistPath;
|
|
||||||
this.system.playlistExists = payload.playlistExists;
|
|
||||||
this.system.redisEnabled = payload.redisEnabled;
|
|
||||||
this.system.redisMemoryUsed = payload.redisMemoryUsed;
|
|
||||||
this.system.services = payload.services || [];
|
|
||||||
this.system.generatedAt = payload.generatedAt;
|
|
||||||
this.system.status = 'System summary loaded.';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load system summary', error);
|
|
||||||
this.system.status = 'Failed to load system summary.';
|
|
||||||
this.system.error = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async refreshMediaLibrary() {
|
|
||||||
this.media.status = 'Loading media library...';
|
|
||||||
this.media.error = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/media');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
this.media.files = payload.files || [];
|
|
||||||
this.media.generatedAt = payload.generatedAt;
|
|
||||||
this.media.status = 'Media library loaded.';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load media library', error);
|
|
||||||
this.media.status = 'Failed to load media library.';
|
|
||||||
this.media.error = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async savePlaylist() {
|
|
||||||
this.playlist.saving = true;
|
|
||||||
this.playlist.status = 'Saving playlist...';
|
|
||||||
this.playlist.error = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/playlist', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ content: this.playlist.content })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
this.playlist.content = payload.content;
|
|
||||||
this.playlist.status = 'Playlist saved.';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save playlist', error);
|
|
||||||
this.playlist.status = 'Failed to save playlist.';
|
|
||||||
this.playlist.error = true;
|
|
||||||
} finally {
|
|
||||||
this.playlist.saving = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async refreshModerationLogs() {
|
|
||||||
this.moderation.status = 'Loading moderation logs...';
|
|
||||||
this.moderation.error = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
this.moderation.logs = payload.logs || [];
|
|
||||||
this.moderation.status = 'Moderation logs loaded.';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load moderation logs', error);
|
|
||||||
this.moderation.status = 'Failed to load moderation logs.';
|
|
||||||
this.moderation.error = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async submitModerationAction() {
|
|
||||||
this.moderation.submitting = true;
|
|
||||||
this.moderation.error = false;
|
|
||||||
this.moderation.status = 'Submitting moderation action...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/moderation/actions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(this.moderation.form)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.moderation.status = 'Moderation action recorded.';
|
|
||||||
this.moderation.form.target = '';
|
|
||||||
this.moderation.form.reason = '';
|
|
||||||
await this.refreshModerationLogs();
|
|
||||||
await this.refreshOverview();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to submit moderation action', error);
|
|
||||||
this.moderation.status = 'Failed to submit moderation action.';
|
|
||||||
this.moderation.error = true;
|
|
||||||
} finally {
|
|
||||||
this.moderation.submitting = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async refreshUsers() {
|
|
||||||
this.users.status = 'Loading active users...';
|
|
||||||
this.users.error = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/users');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
this.users.entries = payload.users || [];
|
|
||||||
this.users.status = payload.redisEnabled ? 'Active users loaded.' : 'Redis disabled; no live presence data available.';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load users', error);
|
|
||||||
this.users.status = 'Failed to load active users.';
|
|
||||||
this.users.error = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async logout() {
|
|
||||||
try {
|
|
||||||
await fetch('/api/admin/logout', { method: 'POST' });
|
|
||||||
} finally {
|
|
||||||
window.location.href = '/admin/login/';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.refreshOverview();
|
|
||||||
this.refreshAnalytics();
|
|
||||||
this.refreshMediaLibrary();
|
|
||||||
this.refreshPlaylist();
|
|
||||||
this.refreshSchedule();
|
|
||||||
this.refreshModerationLogs();
|
|
||||||
this.refreshSystemSummary();
|
|
||||||
this.refreshUsers();
|
|
||||||
console.log('Admin Interface initialized (ADR-004/ADR-005)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadVideo(event) {
|
|
||||||
const file = event.target.files[0];
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = Alpine.$data(document.querySelector('[x-data="admin()"]'));
|
|
||||||
component.media.uploading = true;
|
|
||||||
component.media.error = false;
|
|
||||||
component.media.status = `Uploading ${file.name}...`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const response = await fetch('/api/admin/media', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
component.media.files = payload.files || [];
|
|
||||||
component.media.generatedAt = payload.generatedAt;
|
|
||||||
component.media.status = `${file.name} uploaded.`;
|
|
||||||
document.getElementById('video-upload').value = '';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to upload media file', error);
|
|
||||||
component.media.status = 'Failed to upload media file.';
|
|
||||||
component.media.error = true;
|
|
||||||
} finally {
|
|
||||||
component.media.uploading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runVacuum() {
|
|
||||||
alert('VACUUM executed on SQLite database (per ADR-006). Audit log entry created.');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -5,61 +5,61 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Linear Void Admin Login</title>
|
<title>Linear Void Admin Login</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="../../src/style.css" />
|
<link rel="stylesheet" href="../../src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center px-6">
|
<body class="auth-body">
|
||||||
<main class="w-full max-w-md bg-slate-900 border border-slate-800 rounded-[2rem] shadow-2xl p-8">
|
<main class="auth-shell" aria-label="Admin authentication">
|
||||||
<div class="mb-8">
|
<section class="auth-card" aria-labelledby="auth-title">
|
||||||
<div class="text-xs uppercase tracking-[0.3em] text-slate-500 mb-3">Linear Void</div>
|
<p class="auth-kicker">Linear Void</p>
|
||||||
<h1 class="text-4xl font-semibold text-white mb-2">Admin Login</h1>
|
<h1 id="auth-title">Admin Login</h1>
|
||||||
<p class="text-sm text-slate-400">Authenticate to access protected admin controls.</p>
|
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="login-form" class="space-y-4">
|
<form id="login-form" class="form-stack" novalidate>
|
||||||
<label class="block text-sm">
|
<label for="username">Username</label>
|
||||||
<span class="block text-slate-400 mb-2">Username</span>
|
<input id="username" type="text" class="text-input" autocomplete="username" required />
|
||||||
<input id="username" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" autocomplete="username" required />
|
|
||||||
</label>
|
<label for="password">Password</label>
|
||||||
<label class="block text-sm">
|
<input id="password" type="password" class="text-input" autocomplete="current-password" required />
|
||||||
<span class="block text-slate-400 mb-2">Password</span>
|
|
||||||
<input id="password" type="password" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" autocomplete="current-password" required />
|
<button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
|
||||||
</label>
|
<p id="status" class="status-line" aria-live="polite"></p>
|
||||||
<button id="submit" type="submit" class="w-full bg-blue-600 hover:bg-blue-500 px-4 py-3 rounded-xl font-medium text-white">Sign in</button>
|
|
||||||
<div id="status" class="text-sm text-slate-400 min-h-6"></div>
|
|
||||||
</form>
|
</form>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const form = document.getElementById('login-form');
|
const form = document.getElementById("login-form");
|
||||||
const username = document.getElementById('username');
|
const username = document.getElementById("username");
|
||||||
const password = document.getElementById('password');
|
const password = document.getElementById("password");
|
||||||
const submit = document.getElementById('submit');
|
const submit = document.getElementById("submit");
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById("status");
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
form.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
status.textContent = 'Signing in...';
|
status.textContent = "Signing in...";
|
||||||
status.className = 'text-sm text-slate-400 min-h-6';
|
status.className = "status-line";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/login', {
|
const response = await fetch("/api/admin/login", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username: username.value, password: password.value })
|
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = '/admin/';
|
window.location.href = "/admin/";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to sign in', error);
|
console.error("Failed to sign in", error);
|
||||||
status.textContent = 'Login failed. Check your credentials.';
|
status.textContent = "Login failed. Check your credentials.";
|
||||||
status.className = 'text-sm text-rose-400 min-h-6';
|
status.className = "status-line status-error";
|
||||||
} finally {
|
} finally {
|
||||||
submit.disabled = false;
|
submit.disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-54
@@ -5,77 +5,117 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/src/style.css" />
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-slate-950 text-slate-200">
|
<body class="lv-body">
|
||||||
<div class="flex h-screen">
|
<main class="viewer-shell" aria-label="Live player and chat">
|
||||||
<!-- Video Player -->
|
<header class="viewer-header">
|
||||||
<div class="flex-1 flex flex-col">
|
<div class="viewer-brand-group">
|
||||||
<div class="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900">
|
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-white">Linear Void</h1>
|
<h1 class="viewer-title">Linear Void</h1>
|
||||||
<p class="text-xs text-emerald-400">24/7 Synchronized Channel • Live</p>
|
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="now-playing px-4 py-1 rounded-full text-sm font-medium flex items-center gap-2">
|
|
||||||
<div class="w-2 h-2 bg-emerald-400 rounded-full animate-pulse"></div>
|
|
||||||
<span id="now-playing">Loading schedule...</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">
|
||||||
|
Hide Chat
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="flex-1 flex items-center justify-center p-8 bg-black relative">
|
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||||
<video id="player" class="max-h-full rounded-xl shadow-2xl" controls autoplay muted playsinline></video>
|
<section class="player-stage" aria-label="Video stage">
|
||||||
<div id="now-playing-overlay" class="absolute bottom-8 left-8 bg-black/70 backdrop-blur-md px-6 py-3 rounded-2xl text-sm hidden">
|
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||||
<div class="flex items-center gap-3">
|
<div id="now-playing" class="now-playing-overlay" aria-live="polite">
|
||||||
<div class="text-emerald-400">● LIVE</div>
|
<span id="np-title">Loading...</span>
|
||||||
<div id="overlay-title" class="font-medium"></div>
|
<span id="np-next" class="np-next"></span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="p-4 bg-slate-900 border-t border-slate-800 text-xs text-slate-400 flex justify-between">
|
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||||
<div>Latency: ~25s • Synced for all viewers (per ADR-001)</div>
|
<header class="chat-header">
|
||||||
<div class="flex gap-6">
|
<div class="chat-room-title">
|
||||||
<a href="#" onclick="switchRoom('main')" class="hover:text-white">Main Channel</a>
|
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||||
<a href="#" onclick="switchRoom('chill')" class="hover:text-white">Chill Room</a>
|
|
||||||
<a href="#" onclick="switchRoom('talk')" class="hover:text-white">Talk Room</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chat Sidebar -->
|
|
||||||
<div class="w-96 flex flex-col border-l border-slate-800 bg-slate-900 chat-sidebar">
|
|
||||||
<div class="p-4 border-b border-slate-700 font-medium flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-emerald-400">●</span>
|
|
||||||
<span id="room-name">Main Channel</span>
|
<span id="room-name">Main Channel</span>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="togglePrivateDM()" class="text-xs px-3 py-1 bg-slate-700 hover:bg-slate-600 rounded-lg">New DM</button>
|
<div class="chat-header-actions">
|
||||||
|
<span id="chat-user-label" class="chat-user-label" hidden></span>
|
||||||
|
<button id="auth-toggle-btn" type="button" class="btn btn-secondary">Sign in</button>
|
||||||
|
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- Messages -->
|
<div id="auth-panel" class="auth-modal" hidden>
|
||||||
<div id="messages" class="flex-1 p-4 overflow-y-auto space-y-4 text-sm">
|
<div id="auth-backdrop" class="auth-modal-backdrop"></div>
|
||||||
<!-- Messages populated by JS -->
|
<div class="auth-modal-content">
|
||||||
</div>
|
<button id="auth-close-btn" type="button" class="btn auth-close" aria-label="Close sign in">×</button>
|
||||||
|
<h2 class="auth-modal-title">Sign in</h2>
|
||||||
<!-- Input -->
|
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
|
||||||
<div class="p-4 border-t border-slate-700">
|
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
|
||||||
<div class="flex gap-2">
|
<div class="button-row">
|
||||||
<input id="message-input" type="text" placeholder="Type a message..." class="input flex-1 px-4 py-3 rounded-2xl text-sm focus:outline-none" onkeydown="if(event.key === 'Enter') sendMessage()">
|
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
|
||||||
<button onclick="sendMessage()" class="bg-blue-600 hover:bg-blue-500 px-6 rounded-2xl text-sm font-medium">Send</button>
|
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
|
||||||
</div>
|
|
||||||
<div class="text-[10px] text-slate-500 mt-2 text-center">
|
|
||||||
Synced with video • Moderation active
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p id="auth-status" class="status-line" aria-live="polite"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
|
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
||||||
|
|
||||||
|
<section id="presence-panel" class="presence-panel">
|
||||||
|
<div id="presence-list" class="presence-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="chat-input-area">
|
||||||
|
<div class="chat-input-row">
|
||||||
|
<label for="message-input" class="sr-only">Message input</label>
|
||||||
|
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if (event.key === 'Enter') sendMessage();" />
|
||||||
|
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||||
|
</div>
|
||||||
|
<p class="chat-note">Synced with video • Moderation active</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
||||||
|
<div class="reconnecting-spinner"></div>
|
||||||
|
<span id="reconnecting-text">Reconnecting...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// Load Matomo analytics when configured via the admin runtime config.
|
||||||
|
fetch("/api/matomo")
|
||||||
|
.then(function (r) {
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
})
|
||||||
|
.then(function (cfg) {
|
||||||
|
if (!cfg || !cfg.matomoUrl || !cfg.matomoSiteId) return;
|
||||||
|
var _paq = (window._paq = window._paq || []);
|
||||||
|
_paq.push(["trackPageView"]);
|
||||||
|
_paq.push(["enableLinkTracking"]);
|
||||||
|
var base = String(cfg.matomoUrl).replace(/\/+$/, "");
|
||||||
|
_paq.push(["setTrackerUrl", base + "/matomo.php"]);
|
||||||
|
_paq.push(["setSiteId", String(cfg.matomoSiteId)]);
|
||||||
|
var d = document;
|
||||||
|
var g = d.createElement("script");
|
||||||
|
var s = d.getElementsByTagName("script")[0];
|
||||||
|
g.async = true;
|
||||||
|
g.src = base + "/matomo.js";
|
||||||
|
s.parentNode.insertBefore(g, s);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
/* analytics is optional */
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
Generated
+1217
-207
File diff suppressed because it is too large.
Load diff
+15
-8
@@ -1,22 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
|
"@vitest/ui": "^4.1.10",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.2.0"
|
"vite": "^8.2.0",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"alpinejs": "^3.15.12",
|
"hls.js": "^1.6.16"
|
||||||
"autoprefixer": "^10.5.4",
|
|
||||||
"hls.js": "^1.6.16",
|
|
||||||
"postcss": "^8.5.25",
|
|
||||||
"tailwindcss": "^4.3.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<div class="panel-header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Analytics</h1>
|
||||||
|
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-grid">
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Total Messages</h2>
|
||||||
|
<p id="analytics-total-messages" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Last 24 Hours</h2>
|
||||||
|
<p id="analytics-messages-24h" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Distinct Rooms</h2>
|
||||||
|
<p id="analytics-distinct-rooms" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Moderation Actions</h2>
|
||||||
|
<p id="analytics-moderation-actions" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<div class="panel-header-row">
|
||||||
|
<h2>Top Rooms</h2>
|
||||||
|
<p id="analytics-generated-at" class="small-muted">loading...</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Room</th>
|
||||||
|
<th>Messages</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="analytics-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="analytics-status" class="status-line" aria-live="polite">Loading analytics...</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<div class="panel-header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Chat Log</h1>
|
||||||
|
<p>Browse and manage stored chat messages.</p>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="reload-chat-log-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||||
|
<button id="bulk-delete-chat-log-btn" type="button" class="btn btn-danger" disabled>Delete Selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<div class="chat-log-filters">
|
||||||
|
<input id="chat-log-filter-room" class="text-input" type="text" placeholder="Room" />
|
||||||
|
<input id="chat-log-filter-username" class="text-input" type="text" placeholder="Username" />
|
||||||
|
<input id="chat-log-filter-text" class="text-input" type="text" placeholder="Message text" />
|
||||||
|
<button id="chat-log-filter-btn" type="button" class="btn btn-secondary">Filter</button>
|
||||||
|
<button id="chat-log-clear-filter-btn" type="button" class="btn btn-secondary">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table chat-log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><input type="checkbox" id="chat-log-select-all" title="Select all" /></th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Room</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="chat-log-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="chat-log-pagination">
|
||||||
|
<button id="chat-log-prev-btn" type="button" class="btn btn-secondary" disabled>Previous</button>
|
||||||
|
<span id="chat-log-page-info" class="small-muted">Page 1</span>
|
||||||
|
<button id="chat-log-next-btn" type="button" class="btn btn-secondary" disabled>Next</button>
|
||||||
|
</div>
|
||||||
|
<p id="chat-log-status" class="status-line" aria-live="polite">Loading chat log...</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Concurrent Viewers</h2>
|
||||||
|
<p id="stat-viewers" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Stored Messages</h2>
|
||||||
|
<p id="stat-stored-messages" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Moderation Actions</h2>
|
||||||
|
<p id="stat-moderation-actions" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Media Runtime</h2>
|
||||||
|
<p id="stat-media-runtime" class="stat-value">00:00:00</p>
|
||||||
|
<p id="stat-media-count" class="stat-caption">0 files</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p id="overview-status" class="status-line" aria-live="polite">Loading live admin overview...</p>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<div class="panel-header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Moderation</h1>
|
||||||
|
<p>Create moderation actions and review the recent immutable audit log.</p>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<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 class="moderation-grid">
|
||||||
|
<section class="panel-card">
|
||||||
|
<h2>New Action</h2>
|
||||||
|
<form id="moderation-form" class="form-stack">
|
||||||
|
<label
|
||||||
|
>Action
|
||||||
|
<select id="mod-action" class="text-input">
|
||||||
|
<option value="mute">mute</option>
|
||||||
|
<option value="ban">ban</option>
|
||||||
|
<option value="delete">delete</option>
|
||||||
|
<option value="timeout">timeout</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
>Target
|
||||||
|
<input id="mod-target" class="text-input" type="text" placeholder="username or message id" required />
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
>Moderator
|
||||||
|
<input id="mod-moderator" class="text-input" type="text" placeholder="admin" />
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
>Room
|
||||||
|
<input id="mod-room" class="text-input" type="text" placeholder="main" />
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
>Reason
|
||||||
|
<textarea
|
||||||
|
id="mod-reason"
|
||||||
|
class="text-input text-area"
|
||||||
|
placeholder="Explain why this action is needed"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
<button id="mod-submit" type="submit" class="btn btn-primary">Submit action</button>
|
||||||
|
<p id="moderation-status" class="status-line" aria-live="polite">Loading moderation logs...</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel-card">
|
||||||
|
<div class="panel-header-row">
|
||||||
|
<h2>Recent Audit Log</h2>
|
||||||
|
<p class="small-muted">Latest entries</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Moderator</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="moderation-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<div class="panel-header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Playlist</h1>
|
||||||
|
<p>Edit the active concat playlist. Drag entries to reorder.</p>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||||
|
<button id="save-playlist-btn" type="button" class="btn btn-primary">Save playlist</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<div id="playlist-warnings" class="status-warnings" hidden></div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<div class="panel-header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Schedule</h1>
|
||||||
|
<p>Read-only view of the currently configured playout order.</p>
|
||||||
|
</div>
|
||||||
|
<button id="reload-schedule-btn" type="button" class="btn btn-secondary">Reload schedule</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<p class="small-muted">
|
||||||
|
<span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime
|
||||||
|
<span id="schedule-total-runtime">00:00:00</span>
|
||||||
|
</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Runtime</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Path</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="schedule-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
|
||||||
|
</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>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<h1>System Configuration</h1>
|
||||||
|
<div class="panel-card split-grid">
|
||||||
|
<section>
|
||||||
|
<h2>Redis Stats</h2>
|
||||||
|
<dl class="kv-list">
|
||||||
|
<div>
|
||||||
|
<dt>Memory Used</dt>
|
||||||
|
<dd id="system-redis-memory">loading...</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Connected Clients</dt>
|
||||||
|
<dd id="system-redis-clients">0</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Redis Enabled</dt>
|
||||||
|
<dd id="system-redis-enabled">no</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Storage</h2>
|
||||||
|
<dl class="kv-list">
|
||||||
|
<div>
|
||||||
|
<dt>Database Driver</dt>
|
||||||
|
<dd id="system-db-driver">loading...</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Stored Messages</dt>
|
||||||
|
<dd id="system-stored-messages">0</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Last Refresh</dt>
|
||||||
|
<dd id="system-generated-at">loading...</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Playlist Path</dt>
|
||||||
|
<dd id="system-playlist-path">loading...</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Playlist Present</dt>
|
||||||
|
<dd id="system-playlist-present">no</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<div class="panel-header-row">
|
||||||
|
<h2>Service Health</h2>
|
||||||
|
<button id="reload-system-btn" type="button" class="btn btn-secondary">Reload health</button>
|
||||||
|
</div>
|
||||||
|
<div id="system-services" class="service-list"></div>
|
||||||
|
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<h2>Runtime Configuration</h2>
|
||||||
|
<form id="config-form" class="form-stack" style="max-width: 400px">
|
||||||
|
<label>HLS Segment Duration (seconds) <input id="config-hls-duration" type="number" min="2" max="15" class="text-input" /></label>
|
||||||
|
<label>HLS Window Size (segments) <input id="config-hls-window" type="number" min="3" max="100" class="text-input" /></label>
|
||||||
|
<label>Matomo URL <input id="config-matomo-url" type="url" class="text-input" placeholder="https://..." /></label>
|
||||||
|
<label>Matomo Site ID <input id="config-matomo-site-id" type="text" class="text-input" /></label>
|
||||||
|
<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>
|
||||||
|
<p class="small-muted">HLS changes take effect after restarting the playout service.</p>
|
||||||
|
<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>
|
||||||
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user