diff --git a/.editorconfig b/.editorconfig
index dbd6792..2b55f80 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,55 +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
+# 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
diff --git a/.env.example b/.env.example
index 1158a72..957c321 100644
--- a/.env.example
+++ b/.env.example
@@ -1,73 +1,73 @@
-# Environment variables for Linear Void
-# Copy to .env and fill in real values for local/development use.
-# Production secrets should be managed via Docker secrets, Cloudflare, or Hetzner environment.
-
-# Cloudflare R2 (S3-compatible)
-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
-R2_KEEP_RUNS=2
-R2_MAX_RETENTION_BYTES=10737418240
-
-# Cloudflare (for custom domain, cache rules, Workers if used)
-CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
-CLOUDFLARE_ACCOUNT_ID=your-account-id
-MEDIA_DOMAIN=media.linearvoid.tv
-
-# Public frontend URLs (Vite reads these from the repository root via frontend/vite.config.ts)
-# Preferred local setup in this repo uses the real public domains through Cloudflare/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 / Application Database (compose-safe PostgreSQL by default, SQLite optional)
-DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
-# Or for SQLite-only local development:
-# DATABASE_URL=sqlite://./data/chat.db
-
-# Redis (for presence, rate limiting, pub/sub)
-REDIS_URL=redis://redis:6379/0
-
-# Service Ports (for local docker-compose)
-PLAYOUT_PORT=8080
-CHAT_PORT=3000
-NGINX_PORT=80
-
-# Playout configuration
-PLAYLIST_PATH=/app/playlist/playlist.txt
-HLS_SEGMENT_DURATION=2
-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
-LOG_LEVEL=info
-PROMETHEUS_PORT=9090
-GRAFANA_PORT=3001
-
-# Development flags
-NODE_ENV=development
+# Environment variables for Linear Void
+# Copy to .env and fill in real values for local/development use.
+# Production secrets should be managed via Docker secrets, Cloudflare, or Hetzner environment.
+
+# Cloudflare R2 (S3-compatible)
+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
+R2_KEEP_RUNS=2
+R2_MAX_RETENTION_BYTES=10737418240
+
+# Cloudflare (for custom domain, cache rules, Workers if used)
+CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
+CLOUDFLARE_ACCOUNT_ID=your-account-id
+MEDIA_DOMAIN=media.linearvoid.tv
+
+# Public frontend URLs (Vite reads these from the repository root via frontend/vite.config.ts)
+# Preferred local setup in this repo uses the real public domains through Cloudflare/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 / Application Database (compose-safe PostgreSQL by default, SQLite optional)
+DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
+# Or for SQLite-only local development:
+# DATABASE_URL=sqlite://./data/chat.db
+
+# Redis (for presence, rate limiting, pub/sub)
+REDIS_URL=redis://redis:6379/0
+
+# Service Ports (for local docker-compose)
+PLAYOUT_PORT=8080
+CHAT_PORT=3000
+NGINX_PORT=80
+
+# Playout configuration
+PLAYLIST_PATH=/app/playlist/playlist.txt
+HLS_SEGMENT_DURATION=2
+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
+LOG_LEVEL=info
+PROMETHEUS_PORT=9090
+GRAFANA_PORT=3001
+
+# Development flags
+NODE_ENV=development
GO_ENV=development
\ No newline at end of file
diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml
index afc1705..6bedb6e 100644
--- a/.gitea/workflows/cd.yml
+++ b/.gitea/workflows/cd.yml
@@ -1,288 +1,288 @@
-# 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
-
-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 code
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Login to registry on remote host
- env:
- DOCKER_HOST: tcp://192.168.88.40:2375
- run: |
- echo "${{ secrets.CI_REGISTRY_TOKEN }}" | docker login git.allucanget.biz -u "${{ secrets.CI_REGISTRY_USER }}" --password-stdin
-
- - name: Prepare environment
- run: |
- cat > .env << 'EOF'
- # Database & Redis
- DATABASE_URL="${{ secrets.DATABASE_URL }}"
- REDIS_URL=redis://redis:6379/0
-
- # Playout
- PLAYLIST_PATH=/app/playlist/playlist.txt
- HLS_SEGMENT_DURATION=2
- HLS_WINDOW_SIZE=12
- 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_PASSWORD="${{ secrets.ADMIN_PASSWORD }}"
- ADMIN_SESSION_TTL_HOURS=12
-
- # Nginx
- NGINX_PORT=80
- EOF
-
- - name: Seed Prometheus config on remote host
- env:
- DOCKER_HOST: tcp://192.168.88.40:2375
- run: |
- docker run --rm \
- -v linearvoid_prometheus-config:/etc/prometheus \
- alpine:latest sh -c '
- cat > /etc/prometheus/prometheus.yml << "PROMEOF"
- global:
- scrape_interval: 15s
- evaluation_interval: 15s
-
- rule_files:
- - "/etc/prometheus/alerts.yml"
-
- scrape_configs:
- - job_name: "chat"
- static_configs:
- - targets: ["chat:3000"]
- - job_name: "playout"
- static_configs:
- - targets: ["playout:8081"]
- - job_name: "nginx"
- static_configs:
- - targets: ["nginx-exporter:9113"]
- - job_name: "redis"
- static_configs:
- - targets: ["redis-exporter:9121"]
- - job_name: "postgres"
- static_configs:
- - targets: ["postgres-exporter:9187"]
- - job_name: "node"
- static_configs:
- - targets: ["node-exporter:9100"]
- - job_name: "prometheus"
- 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
- env:
- DOCKER_HOST: tcp://192.168.88.40:2375
- run: |
- docker compose -f docker-compose.prod.yml pull
- docker compose -f docker-compose.prod.yml up -d --remove-orphans --force-recreate
-
- - name: Health check
- env:
- DOCKER_HOST: tcp://192.168.88.40:2375
- run: |
- # Wait for services to stabilize
- sleep 30
-
- # 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
- echo "Checking playout health..."
- for i in $(seq 1 6); do
- if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
- echo "Playout is healthy"
- break
- fi
- echo "Playout not ready yet (attempt $i/6), waiting 20s..."
- sleep 20
- done
- if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
- echo "Playout failed to become healthy after 6 attempts"
- docker logs linearvoid-playout --tail 50
- exit 1
- fi
-
- echo "All services healthy"
-
- - name: Cleanup old images
- env:
- DOCKER_HOST: tcp://192.168.88.40:2375
- run: |
- docker image prune -f --filter "until=24h"
+# 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
+
+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 code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to registry on remote host
+ env:
+ DOCKER_HOST: tcp://192.168.88.40:2375
+ run: |
+ echo "${{ secrets.CI_REGISTRY_TOKEN }}" | docker login git.allucanget.biz -u "${{ secrets.CI_REGISTRY_USER }}" --password-stdin
+
+ - name: Prepare environment
+ run: |
+ cat > .env << 'EOF'
+ # Database & Redis
+ DATABASE_URL="${{ secrets.DATABASE_URL }}"
+ REDIS_URL=redis://redis:6379/0
+
+ # Playout
+ PLAYLIST_PATH=/app/playlist/playlist.txt
+ HLS_SEGMENT_DURATION=2
+ HLS_WINDOW_SIZE=12
+ 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_PASSWORD="${{ secrets.ADMIN_PASSWORD }}"
+ ADMIN_SESSION_TTL_HOURS=12
+
+ # Nginx
+ NGINX_PORT=80
+ EOF
+
+ - name: Seed Prometheus config on remote host
+ env:
+ DOCKER_HOST: tcp://192.168.88.40:2375
+ run: |
+ docker run --rm \
+ -v linearvoid_prometheus-config:/etc/prometheus \
+ alpine:latest sh -c '
+ cat > /etc/prometheus/prometheus.yml << "PROMEOF"
+ global:
+ scrape_interval: 15s
+ evaluation_interval: 15s
+
+ rule_files:
+ - "/etc/prometheus/alerts.yml"
+
+ scrape_configs:
+ - job_name: "chat"
+ static_configs:
+ - targets: ["chat:3000"]
+ - job_name: "playout"
+ static_configs:
+ - targets: ["playout:8081"]
+ - job_name: "nginx"
+ static_configs:
+ - targets: ["nginx-exporter:9113"]
+ - job_name: "redis"
+ static_configs:
+ - targets: ["redis-exporter:9121"]
+ - job_name: "postgres"
+ static_configs:
+ - targets: ["postgres-exporter:9187"]
+ - job_name: "node"
+ static_configs:
+ - targets: ["node-exporter:9100"]
+ - job_name: "prometheus"
+ 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
+ env:
+ DOCKER_HOST: tcp://192.168.88.40:2375
+ run: |
+ docker compose -f docker-compose.prod.yml pull
+ docker compose -f docker-compose.prod.yml up -d --remove-orphans --force-recreate
+
+ - name: Health check
+ env:
+ DOCKER_HOST: tcp://192.168.88.40:2375
+ run: |
+ # Wait for services to stabilize
+ sleep 30
+
+ # 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
+ echo "Checking playout health..."
+ for i in $(seq 1 6); do
+ if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
+ echo "Playout is healthy"
+ break
+ fi
+ echo "Playout not ready yet (attempt $i/6), waiting 20s..."
+ sleep 20
+ done
+ if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
+ echo "Playout failed to become healthy after 6 attempts"
+ docker logs linearvoid-playout --tail 50
+ exit 1
+ fi
+
+ echo "All services healthy"
+
+ - name: Cleanup old images
+ env:
+ DOCKER_HOST: tcp://192.168.88.40:2375
+ run: |
+ docker image prune -f --filter "until=24h"
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index b7d6450..906e6ef 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -1,147 +1,147 @@
-name: CI
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
-
-jobs:
- test:
- runs-on: [ubuntu-latest]
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: "22"
-
- - name: Install Frontend Dependencies
- run: cd frontend && npm ci
-
- - name: Lint Frontend
- run: cd frontend && npm run lint
-
- - name: Test Frontend with Coverage
- run: cd frontend && npm run test:coverage
-
- - name: Build Frontend
- run: cd frontend && npm run build
-
- - name: Setup Go
- uses: actions/setup-go@v5
- with:
- go-version: "1.25"
- env:
- GOTOOLCHAIN: local
-
- - name: Download Go modules
- run: cd backend/chat && GOTOOLCHAIN=local go mod download
-
- - name: Validate Go
- run: cd backend/chat && GOTOOLCHAIN=local go vet ./...
-
- - name: Run Tests
- 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:
- needs: test
- runs-on: [ubuntu-latest]
- if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Log in to Gitea Container Registry
- uses: docker/login-action@v3
- with:
- registry: git.allucanget.biz
- username: ${{ secrets.CI_REGISTRY_USER }}
- password: ${{ secrets.CI_REGISTRY_TOKEN }}
-
- - name: Extract metadata for Playout
- id: meta-playout
- uses: docker/metadata-action@v5
- with:
- images: git.allucanget.biz/zwitschi/linearvoid/playout
- tags: |
- type=ref,event=branch
- type=ref,event=pr
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
- type=raw,value=latest,enable={{is_default_branch}}
-
- - name: Build and push Playout image
- uses: docker/build-push-action@v5
- with:
- context: ./backend/playout
- file: ./backend/playout/Dockerfile
- push: true
- tags: ${{ steps.meta-playout.outputs.tags }}
- 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
- id: meta-chat
- uses: docker/metadata-action@v5
- with:
- images: git.allucanget.biz/zwitschi/linearvoid/chat
- tags: |
- type=ref,event=branch
- type=ref,event=pr
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
- type=raw,value=latest,enable={{is_default_branch}}
-
- - name: Build and push Chat image
- uses: docker/build-push-action@v5
- with:
- context: ./backend/chat
- push: true
- tags: ${{ steps.meta-chat.outputs.tags }}
- 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
- id: meta-nginx
- uses: docker/metadata-action@v5
- with:
- images: git.allucanget.biz/zwitschi/linearvoid/nginx
- tags: |
- type=ref,event=branch
- type=ref,event=pr
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
- type=raw,value=latest,enable={{is_default_branch}}
-
- - name: Build and push Nginx image
- uses: docker/build-push-action@v5
- with:
- context: .
- file: infrastructure/nginx/Dockerfile
- push: true
- tags: ${{ steps.meta-nginx.outputs.tags }}
- 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: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: [ubuntu-latest]
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+
+ - name: Install Frontend Dependencies
+ run: cd frontend && npm ci
+
+ - name: Lint Frontend
+ run: cd frontend && npm run lint
+
+ - name: Test Frontend with Coverage
+ run: cd frontend && npm run test:coverage
+
+ - name: Build Frontend
+ run: cd frontend && npm run build
+
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.25"
+ env:
+ GOTOOLCHAIN: local
+
+ - name: Download Go modules
+ run: cd backend/chat && GOTOOLCHAIN=local go mod download
+
+ - name: Validate Go
+ run: cd backend/chat && GOTOOLCHAIN=local go vet ./...
+
+ - name: Run Tests
+ 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:
+ needs: test
+ runs-on: [ubuntu-latest]
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Gitea Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: git.allucanget.biz
+ username: ${{ secrets.CI_REGISTRY_USER }}
+ password: ${{ secrets.CI_REGISTRY_TOKEN }}
+
+ - name: Extract metadata for Playout
+ id: meta-playout
+ uses: docker/metadata-action@v5
+ with:
+ images: git.allucanget.biz/zwitschi/linearvoid/playout
+ tags: |
+ type=ref,event=branch
+ type=ref,event=pr
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=raw,value=latest,enable={{is_default_branch}}
+
+ - name: Build and push Playout image
+ uses: docker/build-push-action@v5
+ with:
+ context: ./backend/playout
+ file: ./backend/playout/Dockerfile
+ push: true
+ tags: ${{ steps.meta-playout.outputs.tags }}
+ 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
+ id: meta-chat
+ uses: docker/metadata-action@v5
+ with:
+ images: git.allucanget.biz/zwitschi/linearvoid/chat
+ tags: |
+ type=ref,event=branch
+ type=ref,event=pr
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=raw,value=latest,enable={{is_default_branch}}
+
+ - name: Build and push Chat image
+ uses: docker/build-push-action@v5
+ with:
+ context: ./backend/chat
+ push: true
+ tags: ${{ steps.meta-chat.outputs.tags }}
+ 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
+ id: meta-nginx
+ uses: docker/metadata-action@v5
+ with:
+ images: git.allucanget.biz/zwitschi/linearvoid/nginx
+ tags: |
+ type=ref,event=branch
+ type=ref,event=pr
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=raw,value=latest,enable={{is_default_branch}}
+
+ - name: Build and push Nginx image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: infrastructure/nginx/Dockerfile
+ push: true
+ tags: ${{ steps.meta-nginx.outputs.tags }}
+ 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
diff --git a/.github/MEMORY.md b/.github/MEMORY.md
index 544bacf..2626c1d 100644
--- a/.github/MEMORY.md
+++ b/.github/MEMORY.md
@@ -1,154 +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 ``); 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/*`.
+# 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 ``); 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/*`.
diff --git a/.github/instructions/BACKLOG.md b/.github/instructions/BACKLOG.md
index a28b567..26812b7 100644
--- a/.github/instructions/BACKLOG.md
+++ b/.github/instructions/BACKLOG.md
@@ -1,136 +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
+# 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
diff --git a/.github/instructions/TODO.md b/.github/instructions/TODO.md
index d388af3..d860d27 100644
--- a/.github/instructions/TODO.md
+++ b/.github/instructions/TODO.md
@@ -1,57 +1,57 @@
-# TODO
-
-> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
-
-## Chat sidebar
-
-- [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 `
` 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.
- - `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.
-
-## 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
+# TODO
+
+> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
+
+## Chat sidebar
+
+- [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 `` 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.
+ - `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.
+
+## 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
diff --git a/.gitignore b/.gitignore
index 0c0c4f2..e3e392c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,95 +1,95 @@
-# =============================================
-# Linear Void .gitignore
-# =============================================
-
-# Environment & Secrets
-.env
-.env.local
-.env.*.local
-
-# Test coverage
-coverage
-coverage.out
-
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-
-# Benchmark artifacts
-artifacts/
-scripts/loadtest/artifacts/
-
-# Runtime data
-pids
-*.pid
-*.seed
-*.pid.lock
-
-# Dependency directories
-node_modules/
-jspm_packages/
-
-# Build outputs
-dist/
-build/
-out/
-*.min.js
-
-# Go build artifacts
-*.exe
-*.exe~
-*.dll
-*.so
-*.dylib
-
-# FFmpeg temporary files
-*.ts.tmp
-*.m3u8.tmp
-*.m4s.tmp
-hls/
-temp/
-
-# Database & local data
-*.db
-data/
-*.sqlite
-*.sqlite3
-
-# Demo data (tracked in backend/playout/demodata/ for out-of-the-box playout)
-!backend/playout/demodata/
-
-# Docker
-.dockerignore
-**/.dockerignore
-
-# Editor & IDE
-.vscode/
-.idea/
-*.swp
-*.swo
-*~
-
-# OS generated files
-.DS_Store
-.DS_Store?
-._*
-.Spotlight-V100
-.Trashes
-ehthumbs.db
-Thumbs.db
-
-# Terraform / IaC state
-*.tfstate
-*.tfstate.backup
-.terraform/
-
-
-# Temporary architecture artifacts
-**/*.tmp
-**/*.bak
-
+# =============================================
+# Linear Void .gitignore
+# =============================================
+
+# Environment & Secrets
+.env
+.env.local
+.env.*.local
+
+# Test coverage
+coverage
+coverage.out
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Benchmark artifacts
+artifacts/
+scripts/loadtest/artifacts/
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Build outputs
+dist/
+build/
+out/
+*.min.js
+
+# Go build artifacts
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# FFmpeg temporary files
+*.ts.tmp
+*.m3u8.tmp
+*.m4s.tmp
+hls/
+temp/
+
+# Database & local data
+*.db
+data/
+*.sqlite
+*.sqlite3
+
+# Demo data (tracked in backend/playout/demodata/ for out-of-the-box playout)
+!backend/playout/demodata/
+
+# Docker
+.dockerignore
+**/.dockerignore
+
+# Editor & IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS generated files
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
+# Terraform / IaC state
+*.tfstate
+*.tfstate.backup
+.terraform/
+
+
+# Temporary architecture artifacts
+**/*.tmp
+**/*.bak
+
# Note: .github/instructions/ is partially ignored but AGENTS.md and core docs are committed
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
index bc5795e..409a6d6 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,95 +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
+# =============================================
+# 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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index de3868b..651e350 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,10 +6,12 @@
### Added
+- **Bash equivalents of Windows scripts**: Added `scripts/cache-hit-ratio.sh`, `scripts/check-env.sh`, `scripts/check-tokens.sh`, and `scripts/loadtest/run-loadtests.sh`, `scripts/loadtest/summarize-artillery.sh`, `scripts/loadtest/summarize-k6.sh` — Linux/macOS ports of the existing PowerShell scripts (the `.ps1` files are retained).
- **Transient presence flash**: Join/leave announcements now appear as a 3-second flash at the top of the chat panel (`#chat-flash`) with a fade-out, instead of permanent chat rows. Presence indicators still update from the same events.
### Changed
+- **Docs condensed and de-Windows'd**: Removed Windows/PowerShell-specific instructions from the docs and condensed the longest documents into tighter, bash-first guides — `SETUP.md`, `DEPLOYMENT.md`, `API.md`, `MONITORING.md`, `STREAMING.md`, `COOLIFY.md`, `incident-runbook.md`, `operations/scaling-performance-playbook.md`, `architecture/README.md`, and the Prometheus metrics inventory. `scripts/loadtest/README.md` now documents the bash load-test helpers.
- **System messages no longer persisted**: The chat backend no longer stores `system`-type messages; chat history and the admin chat-log table only contain real chat messages (moderation actions remain in the audit log).
- **Reconnect grace period**: Disconnects schedule a delayed "left" announcement (5s); a reconnect within 5s cancels it and skips "joined", eliminating join/leave spam on brief reconnects.
- **HLS segment duration 2s**: Default `HLS_SEGMENT_DURATION` changed from 6 to 2 and `HLS_WINDOW_SIZE` from 24 to 12 (2s × 12 = 24s live window), reducing end-to-end latency; the 2s GOP was already in place.
diff --git a/LICENSE b/LICENSE
index 9885bb9..9ed3010 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,7 +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.
+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.
diff --git a/README.md b/README.md
index 58eaec2..d04c6ed 100644
--- a/README.md
+++ b/README.md
@@ -213,11 +213,13 @@ One-off and CI-friendly scripts for video preparation, R2 sync, backups, and pla
- loadtest/
- [admin-api.js](scripts/loadtest/admin-api.js) — admin API load test script
- - [run-loadtests.ps1](scripts/loadtest/run-loadtests.ps1) — PowerShell script to run load tests
- - [summarize-artillery.ps1](scripts/loadtest/summarize-artillery.ps1) — PowerShell script to summarize Artillery load test results
- - [summarize-k6.ps1](scripts/loadtest/summarize-k6.ps1) — PowerShell script to summarize k6 load test results
+ - [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
> **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`.
diff --git a/backend/chat/Dockerfile b/backend/chat/Dockerfile
index 71fdbe0..ddeb750 100644
--- a/backend/chat/Dockerfile
+++ b/backend/chat/Dockerfile
@@ -1,17 +1,17 @@
-FROM golang:1.25-alpine AS builder
-
-RUN apk add --no-cache gcc musl-dev
-
-WORKDIR /app
-COPY go.mod ./
-COPY src/ ./src/
-RUN go mod tidy
-RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
-
-FROM alpine:3.20
-RUN apk --no-cache add ca-certificates curl ffmpeg
-WORKDIR /app
-COPY --from=builder /chat-server /usr/local/bin/chat-server
-
-EXPOSE 3000
+FROM golang:1.25-alpine AS builder
+
+RUN apk add --no-cache gcc musl-dev
+
+WORKDIR /app
+COPY go.mod ./
+COPY src/ ./src/
+RUN go mod tidy
+RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
+
+FROM alpine:3.20
+RUN apk --no-cache add ca-certificates curl ffmpeg
+WORKDIR /app
+COPY --from=builder /chat-server /usr/local/bin/chat-server
+
+EXPOSE 3000
CMD ["/usr/local/bin/chat-server"]
\ No newline at end of file
diff --git a/backend/chat/src/auth/auth.go b/backend/chat/src/auth/auth.go
index 728edd1..d6219a5 100644
--- a/backend/chat/src/auth/auth.go
+++ b/backend/chat/src/auth/auth.go
@@ -1,207 +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)
+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)
}
\ No newline at end of file
diff --git a/backend/chat/src/auth/auth_test.go b/backend/chat/src/auth/auth_test.go
index b8312a7..489da24 100644
--- a/backend/chat/src/auth/auth_test.go
+++ b/backend/chat/src/auth/auth_test.go
@@ -1,309 +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")
- }
+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")
+ }
}
\ No newline at end of file
diff --git a/backend/chat/src/config/config.go b/backend/chat/src/config/config.go
index e1226fc..392d1fc 100644
--- a/backend/chat/src/config/config.go
+++ b/backend/chat/src/config/config.go
@@ -1,132 +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"))
+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"))
}
\ No newline at end of file
diff --git a/backend/chat/src/config/config_test.go b/backend/chat/src/config/config_test.go
index fbb04d7..c024fe1 100644
--- a/backend/chat/src/config/config_test.go
+++ b/backend/chat/src/config/config_test.go
@@ -1,267 +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)
- }
- })
- }
+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)
+ }
+ })
+ }
}
\ No newline at end of file
diff --git a/backend/chat/src/config/runtime.go b/backend/chat/src/config/runtime.go
index 4f50112..63dd103 100644
--- a/backend/chat/src/config/runtime.go
+++ b/backend/chat/src/config/runtime.go
@@ -1,221 +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(),
- }
-}
+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(),
+ }
+}
diff --git a/backend/chat/src/config/runtime_test.go b/backend/chat/src/config/runtime_test.go
index c2b979e..3758f7a 100644
--- a/backend/chat/src/config/runtime_test.go
+++ b/backend/chat/src/config/runtime_test.go
@@ -1,181 +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)
- }
-}
+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)
+ }
+}
diff --git a/backend/chat/src/db/db.go b/backend/chat/src/db/db.go
index a0191ce..b10b892 100644
--- a/backend/chat/src/db/db.go
+++ b/backend/chat/src/db/db.go
@@ -1,82 +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
+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
}
\ No newline at end of file
diff --git a/backend/chat/src/db/db_test.go b/backend/chat/src/db/db_test.go
index f82ba04..37fb325 100644
--- a/backend/chat/src/db/db_test.go
+++ b/backend/chat/src/db/db_test.go
@@ -1,179 +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")
- }
+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")
+ }
}
\ No newline at end of file
diff --git a/backend/chat/src/db/models.go b/backend/chat/src/db/models.go
index 25a1af8..268caa0 100644
--- a/backend/chat/src/db/models.go
+++ b/backend/chat/src/db/models.go
@@ -1,65 +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"`
+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"`
}
\ No newline at end of file
diff --git a/backend/chat/src/db/redis.go b/backend/chat/src/db/redis.go
index 55d2146..2e5302c 100644
--- a/backend/chat/src/db/redis.go
+++ b/backend/chat/src/db/redis.go
@@ -1,163 +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()
+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()
}
\ No newline at end of file
diff --git a/backend/chat/src/handlers/handlers.go b/backend/chat/src/handlers/handlers.go
index f10b95d..1119310 100644
--- a/backend/chat/src/handlers/handlers.go
+++ b/backend/chat/src/handlers/handlers.go
@@ -1,2049 +1,2049 @@
-package handlers
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "os"
- "path/filepath"
- "sort"
- "strconv"
- "strings"
- "time"
-
- "golang.org/x/crypto/bcrypt"
- "gorm.io/gorm"
-
- "chat/src/auth"
- "chat/src/config"
- "chat/src/db"
- "chat/src/media"
- "chat/src/ratelimit"
- "chat/src/ws"
-)
-
-// AdminOverview response payload.
-type AdminOverview struct {
- Viewers int `json:"viewers"`
- StoredMessages int64 `json:"storedMessages"`
- ModerationActions int64 `json:"moderationActions"`
- MediaCount int64 `json:"mediaCount"`
- MediaTotalDurationSeconds int64 `json:"mediaTotalDurationSeconds"`
- MediaTotalRuntime string `json:"mediaTotalRuntime"`
- RedisEnabled bool `json:"redisEnabled"`
- RedisClients int `json:"redisClients"`
- RedisMemoryUsed string `json:"redisMemoryUsed"`
- DatabaseDriver string `json:"databaseDriver"`
- GeneratedAt string `json:"generatedAt"`
-}
-
-// PlaylistPayload for playlist read/write.
-type PlaylistPayload struct {
- Content string `json:"content"`
-}
-
-// ModerationActionPayload for moderation actions.
-type ModerationActionPayload struct {
- Action string `json:"action"`
- Target string `json:"target"`
- Moderator string `json:"moderator"`
- Reason string `json:"reason"`
- Room string `json:"room"`
-}
-
-// ModerationLogsResponse wraps moderation log entries.
-type ModerationLogsResponse struct {
- Logs []db.ModerationLog `json:"logs"`
-}
-
-// RoomAnalytics for per-room message counts.
-type RoomAnalytics struct {
- Room string `json:"room"`
- MessageCount int64 `json:"messageCount"`
-}
-
-// AdminAnalytics response payload.
-type AdminAnalytics struct {
- TotalMessages int64 `json:"totalMessages"`
- MessagesLast24Hours int64 `json:"messagesLast24Hours"`
- DistinctRooms int64 `json:"distinctRooms"`
- ModerationActions int64 `json:"moderationActions"`
- MediaCount int64 `json:"mediaCount"`
- MediaTotalDurationSeconds int64 `json:"mediaTotalDurationSeconds"`
- MediaTotalRuntime string `json:"mediaTotalRuntime"`
- TopRooms []RoomAnalytics `json:"topRooms"`
- GeneratedAt string `json:"generatedAt"`
-}
-
-// ServiceHealth for system summary.
-type ServiceHealth struct {
- Name string `json:"name"`
- Status string `json:"status"`
- Detail string `json:"detail"`
- Checked string `json:"checked"`
-}
-
-// AdminSystemSummary response payload.
-type AdminSystemSummary struct {
- DatabaseDriver string `json:"databaseDriver"`
- PlaylistPath string `json:"playlistPath"`
- PlaylistExists bool `json:"playlistExists"`
- RedisEnabled bool `json:"redisEnabled"`
- RedisMemoryUsed string `json:"redisMemoryUsed"`
- Services []ServiceHealth `json:"services"`
- GeneratedAt string `json:"generatedAt"`
-}
-
-// ActiveUser for admin users list.
-type ActiveUser struct {
- Username string `json:"username"`
- Room string `json:"room"`
- Online bool `json:"online"`
-}
-
-// AdminUsersResponse wraps active users.
-type AdminUsersResponse struct {
- Users []ActiveUser `json:"users"`
- RedisEnabled bool `json:"redisEnabled"`
- GeneratedAt string `json:"generatedAt"`
-}
-
-// ChatLogEntry for chat log API.
-type ChatLogEntry struct {
- ID uint `json:"id"`
- Room string `json:"room"`
- Username string `json:"username"`
- Text string `json:"text"`
- Timestamp time.Time `json:"timestamp"`
- Type string `json:"type"`
-}
-
-// ChatLogListResponse wraps chat log entries.
-type ChatLogListResponse struct {
- Entries []ChatLogEntry `json:"entries"`
- Total int64 `json:"total"`
-}
-
-// ChatLogBulkDeleteRequest for bulk deletion.
-type ChatLogBulkDeleteRequest struct {
- IDs []uint `json:"ids"`
-}
-
-// AdminScheduleResponse for schedule endpoint.
-type AdminScheduleResponse struct {
- Entries []media.ScheduleEntry `json:"entries"`
- TotalEntries int `json:"totalEntries"`
- MatchedEntries int `json:"matchedEntries"`
- TotalDurationSeconds int `json:"totalDurationSeconds"`
- TotalRuntime string `json:"totalRuntime"`
- GeneratedAt string `json:"generatedAt"`
-}
-
-// AdminMediaResponse wraps media file entries.
-type AdminMediaResponse struct {
- Files []media.FileEntry `json:"files"`
- GeneratedAt string `json:"generatedAt"`
-}
-
-// AdminMediaUploadInitRequest for chunked upload init.
-type AdminMediaUploadInitRequest struct {
- UploadID string `json:"uploadId"`
- FileName string `json:"fileName"`
- FileSize int64 `json:"fileSize"`
- MimeType string `json:"mimeType"`
- ChunkSize int64 `json:"chunkSize"`
- TotalChunks int `json:"totalChunks"`
-}
-
-// AdminMediaUploadSession tracks a chunked upload.
-type AdminMediaUploadSession struct {
- UploadID string `json:"uploadId"`
- FileName string `json:"fileName"`
- FileSize int64 `json:"fileSize"`
- MimeType string `json:"mimeType"`
- ChunkSize int64 `json:"chunkSize"`
- TotalChunks int `json:"totalChunks"`
- CreatedAt string `json:"createdAt"`
- UpdatedAt string `json:"updatedAt"`
-}
-
-// AdminMediaUploadStatusResponse for upload progress.
-type AdminMediaUploadStatusResponse struct {
- UploadID string `json:"uploadId"`
- FileName string `json:"fileName"`
- FileSize int64 `json:"fileSize"`
- ChunkSize int64 `json:"chunkSize"`
- TotalChunks int `json:"totalChunks"`
- UploadedChunks int `json:"uploadedChunks"`
- Progress float64 `json:"progress"`
-}
-
-// AdminLoginPayload for login requests.
-type AdminLoginPayload struct {
- Username string `json:"username"`
- Password string `json:"password"`
-}
-
-// AdminSessionResponse for session status.
-type AdminSessionResponse struct {
- Authenticated bool `json:"authenticated"`
- Username string `json:"username,omitempty"`
-}
-
-// Dependencies holds all shared dependencies for handlers.
-type Dependencies struct {
- DB *gorm.DB
- Redis db.RedisClient
- Hub *ws.Hub
- SessionStore *auth.SessionStore
- UserSessions *auth.UserSessionStore
- DBDriver string
-}
-
-// parseRedisInfoValue extracts a value from Redis INFO output.
-func parseRedisInfoValue(info, key string) string {
- prefix := key + ":"
- for _, line := range strings.Split(info, "\n") {
- line = strings.TrimSpace(line)
- if strings.HasPrefix(line, prefix) {
- return strings.TrimSpace(strings.TrimPrefix(line, prefix))
- }
- }
- return ""
-}
-
-// getAdminOverview builds the admin overview response.
-func getAdminOverview(deps *Dependencies) AdminOverview {
- overview := AdminOverview{
- RedisEnabled: deps.Redis != nil,
- RedisMemoryUsed: "unavailable",
- DatabaseDriver: deps.DBDriver,
- MediaTotalRuntime: "00:00:00",
- GeneratedAt: time.Now().UTC().Format(time.RFC3339),
- }
-
- if deps.Hub != nil {
- overview.Viewers = deps.Hub.ConnectedClients()
- }
-
- if deps.DB != nil {
- deps.DB.Model(&db.Message{}).Count(&overview.StoredMessages)
- deps.DB.Model(&db.ModerationLog{}).Count(&overview.ModerationActions)
- deps.DB.Model(&db.Media{}).Count(&overview.MediaCount)
- deps.DB.Model(&db.Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&overview.MediaTotalDurationSeconds)
- overview.MediaTotalRuntime = media.FormatDuration(int(overview.MediaTotalDurationSeconds))
- }
-
- if deps.Redis != nil {
- ctx := context.Background()
- if info, err := deps.Redis.Info(ctx, "clients", "memory"); err == nil {
- if value := parseRedisInfoValue(info, "connected_clients"); value != "" {
- if count, convErr := strconv.Atoi(value); convErr == nil {
- overview.RedisClients = count
- }
- }
- if value := parseRedisInfoValue(info, "used_memory_human"); value != "" {
- overview.RedisMemoryUsed = value
- }
- }
- }
-
- return overview
-}
-
-// HandleAdminLogin processes admin login requests.
-func HandleAdminLogin(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- clientIP := r.Header.Get("X-Forwarded-For")
- if clientIP == "" {
- clientIP = r.RemoteAddr
- }
-
- allowed, _ := ratelimit.CheckLoginRate(context.Background(), deps.Redis, clientIP)
- if !allowed {
- http.Error(w, "too many login attempts, try again later", http.StatusTooManyRequests)
- return
- }
-
- var payload AdminLoginPayload
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
- return
- }
-
- if payload.Username != config.AdminUsername() || payload.Password != config.AdminPassword() {
- http.Error(w, "invalid credentials", http.StatusUnauthorized)
- return
- }
-
- token, err := auth.GenerateToken()
- if err != nil {
- http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError)
- return
- }
-
- expiresAt := time.Now().Add(config.AdminSessionTTL())
- deps.SessionStore.Add(token, expiresAt)
- auth.SetCookie(w, r, config.AdminSessionCookieName, token, expiresAt, http.SameSiteStrictMode)
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: config.AdminUsername()})
- }
-}
-
-// HandleAdminLogout processes admin logout.
-func HandleAdminLogout(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- if token, ok := auth.GetTokenFromCookie(r, config.AdminSessionCookieName); ok {
- deps.SessionStore.Remove(token)
- }
- auth.ClearCookie(w, r, config.AdminSessionCookieName, http.SameSiteStrictMode)
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: false})
- }
-}
-
-// HandleAdminSession returns current session status.
-func HandleAdminSession(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- token, ok := auth.GetTokenFromCookie(r, config.AdminSessionCookieName)
- if !ok || !deps.SessionStore.Validate(token) {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: config.AdminUsername()})
- }
-}
-
-// userSessionCookieName is the cookie for registered chat users.
-const userSessionCookieName = "linearvoid_user_session"
-
-// UserCredentialsPayload is used by registration and login.
-type UserCredentialsPayload struct {
- Username string `json:"username"`
- Password string `json:"password"`
-}
-
-// validateUsername checks the username length constraints.
-func validateUsername(username string) error {
- if len(username) < 3 || len(username) > 32 {
- return fmt.Errorf("username must be 3-32 characters")
- }
- return nil
-}
-
-// validatePassword checks the password length constraints.
-func validatePassword(password string) error {
- if len(password) < 6 {
- return fmt.Errorf("password must be at least 6 characters")
- }
- return nil
-}
-
-// startUserSession issues a user session cookie for the given username.
-func startUserSession(deps *Dependencies, w http.ResponseWriter, r *http.Request, username string) {
- token, err := auth.GenerateToken()
- if err != nil {
- return
- }
- expiresAt := time.Now().Add(config.UserSessionTTL())
- deps.UserSessions.Add(token, username, expiresAt)
- auth.SetCookie(w, r, userSessionCookieName, token, expiresAt, auth.SameSiteForUserCookie(r))
-}
-
-// HandleUserRegister creates a new chat user and signs them in.
-func HandleUserRegister(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- var payload UserCredentialsPayload
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- http.Error(w, "invalid body", http.StatusBadRequest)
- return
- }
- username := strings.TrimSpace(payload.Username)
- if err := validateUsername(username); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- if err := validatePassword(payload.Password); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- var count int64
- deps.DB.Model(&db.User{}).Where("username = ?", username).Count(&count)
- if count > 0 {
- http.Error(w, "username already taken", http.StatusConflict)
- return
- }
-
- hash, err := bcrypt.GenerateFromPassword([]byte(payload.Password), bcrypt.DefaultCost)
- if err != nil {
- http.Error(w, "failed to hash password", http.StatusInternalServerError)
- return
- }
- user := db.User{Username: username, PasswordHash: string(hash)}
- if err := deps.DB.Create(&user).Error; err != nil {
- http.Error(w, "failed to create user", http.StatusInternalServerError)
- return
- }
-
- startUserSession(deps, w, r, user.Username)
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]any{"username": user.Username})
- }
-}
-
-// HandleUserLogin verifies credentials and signs the user in.
-func HandleUserLogin(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- var payload UserCredentialsPayload
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- http.Error(w, "invalid body", http.StatusBadRequest)
- return
- }
- username := strings.TrimSpace(payload.Username)
-
- var user db.User
- if err := deps.DB.Where("username = ?", username).First(&user).Error; err != nil {
- http.Error(w, "invalid credentials", http.StatusUnauthorized)
- return
- }
- if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(payload.Password)); err != nil {
- http.Error(w, "invalid credentials", http.StatusUnauthorized)
- return
- }
-
- startUserSession(deps, w, r, user.Username)
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]any{"username": user.Username})
- }
-}
-
-// HandleUserSession returns the current user session state.
-func HandleUserSession(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- token, _ := auth.GetTokenFromCookie(r, userSessionCookieName)
- username, ok := deps.UserSessions.Username(token)
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]any{
- "authenticated": ok,
- "username": username,
- })
- }
-}
-
-// HandleUserLogout clears the user session.
-func HandleUserLogout(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- if token, ok := auth.GetTokenFromCookie(r, userSessionCookieName); ok {
- deps.UserSessions.Remove(token)
- }
- auth.ClearCookie(w, r, userSessionCookieName, auth.SameSiteForUserCookie(r))
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]bool{"ok": true})
- }
-}
-
-// currentUsername returns the authenticated username from the user session cookie.
-func currentUsername(deps *Dependencies, r *http.Request) (string, bool) {
- token, ok := auth.GetTokenFromCookie(r, userSessionCookieName)
- if !ok {
- return "", false
- }
- return deps.UserSessions.Username(token)
-}
-
-// dmRoomFor returns the canonical DM room for two users (sorted, prefixed).
-func dmRoomFor(a, b string) string {
- if a > b {
- a, b = b, a
- }
- return "dm:" + a + "-" + b
-}
-
-// HandleUserDMHistory returns paginated private message history with a target user.
-func HandleUserDMHistory(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- username, ok := currentUsername(deps, r)
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
- target := strings.TrimSpace(r.URL.Query().Get("with"))
- if target == "" {
- http.Error(w, "missing target user", http.StatusBadRequest)
- return
- }
- limit := 50
- if raw := r.URL.Query().Get("limit"); raw != "" {
- if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 {
- limit = n
- }
- }
- room := dmRoomFor(username, target)
-
- query := deps.DB.Where("room = ?", room)
- if beforeRaw := r.URL.Query().Get("before"); beforeRaw != "" {
- if t, err := time.Parse(time.RFC3339, beforeRaw); err == nil {
- query = query.Where("timestamp < ?", t)
- }
- }
- var messages []db.Message
- if err := query.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
- http.Error(w, "failed to load history", http.StatusInternalServerError)
- return
- }
- for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
- messages[i], messages[j] = messages[j], messages[i]
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(messages)
- }
-}
-
-// HandleUserDMSearch searches private messages with a target user.
-func HandleUserDMSearch(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- username, ok := currentUsername(deps, r)
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
- target := strings.TrimSpace(r.URL.Query().Get("with"))
- q := strings.TrimSpace(r.URL.Query().Get("q"))
- if target == "" || q == "" {
- http.Error(w, "missing target or query", http.StatusBadRequest)
- return
- }
- limit := 50
- if raw := r.URL.Query().Get("limit"); raw != "" {
- if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 {
- limit = n
- }
- }
- room := dmRoomFor(username, target)
- var messages []db.Message
- if err := deps.DB.Where("room = ? AND LOWER(text) LIKE ?", room, "%"+strings.ToLower(q)+"%").
- Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
- http.Error(w, "failed to search", http.StatusInternalServerError)
- return
- }
- for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
- messages[i], messages[j] = messages[j], messages[i]
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(messages)
- }
-}
-
-// HandleAdminOverview returns the admin dashboard overview.
-func HandleAdminOverview(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(getAdminOverview(deps))
- }
-}
-
-// HandleAdminPlaylist reads/writes the playlist file.
-func HandleAdminPlaylist(deps *Dependencies) http.HandlerFunc {
- playlistPath := config.PlaylistPath()
- return func(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- content, err := os.ReadFile(playlistPath)
- if err != nil {
- http.Error(w, fmt.Sprintf("failed to read playlist: %v", err), http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(PlaylistPayload{Content: string(content)})
- case http.MethodPut:
- var payload PlaylistPayload
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
- return
- }
- if err := os.MkdirAll(filepath.Dir(playlistPath), 0o755); err != nil {
- http.Error(w, fmt.Sprintf("failed to ensure playlist directory: %v", err), http.StatusInternalServerError)
- return
- }
- content := payload.Content
- if content != "" && !strings.HasSuffix(content, "\n") {
- content += "\n"
- }
- if err := os.WriteFile(playlistPath, []byte(content), 0o644); err != nil {
- http.Error(w, fmt.Sprintf("failed to save playlist: %v", err), http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(PlaylistPayload{Content: content})
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- }
-}
-
-// HandlePublicSchedule returns playlist schedule without auth.
-func HandlePublicSchedule(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- content, err := os.ReadFile(config.PlaylistPath())
- if err != nil {
- http.Error(w, "schedule unavailable", http.StatusServiceUnavailable)
- return
- }
- entries := media.ParsePlaylist(string(content))
- var mediaRecords []db.Media
- if deps.DB != nil {
- deps.DB.Find(&mediaRecords)
- }
- mediaByName := make(map[string]db.Media, len(mediaRecords))
- for _, m := range mediaRecords {
- mediaByName[strings.ToLower(m.Filename)] = m
- }
- type PublicEntry struct {
- Index int `json:"index"`
- Label string `json:"label"`
- Title string `json:"title"`
- Status string `json:"status"`
- }
- output := make([]PublicEntry, 0, len(entries))
- for _, e := range entries {
- pe := PublicEntry{Index: e.Index, Label: e.Label, Status: "pending"}
- if m, ok := mediaByName[strings.ToLower(filepath.Base(e.Path))]; ok {
- pe.Title = m.Title
- pe.Status = m.Status
- } else {
- pe.Title = filepath.Base(e.Path)
- }
- output = append(output, pe)
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]any{
- "entries": output,
- "total": len(output),
- "generatedAt": time.Now().UTC().Format(time.RFC3339),
- })
- }
-}
-
-// HandleAdminSchedule returns the full schedule with media metadata.
-func HandleAdminSchedule(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- content, err := os.ReadFile(config.PlaylistPath())
- if err != nil {
- http.Error(w, fmt.Sprintf("failed to read schedule playlist: %v", err), http.StatusInternalServerError)
- return
- }
- entries := media.ParsePlaylist(string(content))
-
- var mediaRecords []db.Media
- if err := deps.DB.Find(&mediaRecords).Error; err != nil {
- http.Error(w, fmt.Sprintf("failed to load media metadata: %v", err), http.StatusInternalServerError)
- return
- }
-
- mediaByFilename := make(map[string]db.Media, len(mediaRecords))
- mediaByPath := make(map[string]db.Media, len(mediaRecords))
- for _, record := range mediaRecords {
- mediaByFilename[strings.ToLower(record.Filename)] = record
- mediaByPath[strings.ToLower(filepath.Clean(record.Path))] = record
- }
-
- totalDurationSeconds := 0
- matchedEntries := 0
- for i := range entries {
- entry := &entries[i]
- entry.Status = "missing"
- entry.Runtime = "00:00:00"
-
- cleanPath := strings.ToLower(filepath.Clean(entry.Path))
- baseName := strings.ToLower(filepath.Base(entry.Path))
-
- record, ok := mediaByPath[cleanPath]
- if !ok {
- record, ok = mediaByFilename[baseName]
- }
- if !ok {
- continue
- }
-
- entry.DurationSeconds = record.DurationSeconds
- entry.Runtime = media.FormatDuration(record.DurationSeconds)
- entry.Status = record.Status
- entry.Title = record.Title
- totalDurationSeconds += record.DurationSeconds
- matchedEntries++
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(AdminScheduleResponse{
- Entries: entries,
- TotalEntries: len(entries),
- MatchedEntries: matchedEntries,
- TotalDurationSeconds: totalDurationSeconds,
- TotalRuntime: media.FormatDuration(totalDurationSeconds),
- GeneratedAt: time.Now().UTC().Format(time.RFC3339),
- })
- }
-}
-
-// HandleAdminScheduleOverrides manages CRUD for schedule overrides.
-func HandleAdminScheduleOverrides(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- var overrides []db.ScheduleOverride
- deps.DB.Order("start_time asc").Find(&overrides)
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(overrides)
-
- case http.MethodPost:
- var ov db.ScheduleOverride
- if err := json.NewDecoder(r.Body).Decode(&ov); err != nil {
- http.Error(w, "invalid request body", http.StatusBadRequest)
- return
- }
- if ov.MediaID == 0 || ov.StartTime.IsZero() {
- http.Error(w, "mediaId and startTime are required", http.StatusBadRequest)
- return
- }
- var conflict int64
- deps.DB.Model(&db.ScheduleOverride{}).
- Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime).
- Count(&conflict)
- if conflict > 0 {
- http.Error(w, fmt.Sprintf("conflict: %d overlapping override(s)", conflict), http.StatusConflict)
- return
- }
- deps.DB.Create(&ov)
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusCreated)
- json.NewEncoder(w).Encode(ov)
-
- case http.MethodDelete:
- idStr := r.URL.Query().Get("id")
- if idStr == "" {
- http.Error(w, "id query param required", http.StatusBadRequest)
- return
- }
- id, err := strconv.ParseUint(idStr, 10, 64)
- if err != nil {
- http.Error(w, "invalid id", http.StatusBadRequest)
- return
- }
- if err := deps.DB.Delete(&db.ScheduleOverride{}, id).Error; err != nil {
- http.Error(w, "failed to delete override", http.StatusInternalServerError)
- return
- }
- w.WriteHeader(http.StatusNoContent)
-
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- }
-}
-
-// HandleAdminModerationLogs returns moderation log entries.
-func HandleAdminModerationLogs(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- limit := 50
- if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
- parsedLimit, err := strconv.Atoi(rawLimit)
- if err != nil || parsedLimit <= 0 {
- http.Error(w, "invalid limit", http.StatusBadRequest)
- return
- }
- if parsedLimit > 200 {
- parsedLimit = 200
- }
- limit = parsedLimit
- }
- var logs []db.ModerationLog
- if err := deps.DB.Order("timestamp desc").Limit(limit).Find(&logs).Error; err != nil {
- http.Error(w, fmt.Sprintf("failed to load moderation logs: %v", err), http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(ModerationLogsResponse{Logs: logs})
- }
-}
-
-// HandleAdminModerationAction processes a moderation action.
-func HandleAdminModerationAction(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- var payload ModerationActionPayload
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
- return
- }
- payload.Action = strings.TrimSpace(payload.Action)
- payload.Target = strings.TrimSpace(payload.Target)
- payload.Moderator = strings.TrimSpace(payload.Moderator)
- payload.Reason = strings.TrimSpace(payload.Reason)
- payload.Room = strings.TrimSpace(payload.Room)
-
- if payload.Action == "" || payload.Target == "" || payload.Reason == "" {
- http.Error(w, "action, target, and reason are required", http.StatusBadRequest)
- return
- }
- if payload.Moderator == "" {
- payload.Moderator = "admin"
- }
- if payload.Room == "" {
- payload.Room = "main"
- }
-
- // Log to DB
- logEntry := db.ModerationLog{
- Action: payload.Action,
- Target: payload.Target,
- Moderator: payload.Moderator,
- Reason: payload.Reason,
- Timestamp: time.Now(),
- }
- deps.DB.Create(&logEntry)
-
- // Broadcast system message
- if deps.Hub != nil {
- deps.Hub.Broadcast(db.Message{
- Room: payload.Room,
- Username: "system",
- Type: "system",
- Text: fmt.Sprintf("Moderator %s %s %s for: %s", payload.Moderator, payload.Action, payload.Target, payload.Reason),
- Timestamp: time.Now(),
- })
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(payload)
- }
-}
-
-// HandleAdminAnalytics returns analytics data.
-func HandleAdminAnalytics(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- analytics := AdminAnalytics{
- TopRooms: []RoomAnalytics{},
- MediaTotalRuntime: "00:00:00",
- GeneratedAt: time.Now().UTC().Format(time.RFC3339),
- }
- if deps.DB == nil {
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(analytics)
- return
- }
-
- deps.DB.Model(&db.Message{}).Count(&analytics.TotalMessages)
- deps.DB.Model(&db.Message{}).
- Where("timestamp >= ?", time.Now().Add(-24*time.Hour)).
- Count(&analytics.MessagesLast24Hours)
- deps.DB.Model(&db.Message{}).Distinct("room").Count(&analytics.DistinctRooms)
- deps.DB.Model(&db.ModerationLog{}).Count(&analytics.ModerationActions)
- deps.DB.Model(&db.Media{}).Count(&analytics.MediaCount)
- deps.DB.Model(&db.Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&analytics.MediaTotalDurationSeconds)
- analytics.MediaTotalRuntime = media.FormatDuration(int(analytics.MediaTotalDurationSeconds))
-
- var topRooms []RoomAnalytics
- deps.DB.Model(&db.Message{}).
- Select("room, COUNT(*) as message_count").
- Group("room").
- Order("message_count DESC").
- Limit(5).
- Scan(&topRooms)
- analytics.TopRooms = topRooms
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(analytics)
- }
-}
-
-// checkServiceHealth probes a service health endpoint.
-func checkServiceHealth(name, url string) ServiceHealth {
- checked := time.Now().UTC().Format(time.RFC3339)
- resp, err := http.Get(url)
- if err != nil {
- return ServiceHealth{Name: name, Status: "down", Detail: err.Error(), Checked: checked}
- }
- defer resp.Body.Close()
-
- body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
- status := "healthy"
- if resp.StatusCode >= 400 {
- status = "degraded"
- }
-
- detail := strings.TrimSpace(string(body))
- if detail == "" {
- detail = resp.Status
- }
-
- return ServiceHealth{Name: name, Status: status, Detail: detail, Checked: checked}
-}
-
-// HandleAdminSystemSummary returns system health information.
-func HandleAdminSystemSummary(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- summary := AdminSystemSummary{
- DatabaseDriver: deps.DBDriver,
- PlaylistPath: config.PlaylistPath(),
- RedisEnabled: deps.Redis != nil,
- RedisMemoryUsed: "unavailable",
- GeneratedAt: time.Now().UTC().Format(time.RFC3339),
- }
-
- if _, err := os.Stat(summary.PlaylistPath); err == nil {
- summary.PlaylistExists = true
- }
-
- if deps.Redis != nil {
- if info, err := deps.Redis.Info(context.Background(), "memory"); err == nil {
- if value := parseRedisInfoValue(info, "used_memory_human"); value != "" {
- summary.RedisMemoryUsed = value
- }
- }
- }
-
- summary.Services = []ServiceHealth{
- {Name: "chat", Status: "healthy", Detail: "local process", Checked: summary.GeneratedAt},
- checkServiceHealth("playout", "http://playout:8081/health"),
- }
-
- if deps.Redis != nil {
- if err := deps.Redis.Ping(context.Background()); err != nil {
- summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "down", Detail: err.Error(), Checked: summary.GeneratedAt})
- } else {
- summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "healthy", Detail: "ping ok", Checked: summary.GeneratedAt})
- }
- } else {
- summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "disabled", Detail: "client unavailable", Checked: summary.GeneratedAt})
- }
-
- if sqlDB, err := deps.DB.DB(); err == nil {
- if err := sqlDB.Ping(); err != nil {
- summary.Services = append(summary.Services, ServiceHealth{Name: "database", Status: "down", Detail: err.Error(), Checked: summary.GeneratedAt})
- } else {
- summary.Services = append(summary.Services, ServiceHealth{Name: "database", Status: "healthy", Detail: "ping ok", Checked: summary.GeneratedAt})
- }
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(summary)
- }
-}
-
-// HandlePublicMatomo exposes the Matomo analytics settings for the viewer frontend.
-func HandlePublicMatomo() http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]any{
- "matomoUrl": config.MatomoURL(),
- "matomoSiteId": config.MatomoSiteID(),
- })
- }
-}
-
-// adminConfigResponse builds the admin configuration payload from runtime config
-// and read-only runtime facts.
-func adminConfigResponse(deps *Dependencies) map[string]any {
- resp := config.RuntimeConfigSnapshot()
- resp["rateLimitMax"] = config.RateLimitMax()
- resp["rateLimitWindowSec"] = int(config.RateLimitWindow().Seconds())
- resp["databaseDriver"] = deps.DBDriver
- resp["redisEnabled"] = deps.Redis != nil
- resp["sessionTTLHours"] = int(config.AdminSessionTTL().Hours())
- return resp
-}
-
-// HandleAdminConfig returns/updates runtime configuration.
-func HandleAdminConfig(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(adminConfigResponse(deps))
- case http.MethodPut:
- // Editable runtime fields (Matomo + HLS). Rate limit fields remain
- // env-driven and are intentionally read-only here.
- var payload struct {
- MatomoURL *string `json:"matomoUrl"`
- MatomoSiteID *string `json:"matomoSiteId"`
- HLSSegmentDuration *int `json:"hlsSegmentDuration"`
- HLSWindowSize *int `json:"hlsWindowSize"`
- }
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- http.Error(w, "invalid body", http.StatusBadRequest)
- return
- }
- if payload.MatomoURL != nil {
- if err := config.SetMatomoURL(*payload.MatomoURL); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- }
- if payload.MatomoSiteID != nil {
- if err := config.SetMatomoSiteID(*payload.MatomoSiteID); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- }
- if payload.HLSSegmentDuration != nil {
- if err := config.SetHLSSegmentDuration(*payload.HLSSegmentDuration); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- }
- if payload.HLSWindowSize != nil {
- if err := config.SetHLSWindowSize(*payload.HLSWindowSize); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- }
- if err := config.SaveRuntimeConfig(); err != nil {
- http.Error(w, fmt.Sprintf("failed to save config: %v", err), http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(adminConfigResponse(deps))
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- }
-}
-
-// HandleAdminRestart returns manual restart instructions.
-func HandleAdminRestart(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- service := r.URL.Query().Get("service")
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]string{
- "status": "restart_not_automated",
- "service": service,
- "message": fmt.Sprintf("Restart %s manually: docker compose -f docker-compose.prod.yml restart %s", service, service),
- })
- }
-}
-
-// HandleAdminUsers lists and manages users.
-func HandleAdminUsers(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- response := AdminUsersResponse{
- Users: []ActiveUser{},
- RedisEnabled: deps.Redis != nil,
- GeneratedAt: time.Now().UTC().Format(time.RFC3339),
- }
- if deps.Hub != nil && deps.Redis != nil {
- presences, err := deps.Hub.ScanPresence(context.Background())
- if err != nil {
- http.Error(w, fmt.Sprintf("failed to scan presence keys: %v", err), http.StatusInternalServerError)
- return
- }
- for _, p := range presences {
- response.Users = append(response.Users, ActiveUser{
- Room: p.Room,
- Username: p.Username,
- Online: p.Online,
- })
- }
- }
- sort.Slice(response.Users, func(i, j int) bool {
- if response.Users[i].Room == response.Users[j].Room {
- return response.Users[i].Username < response.Users[j].Username
- }
- return response.Users[i].Room < response.Users[j].Room
- })
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(response)
-
- case http.MethodPost:
- var payload struct {
- Action string `json:"action"`
- Username string `json:"username"`
- Duration int `json:"duration"`
- }
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- http.Error(w, "invalid body", http.StatusBadRequest)
- return
- }
- if payload.Action == "" || payload.Username == "" {
- http.Error(w, "action and username required", http.StatusBadRequest)
- return
- }
- if deps.Hub == nil {
- http.Error(w, "Redis unavailable", http.StatusServiceUnavailable)
- return
- }
- ctx := context.Background()
- switch payload.Action {
- case "mute":
- dur := time.Duration(payload.Duration) * time.Minute
- if dur <= 0 {
- dur = 10 * time.Minute
- }
- deps.Hub.MuteUser(ctx, payload.Username, dur)
- case "unmute":
- deps.Hub.UnmuteUser(ctx, payload.Username)
- case "ban":
- deps.Hub.BanUser(ctx, payload.Username)
- case "unban":
- deps.Hub.UnbanUser(ctx, payload.Username)
- default:
- http.Error(w, "unknown action", http.StatusBadRequest)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]string{"status": "ok", "action": payload.Action, "username": payload.Username})
-
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- }
-}
-
-// HandleAdminMedia lists and uploads media files.
-func HandleAdminMedia(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- files, err := media.ListFiles(deps.DB)
- if err != nil {
- http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(AdminMediaResponse{Files: files, GeneratedAt: time.Now().UTC().Format(time.RFC3339)})
- case http.MethodPost:
- if err := r.ParseMultipartForm(256 << 20); err != nil {
- http.Error(w, fmt.Sprintf("failed to parse upload: %v", err), http.StatusBadRequest)
- return
- }
- file, header, err := r.FormFile("file")
- if err != nil {
- http.Error(w, fmt.Sprintf("missing file upload: %v", err), http.StatusBadRequest)
- return
- }
- defer file.Close()
-
- fileName := filepath.Base(strings.TrimSpace(header.Filename))
- if fileName == "" {
- http.Error(w, "missing file name", http.StatusBadRequest)
- return
- }
- if !media.IsAllowedExtension(fileName) {
- http.Error(w, "unsupported file type", http.StatusBadRequest)
- return
- }
- if header.Size > config.MaxAdminUploadBytes {
- http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge)
- return
- }
-
- mediaDir := config.MediaDir()
- if err := os.MkdirAll(mediaDir, 0o755); err != nil {
- http.Error(w, fmt.Sprintf("failed to ensure media directory: %v", err), http.StatusInternalServerError)
- return
- }
-
- targetPath := media.UniqueTargetPath(mediaDir, fileName)
- targetFile, err := os.Create(targetPath)
- if err != nil {
- http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError)
- return
- }
-
- if _, err := io.Copy(targetFile, file); err != nil {
- targetFile.Close()
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("failed to store media file: %v", err), http.StatusInternalServerError)
- return
- }
- if err := targetFile.Close(); err != nil {
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("failed to finalize media file: %v", err), http.StatusInternalServerError)
- return
- }
-
- fileInfo, err := os.Stat(targetPath)
- if err != nil {
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("failed to read media file info: %v", err), http.StatusInternalServerError)
- return
- }
- if fileInfo.Size() > config.MaxAdminUploadBytes {
- _ = os.Remove(targetPath)
- http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge)
- return
- }
-
- detectedMime, err := media.DetectMime(targetPath)
- if err != nil {
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("failed to inspect media file: %v", err), http.StatusBadRequest)
- return
- }
- if !strings.HasPrefix(strings.ToLower(detectedMime), "video/") {
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("unsupported media content type: %s", detectedMime), http.StatusBadRequest)
- return
- }
-
- durationSeconds, err := media.ProbeDurationSeconds(targetPath)
- if err != nil {
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("failed to probe media duration: %v", err), http.StatusBadRequest)
- return
- }
-
- normResult := media.ValidateNormalization(targetPath)
- status := media.StatusReady
- if !normResult.Valid {
- status = media.StatusNeedsNormalization
- }
-
- filename := filepath.Base(targetPath)
- mediaRecord := db.Media{
- Filename: filename,
- OriginalName: fileName,
- Path: targetPath,
- SizeBytes: fileInfo.Size(),
- DurationSeconds: durationSeconds,
- Status: status,
- Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
- Tags: fmt.Sprintf(`"normalization":%v`, normResult.Valid),
- }
- if err := deps.DB.Create(&mediaRecord).Error; err != nil {
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("failed to persist media metadata: %v", err), http.StatusInternalServerError)
- return
- }
-
- if !normResult.Valid {
- go normalizeMediaRecord(deps, mediaRecord)
- }
-
- files, err := media.ListFiles(deps.DB)
- if err != nil {
- http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(AdminMediaResponse{Files: files, GeneratedAt: time.Now().UTC().Format(time.RFC3339)})
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- }
-}
-
-// HandleAdminMediaUploads manages chunked upload sessions.
-func HandleAdminMediaUploads(deps *Dependencies) http.HandlerFunc {
- basePath := "/api/admin/media/uploads"
- mediaDir := config.MediaDir()
-
- return func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == basePath {
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- var payload AdminMediaUploadInitRequest
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
- return
- }
-
- fileName := filepath.Base(strings.TrimSpace(payload.FileName))
- if fileName == "" {
- http.Error(w, "file name is required", http.StatusBadRequest)
- return
- }
- if !media.IsAllowedExtension(fileName) {
- http.Error(w, "unsupported file type", http.StatusBadRequest)
- return
- }
- if payload.FileSize <= 0 {
- http.Error(w, "file size must be positive", http.StatusBadRequest)
- return
- }
- if payload.FileSize > config.MaxAdminUploadBytes {
- http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge)
- return
- }
- if !media.IsAllowedMime(payload.MimeType) {
- http.Error(w, "unsupported mime type", http.StatusBadRequest)
- return
- }
-
- chunkSize := payload.ChunkSize
- if chunkSize <= 0 {
- chunkSize = config.DefaultAdminChunkSize
- }
- if chunkSize > config.MaxAdminChunkSize {
- http.Error(w, "chunk size exceeds server limit", http.StatusBadRequest)
- return
- }
-
- expectedChunks := int((payload.FileSize + chunkSize - 1) / chunkSize)
- if payload.TotalChunks <= 0 {
- payload.TotalChunks = expectedChunks
- }
- if payload.TotalChunks != expectedChunks {
- http.Error(w, "total chunk count mismatch", http.StatusBadRequest)
- return
- }
-
- uploadID := strings.TrimSpace(payload.UploadID)
- if uploadID != "" && !media.ValidateUploadID(uploadID) {
- http.Error(w, "invalid upload id", http.StatusBadRequest)
- return
- }
-
- now := time.Now().UTC().Format(time.RFC3339)
- session := AdminMediaUploadSession{}
-
- if uploadID != "" {
- loaded, err := loadUploadSession(mediaDir, uploadID)
- if err == nil {
- if loaded.FileName != fileName || loaded.FileSize != payload.FileSize {
- http.Error(w, "upload id does not match selected file", http.StatusConflict)
- return
- }
- session = loaded
- } else if !os.IsNotExist(err) {
- http.Error(w, fmt.Sprintf("failed to resume upload session: %v", err), http.StatusInternalServerError)
- return
- }
- }
-
- if session.UploadID == "" {
- randomID, err := auth.GenerateToken()
- if err != nil {
- http.Error(w, fmt.Sprintf("failed to create upload session: %v", err), http.StatusInternalServerError)
- return
- }
- session = AdminMediaUploadSession{
- UploadID: randomID,
- FileName: fileName,
- FileSize: payload.FileSize,
- MimeType: payload.MimeType,
- ChunkSize: chunkSize,
- TotalChunks: payload.TotalChunks,
- CreatedAt: now,
- UpdatedAt: now,
- }
- } else {
- session.UpdatedAt = now
- }
-
- if err := saveUploadSession(mediaDir, session); err != nil {
- http.Error(w, fmt.Sprintf("failed to persist upload session: %v", err), http.StatusInternalServerError)
- return
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(buildUploadStatus(mediaDir, session))
- return
- }
-
- if !strings.HasPrefix(r.URL.Path, basePath+"/") {
- http.NotFound(w, r)
- return
- }
-
- remainder := strings.TrimPrefix(r.URL.Path, basePath+"/")
- parts := strings.Split(strings.Trim(remainder, "/"), "/")
- if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
- http.Error(w, "missing upload id", http.StatusBadRequest)
- return
- }
-
- uploadID := strings.TrimSpace(parts[0])
- if !media.ValidateUploadID(uploadID) {
- http.Error(w, "invalid upload id", http.StatusBadRequest)
- return
- }
-
- session, err := loadUploadSession(mediaDir, uploadID)
- if err != nil {
- if os.IsNotExist(err) {
- http.Error(w, "upload session not found", http.StatusNotFound)
- return
- }
- http.Error(w, fmt.Sprintf("failed to read upload session: %v", err), http.StatusInternalServerError)
- return
- }
-
- if len(parts) == 1 {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(buildUploadStatus(mediaDir, session))
- return
- }
-
- if len(parts) != 2 {
- http.NotFound(w, r)
- return
- }
-
- subresource := parts[1]
- switch subresource {
- case "chunk":
- if r.Method != http.MethodPut {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- chunkIndexRaw := strings.TrimSpace(r.URL.Query().Get("index"))
- chunkIndex, err := strconv.Atoi(chunkIndexRaw)
- if err != nil || chunkIndex < 0 || chunkIndex >= session.TotalChunks {
- http.Error(w, "invalid chunk index", http.StatusBadRequest)
- return
- }
-
- chunkPath := media.UploadChunkPath(mediaDir, uploadID, chunkIndex)
- tmpPath := chunkPath + ".tmp"
-
- chunkFile, err := os.Create(tmpPath)
- if err != nil {
- http.Error(w, fmt.Sprintf("failed to open chunk target: %v", err), http.StatusInternalServerError)
- return
- }
-
- maxRead := session.ChunkSize + 1
- written, copyErr := io.Copy(chunkFile, io.LimitReader(r.Body, maxRead))
- closeErr := chunkFile.Close()
- if copyErr != nil || closeErr != nil {
- _ = os.Remove(tmpPath)
- http.Error(w, "failed to persist upload chunk", http.StatusInternalServerError)
- return
- }
-
- expectedMax := session.ChunkSize
- if chunkIndex == session.TotalChunks-1 {
- remaining := session.FileSize - (int64(chunkIndex) * session.ChunkSize)
- if remaining > 0 {
- expectedMax = remaining
- }
- }
- if written <= 0 || written > expectedMax {
- _ = os.Remove(tmpPath)
- http.Error(w, "invalid chunk size", http.StatusBadRequest)
- return
- }
-
- if err := os.Rename(tmpPath, chunkPath); err != nil {
- _ = os.Remove(tmpPath)
- http.Error(w, fmt.Sprintf("failed to finalize chunk: %v", err), http.StatusInternalServerError)
- return
- }
-
- session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
- if err := saveUploadSession(mediaDir, session); err != nil {
- http.Error(w, fmt.Sprintf("failed to update upload session: %v", err), http.StatusInternalServerError)
- return
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(buildUploadStatus(mediaDir, session))
-
- case "complete":
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- if media.CountUploadedChunks(mediaDir, uploadID, session.TotalChunks) != session.TotalChunks {
- http.Error(w, "upload is incomplete", http.StatusBadRequest)
- return
- }
-
- if err := os.MkdirAll(mediaDir, 0o755); err != nil {
- http.Error(w, fmt.Sprintf("failed to ensure media directory: %v", err), http.StatusInternalServerError)
- return
- }
-
- targetPath := media.UniqueTargetPath(mediaDir, session.FileName)
- targetFile, err := os.Create(targetPath)
- if err != nil {
- http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError)
- return
- }
-
- var totalWritten int64
- for i := 0; i < session.TotalChunks; i++ {
- chunkPath := media.UploadChunkPath(mediaDir, uploadID, i)
- chunkReader, err := os.Open(chunkPath)
- if err != nil {
- targetFile.Close()
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("missing chunk %d", i), http.StatusBadRequest)
- return
- }
- written, err := io.Copy(targetFile, chunkReader)
- _ = chunkReader.Close()
- if err != nil {
- targetFile.Close()
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("failed to assemble media file: %v", err), http.StatusInternalServerError)
- return
- }
- totalWritten += written
- }
-
- if err := targetFile.Close(); err != nil {
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("failed to finalize media file: %v", err), http.StatusInternalServerError)
- return
- }
-
- if totalWritten != session.FileSize {
- _ = os.Remove(targetPath)
- http.Error(w, "assembled file size mismatch", http.StatusBadRequest)
- return
- }
-
- detectedMime, err := media.DetectMime(targetPath)
- if err != nil {
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("failed to inspect media file: %v", err), http.StatusBadRequest)
- return
- }
- if !strings.HasPrefix(strings.ToLower(detectedMime), "video/") {
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("unsupported media content type: %s", detectedMime), http.StatusBadRequest)
- return
- }
-
- durationSeconds, err := media.ProbeDurationSeconds(targetPath)
- if err != nil {
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("failed to probe media duration: %v", err), http.StatusBadRequest)
- return
- }
-
- fileInfo, err := os.Stat(targetPath)
- if err != nil {
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("failed to read media file info: %v", err), http.StatusInternalServerError)
- return
- }
-
- normResult := media.ValidateNormalization(targetPath)
- status := media.StatusReady
- if !normResult.Valid {
- status = media.StatusNeedsNormalization
- }
-
- filename := filepath.Base(targetPath)
- mediaRecord := db.Media{
- Filename: filename,
- OriginalName: session.FileName,
- Path: targetPath,
- SizeBytes: fileInfo.Size(),
- DurationSeconds: durationSeconds,
- Status: status,
- Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
- Tags: fmt.Sprintf(`"normalization":%v`, normResult.Valid),
- }
- if err := deps.DB.Create(&mediaRecord).Error; err != nil {
- _ = os.Remove(targetPath)
- http.Error(w, fmt.Sprintf("failed to persist media metadata: %v", err), http.StatusInternalServerError)
- return
- }
-
- if !normResult.Valid {
- go normalizeMediaRecord(deps, mediaRecord)
- }
-
- _ = os.RemoveAll(media.UploadSessionDir(mediaDir, uploadID))
-
- files, err := media.ListFiles(deps.DB)
- if err != nil {
- http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(AdminMediaResponse{Files: files, GeneratedAt: time.Now().UTC().Format(time.RFC3339)})
- default:
- http.NotFound(w, r)
- }
- }
-}
-
-// HandleAdminMediaByID handles single media item CRUD.
-func HandleAdminMediaByID(deps *Dependencies) http.HandlerFunc {
- mediaDir := config.MediaDir()
- return func(w http.ResponseWriter, r *http.Request) {
- if strings.HasSuffix(r.URL.Path, "/normalize") {
- handleNormalizeMedia(deps, w, r)
- return
- }
-
- mediaID, err := media.GetMediaIDFromPath(r.URL.Path, "/api/admin/media/")
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
-
- var record db.Media
- if err := deps.DB.First(&record, mediaID).Error; err != nil {
- http.Error(w, "media item not found", http.StatusNotFound)
- return
- }
-
- switch r.Method {
- case http.MethodGet:
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(mediaEntryFromRecord(record))
- case http.MethodPut:
- var payload struct {
- Status string `json:"status"`
- Title string `json:"title"`
- Tags string `json:"tags"`
- }
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
- return
- }
- updates := map[string]any{}
- if payload.Status != "" {
- updates["status"] = strings.TrimSpace(payload.Status)
- }
- if payload.Title != "" {
- updates["title"] = strings.TrimSpace(payload.Title)
- }
- updates["tags"] = strings.TrimSpace(payload.Tags)
- if err := deps.DB.Model(&record).Updates(updates).Error; err != nil {
- http.Error(w, fmt.Sprintf("failed to update media metadata: %v", err), http.StatusInternalServerError)
- return
- }
- if err := deps.DB.First(&record, mediaID).Error; err != nil {
- http.Error(w, "media item not found after update", http.StatusNotFound)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(mediaEntryFromRecord(record))
- case http.MethodDelete:
- absMediaDir, err := filepath.Abs(mediaDir)
- if err != nil {
- http.Error(w, fmt.Sprintf("failed to resolve media directory: %v", err), http.StatusInternalServerError)
- return
- }
- absTargetPath, err := filepath.Abs(record.Path)
- if err != nil {
- http.Error(w, fmt.Sprintf("failed to resolve media path: %v", err), http.StatusInternalServerError)
- return
- }
- prefix := absMediaDir + string(os.PathSeparator)
- if absTargetPath != absMediaDir && !strings.HasPrefix(absTargetPath, prefix) {
- http.Error(w, "media path consistency check failed", http.StatusConflict)
- return
- }
- if err := os.Remove(absTargetPath); err != nil && !os.IsNotExist(err) {
- http.Error(w, fmt.Sprintf("failed to delete media file: %v", err), http.StatusInternalServerError)
- return
- }
- if err := deps.DB.Delete(&record).Error; err != nil {
- http.Error(w, fmt.Sprintf("failed to delete media record: %v", err), http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]any{"deleted": true, "id": mediaID})
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- }
-}
-
-// normalizeMediaRecord runs ffmpeg normalization for a media record and updates
-// its status, path, duration, and size. Safe to run asynchronously.
-func normalizeMediaRecord(deps *Dependencies, record db.Media) {
- src := record.Path
- dst := strings.TrimSuffix(src, filepath.Ext(src)) + ".normalized.mp4"
-
- if err := deps.DB.Model(&record).Update("status", media.StatusNormalizing).Error; err != nil {
- return
- }
-
- duration, size, err := media.NormalizeVideo(src, dst)
- if err != nil {
- _ = deps.DB.Model(&record).Updates(map[string]any{
- "status": media.StatusNormalizationFailed,
- "tags": `"normalization":false`,
- }).Error
- return
- }
-
- _ = deps.DB.Model(&record).Updates(map[string]any{
- "path": dst,
- "filename": filepath.Base(dst),
- "size_bytes": size,
- "duration_seconds": duration,
- "status": media.StatusReady,
- "tags": `"normalization":true`,
- }).Error
-}
-
-// handleNormalizeMedia handles POST /api/admin/media/{id}/normalize.
-func handleNormalizeMedia(deps *Dependencies, w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- idPart := strings.TrimSuffix(r.URL.Path, "/normalize")
- idPart = strings.TrimPrefix(idPart, "/api/admin/media/")
- idPart = strings.Trim(idPart, "/")
- mediaID, err := strconv.ParseUint(idPart, 10, 64)
- if err != nil || mediaID == 0 {
- http.Error(w, "invalid media id", http.StatusBadRequest)
- return
- }
-
- var record db.Media
- if err := deps.DB.First(&record, mediaID).Error; err != nil {
- http.Error(w, "media item not found", http.StatusNotFound)
- return
- }
-
- if record.Status == media.StatusNormalizing {
- http.Error(w, "normalization already in progress", http.StatusConflict)
- return
- }
-
- record.Status = media.StatusNormalizing
- if err := deps.DB.Model(&record).Update("status", media.StatusNormalizing).Error; err != nil {
- http.Error(w, fmt.Sprintf("failed to update media status: %v", err), http.StatusInternalServerError)
- return
- }
-
- go normalizeMediaRecord(deps, record)
-
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusAccepted)
- json.NewEncoder(w).Encode(mediaEntryFromRecord(record))
-}
-
-// HandleAdminChatLog lists chat log entries.
-func HandleAdminChatLog(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- query := r.URL.Query()
- room := strings.TrimSpace(query.Get("room"))
- limit := 50
- if rawLimit := strings.TrimSpace(query.Get("limit")); rawLimit != "" {
- parsedLimit, err := strconv.Atoi(rawLimit)
- if err != nil || parsedLimit <= 0 {
- http.Error(w, "invalid limit", http.StatusBadRequest)
- return
- }
- if parsedLimit > 200 {
- parsedLimit = 200
- }
- limit = parsedLimit
- }
-
- dbQuery := deps.DB.Model(&db.Message{}).Where("type <> ?", "system")
- if room != "" {
- dbQuery = dbQuery.Where("room = ?", room)
- }
- if beforeRaw := strings.TrimSpace(query.Get("before")); beforeRaw != "" {
- beforeTime, err := time.Parse(time.RFC3339, beforeRaw)
- if err != nil {
- http.Error(w, "invalid before timestamp, use RFC3339 format", http.StatusBadRequest)
- return
- }
- dbQuery = dbQuery.Where("timestamp < ?", beforeTime)
- }
- if username := strings.TrimSpace(query.Get("username")); username != "" {
- dbQuery = dbQuery.Where("username LIKE ?", "%"+username+"%")
- }
- if text := strings.TrimSpace(query.Get("text")); text != "" {
- dbQuery = dbQuery.Where("text LIKE ?", "%"+text+"%")
- }
-
- var total int64
- if err := dbQuery.Count(&total).Error; err != nil {
- http.Error(w, fmt.Sprintf("failed to count chat log entries: %v", err), http.StatusInternalServerError)
- return
- }
-
- var messages []db.Message
- if err := dbQuery.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
- http.Error(w, fmt.Sprintf("failed to load chat log entries: %v", err), http.StatusInternalServerError)
- return
- }
-
- entries := make([]ChatLogEntry, len(messages))
- for i, msg := range messages {
- entries[i] = ChatLogEntry{
- ID: msg.ID,
- Room: msg.Room,
- Username: msg.Username,
- Text: msg.Text,
- Timestamp: msg.Timestamp,
- Type: msg.Type,
- }
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(ChatLogListResponse{Entries: entries, Total: total})
- }
-}
-
-// HandleAdminChatLogByID returns or deletes a single chat log entry.
-func HandleAdminChatLogByID(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- idPart := strings.TrimPrefix(r.URL.Path, "/api/admin/chat-log/")
- idPart = strings.TrimSpace(idPart)
- if idPart == "" || strings.Contains(idPart, "/") {
- http.Error(w, "invalid chat log id", http.StatusBadRequest)
- return
- }
- id, err := strconv.ParseUint(idPart, 10, 64)
- if err != nil || id == 0 {
- http.Error(w, "invalid chat log id", http.StatusBadRequest)
- return
- }
-
- switch r.Method {
- case http.MethodGet:
- var msg db.Message
- if err := deps.DB.First(&msg, id).Error; err != nil {
- http.Error(w, "chat log entry not found", http.StatusNotFound)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(ChatLogEntry{
- ID: msg.ID,
- Room: msg.Room,
- Username: msg.Username,
- Text: msg.Text,
- Timestamp: msg.Timestamp,
- Type: msg.Type,
- })
- case http.MethodDelete:
- result := deps.DB.Delete(&db.Message{}, id)
- if result.Error != nil {
- http.Error(w, fmt.Sprintf("failed to delete chat log entry: %v", result.Error), http.StatusInternalServerError)
- return
- }
- if result.RowsAffected == 0 {
- http.Error(w, "chat log entry not found", http.StatusNotFound)
- return
- }
- w.WriteHeader(http.StatusNoContent)
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- }
-}
-
-// HandleAdminChatLogExport exports chat log as CSV.
-func HandleAdminChatLogExport(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- query := r.URL.Query()
- room := strings.TrimSpace(query.Get("room"))
- dbQuery := deps.DB.Model(&db.Message{})
- if room != "" {
- dbQuery = dbQuery.Where("room = ?", room)
- }
- if username := strings.TrimSpace(query.Get("username")); username != "" {
- dbQuery = dbQuery.Where("username LIKE ?", "%"+username+"%")
- }
-
- var messages []db.Message
- if err := dbQuery.Order("timestamp desc").Limit(10000).Find(&messages).Error; err != nil {
- http.Error(w, "failed to load messages", http.StatusInternalServerError)
- return
- }
-
- w.Header().Set("Content-Type", "text/csv; charset=utf-8")
- w.Header().Set("Content-Disposition", "attachment; filename=chat-log.csv")
- fmt.Fprintln(w, "id,room,username,type,text,timestamp")
- for _, msg := range messages {
- text := strings.ReplaceAll(msg.Text, `"`, `""`)
- fmt.Fprintf(w, "%d,%s,%s,%s,\"%s\",%s\n",
- msg.ID, msg.Room, msg.Username, msg.Type, text, msg.Timestamp.Format(time.RFC3339))
- }
- }
-}
-
-// HandleAdminChatLogBulk deletes multiple chat log entries.
-func HandleAdminChatLogBulk(deps *Dependencies) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodDelete {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- var payload ChatLogBulkDeleteRequest
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
- return
- }
- if len(payload.IDs) == 0 {
- http.Error(w, "ids array is required", http.StatusBadRequest)
- return
- }
- if len(payload.IDs) > 500 {
- http.Error(w, "maximum 500 ids per bulk request", http.StatusBadRequest)
- return
- }
- result := deps.DB.Delete(&db.Message{}, payload.IDs)
- if result.Error != nil {
- http.Error(w, fmt.Sprintf("failed to bulk delete chat log entries: %v", result.Error), http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]any{"deleted": result.RowsAffected})
- }
-}
-
-// --- Upload session helpers ---
-
-func loadUploadSession(mediaDir, uploadID string) (AdminMediaUploadSession, error) {
- var session AdminMediaUploadSession
- data, err := os.ReadFile(media.UploadSessionMetaPath(mediaDir, uploadID))
- if err != nil {
- return session, err
- }
- if err := json.Unmarshal(data, &session); err != nil {
- return session, err
- }
- return session, nil
-}
-
-func saveUploadSession(mediaDir string, session AdminMediaUploadSession) error {
- if err := os.MkdirAll(media.UploadSessionDir(mediaDir, session.UploadID), 0o755); err != nil {
- return err
- }
- data, err := json.MarshalIndent(session, "", " ")
- if err != nil {
- return err
- }
- return os.WriteFile(media.UploadSessionMetaPath(mediaDir, session.UploadID), data, 0o644)
-}
-
-func buildUploadStatus(mediaDir string, session AdminMediaUploadSession) AdminMediaUploadStatusResponse {
- uploadedChunks := media.CountUploadedChunks(mediaDir, session.UploadID, session.TotalChunks)
- progress := 0.0
- if session.TotalChunks > 0 {
- progress = (float64(uploadedChunks) / float64(session.TotalChunks)) * 100
- }
- return AdminMediaUploadStatusResponse{
- UploadID: session.UploadID,
- FileName: session.FileName,
- FileSize: session.FileSize,
- ChunkSize: session.ChunkSize,
- TotalChunks: session.TotalChunks,
- UploadedChunks: uploadedChunks,
- Progress: progress,
- }
-}
-
-func mediaEntryFromRecord(record db.Media) media.FileEntry {
- return media.FileEntry{
- ID: record.ID,
- Name: record.Filename,
- OriginalName: record.OriginalName,
- Path: record.Path,
- Size: record.SizeBytes,
- DurationSeconds: record.DurationSeconds,
- Runtime: media.FormatDuration(record.DurationSeconds),
- Status: record.Status,
- Title: record.Title,
- Tags: record.Tags,
- UploadedAt: record.UploadedAt.UTC().Format(time.RFC3339),
- ModifiedAt: record.UpdatedAt.UTC().Format(time.RFC3339),
- }
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+ "gorm.io/gorm"
+
+ "chat/src/auth"
+ "chat/src/config"
+ "chat/src/db"
+ "chat/src/media"
+ "chat/src/ratelimit"
+ "chat/src/ws"
+)
+
+// AdminOverview response payload.
+type AdminOverview struct {
+ Viewers int `json:"viewers"`
+ StoredMessages int64 `json:"storedMessages"`
+ ModerationActions int64 `json:"moderationActions"`
+ MediaCount int64 `json:"mediaCount"`
+ MediaTotalDurationSeconds int64 `json:"mediaTotalDurationSeconds"`
+ MediaTotalRuntime string `json:"mediaTotalRuntime"`
+ RedisEnabled bool `json:"redisEnabled"`
+ RedisClients int `json:"redisClients"`
+ RedisMemoryUsed string `json:"redisMemoryUsed"`
+ DatabaseDriver string `json:"databaseDriver"`
+ GeneratedAt string `json:"generatedAt"`
+}
+
+// PlaylistPayload for playlist read/write.
+type PlaylistPayload struct {
+ Content string `json:"content"`
+}
+
+// ModerationActionPayload for moderation actions.
+type ModerationActionPayload struct {
+ Action string `json:"action"`
+ Target string `json:"target"`
+ Moderator string `json:"moderator"`
+ Reason string `json:"reason"`
+ Room string `json:"room"`
+}
+
+// ModerationLogsResponse wraps moderation log entries.
+type ModerationLogsResponse struct {
+ Logs []db.ModerationLog `json:"logs"`
+}
+
+// RoomAnalytics for per-room message counts.
+type RoomAnalytics struct {
+ Room string `json:"room"`
+ MessageCount int64 `json:"messageCount"`
+}
+
+// AdminAnalytics response payload.
+type AdminAnalytics struct {
+ TotalMessages int64 `json:"totalMessages"`
+ MessagesLast24Hours int64 `json:"messagesLast24Hours"`
+ DistinctRooms int64 `json:"distinctRooms"`
+ ModerationActions int64 `json:"moderationActions"`
+ MediaCount int64 `json:"mediaCount"`
+ MediaTotalDurationSeconds int64 `json:"mediaTotalDurationSeconds"`
+ MediaTotalRuntime string `json:"mediaTotalRuntime"`
+ TopRooms []RoomAnalytics `json:"topRooms"`
+ GeneratedAt string `json:"generatedAt"`
+}
+
+// ServiceHealth for system summary.
+type ServiceHealth struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Detail string `json:"detail"`
+ Checked string `json:"checked"`
+}
+
+// AdminSystemSummary response payload.
+type AdminSystemSummary struct {
+ DatabaseDriver string `json:"databaseDriver"`
+ PlaylistPath string `json:"playlistPath"`
+ PlaylistExists bool `json:"playlistExists"`
+ RedisEnabled bool `json:"redisEnabled"`
+ RedisMemoryUsed string `json:"redisMemoryUsed"`
+ Services []ServiceHealth `json:"services"`
+ GeneratedAt string `json:"generatedAt"`
+}
+
+// ActiveUser for admin users list.
+type ActiveUser struct {
+ Username string `json:"username"`
+ Room string `json:"room"`
+ Online bool `json:"online"`
+}
+
+// AdminUsersResponse wraps active users.
+type AdminUsersResponse struct {
+ Users []ActiveUser `json:"users"`
+ RedisEnabled bool `json:"redisEnabled"`
+ GeneratedAt string `json:"generatedAt"`
+}
+
+// ChatLogEntry for chat log API.
+type ChatLogEntry struct {
+ ID uint `json:"id"`
+ Room string `json:"room"`
+ Username string `json:"username"`
+ Text string `json:"text"`
+ Timestamp time.Time `json:"timestamp"`
+ Type string `json:"type"`
+}
+
+// ChatLogListResponse wraps chat log entries.
+type ChatLogListResponse struct {
+ Entries []ChatLogEntry `json:"entries"`
+ Total int64 `json:"total"`
+}
+
+// ChatLogBulkDeleteRequest for bulk deletion.
+type ChatLogBulkDeleteRequest struct {
+ IDs []uint `json:"ids"`
+}
+
+// AdminScheduleResponse for schedule endpoint.
+type AdminScheduleResponse struct {
+ Entries []media.ScheduleEntry `json:"entries"`
+ TotalEntries int `json:"totalEntries"`
+ MatchedEntries int `json:"matchedEntries"`
+ TotalDurationSeconds int `json:"totalDurationSeconds"`
+ TotalRuntime string `json:"totalRuntime"`
+ GeneratedAt string `json:"generatedAt"`
+}
+
+// AdminMediaResponse wraps media file entries.
+type AdminMediaResponse struct {
+ Files []media.FileEntry `json:"files"`
+ GeneratedAt string `json:"generatedAt"`
+}
+
+// AdminMediaUploadInitRequest for chunked upload init.
+type AdminMediaUploadInitRequest struct {
+ UploadID string `json:"uploadId"`
+ FileName string `json:"fileName"`
+ FileSize int64 `json:"fileSize"`
+ MimeType string `json:"mimeType"`
+ ChunkSize int64 `json:"chunkSize"`
+ TotalChunks int `json:"totalChunks"`
+}
+
+// AdminMediaUploadSession tracks a chunked upload.
+type AdminMediaUploadSession struct {
+ UploadID string `json:"uploadId"`
+ FileName string `json:"fileName"`
+ FileSize int64 `json:"fileSize"`
+ MimeType string `json:"mimeType"`
+ ChunkSize int64 `json:"chunkSize"`
+ TotalChunks int `json:"totalChunks"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+// AdminMediaUploadStatusResponse for upload progress.
+type AdminMediaUploadStatusResponse struct {
+ UploadID string `json:"uploadId"`
+ FileName string `json:"fileName"`
+ FileSize int64 `json:"fileSize"`
+ ChunkSize int64 `json:"chunkSize"`
+ TotalChunks int `json:"totalChunks"`
+ UploadedChunks int `json:"uploadedChunks"`
+ Progress float64 `json:"progress"`
+}
+
+// AdminLoginPayload for login requests.
+type AdminLoginPayload struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+}
+
+// AdminSessionResponse for session status.
+type AdminSessionResponse struct {
+ Authenticated bool `json:"authenticated"`
+ Username string `json:"username,omitempty"`
+}
+
+// Dependencies holds all shared dependencies for handlers.
+type Dependencies struct {
+ DB *gorm.DB
+ Redis db.RedisClient
+ Hub *ws.Hub
+ SessionStore *auth.SessionStore
+ UserSessions *auth.UserSessionStore
+ DBDriver string
+}
+
+// parseRedisInfoValue extracts a value from Redis INFO output.
+func parseRedisInfoValue(info, key string) string {
+ prefix := key + ":"
+ for _, line := range strings.Split(info, "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, prefix) {
+ return strings.TrimSpace(strings.TrimPrefix(line, prefix))
+ }
+ }
+ return ""
+}
+
+// getAdminOverview builds the admin overview response.
+func getAdminOverview(deps *Dependencies) AdminOverview {
+ overview := AdminOverview{
+ RedisEnabled: deps.Redis != nil,
+ RedisMemoryUsed: "unavailable",
+ DatabaseDriver: deps.DBDriver,
+ MediaTotalRuntime: "00:00:00",
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ }
+
+ if deps.Hub != nil {
+ overview.Viewers = deps.Hub.ConnectedClients()
+ }
+
+ if deps.DB != nil {
+ deps.DB.Model(&db.Message{}).Count(&overview.StoredMessages)
+ deps.DB.Model(&db.ModerationLog{}).Count(&overview.ModerationActions)
+ deps.DB.Model(&db.Media{}).Count(&overview.MediaCount)
+ deps.DB.Model(&db.Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&overview.MediaTotalDurationSeconds)
+ overview.MediaTotalRuntime = media.FormatDuration(int(overview.MediaTotalDurationSeconds))
+ }
+
+ if deps.Redis != nil {
+ ctx := context.Background()
+ if info, err := deps.Redis.Info(ctx, "clients", "memory"); err == nil {
+ if value := parseRedisInfoValue(info, "connected_clients"); value != "" {
+ if count, convErr := strconv.Atoi(value); convErr == nil {
+ overview.RedisClients = count
+ }
+ }
+ if value := parseRedisInfoValue(info, "used_memory_human"); value != "" {
+ overview.RedisMemoryUsed = value
+ }
+ }
+ }
+
+ return overview
+}
+
+// HandleAdminLogin processes admin login requests.
+func HandleAdminLogin(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ clientIP := r.Header.Get("X-Forwarded-For")
+ if clientIP == "" {
+ clientIP = r.RemoteAddr
+ }
+
+ allowed, _ := ratelimit.CheckLoginRate(context.Background(), deps.Redis, clientIP)
+ if !allowed {
+ http.Error(w, "too many login attempts, try again later", http.StatusTooManyRequests)
+ return
+ }
+
+ var payload AdminLoginPayload
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ if payload.Username != config.AdminUsername() || payload.Password != config.AdminPassword() {
+ http.Error(w, "invalid credentials", http.StatusUnauthorized)
+ return
+ }
+
+ token, err := auth.GenerateToken()
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ expiresAt := time.Now().Add(config.AdminSessionTTL())
+ deps.SessionStore.Add(token, expiresAt)
+ auth.SetCookie(w, r, config.AdminSessionCookieName, token, expiresAt, http.SameSiteStrictMode)
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: config.AdminUsername()})
+ }
+}
+
+// HandleAdminLogout processes admin logout.
+func HandleAdminLogout(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ if token, ok := auth.GetTokenFromCookie(r, config.AdminSessionCookieName); ok {
+ deps.SessionStore.Remove(token)
+ }
+ auth.ClearCookie(w, r, config.AdminSessionCookieName, http.SameSiteStrictMode)
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: false})
+ }
+}
+
+// HandleAdminSession returns current session status.
+func HandleAdminSession(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ token, ok := auth.GetTokenFromCookie(r, config.AdminSessionCookieName)
+ if !ok || !deps.SessionStore.Validate(token) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: config.AdminUsername()})
+ }
+}
+
+// userSessionCookieName is the cookie for registered chat users.
+const userSessionCookieName = "linearvoid_user_session"
+
+// UserCredentialsPayload is used by registration and login.
+type UserCredentialsPayload struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+}
+
+// validateUsername checks the username length constraints.
+func validateUsername(username string) error {
+ if len(username) < 3 || len(username) > 32 {
+ return fmt.Errorf("username must be 3-32 characters")
+ }
+ return nil
+}
+
+// validatePassword checks the password length constraints.
+func validatePassword(password string) error {
+ if len(password) < 6 {
+ return fmt.Errorf("password must be at least 6 characters")
+ }
+ return nil
+}
+
+// startUserSession issues a user session cookie for the given username.
+func startUserSession(deps *Dependencies, w http.ResponseWriter, r *http.Request, username string) {
+ token, err := auth.GenerateToken()
+ if err != nil {
+ return
+ }
+ expiresAt := time.Now().Add(config.UserSessionTTL())
+ deps.UserSessions.Add(token, username, expiresAt)
+ auth.SetCookie(w, r, userSessionCookieName, token, expiresAt, auth.SameSiteForUserCookie(r))
+}
+
+// HandleUserRegister creates a new chat user and signs them in.
+func HandleUserRegister(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var payload UserCredentialsPayload
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, "invalid body", http.StatusBadRequest)
+ return
+ }
+ username := strings.TrimSpace(payload.Username)
+ if err := validateUsername(username); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if err := validatePassword(payload.Password); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ var count int64
+ deps.DB.Model(&db.User{}).Where("username = ?", username).Count(&count)
+ if count > 0 {
+ http.Error(w, "username already taken", http.StatusConflict)
+ return
+ }
+
+ hash, err := bcrypt.GenerateFromPassword([]byte(payload.Password), bcrypt.DefaultCost)
+ if err != nil {
+ http.Error(w, "failed to hash password", http.StatusInternalServerError)
+ return
+ }
+ user := db.User{Username: username, PasswordHash: string(hash)}
+ if err := deps.DB.Create(&user).Error; err != nil {
+ http.Error(w, "failed to create user", http.StatusInternalServerError)
+ return
+ }
+
+ startUserSession(deps, w, r, user.Username)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"username": user.Username})
+ }
+}
+
+// HandleUserLogin verifies credentials and signs the user in.
+func HandleUserLogin(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var payload UserCredentialsPayload
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, "invalid body", http.StatusBadRequest)
+ return
+ }
+ username := strings.TrimSpace(payload.Username)
+
+ var user db.User
+ if err := deps.DB.Where("username = ?", username).First(&user).Error; err != nil {
+ http.Error(w, "invalid credentials", http.StatusUnauthorized)
+ return
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(payload.Password)); err != nil {
+ http.Error(w, "invalid credentials", http.StatusUnauthorized)
+ return
+ }
+
+ startUserSession(deps, w, r, user.Username)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"username": user.Username})
+ }
+}
+
+// HandleUserSession returns the current user session state.
+func HandleUserSession(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ token, _ := auth.GetTokenFromCookie(r, userSessionCookieName)
+ username, ok := deps.UserSessions.Username(token)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "authenticated": ok,
+ "username": username,
+ })
+ }
+}
+
+// HandleUserLogout clears the user session.
+func HandleUserLogout(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if token, ok := auth.GetTokenFromCookie(r, userSessionCookieName); ok {
+ deps.UserSessions.Remove(token)
+ }
+ auth.ClearCookie(w, r, userSessionCookieName, auth.SameSiteForUserCookie(r))
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]bool{"ok": true})
+ }
+}
+
+// currentUsername returns the authenticated username from the user session cookie.
+func currentUsername(deps *Dependencies, r *http.Request) (string, bool) {
+ token, ok := auth.GetTokenFromCookie(r, userSessionCookieName)
+ if !ok {
+ return "", false
+ }
+ return deps.UserSessions.Username(token)
+}
+
+// dmRoomFor returns the canonical DM room for two users (sorted, prefixed).
+func dmRoomFor(a, b string) string {
+ if a > b {
+ a, b = b, a
+ }
+ return "dm:" + a + "-" + b
+}
+
+// HandleUserDMHistory returns paginated private message history with a target user.
+func HandleUserDMHistory(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ username, ok := currentUsername(deps, r)
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ target := strings.TrimSpace(r.URL.Query().Get("with"))
+ if target == "" {
+ http.Error(w, "missing target user", http.StatusBadRequest)
+ return
+ }
+ limit := 50
+ if raw := r.URL.Query().Get("limit"); raw != "" {
+ if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 {
+ limit = n
+ }
+ }
+ room := dmRoomFor(username, target)
+
+ query := deps.DB.Where("room = ?", room)
+ if beforeRaw := r.URL.Query().Get("before"); beforeRaw != "" {
+ if t, err := time.Parse(time.RFC3339, beforeRaw); err == nil {
+ query = query.Where("timestamp < ?", t)
+ }
+ }
+ var messages []db.Message
+ if err := query.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
+ http.Error(w, "failed to load history", http.StatusInternalServerError)
+ return
+ }
+ for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
+ messages[i], messages[j] = messages[j], messages[i]
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(messages)
+ }
+}
+
+// HandleUserDMSearch searches private messages with a target user.
+func HandleUserDMSearch(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ username, ok := currentUsername(deps, r)
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ target := strings.TrimSpace(r.URL.Query().Get("with"))
+ q := strings.TrimSpace(r.URL.Query().Get("q"))
+ if target == "" || q == "" {
+ http.Error(w, "missing target or query", http.StatusBadRequest)
+ return
+ }
+ limit := 50
+ if raw := r.URL.Query().Get("limit"); raw != "" {
+ if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 {
+ limit = n
+ }
+ }
+ room := dmRoomFor(username, target)
+ var messages []db.Message
+ if err := deps.DB.Where("room = ? AND LOWER(text) LIKE ?", room, "%"+strings.ToLower(q)+"%").
+ Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
+ http.Error(w, "failed to search", http.StatusInternalServerError)
+ return
+ }
+ for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
+ messages[i], messages[j] = messages[j], messages[i]
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(messages)
+ }
+}
+
+// HandleAdminOverview returns the admin dashboard overview.
+func HandleAdminOverview(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(getAdminOverview(deps))
+ }
+}
+
+// HandleAdminPlaylist reads/writes the playlist file.
+func HandleAdminPlaylist(deps *Dependencies) http.HandlerFunc {
+ playlistPath := config.PlaylistPath()
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ content, err := os.ReadFile(playlistPath)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to read playlist: %v", err), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(PlaylistPayload{Content: string(content)})
+ case http.MethodPut:
+ var payload PlaylistPayload
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
+ return
+ }
+ if err := os.MkdirAll(filepath.Dir(playlistPath), 0o755); err != nil {
+ http.Error(w, fmt.Sprintf("failed to ensure playlist directory: %v", err), http.StatusInternalServerError)
+ return
+ }
+ content := payload.Content
+ if content != "" && !strings.HasSuffix(content, "\n") {
+ content += "\n"
+ }
+ if err := os.WriteFile(playlistPath, []byte(content), 0o644); err != nil {
+ http.Error(w, fmt.Sprintf("failed to save playlist: %v", err), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(PlaylistPayload{Content: content})
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ }
+}
+
+// HandlePublicSchedule returns playlist schedule without auth.
+func HandlePublicSchedule(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ content, err := os.ReadFile(config.PlaylistPath())
+ if err != nil {
+ http.Error(w, "schedule unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ entries := media.ParsePlaylist(string(content))
+ var mediaRecords []db.Media
+ if deps.DB != nil {
+ deps.DB.Find(&mediaRecords)
+ }
+ mediaByName := make(map[string]db.Media, len(mediaRecords))
+ for _, m := range mediaRecords {
+ mediaByName[strings.ToLower(m.Filename)] = m
+ }
+ type PublicEntry struct {
+ Index int `json:"index"`
+ Label string `json:"label"`
+ Title string `json:"title"`
+ Status string `json:"status"`
+ }
+ output := make([]PublicEntry, 0, len(entries))
+ for _, e := range entries {
+ pe := PublicEntry{Index: e.Index, Label: e.Label, Status: "pending"}
+ if m, ok := mediaByName[strings.ToLower(filepath.Base(e.Path))]; ok {
+ pe.Title = m.Title
+ pe.Status = m.Status
+ } else {
+ pe.Title = filepath.Base(e.Path)
+ }
+ output = append(output, pe)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "entries": output,
+ "total": len(output),
+ "generatedAt": time.Now().UTC().Format(time.RFC3339),
+ })
+ }
+}
+
+// HandleAdminSchedule returns the full schedule with media metadata.
+func HandleAdminSchedule(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ content, err := os.ReadFile(config.PlaylistPath())
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to read schedule playlist: %v", err), http.StatusInternalServerError)
+ return
+ }
+ entries := media.ParsePlaylist(string(content))
+
+ var mediaRecords []db.Media
+ if err := deps.DB.Find(&mediaRecords).Error; err != nil {
+ http.Error(w, fmt.Sprintf("failed to load media metadata: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ mediaByFilename := make(map[string]db.Media, len(mediaRecords))
+ mediaByPath := make(map[string]db.Media, len(mediaRecords))
+ for _, record := range mediaRecords {
+ mediaByFilename[strings.ToLower(record.Filename)] = record
+ mediaByPath[strings.ToLower(filepath.Clean(record.Path))] = record
+ }
+
+ totalDurationSeconds := 0
+ matchedEntries := 0
+ for i := range entries {
+ entry := &entries[i]
+ entry.Status = "missing"
+ entry.Runtime = "00:00:00"
+
+ cleanPath := strings.ToLower(filepath.Clean(entry.Path))
+ baseName := strings.ToLower(filepath.Base(entry.Path))
+
+ record, ok := mediaByPath[cleanPath]
+ if !ok {
+ record, ok = mediaByFilename[baseName]
+ }
+ if !ok {
+ continue
+ }
+
+ entry.DurationSeconds = record.DurationSeconds
+ entry.Runtime = media.FormatDuration(record.DurationSeconds)
+ entry.Status = record.Status
+ entry.Title = record.Title
+ totalDurationSeconds += record.DurationSeconds
+ matchedEntries++
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(AdminScheduleResponse{
+ Entries: entries,
+ TotalEntries: len(entries),
+ MatchedEntries: matchedEntries,
+ TotalDurationSeconds: totalDurationSeconds,
+ TotalRuntime: media.FormatDuration(totalDurationSeconds),
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ })
+ }
+}
+
+// HandleAdminScheduleOverrides manages CRUD for schedule overrides.
+func HandleAdminScheduleOverrides(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ var overrides []db.ScheduleOverride
+ deps.DB.Order("start_time asc").Find(&overrides)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(overrides)
+
+ case http.MethodPost:
+ var ov db.ScheduleOverride
+ if err := json.NewDecoder(r.Body).Decode(&ov); err != nil {
+ http.Error(w, "invalid request body", http.StatusBadRequest)
+ return
+ }
+ if ov.MediaID == 0 || ov.StartTime.IsZero() {
+ http.Error(w, "mediaId and startTime are required", http.StatusBadRequest)
+ return
+ }
+ var conflict int64
+ deps.DB.Model(&db.ScheduleOverride{}).
+ Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime).
+ Count(&conflict)
+ if conflict > 0 {
+ http.Error(w, fmt.Sprintf("conflict: %d overlapping override(s)", conflict), http.StatusConflict)
+ return
+ }
+ deps.DB.Create(&ov)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(ov)
+
+ case http.MethodDelete:
+ idStr := r.URL.Query().Get("id")
+ if idStr == "" {
+ http.Error(w, "id query param required", http.StatusBadRequest)
+ return
+ }
+ id, err := strconv.ParseUint(idStr, 10, 64)
+ if err != nil {
+ http.Error(w, "invalid id", http.StatusBadRequest)
+ return
+ }
+ if err := deps.DB.Delete(&db.ScheduleOverride{}, id).Error; err != nil {
+ http.Error(w, "failed to delete override", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ }
+}
+
+// HandleAdminModerationLogs returns moderation log entries.
+func HandleAdminModerationLogs(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ limit := 50
+ if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
+ parsedLimit, err := strconv.Atoi(rawLimit)
+ if err != nil || parsedLimit <= 0 {
+ http.Error(w, "invalid limit", http.StatusBadRequest)
+ return
+ }
+ if parsedLimit > 200 {
+ parsedLimit = 200
+ }
+ limit = parsedLimit
+ }
+ var logs []db.ModerationLog
+ if err := deps.DB.Order("timestamp desc").Limit(limit).Find(&logs).Error; err != nil {
+ http.Error(w, fmt.Sprintf("failed to load moderation logs: %v", err), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(ModerationLogsResponse{Logs: logs})
+ }
+}
+
+// HandleAdminModerationAction processes a moderation action.
+func HandleAdminModerationAction(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var payload ModerationActionPayload
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
+ return
+ }
+ payload.Action = strings.TrimSpace(payload.Action)
+ payload.Target = strings.TrimSpace(payload.Target)
+ payload.Moderator = strings.TrimSpace(payload.Moderator)
+ payload.Reason = strings.TrimSpace(payload.Reason)
+ payload.Room = strings.TrimSpace(payload.Room)
+
+ if payload.Action == "" || payload.Target == "" || payload.Reason == "" {
+ http.Error(w, "action, target, and reason are required", http.StatusBadRequest)
+ return
+ }
+ if payload.Moderator == "" {
+ payload.Moderator = "admin"
+ }
+ if payload.Room == "" {
+ payload.Room = "main"
+ }
+
+ // Log to DB
+ logEntry := db.ModerationLog{
+ Action: payload.Action,
+ Target: payload.Target,
+ Moderator: payload.Moderator,
+ Reason: payload.Reason,
+ Timestamp: time.Now(),
+ }
+ deps.DB.Create(&logEntry)
+
+ // Broadcast system message
+ if deps.Hub != nil {
+ deps.Hub.Broadcast(db.Message{
+ Room: payload.Room,
+ Username: "system",
+ Type: "system",
+ Text: fmt.Sprintf("Moderator %s %s %s for: %s", payload.Moderator, payload.Action, payload.Target, payload.Reason),
+ Timestamp: time.Now(),
+ })
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(payload)
+ }
+}
+
+// HandleAdminAnalytics returns analytics data.
+func HandleAdminAnalytics(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ analytics := AdminAnalytics{
+ TopRooms: []RoomAnalytics{},
+ MediaTotalRuntime: "00:00:00",
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ }
+ if deps.DB == nil {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(analytics)
+ return
+ }
+
+ deps.DB.Model(&db.Message{}).Count(&analytics.TotalMessages)
+ deps.DB.Model(&db.Message{}).
+ Where("timestamp >= ?", time.Now().Add(-24*time.Hour)).
+ Count(&analytics.MessagesLast24Hours)
+ deps.DB.Model(&db.Message{}).Distinct("room").Count(&analytics.DistinctRooms)
+ deps.DB.Model(&db.ModerationLog{}).Count(&analytics.ModerationActions)
+ deps.DB.Model(&db.Media{}).Count(&analytics.MediaCount)
+ deps.DB.Model(&db.Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&analytics.MediaTotalDurationSeconds)
+ analytics.MediaTotalRuntime = media.FormatDuration(int(analytics.MediaTotalDurationSeconds))
+
+ var topRooms []RoomAnalytics
+ deps.DB.Model(&db.Message{}).
+ Select("room, COUNT(*) as message_count").
+ Group("room").
+ Order("message_count DESC").
+ Limit(5).
+ Scan(&topRooms)
+ analytics.TopRooms = topRooms
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(analytics)
+ }
+}
+
+// checkServiceHealth probes a service health endpoint.
+func checkServiceHealth(name, url string) ServiceHealth {
+ checked := time.Now().UTC().Format(time.RFC3339)
+ resp, err := http.Get(url)
+ if err != nil {
+ return ServiceHealth{Name: name, Status: "down", Detail: err.Error(), Checked: checked}
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ status := "healthy"
+ if resp.StatusCode >= 400 {
+ status = "degraded"
+ }
+
+ detail := strings.TrimSpace(string(body))
+ if detail == "" {
+ detail = resp.Status
+ }
+
+ return ServiceHealth{Name: name, Status: status, Detail: detail, Checked: checked}
+}
+
+// HandleAdminSystemSummary returns system health information.
+func HandleAdminSystemSummary(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ summary := AdminSystemSummary{
+ DatabaseDriver: deps.DBDriver,
+ PlaylistPath: config.PlaylistPath(),
+ RedisEnabled: deps.Redis != nil,
+ RedisMemoryUsed: "unavailable",
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ }
+
+ if _, err := os.Stat(summary.PlaylistPath); err == nil {
+ summary.PlaylistExists = true
+ }
+
+ if deps.Redis != nil {
+ if info, err := deps.Redis.Info(context.Background(), "memory"); err == nil {
+ if value := parseRedisInfoValue(info, "used_memory_human"); value != "" {
+ summary.RedisMemoryUsed = value
+ }
+ }
+ }
+
+ summary.Services = []ServiceHealth{
+ {Name: "chat", Status: "healthy", Detail: "local process", Checked: summary.GeneratedAt},
+ checkServiceHealth("playout", "http://playout:8081/health"),
+ }
+
+ if deps.Redis != nil {
+ if err := deps.Redis.Ping(context.Background()); err != nil {
+ summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "down", Detail: err.Error(), Checked: summary.GeneratedAt})
+ } else {
+ summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "healthy", Detail: "ping ok", Checked: summary.GeneratedAt})
+ }
+ } else {
+ summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "disabled", Detail: "client unavailable", Checked: summary.GeneratedAt})
+ }
+
+ if sqlDB, err := deps.DB.DB(); err == nil {
+ if err := sqlDB.Ping(); err != nil {
+ summary.Services = append(summary.Services, ServiceHealth{Name: "database", Status: "down", Detail: err.Error(), Checked: summary.GeneratedAt})
+ } else {
+ summary.Services = append(summary.Services, ServiceHealth{Name: "database", Status: "healthy", Detail: "ping ok", Checked: summary.GeneratedAt})
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(summary)
+ }
+}
+
+// HandlePublicMatomo exposes the Matomo analytics settings for the viewer frontend.
+func HandlePublicMatomo() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "matomoUrl": config.MatomoURL(),
+ "matomoSiteId": config.MatomoSiteID(),
+ })
+ }
+}
+
+// adminConfigResponse builds the admin configuration payload from runtime config
+// and read-only runtime facts.
+func adminConfigResponse(deps *Dependencies) map[string]any {
+ resp := config.RuntimeConfigSnapshot()
+ resp["rateLimitMax"] = config.RateLimitMax()
+ resp["rateLimitWindowSec"] = int(config.RateLimitWindow().Seconds())
+ resp["databaseDriver"] = deps.DBDriver
+ resp["redisEnabled"] = deps.Redis != nil
+ resp["sessionTTLHours"] = int(config.AdminSessionTTL().Hours())
+ return resp
+}
+
+// HandleAdminConfig returns/updates runtime configuration.
+func HandleAdminConfig(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(adminConfigResponse(deps))
+ case http.MethodPut:
+ // Editable runtime fields (Matomo + HLS). Rate limit fields remain
+ // env-driven and are intentionally read-only here.
+ var payload struct {
+ MatomoURL *string `json:"matomoUrl"`
+ MatomoSiteID *string `json:"matomoSiteId"`
+ HLSSegmentDuration *int `json:"hlsSegmentDuration"`
+ HLSWindowSize *int `json:"hlsWindowSize"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, "invalid body", http.StatusBadRequest)
+ return
+ }
+ if payload.MatomoURL != nil {
+ if err := config.SetMatomoURL(*payload.MatomoURL); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+ if payload.MatomoSiteID != nil {
+ if err := config.SetMatomoSiteID(*payload.MatomoSiteID); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+ if payload.HLSSegmentDuration != nil {
+ if err := config.SetHLSSegmentDuration(*payload.HLSSegmentDuration); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+ if payload.HLSWindowSize != nil {
+ if err := config.SetHLSWindowSize(*payload.HLSWindowSize); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+ if err := config.SaveRuntimeConfig(); err != nil {
+ http.Error(w, fmt.Sprintf("failed to save config: %v", err), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(adminConfigResponse(deps))
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ }
+}
+
+// HandleAdminRestart returns manual restart instructions.
+func HandleAdminRestart(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ service := r.URL.Query().Get("service")
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{
+ "status": "restart_not_automated",
+ "service": service,
+ "message": fmt.Sprintf("Restart %s manually: docker compose -f docker-compose.prod.yml restart %s", service, service),
+ })
+ }
+}
+
+// HandleAdminUsers lists and manages users.
+func HandleAdminUsers(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ response := AdminUsersResponse{
+ Users: []ActiveUser{},
+ RedisEnabled: deps.Redis != nil,
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ }
+ if deps.Hub != nil && deps.Redis != nil {
+ presences, err := deps.Hub.ScanPresence(context.Background())
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to scan presence keys: %v", err), http.StatusInternalServerError)
+ return
+ }
+ for _, p := range presences {
+ response.Users = append(response.Users, ActiveUser{
+ Room: p.Room,
+ Username: p.Username,
+ Online: p.Online,
+ })
+ }
+ }
+ sort.Slice(response.Users, func(i, j int) bool {
+ if response.Users[i].Room == response.Users[j].Room {
+ return response.Users[i].Username < response.Users[j].Username
+ }
+ return response.Users[i].Room < response.Users[j].Room
+ })
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+
+ case http.MethodPost:
+ var payload struct {
+ Action string `json:"action"`
+ Username string `json:"username"`
+ Duration int `json:"duration"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, "invalid body", http.StatusBadRequest)
+ return
+ }
+ if payload.Action == "" || payload.Username == "" {
+ http.Error(w, "action and username required", http.StatusBadRequest)
+ return
+ }
+ if deps.Hub == nil {
+ http.Error(w, "Redis unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ ctx := context.Background()
+ switch payload.Action {
+ case "mute":
+ dur := time.Duration(payload.Duration) * time.Minute
+ if dur <= 0 {
+ dur = 10 * time.Minute
+ }
+ deps.Hub.MuteUser(ctx, payload.Username, dur)
+ case "unmute":
+ deps.Hub.UnmuteUser(ctx, payload.Username)
+ case "ban":
+ deps.Hub.BanUser(ctx, payload.Username)
+ case "unban":
+ deps.Hub.UnbanUser(ctx, payload.Username)
+ default:
+ http.Error(w, "unknown action", http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok", "action": payload.Action, "username": payload.Username})
+
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ }
+}
+
+// HandleAdminMedia lists and uploads media files.
+func HandleAdminMedia(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ files, err := media.ListFiles(deps.DB)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(AdminMediaResponse{Files: files, GeneratedAt: time.Now().UTC().Format(time.RFC3339)})
+ case http.MethodPost:
+ if err := r.ParseMultipartForm(256 << 20); err != nil {
+ http.Error(w, fmt.Sprintf("failed to parse upload: %v", err), http.StatusBadRequest)
+ return
+ }
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ http.Error(w, fmt.Sprintf("missing file upload: %v", err), http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ fileName := filepath.Base(strings.TrimSpace(header.Filename))
+ if fileName == "" {
+ http.Error(w, "missing file name", http.StatusBadRequest)
+ return
+ }
+ if !media.IsAllowedExtension(fileName) {
+ http.Error(w, "unsupported file type", http.StatusBadRequest)
+ return
+ }
+ if header.Size > config.MaxAdminUploadBytes {
+ http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge)
+ return
+ }
+
+ mediaDir := config.MediaDir()
+ if err := os.MkdirAll(mediaDir, 0o755); err != nil {
+ http.Error(w, fmt.Sprintf("failed to ensure media directory: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ targetPath := media.UniqueTargetPath(mediaDir, fileName)
+ targetFile, err := os.Create(targetPath)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ if _, err := io.Copy(targetFile, file); err != nil {
+ targetFile.Close()
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("failed to store media file: %v", err), http.StatusInternalServerError)
+ return
+ }
+ if err := targetFile.Close(); err != nil {
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("failed to finalize media file: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ fileInfo, err := os.Stat(targetPath)
+ if err != nil {
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("failed to read media file info: %v", err), http.StatusInternalServerError)
+ return
+ }
+ if fileInfo.Size() > config.MaxAdminUploadBytes {
+ _ = os.Remove(targetPath)
+ http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge)
+ return
+ }
+
+ detectedMime, err := media.DetectMime(targetPath)
+ if err != nil {
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("failed to inspect media file: %v", err), http.StatusBadRequest)
+ return
+ }
+ if !strings.HasPrefix(strings.ToLower(detectedMime), "video/") {
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("unsupported media content type: %s", detectedMime), http.StatusBadRequest)
+ return
+ }
+
+ durationSeconds, err := media.ProbeDurationSeconds(targetPath)
+ if err != nil {
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("failed to probe media duration: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ normResult := media.ValidateNormalization(targetPath)
+ status := media.StatusReady
+ if !normResult.Valid {
+ status = media.StatusNeedsNormalization
+ }
+
+ filename := filepath.Base(targetPath)
+ mediaRecord := db.Media{
+ Filename: filename,
+ OriginalName: fileName,
+ Path: targetPath,
+ SizeBytes: fileInfo.Size(),
+ DurationSeconds: durationSeconds,
+ Status: status,
+ Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
+ Tags: fmt.Sprintf(`"normalization":%v`, normResult.Valid),
+ }
+ if err := deps.DB.Create(&mediaRecord).Error; err != nil {
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("failed to persist media metadata: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ if !normResult.Valid {
+ go normalizeMediaRecord(deps, mediaRecord)
+ }
+
+ files, err := media.ListFiles(deps.DB)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(AdminMediaResponse{Files: files, GeneratedAt: time.Now().UTC().Format(time.RFC3339)})
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ }
+}
+
+// HandleAdminMediaUploads manages chunked upload sessions.
+func HandleAdminMediaUploads(deps *Dependencies) http.HandlerFunc {
+ basePath := "/api/admin/media/uploads"
+ mediaDir := config.MediaDir()
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == basePath {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var payload AdminMediaUploadInitRequest
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ fileName := filepath.Base(strings.TrimSpace(payload.FileName))
+ if fileName == "" {
+ http.Error(w, "file name is required", http.StatusBadRequest)
+ return
+ }
+ if !media.IsAllowedExtension(fileName) {
+ http.Error(w, "unsupported file type", http.StatusBadRequest)
+ return
+ }
+ if payload.FileSize <= 0 {
+ http.Error(w, "file size must be positive", http.StatusBadRequest)
+ return
+ }
+ if payload.FileSize > config.MaxAdminUploadBytes {
+ http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge)
+ return
+ }
+ if !media.IsAllowedMime(payload.MimeType) {
+ http.Error(w, "unsupported mime type", http.StatusBadRequest)
+ return
+ }
+
+ chunkSize := payload.ChunkSize
+ if chunkSize <= 0 {
+ chunkSize = config.DefaultAdminChunkSize
+ }
+ if chunkSize > config.MaxAdminChunkSize {
+ http.Error(w, "chunk size exceeds server limit", http.StatusBadRequest)
+ return
+ }
+
+ expectedChunks := int((payload.FileSize + chunkSize - 1) / chunkSize)
+ if payload.TotalChunks <= 0 {
+ payload.TotalChunks = expectedChunks
+ }
+ if payload.TotalChunks != expectedChunks {
+ http.Error(w, "total chunk count mismatch", http.StatusBadRequest)
+ return
+ }
+
+ uploadID := strings.TrimSpace(payload.UploadID)
+ if uploadID != "" && !media.ValidateUploadID(uploadID) {
+ http.Error(w, "invalid upload id", http.StatusBadRequest)
+ return
+ }
+
+ now := time.Now().UTC().Format(time.RFC3339)
+ session := AdminMediaUploadSession{}
+
+ if uploadID != "" {
+ loaded, err := loadUploadSession(mediaDir, uploadID)
+ if err == nil {
+ if loaded.FileName != fileName || loaded.FileSize != payload.FileSize {
+ http.Error(w, "upload id does not match selected file", http.StatusConflict)
+ return
+ }
+ session = loaded
+ } else if !os.IsNotExist(err) {
+ http.Error(w, fmt.Sprintf("failed to resume upload session: %v", err), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ if session.UploadID == "" {
+ randomID, err := auth.GenerateToken()
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to create upload session: %v", err), http.StatusInternalServerError)
+ return
+ }
+ session = AdminMediaUploadSession{
+ UploadID: randomID,
+ FileName: fileName,
+ FileSize: payload.FileSize,
+ MimeType: payload.MimeType,
+ ChunkSize: chunkSize,
+ TotalChunks: payload.TotalChunks,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ } else {
+ session.UpdatedAt = now
+ }
+
+ if err := saveUploadSession(mediaDir, session); err != nil {
+ http.Error(w, fmt.Sprintf("failed to persist upload session: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(buildUploadStatus(mediaDir, session))
+ return
+ }
+
+ if !strings.HasPrefix(r.URL.Path, basePath+"/") {
+ http.NotFound(w, r)
+ return
+ }
+
+ remainder := strings.TrimPrefix(r.URL.Path, basePath+"/")
+ parts := strings.Split(strings.Trim(remainder, "/"), "/")
+ if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
+ http.Error(w, "missing upload id", http.StatusBadRequest)
+ return
+ }
+
+ uploadID := strings.TrimSpace(parts[0])
+ if !media.ValidateUploadID(uploadID) {
+ http.Error(w, "invalid upload id", http.StatusBadRequest)
+ return
+ }
+
+ session, err := loadUploadSession(mediaDir, uploadID)
+ if err != nil {
+ if os.IsNotExist(err) {
+ http.Error(w, "upload session not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, fmt.Sprintf("failed to read upload session: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ if len(parts) == 1 {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(buildUploadStatus(mediaDir, session))
+ return
+ }
+
+ if len(parts) != 2 {
+ http.NotFound(w, r)
+ return
+ }
+
+ subresource := parts[1]
+ switch subresource {
+ case "chunk":
+ if r.Method != http.MethodPut {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ chunkIndexRaw := strings.TrimSpace(r.URL.Query().Get("index"))
+ chunkIndex, err := strconv.Atoi(chunkIndexRaw)
+ if err != nil || chunkIndex < 0 || chunkIndex >= session.TotalChunks {
+ http.Error(w, "invalid chunk index", http.StatusBadRequest)
+ return
+ }
+
+ chunkPath := media.UploadChunkPath(mediaDir, uploadID, chunkIndex)
+ tmpPath := chunkPath + ".tmp"
+
+ chunkFile, err := os.Create(tmpPath)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to open chunk target: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ maxRead := session.ChunkSize + 1
+ written, copyErr := io.Copy(chunkFile, io.LimitReader(r.Body, maxRead))
+ closeErr := chunkFile.Close()
+ if copyErr != nil || closeErr != nil {
+ _ = os.Remove(tmpPath)
+ http.Error(w, "failed to persist upload chunk", http.StatusInternalServerError)
+ return
+ }
+
+ expectedMax := session.ChunkSize
+ if chunkIndex == session.TotalChunks-1 {
+ remaining := session.FileSize - (int64(chunkIndex) * session.ChunkSize)
+ if remaining > 0 {
+ expectedMax = remaining
+ }
+ }
+ if written <= 0 || written > expectedMax {
+ _ = os.Remove(tmpPath)
+ http.Error(w, "invalid chunk size", http.StatusBadRequest)
+ return
+ }
+
+ if err := os.Rename(tmpPath, chunkPath); err != nil {
+ _ = os.Remove(tmpPath)
+ http.Error(w, fmt.Sprintf("failed to finalize chunk: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
+ if err := saveUploadSession(mediaDir, session); err != nil {
+ http.Error(w, fmt.Sprintf("failed to update upload session: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(buildUploadStatus(mediaDir, session))
+
+ case "complete":
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if media.CountUploadedChunks(mediaDir, uploadID, session.TotalChunks) != session.TotalChunks {
+ http.Error(w, "upload is incomplete", http.StatusBadRequest)
+ return
+ }
+
+ if err := os.MkdirAll(mediaDir, 0o755); err != nil {
+ http.Error(w, fmt.Sprintf("failed to ensure media directory: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ targetPath := media.UniqueTargetPath(mediaDir, session.FileName)
+ targetFile, err := os.Create(targetPath)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ var totalWritten int64
+ for i := 0; i < session.TotalChunks; i++ {
+ chunkPath := media.UploadChunkPath(mediaDir, uploadID, i)
+ chunkReader, err := os.Open(chunkPath)
+ if err != nil {
+ targetFile.Close()
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("missing chunk %d", i), http.StatusBadRequest)
+ return
+ }
+ written, err := io.Copy(targetFile, chunkReader)
+ _ = chunkReader.Close()
+ if err != nil {
+ targetFile.Close()
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("failed to assemble media file: %v", err), http.StatusInternalServerError)
+ return
+ }
+ totalWritten += written
+ }
+
+ if err := targetFile.Close(); err != nil {
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("failed to finalize media file: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ if totalWritten != session.FileSize {
+ _ = os.Remove(targetPath)
+ http.Error(w, "assembled file size mismatch", http.StatusBadRequest)
+ return
+ }
+
+ detectedMime, err := media.DetectMime(targetPath)
+ if err != nil {
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("failed to inspect media file: %v", err), http.StatusBadRequest)
+ return
+ }
+ if !strings.HasPrefix(strings.ToLower(detectedMime), "video/") {
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("unsupported media content type: %s", detectedMime), http.StatusBadRequest)
+ return
+ }
+
+ durationSeconds, err := media.ProbeDurationSeconds(targetPath)
+ if err != nil {
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("failed to probe media duration: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ fileInfo, err := os.Stat(targetPath)
+ if err != nil {
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("failed to read media file info: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ normResult := media.ValidateNormalization(targetPath)
+ status := media.StatusReady
+ if !normResult.Valid {
+ status = media.StatusNeedsNormalization
+ }
+
+ filename := filepath.Base(targetPath)
+ mediaRecord := db.Media{
+ Filename: filename,
+ OriginalName: session.FileName,
+ Path: targetPath,
+ SizeBytes: fileInfo.Size(),
+ DurationSeconds: durationSeconds,
+ Status: status,
+ Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
+ Tags: fmt.Sprintf(`"normalization":%v`, normResult.Valid),
+ }
+ if err := deps.DB.Create(&mediaRecord).Error; err != nil {
+ _ = os.Remove(targetPath)
+ http.Error(w, fmt.Sprintf("failed to persist media metadata: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ if !normResult.Valid {
+ go normalizeMediaRecord(deps, mediaRecord)
+ }
+
+ _ = os.RemoveAll(media.UploadSessionDir(mediaDir, uploadID))
+
+ files, err := media.ListFiles(deps.DB)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(AdminMediaResponse{Files: files, GeneratedAt: time.Now().UTC().Format(time.RFC3339)})
+ default:
+ http.NotFound(w, r)
+ }
+ }
+}
+
+// HandleAdminMediaByID handles single media item CRUD.
+func HandleAdminMediaByID(deps *Dependencies) http.HandlerFunc {
+ mediaDir := config.MediaDir()
+ return func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/normalize") {
+ handleNormalizeMedia(deps, w, r)
+ return
+ }
+
+ mediaID, err := media.GetMediaIDFromPath(r.URL.Path, "/api/admin/media/")
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ var record db.Media
+ if err := deps.DB.First(&record, mediaID).Error; err != nil {
+ http.Error(w, "media item not found", http.StatusNotFound)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(mediaEntryFromRecord(record))
+ case http.MethodPut:
+ var payload struct {
+ Status string `json:"status"`
+ Title string `json:"title"`
+ Tags string `json:"tags"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
+ return
+ }
+ updates := map[string]any{}
+ if payload.Status != "" {
+ updates["status"] = strings.TrimSpace(payload.Status)
+ }
+ if payload.Title != "" {
+ updates["title"] = strings.TrimSpace(payload.Title)
+ }
+ updates["tags"] = strings.TrimSpace(payload.Tags)
+ if err := deps.DB.Model(&record).Updates(updates).Error; err != nil {
+ http.Error(w, fmt.Sprintf("failed to update media metadata: %v", err), http.StatusInternalServerError)
+ return
+ }
+ if err := deps.DB.First(&record, mediaID).Error; err != nil {
+ http.Error(w, "media item not found after update", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(mediaEntryFromRecord(record))
+ case http.MethodDelete:
+ absMediaDir, err := filepath.Abs(mediaDir)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to resolve media directory: %v", err), http.StatusInternalServerError)
+ return
+ }
+ absTargetPath, err := filepath.Abs(record.Path)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to resolve media path: %v", err), http.StatusInternalServerError)
+ return
+ }
+ prefix := absMediaDir + string(os.PathSeparator)
+ if absTargetPath != absMediaDir && !strings.HasPrefix(absTargetPath, prefix) {
+ http.Error(w, "media path consistency check failed", http.StatusConflict)
+ return
+ }
+ if err := os.Remove(absTargetPath); err != nil && !os.IsNotExist(err) {
+ http.Error(w, fmt.Sprintf("failed to delete media file: %v", err), http.StatusInternalServerError)
+ return
+ }
+ if err := deps.DB.Delete(&record).Error; err != nil {
+ http.Error(w, fmt.Sprintf("failed to delete media record: %v", err), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"deleted": true, "id": mediaID})
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ }
+}
+
+// normalizeMediaRecord runs ffmpeg normalization for a media record and updates
+// its status, path, duration, and size. Safe to run asynchronously.
+func normalizeMediaRecord(deps *Dependencies, record db.Media) {
+ src := record.Path
+ dst := strings.TrimSuffix(src, filepath.Ext(src)) + ".normalized.mp4"
+
+ if err := deps.DB.Model(&record).Update("status", media.StatusNormalizing).Error; err != nil {
+ return
+ }
+
+ duration, size, err := media.NormalizeVideo(src, dst)
+ if err != nil {
+ _ = deps.DB.Model(&record).Updates(map[string]any{
+ "status": media.StatusNormalizationFailed,
+ "tags": `"normalization":false`,
+ }).Error
+ return
+ }
+
+ _ = deps.DB.Model(&record).Updates(map[string]any{
+ "path": dst,
+ "filename": filepath.Base(dst),
+ "size_bytes": size,
+ "duration_seconds": duration,
+ "status": media.StatusReady,
+ "tags": `"normalization":true`,
+ }).Error
+}
+
+// handleNormalizeMedia handles POST /api/admin/media/{id}/normalize.
+func handleNormalizeMedia(deps *Dependencies, w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ idPart := strings.TrimSuffix(r.URL.Path, "/normalize")
+ idPart = strings.TrimPrefix(idPart, "/api/admin/media/")
+ idPart = strings.Trim(idPart, "/")
+ mediaID, err := strconv.ParseUint(idPart, 10, 64)
+ if err != nil || mediaID == 0 {
+ http.Error(w, "invalid media id", http.StatusBadRequest)
+ return
+ }
+
+ var record db.Media
+ if err := deps.DB.First(&record, mediaID).Error; err != nil {
+ http.Error(w, "media item not found", http.StatusNotFound)
+ return
+ }
+
+ if record.Status == media.StatusNormalizing {
+ http.Error(w, "normalization already in progress", http.StatusConflict)
+ return
+ }
+
+ record.Status = media.StatusNormalizing
+ if err := deps.DB.Model(&record).Update("status", media.StatusNormalizing).Error; err != nil {
+ http.Error(w, fmt.Sprintf("failed to update media status: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ go normalizeMediaRecord(deps, record)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+ json.NewEncoder(w).Encode(mediaEntryFromRecord(record))
+}
+
+// HandleAdminChatLog lists chat log entries.
+func HandleAdminChatLog(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ query := r.URL.Query()
+ room := strings.TrimSpace(query.Get("room"))
+ limit := 50
+ if rawLimit := strings.TrimSpace(query.Get("limit")); rawLimit != "" {
+ parsedLimit, err := strconv.Atoi(rawLimit)
+ if err != nil || parsedLimit <= 0 {
+ http.Error(w, "invalid limit", http.StatusBadRequest)
+ return
+ }
+ if parsedLimit > 200 {
+ parsedLimit = 200
+ }
+ limit = parsedLimit
+ }
+
+ dbQuery := deps.DB.Model(&db.Message{}).Where("type <> ?", "system")
+ if room != "" {
+ dbQuery = dbQuery.Where("room = ?", room)
+ }
+ if beforeRaw := strings.TrimSpace(query.Get("before")); beforeRaw != "" {
+ beforeTime, err := time.Parse(time.RFC3339, beforeRaw)
+ if err != nil {
+ http.Error(w, "invalid before timestamp, use RFC3339 format", http.StatusBadRequest)
+ return
+ }
+ dbQuery = dbQuery.Where("timestamp < ?", beforeTime)
+ }
+ if username := strings.TrimSpace(query.Get("username")); username != "" {
+ dbQuery = dbQuery.Where("username LIKE ?", "%"+username+"%")
+ }
+ if text := strings.TrimSpace(query.Get("text")); text != "" {
+ dbQuery = dbQuery.Where("text LIKE ?", "%"+text+"%")
+ }
+
+ var total int64
+ if err := dbQuery.Count(&total).Error; err != nil {
+ http.Error(w, fmt.Sprintf("failed to count chat log entries: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ var messages []db.Message
+ if err := dbQuery.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
+ http.Error(w, fmt.Sprintf("failed to load chat log entries: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ entries := make([]ChatLogEntry, len(messages))
+ for i, msg := range messages {
+ entries[i] = ChatLogEntry{
+ ID: msg.ID,
+ Room: msg.Room,
+ Username: msg.Username,
+ Text: msg.Text,
+ Timestamp: msg.Timestamp,
+ Type: msg.Type,
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(ChatLogListResponse{Entries: entries, Total: total})
+ }
+}
+
+// HandleAdminChatLogByID returns or deletes a single chat log entry.
+func HandleAdminChatLogByID(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ idPart := strings.TrimPrefix(r.URL.Path, "/api/admin/chat-log/")
+ idPart = strings.TrimSpace(idPart)
+ if idPart == "" || strings.Contains(idPart, "/") {
+ http.Error(w, "invalid chat log id", http.StatusBadRequest)
+ return
+ }
+ id, err := strconv.ParseUint(idPart, 10, 64)
+ if err != nil || id == 0 {
+ http.Error(w, "invalid chat log id", http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ var msg db.Message
+ if err := deps.DB.First(&msg, id).Error; err != nil {
+ http.Error(w, "chat log entry not found", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(ChatLogEntry{
+ ID: msg.ID,
+ Room: msg.Room,
+ Username: msg.Username,
+ Text: msg.Text,
+ Timestamp: msg.Timestamp,
+ Type: msg.Type,
+ })
+ case http.MethodDelete:
+ result := deps.DB.Delete(&db.Message{}, id)
+ if result.Error != nil {
+ http.Error(w, fmt.Sprintf("failed to delete chat log entry: %v", result.Error), http.StatusInternalServerError)
+ return
+ }
+ if result.RowsAffected == 0 {
+ http.Error(w, "chat log entry not found", http.StatusNotFound)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ }
+}
+
+// HandleAdminChatLogExport exports chat log as CSV.
+func HandleAdminChatLogExport(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ query := r.URL.Query()
+ room := strings.TrimSpace(query.Get("room"))
+ dbQuery := deps.DB.Model(&db.Message{})
+ if room != "" {
+ dbQuery = dbQuery.Where("room = ?", room)
+ }
+ if username := strings.TrimSpace(query.Get("username")); username != "" {
+ dbQuery = dbQuery.Where("username LIKE ?", "%"+username+"%")
+ }
+
+ var messages []db.Message
+ if err := dbQuery.Order("timestamp desc").Limit(10000).Find(&messages).Error; err != nil {
+ http.Error(w, "failed to load messages", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/csv; charset=utf-8")
+ w.Header().Set("Content-Disposition", "attachment; filename=chat-log.csv")
+ fmt.Fprintln(w, "id,room,username,type,text,timestamp")
+ for _, msg := range messages {
+ text := strings.ReplaceAll(msg.Text, `"`, `""`)
+ fmt.Fprintf(w, "%d,%s,%s,%s,\"%s\",%s\n",
+ msg.ID, msg.Room, msg.Username, msg.Type, text, msg.Timestamp.Format(time.RFC3339))
+ }
+ }
+}
+
+// HandleAdminChatLogBulk deletes multiple chat log entries.
+func HandleAdminChatLogBulk(deps *Dependencies) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var payload ChatLogBulkDeleteRequest
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
+ return
+ }
+ if len(payload.IDs) == 0 {
+ http.Error(w, "ids array is required", http.StatusBadRequest)
+ return
+ }
+ if len(payload.IDs) > 500 {
+ http.Error(w, "maximum 500 ids per bulk request", http.StatusBadRequest)
+ return
+ }
+ result := deps.DB.Delete(&db.Message{}, payload.IDs)
+ if result.Error != nil {
+ http.Error(w, fmt.Sprintf("failed to bulk delete chat log entries: %v", result.Error), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"deleted": result.RowsAffected})
+ }
+}
+
+// --- Upload session helpers ---
+
+func loadUploadSession(mediaDir, uploadID string) (AdminMediaUploadSession, error) {
+ var session AdminMediaUploadSession
+ data, err := os.ReadFile(media.UploadSessionMetaPath(mediaDir, uploadID))
+ if err != nil {
+ return session, err
+ }
+ if err := json.Unmarshal(data, &session); err != nil {
+ return session, err
+ }
+ return session, nil
+}
+
+func saveUploadSession(mediaDir string, session AdminMediaUploadSession) error {
+ if err := os.MkdirAll(media.UploadSessionDir(mediaDir, session.UploadID), 0o755); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(session, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(media.UploadSessionMetaPath(mediaDir, session.UploadID), data, 0o644)
+}
+
+func buildUploadStatus(mediaDir string, session AdminMediaUploadSession) AdminMediaUploadStatusResponse {
+ uploadedChunks := media.CountUploadedChunks(mediaDir, session.UploadID, session.TotalChunks)
+ progress := 0.0
+ if session.TotalChunks > 0 {
+ progress = (float64(uploadedChunks) / float64(session.TotalChunks)) * 100
+ }
+ return AdminMediaUploadStatusResponse{
+ UploadID: session.UploadID,
+ FileName: session.FileName,
+ FileSize: session.FileSize,
+ ChunkSize: session.ChunkSize,
+ TotalChunks: session.TotalChunks,
+ UploadedChunks: uploadedChunks,
+ Progress: progress,
+ }
+}
+
+func mediaEntryFromRecord(record db.Media) media.FileEntry {
+ return media.FileEntry{
+ ID: record.ID,
+ Name: record.Filename,
+ OriginalName: record.OriginalName,
+ Path: record.Path,
+ Size: record.SizeBytes,
+ DurationSeconds: record.DurationSeconds,
+ Runtime: media.FormatDuration(record.DurationSeconds),
+ Status: record.Status,
+ Title: record.Title,
+ Tags: record.Tags,
+ UploadedAt: record.UploadedAt.UTC().Format(time.RFC3339),
+ ModifiedAt: record.UpdatedAt.UTC().Format(time.RFC3339),
+ }
}
\ No newline at end of file
diff --git a/backend/chat/src/handlers/handlers_test.go b/backend/chat/src/handlers/handlers_test.go
index 43898b5..9c5566b 100644
--- a/backend/chat/src/handlers/handlers_test.go
+++ b/backend/chat/src/handlers/handlers_test.go
@@ -1,1423 +1,1423 @@
-package handlers
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "strings"
- "sync"
- "testing"
- "time"
-
- "github.com/glebarez/sqlite"
- "golang.org/x/crypto/bcrypt"
- "gorm.io/gorm"
-
- "chat/src/auth"
- "chat/src/config"
- "chat/src/db"
- "chat/src/media"
- "chat/src/ws"
-)
-
-// mockRedis implements db.RedisClient for handler tests.
-type mockRedis struct {
- mu sync.Mutex
- store map[string]string
-}
-
-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) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.store[key] = "1"
- return 1, 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 {
- return nil
-}
-func (m *mockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub {
- return nil
-}
-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 "connected_clients:1\nused_memory_human:1M\n", nil
-}
-func (m *mockRedis) Close() error { return nil }
-
-func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
- t.Helper()
- gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open test DB: %v", err)
- }
- // A single connection keeps the in-memory SQLite database shared across
- // goroutines (each :memory: connection would otherwise be a separate DB).
- 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{}, &db.Media{}, &db.ScheduleOverride{}, &db.User{})
- mr := newMockRedis()
- sessionStore := auth.NewSessionStore()
- hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
-
- deps := &Dependencies{
- DB: gormDB,
- Redis: mr,
- Hub: hub,
- SessionStore: sessionStore,
- UserSessions: auth.NewUserSessionStore(),
- DBDriver: "sqlite",
- }
- return deps, gormDB, sessionStore
-}
-
-// adminCookie creates a valid admin session and returns the cookie header.
-func adminCookie(t *testing.T, store *auth.SessionStore) string {
- t.Helper()
- token, _ := auth.GenerateToken()
- store.Add(token, time.Now().Add(1*time.Hour))
- return config.AdminSessionCookieName + "=" + token
-}
-
-func TestHandleAdminLogin_Success(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
- handler := HandleAdminLogin(deps)
-
- // Ensure env vars are set for credential check
- os.Setenv("ADMIN_USERNAME", "admin")
- os.Setenv("ADMIN_PASSWORD", "admin")
- defer os.Unsetenv("ADMIN_USERNAME")
- defer os.Unsetenv("ADMIN_PASSWORD")
-
- body := `{"username":"admin","password":"admin"}`
- req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var resp AdminSessionResponse
- json.NewDecoder(w.Body).Decode(&resp)
- if !resp.Authenticated {
- t.Error("Authenticated = false, want true")
- }
-
- // Check cookie set
- cookies := w.Result().Cookies()
- found := false
- for _, c := range cookies {
- if c.Name == config.AdminSessionCookieName {
- found = true
- break
- }
- }
- if !found {
- t.Error("session cookie not set")
- }
-}
-
-func TestHandleAdminLogin_InvalidCreds(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
- handler := HandleAdminLogin(deps)
-
- body := `{"username":"admin","password":"wrong"}`
- req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusUnauthorized {
- t.Errorf("status = %d, want 401", w.Code)
- }
-}
-
-func TestHandleAdminLogin_InvalidJSON(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
- handler := HandleAdminLogin(deps)
-
- req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader("not json"))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want 400", w.Code)
- }
-}
-
-func TestHandleAdminLogin_WrongMethod(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
- handler := HandleAdminLogin(deps)
-
- req := httptest.NewRequest("GET", "/api/admin/login", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusMethodNotAllowed {
- t.Errorf("status = %d, want 405", w.Code)
- }
-}
-
-func TestHandleAdminLogout(t *testing.T) {
- deps, _, store := setupTestDeps(t)
- handler := HandleAdminLogout(deps)
-
- cookie := adminCookie(t, store)
- req := httptest.NewRequest("POST", "/api/admin/logout", nil)
- req.Header.Set("Cookie", cookie)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- // Cookie should be cleared
- cookies := w.Result().Cookies()
- found := false
- for _, c := range cookies {
- if c.Name == config.AdminSessionCookieName && c.MaxAge == -1 {
- found = true
- break
- }
- }
- if !found {
- t.Error("session cookie not cleared")
- }
-}
-
-func TestHandleAdminSession_Authenticated(t *testing.T) {
- deps, _, store := setupTestDeps(t)
- handler := HandleAdminSession(deps)
-
- cookie := adminCookie(t, store)
- req := httptest.NewRequest("GET", "/api/admin/session", nil)
- req.Header.Set("Cookie", cookie)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var resp AdminSessionResponse
- json.NewDecoder(w.Body).Decode(&resp)
- if !resp.Authenticated {
- t.Error("Authenticated = false, want true")
- }
-}
-
-func TestHandleAdminSession_Unauthenticated(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
- handler := HandleAdminSession(deps)
-
- req := httptest.NewRequest("GET", "/api/admin/session", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusUnauthorized {
- t.Errorf("status = %d, want 401", w.Code)
- }
-}
-
-func TestHandleAdminOverview(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- // 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()})
-
- handler := HandleAdminOverview(deps)
- req := httptest.NewRequest("GET", "/api/admin/overview", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var overview AdminOverview
- json.NewDecoder(w.Body).Decode(&overview)
- if overview.StoredMessages != 1 {
- t.Errorf("StoredMessages = %d, want 1", overview.StoredMessages)
- }
- if overview.ModerationActions != 1 {
- t.Errorf("ModerationActions = %d, want 1", overview.ModerationActions)
- }
-}
-
-func TestHandleAdminPlaylist_Get(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
-
- // Create temp playlist
- tmpDir := t.TempDir()
- playlistPath := filepath.Join(tmpDir, "playlist.txt")
- os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
- os.Setenv("PLAYLIST_PATH", playlistPath)
- defer os.Unsetenv("PLAYLIST_PATH")
-
- handler := HandleAdminPlaylist(deps)
- req := httptest.NewRequest("GET", "/api/admin/playlist", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var payload PlaylistPayload
- json.NewDecoder(w.Body).Decode(&payload)
- if !strings.Contains(payload.Content, "test.mp4") {
- t.Errorf("Content = %q, want test.mp4", payload.Content)
- }
-}
-
-func TestHandleAdminPlaylist_Put(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
-
- tmpDir := t.TempDir()
- playlistPath := filepath.Join(tmpDir, "playlist.txt")
- os.Setenv("PLAYLIST_PATH", playlistPath)
- defer os.Unsetenv("PLAYLIST_PATH")
-
- handler := HandleAdminPlaylist(deps)
- body := `{"content":"file /videos/new.mp4"}`
- req := httptest.NewRequest("PUT", "/api/admin/playlist", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- // Verify file written
- data, _ := os.ReadFile(playlistPath)
- if !strings.Contains(string(data), "new.mp4") {
- t.Errorf("file content = %q, want new.mp4", string(data))
- }
-}
-
-func TestHandleAdminModerationLogs(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u1", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
-
- handler := HandleAdminModerationLogs(deps)
- req := httptest.NewRequest("GET", "/api/admin/moderation/logs", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var resp ModerationLogsResponse
- json.NewDecoder(w.Body).Decode(&resp)
- if len(resp.Logs) != 1 {
- t.Errorf("len(Logs) = %d, want 1", len(resp.Logs))
- }
-}
-
-func TestHandleAdminModerationAction(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- handler := HandleAdminModerationAction(deps)
- body := `{"action":"mute","target":"baduser","moderator":"admin","reason":"spam","room":"main"}`
- req := httptest.NewRequest("POST", "/api/admin/moderation/actions", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var count int64
- gormDB.Model(&db.ModerationLog{}).Count(&count)
- if count != 1 {
- t.Errorf("moderation log count = %d, want 1", count)
- }
-}
-
-func TestHandleAdminModerationAction_MissingFields(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
-
- handler := HandleAdminModerationAction(deps)
- body := `{"action":"mute"}`
- req := httptest.NewRequest("POST", "/api/admin/moderation/actions", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want 400", w.Code)
- }
-}
-
-func TestHandleAdminAnalytics(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
- gormDB.Create(&db.Message{Room: "main", Username: "u2", Text: "hey", Timestamp: time.Now(), Type: "public"})
-
- handler := HandleAdminAnalytics(deps)
- req := httptest.NewRequest("GET", "/api/admin/analytics", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var analytics AdminAnalytics
- json.NewDecoder(w.Body).Decode(&analytics)
- if analytics.TotalMessages != 2 {
- t.Errorf("TotalMessages = %d, want 2", analytics.TotalMessages)
- }
-}
-
-func TestHandleAdminSystemSummary(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
-
- handler := HandleAdminSystemSummary(deps)
- req := httptest.NewRequest("GET", "/api/admin/system", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var summary AdminSystemSummary
- json.NewDecoder(w.Body).Decode(&summary)
- if summary.DatabaseDriver != "sqlite" {
- t.Errorf("DatabaseDriver = %q, want sqlite", summary.DatabaseDriver)
- }
-}
-
-func TestHandleAdminConfig_Get(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
-
- handler := HandleAdminConfig(deps)
- req := httptest.NewRequest("GET", "/api/admin/config", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-}
-
-func TestHandleAdminConfig_Put(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
- setupConfigTest(t)
-
- handler := HandleAdminConfig(deps)
- body := `{"rateLimitMax":20}`
- req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-}
-
-// setupConfigTest points the runtime config at a fresh temp file and resets the store.
-func setupConfigTest(t *testing.T) {
- t.Helper()
- os.Setenv("CONFIG_PATH", filepath.Join(t.TempDir(), "config.json"))
- os.Unsetenv("MATOMO_URL")
- os.Unsetenv("MATOMO_SITE_ID")
- os.Unsetenv("HLS_SEGMENT_DURATION")
- os.Unsetenv("HLS_WINDOW_SIZE")
- if err := config.LoadRuntimeConfig(); err != nil {
- t.Fatalf("LoadRuntimeConfig: %v", err)
- }
- t.Cleanup(func() { os.Unsetenv("CONFIG_PATH") })
-}
-
-func TestHandleAdminConfig_Get_NewFields(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
- setupConfigTest(t)
-
- handler := HandleAdminConfig(deps)
- req := httptest.NewRequest("GET", "/api/admin/config", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200", w.Code)
- }
- var cfg map[string]any
- if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
- t.Fatalf("decode: %v", err)
- }
- for _, k := range []string{"matomoUrl", "matomoSiteId", "hlsSegmentDuration", "hlsWindowSize"} {
- if _, ok := cfg[k]; !ok {
- t.Errorf("response missing %q", k)
- }
- }
-}
-
-func TestHandleAdminConfig_Put_Persists(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
- setupConfigTest(t)
-
- handler := HandleAdminConfig(deps)
- body := `{"hlsSegmentDuration":5,"hlsWindowSize":30,"matomoUrl":"https://stats.example.com","matomoSiteId":"42"}`
- req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200", w.Code)
- }
- var cfg map[string]any
- if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
- t.Fatalf("decode: %v", err)
- }
- if cfg["hlsSegmentDuration"] != float64(5) {
- t.Errorf("hlsSegmentDuration = %v, want 5", cfg["hlsSegmentDuration"])
- }
- if cfg["hlsWindowSize"] != float64(30) {
- t.Errorf("hlsWindowSize = %v, want 30", cfg["hlsWindowSize"])
- }
- if cfg["matomoUrl"] != "https://stats.example.com" {
- t.Errorf("matomoUrl = %v", cfg["matomoUrl"])
- }
- if cfg["matomoSiteId"] != "42" {
- t.Errorf("matomoSiteId = %v", cfg["matomoSiteId"])
- }
-}
-
-func TestHandleAdminConfig_Put_Invalid(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
- setupConfigTest(t)
-
- handler := HandleAdminConfig(deps)
- body := `{"hlsSegmentDuration":99}`
- req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want 400", w.Code)
- }
-}
-
-func TestHandlePublicMatomo(t *testing.T) {
- setupConfigTest(t)
-
- handler := HandlePublicMatomo()
- req := httptest.NewRequest("GET", "/api/matomo", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200", w.Code)
- }
- var resp map[string]any
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decode: %v", err)
- }
- for _, k := range []string{"matomoUrl", "matomoSiteId"} {
- if _, ok := resp[k]; !ok {
- t.Errorf("response missing %q", k)
- }
- }
-}
-
-func TestHandleUserRegister_Success(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- handler := HandleUserRegister(deps)
- body := `{"username":"alice","password":"secret123"}`
- req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
- }
- var count int64
- gormDB.Model(&db.User{}).Where("username = ?", "alice").Count(&count)
- if count != 1 {
- t.Errorf("user count = %d, want 1", count)
- }
- if len(w.Result().Cookies()) == 0 {
- t.Error("expected session cookie to be set")
- }
-}
-
-func TestHandleUserRegister_Duplicate(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
- gormDB.Create(&db.User{Username: "alice", PasswordHash: "x"})
-
- handler := HandleUserRegister(deps)
- body := `{"username":"alice","password":"secret123"}`
- req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusConflict {
- t.Errorf("status = %d, want 409", w.Code)
- }
-}
-
-func TestHandleUserLogin_Success(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
- hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
- gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
-
- handler := HandleUserLogin(deps)
- body := `{"username":"bob","password":"secret123"}`
- req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
- }
-}
-
-func TestHandleUserLogin_WrongPassword(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
- hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
- gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
-
- handler := HandleUserLogin(deps)
- body := `{"username":"bob","password":"wrong"}`
- req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusUnauthorized {
- t.Errorf("status = %d, want 401", w.Code)
- }
-}
-
-func TestHandleUserSession(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
- handler := HandleUserSession(deps)
-
- // Unauthenticated
- req := httptest.NewRequest("GET", "/api/auth/session", nil)
- w := httptest.NewRecorder()
- handler(w, req)
- var resp map[string]any
- json.NewDecoder(w.Body).Decode(&resp)
- if resp["authenticated"] != false {
- t.Errorf("authenticated = %v, want false", resp["authenticated"])
- }
-
- // Authenticated
- token, _ := auth.GenerateToken()
- deps.UserSessions.Add(token, "carol", time.Now().Add(1*time.Hour))
- req2 := httptest.NewRequest("GET", "/api/auth/session", nil)
- req2.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
- w2 := httptest.NewRecorder()
- handler(w2, req2)
- var resp2 map[string]any
- json.NewDecoder(w2.Body).Decode(&resp2)
- if resp2["username"] != "carol" {
- t.Errorf("username = %v, want carol", resp2["username"])
- }
-}
-
-func TestHandleUserDMHistory(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- token, _ := auth.GenerateToken()
- deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
-
- now := time.Now()
- gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "hi bob", Type: "private", Timestamp: now.Add(-2 * time.Hour)})
- gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "hi alice", Type: "private", Timestamp: now.Add(-1 * time.Hour)})
-
- handler := HandleUserDMHistory(deps)
- req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
- req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
- w := httptest.NewRecorder()
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200", w.Code)
- }
- var msgs []db.Message
- json.NewDecoder(w.Body).Decode(&msgs)
- if len(msgs) != 2 {
- t.Fatalf("len = %d, want 2", len(msgs))
- }
- if msgs[0].Text != "hi bob" {
- t.Errorf("first message = %q, want hi bob (chronological)", msgs[0].Text)
- }
-}
-
-func TestHandleUserDMHistory_Unauthorized(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
-
- handler := HandleUserDMHistory(deps)
- req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
- w := httptest.NewRecorder()
- handler(w, req)
-
- if w.Code != http.StatusUnauthorized {
- t.Errorf("status = %d, want 401", w.Code)
- }
-}
-
-func TestHandleUserDMSearch(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- token, _ := auth.GenerateToken()
- deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
-
- gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "meet at 8", Type: "private"})
- gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "see you", Type: "private"})
-
- handler := HandleUserDMSearch(deps)
- req := httptest.NewRequest("GET", "/api/dm/search?with=bob&q=meet", nil)
- req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
- w := httptest.NewRecorder()
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200", w.Code)
- }
- var msgs []db.Message
- json.NewDecoder(w.Body).Decode(&msgs)
- if len(msgs) != 1 || msgs[0].Text != "meet at 8" {
- t.Fatalf("search results = %v, want [meet at 8]", msgs)
- }
-}
-
-func TestHandleAdminRestart(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
-
- handler := HandleAdminRestart(deps)
- req := httptest.NewRequest("POST", "/api/admin/restart?service=chat", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-}
-
-func TestHandleAdminUsers_Get(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
-
- handler := HandleAdminUsers(deps)
- req := httptest.NewRequest("GET", "/api/admin/users", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-}
-
-func TestHandleAdminUsers_Post(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
-
- handler := HandleAdminUsers(deps)
- body := `{"action":"mute","username":"baduser","duration":10}`
- req := httptest.NewRequest("POST", "/api/admin/users", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-}
-
-func TestHandleAdminSchedule(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- tmpDir := t.TempDir()
- playlistPath := filepath.Join(tmpDir, "playlist.txt")
- os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
- os.Setenv("PLAYLIST_PATH", playlistPath)
- defer os.Unsetenv("PLAYLIST_PATH")
-
- gormDB.Create(&db.Media{
- Filename: "test.mp4",
- OriginalName: "test.mp4",
- Path: "/videos/test.mp4",
- SizeBytes: 1024,
- DurationSeconds: 60,
- Status: "ready",
- Title: "Test Video",
- })
-
- handler := HandleAdminSchedule(deps)
- req := httptest.NewRequest("GET", "/api/admin/schedule", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var resp AdminScheduleResponse
- json.NewDecoder(w.Body).Decode(&resp)
- if resp.MatchedEntries != 1 {
- t.Errorf("MatchedEntries = %d, want 1", resp.MatchedEntries)
- }
-}
-
-func TestHandleAdminScheduleOverrides_CRUD(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- // Create
- handler := HandleAdminScheduleOverrides(deps)
- now := time.Now()
- body, _ := json.Marshal(db.ScheduleOverride{
- MediaID: 1,
- StartTime: now,
- EndTime: now.Add(1 * time.Hour),
- Reason: "test",
- })
- req := httptest.NewRequest("POST", "/api/admin/schedule/overrides", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handler(w, req)
-
- if w.Code != http.StatusCreated {
- t.Errorf("POST status = %d, want 201", w.Code)
- }
-
- // List
- req2 := httptest.NewRequest("GET", "/api/admin/schedule/overrides", nil)
- w2 := httptest.NewRecorder()
- handler(w2, req2)
-
- if w2.Code != http.StatusOK {
- t.Errorf("GET status = %d, want 200", w2.Code)
- }
-
- // Delete
- var overrides []db.ScheduleOverride
- gormDB.Find(&overrides)
- if len(overrides) > 0 {
- req3 := httptest.NewRequest("DELETE", "/api/admin/schedule/overrides?id=1", nil)
- w3 := httptest.NewRecorder()
- handler(w3, req3)
- if w3.Code != http.StatusNoContent {
- t.Errorf("DELETE status = %d, want 204", w3.Code)
- }
- }
-}
-
-func TestHandleAdminChatLog(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
-
- handler := HandleAdminChatLog(deps)
- req := httptest.NewRequest("GET", "/api/admin/chat-log", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var resp ChatLogListResponse
- json.NewDecoder(w.Body).Decode(&resp)
- if resp.Total != 1 {
- t.Errorf("Total = %d, want 1", resp.Total)
- }
-}
-
-func TestHandleAdminChatLog_FilterByRoom(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
- gormDB.Create(&db.Message{Room: "lobby", Username: "u2", Text: "hey", Timestamp: time.Now(), Type: "public"})
-
- handler := HandleAdminChatLog(deps)
- req := httptest.NewRequest("GET", "/api/admin/chat-log?room=main", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var resp ChatLogListResponse
- json.NewDecoder(w.Body).Decode(&resp)
- if resp.Total != 1 {
- t.Errorf("Total = %d, want 1", resp.Total)
- }
-}
-
-func TestHandleAdminChatLog_ExcludesSystem(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- gormDB.Create(&db.Message{Room: "main", Username: "system", Text: "bob joined main", Timestamp: time.Now(), Type: "system"})
- gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
-
- handler := HandleAdminChatLog(deps)
- req := httptest.NewRequest("GET", "/api/admin/chat-log", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var resp ChatLogListResponse
- json.NewDecoder(w.Body).Decode(&resp)
- if resp.Total != 1 {
- t.Errorf("Total = %d, want 1 (system messages must be excluded)", resp.Total)
- }
- if len(resp.Entries) != 1 || resp.Entries[0].Text != "hello" {
- t.Errorf("entries = %v, want only the public chat message", resp.Entries)
- }
-}
-
-func TestHandleAdminChatLogByID_Get(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- msg := db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"}
- gormDB.Create(&msg)
-
- handler := HandleAdminChatLogByID(deps)
- req := httptest.NewRequest("GET", "/api/admin/chat-log/1", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-}
-
-func TestHandleAdminChatLogByID_Delete(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- msg := db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"}
- gormDB.Create(&msg)
-
- handler := HandleAdminChatLogByID(deps)
- req := httptest.NewRequest("DELETE", "/api/admin/chat-log/1", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusNoContent {
- t.Errorf("status = %d, want 204", w.Code)
- }
-
- var count int64
- gormDB.Model(&db.Message{}).Count(&count)
- if count != 0 {
- t.Errorf("message count after delete = %d, want 0", count)
- }
-}
-
-func TestHandleAdminChatLogBulk(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "m1", Timestamp: time.Now(), Type: "public"})
- gormDB.Create(&db.Message{Room: "main", Username: "u2", Text: "m2", Timestamp: time.Now(), Type: "public"})
-
- handler := HandleAdminChatLogBulk(deps)
- body := `{"ids":[1,2]}`
- req := httptest.NewRequest("DELETE", "/api/admin/chat-log/bulk", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var count int64
- gormDB.Model(&db.Message{}).Count(&count)
- if count != 0 {
- t.Errorf("message count after bulk delete = %d, want 0", count)
- }
-}
-
-func TestHandleAdminChatLogExport(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
-
- handler := HandleAdminChatLogExport(deps)
- req := httptest.NewRequest("GET", "/api/admin/chat-log/export", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- contentType := w.Header().Get("Content-Type")
- if !strings.Contains(contentType, "text/csv") {
- t.Errorf("Content-Type = %q, want text/csv", contentType)
- }
-
- body := w.Body.String()
- if !strings.Contains(body, "hello") {
- t.Errorf("CSV body missing 'hello': %s", body)
- }
-}
-
-func TestHandlePublicSchedule(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
-
- tmpDir := t.TempDir()
- playlistPath := filepath.Join(tmpDir, "playlist.txt")
- os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
- os.Setenv("PLAYLIST_PATH", playlistPath)
- defer os.Unsetenv("PLAYLIST_PATH")
-
- handler := HandlePublicSchedule(deps)
- req := httptest.NewRequest("GET", "/api/schedule", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-}
-
-func TestHandleAdminMedia_Get(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- gormDB.Create(&db.Media{
- Filename: "test.mp4",
- OriginalName: "test.mp4",
- Path: "/media/test.mp4",
- SizeBytes: 1024,
- DurationSeconds: 60,
- Status: "ready",
- Title: "Test",
- })
-
- handler := HandleAdminMedia(deps)
- req := httptest.NewRequest("GET", "/api/admin/media", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var resp AdminMediaResponse
- json.NewDecoder(w.Body).Decode(&resp)
- if len(resp.Files) != 1 {
- t.Errorf("len(Files) = %d, want 1", len(resp.Files))
- }
-}
-
-func TestHandleAdminMediaByID_Get(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- gormDB.Create(&db.Media{
- Filename: "test.mp4",
- OriginalName: "test.mp4",
- Path: "/media/test.mp4",
- SizeBytes: 1024,
- DurationSeconds: 60,
- Status: "ready",
- Title: "Test",
- })
-
- handler := HandleAdminMediaByID(deps)
- req := httptest.NewRequest("GET", "/api/admin/media/1", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-}
-
-func TestHandleAdminMediaByID_NotFound(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
-
- handler := HandleAdminMediaByID(deps)
- req := httptest.NewRequest("GET", "/api/admin/media/999", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Errorf("status = %d, want 404", w.Code)
- }
-}
-
-func TestHandleAdminMediaByID_Put(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- gormDB.Create(&db.Media{
- Filename: "test.mp4",
- OriginalName: "test.mp4",
- Path: "/media/test.mp4",
- SizeBytes: 1024,
- DurationSeconds: 60,
- Status: "ready",
- Title: "Old Title",
- })
-
- handler := HandleAdminMediaByID(deps)
- body := `{"title":"New Title","status":"needs_normalization","tags":"tag1"}`
- req := httptest.NewRequest("PUT", "/api/admin/media/1", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var updated db.Media
- gormDB.First(&updated, 1)
- if updated.Title != "New Title" {
- t.Errorf("Title = %q, want New Title", updated.Title)
- }
-}
-
-func TestHandleAdminMediaByID_Delete(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- // Create a real temp file for the media record
- tmpDir := t.TempDir()
- os.Setenv("MEDIA_DIR", tmpDir)
- defer os.Unsetenv("MEDIA_DIR")
-
- mediaPath := filepath.Join(tmpDir, "test.mp4")
- os.WriteFile(mediaPath, []byte("fake video"), 0644)
-
- gormDB.Create(&db.Media{
- Filename: "test.mp4",
- OriginalName: "test.mp4",
- Path: mediaPath,
- SizeBytes: 10,
- DurationSeconds: 1,
- Status: "ready",
- Title: "Test",
- })
-
- handler := HandleAdminMediaByID(deps)
- req := httptest.NewRequest("DELETE", "/api/admin/media/1", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var count int64
- gormDB.Model(&db.Media{}).Count(&count)
- if count != 0 {
- t.Errorf("media count after delete = %d, want 0", count)
- }
-}
-
-func TestHandleAdminMediaUploads_Init(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
-
- handler := HandleAdminMediaUploads(deps)
- body := `{"fileName":"video.mp4","fileSize":1024,"mimeType":"video/mp4","chunkSize":524288,"totalChunks":1}`
- req := httptest.NewRequest("POST", "/api/admin/media/uploads", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("status = %d, want 200", w.Code)
- }
-
- var status AdminMediaUploadStatusResponse
- json.NewDecoder(w.Body).Decode(&status)
- if status.FileName != "video.mp4" {
- t.Errorf("FileName = %q, want video.mp4", status.FileName)
- }
- if status.UploadID == "" {
- t.Error("UploadID is empty")
- }
-}
-
-func TestHandleAdminMediaUploads_InvalidFile(t *testing.T) {
- deps, _, _ := setupTestDeps(t)
-
- handler := HandleAdminMediaUploads(deps)
- body := `{"fileName":"bad.exe","fileSize":1024,"mimeType":"application/x-msdownload","chunkSize":524288,"totalChunks":1}`
- req := httptest.NewRequest("POST", "/api/admin/media/uploads", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want 400", w.Code)
- }
-}
-
-func TestNormalizeMediaRecord_Success(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- tmpDir := t.TempDir()
- os.Setenv("MEDIA_DIR", tmpDir)
- defer os.Unsetenv("MEDIA_DIR")
-
- srcPath := filepath.Join(tmpDir, "source.mov")
- if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- gormDB.Create(&db.Media{
- Filename: "source.mov",
- OriginalName: "source.mov",
- Path: srcPath,
- SizeBytes: 10,
- DurationSeconds: 0,
- Status: "needs_normalization",
- Title: "source",
- })
-
- origNormalize := media.NormalizeVideo
- t.Cleanup(func() { media.NormalizeVideo = origNormalize })
- media.NormalizeVideo = func(src, dst string) (int, int64, error) {
- if err := os.WriteFile(dst, []byte("normalized"), 0o644); err != nil {
- return 0, 0, err
- }
- return 120, 10, nil
- }
-
- var record db.Media
- gormDB.First(&record, 1)
- normalizeMediaRecord(deps, record)
-
- var updated db.Media
- gormDB.First(&updated, 1)
- if updated.Status != "ready" {
- t.Errorf("Status = %q, want ready", updated.Status)
- }
- if updated.DurationSeconds != 120 {
- t.Errorf("DurationSeconds = %d, want 120", updated.DurationSeconds)
- }
- if !strings.HasSuffix(updated.Path, ".normalized.mp4") {
- t.Errorf("Path = %q, want .normalized.mp4 suffix", updated.Path)
- }
-}
-
-func TestNormalizeMediaRecord_Failure(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- tmpDir := t.TempDir()
- os.Setenv("MEDIA_DIR", tmpDir)
- defer os.Unsetenv("MEDIA_DIR")
-
- srcPath := filepath.Join(tmpDir, "source.mov")
- if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- gormDB.Create(&db.Media{
- Filename: "source.mov",
- OriginalName: "source.mov",
- Path: srcPath,
- SizeBytes: 10,
- DurationSeconds: 0,
- Status: "needs_normalization",
- Title: "source",
- })
-
- origNormalize := media.NormalizeVideo
- t.Cleanup(func() { media.NormalizeVideo = origNormalize })
- media.NormalizeVideo = func(src, dst string) (int, int64, error) {
- return 0, 0, fmt.Errorf("ffmpeg failed")
- }
-
- var record db.Media
- gormDB.First(&record, 1)
- normalizeMediaRecord(deps, record)
-
- var updated db.Media
- gormDB.First(&updated, 1)
- if updated.Status != "normalization_failed" {
- t.Errorf("Status = %q, want normalization_failed", updated.Status)
- }
-}
-
-func TestHandleAdminMediaByID_Normalize(t *testing.T) {
- deps, gormDB, _ := setupTestDeps(t)
-
- tmpDir := t.TempDir()
- os.Setenv("MEDIA_DIR", tmpDir)
- defer os.Unsetenv("MEDIA_DIR")
-
- srcPath := filepath.Join(tmpDir, "source.mov")
- if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- gormDB.Create(&db.Media{
- Filename: "source.mov",
- OriginalName: "source.mov",
- Path: srcPath,
- SizeBytes: 10,
- DurationSeconds: 0,
- Status: "needs_normalization",
- Title: "source",
- })
-
- origNormalize := media.NormalizeVideo
- t.Cleanup(func() { media.NormalizeVideo = origNormalize })
- media.NormalizeVideo = func(src, dst string) (int, int64, error) {
- if err := os.WriteFile(dst, []byte("normalized"), 0o644); err != nil {
- return 0, 0, err
- }
- return 90, 9, nil
- }
-
- handler := HandleAdminMediaByID(deps)
- req := httptest.NewRequest("POST", "/api/admin/media/1/normalize", nil)
- w := httptest.NewRecorder()
-
- handler(w, req)
-
- if w.Code != http.StatusAccepted {
- t.Errorf("status = %d, want 202", w.Code)
- }
-
- // Wait for the asynchronous normalization goroutine to finish before
- // t.Cleanup restores media.NormalizeVideo, avoiding a data race on the
- // package-level mock variable.
- var updated db.Media
- deadline := time.Now().Add(5 * time.Second)
- for {
- gormDB.First(&updated, 1)
- if updated.Status != media.StatusNormalizing {
- break
- }
- if time.Now().After(deadline) {
- t.Fatal("normalization did not complete within 5s")
- }
- time.Sleep(10 * time.Millisecond)
- }
-
- if updated.Status != media.StatusReady {
- t.Errorf("Status = %q, want ready", updated.Status)
- }
- if updated.DurationSeconds != 90 {
- t.Errorf("DurationSeconds = %d, want 90", updated.DurationSeconds)
- }
- if !strings.HasSuffix(updated.Path, ".normalized.mp4") {
- t.Errorf("Path = %q, want .normalized.mp4 suffix", updated.Path)
- }
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/glebarez/sqlite"
+ "golang.org/x/crypto/bcrypt"
+ "gorm.io/gorm"
+
+ "chat/src/auth"
+ "chat/src/config"
+ "chat/src/db"
+ "chat/src/media"
+ "chat/src/ws"
+)
+
+// mockRedis implements db.RedisClient for handler tests.
+type mockRedis struct {
+ mu sync.Mutex
+ store map[string]string
+}
+
+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) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.store[key] = "1"
+ return 1, 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 {
+ return nil
+}
+func (m *mockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub {
+ return nil
+}
+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 "connected_clients:1\nused_memory_human:1M\n", nil
+}
+func (m *mockRedis) Close() error { return nil }
+
+func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
+ t.Helper()
+ gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("failed to open test DB: %v", err)
+ }
+ // A single connection keeps the in-memory SQLite database shared across
+ // goroutines (each :memory: connection would otherwise be a separate DB).
+ 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{}, &db.Media{}, &db.ScheduleOverride{}, &db.User{})
+ mr := newMockRedis()
+ sessionStore := auth.NewSessionStore()
+ hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
+
+ deps := &Dependencies{
+ DB: gormDB,
+ Redis: mr,
+ Hub: hub,
+ SessionStore: sessionStore,
+ UserSessions: auth.NewUserSessionStore(),
+ DBDriver: "sqlite",
+ }
+ return deps, gormDB, sessionStore
+}
+
+// adminCookie creates a valid admin session and returns the cookie header.
+func adminCookie(t *testing.T, store *auth.SessionStore) string {
+ t.Helper()
+ token, _ := auth.GenerateToken()
+ store.Add(token, time.Now().Add(1*time.Hour))
+ return config.AdminSessionCookieName + "=" + token
+}
+
+func TestHandleAdminLogin_Success(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+ handler := HandleAdminLogin(deps)
+
+ // Ensure env vars are set for credential check
+ os.Setenv("ADMIN_USERNAME", "admin")
+ os.Setenv("ADMIN_PASSWORD", "admin")
+ defer os.Unsetenv("ADMIN_USERNAME")
+ defer os.Unsetenv("ADMIN_PASSWORD")
+
+ body := `{"username":"admin","password":"admin"}`
+ req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var resp AdminSessionResponse
+ json.NewDecoder(w.Body).Decode(&resp)
+ if !resp.Authenticated {
+ t.Error("Authenticated = false, want true")
+ }
+
+ // Check cookie set
+ cookies := w.Result().Cookies()
+ found := false
+ for _, c := range cookies {
+ if c.Name == config.AdminSessionCookieName {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("session cookie not set")
+ }
+}
+
+func TestHandleAdminLogin_InvalidCreds(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+ handler := HandleAdminLogin(deps)
+
+ body := `{"username":"admin","password":"wrong"}`
+ req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Errorf("status = %d, want 401", w.Code)
+ }
+}
+
+func TestHandleAdminLogin_InvalidJSON(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+ handler := HandleAdminLogin(deps)
+
+ req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader("not json"))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleAdminLogin_WrongMethod(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+ handler := HandleAdminLogin(deps)
+
+ req := httptest.NewRequest("GET", "/api/admin/login", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusMethodNotAllowed {
+ t.Errorf("status = %d, want 405", w.Code)
+ }
+}
+
+func TestHandleAdminLogout(t *testing.T) {
+ deps, _, store := setupTestDeps(t)
+ handler := HandleAdminLogout(deps)
+
+ cookie := adminCookie(t, store)
+ req := httptest.NewRequest("POST", "/api/admin/logout", nil)
+ req.Header.Set("Cookie", cookie)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ // Cookie should be cleared
+ cookies := w.Result().Cookies()
+ found := false
+ for _, c := range cookies {
+ if c.Name == config.AdminSessionCookieName && c.MaxAge == -1 {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("session cookie not cleared")
+ }
+}
+
+func TestHandleAdminSession_Authenticated(t *testing.T) {
+ deps, _, store := setupTestDeps(t)
+ handler := HandleAdminSession(deps)
+
+ cookie := adminCookie(t, store)
+ req := httptest.NewRequest("GET", "/api/admin/session", nil)
+ req.Header.Set("Cookie", cookie)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var resp AdminSessionResponse
+ json.NewDecoder(w.Body).Decode(&resp)
+ if !resp.Authenticated {
+ t.Error("Authenticated = false, want true")
+ }
+}
+
+func TestHandleAdminSession_Unauthenticated(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+ handler := HandleAdminSession(deps)
+
+ req := httptest.NewRequest("GET", "/api/admin/session", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Errorf("status = %d, want 401", w.Code)
+ }
+}
+
+func TestHandleAdminOverview(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ // 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()})
+
+ handler := HandleAdminOverview(deps)
+ req := httptest.NewRequest("GET", "/api/admin/overview", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var overview AdminOverview
+ json.NewDecoder(w.Body).Decode(&overview)
+ if overview.StoredMessages != 1 {
+ t.Errorf("StoredMessages = %d, want 1", overview.StoredMessages)
+ }
+ if overview.ModerationActions != 1 {
+ t.Errorf("ModerationActions = %d, want 1", overview.ModerationActions)
+ }
+}
+
+func TestHandleAdminPlaylist_Get(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+
+ // Create temp playlist
+ tmpDir := t.TempDir()
+ playlistPath := filepath.Join(tmpDir, "playlist.txt")
+ os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
+ os.Setenv("PLAYLIST_PATH", playlistPath)
+ defer os.Unsetenv("PLAYLIST_PATH")
+
+ handler := HandleAdminPlaylist(deps)
+ req := httptest.NewRequest("GET", "/api/admin/playlist", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var payload PlaylistPayload
+ json.NewDecoder(w.Body).Decode(&payload)
+ if !strings.Contains(payload.Content, "test.mp4") {
+ t.Errorf("Content = %q, want test.mp4", payload.Content)
+ }
+}
+
+func TestHandleAdminPlaylist_Put(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+
+ tmpDir := t.TempDir()
+ playlistPath := filepath.Join(tmpDir, "playlist.txt")
+ os.Setenv("PLAYLIST_PATH", playlistPath)
+ defer os.Unsetenv("PLAYLIST_PATH")
+
+ handler := HandleAdminPlaylist(deps)
+ body := `{"content":"file /videos/new.mp4"}`
+ req := httptest.NewRequest("PUT", "/api/admin/playlist", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ // Verify file written
+ data, _ := os.ReadFile(playlistPath)
+ if !strings.Contains(string(data), "new.mp4") {
+ t.Errorf("file content = %q, want new.mp4", string(data))
+ }
+}
+
+func TestHandleAdminModerationLogs(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u1", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
+
+ handler := HandleAdminModerationLogs(deps)
+ req := httptest.NewRequest("GET", "/api/admin/moderation/logs", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var resp ModerationLogsResponse
+ json.NewDecoder(w.Body).Decode(&resp)
+ if len(resp.Logs) != 1 {
+ t.Errorf("len(Logs) = %d, want 1", len(resp.Logs))
+ }
+}
+
+func TestHandleAdminModerationAction(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ handler := HandleAdminModerationAction(deps)
+ body := `{"action":"mute","target":"baduser","moderator":"admin","reason":"spam","room":"main"}`
+ req := httptest.NewRequest("POST", "/api/admin/moderation/actions", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var count int64
+ gormDB.Model(&db.ModerationLog{}).Count(&count)
+ if count != 1 {
+ t.Errorf("moderation log count = %d, want 1", count)
+ }
+}
+
+func TestHandleAdminModerationAction_MissingFields(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+
+ handler := HandleAdminModerationAction(deps)
+ body := `{"action":"mute"}`
+ req := httptest.NewRequest("POST", "/api/admin/moderation/actions", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleAdminAnalytics(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
+ gormDB.Create(&db.Message{Room: "main", Username: "u2", Text: "hey", Timestamp: time.Now(), Type: "public"})
+
+ handler := HandleAdminAnalytics(deps)
+ req := httptest.NewRequest("GET", "/api/admin/analytics", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var analytics AdminAnalytics
+ json.NewDecoder(w.Body).Decode(&analytics)
+ if analytics.TotalMessages != 2 {
+ t.Errorf("TotalMessages = %d, want 2", analytics.TotalMessages)
+ }
+}
+
+func TestHandleAdminSystemSummary(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+
+ handler := HandleAdminSystemSummary(deps)
+ req := httptest.NewRequest("GET", "/api/admin/system", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var summary AdminSystemSummary
+ json.NewDecoder(w.Body).Decode(&summary)
+ if summary.DatabaseDriver != "sqlite" {
+ t.Errorf("DatabaseDriver = %q, want sqlite", summary.DatabaseDriver)
+ }
+}
+
+func TestHandleAdminConfig_Get(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+
+ handler := HandleAdminConfig(deps)
+ req := httptest.NewRequest("GET", "/api/admin/config", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+}
+
+func TestHandleAdminConfig_Put(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+ setupConfigTest(t)
+
+ handler := HandleAdminConfig(deps)
+ body := `{"rateLimitMax":20}`
+ req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+}
+
+// setupConfigTest points the runtime config at a fresh temp file and resets the store.
+func setupConfigTest(t *testing.T) {
+ t.Helper()
+ os.Setenv("CONFIG_PATH", filepath.Join(t.TempDir(), "config.json"))
+ os.Unsetenv("MATOMO_URL")
+ os.Unsetenv("MATOMO_SITE_ID")
+ os.Unsetenv("HLS_SEGMENT_DURATION")
+ os.Unsetenv("HLS_WINDOW_SIZE")
+ if err := config.LoadRuntimeConfig(); err != nil {
+ t.Fatalf("LoadRuntimeConfig: %v", err)
+ }
+ t.Cleanup(func() { os.Unsetenv("CONFIG_PATH") })
+}
+
+func TestHandleAdminConfig_Get_NewFields(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+ setupConfigTest(t)
+
+ handler := HandleAdminConfig(deps)
+ req := httptest.NewRequest("GET", "/api/admin/config", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ var cfg map[string]any
+ if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ for _, k := range []string{"matomoUrl", "matomoSiteId", "hlsSegmentDuration", "hlsWindowSize"} {
+ if _, ok := cfg[k]; !ok {
+ t.Errorf("response missing %q", k)
+ }
+ }
+}
+
+func TestHandleAdminConfig_Put_Persists(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+ setupConfigTest(t)
+
+ handler := HandleAdminConfig(deps)
+ body := `{"hlsSegmentDuration":5,"hlsWindowSize":30,"matomoUrl":"https://stats.example.com","matomoSiteId":"42"}`
+ req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ var cfg map[string]any
+ if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if cfg["hlsSegmentDuration"] != float64(5) {
+ t.Errorf("hlsSegmentDuration = %v, want 5", cfg["hlsSegmentDuration"])
+ }
+ if cfg["hlsWindowSize"] != float64(30) {
+ t.Errorf("hlsWindowSize = %v, want 30", cfg["hlsWindowSize"])
+ }
+ if cfg["matomoUrl"] != "https://stats.example.com" {
+ t.Errorf("matomoUrl = %v", cfg["matomoUrl"])
+ }
+ if cfg["matomoSiteId"] != "42" {
+ t.Errorf("matomoSiteId = %v", cfg["matomoSiteId"])
+ }
+}
+
+func TestHandleAdminConfig_Put_Invalid(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+ setupConfigTest(t)
+
+ handler := HandleAdminConfig(deps)
+ body := `{"hlsSegmentDuration":99}`
+ req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandlePublicMatomo(t *testing.T) {
+ setupConfigTest(t)
+
+ handler := HandlePublicMatomo()
+ req := httptest.NewRequest("GET", "/api/matomo", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ var resp map[string]any
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ for _, k := range []string{"matomoUrl", "matomoSiteId"} {
+ if _, ok := resp[k]; !ok {
+ t.Errorf("response missing %q", k)
+ }
+ }
+}
+
+func TestHandleUserRegister_Success(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ handler := HandleUserRegister(deps)
+ body := `{"username":"alice","password":"secret123"}`
+ req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
+ }
+ var count int64
+ gormDB.Model(&db.User{}).Where("username = ?", "alice").Count(&count)
+ if count != 1 {
+ t.Errorf("user count = %d, want 1", count)
+ }
+ if len(w.Result().Cookies()) == 0 {
+ t.Error("expected session cookie to be set")
+ }
+}
+
+func TestHandleUserRegister_Duplicate(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+ gormDB.Create(&db.User{Username: "alice", PasswordHash: "x"})
+
+ handler := HandleUserRegister(deps)
+ body := `{"username":"alice","password":"secret123"}`
+ req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusConflict {
+ t.Errorf("status = %d, want 409", w.Code)
+ }
+}
+
+func TestHandleUserLogin_Success(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+ hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
+ gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
+
+ handler := HandleUserLogin(deps)
+ body := `{"username":"bob","password":"secret123"}`
+ req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
+ }
+}
+
+func TestHandleUserLogin_WrongPassword(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+ hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
+ gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
+
+ handler := HandleUserLogin(deps)
+ body := `{"username":"bob","password":"wrong"}`
+ req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Errorf("status = %d, want 401", w.Code)
+ }
+}
+
+func TestHandleUserSession(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+ handler := HandleUserSession(deps)
+
+ // Unauthenticated
+ req := httptest.NewRequest("GET", "/api/auth/session", nil)
+ w := httptest.NewRecorder()
+ handler(w, req)
+ var resp map[string]any
+ json.NewDecoder(w.Body).Decode(&resp)
+ if resp["authenticated"] != false {
+ t.Errorf("authenticated = %v, want false", resp["authenticated"])
+ }
+
+ // Authenticated
+ token, _ := auth.GenerateToken()
+ deps.UserSessions.Add(token, "carol", time.Now().Add(1*time.Hour))
+ req2 := httptest.NewRequest("GET", "/api/auth/session", nil)
+ req2.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
+ w2 := httptest.NewRecorder()
+ handler(w2, req2)
+ var resp2 map[string]any
+ json.NewDecoder(w2.Body).Decode(&resp2)
+ if resp2["username"] != "carol" {
+ t.Errorf("username = %v, want carol", resp2["username"])
+ }
+}
+
+func TestHandleUserDMHistory(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ token, _ := auth.GenerateToken()
+ deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
+
+ now := time.Now()
+ gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "hi bob", Type: "private", Timestamp: now.Add(-2 * time.Hour)})
+ gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "hi alice", Type: "private", Timestamp: now.Add(-1 * time.Hour)})
+
+ handler := HandleUserDMHistory(deps)
+ req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
+ req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
+ w := httptest.NewRecorder()
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ var msgs []db.Message
+ json.NewDecoder(w.Body).Decode(&msgs)
+ if len(msgs) != 2 {
+ t.Fatalf("len = %d, want 2", len(msgs))
+ }
+ if msgs[0].Text != "hi bob" {
+ t.Errorf("first message = %q, want hi bob (chronological)", msgs[0].Text)
+ }
+}
+
+func TestHandleUserDMHistory_Unauthorized(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+
+ handler := HandleUserDMHistory(deps)
+ req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
+ w := httptest.NewRecorder()
+ handler(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Errorf("status = %d, want 401", w.Code)
+ }
+}
+
+func TestHandleUserDMSearch(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ token, _ := auth.GenerateToken()
+ deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
+
+ gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "meet at 8", Type: "private"})
+ gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "see you", Type: "private"})
+
+ handler := HandleUserDMSearch(deps)
+ req := httptest.NewRequest("GET", "/api/dm/search?with=bob&q=meet", nil)
+ req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
+ w := httptest.NewRecorder()
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ var msgs []db.Message
+ json.NewDecoder(w.Body).Decode(&msgs)
+ if len(msgs) != 1 || msgs[0].Text != "meet at 8" {
+ t.Fatalf("search results = %v, want [meet at 8]", msgs)
+ }
+}
+
+func TestHandleAdminRestart(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+
+ handler := HandleAdminRestart(deps)
+ req := httptest.NewRequest("POST", "/api/admin/restart?service=chat", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+}
+
+func TestHandleAdminUsers_Get(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+
+ handler := HandleAdminUsers(deps)
+ req := httptest.NewRequest("GET", "/api/admin/users", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+}
+
+func TestHandleAdminUsers_Post(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+
+ handler := HandleAdminUsers(deps)
+ body := `{"action":"mute","username":"baduser","duration":10}`
+ req := httptest.NewRequest("POST", "/api/admin/users", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+}
+
+func TestHandleAdminSchedule(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ tmpDir := t.TempDir()
+ playlistPath := filepath.Join(tmpDir, "playlist.txt")
+ os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
+ os.Setenv("PLAYLIST_PATH", playlistPath)
+ defer os.Unsetenv("PLAYLIST_PATH")
+
+ gormDB.Create(&db.Media{
+ Filename: "test.mp4",
+ OriginalName: "test.mp4",
+ Path: "/videos/test.mp4",
+ SizeBytes: 1024,
+ DurationSeconds: 60,
+ Status: "ready",
+ Title: "Test Video",
+ })
+
+ handler := HandleAdminSchedule(deps)
+ req := httptest.NewRequest("GET", "/api/admin/schedule", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var resp AdminScheduleResponse
+ json.NewDecoder(w.Body).Decode(&resp)
+ if resp.MatchedEntries != 1 {
+ t.Errorf("MatchedEntries = %d, want 1", resp.MatchedEntries)
+ }
+}
+
+func TestHandleAdminScheduleOverrides_CRUD(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ // Create
+ handler := HandleAdminScheduleOverrides(deps)
+ now := time.Now()
+ body, _ := json.Marshal(db.ScheduleOverride{
+ MediaID: 1,
+ StartTime: now,
+ EndTime: now.Add(1 * time.Hour),
+ Reason: "test",
+ })
+ req := httptest.NewRequest("POST", "/api/admin/schedule/overrides", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ handler(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Errorf("POST status = %d, want 201", w.Code)
+ }
+
+ // List
+ req2 := httptest.NewRequest("GET", "/api/admin/schedule/overrides", nil)
+ w2 := httptest.NewRecorder()
+ handler(w2, req2)
+
+ if w2.Code != http.StatusOK {
+ t.Errorf("GET status = %d, want 200", w2.Code)
+ }
+
+ // Delete
+ var overrides []db.ScheduleOverride
+ gormDB.Find(&overrides)
+ if len(overrides) > 0 {
+ req3 := httptest.NewRequest("DELETE", "/api/admin/schedule/overrides?id=1", nil)
+ w3 := httptest.NewRecorder()
+ handler(w3, req3)
+ if w3.Code != http.StatusNoContent {
+ t.Errorf("DELETE status = %d, want 204", w3.Code)
+ }
+ }
+}
+
+func TestHandleAdminChatLog(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
+
+ handler := HandleAdminChatLog(deps)
+ req := httptest.NewRequest("GET", "/api/admin/chat-log", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var resp ChatLogListResponse
+ json.NewDecoder(w.Body).Decode(&resp)
+ if resp.Total != 1 {
+ t.Errorf("Total = %d, want 1", resp.Total)
+ }
+}
+
+func TestHandleAdminChatLog_FilterByRoom(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
+ gormDB.Create(&db.Message{Room: "lobby", Username: "u2", Text: "hey", Timestamp: time.Now(), Type: "public"})
+
+ handler := HandleAdminChatLog(deps)
+ req := httptest.NewRequest("GET", "/api/admin/chat-log?room=main", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var resp ChatLogListResponse
+ json.NewDecoder(w.Body).Decode(&resp)
+ if resp.Total != 1 {
+ t.Errorf("Total = %d, want 1", resp.Total)
+ }
+}
+
+func TestHandleAdminChatLog_ExcludesSystem(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ gormDB.Create(&db.Message{Room: "main", Username: "system", Text: "bob joined main", Timestamp: time.Now(), Type: "system"})
+ gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
+
+ handler := HandleAdminChatLog(deps)
+ req := httptest.NewRequest("GET", "/api/admin/chat-log", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var resp ChatLogListResponse
+ json.NewDecoder(w.Body).Decode(&resp)
+ if resp.Total != 1 {
+ t.Errorf("Total = %d, want 1 (system messages must be excluded)", resp.Total)
+ }
+ if len(resp.Entries) != 1 || resp.Entries[0].Text != "hello" {
+ t.Errorf("entries = %v, want only the public chat message", resp.Entries)
+ }
+}
+
+func TestHandleAdminChatLogByID_Get(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ msg := db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"}
+ gormDB.Create(&msg)
+
+ handler := HandleAdminChatLogByID(deps)
+ req := httptest.NewRequest("GET", "/api/admin/chat-log/1", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+}
+
+func TestHandleAdminChatLogByID_Delete(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ msg := db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"}
+ gormDB.Create(&msg)
+
+ handler := HandleAdminChatLogByID(deps)
+ req := httptest.NewRequest("DELETE", "/api/admin/chat-log/1", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusNoContent {
+ t.Errorf("status = %d, want 204", w.Code)
+ }
+
+ var count int64
+ gormDB.Model(&db.Message{}).Count(&count)
+ if count != 0 {
+ t.Errorf("message count after delete = %d, want 0", count)
+ }
+}
+
+func TestHandleAdminChatLogBulk(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "m1", Timestamp: time.Now(), Type: "public"})
+ gormDB.Create(&db.Message{Room: "main", Username: "u2", Text: "m2", Timestamp: time.Now(), Type: "public"})
+
+ handler := HandleAdminChatLogBulk(deps)
+ body := `{"ids":[1,2]}`
+ req := httptest.NewRequest("DELETE", "/api/admin/chat-log/bulk", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var count int64
+ gormDB.Model(&db.Message{}).Count(&count)
+ if count != 0 {
+ t.Errorf("message count after bulk delete = %d, want 0", count)
+ }
+}
+
+func TestHandleAdminChatLogExport(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
+
+ handler := HandleAdminChatLogExport(deps)
+ req := httptest.NewRequest("GET", "/api/admin/chat-log/export", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ contentType := w.Header().Get("Content-Type")
+ if !strings.Contains(contentType, "text/csv") {
+ t.Errorf("Content-Type = %q, want text/csv", contentType)
+ }
+
+ body := w.Body.String()
+ if !strings.Contains(body, "hello") {
+ t.Errorf("CSV body missing 'hello': %s", body)
+ }
+}
+
+func TestHandlePublicSchedule(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+
+ tmpDir := t.TempDir()
+ playlistPath := filepath.Join(tmpDir, "playlist.txt")
+ os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
+ os.Setenv("PLAYLIST_PATH", playlistPath)
+ defer os.Unsetenv("PLAYLIST_PATH")
+
+ handler := HandlePublicSchedule(deps)
+ req := httptest.NewRequest("GET", "/api/schedule", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+}
+
+func TestHandleAdminMedia_Get(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ gormDB.Create(&db.Media{
+ Filename: "test.mp4",
+ OriginalName: "test.mp4",
+ Path: "/media/test.mp4",
+ SizeBytes: 1024,
+ DurationSeconds: 60,
+ Status: "ready",
+ Title: "Test",
+ })
+
+ handler := HandleAdminMedia(deps)
+ req := httptest.NewRequest("GET", "/api/admin/media", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var resp AdminMediaResponse
+ json.NewDecoder(w.Body).Decode(&resp)
+ if len(resp.Files) != 1 {
+ t.Errorf("len(Files) = %d, want 1", len(resp.Files))
+ }
+}
+
+func TestHandleAdminMediaByID_Get(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ gormDB.Create(&db.Media{
+ Filename: "test.mp4",
+ OriginalName: "test.mp4",
+ Path: "/media/test.mp4",
+ SizeBytes: 1024,
+ DurationSeconds: 60,
+ Status: "ready",
+ Title: "Test",
+ })
+
+ handler := HandleAdminMediaByID(deps)
+ req := httptest.NewRequest("GET", "/api/admin/media/1", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+}
+
+func TestHandleAdminMediaByID_NotFound(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+
+ handler := HandleAdminMediaByID(deps)
+ req := httptest.NewRequest("GET", "/api/admin/media/999", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", w.Code)
+ }
+}
+
+func TestHandleAdminMediaByID_Put(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ gormDB.Create(&db.Media{
+ Filename: "test.mp4",
+ OriginalName: "test.mp4",
+ Path: "/media/test.mp4",
+ SizeBytes: 1024,
+ DurationSeconds: 60,
+ Status: "ready",
+ Title: "Old Title",
+ })
+
+ handler := HandleAdminMediaByID(deps)
+ body := `{"title":"New Title","status":"needs_normalization","tags":"tag1"}`
+ req := httptest.NewRequest("PUT", "/api/admin/media/1", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var updated db.Media
+ gormDB.First(&updated, 1)
+ if updated.Title != "New Title" {
+ t.Errorf("Title = %q, want New Title", updated.Title)
+ }
+}
+
+func TestHandleAdminMediaByID_Delete(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ // Create a real temp file for the media record
+ tmpDir := t.TempDir()
+ os.Setenv("MEDIA_DIR", tmpDir)
+ defer os.Unsetenv("MEDIA_DIR")
+
+ mediaPath := filepath.Join(tmpDir, "test.mp4")
+ os.WriteFile(mediaPath, []byte("fake video"), 0644)
+
+ gormDB.Create(&db.Media{
+ Filename: "test.mp4",
+ OriginalName: "test.mp4",
+ Path: mediaPath,
+ SizeBytes: 10,
+ DurationSeconds: 1,
+ Status: "ready",
+ Title: "Test",
+ })
+
+ handler := HandleAdminMediaByID(deps)
+ req := httptest.NewRequest("DELETE", "/api/admin/media/1", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var count int64
+ gormDB.Model(&db.Media{}).Count(&count)
+ if count != 0 {
+ t.Errorf("media count after delete = %d, want 0", count)
+ }
+}
+
+func TestHandleAdminMediaUploads_Init(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+
+ handler := HandleAdminMediaUploads(deps)
+ body := `{"fileName":"video.mp4","fileSize":1024,"mimeType":"video/mp4","chunkSize":524288,"totalChunks":1}`
+ req := httptest.NewRequest("POST", "/api/admin/media/uploads", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", w.Code)
+ }
+
+ var status AdminMediaUploadStatusResponse
+ json.NewDecoder(w.Body).Decode(&status)
+ if status.FileName != "video.mp4" {
+ t.Errorf("FileName = %q, want video.mp4", status.FileName)
+ }
+ if status.UploadID == "" {
+ t.Error("UploadID is empty")
+ }
+}
+
+func TestHandleAdminMediaUploads_InvalidFile(t *testing.T) {
+ deps, _, _ := setupTestDeps(t)
+
+ handler := HandleAdminMediaUploads(deps)
+ body := `{"fileName":"bad.exe","fileSize":1024,"mimeType":"application/x-msdownload","chunkSize":524288,"totalChunks":1}`
+ req := httptest.NewRequest("POST", "/api/admin/media/uploads", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestNormalizeMediaRecord_Success(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ tmpDir := t.TempDir()
+ os.Setenv("MEDIA_DIR", tmpDir)
+ defer os.Unsetenv("MEDIA_DIR")
+
+ srcPath := filepath.Join(tmpDir, "source.mov")
+ if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ gormDB.Create(&db.Media{
+ Filename: "source.mov",
+ OriginalName: "source.mov",
+ Path: srcPath,
+ SizeBytes: 10,
+ DurationSeconds: 0,
+ Status: "needs_normalization",
+ Title: "source",
+ })
+
+ origNormalize := media.NormalizeVideo
+ t.Cleanup(func() { media.NormalizeVideo = origNormalize })
+ media.NormalizeVideo = func(src, dst string) (int, int64, error) {
+ if err := os.WriteFile(dst, []byte("normalized"), 0o644); err != nil {
+ return 0, 0, err
+ }
+ return 120, 10, nil
+ }
+
+ var record db.Media
+ gormDB.First(&record, 1)
+ normalizeMediaRecord(deps, record)
+
+ var updated db.Media
+ gormDB.First(&updated, 1)
+ if updated.Status != "ready" {
+ t.Errorf("Status = %q, want ready", updated.Status)
+ }
+ if updated.DurationSeconds != 120 {
+ t.Errorf("DurationSeconds = %d, want 120", updated.DurationSeconds)
+ }
+ if !strings.HasSuffix(updated.Path, ".normalized.mp4") {
+ t.Errorf("Path = %q, want .normalized.mp4 suffix", updated.Path)
+ }
+}
+
+func TestNormalizeMediaRecord_Failure(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ tmpDir := t.TempDir()
+ os.Setenv("MEDIA_DIR", tmpDir)
+ defer os.Unsetenv("MEDIA_DIR")
+
+ srcPath := filepath.Join(tmpDir, "source.mov")
+ if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ gormDB.Create(&db.Media{
+ Filename: "source.mov",
+ OriginalName: "source.mov",
+ Path: srcPath,
+ SizeBytes: 10,
+ DurationSeconds: 0,
+ Status: "needs_normalization",
+ Title: "source",
+ })
+
+ origNormalize := media.NormalizeVideo
+ t.Cleanup(func() { media.NormalizeVideo = origNormalize })
+ media.NormalizeVideo = func(src, dst string) (int, int64, error) {
+ return 0, 0, fmt.Errorf("ffmpeg failed")
+ }
+
+ var record db.Media
+ gormDB.First(&record, 1)
+ normalizeMediaRecord(deps, record)
+
+ var updated db.Media
+ gormDB.First(&updated, 1)
+ if updated.Status != "normalization_failed" {
+ t.Errorf("Status = %q, want normalization_failed", updated.Status)
+ }
+}
+
+func TestHandleAdminMediaByID_Normalize(t *testing.T) {
+ deps, gormDB, _ := setupTestDeps(t)
+
+ tmpDir := t.TempDir()
+ os.Setenv("MEDIA_DIR", tmpDir)
+ defer os.Unsetenv("MEDIA_DIR")
+
+ srcPath := filepath.Join(tmpDir, "source.mov")
+ if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ gormDB.Create(&db.Media{
+ Filename: "source.mov",
+ OriginalName: "source.mov",
+ Path: srcPath,
+ SizeBytes: 10,
+ DurationSeconds: 0,
+ Status: "needs_normalization",
+ Title: "source",
+ })
+
+ origNormalize := media.NormalizeVideo
+ t.Cleanup(func() { media.NormalizeVideo = origNormalize })
+ media.NormalizeVideo = func(src, dst string) (int, int64, error) {
+ if err := os.WriteFile(dst, []byte("normalized"), 0o644); err != nil {
+ return 0, 0, err
+ }
+ return 90, 9, nil
+ }
+
+ handler := HandleAdminMediaByID(deps)
+ req := httptest.NewRequest("POST", "/api/admin/media/1/normalize", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusAccepted {
+ t.Errorf("status = %d, want 202", w.Code)
+ }
+
+ // Wait for the asynchronous normalization goroutine to finish before
+ // t.Cleanup restores media.NormalizeVideo, avoiding a data race on the
+ // package-level mock variable.
+ var updated db.Media
+ deadline := time.Now().Add(5 * time.Second)
+ for {
+ gormDB.First(&updated, 1)
+ if updated.Status != media.StatusNormalizing {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("normalization did not complete within 5s")
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ if updated.Status != media.StatusReady {
+ t.Errorf("Status = %q, want ready", updated.Status)
+ }
+ if updated.DurationSeconds != 90 {
+ t.Errorf("DurationSeconds = %d, want 90", updated.DurationSeconds)
+ }
+ if !strings.HasSuffix(updated.Path, ".normalized.mp4") {
+ t.Errorf("Path = %q, want .normalized.mp4 suffix", updated.Path)
+ }
}
\ No newline at end of file
diff --git a/backend/chat/src/main.go b/backend/chat/src/main.go
index 517e3db..92a82ce 100644
--- a/backend/chat/src/main.go
+++ b/backend/chat/src/main.go
@@ -1,111 +1,111 @@
-package main
-
-import (
- "crypto/rand"
- "encoding/hex"
- "fmt"
- "log"
- "net/http"
-
- "chat/src/auth"
- "chat/src/config"
- "chat/src/db"
- "chat/src/handlers"
- "chat/src/metrics"
- "chat/src/ws"
-)
-
-func generateShortID() string {
- b := make([]byte, 6)
- rand.Read(b)
- return hex.EncodeToString(b)
-}
-
-func main() {
- instanceID := fmt.Sprintf("chat-%s", generateShortID())
-
- rateLimitMax := config.RateLimitMax()
- rateLimitWindow := config.RateLimitWindow()
- log.Printf("Rate limit: %d messages per %v", rateLimitMax, rateLimitWindow)
-
- // Load persisted runtime configuration (Matomo + HLS settings)
- if err := config.LoadRuntimeConfig(); err != nil {
- log.Printf("Warning: failed to load runtime config: %v", err)
- }
-
- // Initialize Redis
- redisWrapper := db.InitRedis(config.RedisAddress())
-
- // Initialize DB
- dbClient, dbDriver, err := db.InitDB(config.DatabaseURL())
- if err != nil {
- log.Fatalf("Failed to initialize database: %v", err)
- }
-
- // Initialize session store
- sessionStore := auth.NewSessionStore()
- userSessions := auth.NewUserSessionStore()
-
- // Initialize WebSocket hub
- hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername())
-
- // Start Redis pub/sub subscriber
- go hub.SubscribeRoomMessages()
-
- log.Printf("Instance ID: %s", instanceID)
-
- // Build dependencies
- deps := &handlers.Dependencies{
- DB: dbClient.DB,
- Redis: redisWrapper,
- Hub: hub,
- SessionStore: sessionStore,
- UserSessions: userSessions,
- DBDriver: dbDriver,
- }
-
- // Auth middleware
- requireAdmin := auth.RequireAdmin(sessionStore, config.AdminSessionCookieName)
-
- // Register routes
- http.HandleFunc("/ws", hub.HandleConnection)
- http.HandleFunc("/api/auth/register", metrics.WrapRequestTiming(handlers.HandleUserRegister(deps)))
- http.HandleFunc("/api/auth/login", metrics.WrapRequestTiming(handlers.HandleUserLogin(deps)))
- http.HandleFunc("/api/auth/session", metrics.WrapRequestTiming(handlers.HandleUserSession(deps)))
- http.HandleFunc("/api/auth/logout", metrics.WrapRequestTiming(handlers.HandleUserLogout(deps)))
- http.HandleFunc("/api/dm/history", metrics.WrapRequestTiming(handlers.HandleUserDMHistory(deps)))
- http.HandleFunc("/api/dm/search", metrics.WrapRequestTiming(handlers.HandleUserDMSearch(deps)))
- http.HandleFunc("/api/admin/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps)))
- http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps)))
- http.HandleFunc("/api/admin/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(deps)))
- http.HandleFunc("/api/admin/overview", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminOverview(deps))))
- http.HandleFunc("/api/admin/playlist", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminPlaylist(deps))))
- http.HandleFunc("/api/admin/moderation/logs", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationLogs(deps))))
- http.HandleFunc("/api/admin/moderation/actions", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationAction(deps))))
- http.HandleFunc("/api/admin/analytics", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminAnalytics(deps))))
- http.HandleFunc("/api/admin/system", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSystemSummary(deps))))
- http.HandleFunc("/api/admin/config", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminConfig(deps))))
- http.HandleFunc("/api/admin/restart", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminRestart(deps))))
- http.HandleFunc("/api/admin/users", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminUsers(deps))))
- http.HandleFunc("/api/admin/schedule", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(deps))))
- http.HandleFunc("/api/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(deps))))
- http.HandleFunc("/api/schedule", metrics.WrapRequestTiming(handlers.HandlePublicSchedule(deps)))
- http.HandleFunc("/api/matomo", metrics.WrapRequestTiming(handlers.HandlePublicMatomo()))
- http.HandleFunc("/api/admin/media", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMedia(deps))))
- http.HandleFunc("/api/admin/media/uploads", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
- http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
- http.HandleFunc("/api/admin/media/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaByID(deps))))
- http.HandleFunc("/api/admin/chat-log/bulk", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogBulk(deps))))
- http.HandleFunc("/api/admin/chat-log/export", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogExport(deps))))
- http.HandleFunc("/api/admin/chat-log/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogByID(deps))))
- http.HandleFunc("/api/admin/chat-log", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLog(deps))))
- http.HandleFunc("/metrics", metrics.HandleMetrics(dbClient.DB, redisWrapper, hub, sessionStore, dbDriver, instanceID))
- http.HandleFunc("/health", metrics.WrapRequestTiming(metrics.HandleHealth()))
- http.HandleFunc("/", metrics.WrapRequestTiming(metrics.HandleNotFound()))
-
- port := config.ChatPort()
- log.Printf("Chat service starting on :%s", port)
- if err := http.ListenAndServe(":"+port, nil); err != nil {
- log.Fatalf("Server failed: %v", err)
- }
+package main
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "log"
+ "net/http"
+
+ "chat/src/auth"
+ "chat/src/config"
+ "chat/src/db"
+ "chat/src/handlers"
+ "chat/src/metrics"
+ "chat/src/ws"
+)
+
+func generateShortID() string {
+ b := make([]byte, 6)
+ rand.Read(b)
+ return hex.EncodeToString(b)
+}
+
+func main() {
+ instanceID := fmt.Sprintf("chat-%s", generateShortID())
+
+ rateLimitMax := config.RateLimitMax()
+ rateLimitWindow := config.RateLimitWindow()
+ log.Printf("Rate limit: %d messages per %v", rateLimitMax, rateLimitWindow)
+
+ // Load persisted runtime configuration (Matomo + HLS settings)
+ if err := config.LoadRuntimeConfig(); err != nil {
+ log.Printf("Warning: failed to load runtime config: %v", err)
+ }
+
+ // Initialize Redis
+ redisWrapper := db.InitRedis(config.RedisAddress())
+
+ // Initialize DB
+ dbClient, dbDriver, err := db.InitDB(config.DatabaseURL())
+ if err != nil {
+ log.Fatalf("Failed to initialize database: %v", err)
+ }
+
+ // Initialize session store
+ sessionStore := auth.NewSessionStore()
+ userSessions := auth.NewUserSessionStore()
+
+ // Initialize WebSocket hub
+ hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername())
+
+ // Start Redis pub/sub subscriber
+ go hub.SubscribeRoomMessages()
+
+ log.Printf("Instance ID: %s", instanceID)
+
+ // Build dependencies
+ deps := &handlers.Dependencies{
+ DB: dbClient.DB,
+ Redis: redisWrapper,
+ Hub: hub,
+ SessionStore: sessionStore,
+ UserSessions: userSessions,
+ DBDriver: dbDriver,
+ }
+
+ // Auth middleware
+ requireAdmin := auth.RequireAdmin(sessionStore, config.AdminSessionCookieName)
+
+ // Register routes
+ http.HandleFunc("/ws", hub.HandleConnection)
+ http.HandleFunc("/api/auth/register", metrics.WrapRequestTiming(handlers.HandleUserRegister(deps)))
+ http.HandleFunc("/api/auth/login", metrics.WrapRequestTiming(handlers.HandleUserLogin(deps)))
+ http.HandleFunc("/api/auth/session", metrics.WrapRequestTiming(handlers.HandleUserSession(deps)))
+ http.HandleFunc("/api/auth/logout", metrics.WrapRequestTiming(handlers.HandleUserLogout(deps)))
+ http.HandleFunc("/api/dm/history", metrics.WrapRequestTiming(handlers.HandleUserDMHistory(deps)))
+ http.HandleFunc("/api/dm/search", metrics.WrapRequestTiming(handlers.HandleUserDMSearch(deps)))
+ http.HandleFunc("/api/admin/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps)))
+ http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps)))
+ http.HandleFunc("/api/admin/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(deps)))
+ http.HandleFunc("/api/admin/overview", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminOverview(deps))))
+ http.HandleFunc("/api/admin/playlist", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminPlaylist(deps))))
+ http.HandleFunc("/api/admin/moderation/logs", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationLogs(deps))))
+ http.HandleFunc("/api/admin/moderation/actions", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationAction(deps))))
+ http.HandleFunc("/api/admin/analytics", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminAnalytics(deps))))
+ http.HandleFunc("/api/admin/system", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSystemSummary(deps))))
+ http.HandleFunc("/api/admin/config", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminConfig(deps))))
+ http.HandleFunc("/api/admin/restart", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminRestart(deps))))
+ http.HandleFunc("/api/admin/users", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminUsers(deps))))
+ http.HandleFunc("/api/admin/schedule", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(deps))))
+ http.HandleFunc("/api/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(deps))))
+ http.HandleFunc("/api/schedule", metrics.WrapRequestTiming(handlers.HandlePublicSchedule(deps)))
+ http.HandleFunc("/api/matomo", metrics.WrapRequestTiming(handlers.HandlePublicMatomo()))
+ http.HandleFunc("/api/admin/media", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMedia(deps))))
+ http.HandleFunc("/api/admin/media/uploads", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
+ http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
+ http.HandleFunc("/api/admin/media/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaByID(deps))))
+ http.HandleFunc("/api/admin/chat-log/bulk", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogBulk(deps))))
+ http.HandleFunc("/api/admin/chat-log/export", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogExport(deps))))
+ http.HandleFunc("/api/admin/chat-log/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogByID(deps))))
+ http.HandleFunc("/api/admin/chat-log", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLog(deps))))
+ http.HandleFunc("/metrics", metrics.HandleMetrics(dbClient.DB, redisWrapper, hub, sessionStore, dbDriver, instanceID))
+ http.HandleFunc("/health", metrics.WrapRequestTiming(metrics.HandleHealth()))
+ http.HandleFunc("/", metrics.WrapRequestTiming(metrics.HandleNotFound()))
+
+ port := config.ChatPort()
+ log.Printf("Chat service starting on :%s", port)
+ if err := http.ListenAndServe(":"+port, nil); err != nil {
+ log.Fatalf("Server failed: %v", err)
+ }
}
\ No newline at end of file
diff --git a/backend/chat/src/media/media.go b/backend/chat/src/media/media.go
index 4a90f35..b822362 100644
--- a/backend/chat/src/media/media.go
+++ b/backend/chat/src/media/media.go
@@ -1,450 +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
+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
}
\ No newline at end of file
diff --git a/backend/chat/src/media/media_test.go b/backend/chat/src/media/media_test.go
index 6f24972..623e61d 100644
--- a/backend/chat/src/media/media_test.go
+++ b/backend/chat/src/media/media_test.go
@@ -1,246 +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")
- }
+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")
+ }
}
\ No newline at end of file
diff --git a/backend/chat/src/metrics/metrics.go b/backend/chat/src/metrics/metrics.go
index 8a5fdb9..013054c 100644
--- a/backend/chat/src/metrics/metrics.go
+++ b/backend/chat/src/metrics/metrics.go
@@ -1,278 +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)
+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)
}
\ No newline at end of file
diff --git a/backend/chat/src/metrics/metrics_test.go b/backend/chat/src/metrics/metrics_test.go
index 77f572e..c80fe4a 100644
--- a/backend/chat/src/metrics/metrics_test.go
+++ b/backend/chat/src/metrics/metrics_test.go
@@ -1,216 +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
-}
+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 }
\ No newline at end of file
diff --git a/backend/chat/src/ratelimit/ratelimit.go b/backend/chat/src/ratelimit/ratelimit.go
index ee34a41..a143d36 100644
--- a/backend/chat/src/ratelimit/ratelimit.go
+++ b/backend/chat/src/ratelimit/ratelimit.go
@@ -1,75 +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)
+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)
}
\ No newline at end of file
diff --git a/backend/chat/src/ratelimit/ratelimit_test.go b/backend/chat/src/ratelimit/ratelimit_test.go
index 5164133..e73e039 100644
--- a/backend/chat/src/ratelimit/ratelimit_test.go
+++ b/backend/chat/src/ratelimit/ratelimit_test.go
@@ -1,190 +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")
+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")
}
\ No newline at end of file
diff --git a/backend/chat/src/ws/ws.go b/backend/chat/src/ws/ws.go
index aca0c6f..8a58545 100644
--- a/backend/chat/src/ws/ws.go
+++ b/backend/chat/src/ws/ws.go
@@ -1,500 +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 or /ban ", 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))
+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 or /ban ", 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))
}
\ No newline at end of file
diff --git a/backend/chat/src/ws/ws_test.go b/backend/chat/src/ws/ws_test.go
index ee4b297..4a248d4 100644
--- a/backend/chat/src/ws/ws_test.go
+++ b/backend/chat/src/ws/ws_test.go
@@ -1,611 +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")
- }
+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")
+ }
}
\ No newline at end of file
diff --git a/backend/playout/Dockerfile b/backend/playout/Dockerfile
index 606311c..0a3c6ad 100644
--- a/backend/playout/Dockerfile
+++ b/backend/playout/Dockerfile
@@ -1,33 +1,33 @@
-FROM golang:1.25-alpine AS builder
-
-WORKDIR /app
-COPY src/ ./src/
-COPY src/go.mod src/go.sum ./
-
-# Download dependencies and build
-RUN go mod download
-RUN CGO_ENABLED=0 GOOS=linux GOFLAGS=-mod=mod go build -o /playlist-manager ./src/playlist.go
-
-FROM alpine:3.20
-
-# Install FFmpeg, curl for health checks, and basic tools
-RUN apk add --no-cache \
- ffmpeg \
- curl \
- bash
-# Set working directory for playout
-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 start-playout.sh /app/
-# Copy demo data for out-of-the-box playout
-COPY demodata/ /app/
-# Copy the built playlist manager binary from the builder stage
-COPY --from=builder /playlist-manager /app/playlist-manager
-# Set execute permissions for the start script and playlist manager binary
-RUN chmod +x /app/start-playout.sh /app/playlist-manager
-# Expose HLS directory (will be served by nginx)
-VOLUME ["/app/hls"]
-# Default command runs the playout loop (which calls the Go validator)
+FROM golang:1.25-alpine AS builder
+
+WORKDIR /app
+COPY src/ ./src/
+COPY src/go.mod src/go.sum ./
+
+# Download dependencies and build
+RUN go mod download
+RUN CGO_ENABLED=0 GOOS=linux GOFLAGS=-mod=mod go build -o /playlist-manager ./src/playlist.go
+
+FROM alpine:3.20
+
+# Install FFmpeg, curl for health checks, and basic tools
+RUN apk add --no-cache \
+ ffmpeg \
+ curl \
+ bash
+# Set working directory for playout
+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 start-playout.sh /app/
+# Copy demo data for out-of-the-box playout
+COPY demodata/ /app/
+# Copy the built playlist manager binary from the builder stage
+COPY --from=builder /playlist-manager /app/playlist-manager
+# Set execute permissions for the start script and playlist manager binary
+RUN chmod +x /app/start-playout.sh /app/playlist-manager
+# Expose HLS directory (will be served by nginx)
+VOLUME ["/app/hls"]
+# Default command runs the playout loop (which calls the Go validator)
CMD ["./start-playout.sh"]
\ No newline at end of file
diff --git a/backend/playout/demodata/playlist/playlist.txt b/backend/playout/demodata/playlist/playlist.txt
index eb3c345..9da7c4a 100644
--- a/backend/playout/demodata/playlist/playlist.txt
+++ b/backend/playout/demodata/playlist/playlist.txt
@@ -1,3 +1,3 @@
-# Linear Void Demo Playlist
-file '/app/media/demo1.mp4'
+# Linear Void Demo Playlist
+file '/app/media/demo1.mp4'
file '/app/media/demo2.mp4'
\ No newline at end of file
diff --git a/backend/playout/src/playlist.go b/backend/playout/src/playlist.go
index ee02473..d9bb7d9 100644
--- a/backend/playout/src/playlist.go
+++ b/backend/playout/src/playlist.go
@@ -1,1221 +1,1221 @@
-package main
-
-import (
- "bufio"
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "net/http"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "sort"
- "strconv"
- "strings"
- "sync"
- "time"
-
- "github.com/aws/aws-sdk-go-v2/aws"
- "github.com/aws/aws-sdk-go-v2/config"
- "github.com/aws/aws-sdk-go-v2/service/s3"
- "github.com/aws/aws-sdk-go-v2/service/s3/types"
- "github.com/fsnotify/fsnotify"
-)
-
-// VideoFile represents a normalized video entry in the playlist
-type VideoFile struct {
- Path string
- Duration float64
- Valid bool
- Validation string
-}
-
-var (
- r2Client *s3.Client
- putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
- return client.PutObject(context.TODO(), input)
- }
- listObjects = func(client *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
- return client.ListObjectsV2(context.TODO(), input)
- }
- deleteObjects = func(client *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
- return client.DeleteObjects(context.TODO(), input)
- }
- bucket = os.Getenv("R2_BUCKET_NAME")
- playoutMode = "starting" // "live", "slate", "degraded"
- restartCount = 0
- restartWindow = time.Now()
- slateDir = "/app/slate"
- hlsDir = "/app/hls"
- hlsSegmentDuration = envWithDefault("HLS_SEGMENT_DURATION", "2")
- hlsWindowSize = envWithDefault("HLS_WINDOW_SIZE", "12")
-
- // Unique segment naming (STREAMING.md contract: r-{runID}/seg-{seq}.m4s)
- runID string
- segSequence uint64
-
- // Health endpoint detail (STREAMING.md contract)
- activeItem string
- playlistRevision string
- lastSegmentSeq uint64
- lastR2PubTime time.Time
- consecutiveFails int
- lastFFmpegStartTime time.Time
- lastFFmpegExitError error
-
- // R2 retention cleanup counter
- r2CleanupObjectsTotal int64
-
- // R2 retention gauges
- r2RunsRetained int64
- r2BytesRetained int64
-
- // Continuous playout loop metrics
- loopsTotal int64
- lastRunDuration time.Duration
- lastRunClean bool
-)
-
-const (
- maxRestartsPerMinute = 5
- slateManifestName = "playlist.m3u8"
- // Cache-Control header values for R2 uploads.
- segmentCacheControl = "public, max-age=31536000, immutable"
- manifestCacheControl = "public, max-age=1, s-maxage=2, must-revalidate"
-
- // R2 retention policy.
- defaultR2KeepRuns = 2
- defaultR2MaxRetentionBytes = int64(10 * 1024 * 1024 * 1024) // 10 GiB
-)
-
-// envWithDefault returns the value of the environment variable or the default if not set.
-func envWithDefault(key, defaultVal string) string {
- if v := os.Getenv(key); v != "" {
- return v
- }
- return defaultVal
-}
-
-// isR2Configured returns true when the required R2 environment variables are set.
-func isR2Configured() bool {
- return os.Getenv("R2_ENDPOINT") != "" &&
- os.Getenv("R2_ACCESS_KEY_ID") != "" &&
- os.Getenv("R2_SECRET_ACCESS_KEY") != "" &&
- os.Getenv("R2_BUCKET_NAME") != ""
-}
-
-// initR2Client initializes the R2 (S3-compatible) client.
-// Returns an error if required environment variables are missing.
-func initR2Client() error {
- if !isR2Configured() {
- return fmt.Errorf("R2 not configured: R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, or R2_BUCKET_NAME is empty")
- }
-
- cfg, err := config.LoadDefaultConfig(context.TODO(),
- config.WithRegion("auto"),
- config.WithCredentialsProvider(aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) {
- return aws.Credentials{
- AccessKeyID: os.Getenv("R2_ACCESS_KEY_ID"),
- SecretAccessKey: os.Getenv("R2_SECRET_ACCESS_KEY"),
- }, nil
- })),
- )
- if err != nil {
- return err
- }
- r2Client = s3.NewFromConfig(cfg, func(o *s3.Options) {
- o.BaseEndpoint = aws.String(os.Getenv("R2_ENDPOINT"))
- })
- return nil
-}
-
-// initRunID generates a unique run/session ID for globally unique segment naming (STREAMING.md contract).
-func initRunID() {
- runID = "r-" + time.Now().UTC().Format("20060102T150405Z")
- segSequence = 0
-}
-
-// watchAndPublishSegments watches the HLS output directory for new .m4s segments
-// and uploads them to R2 in real time with runID-based keys (STREAMING.md contract).
-func watchAndPublishSegments(ctx context.Context, hlsOutputDir string) {
- watcher, err := fsnotify.NewWatcher()
- if err != nil {
- fmt.Println("Warning: fsnotify unavailable, R2 sync falls back to batch:", err)
- return
- }
- defer watcher.Close()
-
- if err := watcher.Add(hlsOutputDir); err != nil {
- fmt.Println("Warning: cannot watch HLS dir:", err)
- return
- }
-
- for {
- select {
- case event, ok := <-watcher.Events:
- if !ok {
- return
- }
- if event.Op&fsnotify.Create != 0 && strings.HasSuffix(event.Name, ".m4s") {
- // Wait for FFmpeg to finish writing the segment
- time.Sleep(500 * time.Millisecond)
- segSequence++
- lastSegmentSeq = segSequence
- key := "hls/" + runID + "/" + filepath.Base(event.Name)
- if err := SyncToR2(event.Name, key); err != nil {
- consecutiveFails++
- fmt.Printf("Failed to sync segment %s: %v\n", event.Name, err)
- } else {
- consecutiveFails = 0
- lastR2PubTime = time.Now()
- }
- }
- case err, ok := <-watcher.Errors:
- if !ok {
- return
- }
- fmt.Println("fsnotify error:", err)
- case <-ctx.Done():
- return
- }
- }
-}
-
-// publishManifest uploads the manifest atomically: temp key first, then copy to final key (STREAMING.md contract).
-func publishManifest(localManifestPath string) error {
- if r2Client == nil {
- if err := initR2Client(); err != nil {
- return err
- }
- }
-
- // 1. Upload to temp key
- if err := SyncToR2(localManifestPath, "hls/playlist.m3u8.tmp"); err != nil {
- return fmt.Errorf("temp manifest upload failed: %w", err)
- }
-
- // 2. Atomic copy to final key
- _, err := r2Client.CopyObject(context.TODO(), &s3.CopyObjectInput{
- Bucket: aws.String(bucket),
- CopySource: aws.String(bucket + "/hls/playlist.m3u8.tmp"),
- Key: aws.String("hls/playlist.m3u8"),
- })
- if err != nil {
- return fmt.Errorf("manifest atomic swap failed: %w", err)
- }
-
- return nil
-}
-
-// LoadPlaylist reads a playlist file and returns a list of VideoFile entries
-func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
- file, err := os.Open(playlistPath)
- if err != nil {
- return nil, err
- }
- defer file.Close()
-
- var videos []VideoFile
- scanner := bufio.NewScanner(file)
- for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
-
- if strings.HasPrefix(line, "file ") {
- line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
- line = strings.Trim(line, "'\"")
- }
-
- videos = append(videos, VideoFile{Path: line})
- }
-
- return videos, scanner.Err()
-}
-
-// ValidateVideo uses ffprobe to enforce codec, fps, and keyframe interval (ADR-001)
-func ValidateVideo(videoPath string) (VideoFile, error) {
- v := VideoFile{Path: videoPath}
-
- // Run the stream/format probe and the keyframe probe concurrently: they are
- // independent, and the keyframe probe (-show_frames) is the expensive one.
- var streamOut []byte
- var streamErr error
- var keyframeErr error
- var wg sync.WaitGroup
- wg.Add(2)
- go func() {
- defer wg.Done()
- cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath)
- streamOut, streamErr = cmd.Output()
- }()
- go func() {
- defer wg.Done()
- keyframeErr = validateKeyframeInterval(videoPath)
- }()
- wg.Wait()
-
- if streamErr != nil {
- v.Validation = "ffprobe failed: " + streamErr.Error()
- return v, fmt.Errorf("ffprobe: %w", streamErr)
- }
-
- var errors []string
-
- // Parse JSON
- 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"`
- Channels int `json:"channels"`
- Duration string `json:"duration"`
- } `json:"streams"`
- Format struct {
- Duration string `json:"duration"`
- } `json:"format"`
- }
- if err := json.Unmarshal(streamOut, &probe); err != nil {
- v.Validation = "ffprobe JSON parse failed: " + err.Error()
- return v, fmt.Errorf("ffprobe json: %w", err)
- }
-
- if len(probe.Streams) == 0 {
- errors = append(errors, "no streams detected")
- }
-
- var hasVideo, hasAudio bool
- for _, s := range probe.Streams {
- switch s.CodecType {
- case "video":
- hasVideo = true
- if s.CodecName != "h264" {
- errors = append(errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
- }
- if s.RFrameRate != "30/1" {
- errors = append(errors, fmt.Sprintf("framerate must be 30/1, got %s", s.RFrameRate))
- }
- if s.Width != 1280 && s.Width != 1920 {
- errors = append(errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
- }
- if s.Height != 720 && s.Height != 1080 {
- errors = append(errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
- }
- if s.PixFmt != "yuv420p" {
- errors = append(errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
- }
- case "audio":
- hasAudio = true
- if s.CodecName != "aac" {
- errors = append(errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
- }
- if s.SampleRate != "48000" {
- errors = append(errors, fmt.Sprintf("audio sample rate must be 48000, got %s", s.SampleRate))
- }
- }
- }
-
- if !hasVideo {
- errors = append(errors, "no video stream found")
- }
- if !hasAudio {
- errors = append(errors, "no audio stream found")
- }
-
- // Parse duration from format or stream
- durationSec := 0.0
- if probe.Format.Duration != "" {
- if d, err := strconv.ParseFloat(probe.Format.Duration, 64); err == nil {
- durationSec = d
- }
- }
-
- if len(errors) > 0 {
- v.Valid = false
- v.Validation = strings.Join(errors, "; ")
- return v, fmt.Errorf("validation failed: %s", v.Validation)
- }
-
- // Keyframe interval check (probe already completed concurrently above).
- if keyframeErr != nil {
- errors = append(errors, keyframeErr.Error())
- }
-
- if len(errors) > 0 {
- v.Valid = false
- v.Validation = strings.Join(errors, "; ")
- return v, fmt.Errorf("validation failed: %s", v.Validation)
- }
-
- v.Valid = true
- v.Duration = durationSec
- v.Validation = fmt.Sprintf("OK: H.264/AAC, 30fps, yuv420p, 48kHz, %.1fs, keyframes OK", durationSec)
- return v, nil
-}
-
-// validateKeyframeInterval checks that max gap between keyframes <=2s (60 frames at 30fps).
-func validateKeyframeInterval(videoPath string) error {
- cmd := exec.Command("ffprobe",
- "-v", "quiet",
- "-select_streams", "v:0",
- "-show_frames",
- "-show_entries", "frame=key_frame,best_effort_timestamp_time,pkt_pts_time",
- "-of", "csv=p=0",
- videoPath)
- output, err := cmd.Output()
- if err != nil {
- return fmt.Errorf("keyframe probe failed: %w", err)
- }
-
- type kfEntry struct{ pts float64 }
- var keyframes []kfEntry
- lines := strings.Split(strings.TrimSpace(string(output)), "\n")
- for _, line := range lines {
- line = strings.TrimSpace(line)
- if line == "" {
- continue
- }
- parts := strings.Split(line, ",")
- if len(parts) < 2 || parts[0] != "1" {
- continue
- }
- var pts float64
- var found bool
- for _, raw := range parts[1:] {
- if value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64); err == nil {
- pts = value
- found = true
- break
- }
- }
- if !found {
- continue
- }
- keyframes = append(keyframes, kfEntry{pts})
- }
-
- if len(keyframes) < 2 {
- return fmt.Errorf("insufficient keyframes for interval analysis (need >=2, got %d)", len(keyframes))
- }
-
- maxGap := 0.0
- for i := 1; i < len(keyframes); i++ {
- gap := keyframes[i].pts - keyframes[i-1].pts
- if gap > maxGap {
- maxGap = gap
- }
- }
-
- if maxGap > 2.1 { // small tolerance for floating point
- return fmt.Errorf("keyframe interval too large: max %.2fs (must be <=2s)", maxGap)
- }
-
- return nil
-}
-
-// ValidateVideoStrict runs ValidateVideo and returns a structured JSON result.
-func ValidateVideoStrict(videoPath string) map[string]any {
- v, err := ValidateVideo(videoPath)
- result := map[string]any{
- "path": videoPath,
- "valid": v.Valid,
- "errors": []string{},
- }
- if err != nil {
- result["errors"] = strings.Split(v.Validation, "; ")
- }
- if v.Valid {
- result["duration_seconds"] = v.Duration
- }
- return result
-}
-
-// validateWorkers returns the number of concurrent validation workers.
-func validateWorkers() int {
- n := runtime.NumCPU()
- if v := os.Getenv("PLAYOUT_VALIDATE_WORKERS"); v != "" {
- if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
- n = parsed
- }
- }
- return n
-}
-
-// ValidatePlaylist runs normalization validation on all entries in parallel,
-// deduplicating repeated paths so each unique file is probed once.
-func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
- videos, err := LoadPlaylist(playlistPath)
- if err != nil {
- return nil, err
- }
-
- // Collect unique paths in order of first appearance.
- var order []string
- seen := make(map[string]bool, len(videos))
- for _, v := range videos {
- if !seen[v.Path] {
- seen[v.Path] = true
- order = append(order, v.Path)
- }
- }
-
- // Validate unique paths concurrently with a bounded worker pool.
- results := make(map[string]VideoFile, len(order))
- var mu sync.Mutex
- var wg sync.WaitGroup
- sem := make(chan struct{}, validateWorkers())
- for _, path := range order {
- wg.Add(1)
- sem <- struct{}{}
- go func(p string) {
- defer wg.Done()
- defer func() { <-sem }()
- vf, err := ValidateVideo(p)
- if err != nil {
- fmt.Printf("Warning: %s - %s\n", p, err)
- }
- mu.Lock()
- results[p] = vf
- mu.Unlock()
- }(path)
- }
- wg.Wait()
-
- // Map results back onto the playlist (preserving order and duplicates).
- for i := range videos {
- videos[i] = results[videos[i].Path]
- }
-
- return videos, nil
-}
-
-// SyncToR2 uploads a file to R2 and returns the key.
-// Cache-Control is set automatically based on file extension:
-// - .m4s, .ts, .mp4, .m4a -> immutable (1 year)
-// - .m3u8 -> short-lived (2s, live manifest)
-// - everything else -> no cache header
-func SyncToR2(localPath, r2Key string) error {
- if r2Client == nil {
- if err := initR2Client(); err != nil {
- return err
- }
- }
-
- file, err := os.Open(localPath)
- if err != nil {
- return err
- }
- defer file.Close()
-
- input := &s3.PutObjectInput{
- Bucket: aws.String(bucket),
- Key: aws.String(r2Key),
- Body: file,
- }
-
- ext := strings.ToLower(filepath.Ext(r2Key))
- switch ext {
- case ".m4s", ".ts", ".mp4", ".m4a":
- input.CacheControl = aws.String(segmentCacheControl)
- input.ContentType = aws.String("video/MP2T")
- case ".m3u8":
- input.CacheControl = aws.String(manifestCacheControl)
- input.ContentType = aws.String("application/vnd.apple.mpegurl")
- }
-
- _, err = putObject(r2Client, input)
- return err
-}
-
-// HealthHandler provides a detailed health check endpoint (STREAMING.md contract)
-func HealthHandler(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- status := "healthy"
- if playoutMode == "slate" || playoutMode == "degraded" {
- status = "degraded"
- }
- // Unhealthy if manifest has not advanced for >2 segment durations (12s)
- if hlsDir != "" && time.Since(lastR2PubTime) > 12*time.Second && playoutMode != "starting" {
- status = "unhealthy"
- }
-
- lastExit := ""
- if lastFFmpegExitError != nil {
- lastExit = lastFFmpegExitError.Error()
- }
- resp := map[string]any{
- "status": status,
- "service": "playout",
- "mode": playoutMode,
- "crash_count": restartCount,
- "run_id": runID,
- "active_item": activeItem,
- "playlist_revision": playlistRevision,
- "last_segment_seq": lastSegmentSeq,
- "last_r2_pub": lastR2PubTime.UTC().Format(time.RFC3339),
- "consecutive_failures": consecutiveFails,
- "last_exit_error": lastExit,
- "timestamp": time.Now().UTC().Format(time.RFC3339),
- }
- json.NewEncoder(w).Encode(resp)
-}
-
-// activateSlate copies pre-rendered slate HLS into the live output directory.
-// Writes a static manifest referencing slate segments. Returns error if slate not found.
-func activateSlate() error {
- slateHLS := filepath.Join(slateDir, "hls")
- if _, err := os.Stat(filepath.Join(slateHLS, "slate.m3u8")); os.IsNotExist(err) {
- return fmt.Errorf("slate HLS not found at %s — run scripts/generate-slate.sh first", slateHLS)
- }
-
- // Clear existing live segments
- if err := resetHLSOutput(hlsDir); err != nil {
- return fmt.Errorf("failed to clear HLS output for slate: %w", err)
- }
-
- // Copy slate segments into live HLS dir
- slateFiles, err := filepath.Glob(filepath.Join(slateHLS, "*"))
- if err != nil {
- return err
- }
- for _, src := range slateFiles {
- dst := filepath.Join(hlsDir, filepath.Base(src))
- data, err := os.ReadFile(src)
- if err != nil {
- return err
- }
- if err := os.WriteFile(dst, data, 0o644); err != nil {
- return err
- }
- }
-
- playoutMode = "slate"
- fmt.Printf("[%s] EMERGENCY: Switched to slate fallback. %d restarts in window.\n",
- time.Now().Format(time.RFC3339), restartCount)
- return nil
-}
-
-// deactivateSlate clears slate state and prepares for live playout resumption.
-func deactivateSlate() {
- playoutMode = "live"
- fmt.Printf("[%s] RECOVERY: Slate deactivated, resuming live playout.\n",
- time.Now().Format(time.RFC3339))
-}
-
-// shouldEscalateToSlate checks if restart count exceeds threshold within the window.
-func shouldEscalateToSlate() bool {
- if time.Since(restartWindow) > 60*time.Second {
- restartCount = 0
- restartWindow = time.Now()
- return false
- }
- return restartCount >= maxRestartsPerMinute
-}
-
-func resetHLSOutput(hlsDir string) error {
- if err := os.MkdirAll(hlsDir, 0o755); err != nil {
- return err
- }
-
- matches, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.ts"))
- if err != nil {
- return err
- }
-
- for _, match := range matches {
- if err := os.Remove(match); err != nil && !os.IsNotExist(err) {
- return err
- }
- }
-
- fmp4Matches, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.m4s"))
- if err != nil {
- return err
- }
-
- for _, match := range fmp4Matches {
- if err := os.Remove(match); err != nil && !os.IsNotExist(err) {
- return err
- }
- }
-
- manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
- if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) {
- return err
- }
-
- initPath := filepath.Join(hlsDir, "init.mp4")
- if err := os.Remove(initPath); err != nil && !os.IsNotExist(err) {
- return err
- }
-
- return nil
-}
-
-func main() {
- playlistPath := "/app/playlist/playlist.txt"
- validateOnly := false
- for _, arg := range os.Args[1:] {
- switch arg {
- case "-validate":
- validateOnly = true
- default:
- if !strings.HasPrefix(arg, "-") {
- playlistPath = arg
- }
- }
- }
-
- if validateOnly {
- runValidateOnly(playlistPath)
- return
- }
-
- // Load shared HLS runtime config (written by the chat admin) before starting.
- loadSharedHLSConfig()
-
- fmt.Println("=== Linear Void Playout Service Starting ===")
- fmt.Println("Playlist:", playlistPath)
- fmt.Println("R2 Bucket:", bucket)
-
- // Initialize run ID for globally unique segment naming (STREAMING.md)
- initRunID()
- fmt.Println("Run ID:", runID)
-
- // Validate playlist before starting (ADR-001 normalization enforcement).
- // Runs once here; start-playout.sh no longer pre-validates separately.
- videos, err := ValidatePlaylist(playlistPath)
- if err != nil {
- fmt.Println("ERROR: Playlist validation failed:", err)
- os.Exit(1)
- }
- invalid := 0
- for _, v := range videos {
- if !v.Valid {
- fmt.Printf("ERROR: invalid media: %s — %s\n", v.Path, v.Validation)
- invalid++
- }
- }
- if invalid > 0 {
- fmt.Printf("ERROR: Playlist validation failed: %d invalid entries\n", invalid)
- os.Exit(1)
- }
-
- // Initialize R2 client (skip if not configured — local HLS via nginx still works)
- if err := initR2Client(); err != nil {
- fmt.Println("R2 sync disabled:", err)
- }
-
- // Start R2 segment retention cleanup (hourly + once at startup)
- if r2Client != nil {
- go runR2CleanupLoop()
- }
-
- // Start HTTP health check server
- http.HandleFunc("/health", HealthHandler)
- http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
- fmt.Fprintln(w, "# HELP linearvoid_playout_crashes_total Total FFmpeg crashes (not clean completions).")
- fmt.Fprintln(w, "# TYPE linearvoid_playout_crashes_total counter")
- fmt.Fprintf(w, "linearvoid_playout_crashes_total %d\n", restartCount)
- fmt.Fprintln(w, "# HELP linearvoid_playout_mode Current playout mode (0=live, 1=slate, 2=degraded).")
- fmt.Fprintln(w, "# TYPE linearvoid_playout_mode gauge")
- modeVal := 0
- if playoutMode == "slate" {
- modeVal = 1
- } else if playoutMode == "degraded" {
- modeVal = 2
- }
- fmt.Fprintf(w, "linearvoid_playout_mode %d\n", modeVal)
- fmt.Fprintln(w, "# HELP linearvoid_playout_r2_cleanup_objects_total Total R2 objects deleted by retention cleanup.")
- fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_cleanup_objects_total counter")
- fmt.Fprintf(w, "linearvoid_playout_r2_cleanup_objects_total %d\n", r2CleanupObjectsTotal)
- fmt.Fprintln(w, "# HELP linearvoid_playout_r2_runs_retained Number of R2 run folders retained by cleanup.")
- fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_runs_retained gauge")
- fmt.Fprintf(w, "linearvoid_playout_r2_runs_retained %d\n", r2RunsRetained)
- fmt.Fprintln(w, "# HELP linearvoid_playout_r2_bytes_retained Total bytes retained in R2 run folders.")
- fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_bytes_retained gauge")
- fmt.Fprintf(w, "linearvoid_playout_r2_bytes_retained %d\n", r2BytesRetained)
- fmt.Fprintln(w, "# HELP linearvoid_playout_loops_total Total clean playlist completions (loops).")
- fmt.Fprintln(w, "# TYPE linearvoid_playout_loops_total counter")
- fmt.Fprintf(w, "linearvoid_playout_loops_total %d\n", loopsTotal)
- fmt.Fprintln(w, "# HELP linearvoid_playout_run_duration_seconds Duration of the last FFmpeg run in seconds.")
- fmt.Fprintln(w, "# TYPE linearvoid_playout_run_duration_seconds gauge")
- fmt.Fprintf(w, "linearvoid_playout_run_duration_seconds %f\n", lastRunDuration.Seconds())
- })
- go func() {
- fmt.Println("Health check server listening on :8081/health")
- if err := http.ListenAndServe(":8081", nil); err != nil {
- fmt.Println("Health server error:", err)
- }
- }()
-
- // Run FFmpeg in a loop for continuous playback with slate fallback
- for {
- if playoutMode == "slate" {
- // During slate: attempt recovery by restarting FFmpeg
- fmt.Printf("[%s] Slate active — attempting live playout recovery...\n", time.Now().Format(time.RFC3339))
- }
-
- // New run ID on each restart for unique R2 segment keys (STREAMING.md)
- initRunID()
-
- // Remove run folders older than the retention policy (keep the last N runs,
- // cap total bytes) now that the next run has started. This never touches the
- // current or previous run folders.
- if r2Client != nil {
- if _, err := cleanupOldRuns(context.Background()); err != nil {
- fmt.Println("R2 cleanup error:", err)
- }
- }
-
- // Reset HLS output only when the playlist changed, we are recovering from a
- // crash/slate, or this is the first run. Otherwise continue the segment
- // sequence so the loop is seamless for viewers (no gap on clean loops).
- fingerprint, fpErr := playlistFingerprint(playlistPath)
- if fpErr != nil {
- fmt.Println("Warning: cannot fingerprint playlist:", fpErr)
- fingerprint = ""
- }
- playlistChanged := fingerprint != playlistRevision
- shouldReset := playlistChanged || playoutMode != "live" || !lastRunClean
-
- windowSize := 24
- if ws, err := strconv.Atoi(hlsWindowSize); err == nil && ws > 0 {
- windowSize = ws
- }
-
- startNumber := 0
- if shouldReset {
- if err := resetHLSOutput(hlsDir); err != nil {
- fmt.Println("Failed to reset HLS output:", err)
- time.Sleep(5 * time.Second)
- continue
- }
- playlistRevision = fingerprint
- } else {
- if n, err := hlsNextStartNumber(hlsDir, windowSize); err == nil {
- startNumber = n
- }
- }
-
- // Start filesystem watcher for real-time R2 segment upload (STREAMING.md).
- // Only active when R2 is configured; otherwise the watcher is skipped.
- var watchCtx context.Context
- var watchCancel context.CancelFunc
- if r2Client != nil {
- watchCtx, watchCancel = context.WithCancel(context.Background())
- go watchAndPublishSegments(watchCtx, hlsDir)
- }
-
- lastFFmpegStartTime = time.Now()
- fmt.Printf("[%s] Starting FFmpeg playout (run %s) with browser-safe H.264/AAC HLS encoding...\n",
- lastFFmpegStartTime.UTC().Format(time.RFC3339), runID)
-
- cmd := exec.Command("ffmpeg", "-re", "-f", "concat", "-safe", "0", "-i", playlistPath,
- "-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", hlsSegmentDuration,
- "-hls_list_size", hlsWindowSize,
- "-hls_segment_type", "fmp4",
- "-hls_fmp4_init_filename", "init.mp4",
- "-hls_flags", "delete_segments+independent_segments",
- "-hls_segment_filename", filepath.Join(hlsDir, "seg-%06d.m4s"),
- "-start_number", strconv.Itoa(startNumber),
- filepath.Join(hlsDir, "playlist.m3u8"))
-
- runErr := cmd.Run()
- runDuration := time.Since(lastFFmpegStartTime)
- lastFFmpegExitError = runErr
- lastRunDuration = runDuration
- lastRunClean = runErr == nil
-
- if watchCancel != nil {
- watchCancel() // Stop the filesystem watcher
- }
-
- // After FFmpeg exits, sync remaining segments and publish manifest to R2.
- // Do this before logging/restart logic so R2 is up to date regardless of exit cause.
- if r2Client != nil {
- if err := syncLatestToR2(hlsDir); err != nil {
- fmt.Println("R2 sync warning:", err)
- }
-
- // Publish manifest atomically (STREAMING.md safe publication)
- manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
- if err := publishManifest(manifestPath); err != nil {
- fmt.Println("Manifest publish warning:", err)
- }
- }
-
- // Differentiate clean playlist completion from FFmpeg crash.
- // A nil error means FFmpeg completed the playlist normally — not a crash.
- if runErr == nil {
- loopsTotal++
- fmt.Printf("[%s] Playlist completed normally after %s. Looping...\n",
- time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second))
- // If we were in slate mode, a successful run means recovery
- if playoutMode == "slate" {
- deactivateSlate()
- }
- // Minimal delay for clean loop — just enough for watcher goroutine cleanup
- time.Sleep(500 * time.Millisecond)
- continue
- }
-
- // FFmpeg exited with an error — this is a real crash
- restartCount++
- fmt.Printf("[%s] FFmpeg CRASHED after %s (crash #%d in window): %v\n",
- time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second), restartCount, runErr)
-
- if shouldEscalateToSlate() {
- fmt.Printf("[%s] CRITICAL: %d crashes in 60s — escalating to slate fallback\n",
- time.Now().UTC().Format(time.RFC3339), restartCount)
- if err := activateSlate(); err != nil {
- fmt.Printf("[%s] Slate activation failed: %v — will retry\n",
- time.Now().UTC().Format(time.RFC3339), err)
- }
- // Stay in slate mode, retry recovery after delay
- time.Sleep(30 * time.Second)
- continue
- }
-
- // If we were in slate but FFmpeg ran long enough to be a recovery, deactivate slate
- if playoutMode == "slate" && runDuration > 30*time.Second {
- deactivateSlate()
- }
-
- // Backoff delay after crash before retry
- time.Sleep(2 * time.Second)
- }
-}
-
-// syncLatestToR2 uploads new segments to R2 with runID-based keys
-func syncLatestToR2(hlsDir string) error {
- files, err := filepath.Glob(filepath.Join(hlsDir, "seg-*.m4s"))
- if err != nil {
- return err
- }
-
- for _, f := range files {
- key := "hls/" + runID + "/" + filepath.Base(f)
- if err := SyncToR2(f, key); err != nil {
- fmt.Printf("Failed to sync %s: %v\n", f, err)
- } else {
- fmt.Printf("Synced segment to R2: %s\n", key)
- }
- }
-
- // Sync init segment to run-specific R2 key
- initPath := filepath.Join(hlsDir, "init.mp4")
- if err := SyncToR2(initPath, "hls/"+runID+"/init.mp4"); err != nil {
- return err
- }
- fmt.Println("Synced init.mp4 to R2")
-
- return nil
-}
-
-// r2KeepRuns returns how many of the most recent run folders to keep in R2.
-func r2KeepRuns() int {
- n, err := strconv.Atoi(envWithDefault("R2_KEEP_RUNS", "2"))
- if err != nil || n < 1 {
- return defaultR2KeepRuns
- }
- return n
-}
-
-// r2MaxRetentionBytes returns the maximum total bytes to retain across run folders.
-func r2MaxRetentionBytes() int64 {
- n, err := strconv.ParseInt(envWithDefault("R2_MAX_RETENTION_BYTES", "10737418240"), 10, 64)
- if err != nil || n <= 0 {
- return defaultR2MaxRetentionBytes
- }
- return n
-}
-
-// r2RunFromKey extracts the run folder name ("r-...") from an R2 object key.
-func r2RunFromKey(key string) (string, bool) {
- rest := strings.TrimPrefix(key, "hls/")
- if rest == key || !strings.HasPrefix(rest, "r-") {
- return "", false
- }
- if idx := strings.IndexByte(rest, '/'); idx > 0 {
- return rest[:idx], true
- }
- return "", false
-}
-
-// runObjects groups the R2 object keys and total size for a single run folder.
-type runObjects struct {
- keys []string
- size int64
-}
-
-// deleteR2Keys deletes the given R2 object keys in batches of up to 1000.
-func deleteR2Keys(keys []string) (int, error) {
- deleted := 0
- for i := 0; i < len(keys); i += 1000 {
- end := i + 1000
- if end > len(keys) {
- end = len(keys)
- }
- batch := keys[i:end]
- objects := make([]types.ObjectIdentifier, len(batch))
- for j, key := range batch {
- objects[j] = types.ObjectIdentifier{Key: aws.String(key)}
- }
- if _, err := deleteObjects(r2Client, &s3.DeleteObjectsInput{
- Bucket: aws.String(bucket),
- Delete: &types.Delete{Objects: objects, Quiet: aws.Bool(true)},
- }); err != nil {
- return deleted, err
- }
- for _, key := range batch {
- fmt.Printf("R2 cleanup: deleted %s\n", key)
- }
- deleted += len(batch)
- }
- return deleted, nil
-}
-
-// cleanupOldRuns lists run-scoped objects under hls/, keeps the most recent
-// R2_KEEP_RUNS runs, and deletes older runs. It also enforces the
-// R2_MAX_RETENTION_BYTES cap by removing oldest retained runs first, always
-// keeping at least the newest run. The live manifest keys are never deleted.
-func cleanupOldRuns(ctx context.Context) (int, error) {
- if r2Client == nil {
- if err := initR2Client(); err != nil {
- return 0, err
- }
- }
-
- runs := map[string]*runObjects{}
- var token *string
- for {
- output, err := listObjects(r2Client, &s3.ListObjectsV2Input{
- Bucket: aws.String(bucket),
- Prefix: aws.String("hls/"),
- ContinuationToken: token,
- })
- if err != nil {
- return 0, err
- }
- for _, obj := range output.Contents {
- key := aws.ToString(obj.Key)
- if key == "hls/playlist.m3u8" || key == "hls/playlist.m3u8.tmp" {
- continue
- }
- run, ok := r2RunFromKey(key)
- if !ok {
- continue
- }
- r, exists := runs[run]
- if !exists {
- r = &runObjects{}
- runs[run] = r
- }
- r.keys = append(r.keys, key)
- if obj.Size != nil {
- r.size += *obj.Size
- }
- }
- if output.IsTruncated == nil || !*output.IsTruncated {
- break
- }
- token = output.NextContinuationToken
- }
-
- if len(runs) == 0 {
- r2RunsRetained = 0
- r2BytesRetained = 0
- return 0, nil
- }
-
- // Run IDs are ISO timestamps, so lexicographic order equals chronological order.
- sortedRuns := make([]string, 0, len(runs))
- for run := range runs {
- sortedRuns = append(sortedRuns, run)
- }
- sort.Strings(sortedRuns) // ascending: oldest -> newest
-
- deleteRun := map[string]bool{}
- keepRuns := r2KeepRuns()
-
- // Mark all but the newest keepRuns runs for deletion.
- if len(sortedRuns) > keepRuns {
- for _, run := range sortedRuns[:len(sortedRuns)-keepRuns] {
- deleteRun[run] = true
- }
- }
-
- // Enforce the total size cap on retained runs (oldest retained first), always
- // keeping the newest run even if it alone exceeds the cap.
- maxBytes := r2MaxRetentionBytes()
- retained := make([]string, 0, keepRuns)
- var retainedBytes int64
- for _, run := range sortedRuns {
- if !deleteRun[run] {
- retained = append(retained, run)
- retainedBytes += runs[run].size
- }
- }
- for i := 0; retainedBytes > maxBytes && i < len(retained)-1; i++ {
- run := retained[i]
- deleteRun[run] = true
- retainedBytes -= runs[run].size
- }
-
- var toDelete []string
- for run, del := range deleteRun {
- if del {
- toDelete = append(toDelete, runs[run].keys...)
- }
- }
-
- deleted, err := deleteR2Keys(toDelete)
- if err != nil {
- return deleted, err
- }
-
- keptRuns := int64(0)
- keptBytes := int64(0)
- for run, r := range runs {
- if !deleteRun[run] {
- keptRuns++
- keptBytes += r.size
- }
- }
- r2RunsRetained = keptRuns
- r2BytesRetained = keptBytes
-
- r2CleanupObjectsTotal += int64(deleted)
- return deleted, nil
-}
-
-// runR2CleanupLoop runs retention cleanup once at startup, then hourly.
-func runR2CleanupLoop() {
- if _, err := cleanupOldRuns(context.Background()); err != nil {
- fmt.Println("R2 cleanup error:", err)
- }
- ticker := time.NewTicker(time.Hour)
- defer ticker.Stop()
- for range ticker.C {
- if _, err := cleanupOldRuns(context.Background()); err != nil {
- fmt.Println("R2 cleanup error:", err)
- }
- }
-}
-
-// playlistFingerprint returns a short hash of the playlist file content, used to
-// detect playlist changes between loops.
-func playlistFingerprint(playlistPath string) (string, error) {
- data, err := os.ReadFile(playlistPath)
- if err != nil {
- return "", err
- }
- sum := sha256.Sum256(data)
- return hex.EncodeToString(sum[:]), nil
-}
-
-// hlsNextStartNumber returns the next segment sequence number to use, and prunes
-// stale segments outside the live window so they do not accumulate across loops.
-func hlsNextStartNumber(hlsDir string, windowSize int) (int, error) {
- files, err := filepath.Glob(filepath.Join(hlsDir, "seg-*.m4s"))
- if err != nil {
- return 0, err
- }
-
- seqOf := func(f string) int {
- base := filepath.Base(f)
- num := strings.TrimSuffix(strings.TrimPrefix(base, "seg-"), ".m4s")
- n, err := strconv.Atoi(num)
- if err != nil {
- return -1
- }
- return n
- }
-
- maxSeq := -1
- for _, f := range files {
- if n := seqOf(f); n > maxSeq {
- maxSeq = n
- }
- }
- next := maxSeq + 1
-
- // Keep the live window; delete older segments no longer referenced.
- threshold := next - windowSize
- for _, f := range files {
- if n := seqOf(f); n >= 0 && n < threshold {
- if err := os.Remove(f); err != nil && !os.IsNotExist(err) {
- return next, err
- }
- }
- }
- return next, nil
-}
-
-// runValidateOnly validates the playlist and exits 0 (valid) or 1 (invalid/missing).
-func runValidateOnly(playlistPath string) {
- videos, err := ValidatePlaylist(playlistPath)
- if err != nil {
- fmt.Println("ERROR: Playlist validation failed:", err)
- os.Exit(1)
- }
- valid := true
- for _, v := range videos {
- if v.Valid {
- fmt.Printf("OK: %s (%.1fs)\n", v.Path, v.Duration)
- } else {
- valid = false
- fmt.Printf("INVALID: %s — %s\n", v.Path, v.Validation)
- }
- }
- if !valid {
- os.Exit(1)
- }
- fmt.Println("Playlist valid.")
-}
-
-// loadSharedHLSConfig reads HLS settings from the shared runtime config file
-// (written by the chat admin service) and overrides the env-derived defaults.
-func loadSharedHLSConfig() {
- path := envWithDefault("PLAYOUT_CONFIG_PATH", "/app/playlist/config.json")
- data, err := os.ReadFile(path)
- if err != nil {
- if !os.IsNotExist(err) {
- fmt.Println("Warning: cannot read shared config:", err)
- }
- return
- }
- var cfg map[string]string
- if err := json.Unmarshal(data, &cfg); err != nil {
- fmt.Println("Warning: cannot parse shared config:", err)
- return
- }
- if v := strings.TrimSpace(cfg["hls_segment_duration"]); v != "" {
- if n, err := strconv.Atoi(v); err == nil && n > 0 {
- hlsSegmentDuration = strconv.Itoa(n)
- }
- }
- if v := strings.TrimSpace(cfg["hls_window_size"]); v != "" {
- if n, err := strconv.Atoi(v); err == nil && n > 0 {
- hlsWindowSize = strconv.Itoa(n)
- }
- }
- fmt.Printf("Shared HLS config: segment=%ss window=%s\n", hlsSegmentDuration, hlsWindowSize)
+package main
+
+import (
+ "bufio"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "github.com/fsnotify/fsnotify"
+)
+
+// VideoFile represents a normalized video entry in the playlist
+type VideoFile struct {
+ Path string
+ Duration float64
+ Valid bool
+ Validation string
+}
+
+var (
+ r2Client *s3.Client
+ putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
+ return client.PutObject(context.TODO(), input)
+ }
+ listObjects = func(client *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
+ return client.ListObjectsV2(context.TODO(), input)
+ }
+ deleteObjects = func(client *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
+ return client.DeleteObjects(context.TODO(), input)
+ }
+ bucket = os.Getenv("R2_BUCKET_NAME")
+ playoutMode = "starting" // "live", "slate", "degraded"
+ restartCount = 0
+ restartWindow = time.Now()
+ slateDir = "/app/slate"
+ hlsDir = "/app/hls"
+ hlsSegmentDuration = envWithDefault("HLS_SEGMENT_DURATION", "2")
+ hlsWindowSize = envWithDefault("HLS_WINDOW_SIZE", "12")
+
+ // Unique segment naming (STREAMING.md contract: r-{runID}/seg-{seq}.m4s)
+ runID string
+ segSequence uint64
+
+ // Health endpoint detail (STREAMING.md contract)
+ activeItem string
+ playlistRevision string
+ lastSegmentSeq uint64
+ lastR2PubTime time.Time
+ consecutiveFails int
+ lastFFmpegStartTime time.Time
+ lastFFmpegExitError error
+
+ // R2 retention cleanup counter
+ r2CleanupObjectsTotal int64
+
+ // R2 retention gauges
+ r2RunsRetained int64
+ r2BytesRetained int64
+
+ // Continuous playout loop metrics
+ loopsTotal int64
+ lastRunDuration time.Duration
+ lastRunClean bool
+)
+
+const (
+ maxRestartsPerMinute = 5
+ slateManifestName = "playlist.m3u8"
+ // Cache-Control header values for R2 uploads.
+ segmentCacheControl = "public, max-age=31536000, immutable"
+ manifestCacheControl = "public, max-age=1, s-maxage=2, must-revalidate"
+
+ // R2 retention policy.
+ defaultR2KeepRuns = 2
+ defaultR2MaxRetentionBytes = int64(10 * 1024 * 1024 * 1024) // 10 GiB
+)
+
+// envWithDefault returns the value of the environment variable or the default if not set.
+func envWithDefault(key, defaultVal string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return defaultVal
+}
+
+// isR2Configured returns true when the required R2 environment variables are set.
+func isR2Configured() bool {
+ return os.Getenv("R2_ENDPOINT") != "" &&
+ os.Getenv("R2_ACCESS_KEY_ID") != "" &&
+ os.Getenv("R2_SECRET_ACCESS_KEY") != "" &&
+ os.Getenv("R2_BUCKET_NAME") != ""
+}
+
+// initR2Client initializes the R2 (S3-compatible) client.
+// Returns an error if required environment variables are missing.
+func initR2Client() error {
+ if !isR2Configured() {
+ return fmt.Errorf("R2 not configured: R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, or R2_BUCKET_NAME is empty")
+ }
+
+ cfg, err := config.LoadDefaultConfig(context.TODO(),
+ config.WithRegion("auto"),
+ config.WithCredentialsProvider(aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) {
+ return aws.Credentials{
+ AccessKeyID: os.Getenv("R2_ACCESS_KEY_ID"),
+ SecretAccessKey: os.Getenv("R2_SECRET_ACCESS_KEY"),
+ }, nil
+ })),
+ )
+ if err != nil {
+ return err
+ }
+ r2Client = s3.NewFromConfig(cfg, func(o *s3.Options) {
+ o.BaseEndpoint = aws.String(os.Getenv("R2_ENDPOINT"))
+ })
+ return nil
+}
+
+// initRunID generates a unique run/session ID for globally unique segment naming (STREAMING.md contract).
+func initRunID() {
+ runID = "r-" + time.Now().UTC().Format("20060102T150405Z")
+ segSequence = 0
+}
+
+// watchAndPublishSegments watches the HLS output directory for new .m4s segments
+// and uploads them to R2 in real time with runID-based keys (STREAMING.md contract).
+func watchAndPublishSegments(ctx context.Context, hlsOutputDir string) {
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ fmt.Println("Warning: fsnotify unavailable, R2 sync falls back to batch:", err)
+ return
+ }
+ defer watcher.Close()
+
+ if err := watcher.Add(hlsOutputDir); err != nil {
+ fmt.Println("Warning: cannot watch HLS dir:", err)
+ return
+ }
+
+ for {
+ select {
+ case event, ok := <-watcher.Events:
+ if !ok {
+ return
+ }
+ if event.Op&fsnotify.Create != 0 && strings.HasSuffix(event.Name, ".m4s") {
+ // Wait for FFmpeg to finish writing the segment
+ time.Sleep(500 * time.Millisecond)
+ segSequence++
+ lastSegmentSeq = segSequence
+ key := "hls/" + runID + "/" + filepath.Base(event.Name)
+ if err := SyncToR2(event.Name, key); err != nil {
+ consecutiveFails++
+ fmt.Printf("Failed to sync segment %s: %v\n", event.Name, err)
+ } else {
+ consecutiveFails = 0
+ lastR2PubTime = time.Now()
+ }
+ }
+ case err, ok := <-watcher.Errors:
+ if !ok {
+ return
+ }
+ fmt.Println("fsnotify error:", err)
+ case <-ctx.Done():
+ return
+ }
+ }
+}
+
+// publishManifest uploads the manifest atomically: temp key first, then copy to final key (STREAMING.md contract).
+func publishManifest(localManifestPath string) error {
+ if r2Client == nil {
+ if err := initR2Client(); err != nil {
+ return err
+ }
+ }
+
+ // 1. Upload to temp key
+ if err := SyncToR2(localManifestPath, "hls/playlist.m3u8.tmp"); err != nil {
+ return fmt.Errorf("temp manifest upload failed: %w", err)
+ }
+
+ // 2. Atomic copy to final key
+ _, err := r2Client.CopyObject(context.TODO(), &s3.CopyObjectInput{
+ Bucket: aws.String(bucket),
+ CopySource: aws.String(bucket + "/hls/playlist.m3u8.tmp"),
+ Key: aws.String("hls/playlist.m3u8"),
+ })
+ if err != nil {
+ return fmt.Errorf("manifest atomic swap failed: %w", err)
+ }
+
+ return nil
+}
+
+// LoadPlaylist reads a playlist file and returns a list of VideoFile entries
+func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
+ file, err := os.Open(playlistPath)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ var videos []VideoFile
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ if strings.HasPrefix(line, "file ") {
+ line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
+ line = strings.Trim(line, "'\"")
+ }
+
+ videos = append(videos, VideoFile{Path: line})
+ }
+
+ return videos, scanner.Err()
+}
+
+// ValidateVideo uses ffprobe to enforce codec, fps, and keyframe interval (ADR-001)
+func ValidateVideo(videoPath string) (VideoFile, error) {
+ v := VideoFile{Path: videoPath}
+
+ // Run the stream/format probe and the keyframe probe concurrently: they are
+ // independent, and the keyframe probe (-show_frames) is the expensive one.
+ var streamOut []byte
+ var streamErr error
+ var keyframeErr error
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath)
+ streamOut, streamErr = cmd.Output()
+ }()
+ go func() {
+ defer wg.Done()
+ keyframeErr = validateKeyframeInterval(videoPath)
+ }()
+ wg.Wait()
+
+ if streamErr != nil {
+ v.Validation = "ffprobe failed: " + streamErr.Error()
+ return v, fmt.Errorf("ffprobe: %w", streamErr)
+ }
+
+ var errors []string
+
+ // Parse JSON
+ 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"`
+ Channels int `json:"channels"`
+ Duration string `json:"duration"`
+ } `json:"streams"`
+ Format struct {
+ Duration string `json:"duration"`
+ } `json:"format"`
+ }
+ if err := json.Unmarshal(streamOut, &probe); err != nil {
+ v.Validation = "ffprobe JSON parse failed: " + err.Error()
+ return v, fmt.Errorf("ffprobe json: %w", err)
+ }
+
+ if len(probe.Streams) == 0 {
+ errors = append(errors, "no streams detected")
+ }
+
+ var hasVideo, hasAudio bool
+ for _, s := range probe.Streams {
+ switch s.CodecType {
+ case "video":
+ hasVideo = true
+ if s.CodecName != "h264" {
+ errors = append(errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
+ }
+ if s.RFrameRate != "30/1" {
+ errors = append(errors, fmt.Sprintf("framerate must be 30/1, got %s", s.RFrameRate))
+ }
+ if s.Width != 1280 && s.Width != 1920 {
+ errors = append(errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
+ }
+ if s.Height != 720 && s.Height != 1080 {
+ errors = append(errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
+ }
+ if s.PixFmt != "yuv420p" {
+ errors = append(errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
+ }
+ case "audio":
+ hasAudio = true
+ if s.CodecName != "aac" {
+ errors = append(errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
+ }
+ if s.SampleRate != "48000" {
+ errors = append(errors, fmt.Sprintf("audio sample rate must be 48000, got %s", s.SampleRate))
+ }
+ }
+ }
+
+ if !hasVideo {
+ errors = append(errors, "no video stream found")
+ }
+ if !hasAudio {
+ errors = append(errors, "no audio stream found")
+ }
+
+ // Parse duration from format or stream
+ durationSec := 0.0
+ if probe.Format.Duration != "" {
+ if d, err := strconv.ParseFloat(probe.Format.Duration, 64); err == nil {
+ durationSec = d
+ }
+ }
+
+ if len(errors) > 0 {
+ v.Valid = false
+ v.Validation = strings.Join(errors, "; ")
+ return v, fmt.Errorf("validation failed: %s", v.Validation)
+ }
+
+ // Keyframe interval check (probe already completed concurrently above).
+ if keyframeErr != nil {
+ errors = append(errors, keyframeErr.Error())
+ }
+
+ if len(errors) > 0 {
+ v.Valid = false
+ v.Validation = strings.Join(errors, "; ")
+ return v, fmt.Errorf("validation failed: %s", v.Validation)
+ }
+
+ v.Valid = true
+ v.Duration = durationSec
+ v.Validation = fmt.Sprintf("OK: H.264/AAC, 30fps, yuv420p, 48kHz, %.1fs, keyframes OK", durationSec)
+ return v, nil
+}
+
+// validateKeyframeInterval checks that max gap between keyframes <=2s (60 frames at 30fps).
+func validateKeyframeInterval(videoPath string) error {
+ cmd := exec.Command("ffprobe",
+ "-v", "quiet",
+ "-select_streams", "v:0",
+ "-show_frames",
+ "-show_entries", "frame=key_frame,best_effort_timestamp_time,pkt_pts_time",
+ "-of", "csv=p=0",
+ videoPath)
+ output, err := cmd.Output()
+ if err != nil {
+ return fmt.Errorf("keyframe probe failed: %w", err)
+ }
+
+ type kfEntry struct{ pts float64 }
+ var keyframes []kfEntry
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ parts := strings.Split(line, ",")
+ if len(parts) < 2 || parts[0] != "1" {
+ continue
+ }
+ var pts float64
+ var found bool
+ for _, raw := range parts[1:] {
+ if value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64); err == nil {
+ pts = value
+ found = true
+ break
+ }
+ }
+ if !found {
+ continue
+ }
+ keyframes = append(keyframes, kfEntry{pts})
+ }
+
+ if len(keyframes) < 2 {
+ return fmt.Errorf("insufficient keyframes for interval analysis (need >=2, got %d)", len(keyframes))
+ }
+
+ maxGap := 0.0
+ for i := 1; i < len(keyframes); i++ {
+ gap := keyframes[i].pts - keyframes[i-1].pts
+ if gap > maxGap {
+ maxGap = gap
+ }
+ }
+
+ if maxGap > 2.1 { // small tolerance for floating point
+ return fmt.Errorf("keyframe interval too large: max %.2fs (must be <=2s)", maxGap)
+ }
+
+ return nil
+}
+
+// ValidateVideoStrict runs ValidateVideo and returns a structured JSON result.
+func ValidateVideoStrict(videoPath string) map[string]any {
+ v, err := ValidateVideo(videoPath)
+ result := map[string]any{
+ "path": videoPath,
+ "valid": v.Valid,
+ "errors": []string{},
+ }
+ if err != nil {
+ result["errors"] = strings.Split(v.Validation, "; ")
+ }
+ if v.Valid {
+ result["duration_seconds"] = v.Duration
+ }
+ return result
+}
+
+// validateWorkers returns the number of concurrent validation workers.
+func validateWorkers() int {
+ n := runtime.NumCPU()
+ if v := os.Getenv("PLAYOUT_VALIDATE_WORKERS"); v != "" {
+ if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
+ n = parsed
+ }
+ }
+ return n
+}
+
+// ValidatePlaylist runs normalization validation on all entries in parallel,
+// deduplicating repeated paths so each unique file is probed once.
+func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
+ videos, err := LoadPlaylist(playlistPath)
+ if err != nil {
+ return nil, err
+ }
+
+ // Collect unique paths in order of first appearance.
+ var order []string
+ seen := make(map[string]bool, len(videos))
+ for _, v := range videos {
+ if !seen[v.Path] {
+ seen[v.Path] = true
+ order = append(order, v.Path)
+ }
+ }
+
+ // Validate unique paths concurrently with a bounded worker pool.
+ results := make(map[string]VideoFile, len(order))
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+ sem := make(chan struct{}, validateWorkers())
+ for _, path := range order {
+ wg.Add(1)
+ sem <- struct{}{}
+ go func(p string) {
+ defer wg.Done()
+ defer func() { <-sem }()
+ vf, err := ValidateVideo(p)
+ if err != nil {
+ fmt.Printf("Warning: %s - %s\n", p, err)
+ }
+ mu.Lock()
+ results[p] = vf
+ mu.Unlock()
+ }(path)
+ }
+ wg.Wait()
+
+ // Map results back onto the playlist (preserving order and duplicates).
+ for i := range videos {
+ videos[i] = results[videos[i].Path]
+ }
+
+ return videos, nil
+}
+
+// SyncToR2 uploads a file to R2 and returns the key.
+// Cache-Control is set automatically based on file extension:
+// - .m4s, .ts, .mp4, .m4a -> immutable (1 year)
+// - .m3u8 -> short-lived (2s, live manifest)
+// - everything else -> no cache header
+func SyncToR2(localPath, r2Key string) error {
+ if r2Client == nil {
+ if err := initR2Client(); err != nil {
+ return err
+ }
+ }
+
+ file, err := os.Open(localPath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ input := &s3.PutObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(r2Key),
+ Body: file,
+ }
+
+ ext := strings.ToLower(filepath.Ext(r2Key))
+ switch ext {
+ case ".m4s", ".ts", ".mp4", ".m4a":
+ input.CacheControl = aws.String(segmentCacheControl)
+ input.ContentType = aws.String("video/MP2T")
+ case ".m3u8":
+ input.CacheControl = aws.String(manifestCacheControl)
+ input.ContentType = aws.String("application/vnd.apple.mpegurl")
+ }
+
+ _, err = putObject(r2Client, input)
+ return err
+}
+
+// HealthHandler provides a detailed health check endpoint (STREAMING.md contract)
+func HealthHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ status := "healthy"
+ if playoutMode == "slate" || playoutMode == "degraded" {
+ status = "degraded"
+ }
+ // Unhealthy if manifest has not advanced for >2 segment durations (12s)
+ if hlsDir != "" && time.Since(lastR2PubTime) > 12*time.Second && playoutMode != "starting" {
+ status = "unhealthy"
+ }
+
+ lastExit := ""
+ if lastFFmpegExitError != nil {
+ lastExit = lastFFmpegExitError.Error()
+ }
+ resp := map[string]any{
+ "status": status,
+ "service": "playout",
+ "mode": playoutMode,
+ "crash_count": restartCount,
+ "run_id": runID,
+ "active_item": activeItem,
+ "playlist_revision": playlistRevision,
+ "last_segment_seq": lastSegmentSeq,
+ "last_r2_pub": lastR2PubTime.UTC().Format(time.RFC3339),
+ "consecutive_failures": consecutiveFails,
+ "last_exit_error": lastExit,
+ "timestamp": time.Now().UTC().Format(time.RFC3339),
+ }
+ json.NewEncoder(w).Encode(resp)
+}
+
+// activateSlate copies pre-rendered slate HLS into the live output directory.
+// Writes a static manifest referencing slate segments. Returns error if slate not found.
+func activateSlate() error {
+ slateHLS := filepath.Join(slateDir, "hls")
+ if _, err := os.Stat(filepath.Join(slateHLS, "slate.m3u8")); os.IsNotExist(err) {
+ return fmt.Errorf("slate HLS not found at %s — run scripts/generate-slate.sh first", slateHLS)
+ }
+
+ // Clear existing live segments
+ if err := resetHLSOutput(hlsDir); err != nil {
+ return fmt.Errorf("failed to clear HLS output for slate: %w", err)
+ }
+
+ // Copy slate segments into live HLS dir
+ slateFiles, err := filepath.Glob(filepath.Join(slateHLS, "*"))
+ if err != nil {
+ return err
+ }
+ for _, src := range slateFiles {
+ dst := filepath.Join(hlsDir, filepath.Base(src))
+ data, err := os.ReadFile(src)
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(dst, data, 0o644); err != nil {
+ return err
+ }
+ }
+
+ playoutMode = "slate"
+ fmt.Printf("[%s] EMERGENCY: Switched to slate fallback. %d restarts in window.\n",
+ time.Now().Format(time.RFC3339), restartCount)
+ return nil
+}
+
+// deactivateSlate clears slate state and prepares for live playout resumption.
+func deactivateSlate() {
+ playoutMode = "live"
+ fmt.Printf("[%s] RECOVERY: Slate deactivated, resuming live playout.\n",
+ time.Now().Format(time.RFC3339))
+}
+
+// shouldEscalateToSlate checks if restart count exceeds threshold within the window.
+func shouldEscalateToSlate() bool {
+ if time.Since(restartWindow) > 60*time.Second {
+ restartCount = 0
+ restartWindow = time.Now()
+ return false
+ }
+ return restartCount >= maxRestartsPerMinute
+}
+
+func resetHLSOutput(hlsDir string) error {
+ if err := os.MkdirAll(hlsDir, 0o755); err != nil {
+ return err
+ }
+
+ matches, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.ts"))
+ if err != nil {
+ return err
+ }
+
+ for _, match := range matches {
+ if err := os.Remove(match); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ }
+
+ fmp4Matches, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.m4s"))
+ if err != nil {
+ return err
+ }
+
+ for _, match := range fmp4Matches {
+ if err := os.Remove(match); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ }
+
+ manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
+ if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+
+ initPath := filepath.Join(hlsDir, "init.mp4")
+ if err := os.Remove(initPath); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+
+ return nil
+}
+
+func main() {
+ playlistPath := "/app/playlist/playlist.txt"
+ validateOnly := false
+ for _, arg := range os.Args[1:] {
+ switch arg {
+ case "-validate":
+ validateOnly = true
+ default:
+ if !strings.HasPrefix(arg, "-") {
+ playlistPath = arg
+ }
+ }
+ }
+
+ if validateOnly {
+ runValidateOnly(playlistPath)
+ return
+ }
+
+ // Load shared HLS runtime config (written by the chat admin) before starting.
+ loadSharedHLSConfig()
+
+ fmt.Println("=== Linear Void Playout Service Starting ===")
+ fmt.Println("Playlist:", playlistPath)
+ fmt.Println("R2 Bucket:", bucket)
+
+ // Initialize run ID for globally unique segment naming (STREAMING.md)
+ initRunID()
+ fmt.Println("Run ID:", runID)
+
+ // Validate playlist before starting (ADR-001 normalization enforcement).
+ // Runs once here; start-playout.sh no longer pre-validates separately.
+ videos, err := ValidatePlaylist(playlistPath)
+ if err != nil {
+ fmt.Println("ERROR: Playlist validation failed:", err)
+ os.Exit(1)
+ }
+ invalid := 0
+ for _, v := range videos {
+ if !v.Valid {
+ fmt.Printf("ERROR: invalid media: %s — %s\n", v.Path, v.Validation)
+ invalid++
+ }
+ }
+ if invalid > 0 {
+ fmt.Printf("ERROR: Playlist validation failed: %d invalid entries\n", invalid)
+ os.Exit(1)
+ }
+
+ // Initialize R2 client (skip if not configured — local HLS via nginx still works)
+ if err := initR2Client(); err != nil {
+ fmt.Println("R2 sync disabled:", err)
+ }
+
+ // Start R2 segment retention cleanup (hourly + once at startup)
+ if r2Client != nil {
+ go runR2CleanupLoop()
+ }
+
+ // Start HTTP health check server
+ http.HandleFunc("/health", HealthHandler)
+ http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
+ fmt.Fprintln(w, "# HELP linearvoid_playout_crashes_total Total FFmpeg crashes (not clean completions).")
+ fmt.Fprintln(w, "# TYPE linearvoid_playout_crashes_total counter")
+ fmt.Fprintf(w, "linearvoid_playout_crashes_total %d\n", restartCount)
+ fmt.Fprintln(w, "# HELP linearvoid_playout_mode Current playout mode (0=live, 1=slate, 2=degraded).")
+ fmt.Fprintln(w, "# TYPE linearvoid_playout_mode gauge")
+ modeVal := 0
+ if playoutMode == "slate" {
+ modeVal = 1
+ } else if playoutMode == "degraded" {
+ modeVal = 2
+ }
+ fmt.Fprintf(w, "linearvoid_playout_mode %d\n", modeVal)
+ fmt.Fprintln(w, "# HELP linearvoid_playout_r2_cleanup_objects_total Total R2 objects deleted by retention cleanup.")
+ fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_cleanup_objects_total counter")
+ fmt.Fprintf(w, "linearvoid_playout_r2_cleanup_objects_total %d\n", r2CleanupObjectsTotal)
+ fmt.Fprintln(w, "# HELP linearvoid_playout_r2_runs_retained Number of R2 run folders retained by cleanup.")
+ fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_runs_retained gauge")
+ fmt.Fprintf(w, "linearvoid_playout_r2_runs_retained %d\n", r2RunsRetained)
+ fmt.Fprintln(w, "# HELP linearvoid_playout_r2_bytes_retained Total bytes retained in R2 run folders.")
+ fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_bytes_retained gauge")
+ fmt.Fprintf(w, "linearvoid_playout_r2_bytes_retained %d\n", r2BytesRetained)
+ fmt.Fprintln(w, "# HELP linearvoid_playout_loops_total Total clean playlist completions (loops).")
+ fmt.Fprintln(w, "# TYPE linearvoid_playout_loops_total counter")
+ fmt.Fprintf(w, "linearvoid_playout_loops_total %d\n", loopsTotal)
+ fmt.Fprintln(w, "# HELP linearvoid_playout_run_duration_seconds Duration of the last FFmpeg run in seconds.")
+ fmt.Fprintln(w, "# TYPE linearvoid_playout_run_duration_seconds gauge")
+ fmt.Fprintf(w, "linearvoid_playout_run_duration_seconds %f\n", lastRunDuration.Seconds())
+ })
+ go func() {
+ fmt.Println("Health check server listening on :8081/health")
+ if err := http.ListenAndServe(":8081", nil); err != nil {
+ fmt.Println("Health server error:", err)
+ }
+ }()
+
+ // Run FFmpeg in a loop for continuous playback with slate fallback
+ for {
+ if playoutMode == "slate" {
+ // During slate: attempt recovery by restarting FFmpeg
+ fmt.Printf("[%s] Slate active — attempting live playout recovery...\n", time.Now().Format(time.RFC3339))
+ }
+
+ // New run ID on each restart for unique R2 segment keys (STREAMING.md)
+ initRunID()
+
+ // Remove run folders older than the retention policy (keep the last N runs,
+ // cap total bytes) now that the next run has started. This never touches the
+ // current or previous run folders.
+ if r2Client != nil {
+ if _, err := cleanupOldRuns(context.Background()); err != nil {
+ fmt.Println("R2 cleanup error:", err)
+ }
+ }
+
+ // Reset HLS output only when the playlist changed, we are recovering from a
+ // crash/slate, or this is the first run. Otherwise continue the segment
+ // sequence so the loop is seamless for viewers (no gap on clean loops).
+ fingerprint, fpErr := playlistFingerprint(playlistPath)
+ if fpErr != nil {
+ fmt.Println("Warning: cannot fingerprint playlist:", fpErr)
+ fingerprint = ""
+ }
+ playlistChanged := fingerprint != playlistRevision
+ shouldReset := playlistChanged || playoutMode != "live" || !lastRunClean
+
+ windowSize := 24
+ if ws, err := strconv.Atoi(hlsWindowSize); err == nil && ws > 0 {
+ windowSize = ws
+ }
+
+ startNumber := 0
+ if shouldReset {
+ if err := resetHLSOutput(hlsDir); err != nil {
+ fmt.Println("Failed to reset HLS output:", err)
+ time.Sleep(5 * time.Second)
+ continue
+ }
+ playlistRevision = fingerprint
+ } else {
+ if n, err := hlsNextStartNumber(hlsDir, windowSize); err == nil {
+ startNumber = n
+ }
+ }
+
+ // Start filesystem watcher for real-time R2 segment upload (STREAMING.md).
+ // Only active when R2 is configured; otherwise the watcher is skipped.
+ var watchCtx context.Context
+ var watchCancel context.CancelFunc
+ if r2Client != nil {
+ watchCtx, watchCancel = context.WithCancel(context.Background())
+ go watchAndPublishSegments(watchCtx, hlsDir)
+ }
+
+ lastFFmpegStartTime = time.Now()
+ fmt.Printf("[%s] Starting FFmpeg playout (run %s) with browser-safe H.264/AAC HLS encoding...\n",
+ lastFFmpegStartTime.UTC().Format(time.RFC3339), runID)
+
+ cmd := exec.Command("ffmpeg", "-re", "-f", "concat", "-safe", "0", "-i", playlistPath,
+ "-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", hlsSegmentDuration,
+ "-hls_list_size", hlsWindowSize,
+ "-hls_segment_type", "fmp4",
+ "-hls_fmp4_init_filename", "init.mp4",
+ "-hls_flags", "delete_segments+independent_segments",
+ "-hls_segment_filename", filepath.Join(hlsDir, "seg-%06d.m4s"),
+ "-start_number", strconv.Itoa(startNumber),
+ filepath.Join(hlsDir, "playlist.m3u8"))
+
+ runErr := cmd.Run()
+ runDuration := time.Since(lastFFmpegStartTime)
+ lastFFmpegExitError = runErr
+ lastRunDuration = runDuration
+ lastRunClean = runErr == nil
+
+ if watchCancel != nil {
+ watchCancel() // Stop the filesystem watcher
+ }
+
+ // After FFmpeg exits, sync remaining segments and publish manifest to R2.
+ // Do this before logging/restart logic so R2 is up to date regardless of exit cause.
+ if r2Client != nil {
+ if err := syncLatestToR2(hlsDir); err != nil {
+ fmt.Println("R2 sync warning:", err)
+ }
+
+ // Publish manifest atomically (STREAMING.md safe publication)
+ manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
+ if err := publishManifest(manifestPath); err != nil {
+ fmt.Println("Manifest publish warning:", err)
+ }
+ }
+
+ // Differentiate clean playlist completion from FFmpeg crash.
+ // A nil error means FFmpeg completed the playlist normally — not a crash.
+ if runErr == nil {
+ loopsTotal++
+ fmt.Printf("[%s] Playlist completed normally after %s. Looping...\n",
+ time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second))
+ // If we were in slate mode, a successful run means recovery
+ if playoutMode == "slate" {
+ deactivateSlate()
+ }
+ // Minimal delay for clean loop — just enough for watcher goroutine cleanup
+ time.Sleep(500 * time.Millisecond)
+ continue
+ }
+
+ // FFmpeg exited with an error — this is a real crash
+ restartCount++
+ fmt.Printf("[%s] FFmpeg CRASHED after %s (crash #%d in window): %v\n",
+ time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second), restartCount, runErr)
+
+ if shouldEscalateToSlate() {
+ fmt.Printf("[%s] CRITICAL: %d crashes in 60s — escalating to slate fallback\n",
+ time.Now().UTC().Format(time.RFC3339), restartCount)
+ if err := activateSlate(); err != nil {
+ fmt.Printf("[%s] Slate activation failed: %v — will retry\n",
+ time.Now().UTC().Format(time.RFC3339), err)
+ }
+ // Stay in slate mode, retry recovery after delay
+ time.Sleep(30 * time.Second)
+ continue
+ }
+
+ // If we were in slate but FFmpeg ran long enough to be a recovery, deactivate slate
+ if playoutMode == "slate" && runDuration > 30*time.Second {
+ deactivateSlate()
+ }
+
+ // Backoff delay after crash before retry
+ time.Sleep(2 * time.Second)
+ }
+}
+
+// syncLatestToR2 uploads new segments to R2 with runID-based keys
+func syncLatestToR2(hlsDir string) error {
+ files, err := filepath.Glob(filepath.Join(hlsDir, "seg-*.m4s"))
+ if err != nil {
+ return err
+ }
+
+ for _, f := range files {
+ key := "hls/" + runID + "/" + filepath.Base(f)
+ if err := SyncToR2(f, key); err != nil {
+ fmt.Printf("Failed to sync %s: %v\n", f, err)
+ } else {
+ fmt.Printf("Synced segment to R2: %s\n", key)
+ }
+ }
+
+ // Sync init segment to run-specific R2 key
+ initPath := filepath.Join(hlsDir, "init.mp4")
+ if err := SyncToR2(initPath, "hls/"+runID+"/init.mp4"); err != nil {
+ return err
+ }
+ fmt.Println("Synced init.mp4 to R2")
+
+ return nil
+}
+
+// r2KeepRuns returns how many of the most recent run folders to keep in R2.
+func r2KeepRuns() int {
+ n, err := strconv.Atoi(envWithDefault("R2_KEEP_RUNS", "2"))
+ if err != nil || n < 1 {
+ return defaultR2KeepRuns
+ }
+ return n
+}
+
+// r2MaxRetentionBytes returns the maximum total bytes to retain across run folders.
+func r2MaxRetentionBytes() int64 {
+ n, err := strconv.ParseInt(envWithDefault("R2_MAX_RETENTION_BYTES", "10737418240"), 10, 64)
+ if err != nil || n <= 0 {
+ return defaultR2MaxRetentionBytes
+ }
+ return n
+}
+
+// r2RunFromKey extracts the run folder name ("r-...") from an R2 object key.
+func r2RunFromKey(key string) (string, bool) {
+ rest := strings.TrimPrefix(key, "hls/")
+ if rest == key || !strings.HasPrefix(rest, "r-") {
+ return "", false
+ }
+ if idx := strings.IndexByte(rest, '/'); idx > 0 {
+ return rest[:idx], true
+ }
+ return "", false
+}
+
+// runObjects groups the R2 object keys and total size for a single run folder.
+type runObjects struct {
+ keys []string
+ size int64
+}
+
+// deleteR2Keys deletes the given R2 object keys in batches of up to 1000.
+func deleteR2Keys(keys []string) (int, error) {
+ deleted := 0
+ for i := 0; i < len(keys); i += 1000 {
+ end := i + 1000
+ if end > len(keys) {
+ end = len(keys)
+ }
+ batch := keys[i:end]
+ objects := make([]types.ObjectIdentifier, len(batch))
+ for j, key := range batch {
+ objects[j] = types.ObjectIdentifier{Key: aws.String(key)}
+ }
+ if _, err := deleteObjects(r2Client, &s3.DeleteObjectsInput{
+ Bucket: aws.String(bucket),
+ Delete: &types.Delete{Objects: objects, Quiet: aws.Bool(true)},
+ }); err != nil {
+ return deleted, err
+ }
+ for _, key := range batch {
+ fmt.Printf("R2 cleanup: deleted %s\n", key)
+ }
+ deleted += len(batch)
+ }
+ return deleted, nil
+}
+
+// cleanupOldRuns lists run-scoped objects under hls/, keeps the most recent
+// R2_KEEP_RUNS runs, and deletes older runs. It also enforces the
+// R2_MAX_RETENTION_BYTES cap by removing oldest retained runs first, always
+// keeping at least the newest run. The live manifest keys are never deleted.
+func cleanupOldRuns(ctx context.Context) (int, error) {
+ if r2Client == nil {
+ if err := initR2Client(); err != nil {
+ return 0, err
+ }
+ }
+
+ runs := map[string]*runObjects{}
+ var token *string
+ for {
+ output, err := listObjects(r2Client, &s3.ListObjectsV2Input{
+ Bucket: aws.String(bucket),
+ Prefix: aws.String("hls/"),
+ ContinuationToken: token,
+ })
+ if err != nil {
+ return 0, err
+ }
+ for _, obj := range output.Contents {
+ key := aws.ToString(obj.Key)
+ if key == "hls/playlist.m3u8" || key == "hls/playlist.m3u8.tmp" {
+ continue
+ }
+ run, ok := r2RunFromKey(key)
+ if !ok {
+ continue
+ }
+ r, exists := runs[run]
+ if !exists {
+ r = &runObjects{}
+ runs[run] = r
+ }
+ r.keys = append(r.keys, key)
+ if obj.Size != nil {
+ r.size += *obj.Size
+ }
+ }
+ if output.IsTruncated == nil || !*output.IsTruncated {
+ break
+ }
+ token = output.NextContinuationToken
+ }
+
+ if len(runs) == 0 {
+ r2RunsRetained = 0
+ r2BytesRetained = 0
+ return 0, nil
+ }
+
+ // Run IDs are ISO timestamps, so lexicographic order equals chronological order.
+ sortedRuns := make([]string, 0, len(runs))
+ for run := range runs {
+ sortedRuns = append(sortedRuns, run)
+ }
+ sort.Strings(sortedRuns) // ascending: oldest -> newest
+
+ deleteRun := map[string]bool{}
+ keepRuns := r2KeepRuns()
+
+ // Mark all but the newest keepRuns runs for deletion.
+ if len(sortedRuns) > keepRuns {
+ for _, run := range sortedRuns[:len(sortedRuns)-keepRuns] {
+ deleteRun[run] = true
+ }
+ }
+
+ // Enforce the total size cap on retained runs (oldest retained first), always
+ // keeping the newest run even if it alone exceeds the cap.
+ maxBytes := r2MaxRetentionBytes()
+ retained := make([]string, 0, keepRuns)
+ var retainedBytes int64
+ for _, run := range sortedRuns {
+ if !deleteRun[run] {
+ retained = append(retained, run)
+ retainedBytes += runs[run].size
+ }
+ }
+ for i := 0; retainedBytes > maxBytes && i < len(retained)-1; i++ {
+ run := retained[i]
+ deleteRun[run] = true
+ retainedBytes -= runs[run].size
+ }
+
+ var toDelete []string
+ for run, del := range deleteRun {
+ if del {
+ toDelete = append(toDelete, runs[run].keys...)
+ }
+ }
+
+ deleted, err := deleteR2Keys(toDelete)
+ if err != nil {
+ return deleted, err
+ }
+
+ keptRuns := int64(0)
+ keptBytes := int64(0)
+ for run, r := range runs {
+ if !deleteRun[run] {
+ keptRuns++
+ keptBytes += r.size
+ }
+ }
+ r2RunsRetained = keptRuns
+ r2BytesRetained = keptBytes
+
+ r2CleanupObjectsTotal += int64(deleted)
+ return deleted, nil
+}
+
+// runR2CleanupLoop runs retention cleanup once at startup, then hourly.
+func runR2CleanupLoop() {
+ if _, err := cleanupOldRuns(context.Background()); err != nil {
+ fmt.Println("R2 cleanup error:", err)
+ }
+ ticker := time.NewTicker(time.Hour)
+ defer ticker.Stop()
+ for range ticker.C {
+ if _, err := cleanupOldRuns(context.Background()); err != nil {
+ fmt.Println("R2 cleanup error:", err)
+ }
+ }
+}
+
+// playlistFingerprint returns a short hash of the playlist file content, used to
+// detect playlist changes between loops.
+func playlistFingerprint(playlistPath string) (string, error) {
+ data, err := os.ReadFile(playlistPath)
+ if err != nil {
+ return "", err
+ }
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:]), nil
+}
+
+// hlsNextStartNumber returns the next segment sequence number to use, and prunes
+// stale segments outside the live window so they do not accumulate across loops.
+func hlsNextStartNumber(hlsDir string, windowSize int) (int, error) {
+ files, err := filepath.Glob(filepath.Join(hlsDir, "seg-*.m4s"))
+ if err != nil {
+ return 0, err
+ }
+
+ seqOf := func(f string) int {
+ base := filepath.Base(f)
+ num := strings.TrimSuffix(strings.TrimPrefix(base, "seg-"), ".m4s")
+ n, err := strconv.Atoi(num)
+ if err != nil {
+ return -1
+ }
+ return n
+ }
+
+ maxSeq := -1
+ for _, f := range files {
+ if n := seqOf(f); n > maxSeq {
+ maxSeq = n
+ }
+ }
+ next := maxSeq + 1
+
+ // Keep the live window; delete older segments no longer referenced.
+ threshold := next - windowSize
+ for _, f := range files {
+ if n := seqOf(f); n >= 0 && n < threshold {
+ if err := os.Remove(f); err != nil && !os.IsNotExist(err) {
+ return next, err
+ }
+ }
+ }
+ return next, nil
+}
+
+// runValidateOnly validates the playlist and exits 0 (valid) or 1 (invalid/missing).
+func runValidateOnly(playlistPath string) {
+ videos, err := ValidatePlaylist(playlistPath)
+ if err != nil {
+ fmt.Println("ERROR: Playlist validation failed:", err)
+ os.Exit(1)
+ }
+ valid := true
+ for _, v := range videos {
+ if v.Valid {
+ fmt.Printf("OK: %s (%.1fs)\n", v.Path, v.Duration)
+ } else {
+ valid = false
+ fmt.Printf("INVALID: %s — %s\n", v.Path, v.Validation)
+ }
+ }
+ if !valid {
+ os.Exit(1)
+ }
+ fmt.Println("Playlist valid.")
+}
+
+// loadSharedHLSConfig reads HLS settings from the shared runtime config file
+// (written by the chat admin service) and overrides the env-derived defaults.
+func loadSharedHLSConfig() {
+ path := envWithDefault("PLAYOUT_CONFIG_PATH", "/app/playlist/config.json")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ fmt.Println("Warning: cannot read shared config:", err)
+ }
+ return
+ }
+ var cfg map[string]string
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ fmt.Println("Warning: cannot parse shared config:", err)
+ return
+ }
+ if v := strings.TrimSpace(cfg["hls_segment_duration"]); v != "" {
+ if n, err := strconv.Atoi(v); err == nil && n > 0 {
+ hlsSegmentDuration = strconv.Itoa(n)
+ }
+ }
+ if v := strings.TrimSpace(cfg["hls_window_size"]); v != "" {
+ if n, err := strconv.Atoi(v); err == nil && n > 0 {
+ hlsWindowSize = strconv.Itoa(n)
+ }
+ }
+ fmt.Printf("Shared HLS config: segment=%ss window=%s\n", hlsSegmentDuration, hlsWindowSize)
}
\ No newline at end of file
diff --git a/backend/playout/src/playlist_test.go b/backend/playout/src/playlist_test.go
index f5e1cd9..6db7ab3 100644
--- a/backend/playout/src/playlist_test.go
+++ b/backend/playout/src/playlist_test.go
@@ -1,947 +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)
- }
+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)
+ }
}
\ No newline at end of file
diff --git a/backend/playout/testdata/comments_only.txt b/backend/playout/testdata/comments_only.txt
index a175710..28e206a 100644
--- a/backend/playout/testdata/comments_only.txt
+++ b/backend/playout/testdata/comments_only.txt
@@ -1,2 +1,2 @@
-# Linear Void test playlist
-# No media entries here
+# Linear Void test playlist
+# No media entries here
diff --git a/backend/playout/testdata/empty_playlist.txt b/backend/playout/testdata/empty_playlist.txt
index 8b13789..d3f5a12 100644
--- a/backend/playout/testdata/empty_playlist.txt
+++ b/backend/playout/testdata/empty_playlist.txt
@@ -1 +1 @@
-
+
diff --git a/backend/playout/testdata/mixed_valid_invalid.txt b/backend/playout/testdata/mixed_valid_invalid.txt
index e21369d..8332fee 100644
--- a/backend/playout/testdata/mixed_valid_invalid.txt
+++ b/backend/playout/testdata/mixed_valid_invalid.txt
@@ -1,6 +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
+# 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'
\ No newline at end of file
diff --git a/backend/playout/testdata/valid_playlist.txt b/backend/playout/testdata/valid_playlist.txt
index 8249822..c3b2682 100644
--- a/backend/playout/testdata/valid_playlist.txt
+++ b/backend/playout/testdata/valid_playlist.txt
@@ -1,2 +1,2 @@
-file '/app/media/demo1.mp4'
+file '/app/media/demo1.mp4'
file '/app/media/demo2.mp4'
\ No newline at end of file
diff --git a/docs/API.md b/docs/API.md
index a8a8be8..a178164 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1,216 +1,101 @@
-# API Reference
+# Linear Void API Reference
-This document describes the currently implemented API surfaces in Linear Void.
+Base URLs: Public/admin app `https://linearvoid.tv` · WebSocket `wss://linearvoid.tv/ws` · Local fallback `http://localhost` / `ws://localhost/ws`
-## Base URLs
+## Authentication
-- Public app and admin:
-- WebSocket endpoint:
-- Local fallback: and
+Admin REST endpoints use a cookie session:
-## Authentication Model
-
-Admin REST endpoints use a cookie session.
-
-- Cookie name: linearvoid_admin_session
-- Cookie attributes: HttpOnly, SameSite=Strict, Secure when request is HTTPS (or X-Forwarded-Proto=https)
-- Session TTL: controlled by ADMIN_SESSION_TTL_HOURS (default 12h)
+- 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
+Connect: `GET /ws?username={name}&room={room}` — client/server exchange JSON messages.
-Endpoint:
+| 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 | |
-- GET /ws?username={name}&room={room}
-
-Client sends JSON messages over the socket.
-
-### Message Envelope (client -> server)
+Server→client envelope adds `"timestamp"` (ISO-8601, e.g. `2026-08-04T12:34:56Z`).
```json
-{
- "room": "main",
- "username": "viewer123",
- "text": "hello world",
- "type": "public"
-}
-```
-
-Notes:
-
-- type is usually public for room chat and private for DM room routing.
-- Private room names are formatted like dm:userA-userB (sorted usernames).
-- Moderation/system events are sent as type=system messages.
-
-### Message Envelope (server -> client)
-
-```json
-{
- "room": "main",
- "username": "viewer123",
- "text": "hello world",
- "type": "public",
- "timestamp": "2026-08-04T12:34:56Z"
-}
+{ "room": "main", "username": "viewer123", "text": "hello world", "type": "public" }
```
## Admin Session API
-### Login
+| 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 |
-- POST /api/admin/login
-- Auth: none
-
-Request:
+Login request:
```json
-{
- "username": "admin",
- "password": "your-password"
-}
+{ "username": "admin", "password": "your-password" }
```
-Success response:
+Login/logout/session success response (session returns 401 on failure):
```json
-{
- "authenticated": true,
- "username": "admin"
-}
+{ "authenticated": true, "username": "admin" }
```
-### Logout
+Logout response: `{ "authenticated": false }`
-- POST /api/admin/logout
-- Auth: optional (clears cookie either way)
+## Admin Data API (auth required)
-Response:
+| 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
-{
- "authenticated": false
-}
+{ "action": "mute", "target": "username-or-id", "moderator": "admin", "reason": "spam", "room": "main" }
```
-### Session Check
-
-- GET /api/admin/session
-- Auth: cookie required
-
-Success response:
-
-```json
-{
- "authenticated": true,
- "username": "admin"
-}
-```
-
-Failure:
-
-- 401 unauthorized
-
-## Admin Data API
-
-All endpoints below require admin authentication.
-
-### Overview
-
-- GET /api/admin/overview
-
-Returns aggregate counters and runtime/system snapshot fields.
-
-### Playlist
-
-- GET /api/admin/playlist
-- PUT /api/admin/playlist
-
-PUT request:
-
-```json
-{
- "content": "file '/app/media/video1.mp4'\nfile '/app/media/video2.mp4'\n"
-}
-```
-
-### Schedule
-
-- GET /api/admin/schedule
-
-Returns parsed playlist entries plus matched media metadata/runtime.
-
-### Moderation
-
-- GET /api/admin/moderation/logs?limit=50
-- POST /api/admin/moderation/actions
-
-Action request:
-
-```json
-{
- "action": "mute",
- "target": "username-or-id",
- "moderator": "admin",
- "reason": "spam",
- "room": "main"
-}
-```
-
-### Analytics
-
-- GET /api/admin/analytics
-
-Returns totals (messages, moderation actions, rooms) and top room summary.
-
-### System
-
-- GET /api/admin/system
-
-Returns service health snapshot for chat/playout/redis/database and config metadata.
-
-### Active Users
-
-- GET /api/admin/users
-
-Returns currently discovered presence users (room + username) when Redis is available.
-
## Media Library API
-### Media Collection
+| 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 |
-- GET /api/admin/media
-- POST /api/admin/media (multipart upload, legacy path)
-
-Multipart form field name: file
-
-### Media Item
-
-- GET /api/admin/media/{id}
-- PUT /api/admin/media/{id}
-- DELETE /api/admin/media/{id}
-
-PUT request:
+PUT media body:
```json
-{
- "status": "ready",
- "title": "My Clip",
- "tags": "music,live"
-}
+{ "status": "ready", "title": "My Clip", "tags": "music,live" }
```
## Resumable Upload API
-Maximum file size: 2GB
+Max file size: **2GB** · Default chunk size: **8MB**
-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 or Resume Session
-
-- POST /api/admin/media/uploads
-
-Request:
+Initialize request:
```json
{
@@ -223,7 +108,7 @@ Request:
}
```
-Response:
+Status envelope (returned by init, status check, and chunk upload):
```json
{
@@ -237,61 +122,33 @@ Response:
}
```
-### Check Upload Status
+## Metrics & Health
-- GET /api/admin/media/uploads/{uploadId}
-
-Returns same status envelope as initialize.
-
-### Upload Chunk
-
-- PUT /api/admin/media/uploads/{uploadId}/chunk?index={chunkIndex}
-- Content-Type: application/octet-stream
-
-Response returns updated upload status envelope.
-
-### Complete Upload
-
-- POST /api/admin/media/uploads/{uploadId}/complete
-
-On success returns admin media listing payload (files + generatedAt).
-
-## Metrics and Health
-
-### Chat metrics
-
-- GET /metrics
-
-Prometheus exposition includes connected clients, stored messages, moderation actions, Redis indicators, active admin sessions, and build info.
-
-### Health
-
-- GET /health on chat service
-- GET /health on playout service
-- GET /health on nginx reverse proxy
+- `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, or action |
+| 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 or expired admin session |
-| 403 | Forbidden | Valid session but insufficient permissions |
+| 401 | Unauthorized | Missing/expired admin session |
+| 403 | Forbidden | Valid session, insufficient permissions |
| 404 | Not Found | Resource doesn't exist |
-| 405 | Method Not Allowed | Wrong HTTP method for endpoint |
+| 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 messages, WS connections, login) |
+| 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
-All limits are per-IP (from `X-Forwarded-For` or `RemoteAddr`) unless noted.
+Per-IP (from `X-Forwarded-For` or `RemoteAddr`) unless noted.
| Resource | Limit | Window | Response |
| --------------------- | ---------------------------------- | ---------------------------------------- | ------------------------------------------------------ |
@@ -299,9 +156,4 @@ All limits are per-IP (from `X-Forwarded-For` or `RemoteAddr`) unless noted.
| WebSocket connections | 5 | 60s | 429 + `"too many connections from this IP"` |
| Admin login | 5 | 5min | 429 + `"too many login attempts, try again later"` |
-Rate limit headers on applicable responses:
-
-- `X-RateLimit-Limit`: maximum requests per window
-- `Retry-After`: seconds until window resets
-- 413: payload too large
-- 500: internal server error
+Rate limit headers (applicable responses): `X-RateLimit-Limit` (max requests/window), `Retry-After` (seconds until reset). 413 = payload too large; 500 = internal server error.
diff --git a/docs/COOLIFY.md b/docs/COOLIFY.md
index 865deca..e6ff822 100644
--- a/docs/COOLIFY.md
+++ b/docs/COOLIFY.md
@@ -1,229 +1,148 @@
# Coolify Deployment
-**Last Updated**: 2026-08-28
+*Last Updated: 2026-08-28*
-This guide describes an 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 lets you manage servers, applications, and
-databases over SSH without vendor lock-in.
+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`).
-Coolify is a drop-in alternative to the two existing deployment flows described
-in [SETUP.md](./SETUP.md):
+## Why Coolify
-1. **Local development** (`docker-compose.yml`) — builds from source on your
- workstation.
-2. **Test-lab / production** (`docker-compose.prod.yml`) — pulls prebuilt images
- from the Gitea registry and is driven by the Gitea Actions CD workflow.
+- **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`.
-With Coolify, the same Docker Compose stack is deployed through a web UI and
-Coolify's own reverse proxy (Traefik), giving you automatic HTTPS, domain
-routing, and one-click redeploys without managing `DOCKER_HOST` manually.
-
-## Why use Coolify?
-
-- **Single control plane**: manage the playout, chat, nginx, redis, and db
- services (plus exporters and monitoring) from one dashboard.
-- **Automatic TLS**: Coolify provisions and renews Let's Encrypt certificates
- per application/domain, replacing the manual Cloudflare + nginx origin TLS
- dance for the app hostname.
-- **No vendor lock-in**: all configuration lives on your server. If you stop
- using Coolify, the running containers keep working; you only lose the
- automation.
-- **Simpler cutover**: when the Hetzner VPS is provisioned, point Coolify at the
- new server and redeploy instead of editing CD `DOCKER_HOST` values.
-
-> **Note**: Coolify replaces the _deployment orchestration_ layer only. Cloudflare
-> DNS, the R2 media bucket, cache rules, and the edge Worker described in
-> [SETUP.md](./SETUP.md) still apply and are unchanged.
+> 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
-- A Linux server running **Docker Engine** (any distro Coolify supports).
-- **SSH access** (root or a user with passwordless `sudo`) from the machine
- where you run the Coolify install — Coolify uses SSH to provision the target
- server.
-- At least **2 GB RAM / 20 GB disk** for the Coolify instance itself (plus
- additional resources for the Linear Void stack).
-- A domain name pointed at the server for HTTPS (e.g. `linearvoid.tv`).
-- Ports `80` and `443` reachable on the server for Traefik.
-- The Gitea registry credentials (`CI_REGISTRY_USER` / `CI_REGISTRY_TOKEN`) if
- you intend to pull the prebuilt `playout`, `chat`, and `nginx` images.
+- 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
-Install Coolify on a dedicated server (or alongside your stack — but a dedicated
-Coolify server is the recommended layout):
+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. Open the dashboard in your browser and complete first-run
-setup.
-
-For non-interactive or offline installs, see the
-[installation docs](https://coolify.io/docs/installation) and the
-[install script source](https://github.com/coollabsio/coolify/blob/v4.3.14/scripts/install.sh).
+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. In the Coolify dashboard, go to **Servers → Add**.
-2. Choose your provider (or **Localhost** if Coolify runs on the same machine
- as the stack).
-3. Provide the SSH details. For a remote host, Coolify validates the connection
- and installs its agent.
-4. Verify the server shows as **Reachable** before creating resources.
+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)
-The active test-lab setup is **two-tier**:
+Active test-lab setup is **two-tier**:
-| Component | Host | Environment | Exposes | Docker TCP | Notes |
-| --------------------- | --------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Coolify dashboard** | `192.168.88.18` | Coolify; docker container; coolify dashboard at `8000` | `https://coolify.allucanget.biz` via Cloudflare tunnel (`coolify-cloudflared`) | No | Coolify manages target servers over SSH |
-| **Deployment target** | `192.168.88.40` | Only Linear Void deploys here | `coolify-proxy` = Traefik on 80/443, Port 8000 on this host is bound by Portainer, not Coolify. | Yes | Contains Coolify's per-server helpers (`coolify-proxy`, `coolify-sentinel`, `coolify-cloudflared`) plus the stack via `docker-compose.prod.yml` (the `linearvoid` compose project, 11 containers). |
+| 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 does **not** run on `192.168.88.40`; it lives on
-the dashboard host `192.168.88.18`. When a Coolify deployment creates a Compose
-resource, the app services land on the target server `192.168.88.40`.
+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`:
-Useful checks:
-
-```powershell
-# Environment / deployment health on the target 192.168.88.40 (containers, helpers, health)
-./scripts/check-env.ps1
-
-# Credential / token access (Gitea registry, Cloudflare, R2, admin login)
-./scripts/check-tokens.ps1
+```bash
+./scripts/check-env.sh # env/deployment health on target (containers, helpers, health)
+./scripts/check-tokens.sh # Gitea registry, Cloudflare, R2, admin credential access
```
-The Coolify dashboard runs on the `192.168.88.18` container host (port `8000`,
-publicly `https://coolify.allucanget.biz`) and exposes no Docker TCP socket, so
-the environment/token scripts are intended to run against the deployment target
-`192.168.88.40`, not `192.168.88.18`.
-
## Deploy the stack
-Linear Void is a multi-service Docker Compose stack, so deploy it as a
-**Compose** (Docker Compose) resource rather than individual applications.
+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 Coolify at
- `https://git.allucanget.biz/zwitschi/linearvoid.git` and use
- `docker-compose.prod.yml` as the compose file. Coolify pulls on each
- redeploy.
- - **Raw Compose** — paste the contents of `docker-compose.prod.yml` directly
- if you prefer to manage it manually.
-4. If using the Git source, add the Gitea registry login as a **Deployment Key /
- environment secret** so the `playout`, `chat`, and `nginx` images can be
- pulled. Alternatively, switch those services to `build:` blocks (as in
- `docker-compose.yml`) so Coolify builds from source on the server.
+ - **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
-Coolify injects a secret store into the compose resource. Map the Linear Void
-environment to Coolify's **Environment Variables** tab (these correspond to the
-values normally placed in `.env`):
+Map Linear Void's `.env` values into Coolify's **Environment Variables** tab:
-| Variable | Purpose |
-| ---------------------------------------------- | --------------------------------------------------------------------- |
-| `DATABASE_URL` | `postgres://linearvoid:@db:5432/linearvoid?sslmode=disable` |
-| `REDIS_URL` | `redis://redis:6379/0` |
-| `POSTGRES_PASSWORD` | Database password (used by the `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` |
+| Variable | Purpose |
+|---|---|
+| `DATABASE_URL` | `postgres://linearvoid:@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`.
+Keep internal service URLs on compose hostnames (`redis`, `db`) exactly as in `docker-compose.prod.yml`.
### Persistent storage
-The compose file uses named volumes (`redis-data`, `postgres-data`) and bind
-mounts under `./data` (playlist, media, hls, chat, slate). Configure these under
-**Persistent Storage** so data survives redeploys:
+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 and segments |
-| `data/chat` | Chat service working data |
-| `data/slate` | Slate media |
-| `redis-data` | Redis append-only persistence |
-| `postgres-data` | PostgreSQL data directory |
+| 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 |
-Create the `data` subdirectories before the first deploy if Coolify does not
-auto-create them (`mkdir -p data/playlist data/media data/hls data/chat`).
+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. Open the compose resource and assign the **nginx** service a public domain
- (e.g. `linearvoid.tv`). Coolify's Traefik reverse proxy then terminates TLS
- and forwards to the `nginx` container.
-2. Set the **Ports Exposes** mapping so Traefik targets the nginx service port
- (`80` inside the container, mapped from `${NGINX_PORT:-8080}:80`).
-3. Do **not** also expose the playout (`8081`) or chat (`3000`) health ports
- publicly — keep those internal to the compose network and let Traefik route
- through nginx.
-4. For the media domain (`media.linearvoid.tv`), keep serving HLS from the R2
- custom domain (per SETUP.md) rather than routing it through Coolify, so
- segments stay on Cloudflare's edge cache.
+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:-8080}: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
-After the first deploy, confirm:
-
-- `docker compose ps` (on the server) shows all services healthy.
+- `docker compose ps` on the server shows all services healthy.
- Playout health: `https:///health` via nginx (internal `:8081/health`).
- Chat health: internal `:3000/health`.
-- HLS is written to `data/hls` and served from `media.linearvoid.tv`.
+- 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 the configured credentials.
+- Admin login works at `/admin` with configured credentials.
## Monitoring
-Coolify exposes container logs and basic metrics per resource. The stack also
-ships Prometheus + Grafana (`monitoring/`) and nginx/redis exporters. If you
-want those reachable through Coolify, expose Grafana on a restricted subdomain
-(e.g. `grafana.linearvoid.tv`) and keep Prometheus internal, mirroring the
-access guidance in SETUP.md.
+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
-Coolify can fully replace the Gitea Actions CD step. To switch:
-
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 a 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 continues pulling
- `:latest` images from the registry.
+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**: ensure the Gitea registry credentials are set in the
- compose resource's secret store, or switch the services to `build:` blocks.
-- **Healthchecks fail**: confirm `curl` is available in the built images (the
- compose healthchecks rely on `curl`).
-- **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 the playout service
- restarts cleanly; see the normalization troubleshooting in SETUP.md.
-- **Ports conflict**: Coolify's Traefik binds `80`/`443`; do not also map those
- to the host in the compose file.
+- **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):
- Coolify docs:
-- Linear Void deployment: [DEPLOYMENT.md](./DEPLOYMENT.md)
-- Linear Void setup: [SETUP.md](./SETUP.md)
+- [DEPLOYMENT.md](./DEPLOYMENT.md) · [SETUP.md](./SETUP.md)
\ No newline at end of file
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index f95abfd..b70860f 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -1,64 +1,26 @@
# Deployment Guide
-## Table of Contents
-
-- [Deployment Guide](#deployment-guide)
- - [Table of Contents](#table-of-contents)
- - [Environments](#environments)
- - [Production Target (Hetzner)](#production-target-hetzner)
- - [Planned Infrastructure](#planned-infrastructure)
- - [Cutover Plan](#cutover-plan)
- - [Production Preflight](#production-preflight)
- - [Firewall / Security Group](#firewall--security-group)
- - [Pipeline Readiness](#pipeline-readiness)
- - [Target Topology](#target-topology)
- - [Prerequisites](#prerequisites)
- - [Environment File](#environment-file)
- - [Gitea Secrets](#gitea-secrets)
- - [Pull-and-Run Deployment](#pull-and-run-deployment)
- - [First-Time or Recovery Provisioning](#first-time-or-recovery-provisioning)
- - [Health Verification](#health-verification)
- - [Zero/Low-Downtime Update Pattern](#zerolow-downtime-update-pattern)
- - [Rollback](#rollback)
- - [Operational Tasks](#operational-tasks)
- - [View logs](#view-logs)
- - [Restart one service](#restart-one-service)
- - [Stop stack](#stop-stack)
- - [Keep volumes (recommended) vs remove volumes (destructive)](#keep-volumes-recommended-vs-remove-volumes-destructive)
- - [Nginx and Proxy Considerations](#nginx-and-proxy-considerations)
- - [Security Baseline](#security-baseline)
- - [Common Deployment Pitfalls](#common-deployment-pitfalls)
- - [Troubleshooting](#troubleshooting)
-
## Environments
-Linear Void runs in two separate environments:
+| Environment | Host | Docker Target | Purpose |
+|---|---|---|---|
+| **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation |
+| **Production** | Hetzner VPS (TBD) | `tcp://:2375` | Public linearvoid.tv |
-| 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://:2375` | Public linearvoid.tv serving real viewers |
-
-**Current state (2026-08-06):** Test lab is active and receives automated CD deploys. Production Hetzner VPS is **not yet provisioned**. The CD workflow (`cd.yml`) and `docker-compose.prod.yml` currently target the test lab. Switching to production requires only changing `DOCKER_HOST` — see [Production Deployment](#production-target-hetzner) below.
+**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.** The production deployment plan is documented and the pipeline is ready — only the VPS needs to be created.
+**Status: Not provisioned.** Plan documented and pipeline ready; only the VPS needs creation.
### Planned Infrastructure
-
-| Resource | Spec |
-| -------------- | ------------------------------------------------ |
-| Provider | Hetzner Cloud |
-| Region | Ashburn, VA (us-east) |
-| Instance | CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent |
-| OS | Ubuntu 24.04 LTS or Debian 12 |
-| Bandwidth | 20 TB included traffic/month |
-| Estimated cost | ~€7–10/month base |
+- Provider: Hetzner Cloud, Region: Ashburn, VA (us-east)
+- Instance: CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent
+- OS: Ubuntu 24.04 LTS or Debian 12
+- Bandwidth: 20 TB included traffic/month; Estimated cost: ~€7–10/month base
### Cutover Plan
-
-1. Provision Hetzner VPS (see [Production Deployment](#production-target-hetzner) section)
+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
@@ -66,44 +28,33 @@ Linear Void runs in two separate environments:
6. Enable HTTPS, WAF, cache rules on production zone
7. Decommission test lab as public target
-### Production Preflight
-
-Before the cutover in the plan above, treat the following as the minimum readiness gate:
-
-1. Verify the Hetzner host is reachable over SSH and Docker remote API TLS is working for the Gitea runner.
-2. Confirm all required production secrets are present under the `CI_PROD_*` prefix.
-3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv` DNS targets are correct for the intended phase.
-4. Complete the manual Cloudflare/UptimeRobot checklist and attach evidence in [Cloudflare Uptimerobot checklist](../docs/operations/cloudflare-uptimerobot-checklist.md).
-5. Run the production smoke path end to end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, and playout health.
-6. Verify rollback is still possible before the final DNS switch.
-
-Readiness is not complete until the checklist above and the manual evidence tracker are both satisfied.
+### 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) |
+| 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) |
+| 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 |
-Three Gitea Actions workflows drive the full pipeline:
-
-| Workflow | Trigger | Purpose |
-| -------------------- | ----------------- | ----------------------------------------------------------------------------------------- |
-| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push Docker images (`:latest`, `:main`) |
-| `cd.yml` | Push to `main` | Deploy to test lab (`192.168.88.40:2375`) — runs **in parallel** with CI |
-| `release.yml` | Tag push `*.*.*` | Build & push versioned Docker images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
-| `workers-deploy.yml` | After CI succeeds | Deploy frontend to Cloudflare Workers (see `docs/operations/gitea-cloudflare-workers.md`) |
-
-CI and CD run simultaneously on every push to `main`. The deploy pulls whatever `:latest` images are available in the registry at that moment.
-
-The CD workflow (`cd.yml`) uses `DOCKER_HOST` to target the deployment host. To switch targets:
+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)
@@ -113,10 +64,9 @@ DOCKER_HOST=tcp://192.168.88.40:2375
DOCKER_HOST=tcp://:2375
```
-No code changes needed in the pipeline. See `DEPLOYMENT.md` Production Deployment section for full checklist.
+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
@@ -124,16 +74,14 @@ No code changes needed in the pipeline. See `DEPLOYMENT.md` Production Deploymen
- CDN/edge: Cloudflare
## Prerequisites
-
- Docker Engine + Docker Compose plugin
- Network access to git.allucanget.biz
-- A valid .env file with production values
-- DNS configured for linearvoid.tv (and media.linearvoid.tv if split media domain is used)
+- Valid `.env` file with production values
+- DNS configured for `linearvoid.tv` (and `media.linearvoid.tv` if split media domain used)
## Environment File
-Use `.env` with at least:
-
+`.env` requires at minimum:
- `DATABASE_URL` — PostgreSQL connection string
- `REDIS_URL` — Redis connection string
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` — admin dashboard credentials
@@ -141,186 +89,116 @@ Use `.env` with at least:
- `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 write credentials
-- `R2_BUCKET_NAME` — bucket name (e.g. `linearvoid-media`)
+- `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:
-The CD workflow (`cd.yml`) injects sensitive values from Gitea secrets into the `.env` file at deploy time. The following secrets must be configured in the Gitea repository:
-
-| Secret Name | Description |
-| ---------------------------- | --------------------------------- |
-| `DATABASE_URL` | PostgreSQL connection string |
-| `ADMIN_USERNAME` | Admin dashboard username |
-| `ADMIN_PASSWORD` | Admin dashboard password |
-| `R2_ACCESS_KEY_ID` | Cloudflare R2 access key |
-| `R2_SECRET_ACCESS_KEY` | Cloudflare R2 secret key |
-| `R2_BUCKET_NAME` | R2 bucket name |
-| `R2_ENDPOINT` | R2 S3-compatible endpoint URL |
-| `R2_ADMIN_ACCESS_KEY_ID` | R2 admin access key (optional) |
-| `R2_ADMIN_SECRET_ACCESS_KEY` | R2 admin secret key (optional) |
-| `R2_ADMIN_ENDPOINT` | R2 admin endpoint (optional) |
-| `CLOUDFLARE_API_TOKEN` | Cloudflare API token (optional) |
-| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID (optional) |
-| `CI_REGISTRY_USER` | Gitea container registry username |
-| `CI_REGISTRY_TOKEN` | Gitea container registry token |
+`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 and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](../CONTRIBUTING.md#versioning-and-releases) for the release procedure).
-
-Every push to `main` triggers CI (builds `:main` and `:latest` tags) and CD (deploys `:latest` to test lab) in parallel. Semantic version tags (`*.*.*`, e.g. `1.0.9`) trigger the release workflow which builds `:X.Y.Z`, `:X.Y`, and `:latest` tags and creates a Gitea Release.
-
-From repository root:
+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
-
-1. Ensure host paths exist:
-
```bash
mkdir -p data/chat data/playlist data/media data/hls
-```
-
-1. Confirm volumes and networks:
-
-```bash
docker volume ls
docker network ls
-```
-
-1. Start stack:
-
-```bash
docker compose -f docker-compose.prod.yml up -d
```
## Health Verification
-
-After startup, check service status:
-
```bash
docker compose -f docker-compose.prod.yml ps
```
-
Endpoint checks:
-
-- App:
-- Nginx health:
-- Chat health:
-- Playout health:
-- Prometheus:
-- Grafana:
+- App: `http://{host}:8080/`
+- Nginx health: `http://{host}:8080/health`
+- Chat health: `http://{host}:3000/health`
+- Playout health: `http://{host}:8081/health`
+- Prometheus: `http://{host}:9090`
+- Grafana: `http://{host}:3001`
Domain checks:
-
--
--
--
+- `https://linearvoid.tv`
+- `https://linearvoid.tv/admin`
+- `https://linearvoid.tv/metrics`
## Zero/Low-Downtime Update Pattern
-
1. Pull newest images.
-1. Recreate changed services only:
-
+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
```
-
-1. Re-run health checks.
+3. Re-run health checks.
## Rollback
-
If new images are unhealthy:
-
-1. Pin previous tags in docker-compose.prod.yml.
+1. Pin previous tags in `docker-compose.prod.yml`.
2. Pull pinned tags.
-3. Restart impacted services.
-
+3. Restart impacted services:
```bash
docker compose -f docker-compose.prod.yml pull
-
docker compose -f docker-compose.prod.yml up -d nginx chat playout
```
## Operational Tasks
-
-### View logs
-
```bash
+# View logs
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
-```
-### Restart one service
-
-```bash
+# Restart one service
docker compose -f docker-compose.prod.yml restart chat
-```
-### Stop stack
-
-```bash
-docker compose -f docker-compose.prod.yml down
-```
-
-### Keep volumes (recommended) vs remove volumes (destructive)
-
-```bash
-# keep data
+# Stop stack (keep data)
docker compose -f docker-compose.prod.yml down
-# destructive (remove data)
+# Stop stack — destructive (remove data)
docker compose -f docker-compose.prod.yml down -v
```
## Nginx and Proxy Considerations
-
-- If an external proxy (for example Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx, see [nginx.conf](../infrastructure/nginx/nginx.conf).
-- For resumable admin uploads, ensure client_max_body_size supports chunk requests in both proxy layers.
+- 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 strong ADMIN_PASSWORD and rotate regularly.
+- 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- --tail 50` | Check `.env` vars, volume mounts, port conflicts |
+| Playout stall / slate mode | `curl http://:8081/health` — mode=slate | Check playlist file, media paths, disk space |
+| Chat not connecting | `curl http://: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:///hls/playlist.m3u8` | Check playout health, HLS dir permissions |
+| Prometheus no targets | `curl http://:9090/targets` | Verify container network, scrape config |
+| Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned |
-| Symptom | Check | Fix |
-| -------------------------- | --------------------------------------------- | ------------------------------------------------------------- |
-| Service not starting | `docker logs linearvoid- --tail 50` | Check `.env` vars, volume mounts, port conflicts |
-| Playout stall / slate mode | `curl http://:8081/health` — mode=slate | Check playlist file, media paths, disk space |
-| Chat not connecting | `curl http://: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:///hls/playlist.m3u8` | Check playout health, HLS dir permissions |
-| Prometheus no targets | `curl http://:9090/targets` | Verify container network, scrape config |
-| Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned |
-
-- Proxy-layer body size limits returning 413 on upload.
-- Health checks failing before service warmup is complete.
+Additional notes: proxy-layer body size limits can return 413 on upload; health checks may fail before service warmup completes.
\ No newline at end of file
diff --git a/docs/MONITORING.md b/docs/MONITORING.md
index 704d8f0..2f1d536 100644
--- a/docs/MONITORING.md
+++ b/docs/MONITORING.md
@@ -52,7 +52,7 @@ Config: `monitoring/prometheus/prometheus.yml` (15s scrape interval)
- **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
+## Metrics Checking
See detailed scripts for querying Prometheus scrape targets:
@@ -78,21 +78,13 @@ See detailed scripts for querying Prometheus scrape targets:
| Prometheus targets down | Check container network: all must be on `linearvoid-net` |
| Alert not firing | Check Prometheus → Alerts tab, verify `rule_files` in prometheus.yml |
-### Grafana shows no data
+**Grafana no data**: ensure datasource points to `http://prometheus:9090`; confirm Prometheus is healthy; check dashboard query labels match active jobs.
-- Ensure datasource points to .
-- Confirm Prometheus is healthy.
-- Check dashboard query labels match active jobs.
-
-### High cardinality or query slowness
-
-- Reduce unneeded labels.
-- Limit expensive dashboard panels.
-- Tune retention and query windows.
+**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.
-- Metrics retention is set to **30 days** time-based plus a **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.
+- 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.
diff --git a/docs/README.md b/docs/README.md
index c870ac8..8191c66 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,55 +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.
+# 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.
diff --git a/docs/SETUP.md b/docs/SETUP.md
index a52518e..cb8c7eb 100644
--- a/docs/SETUP.md
+++ b/docs/SETUP.md
@@ -2,572 +2,149 @@
**Last Updated**: 2026-08-03
-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, environment setup, and running the app locally and in production. Architecture: [`docs/architecture/README.md`](./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 22+**: 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.25+**: 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:@db:5432/linearvoid?sslmode=disable`)
-- **Hetzner Cloud Account** (optional for production): For the US-East VPS (Ashburn, VA recommended per ADR-001).
+- **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
-Linear Void operates in two distinct environments. Know which one you're targeting.
+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 Docker Engine :2375"]
end
-
subgraph Prod["Production (Hetzner — NOT provisioned)"]
PROD_HOST["Hetzner CX22 Ashburn, VA 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
+ 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 network `192.168.88.0/24`, host `192.168.88.40`. Receives automated CD deploys from Gitea Actions. Used for integration testing and staging validation.
-
-**Production (pending):** Hetzner Cloud VPS, Ashburn VA. Not yet provisioned. CD workflow and compose files are ready — only `DOCKER_HOST` needs changing. See Production Deployment section in [DEPLOYMENT.md](./DEPLOYMENT.md) for cutover plan.
-
-## Development Prerequisites
-
-- **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.
+- **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. **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`** (copy from `.env.example` and replace placeholders)
-
-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:@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).
+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:@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. **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:@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.
+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 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://.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 `.m4s` and `.m3u8` cache rules from [cache-rules](../docs/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. **Configure R2 CORS**:
- - Add CORS rules to allow the app domain to access the R2 bucket for HLS delivery.
- - Example CORS configuration:
-
- ```json
- [
- {
- "AllowedOrigins": ["https://linearvoid.tv"],
- "AllowedMethods": ["GET"],
- "AllowedHeaders": ["*"],
- "ExposeHeaders": ["ETag", "Cache-Control"],
- "MaxAgeSeconds": 3600
- }
- ]
- ```
-
-8. **Verify access**:
- - Confirm uploads with an S3-compatible client such as `aws s3 ls --endpoint-url `.
- - After deployment, check Cloudflare analytics for cache hit ratio and origin request volume.
-
-## Cloudflare
-
-Use this section for full edge setup (DNS, Workers, cache, security, and monitoring).
-
-Execution tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`.
-
-### 1. DNS Setup
-
-1. Add apex record for `linearvoid.tv` pointing to the Cloudflare Worker.
-2. Add `www` CNAME to the Cloudflare Worker and configure redirect to `https://linearvoid.tv`.
-3. Add `media.linearvoid.tv` for split-domain HLS delivery.
-4. Keep all records proxied (orange cloud) unless troubleshooting requires DNS-only mode.
-
-### 2. R2 Bucket
-
-1. Create bucket `linearvoid-media`.
-2. Set location hint to `enam` (US-East).
-3. Attach custom domain `media.linearvoid.tv` to the bucket.
-4. Configure CORS rules to allow `https://linearvoid.tv` for HLS delivery.
-5. Verify object read works from the custom domain.
-
-### 3. Cache Rules
-
-Configure Cloudflare Cache Rules:
-
-1. HLS segment files (`*.ts`, `*.m4s`):
- - Cache: eligible
- - Edge TTL: `31536000`
- - Browser TTL: long-lived
- - Cache key: include full path
-2. HLS manifests (`*.m3u8`):
- - Cache: eligible
- - Edge TTL: `2s`
- - Revalidation: enabled
-3. Static assets (`*.js`, `*.css`):
- - Cache: eligible
- - Edge TTL: `1y`
- - Browser TTL: long-lived
- - Cache key: include full path
-4. Validate behavior using response headers:
- - `Cf-Cache-Status` should move from `MISS` to `HIT` for hot objects
-
-Reference: [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
-
-### 4. Cloudflare Workers
-
-Also see [the automated Gitea Actions deployment guide](./operations/gitea-cloudflare-workers.md).
-
-Manual setup:
-
-1. Create a Cloudflare Worker for the static SPA (Workers & Pages → Create → Worker).
-2. Build the frontend:
-
- ```bash
- cd frontend && npm run build
- ```
-
-3. Bind the build output (`frontend/dist`) as Worker static assets.
-4. Configure production domain `linearvoid.tv` on the Worker.
-
-### 5. Tiered Cache
-
-1. Enable Smart Tiered Cache in Cloudflare dashboard.
-2. Warm a segment and manifest path.
-3. Confirm tiered behavior through repeated requests and `Cf-Cache-Status: HIT`.
-
-### 6. WAF and Bot Protection
-
-1. Enable OWASP Managed Ruleset.
-2. Start in `Simulate` mode.
-3. Review events for 48 hours.
-4. Switch to `Block` once false positives are addressed.
-5. Enable Bot Fight Mode.
-
-### 7. Rate Limiting
-
-Create rules:
-
-1. Path: `/api/admin/login`
-2. Path: `/api/admin/*`
-3. Threshold: `10 requests / 10 seconds / IP`
-4. Action: `Managed Challenge`
-
-### 8. HTTPS and WSS
-
-1. Enable `Always Use HTTPS`.
-2. Enable HSTS with:
- - `max-age=31536000`
- - `includeSubDomains`
- - `preload`
-3. Minimum TLS version: `1.2`.
-4. Keep SSL mode at `Full (strict)` when origin certs are valid.
-5. Enforce HTTPS redirect at both layers:
- - Cloudflare edge
- - nginx origin
-6. Validate websocket endpoint uses `wss://linearvoid.tv/ws`.
-
-### 9. UptimeRobot Monitors
-
-Create external monitors:
-
-1. HTTP monitor:
- - URL: `https://linearvoid.tv/health`
- - Interval: 5 minutes
-2. Keyword monitor:
- - URL: `https://linearvoid.tv/hls/playlist.m3u8`
- - Keyword: `#EXTM3U`
- - Interval: 5 minutes
-3. Alert policy:
- - Trigger on 2 consecutive failures
- - Notify via email and Discord/Slack webhook
-
-### 10. Verification Checklist
-
-1. DNS resolves for `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv`.
-2. Workers serve frontend for apex and `www` redirects correctly.
-3. HLS segments are cacheable long-term, manifests refresh every ~2s.
-4. Admin paths are rate-limited and WAF events are visible.
-5. HTTPS and WSS both succeed with valid certificates.
-6. UptimeRobot alerts fire as expected when endpoints are intentionally made unavailable.
+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://.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; optionally deploy `infrastructure/cloudflare/worker.js` for header normalization.
+6. Verify: `aws s3 ls --endpoint-url $R2_ENDPOINT`; check cache hit ratio after serving traffic.
## Running the Application
-### Local Development
+### Local development
-1. **Start All Services**:
+```bash
+docker compose up --build # start all services
+docker compose down # stop
+```
- ```bash
- docker compose up --build
- ```
+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`).
-2. **Access the Application**:
- - Preferred local access with the documented proxy setup:
- - Admin Interface: (redirects to `/admin/login/` until authenticated)
- - Chat WebSocket: `wss://linearvoid.tv/ws`
- - Direct-local fallback when bypassing Cloudflare/Nginx Proxy Manager: , , and `ws://localhost/ws`
- - Health Checks:
- - Playout:
- - Chat:
- - Monitoring:
- - Prometheus:
- - Grafana: (default login: 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`).
-3. **Frontend Development** (optional, for UI changes):
+### Test lab (remote Docker host)
- ```bash
- cd frontend
- npm install
- npm run dev
- ```
+Deploy to `192.168.88.40` over Docker's TCP socket (port `2375`).
- The dev server runs on . It proxies API calls to the backend services.
+Enable TCP on Alpine host — edit `/etc/conf.d/docker`:
-4. **Build frontend assets for nginx-backed local runs**:
+```bash
+DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"
+```
- ```bash
- cd frontend
- npm install
- npm run build
- ```
+Then `rc-service docker restart`; verify with `netstat -tlnp | grep 2375`; allow port `2375` from `192.168.88.0/24` in the firewall.
- The local nginx container serves `frontend/dist`, so rebuilding the frontend is required before validating production-like static output.
+Deploy from a machine with the Docker CLI:
-5. **Stop Services**:
+```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
+```
- ```bash
- docker compose down
- ```
+Health: `http://192.168.88.40:8081/health` (playout), `:3000/health` (chat), nginx entrypoint `:8080`.
-### Local Test Environment Deployment (Remote Docker Host)
+### Coolify deployment
-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.
+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).
-#### Test Environment Prerequisites
+### Production setup
-- 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.
+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.
-##### Enabling Docker TCP Socket on Alpine Linux
+### Production (Hetzner VPS + Cloudflare)
-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. Provision CX22 in Ashburn, VA (ADR-001).
+2. Cloudflare: R2 bucket (`enam` hint), `media.linearvoid.tv` custom domain + cache rules, `linearvoid.tv`/`www` at the app entrypoint with `www`→apex redirect, Full (strict) TLS, deploy `infrastructure/cloudflare/worker.js`.
+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/`.
-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
- ```
-
-### Coolify Deployment
-
-Coolify is an open-source, self-hostable PaaS (Heroku/Netlify/Vercel
-alternative) that can deploy the Linear Void stack through a web UI with
-automatic HTTPS and one-click redeploys. It replaces the deployment
-orchestration layer (the Gitea CD `DOCKER_HOST` flow) while leaving Cloudflare
-DNS, R2 media, and cache rules unchanged.
-
-To deploy via Coolify:
-
-1. **Install Coolify** on a dedicated server:
-
- ```bash
- curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
- ```
-
-2. **Add the target server** in the dashboard via SSH (or use Localhost if
- Coolify runs on the same machine as the stack).
-3. **Create a Docker Compose resource** pointing at either the Git repository
- (`docker-compose.prod.yml`) or the compose file contents directly.
-4. **Configure environment variables** in Coolify's secret store (the values
- normally held in `.env`), keeping internal URLs on compose hostnames
- (`redis`, `db`).
-5. **Map persistent storage** for `data/` mounts and the `redis-data` /
- `postgres-data` named volumes so data survives redeploys.
-6. **Assign a domain** to the nginx service so Coolify's Traefik proxy
- terminates TLS for `linearvoid.tv`, while `media.linearvoid.tv` continues to
- serve HLS from Cloudflare R2.
-
-For the full walkthrough, environment-variable table, verification steps, and
-cutover notes, see [COOLIFY.md](./COOLIFY.md).
-
-### 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 [cache-rules](../docs/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.
+See `docs/architecture/README.md` (arc42) and the ADRs for decisions and cost/scaling guidance (ADR-001).
## 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 (`.m4s` = 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.
+- **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` (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.
+Architecture: `docs/architecture/README.md` · Decisions: `docs/architecture/adr/` (ADR-001…007) · Contributing: `CONTRIBUTING.md` · Overview: root `README.md`.
diff --git a/docs/STREAMING.md b/docs/STREAMING.md
index 3e364f8..64adff1 100644
--- a/docs/STREAMING.md
+++ b/docs/STREAMING.md
@@ -1,6 +1,6 @@
# Streaming Pipeline
-This document is the operational contract for Linear Void video ingest, playout, publication, caching, and browser playback. General deployment, chat, and admin details remain in `DEPLOYMENT.md`, `SETUP.md`, and `API.md`.
+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
@@ -20,7 +20,7 @@ The playout service is the timeline authority. It reads the ordered playlist, ru
https://media.linearvoid.tv/hls/playlist.m3u8
```
-Nginx `/hls/` is a local/test fallback, not the production media origin. The SPA, admin API, and chat use `linearvoid.tv`; media uses `media.linearvoid.tv`.
+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
@@ -30,12 +30,12 @@ Only validated files may enter the production playlist.
| ---------- | ------------------------------------------------------------------------ |
| Container | MP4 |
| Video | H.264/AVC, `yuv420p` |
-| Resolution | 1280x720 or 1920x1080; avoid changing resolution within a playlist block |
+| 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 incorrect keyframe cadence. Assets are immutable after acceptance; corrections create a new asset ID and object key.
+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:
@@ -51,124 +51,111 @@ ffmpeg -i input.mov \
## Playlist and timeline
-- `data/playlist/playlist.txt` contains one local media path per line; blank lines and `#` comments are ignored.
-- The service validates the complete playlist before activating a revision.
-- Playlist edits are written atomically and receive 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 do not calculate the schedule independently.
-- Schedule metadata must be derived from the same active playlist revision and timeline so “now/next” agrees with the video.
-- When the last item ends, playout returns to the first item unless an active schedule override says otherwise.
+- `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 and playlist revision, restarts failed processes, and exposes `/health` plus Prometheus metrics.
+The playout service wraps FFmpeg, tracks the active item/revision, restarts failed processes, and exposes `/health` plus Prometheus metrics.
-Baseline packaging:
+| 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 |
-| Setting | Value |
-| ---------------- | ------------------------------------------------------------------------------------- |
-| Protocol | Standard live HLS, not LL-HLS |
-| Segment format | fMP4 (`.m4s`) with an initialization segment; `.ts` may be supported during migration |
-| Segment target | 2 seconds (`HLS_SEGMENT_DURATION=2`) |
-| Live window | 12 segments (`HLS_WINDOW_SIZE=12`), about 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 boundaries must align with keyframes. Timestamps must remain monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new initialization 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.
-**Segment duration rationale**: 2s segments were chosen to lower end-to-end latency and keep the live window small (12 × 2s = 24s) while staying well above the manifest TTL (1–2s). The 2s GOP (30fps, `-g 60 -keyint_min 60`) already matches the target, so segments cut cleanly on keyframes. Longer durations (4–6s) increase latency and window depth; shorter than 2s inflates segment/request overhead and R2 Class B operations with little latency gain.
-
-Every segment name is globally unique and never reused, including after a restart or playlist loop. Use a run/session ID plus a monotonically increasing media sequence, for example:
+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
```
-This naming rule is essential: a URL advertised as immutable must never later contain different bytes.
+A URL advertised as immutable must never later contain different bytes.
## Safe publication to R2
-Publication order prevents a manifest from referencing an unavailable segment:
+Order prevents a manifest referencing an unavailable segment:
-1. FFmpeg writes a segment to a temporary local name and closes it.
-2. The publisher uploads the initialization segment, if new, and media segment to their final immutable R2 keys.
-3. It verifies successful upload and records object size/checksum.
-4. It renders the next media manifest referencing only confirmed objects.
-5. It uploads the manifest to a temporary key, then replaces `hls/playlist.m3u8` as the final commit step.
+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 instance, or fencing/leader election, so two playout workers cannot update the live manifest concurrently.
+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.
-The local `data/hls/` directory is staging data and may be regenerated. Source media, playlist revisions, and schedule state are authoritative; generated HLS is not backed up as application state.
+`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 2 seconds | 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` |
+| 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:
-- Do not add query-string cache busters to segment URLs; uniqueness belongs in the path.
-- Cache keys must preserve hostname and full path. Ignore only explicitly approved tracking parameters.
-- HLS responses require `Access-Control-Allow-Origin: https://linearvoid.tv` (or the reviewed origin allowlist), `GET`, `HEAD`, and `OPTIONS` support, and correct MIME types.
-- Enable Smart Tiered Cache for `media.linearvoid.tv` to reduce R2 reads on regional misses.
-- Purge manifests when necessary, but never purge immutable segments during normal operation.
-- Retain expired-window segments in R2 for at least the maximum manifest TTL plus client retry/grace time. A practical minimum is one hour; lifecycle deletion may run later according to storage policy.
+- 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.
-Expected validation:
+Validation:
```bash
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
curl -I https://media.linearvoid.tv/hls/.m4s
```
-The manifest must refresh within a few seconds. Repeated segment requests should progress from `CF-Cache-Status: MISS` to `HIT`, retain the same ETag/content length, and carry the immutable cache header.
+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
-The SPA uses native HLS where supported and hls.js elsewhere.
-
-- Firefox has no native HLS, so it uses the hls.js/MSE path; fMP4 (`.m4s` + init segment) is supported. Autoplay requires `muted` + `playsinline`, which are already set on the `` element.
+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 `` element.
- Start at the live edge with a conservative buffer; do not seek to playlist start.
-- Treat the manifest as the synchronization source and target roughly three segments behind the newest segment unless testing supports a smaller margin.
-- Recover transient network/media errors with bounded retries and exponential backoff.
-- On a fatal hls.js network error, reload the manifest; on a fatal media error, attempt media recovery once before recreating the player.
-- When tab suspension or drift leaves playback materially behind the target live position, seek forward to the target rather than playing the backlog.
-- Surface a clear reconnecting state. Never silently loop stale media.
-- Schedule/chat timestamps should account for the measured player latency rather than assuming wall-clock playout is already visible.
+- 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 a bounded backoff. After the configured failure threshold it switches to a pre-normalized looping slate and reports `mode=slate`. It periodically retries normal playout and returns only after a segment and committed manifest have been published successfully.
+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 or host restart:
+On process/host restart:
-- create a new run/session ID and continue with globally unique segment paths;
-- resume from the schedule-derived current position when reliable state exists, otherwise start the current playlist item;
+- 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 stream is playable;
-- emit a discontinuity when timeline continuity cannot be guaranteed.
+- keep the last valid manifest available until the replacement is playable;
+- emit a discontinuity when timeline continuity isn't guaranteed.
-Health is not merely “process running.” `/health` should report at least mode (`live`, `slate`, `starting`, `degraded`), active item, playlist revision, run ID, last segment sequence/time, last successful R2 publication, consecutive failures, and uptime. Mark the service unhealthy if the manifest or media sequence has not advanced for more than two segment durations.
+`/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 checks
+## Monitoring and acceptance
-Monitor and alert on:
+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.
-- manifest age and media-sequence advancement;
-- time since last successfully published segment;
-- FFmpeg restarts and consecutive item failures;
-- live/slate mode;
-- R2 upload latency and errors;
-- segment 404/5xx rate and manifest/segment request latency;
-- Cloudflare cache-hit ratio, origin requests, and R2 Class B operations;
-- player startup time, fatal errors, rebuffer ratio, and live-edge drift;
-- local staging disk usage.
-
-A release is streaming-ready only when one full playlist boundary has played without timestamp errors, the manifest never references a missing object, a second viewer joins the same timeline, cache headers match the table above, a forced FFmpeg failure activates the slate, and normal playout recovers without reusing an object URL.
+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
@@ -179,14 +166,14 @@ A release is streaming-ready only when one full playlist boundary has played wit
| `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, available only to the publisher |
-| `R2_KEEP_RUNS` | Number of run folders to retain (default `2`) |
-| `R2_MAX_RETENTION_BYTES` | Max bytes retained across run folders (10 GiB) |
+| `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 are `MEDIA_DOMAIN=media.linearvoid.tv` and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`. Local fallback may use `http://localhost/hls/playlist.m3u8`.
+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
-Adaptive-bitrate ladders, LL-HLS, DVR/catch-up, multi-origin playout, and regional chat affinity are outside the v1 contract. Adding any of them requires an ADR and an update to this document.
+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.
\ No newline at end of file
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 85ae029..59ae26c 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -1,159 +1,54 @@
# 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
-
-- [Architecture Documentation for Linear Void](#architecture-documentation-for-linear-void)
- - [Table of Contents](#table-of-contents)
- - [Companion Documentation](#companion-documentation)
- - [Introduction and Goals](#introduction-and-goals)
- - [Requirements Overview](#requirements-overview)
- - [Quality Goals](#quality-goals)
- - [Stakeholders](#stakeholders)
- - [Architecture Constraints](#architecture-constraints)
- - [Context and Scope](#context-and-scope)
- - [Business Context](#business-context)
- - [High-Level Context Diagram (Mermaid)](#high-level-context-diagram-mermaid)
- - [Technical Context](#technical-context)
- - [Solution Strategy](#solution-strategy)
- - [Building Block View](#building-block-view)
- - [Whitebox Overall System](#whitebox-overall-system)
- - [Level 2](#level-2)
- - [Playout Service (`backend/playout/src/playlist.go`)](#playout-service-backendplayoutsrcplaylistgo)
- - [Chat Service (`backend/chat/src/main.go`)](#chat-service-backendchatsrcmaingo)
- - [Frontend SPA (`frontend/`)](#frontend-spa-frontend)
- - [Reverse Proxy (`infrastructure/nginx/nginx.conf`)](#reverse-proxy-infrastructurenginxnginxconf)
- - [Level 3](#level-3)
- - [Chat Service Modules](#chat-service-modules)
- - [Playout Service Modules](#playout-service-modules)
- - [Frontend Modules](#frontend-modules)
- - [Runtime View](#runtime-view)
- - [Playout \& Delivery Scenario](#playout--delivery-scenario)
- - [Deployment View](#deployment-view)
- - [CI/CD Pipeline Architecture](#cicd-pipeline-architecture)
- - [Infrastructure Level 1](#infrastructure-level-1)
- - [Infrastructure Level 2](#infrastructure-level-2)
- - [Cross-cutting Concepts](#cross-cutting-concepts)
- - [Caching \& Cost Optimization](#caching--cost-optimization)
- - [Video Normalization \& Playout](#video-normalization--playout)
- - [Required Source Profile (Linear Void v1)](#required-source-profile-linear-void-v1)
- - [Reference Normalization Command](#reference-normalization-command)
- - [Automatic Normalization \& Verification (ffprobe)](#automatic-normalization--verification-ffprobe)
- - [Runtime Configuration Store](#runtime-configuration-store)
- - [Analytics (Matomo)](#analytics-matomo)
- - [Monitoring \& Resilience](#monitoring--resilience)
- - [Prometheus Baseline](#prometheus-baseline)
- - [Grafana Dashboards](#grafana-dashboards)
- - [Alerting Rules (Prometheus Rule Groups)](#alerting-rules-prometheus-rule-groups)
- - [API Documentation Reference](#api-documentation-reference)
- - [Chat WebSocket API](#chat-websocket-api)
- - [Playout and Delivery REST API](#playout-and-delivery-rest-api)
- - [Admin REST API](#admin-rest-api)
- - [Viewer REST API](#viewer-rest-api)
- - [Cloudflare Configuration Reference](#cloudflare-configuration-reference)
- - [DNS and Zone Routing](#dns-and-zone-routing)
- - [Cache Rules Baseline](#cache-rules-baseline)
- - [Edge Security and Controls](#edge-security-and-controls)
- - [Worker and Header Policy](#worker-and-header-policy)
- - [Operations and Validation](#operations-and-validation)
- - [Security \& Moderation](#security--moderation)
- - [Transport and Edge Protection](#transport-and-edge-protection)
- - [Reverse Proxy Enforcement (nginx)](#reverse-proxy-enforcement-nginx)
- - [Admin Authentication and Session Security](#admin-authentication-and-session-security)
- - [Viewer User Accounts](#viewer-user-accounts)
- - [Rate Limiting and Abuse Controls](#rate-limiting-and-abuse-controls)
- - [CORS and Cross-Origin Behavior](#cors-and-cross-origin-behavior)
- - [Moderation and Privacy](#moderation-and-privacy)
- - [Chat](#chat)
- - [Architecture Decisions](#architecture-decisions)
- - [Quality Requirements](#quality-requirements)
- - [Performance Benchmarks and Capacity Planning](#performance-benchmarks-and-capacity-planning)
- - [Baseline SLO Targets (v1)](#baseline-slo-targets-v1)
- - [Workload Assumptions](#workload-assumptions)
- - [Initial Capacity Model](#initial-capacity-model)
- - [Benchmark Plan](#benchmark-plan)
- - [Capacity Triggers and Actions](#capacity-triggers-and-actions)
- - [Scaling Strategy](#scaling-strategy)
- - [Phase 1: Vertical + Cache-First (Current Baseline)](#phase-1-vertical--cache-first-current-baseline)
- - [Phase 2: Service Separation](#phase-2-service-separation)
- - [Phase 3: Multi-Origin Resilience](#phase-3-multi-origin-resilience)
- - [Phase 4: Regional and Throughput Expansion](#phase-4-regional-and-throughput-expansion)
- - [Scaling Triggers](#scaling-triggers)
- - [Disaster Recovery and Backup Procedures](#disaster-recovery-and-backup-procedures)
- - [Recovery Targets](#recovery-targets)
- - [Backup Scope](#backup-scope)
- - [Backup Cadence](#backup-cadence)
- - [Recovery Runbook (Minimum)](#recovery-runbook-minimum)
- - [Failure Modes and Strategy](#failure-modes-and-strategy)
- - [Risks and Technical Debts](#risks-and-technical-debts)
- - [Glossary](#glossary)
-
-## Companion Documentation
-
-see [Documentation README](../README.md) for an overview of all documentation, including architecture, ADRs, operational procedures, and setup guides.
+> Companion docs: see [Documentation README](../README.md) and `adr/` for full decision rationale, alternatives, and consequences.
## Introduction and Goals
-### Requirements Overview
-
-Linear Void is an independent **24/7 linear video channel** broadcasting 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.).
+**Requirements Overview** — Linear Void is an independent **24/7 linear video channel** broadcasting self-owned pre-recorded videos to a primarily US audience via a custom lightweight HTML/CSS/JS SPA with synchronized HLS playback, schedule metadata, public chat rooms, and private 1:1 messaging. Fully independent of third-party video platforms (YouTube, Twitch, etc.).
**Core Requirements** (merged from all ADRs):
-- Continuous synchronized 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.
- 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.
-- Storage planning for 48h of 720p30/1080p30 content.
+- Storage for 48h of 720p30/1080p30 content.
- Browser-compatible playback (`hls.js` + native HLS).
- 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 |
-| -------- | ------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
-| 1 | **Cost Efficiency** | <$25/month for moderate scale (100s concurrent) | R2 $0 egress + immutable `.m4s` and `.ts` edge caching eliminates per-viewer bandwidth cost |
-| 2 | **Performance / Latency** | Low US latency; acceptable HLS delay | Hetzner Ashburn origin + Cloudflare US POPs (15–35s end-to-end) |
-| 3 | **Availability** | 24/7 with minimal downtime | FFmpeg/MediaMTX auto-restart loop, fallback slate, monitoring |
-| 4 | **Scalability** | Grow from 10s to 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 |
-| 6 | **Security / Usability** | Secure transport, moderation tools, good UX | HTTPS/WSS, Cloudflare WAF, rate limits, intuitive chat/player |
+| # | Attribute | Goal | Scenario |
+| --- | ------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------ |
+| 1 | Cost Efficiency | <$25/month at moderate scale (100s concurrent) | R2 $0 egress + immutable `.m4s`/`.ts` edge caching removes per-viewer bandwidth cost |
+| 2 | Performance/Latency | Low US latency; acceptable HLS delay | Hetzner Ashburn origin + Cloudflare US POPs (15–35s end-to-end) |
+| 3 | Availability | 24/7 with minimal downtime | FFmpeg/MediaMTX auto-restart, fallback slate, monitoring |
+| 4 | Scalability | Grow 10s→1000s viewers without proportional cost | Edge caching + R2; only request volume limits |
+| 5 | Maintainability | Lightweight, clear configs, low ops | Static SPA, Go/Node chat, SQLite/PostgreSQL+Redis |
+| 6 | Security/Usability | Secure transport, moderation, good UX | HTTPS/WSS, Cloudflare WAF, rate limits, intuitive chat/player |
-### Stakeholders
-
-| 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.)
+**Stakeholders**: Product Owner, Engineering Team, US Viewers, Moderators/Admins, Operations — all expecting a cost-effective, reliable, low-latency, low-TCO independent channel with rich chat and easy management.
## Architecture Constraints
-- Independence from video hosting platforms (no YouTube, Twitch, paid Cloudflare Stream).
+- No YouTube/Twitch/paid Cloudflare Stream dependency.
- 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).
-- Cloudflare ToS compliance for video delivery (use R2 as origin for large immutable files).
-- HLS as primary protocol (standard, not LL-HLS initially).
-- Self-hosted playlist normalization and continuous playout required.
-
-(See `adr/ADR-001.md` for full forces and trade-offs.)
+- Cloudflare ToS compliance for video (R2 as origin for large immutable files).
+- HLS primary (standard, not LL-HLS initially).
+- Self-hosted playlist normalization and continuous playout.
## Context and Scope
-### Business Context
+**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 (Mermaid)
+**High-Level Context Diagram**
```mermaid
flowchart TD
@@ -185,48 +80,32 @@ flowchart TD
CDN -->|Origin Pull on Miss| R2
```
-**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 `.m4s` and `.ts` vs `.m3u8`), R2 (object storage, $0 egress), Workers (static hosting), WAF/TLS.
-- **Hetzner VPS**: Compute for playout and chat (low-cost US-East with good peering).
-- **Browsers**: Native HLS (`` + 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
+**Technical Context**
- **Playout**: FFmpeg consuming normalized playlist → continuous HLS.
-- **Packaging/Delivery**: MediaMTX/SRS/Nginx → immutable `.m4s` and `.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.
-- **Chat**: Lightweight WebSocket service (Node/Go) + Redis (presence/rate limits) + PostgreSQL/SQLite (history/moderation).
-- **Normalization**: All source videos must be H.264/AAC, 30 fps constant, 2s keyframes.
+- **Chat**: Lightweight WebSocket service + Redis (presence/rate limits) + PostgreSQL/SQLite (history/moderation).
+- **Normalization**: All sources H.264/AAC, constant 30 fps, 2s keyframes.
-**Mapping**:
-
-- Input: Normalized MP4s in R2 + playlist.
-- Output: Live `master.m3u8` + segments served via Cloudflare; chat via WSS.
+**Mapping**: Input = normalized MP4s in R2 + playlist; Output = live `master.m3u8` + segments via Cloudflare, chat via WSS.
## 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).
-2. **Immutable HLS to Cloudflare R2**: Segments use unique names; R2 acts as origin with $0 egress.
-3. **Aggressive Edge Caching**: Long TTL for `.m4s` and `.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.
+**Accepted Trade-off**: standard HLS latency (15–35s) acceptable for linear programming/chat; LL-HLS/WebRTC deferred.
## Building Block View
-### Whitebox Overall System
-
-Overview (Mermaid diagram):
+**Whitebox Overall System**
```mermaid
graph TD
@@ -251,23 +130,13 @@ graph TD
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).
-- **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.
-**Important Interfaces**: HTTP(S) for HLS, WSS for chat, S3-compatible for R2 uploads.
-
-Detailed black-box descriptions, level-2/3 views, and code locations are in `adr/ADR-001.md` and future implementation ADRs.
-
-### Level 2
-
-Detailed component diagram with code locations and persistence.
+**Level 2 — components with code locations and persistence**
```mermaid
graph TB
@@ -303,109 +172,97 @@ graph TB
PLAYOUT -->|reads| R2
```
-#### Playout Service (`backend/playout/src/playlist.go`)
+**Playout Service** (`backend/playout/src/playlist.go`)
-- **Role**: Continuous HLS encoding loop using FFmpeg concat demuxer
-- **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths
-- **Output**: HLS segments (`.m4s` and `.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2
-- **Resilience**: Restart counter (>5/60s → activate slate), Prometheus metrics on port 8081
-- **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval); `-validate` mode checks the playlist without running the loop
-- **Seamless looping**: Only resets HLS output when the playlist fingerprint (SHA-256) changes, on crash/slate recovery, or first run; otherwise continues segment numbering via `-start_number`
-- **R2 retention**: `cleanupOldRuns` keeps the newest `R2_KEEP_RUNS` run folders (default 2) and caps retained data at `R2_MAX_RETENTION_BYTES` (default 10 GiB), triggered after each run plus an hourly backstop
-- **Shared config**: Reads the runtime `config.json` (`loadSharedHLSConfig`) as an env fallback for HLS segment duration/window
+- 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.
+- 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`)
+**Chat Service** (`backend/chat/src/main.go`)
-- **Role**: WebSocket chat with rooms, DMs, moderation, admin API
-- **Persistence**: PostgreSQL 15 via GORM (messages, moderation logs, media metadata). SQLite for dev fallback
-- **Redis**: Presence tracking, rate limiting, pub/sub for multi-instance fan-out
-- **Auth**: Session-cookie admin auth with Redis-backed expiry
-- **Metrics**: Connected clients, stored messages, DMs, moderation actions on `/metrics`
+- 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/`)
+**Frontend SPA** (`frontend/`)
-- **Viewer**: `src/main.ts` — hls.js player, WebSocket chat, presence indicators, schedule overlay
-- **Admin**: `src/admin.ts` — 9 dynamic tabs (dashboard, upload, playlist, schedule, moderation, chat-log, users, analytics, system)
-- **Build**: Vite → static output in `frontend/dist/`
+- 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`)
+**Reverse Proxy** (`infrastructure/nginx/nginx.conf`)
-- auth_request gate on `/admin` and `/api/admin`
-- WebSocket upgrade for `/ws`
-- HLS CORS headers + OPTIONS preflight
-- `/health` JSON endpoint, `/nginx-status` stub_status
+- `auth_request` gate on `/admin` and `/api/admin`; WS upgrade for `/ws`; HLS CORS + OPTIONS preflight; `/health` JSON + `/nginx-status` stub_status.
-### Level 3
+**Level 3 — Chat Service Modules**
-Module-level breakdown per service.
+| 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 |
-#### Chat Service Modules
+**Level 3 — Playout Service Modules**
-| Module | File/Lines | Responsibility |
-| ---------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
-| **Connection Manager** | `main.go` (hub, clients map) | Track active WebSocket connections per room, fan-out messages |
-| **Message Parser** | `main.go` (readPump, handleMessage) | Parse JSON envelope `{type, room?, ...}`, validate fields, route to handler |
-| **Room Registry** | `main.go` (rooms map, room broadcasts) | Map room name → client set, handle join/leave/close |
-| **DM Router** | `main.go` (handleDirectMessage) | Resolve target user in clients map, deliver private message |
-| **Persistence Layer** | `main.go` (GORM models: Message, DirectMessage, ModerationLog) | Store/retrieve chat messages, DMs, moderation events |
-| **Redis Subsystem** | `main.go` (redisClient, rate limiting, pub/sub, presence) | INCR+EXPIRE rate limit, SETEX presence, PSUBSCRIBE room fan-out |
-| **Auth Handler** | `main.go` (handleAdminLogin, adminAuthMiddleware) | Session cookie creation/validation, login rate limiting |
-| **User Auth Handler** | `handlers/handlers.go` (HandleUserRegister/Login/Session/Logout) | Viewer register/login/logout/session (bcrypt, user session cookie) |
-| **DM History/Search** | `handlers/handlers.go` (HandleUserDMHistory, HandleUserDMSearch) | Paginated DM history + search for authenticated users |
-| **Runtime Config** | `config/runtime.go` (GetRuntime, SetRuntime, typed setters) | Thread-safe JSON runtime config (HLS duration/window, Matomo) |
-| **Admin API Router** | `main.go` (handleAdmin\*) | Upload, playlist CRUD, schedule overrides, moderation actions, analytics, system summary |
-| **Metrics Exporter** | `main.go` (prometheus.MustRegister) | Expose connected_clients, messages_total, dms_total, moderation_actions, redis_up |
+| 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 |
-#### Playout Service Modules
+**Level 3 — Frontend Modules**
-| Module | File/Lines | Responsibility |
-| -------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
-| **Playlist Parser** | `playlist.go` (parsePlaylist) | Read `playlist.txt`, skip comments/blank lines, resolve file paths |
-| **FFmpeg Manager** | `playlist.go` (runPlayout, restart logic) | Spawn/monitor FFmpeg concat process, restart on crash |
-| **Loop Controller** | `playlist.go` (playlistFingerprint, hlsNextStartNumber) | Detect playlist changes (SHA-256), continue segment numbering on clean loops |
-| **Slate Controller** | `playlist.go` (activateSlate, deactivateSlate) | Switch output to slate loop on excessive failures |
-| **R2 Sync** | `playlist.go` (syncToR2) | Upload HLS segments + manifest to R2 bucket |
-| **R2 Cleanup** | `playlist.go` (cleanupOldRuns) | Keep the last N run folders under the size cap; delete older `hls/r-*/*` objects |
-| **Health Endpoint** | `playlist.go` (healthHandler) | `/health` → JSON with mode, segment count, uptime |
-| **Metrics Exporter** | `playlist.go` (prometheus) | Expose restarts_total, mode (live/slate), loops_total, run_duration_seconds, r2_cleanup_objects_total |
-
-#### Frontend Modules
-
-| Module | File | Responsibility |
-| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
-| **HLS Player** | `src/main.ts` | hls.js initialization, attach to video element, error recovery |
-| **WebSocket Client** | `src/main.ts` | Connect/disconnect, parse envelope, dispatch to UI handlers |
-| **Chat UI** | `src/main.ts` | Message list rendering, room tabs, DM panel, presence dots, typing indicator, user-list toggle |
-| **DM Search** | `src/main.ts` | Search DM history (`/api/dm/search`) and render results |
-| **Auth UI** | `src/main.ts` + `index.html` | Viewer login/register/logout, session refresh, user label |
-| **Schedule Overlay** | `src/main.ts` | Poll `/api/schedule`, render current/next program |
-| **Admin Router** | `src/admin.ts` | Tab-based navigation, dynamic page loading |
-| **Upload Manager** | `src/admin.ts` | Multipart upload with chunk progress, normalization status badges |
-| **Playlist Editor** | `src/admin.ts` | Drag-reorder, add/remove items (gated to `ready` media), save to `/api/admin/playlist` |
-| **Moderation Panel** | `src/admin.ts` + `public/admin/moderation.html` | Inline mute/ban, action log viewer |
-| **Analytics Dashboard** | `src/admin.ts` + `public/admin/analytics.html` | Message counts, top rooms, moderation stats |
-| **System Config** | `src/admin.ts` + `public/admin/system.html` | Service health, runtime config form (HLS duration/window, Matomo), restart buttons |
+| Module | File | Responsibility |
+| ------------------- | ----------------------------------------------- | ------------------------------------------------------------------- |
+| HLS Player | `src/main.ts` | hls.js init, attach, error recovery |
+| WebSocket Client | `src/main.ts` | Connect/parse envelope/dispatch |
+| Chat UI | `src/main.ts` | Message list, room tabs, DM panel, presence dots, typing, user-list |
+| DM Search | `src/main.ts` | `/api/dm/search` + render |
+| Auth UI | `src/main.ts` + `index.html` | Viewer login/register/logout, session, user label |
+| Schedule Overlay | `src/main.ts` | Poll `/api/schedule`, render current/next |
+| Admin Router | `src/admin.ts` | Tab nav, dynamic pages |
+| Upload Manager | `src/admin.ts` | Multipart chunk upload, normalization badges |
+| Playlist Editor | `src/admin.ts` | Drag-reorder (gated to `ready` media), save |
+| Moderation Panel | `src/admin.ts` + `public/admin/moderation.html` | Mute/ban, action log |
+| Analytics Dashboard | `src/admin.ts` + `public/admin/analytics.html` | Message counts, top rooms, moderation stats |
+| System Config | `src/admin.ts` + `public/admin/system.html` | Health, runtime config form, restart |
## Runtime View
-### Playout & Delivery Scenario
+**Playout & Delivery Scenario**
1. Admin adds normalized MP4 to R2 + updates playlist.
-2. Playout worker (FFmpeg loop with concat demuxer) reads next file, packages as HLS segments (2s).
+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.
-4. Viewer loads SPA → requests master/live manifest → hls.js player follows current edge.
-5. Cloudflare serves `.m4s` and `.ts` from nearest edge cache (origin/R2 hit only once per POP/region).
-6. Parallel WebSocket connection to chat service for synchronized interaction.
+4. Viewer loads SPA → requests manifest → hls.js follows current edge.
+5. Cloudflare serves `.m4s`/`.ts` from nearest edge cache (origin hit once per POP/region).
+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`.
-
-(Additional scenarios: moderation events, scale spikes, failover covered in the ADR.)
+Manifest freshness keeps all viewers on the same timeline; cache hit ratio drives cost/latency. Fallback slate activates on failure. (Moderation events, scale spikes, failover in ADR.)
## Deployment View
-### CI/CD Pipeline Architecture
-
-The deployment uses parallel workflows on each push to `main`: **CI builds and pushes images** while **CD deploys latest images** to the test lab. A dedicated release workflow handles semantic version tags. Current runtime target is the **test lab** (192.168.88.40). Production Hetzner VPS is planned but not yet provisioned — the same pipeline will deploy there by changing `DOCKER_HOST`.
+**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`.
```mermaid
flowchart LR
@@ -439,78 +296,41 @@ flowchart LR
DOCKER --> GRAF
```
-**Pipeline Sequence:**
+**Pipeline sequence**
-1. **CI Workflow** (`.gitea/workflows/ci.yml`):
- - Triggers on `push` to `main` and `pull_request`
- - Runs `test` job: frontend lint/build and Go validation
- - 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 `push` to `main` (parallel to CI), or manually via `workflow_dispatch`
- - Logs in remote Docker host to private registry
- - Generates `.env` from secrets
- - Seeds Prometheus and Grafana provisioning into named volumes
- - Deploys via `docker compose -f docker-compose.prod.yml up -d --remove-orphans --force-recreate` to remote host (`192.168.88.40:2375` — test lab; switch to Hetzner IP for production)
- - Runs health checks with retry logic for playout initialization
-3. **Release Workflow** (`.gitea/workflows/release.yml`):
- - Triggers on semantic tags in `*.*.*` format (for example `1.0.9`)
- - Builds and pushes versioned image tags (`X.Y.Z`, `X.Y`, `latest`)
- - Creates release entry in Gitea
+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.
+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.
+3. **Release** (`.gitea/workflows/release.yml`): on tags `*.*.*` (e.g. `1.0.9`); pushes `X.Y.Z`, `X.Y`, `latest`; creates Gitea release.
-### 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/Workers/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.
-
-**Quality/Performance Features**: High cache-hit ratio for video, low US latency, automatic scaling at edge, built-in WAF/DDoS protection.
-
-**Mapping of Building Blocks to Infrastructure**:
-
-- Playout + Chat + Nginx → Hetzner Cloud CX22 (Ashburn, VA).
-- HLS storage, caching, static SPA → Cloudflare R2 + CDN + Workers (custom domains, `enam` location hint).
-- All public traffic routed through Cloudflare.
-
-### Infrastructure Level 2
-
-**Hetzner VPS (planned):** Will run playout (FFmpeg/MediaMTX), chat backend, Nginx. Monitoring via dashboards + UptimeRobot. Backups to R2. Currently test lab (`192.168.88.40`) serves this role. Production Hetzner instance not yet provisioned — see Production Deployment section in [`DEPLOYMENT.md`](../../DEPLOYMENT.md).
-
-**Cloudflare Layer**:
-
-- R2 bucket for media.
-- Cache Rules (`.m4s` and `.ts`: Cache Everything, immutable, long TTL; `.m3u8`: short TTL/must-revalidate).
-- WAF, rate limiting, Tiered Cache.
-- Workers for SPA hosting.
-
-Future evolution: Docker Compose, managed DB, secondary regions (detailed in subsequent ADRs).
+- **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.
+- Future: Docker Compose, managed DB, secondary regions (later ADRs).
## 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 `.m4s` and `.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 conform to the Linear Void source profile before they are accepted into production playlists. The chat backend **auto-normalizes** non-conforming uploads on ingest (`media.NormalizeVideo`), so admins no longer need to pre-process files manually. This prevents decode resets, timestamp drift, and audio/video discontinuities during continuous concat playout.
+| 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 |
-#### Required Source Profile (Linear Void v1)
-
-| Field | Required Value | Notes |
-| ----------------------- | ---------------------------------- | ------------------------------------------------------- |
-| Container | MP4 | Single-video-asset input format |
-| Video codec | H.264/AVC (`libx264`) | Baseline delivery codec for broad compatibility |
-| Audio codec | AAC-LC | Stereo preferred |
-| Resolution | 1280x720 or 1920x1080 | Keep single resolution per playlist block when possible |
-| Framerate | Constant 30 fps (`-r 30`) | Variable frame rate content must be normalized |
-| GOP / keyframe interval | 2 seconds (`-g 60 -keyint_min 60`) | Matches HLS segment boundaries better |
-| Pixel format | `yuv420p` | Browser compatibility baseline |
-| Audio sample rate | 48 kHz | Stable timing for concat playout |
-| Audio bitrate | 128k-192k | Predictable quality envelope |
-
-#### Reference Normalization Command
+_Reference normalization command_
```bash
ffmpeg -i input.mov \
@@ -522,436 +342,147 @@ ffmpeg -i input.mov \
output-normalized.mp4
```
-#### Automatic Normalization & Verification (ffprobe)
+_Automatic normalization & verification (ffprobe)_
-- `media.NormalizeVideo` runs ffmpeg with the Linear Void profile (H.264/AAC, 30fps, 2s keyframes, yuv420p, 48kHz stereo), writes `*.normalized.mp4`, and re-verifies the output with ffprobe.
-- Media records carry a status: `ready`, `needs_normalization`, `normalizing`, or `normalization_failed`; the admin UI shows a "Normalize" action with status polling.
-- `POST /api/admin/media/{id}/normalize` re-triggers normalization manually for failed/re-queued media.
-- Keep verification logic aligned with runtime playout assumptions to preserve `-c copy` efficiency where possible.
+- `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.
-### Runtime Configuration Store
+**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`).
-A thread-safe, JSON-persisted runtime config store lives in `backend/chat/src/config/runtime.go` and is shared with the playout service via the playlist volume (`/app/playlist/config.json`).
-
-- Editable via `GET/PUT /api/admin/config` from the admin UI.
+- Editable via `GET/PUT /api/admin/config`.
- Fields: `hlsSegmentDuration` (2–15s, default 2), `hlsWindowSize` (3–100, default 12), `matomoUrl`, `matomoSiteId`.
-- Playout reads the same file (`loadSharedHLSConfig`) as an env fallback for HLS segment duration/window.
-- Typed setters validate values; invalid input is rejected with `400`.
+- Playout reads same file (`loadSharedHLSConfig`) as env fallback.
+- Typed setters validate; invalid → `400`.
-### Analytics (Matomo)
+**Analytics (Matomo)** — Viewer sends pageviews via public `GET /api/matomo`, returning `matomoUrl` + `matomoSiteId` from the runtime config. Optional per deployment (empty `matomoUrl` disables).
-- The viewer sends pageviews to Matomo driven by the public `GET /api/matomo` endpoint, which returns `matomoUrl` + `matomoSiteId` from the runtime config store.
-- Analytics is optional per deployment: leave `matomoUrl` empty to disable tracking.
+**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.
-### Monitoring & Resilience
+_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).
-Linear Void uses a two-layer monitoring model:
+_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.
-1. **Application layer (chat + platform health)** via Prometheus metrics and a lightweight Grafana dashboard.
-2. **Infrastructure/runtime layer (Prometheus self-observability, TSDB, query engine, Go runtime)** via a detailed Grafana dashboard.
+_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.
-#### Prometheus Baseline
+_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.
-- Config file: `monitoring/prometheus/prometheus.yml`
-- Default scrape interval: `15s`
-- Current scrape jobs (7 total):
- - `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: `--storage.tsdb.retention.time=30d` (delete metrics older than 30 days) + `--storage.tsdb.retention.size=15GB` (disk cap), set on the `prometheus` service in `docker-compose.yml` and `docker-compose.prod.yml`
+**API Reference** — Three operational API surfaces (canonical contract in `API.md`).
-#### Grafana Dashboards
+_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.
-- `monitoring/grafana/dashboard.json` (Linear Void app metrics: connected clients, messages, moderation actions, Redis reachability, admin sessions, scrape health).
-- `monitoring/grafana/dashboards/dashboard.json` (Prometheus operational visibility: status, uptime, CPU/memory, TSDB ingestion/churn/errors, HTTP latencies, query load, GC/scheduler).
+_Playout & Delivery REST_: playout `/health`; edge/origin `/health` via nginx; HLS manifest/segment namespace under `/hls/`. Liveness for orchestration + stable HLS path contract.
-Use the app dashboard for service-level behavior and the Prometheus overview dashboard for platform diagnostics and capacity planning.
+_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.
-#### Alerting Rules (Prometheus Rule Groups)
+_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).
-Rules are implemented in `monitoring/prometheus/alerts.yml` and loaded via `rule_files` from `prometheus.yml`.
+**Cloudflare Configuration Reference** — Cloudflare is part of the production control plane, not only a CDN.
-Current rule set (10):
+_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.
-- `ChatDown`
-- `PlayoutDown`
-- `RedisDown`
-- `PlayoutSlateActive`
-- `HighChatLatency`
-- `PrometheusWALFailing`
-- `HighCPU`
-- `LowCacheHitRatio`
-- `HighRedisMemoryUsage`
-- `LowDiskSpace`
+_Cache rules baseline_: `.ts`/`.m4s` Cache Everything, long TTL, immutable; `.m3u8` short TTL (1–10s), revalidation-friendly.
-Route warning/critical severities to the operations channel and keep runbook links attached to each alert definition.
+_Edge security/controls_: enforce HTTPS/WSS + redirect HTTP; WAF baseline; rate limits on abusive patterns (auth/admin); minimize origin exposure.
-Operational resilience baseline:
+_Worker & header policy_: Worker logic for immutable cache headers/request normalization where cache rules insufficient (`infrastructure/cloudflare/worker.js`); explicit CORS for HLS.
-- Emergency slate fallback for playout failure.
-- Auto-restart policies for playout/chat/nginx containers.
-- Weekly backup and restore drills for stateful data (`data/chat`, playlist metadata, Postgres).
-- Incident runbook with triage order: edge reachability -> origin health -> playout progression -> chat connectivity -> data-plane dependencies.
+_Operations & validation_: verify cache status progression (MISS→HIT), manifest freshness under active playout; track hit ratio, request/error rates, ToS/cost posture.
-### API Documentation Reference
+**Security & Moderation** — Split across edge (Cloudflare), proxy (nginx), and application (chat/admin).
-Linear Void currently exposes three operational API surfaces. The canonical detailed contract should live in `API.md`, while this section provides the architecture-level map.
+- _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 WebSocket API
-
-- Endpoint: `/ws`
-- Transport: `wss://` in production, `ws://` locally
-- Query parameters:
- - `username`
- - `room`
-- Message envelope (typical):
- - `room`
- - `username`
- - `text`
- - `type` (`public`, `private`, `system`)
-- Responsibilities:
- - room fan-out
- - private DM channel routing
- - moderation command handling
- - rate limiting and presence integration
-
-#### Playout and Delivery REST API
-
-- Health endpoints:
- - playout health (`/health` on playout service)
- - edge/origin health (`/health` via nginx)
-- Stream delivery:
- - HLS manifest and segment path namespace under `/hls/`
-- Responsibilities:
- - expose liveness for orchestration and monitoring
- - provide stable HLS path contract for viewer and CDN
-
-#### Admin REST API
-
-- Auth/session:
- - `/api/admin/login`
- - `/api/admin/logout`
- - `/api/admin/session`
-- Operational/admin domains:
- - overview, analytics, users, moderation, schedule, playlist, media
- - runtime config: `GET/PUT /api/admin/config` (HLS segment duration/window, Matomo)
- - media normalization: `POST /api/admin/media/{id}/normalize`
-- Media upload domain:
- - `/api/admin/media/uploads` (init/resume)
- - `/api/admin/media/uploads/{uploadId}/chunk`
- - `/api/admin/media/uploads/{uploadId}/complete`
-- Responsibilities:
- - protected control plane for operator workflows
- - media metadata + upload orchestration
- - moderation/audit and system status management
-
-#### Viewer REST API
-
-- Auth/session:
- - `POST /api/auth/register`
- - `POST /api/auth/login`
- - `POST /api/auth/logout`
- - `GET /api/auth/session`
-- Private messaging:
- - `GET /api/dm/history` (paginated)
- - `GET /api/dm/search`
-- Analytics:
- - `GET /api/matomo` (public; returns Matomo URL + site ID for the viewer pageview snippet)
-
-### Cloudflare Configuration Reference
-
-Cloudflare is part of the production control plane, not only a CDN edge. The following configuration set is required to maintain cost, latency, and reliability targets.
-
-#### DNS and Zone Routing
-
-- `linearvoid.tv` -> application origin (nginx)
-- `www.linearvoid.tv` -> redirect to apex
-- `media.linearvoid.tv` -> media domain for HLS/object delivery when split-domain mode is enabled
-
-#### Cache Rules Baseline
-
-- HLS segments (`.ts`, `.m4s`):
- - Cache Everything
- - long TTL (immutable)
- - respect immutable semantics
-- Manifests (`.m3u8`):
- - short TTL (1-10s)
- - revalidation-friendly policy for timeline freshness
-
-#### Edge Security and Controls
-
-- Enforce HTTPS/WSS and redirect HTTP to HTTPS.
-- Enable WAF baseline protections for common web exploits.
-- Apply rate limits for abusive request patterns (especially auth/admin paths).
-- Keep origin exposure minimized (prefer Cloudflare-only public ingress where feasible).
-
-#### Worker and Header Policy
-
-- Use Worker logic for immutable cache headers and request normalization where cache rules alone are insufficient.
-- Keep CORS behavior explicit for HLS resources to support browser playback.
-
-#### Operations and Validation
-
-- Validate cache behavior after deployment:
- - expected cache status progression (MISS -> HIT)
- - manifest freshness under active playout
-- Track edge metrics and adjust:
- - cache hit ratio
- - request volume and error rates
- - ToS/cost posture for sustained traffic profiles
-
-### Security & Moderation
-
-Security is split between edge controls (Cloudflare), reverse proxy controls (nginx), and application controls (chat/admin backend).
-
-#### Transport and Edge Protection
-
-- Public traffic uses HTTPS/WSS at the edge.
-- Cloudflare WAF and rate limiting are part of the baseline (see [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md)).
-- Cloudflare Worker can enforce immutable cache headers for segment paths (`infrastructure/cloudflare/worker.js`).
-
-#### Reverse Proxy Enforcement (nginx)
-
-- Admin UI and admin API paths are protected by `auth_request` before reaching backend handlers (`/admin/`, `/api/admin/`).
-- Unauthorized admin UI requests are redirected to `/admin/login/`.
-- Unauthorized admin API requests return JSON `401` from a dedicated location handler.
-- WebSocket upgrade headers are explicitly forwarded for `/ws`.
-
-#### Admin Authentication and Session Security
-
-- Admin authentication is cookie-session based (`linearvoid_admin_session`).
-- Session cookie controls in backend include `HttpOnly`, `SameSite=Strict`, and `Secure` when request is HTTPS (derived from TLS or `X-Forwarded-Proto`).
-- Session expiration is enforced server-side.
-
-#### Viewer User Accounts
-
-- Register/login/logout via `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`; current session via `GET /api/auth/session`.
-- Passwords are hashed with bcrypt; sessions use a `linearvoid_user_session` cookie with a 30-day default TTL (`USER_SESSION_TTL_HOURS`). The cookie is `SameSite=None; Secure` over HTTPS (cross-site from the Workers SPA) and `SameSite=Lax` over plain HTTP (local dev).
-- Viewer sign-in UI (username/password) lives in `frontend/index.html`.
-
-#### Rate Limiting and Abuse Controls
-
-- Chat message rate limiting uses Redis `INCR + EXPIRE` windowing.
-- Current baseline in chat handler: `>10` messages within `10s` triggers a user-facing throttle response.
-- If Redis is unavailable, presence/rate-limit features degrade gracefully (warning logged; core chat remains available).
-
-#### CORS and Cross-Origin Behavior
-
-- HLS endpoints exposed by nginx include explicit `Access-Control-Allow-Origin: *` for player compatibility.
-- API routes (`/api/*`) include `Access-Control-Allow-Origin` (reflecting an allowlisted origin) and `Access-Control-Allow-Credentials: true` so the Workers-served SPA can call the app origin cross-origin.
-- The viewer session cookie (`linearvoid_user_session`) uses `SameSite=None; Secure` over HTTPS so it is sent cross-site; the admin session cookie remains `SameSite=Strict`.
-
-#### Moderation and Privacy
-
-- Moderation commands (`/mod`, `/mute`, `/ban`) are handled in backend chat flow and persisted through chat storage paths.
-- Private DMs are application-level private channels, not end-to-end encrypted in current architecture.
-- Moderation and audit retention policies should be formalized in runbook/ops docs as part of production hardening.
-
-### Chat
-
-WebSockets + Redis (presence, pub/sub, rate limits) + DB (history, audit). Public rooms + on-demand private chats. Scales independently of video.
-
-- Private DM history (`GET /api/dm/history`, paginated) and search (`GET /api/dm/search`) are backed by persisted DMs.
-- Viewer UI includes a typing indicator, user-list toggle, and DM search.
-
-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).
+**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
-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-002](./adr/ADR-002-module-structure.md)**: Chosen project folder/module structure (frontend, backend/playout, backend/chat, infrastructure, scripts, monitoring) 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-004](./adr/ADR-004-admin-video.md)**: Video admin web interface (upload, playlist management, schedule overrides, normalization enforcement).
-- **[ADR-005](./adr/ADR-005-admin-moderation-analytics.md)**: Web admin interfaces for user management, moderation dashboard, analytics, and system configuration.
-- **[ADR-006](./adr/ADR-006-data-storage.md)**: Data storage strategy (SQLite vs PostgreSQL for v1, migration path, admin interface capabilities).
-- **[ADR-007](./adr/ADR-007-redis-usage.md)**: Redis usage (exact use cases, TTL policies, persistence, fallback behavior, admin configuration).
+- **ADR-001**: overall architecture, HLS + R2 caching, storage calcs, cost model, risks.
+- **ADR-002**: project folder/module structure and rationale.
+- **ADR-003**: tech stack (TypeScript frontend; Go for chat/playout wrapper; Bash/Python scripts), Docker-first, documented assumptions.
+- **ADR-004**: video admin web interface (upload, playlist, schedule overrides, normalization).
+- **ADR-005**: admin UI for users, moderation dashboard, analytics, system config.
+- **ADR-006**: data storage (SQLite vs PostgreSQL for v1, migration path).
+- **ADR-007**: Redis usage (use cases, TTL, persistence, fallback, admin config).
-These ADRs together form the canonical decision log.
-
-**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 + custom CSS.
-- 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.
+**Key decisions**: self-managed US-East playout (Hetzner Ashburn) FFmpeg + MediaMTX/SRS → HLS → R2 + edge cache; module structure mirroring arc42 blocks; Go for perf-critical services, TypeScript frontend, Docker everywhere; admin interfaces (HTMX + custom CSS); SQLite primary for v1 with PostgreSQL migration path; Redis for rate limiting/presence/pub/sub/session with defined TTL and fallback.
## Quality Requirements
-See **Quality Goals** 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 and Capacity Planning
+**Performance Benchmarks & Capacity Planning**
-#### Baseline SLO Targets (v1)
+_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.
-- Playback startup: target < 4s after first manifest request on warm cache.
-- Steady-state playback: no segment starvation during normal operation.
-- End-to-end HLS latency: expected 15-35s (standard HLS trade-off).
-- Chat delivery latency: p95 < 500ms inside same region under normal load.
-- Admin page responsiveness: p95 API response < 1s for common read operations.
+_Workload assumptions_: US audience, US-East origin; edge serves most segments, origin handles manifest churn + cache misses; chat low-bandwidth vs video.
-#### Workload Assumptions
+_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.
-- Primary audience in US; origin in US-East.
-- Edge cache serves most segment traffic; origin handles manifest churn and cache misses.
-- Chat traffic scales with concurrent viewers but remains low-bandwidth compared to video.
+_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).
-#### Initial Capacity Model
+_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.
-- Video bitrate envelopes (from ADR-001):
- - 720p30: ~2.6-4.2 Mbps total with audio.
- - 1080p30: ~5.1-8.2 Mbps total with audio.
-- Storage planning for 48h rolling library:
- - 720p30: ~57-90 GB raw (allocate ~80-110 GB with headroom).
- - 1080p30: ~110-175 GB raw (allocate ~130-180 GB with headroom).
-- Cost behavior:
- - Viewer scale is mostly constrained by cache efficiency and request volume, not by direct origin egress.
+**Scaling Strategy** — preserve cost efficiency first, then throughput/resilience in phases:
-#### Benchmark Plan
+- **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.
-- Playback path:
- - Measure time-to-first-frame and rebuffer events from multiple US regions.
- - Track manifest freshness and segment availability at edge.
-- Chat path:
- - Load-test concurrent websocket connections and message fan-out.
- - Measure p50/p95 send-to-receive latency and Redis fallback behavior.
-- Admin/API path:
- - Benchmark playlist/system endpoints for p95 latency and error rate.
-- Platform path:
- - Observe TSDB ingestion, query p99, and GC pause behavior in Grafana.
+**Disaster Recovery & Backup**
-#### Capacity Triggers and Actions
+_Recovery targets_: RPO ≤ 24h (chat/moderation/admin metadata); RTO ≤ 60 min (restore baseline streaming + chat).
-- Trigger: sustained viewer growth with rising cache-miss ratio.
- - Action: tighten cache policy, enable/verify Tiered Cache, review manifest TTL.
-- Trigger: chat p95 latency drift or websocket error growth.
- - Action: scale chat service, tune Redis, separate noisy moderation workloads.
-- Trigger: Prometheus WAL/compaction failures or query saturation.
- - Action: tune retention/cardinality, increase resources, split dashboards/queries.
-- Trigger: origin CPU/memory pressure on playout host.
- - Action: optimize FFmpeg profile, isolate services, add secondary origin.
+_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.
-### Scaling Strategy
+_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.
-Scaling is designed to preserve cost efficiency first, then increase throughput and resilience in controlled phases.
+_Recovery runbook (minimum)_:
-#### Phase 1: Vertical + Cache-First (Current Baseline)
+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.
-- Single primary origin in US-East with strong Cloudflare edge caching.
-- Optimize segment immutability and manifest TTL before adding infrastructure.
-- Scale compute vertically when CPU/memory pressure appears on playout/chat host.
-
-#### Phase 2: Service Separation
-
-- Split playout, chat, and observability workloads across dedicated nodes/VMs.
-- Isolate noisy workloads (analytics, heavy admin tasks) from latency-sensitive chat paths.
-- Introduce managed PostgreSQL/Redis or dedicated state nodes where operationally justified.
-
-#### Phase 3: Multi-Origin Resilience
-
-- Add warm standby or active-secondary origin.
-- Use health-aware DNS/origin failover strategy.
-- Keep HLS path and cache contract stable so viewer impact during failover is minimized.
-
-#### Phase 4: Regional and Throughput Expansion
-
-- Add additional origin region only when audience distribution and latency justify it.
-- Keep chat affinity strategy explicit (regional routing, state replication considerations).
-- Continuously evaluate cache miss economics vs additional origin complexity.
-
-#### Scaling Triggers
-
-- Rising cache miss ratio with sustained traffic growth.
-- Chat p95/p99 latency drift or websocket error growth.
-- Persistent origin resource saturation despite optimization.
-- Increased blast-radius concerns from single-host dependency.
-
-### Disaster Recovery and Backup Procedures
-
-#### Recovery Targets
-
-- RPO target: <= 24h for chat/moderation/admin metadata.
-- RTO target: <= 60 minutes for restoring baseline streaming + chat operations.
-
-#### Backup Scope
-
-- Persistent service volumes:
- - Postgres data (`postgres-data` volume).
- - Redis append-only persistence (`redis-data` volume) where enabled.
- - Prometheus long-term TSDB (`prometheus-data` volume) if historical metrics retention is required.
- - Grafana state (`grafana-data` volume) if dashboards/users are managed in-instance.
-- Host-mounted state directories:
- - `data/chat`
- - `data/playlist`
- - `data/media` (authoritative source may be R2/object storage)
- - `data/hls` (optional; can be regenerated by playout)
-- Configuration and infra source of truth:
- - Compose files, nginx config, monitoring configs, and environment template.
-
-#### Backup Cadence
-
-- Daily logical Postgres backups (plus weekly full snapshot).
-- Daily sync of chat/playlist state directories.
-- Weekly integrity check by restoring backups into an isolated environment.
-- Retention policy baseline:
- - Daily backups: 14 days
- - Weekly backups: 8 weeks
- - Monthly backups: 6 months
-
-#### Recovery Runbook (Minimum)
-
-1. Restore infrastructure configuration (compose, nginx, env, monitoring config).
-2. Restore Postgres and Redis persisted state.
-3. Restore `data/playlist` and chat persistence directories.
-4. Rehydrate media from authoritative object storage if local media is missing.
-5. Start stack and validate in order:
- - `playout` health endpoint
- - `chat` health endpoint
- - nginx `/health`
- - HLS playlist availability
- - websocket chat connectivity
-6. Verify admin authentication and moderation audit visibility.
-7. Announce recovery and continue elevated monitoring for at least one playlist cycle.
-
-#### Failure Modes and Strategy
-
-- Single-host loss: redeploy stack on standby host, restore data backups, repoint DNS/edge origin.
-- Storage corruption: restore last known-good snapshot and replay latest logical backups.
-- Partial service failure: replace only impacted service and restore scoped data (for example Postgres only).
-
-Document and rehearse this runbook quarterly; recovery that is not exercised is not reliable.
+_Failure modes_: single-host loss → redeploy on standby, restore backups, repoint DNS/edge; storage corruption → restore last good snapshot + replay logical backups; partial failure → replace only impacted service, restore scoped data. Rehearse quarterly.
## Risks and Technical Debts
-- Cloudflare ToS risk for heavy video on free tier (use R2 path; fallback to BunnyCDN if challenged).
-- HLS latency (15–35s) may affect perceived chat synchronization.
-- Single VPS as SPOF (mitigate with DNS failover in Phase 3).
-- Upfront effort for video normalization and cache tuning.
-- Moderation/abuse prevention operational load at scale.
-- R2 request costs on cache misses (optimize with Tiered Cache; measure in prod).
-- Deferred items: LL-HLS, ABR, E2EE, paid access controls, full Docker Compose.
-
-**Technical Debt**: Detailed service configs, CI/CD, and advanced resilience are left for implementation ADRs.
+- Cloudflare ToS risk for heavy video on free tier (use R2; fallback to BunnyCDN).
+- HLS latency (15–35s) may affect perceived chat sync.
+- Single VPS = SPOF (mitigate with DNS failover, Phase 3).
+- Upfront effort for normalization + cache tuning.
+- Moderation/abuse operational load at scale.
+- R2 request costs on cache misses (optimize Tiered Cache; measure in prod).
+- Deferred: LL-HLS, ABR, E2EE, paid access controls, full Docker Compose.
+- _Debt_: detailed service configs, CI/CD, advanced resilience left for implementation ADRs.
## Glossary
-| Term | Definition |
-| ----------------- | -------------------------------------------------------------------------------------- |
-| HLS | HTTP Live Streaming – protocol using `.m3u8` manifests and media segments (fMP4). |
-| Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. |
-| R2 | Cloudflare object storage ($0 egress, ideal origin for cached HLS). |
-| Edge Cache / POP | Cloudflare points-of-presence that cache and serve content close to viewers. |
-| Playout | Continuous generation of a scheduled linear program from a playlist. |
-| Normalization | Pre-processing videos to consistent codec/fps/keyframe settings for seamless playback. |
-| Cache Rule | Cloudflare configuration defining caching behavior, TTL, and headers per URL pattern. |
-| SPOF | Single Point of Failure (current single VPS origin). |
-| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
+| Term | Definition |
+| ----------------- | -------------------------------------------------------------------------------- |
+| HLS | HTTP Live Streaming — `.m3u8` manifests + media segments (fMP4). |
+| Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. |
+| R2 | Cloudflare object storage ($0 egress; ideal origin for cached HLS). |
+| Edge Cache / POP | Cloudflare points-of-presence caching/serving content near viewers. |
+| Playout | Continuous generation of a scheduled linear program from a playlist. |
+| Normalization | Pre-processing to consistent codec/fps/keyframe settings for seamless playback. |
+| Cache Rule | Cloudflare config for caching behavior, TTL, headers per URL pattern. |
+| SPOF | Single Point of Failure (current single VPS origin). |
+| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
-**References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js 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.
diff --git a/docs/incident-runbook.md b/docs/incident-runbook.md
index d3fe851..cafacc4 100644
--- a/docs/incident-runbook.md
+++ b/docs/incident-runbook.md
@@ -4,155 +4,101 @@ Triage order for Linear Void incidents. Work top-to-bottom — each tier depends
## 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)
+2. **Origin Health** (VPS/Docker)
+3. **Playout Progression** (HLS)
+4. **Chat Connectivity** (WebSocket)
+5. **Data-Plane** (Redis/DB)
## 1. Edge Reachability (Cloudflare)
**Symptoms:** `linearvoid.tv` not resolving, SSL errors, 5xx from Cloudflare.
```bash
-# Check DNS
dig linearvoid.tv
dig www.linearvoid.tv
-
-# Check Cloudflare status
curl -I https://linearvoid.tv/health
```
-**Common causes:**
+**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)).
-- DNS misconfiguration (A record pointing to wrong IP)
-- Cloudflare proxy disabled (grey cloud → origin exposed directly)
-- SSL certificate expired (check Cloudflare dashboard → SSL/TLS)
-- Cloudflare outage → check [https://www.cloudflarestatus.com](https://www.cloudflarestatus.com)
+**Mitigation:** Verify A record points `linearvoid.tv` → origin IP (proxied, orange cloud). Bypass Cloudflare temporarily:
-**Mitigation:**
-
-- Verify A record: `linearvoid.tv` → origin IP (proxied, orange cloud)
-- Bypass Cloudflare temporarily: `curl -H "Host: linearvoid.tv" http:///health`
+```bash
+curl -H "Host: linearvoid.tv" http:///health
+```
## 2. Origin Health (VPS/Docker)
-**Symptoms:** Cloudflare returns 502/504, containers down.
+**Symptoms:** Cloudflare 502/504, containers down.
```bash
-# Remote Docker (test lab)
docker -H tcp://192.168.88.40:2375 ps
docker -H tcp://192.168.88.40:2375 logs linearvoid-nginx --tail 50
-
-# Direct health check
curl http://192.168.88.40/health
```
-**Common causes:**
-
-- Host out of disk space: `df -h`
-- Docker daemon stopped: `systemctl status docker`
-- Container restart loop: `docker ps -a --filter "status=exited"`
-- Resource exhaustion (OOM): `dmesg | grep -i oom`
+**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:**
-- Restart stack: `docker compose -f docker-compose.prod.yml up -d`
-- Free disk: `docker system prune -f`
-- Check logs per service: `docker logs linearvoid- --tail 100`
+```bash
+docker compose -f docker-compose.prod.yml up -d # restart stack
+docker system prune -f # free disk
+docker logs linearvoid- --tail 100 # per-service logs
+```
## 3. Playout Progression (HLS)
**Symptoms:** Viewer sees slate or buffering, playlist not advancing.
```bash
-# Check playout health
-curl http://192.168.88.40:8081/health
-# Response includes "mode": "live" or "slate"
-
-# Check HLS output
+curl 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/
-
-# Check playout logs
docker logs linearvoid-playout --tail 100
```
-**Common causes:**
+**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.
-- FFmpeg crash → slate fallback active (health shows `degraded`)
-- Playlist file empty/missing: `cat data/playlist/playlist.txt`
-- Media file missing from playlist: `ls data/media/`
-- Disk full → HLS segments can't write
-
-**Mitigation:**
-
-- Verify playlist has valid entries
-- Check media files exist at paths in playlist
-- Restart playout: `docker compose restart playout`
-- Generate demo playlist if empty: see `backend/playout/start-playout.sh`
+**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
-# Check chat health
curl http://192.168.88.40:3000/health
-
-# Test WebSocket
wscat -c ws://192.168.88.40/ws?username=test&room=main
-
-# Check chat logs
docker logs linearvoid-chat --tail 100
```
-**Common causes:**
+**Common causes:** Redis down → presence/rate-limiting degraded (chat still works); DB down → messages not persisting; port not exposed (`docker port linearvoid-chat`).
-- Redis down → presence/rate-limiting degraded (chat still works)
-- Database down → messages not persisting
-- Port not exposed: `docker port linearvoid-chat`
-
-**Mitigation:**
-
-- Restart chat: `docker compose restart chat`
-- Check Redis: `docker exec linearvoid-redis redis-cli ping`
-- Check DB: `docker exec linearvoid-db pg_isready -U linearvoid`
-- Chat works without Redis (graceful degradation) — check logs for "Redis connection warning"
+**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
-# Redis
docker exec linearvoid-redis redis-cli ping
docker exec linearvoid-redis redis-cli info memory
-
-# PostgreSQL
docker exec linearvoid-db pg_isready -U linearvoid
docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
```
-**Common causes:**
+**Common causes:** Redis OOM (check `maxmemory`); PostgreSQL connection limit; disk full on volume mount.
-- Redis OOM (check `maxmemory` setting)
-- PostgreSQL connection limit reached
-- Disk full on volume mount
-
-**Mitigation:**
-
-- Redis: `docker compose restart redis` (AOF persist enabled)
-- PostgreSQL: `docker compose restart db`
-- Restore from backup: see `scripts/verify-backups.sh` for procedure
+**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 during business hours, restart service |
-| Info | Single service blip | Monitor, no action unless recurring |
+| 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
@@ -163,67 +109,52 @@ docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
## Quarterly Disaster Recovery Drill
-Goal: validate recovery targets under controlled host-loss simulation.
-
-**Recovery targets:** RPO <= 24h, RTO <= 60min.
+Validate recovery targets under controlled host-loss simulation: **RPO ≤ 24h, RTO ≤ 60min**.
### Prerequisites
-- Latest backup artifacts available from `scripts/backup-db.sh` and `scripts/backup-weekly.sh`
+- Latest backups from `scripts/backup-db.sh` and `scripts/backup-weekly.sh`
- Operator access to Docker host and DNS controls
-- Current deployment variables available (`.env`, compose file, image tags)
+- Current deployment variables (`.env`, compose file, image tags)
-### Drill Scope
+### 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
-### Drill Steps
+### Steps
1. Record drill start timestamp and on-call participants.
2. Capture pre-drill baseline:
- - `curl -fsS http:///health`
- - `curl -fsS http://:3000/health`
- - `curl -fsS http://:8081/health`
-3. Simulate origin outage:
- - `docker compose -f docker-compose.prod.yml down`
-4. Provision clean recovery host (or clean test-lab VM) and copy deployment bundle.
-5. Restore required data paths:
- - `data/playlist/`
- - `data/media/`
- - `data/chat/`
- - `data/hls/` (optional warm cache)
+ ```bash
+ curl -fsS http:///health
+ curl -fsS http://:3000/health
+ curl -fsS http://: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 service health and runtime:
- - `docker ps --format "table {{.Names}}\t{{.Status}}"`
- - `curl -fsS http:///health`
- - `curl -fsS http:///hls/playlist.m3u8 | head -n 5`
- - `curl -fsS http://:9090/-/ready`
-9. Validate app behavior:
- - Viewer SPA loads and HLS plays
- - WebSocket chat connects and message round-trip works
- - Admin login/session endpoint returns success
-10. Run backup integrity check:
- - `scripts/verify-backups.sh`
-11. Record drill end timestamp and compute measured RTO/RPO.
+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:///health
+ curl -fsS http:///hls/playlist.m3u8 | head -n 5
+ curl -fsS http://: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 if all core services healthy and functional in <= 60 minutes.
-- Pass if latest recoverable data point is <= 24 hours old.
-- Fail if any critical path remains degraded (playout, chat, admin auth, monitoring).
+- **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
-Document each drill in incident tracker with:
-
-- 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
+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.
diff --git a/docs/infrastructure/cloudflare/cloudflare-account-troubleshooting.md b/docs/infrastructure/cloudflare/cloudflare-account-troubleshooting.md
index 6d583b6..7cab390 100644
--- a/docs/infrastructure/cloudflare/cloudflare-account-troubleshooting.md
+++ b/docs/infrastructure/cloudflare/cloudflare-account-troubleshooting.md
@@ -6,19 +6,19 @@ Use `wrangler whoami` to verify the account ID and API token are valid, or query
## Validate Cloudflare account and API token
-```powershell
+```bash
# Validate Cloudflare account and API token in .env
-Set-Location -Path "E:\Documents\02-Projects\07-Mentoring\Corey\linearvoid"
-Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN'
-Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID'
+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
-$env:CLOUDFLARE_API_TOKEN = (Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN' | ForEach-Object { $_.Line.Split('=')[1].Trim() })
-$env:CLOUDFLARE_ACCOUNT_ID = (Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID' | ForEach-Object { $_.Line.Split('=')[1].Trim() })
+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/$env:CLOUDFLARE_ACCOUNT_ID/workers/scripts" -H "Authorization: Bearer $env:CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json"
+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"
```
diff --git a/docs/infrastructure/monitoring/prometheus_results.md b/docs/infrastructure/monitoring/prometheus_results.md
index dd42db5..b472917 100644
--- a/docs/infrastructure/monitoring/prometheus_results.md
+++ b/docs/infrastructure/monitoring/prometheus_results.md
@@ -1,1637 +1,124 @@
-# Prometheus Metrics for Linear Void
+# Prometheus Metrics — Linear Void
-## Chat Metrics
+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.
-- ALERTS
-- ALERTS_FOR_STATE
-- 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
-- linearvoid_chat_request_duration_seconds_count
-- linearvoid_chat_request_duration_seconds_sum
-- scrape_duration_seconds
-- scrape_samples_post_metric_relabeling
-- scrape_samples_scraped
-- scrape_series_added
-- up
+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`).
-## Nginx Metrics
+## 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).
-- go_gc_duration_seconds
-- go_gc_duration_seconds_count
-- go_gc_duration_seconds_sum
-- go_gc_gogc_percent
-- go_gc_gomemlimit_bytes
-- go_goroutines
-- go_info
-- go_memstats_alloc_bytes
-- go_memstats_alloc_bytes_total
-- go_memstats_buck_hash_sys_bytes
-- go_memstats_frees_total
-- go_memstats_gc_sys_bytes
-- go_memstats_heap_alloc_bytes
-- go_memstats_heap_idle_bytes
-- go_memstats_heap_inuse_bytes
-- go_memstats_heap_objects
-- go_memstats_heap_released_bytes
-- go_memstats_heap_sys_bytes
-- go_memstats_last_gc_time_seconds
-- go_memstats_mallocs_total
-- go_memstats_mcache_inuse_bytes
-- go_memstats_mcache_sys_bytes
-- go_memstats_mspan_inuse_bytes
-- go_memstats_mspan_sys_bytes
-- go_memstats_next_gc_bytes
-- go_memstats_other_sys_bytes
-- go_memstats_stack_inuse_bytes
-- go_memstats_stack_sys_bytes
-- go_memstats_sys_bytes
-- go_sched_gomaxprocs_threads
-- go_threads
-- nginx_connections_accepted
-- nginx_connections_active
-- nginx_connections_handled
-- nginx_connections_reading
-- nginx_connections_waiting
-- nginx_connections_writing
-- nginx_exporter_build_info
-- nginx_http_requests_total
-- nginx_up
-- process_cpu_seconds_total
-- process_max_fds
-- process_network_receive_bytes_total
-- process_network_transmit_bytes_total
-- process_open_fds
-- process_resident_memory_bytes
-- process_start_time_seconds
-- process_virtual_memory_bytes
-- process_virtual_memory_max_bytes
-- promhttp_metric_handler_requests_in_flight
-- promhttp_metric_handler_requests_total
-- scrape_duration_seconds
-- scrape_samples_post_metric_relabeling
-- scrape_samples_scraped
-- scrape_series_added
-- up
+## Playout Target (`linearvoid_playout_*`)
+- `linearvoid_playout_mode`
+- `linearvoid_playout_restarts_total`
+- Plus `ALERTS`, `ALERTS_FOR_STATE`.
-## Node Metrics
+## 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.
-- go_gc_duration_seconds
-- go_gc_duration_seconds_count
-- go_gc_duration_seconds_sum
-- go_gc_gogc_percent
-- go_gc_gomemlimit_bytes
-- go_goroutines
-- go_info
-- go_memstats_alloc_bytes
-- go_memstats_alloc_bytes_total
-- go_memstats_buck_hash_sys_bytes
-- go_memstats_frees_total
-- go_memstats_gc_sys_bytes
-- go_memstats_heap_alloc_bytes
-- go_memstats_heap_idle_bytes
-- go_memstats_heap_inuse_bytes
-- go_memstats_heap_objects
-- go_memstats_heap_released_bytes
-- go_memstats_heap_sys_bytes
-- go_memstats_last_gc_time_seconds
-- go_memstats_mallocs_total
-- go_memstats_mcache_inuse_bytes
-- go_memstats_mcache_sys_bytes
-- go_memstats_mspan_inuse_bytes
-- go_memstats_mspan_sys_bytes
-- go_memstats_next_gc_bytes
-- go_memstats_other_sys_bytes
-- go_memstats_stack_inuse_bytes
-- go_memstats_stack_sys_bytes
-- go_memstats_sys_bytes
-- go_sched_gomaxprocs_threads
-- go_threads
-- node_arp_entries
-- node_boot_time_seconds
-- node_context_switches_total
-- node_cooling_device_cur_state
-- node_cooling_device_max_state
-- node_cpu_frequency_avg_hertz
-- node_cpu_frequency_hertz
-- node_cpu_frequency_max_hertz
-- node_cpu_frequency_min_hertz
-- node_cpu_guest_seconds_total
-- node_cpu_scaling_frequency_hertz
-- node_cpu_scaling_frequency_max_hertz
-- node_cpu_scaling_frequency_min_hertz
-- node_cpu_scaling_governor
-- node_cpu_seconds_total
-- node_disk_discard_time_seconds_total
-- node_disk_discarded_sectors_total
-- node_disk_discards_completed_total
-- node_disk_discards_merged_total
-- node_disk_flush_requests_time_seconds_total
-- node_disk_flush_requests_total
-- node_disk_info
-- node_disk_io_now
-- node_disk_io_time_seconds_total
-- node_disk_io_time_weighted_seconds_total
-- node_disk_read_bytes_total
-- node_disk_read_time_seconds_total
-- node_disk_reads_completed_total
-- node_disk_reads_merged_total
-- node_disk_write_time_seconds_total
-- node_disk_writes_completed_total
-- node_disk_writes_merged_total
-- node_disk_written_bytes_total
-- node_dmi_info
-- node_entropy_available_bits
-- node_entropy_pool_size_bits
-- node_exporter_build_info
-- node_filefd_allocated
-- node_filefd_maximum
-- node_filesystem_avail_bytes
-- node_filesystem_device_error
-- node_filesystem_files
-- node_filesystem_files_free
-- node_filesystem_free_bytes
-- node_filesystem_mount_info
-- node_filesystem_purgeable_bytes
-- node_filesystem_readonly
-- node_filesystem_size_bytes
-- node_forks_total
-- node_hwmon_chip_names
-- node_hwmon_pwm_max
-- node_hwmon_pwm_min
-- node_hwmon_sensor_label
-- node_hwmon_temp_celsius
-- node_hwmon_temp_crit_celsius
-- node_hwmon_temp_crit_hyst_celsius
-- node_intr_total
-- node_kernel_hung_tasks_total
-- node_load1
-- node_load15
-- node_load5
-- node_memory_Active_anon_bytes
-- node_memory_Active_bytes
-- node_memory_Active_file_bytes
-- node_memory_AnonHugePages_bytes
-- node_memory_AnonPages_bytes
-- node_memory_Bounce_bytes
-- node_memory_Buffers_bytes
-- node_memory_Cached_bytes
-- node_memory_CmaFree_bytes
-- node_memory_CmaTotal_bytes
-- node_memory_CommitLimit_bytes
-- node_memory_Committed_AS_bytes
-- node_memory_DirectMap1G_bytes
-- node_memory_DirectMap2M_bytes
-- node_memory_DirectMap4k_bytes
-- node_memory_Dirty_bytes
-- node_memory_HardwareCorrupted_bytes
-- node_memory_HugePages_Free
-- node_memory_HugePages_Rsvd
-- node_memory_HugePages_Surp
-- node_memory_HugePages_Total
-- node_memory_Hugepagesize_bytes
-- node_memory_Inactive_anon_bytes
-- node_memory_Inactive_bytes
-- node_memory_Inactive_file_bytes
-- node_memory_KernelStack_bytes
-- node_memory_Mapped_bytes
-- node_memory_MemAvailable_bytes
-- node_memory_MemFree_bytes
-- node_memory_MemTotal_bytes
-- node_memory_Mlocked_bytes
-- node_memory_NFS_Unstable_bytes
-- node_memory_PageTables_bytes
-- node_memory_Percpu_bytes
-- node_memory_SReclaimable_bytes
-- node_memory_SUnreclaim_bytes
-- node_memory_ShmemHugePages_bytes
-- node_memory_ShmemPmdMapped_bytes
-- node_memory_Shmem_bytes
-- node_memory_Slab_bytes
-- node_memory_SwapCached_bytes
-- node_memory_SwapFree_bytes
-- node_memory_SwapTotal_bytes
-- node_memory_Unevictable_bytes
-- node_memory_VmallocChunk_bytes
-- node_memory_VmallocTotal_bytes
-- node_memory_VmallocUsed_bytes
-- node_memory_WritebackTmp_bytes
-- node_memory_Writeback_bytes
-- node_memory_Zswap_bytes
-- node_memory_Zswapped_bytes
-- node_netstat_Icmp6_InErrors
-- node_netstat_Icmp6_InMsgs
-- node_netstat_Icmp6_OutMsgs
-- node_netstat_Icmp_InErrors
-- node_netstat_Icmp_InMsgs
-- node_netstat_Icmp_OutMsgs
-- node_netstat_Ip6_InOctets
-- node_netstat_Ip6_OutOctets
-- node_netstat_IpExt_InOctets
-- node_netstat_IpExt_OutOctets
-- node_netstat_Ip_Forwarding
-- node_netstat_TcpExt_ListenDrops
-- node_netstat_TcpExt_ListenOverflows
-- node_netstat_TcpExt_SyncookiesFailed
-- node_netstat_TcpExt_SyncookiesRecv
-- node_netstat_TcpExt_SyncookiesSent
-- node_netstat_TcpExt_TCPOFOQueue
-- node_netstat_TcpExt_TCPRcvQDrop
-- node_netstat_TcpExt_TCPSynRetrans
-- node_netstat_TcpExt_TCPTimeouts
-- node_netstat_Tcp_ActiveOpens
-- node_netstat_Tcp_CurrEstab
-- node_netstat_Tcp_InErrs
-- node_netstat_Tcp_InSegs
-- node_netstat_Tcp_OutRsts
-- node_netstat_Tcp_OutSegs
-- node_netstat_Tcp_PassiveOpens
-- node_netstat_Tcp_RetransSegs
-- node_netstat_Udp6_InDatagrams
-- node_netstat_Udp6_InErrors
-- node_netstat_Udp6_NoPorts
-- node_netstat_Udp6_OutDatagrams
-- node_netstat_Udp6_RcvbufErrors
-- node_netstat_Udp6_SndbufErrors
-- node_netstat_UdpLite6_InErrors
-- node_netstat_UdpLite_InErrors
-- node_netstat_Udp_InDatagrams
-- node_netstat_Udp_InErrors
-- node_netstat_Udp_NoPorts
-- node_netstat_Udp_OutDatagrams
-- node_netstat_Udp_RcvbufErrors
-- node_netstat_Udp_SndbufErrors
-- node_network_address_assign_type
-- node_network_carrier
-- node_network_carrier_changes_total
-- node_network_carrier_down_changes_total
-- node_network_carrier_up_changes_total
-- node_network_device_id
-- node_network_dormant
-- node_network_flags
-- node_network_iface_id
-- node_network_iface_link
-- node_network_iface_link_mode
-- node_network_info
-- node_network_mtu_bytes
-- node_network_name_assign_type
-- node_network_net_dev_group
-- node_network_protocol_type
-- node_network_receive_bytes_total
-- node_network_receive_compressed_total
-- node_network_receive_drop_total
-- node_network_receive_errs_total
-- node_network_receive_fifo_total
-- node_network_receive_frame_total
-- node_network_receive_multicast_total
-- node_network_receive_nohandler_total
-- node_network_receive_packets_total
-- node_network_speed_bytes
-- node_network_transmit_bytes_total
-- node_network_transmit_carrier_total
-- node_network_transmit_colls_total
-- node_network_transmit_compressed_total
-- node_network_transmit_drop_total
-- node_network_transmit_errs_total
-- node_network_transmit_fifo_total
-- node_network_transmit_packets_total
-- node_network_transmit_queue_length
-- node_network_up
-- node_nf_conntrack_entries
-- node_nf_conntrack_entries_limit
-- node_nfs_connections_total
-- node_nfs_packets_total
-- node_nfs_requests_total
-- node_nfs_rpc_authentication_refreshes_total
-- node_nfs_rpc_retransmissions_total
-- node_nfs_rpcs_total
-- node_os_info
-- node_os_version
-- node_pressure_cpu_waiting_seconds_total
-- node_pressure_io_stalled_seconds_total
-- node_pressure_io_waiting_seconds_total
-- node_pressure_memory_stalled_seconds_total
-- node_pressure_memory_waiting_seconds_total
-- node_procs_blocked
-- node_procs_running
-- node_schedstat_running_seconds_total
-- node_schedstat_timeslices_total
-- node_schedstat_waiting_seconds_total
-- node_scrape_collector_duration_seconds
-- node_scrape_collector_success
-- node_selinux_enabled
-- node_sockstat_FRAG6_inuse
-- node_sockstat_FRAG6_memory
-- node_sockstat_FRAG_inuse
-- node_sockstat_FRAG_memory
-- node_sockstat_RAW6_inuse
-- node_sockstat_RAW_inuse
-- node_sockstat_TCP6_inuse
-- node_sockstat_TCP_alloc
-- node_sockstat_TCP_inuse
-- node_sockstat_TCP_mem
-- node_sockstat_TCP_mem_bytes
-- node_sockstat_TCP_orphan
-- node_sockstat_TCP_tw
-- node_sockstat_UDP6_inuse
-- node_sockstat_UDPLITE6_inuse
-- node_sockstat_UDPLITE_inuse
-- node_sockstat_UDP_inuse
-- node_sockstat_UDP_mem
-- node_sockstat_UDP_mem_bytes
-- node_sockstat_sockets_used
-- node_softnet_backlog_len
-- node_softnet_cpu_collision_total
-- node_softnet_dropped_total
-- node_softnet_flow_limit_count_total
-- node_softnet_processed_total
-- node_softnet_received_rps_total
-- node_softnet_times_squeezed_total
-- node_textfile_scrape_error
-- node_time_clocksource_available_info
-- node_time_clocksource_current_info
-- node_time_seconds
-- node_time_zone_offset_seconds
-- node_timex_estimated_error_seconds
-- node_timex_frequency_adjustment_ratio
-- node_timex_loop_time_constant
-- node_timex_maxerror_seconds
-- node_timex_offset_seconds
-- node_timex_pps_calibration_total
-- node_timex_pps_error_total
-- node_timex_pps_frequency_hertz
-- node_timex_pps_jitter_seconds
-- node_timex_pps_jitter_total
-- node_timex_pps_shift_seconds
-- node_timex_pps_stability_exceeded_total
-- node_timex_pps_stability_hertz
-- node_timex_status
-- node_timex_sync_status
-- node_timex_tai_offset_seconds
-- node_timex_tick_seconds
-- node_udp_queues
-- node_uname_info
-- node_vmstat_oom_kill
-- node_vmstat_pgfault
-- node_vmstat_pgmajfault
-- node_vmstat_pgpgin
-- node_vmstat_pgpgout
-- node_vmstat_pswpin
-- node_vmstat_pswpout
-- node_zfs_abd_linear_cnt
-- node_zfs_abd_linear_data_size
-- node_zfs_abd_scatter_chunk_waste
-- node_zfs_abd_scatter_cnt
-- node_zfs_abd_scatter_data_size
-- node_zfs_abd_scatter_order_0
-- node_zfs_abd_scatter_order_1
-- node_zfs_abd_scatter_order_2
-- node_zfs_abd_scatter_order_3
-- node_zfs_abd_scatter_order_4
-- node_zfs_abd_scatter_order_5
-- node_zfs_abd_scatter_order_6
-- node_zfs_abd_scatter_order_7
-- node_zfs_abd_scatter_order_8
-- node_zfs_abd_scatter_order_9
-- node_zfs_abd_scatter_page_alloc_retry
-- node_zfs_abd_scatter_page_multi_chunk
-- node_zfs_abd_scatter_page_multi_zone
-- node_zfs_abd_scatter_sg_table_retry
-- node_zfs_abd_struct_size
-- node_zfs_arc_abd_chunk_waste_size
-- node_zfs_arc_access_skip
-- node_zfs_arc_anon_data
-- node_zfs_arc_anon_evictable_data
-- node_zfs_arc_anon_evictable_metadata
-- node_zfs_arc_anon_metadata
-- node_zfs_arc_anon_size
-- node_zfs_arc_arc_dnode_limit
-- node_zfs_arc_arc_loaned_bytes
-- node_zfs_arc_arc_meta_used
-- node_zfs_arc_arc_need_free
-- node_zfs_arc_arc_no_grow
-- node_zfs_arc_arc_prune
-- node_zfs_arc_arc_raw_size
-- node_zfs_arc_arc_sys_free
-- node_zfs_arc_arc_tempreserve
-- node_zfs_arc_async_upgrade_sync
-- node_zfs_arc_bonus_size
-- node_zfs_arc_c
-- node_zfs_arc_c_max
-- node_zfs_arc_c_min
-- node_zfs_arc_cached_only_in_progress
-- node_zfs_arc_compressed_size
-- node_zfs_arc_data_size
-- node_zfs_arc_dbuf_size
-- node_zfs_arc_deleted
-- node_zfs_arc_demand_data_hits
-- node_zfs_arc_demand_data_iohits
-- node_zfs_arc_demand_data_misses
-- node_zfs_arc_demand_hit_predictive_prefetch
-- node_zfs_arc_demand_hit_prescient_prefetch
-- node_zfs_arc_demand_iohit_predictive_prefetch
-- node_zfs_arc_demand_iohit_prescient_prefetch
-- node_zfs_arc_demand_metadata_hits
-- node_zfs_arc_demand_metadata_iohits
-- node_zfs_arc_demand_metadata_misses
-- node_zfs_arc_dnode_size
-- node_zfs_arc_evict_l2_cached
-- node_zfs_arc_evict_l2_eligible
-- node_zfs_arc_evict_l2_eligible_mfu
-- node_zfs_arc_evict_l2_eligible_mru
-- node_zfs_arc_evict_l2_ineligible
-- node_zfs_arc_evict_l2_skip
-- node_zfs_arc_evict_not_enough
-- node_zfs_arc_evict_skip
-- node_zfs_arc_hash_chain_max
-- node_zfs_arc_hash_chains
-- node_zfs_arc_hash_collisions
-- node_zfs_arc_hash_elements
-- node_zfs_arc_hash_elements_max
-- node_zfs_arc_hdr_size
-- node_zfs_arc_hits
-- node_zfs_arc_iohits
-- node_zfs_arc_l2_abort_lowmem
-- node_zfs_arc_l2_asize
-- node_zfs_arc_l2_bufc_data_asize
-- node_zfs_arc_l2_bufc_metadata_asize
-- node_zfs_arc_l2_cksum_bad
-- node_zfs_arc_l2_data_to_meta_ratio
-- node_zfs_arc_l2_evict_l1cached
-- node_zfs_arc_l2_evict_lock_retry
-- node_zfs_arc_l2_evict_reading
-- node_zfs_arc_l2_feeds
-- node_zfs_arc_l2_free_on_write
-- node_zfs_arc_l2_hdr_size
-- node_zfs_arc_l2_hits
-- node_zfs_arc_l2_io_error
-- node_zfs_arc_l2_log_blk_asize
-- node_zfs_arc_l2_log_blk_avg_asize
-- node_zfs_arc_l2_log_blk_count
-- node_zfs_arc_l2_log_blk_writes
-- node_zfs_arc_l2_mfu_asize
-- node_zfs_arc_l2_misses
-- node_zfs_arc_l2_mru_asize
-- node_zfs_arc_l2_ndev
-- node_zfs_arc_l2_prefetch_asize
-- node_zfs_arc_l2_read_bytes
-- node_zfs_arc_l2_rebuild_asize
-- node_zfs_arc_l2_rebuild_bufs
-- node_zfs_arc_l2_rebuild_bufs_precached
-- node_zfs_arc_l2_rebuild_cksum_lb_errors
-- node_zfs_arc_l2_rebuild_dh_errors
-- node_zfs_arc_l2_rebuild_io_errors
-- node_zfs_arc_l2_rebuild_log_blks
-- node_zfs_arc_l2_rebuild_lowmem
-- node_zfs_arc_l2_rebuild_size
-- node_zfs_arc_l2_rebuild_success
-- node_zfs_arc_l2_rebuild_unsupported
-- node_zfs_arc_l2_rw_clash
-- node_zfs_arc_l2_size
-- node_zfs_arc_l2_write_bytes
-- node_zfs_arc_l2_writes_done
-- node_zfs_arc_l2_writes_error
-- node_zfs_arc_l2_writes_lock_retry
-- node_zfs_arc_l2_writes_sent
-- node_zfs_arc_memory_all_bytes
-- node_zfs_arc_memory_available_bytes
-- node_zfs_arc_memory_direct_count
-- node_zfs_arc_memory_free_bytes
-- node_zfs_arc_memory_indirect_count
-- node_zfs_arc_memory_throttle_count
-- node_zfs_arc_meta
-- node_zfs_arc_metadata_size
-- node_zfs_arc_mfu_data
-- node_zfs_arc_mfu_evictable_data
-- node_zfs_arc_mfu_evictable_metadata
-- node_zfs_arc_mfu_ghost_data
-- node_zfs_arc_mfu_ghost_evictable_data
-- node_zfs_arc_mfu_ghost_evictable_metadata
-- node_zfs_arc_mfu_ghost_hits
-- node_zfs_arc_mfu_ghost_metadata
-- node_zfs_arc_mfu_ghost_size
-- node_zfs_arc_mfu_hits
-- node_zfs_arc_mfu_metadata
-- node_zfs_arc_mfu_size
-- node_zfs_arc_misses
-- node_zfs_arc_mru_data
-- node_zfs_arc_mru_evictable_data
-- node_zfs_arc_mru_evictable_metadata
-- node_zfs_arc_mru_ghost_data
-- node_zfs_arc_mru_ghost_evictable_data
-- node_zfs_arc_mru_ghost_evictable_metadata
-- node_zfs_arc_mru_ghost_hits
-- node_zfs_arc_mru_ghost_metadata
-- node_zfs_arc_mru_ghost_size
-- node_zfs_arc_mru_hits
-- node_zfs_arc_mru_metadata
-- node_zfs_arc_mru_size
-- node_zfs_arc_mutex_miss
-- node_zfs_arc_overhead_size
-- node_zfs_arc_pd
-- node_zfs_arc_pm
-- node_zfs_arc_predictive_prefetch
-- node_zfs_arc_prefetch_data_hits
-- node_zfs_arc_prefetch_data_iohits
-- node_zfs_arc_prefetch_data_misses
-- node_zfs_arc_prefetch_metadata_hits
-- node_zfs_arc_prefetch_metadata_iohits
-- node_zfs_arc_prefetch_metadata_misses
-- node_zfs_arc_prescient_prefetch
-- node_zfs_arc_size
-- node_zfs_arc_uncached_data
-- node_zfs_arc_uncached_evictable_data
-- node_zfs_arc_uncached_evictable_metadata
-- node_zfs_arc_uncached_hits
-- node_zfs_arc_uncached_metadata
-- node_zfs_arc_uncached_size
-- node_zfs_arc_uncompressed_size
-- node_zfs_dbuf_cache_count
-- node_zfs_dbuf_cache_hiwater_bytes
-- node_zfs_dbuf_cache_level_0
-- node_zfs_dbuf_cache_level_0_bytes
-- node_zfs_dbuf_cache_level_1
-- node_zfs_dbuf_cache_level_10
-- node_zfs_dbuf_cache_level_10_bytes
-- node_zfs_dbuf_cache_level_11
-- node_zfs_dbuf_cache_level_11_bytes
-- node_zfs_dbuf_cache_level_1_bytes
-- node_zfs_dbuf_cache_level_2
-- node_zfs_dbuf_cache_level_2_bytes
-- node_zfs_dbuf_cache_level_3
-- node_zfs_dbuf_cache_level_3_bytes
-- node_zfs_dbuf_cache_level_4
-- node_zfs_dbuf_cache_level_4_bytes
-- node_zfs_dbuf_cache_level_5
-- node_zfs_dbuf_cache_level_5_bytes
-- node_zfs_dbuf_cache_level_6
-- node_zfs_dbuf_cache_level_6_bytes
-- node_zfs_dbuf_cache_level_7
-- node_zfs_dbuf_cache_level_7_bytes
-- node_zfs_dbuf_cache_level_8
-- node_zfs_dbuf_cache_level_8_bytes
-- node_zfs_dbuf_cache_level_9
-- node_zfs_dbuf_cache_level_9_bytes
-- node_zfs_dbuf_cache_lowater_bytes
-- node_zfs_dbuf_cache_size_bytes
-- node_zfs_dbuf_cache_size_bytes_max
-- node_zfs_dbuf_cache_target_bytes
-- node_zfs_dbuf_cache_total_evicts
-- node_zfs_dbuf_hash_chain_max
-- node_zfs_dbuf_hash_chains
-- node_zfs_dbuf_hash_collisions
-- node_zfs_dbuf_hash_elements
-- node_zfs_dbuf_hash_hits
-- node_zfs_dbuf_hash_insert_race
-- node_zfs_dbuf_hash_misses
-- node_zfs_dbuf_hash_mutex_count
-- node_zfs_dbuf_hash_table_count
-- node_zfs_dbuf_metadata_cache_count
-- node_zfs_dbuf_metadata_cache_overflow
-- node_zfs_dbuf_metadata_cache_size_bytes
-- node_zfs_dbuf_metadata_cache_size_bytes_max
-- node_zfs_dmu_tx_dmu_tx_assigned
-- node_zfs_dmu_tx_dmu_tx_delay
-- node_zfs_dmu_tx_dmu_tx_dirty_delay
-- node_zfs_dmu_tx_dmu_tx_dirty_frees_delay
-- node_zfs_dmu_tx_dmu_tx_dirty_over_max
-- node_zfs_dmu_tx_dmu_tx_dirty_throttle
-- node_zfs_dmu_tx_dmu_tx_error
-- node_zfs_dmu_tx_dmu_tx_group
-- node_zfs_dmu_tx_dmu_tx_memory_reclaim
-- node_zfs_dmu_tx_dmu_tx_memory_reserve
-- node_zfs_dmu_tx_dmu_tx_quota
-- node_zfs_dmu_tx_dmu_tx_suspended
-- node_zfs_dmu_tx_dmu_tx_wrlog_delay
-- node_zfs_dnode_dnode_alloc_next_block
-- node_zfs_dnode_dnode_alloc_next_chunk
-- node_zfs_dnode_dnode_alloc_race
-- node_zfs_dnode_dnode_allocate
-- node_zfs_dnode_dnode_buf_evict
-- node_zfs_dnode_dnode_free_interior_lock_retry
-- node_zfs_dnode_dnode_hold_alloc_hits
-- node_zfs_dnode_dnode_hold_alloc_interior
-- node_zfs_dnode_dnode_hold_alloc_lock_misses
-- node_zfs_dnode_dnode_hold_alloc_lock_retry
-- node_zfs_dnode_dnode_hold_alloc_misses
-- node_zfs_dnode_dnode_hold_alloc_type_none
-- node_zfs_dnode_dnode_hold_dbuf_hold
-- node_zfs_dnode_dnode_hold_dbuf_read
-- node_zfs_dnode_dnode_hold_free_hits
-- node_zfs_dnode_dnode_hold_free_lock_misses
-- node_zfs_dnode_dnode_hold_free_lock_retry
-- node_zfs_dnode_dnode_hold_free_misses
-- node_zfs_dnode_dnode_hold_free_overflow
-- node_zfs_dnode_dnode_hold_free_refcount
-- node_zfs_dnode_dnode_move_active
-- node_zfs_dnode_dnode_move_handle
-- node_zfs_dnode_dnode_move_invalid
-- node_zfs_dnode_dnode_move_recheck1
-- node_zfs_dnode_dnode_move_recheck2
-- node_zfs_dnode_dnode_move_rwlock
-- node_zfs_dnode_dnode_move_special
-- node_zfs_dnode_dnode_reallocate
-- node_zfs_fm_erpt_dropped
-- node_zfs_fm_erpt_duplicates
-- node_zfs_fm_erpt_set_failed
-- node_zfs_fm_fmri_set_failed
-- node_zfs_fm_payload_set_failed
-- node_zfs_vdev_mirror_non_rotating_linear
-- node_zfs_vdev_mirror_non_rotating_seek
-- node_zfs_vdev_mirror_preferred_found
-- node_zfs_vdev_mirror_preferred_not_found
-- node_zfs_vdev_mirror_rotating_linear
-- node_zfs_vdev_mirror_rotating_offset
-- node_zfs_vdev_mirror_rotating_seek
-- node_zfs_zfetch_future
-- node_zfs_zfetch_hits
-- node_zfs_zfetch_io_active
-- node_zfs_zfetch_io_issued
-- node_zfs_zfetch_max_streams
-- node_zfs_zfetch_misses
-- node_zfs_zfetch_past
-- node_zfs_zfetch_stride
-- node_zfs_zil_zil_commit_count
-- node_zfs_zil_zil_commit_crash_count
-- node_zfs_zil_zil_commit_error_count
-- node_zfs_zil_zil_commit_stall_count
-- node_zfs_zil_zil_commit_suspend_count
-- node_zfs_zil_zil_commit_writer_count
-- node_zfs_zil_zil_itx_copied_bytes
-- node_zfs_zil_zil_itx_copied_count
-- node_zfs_zil_zil_itx_count
-- node_zfs_zil_zil_itx_indirect_bytes
-- node_zfs_zil_zil_itx_indirect_count
-- node_zfs_zil_zil_itx_metaslab_normal_alloc
-- node_zfs_zil_zil_itx_metaslab_normal_bytes
-- node_zfs_zil_zil_itx_metaslab_normal_count
-- node_zfs_zil_zil_itx_metaslab_normal_write
-- node_zfs_zil_zil_itx_metaslab_slog_alloc
-- node_zfs_zil_zil_itx_metaslab_slog_bytes
-- node_zfs_zil_zil_itx_metaslab_slog_count
-- node_zfs_zil_zil_itx_metaslab_slog_write
-- node_zfs_zil_zil_itx_needcopy_bytes
-- node_zfs_zil_zil_itx_needcopy_count
-- process_cpu_seconds_total
-- process_max_fds
-- process_network_receive_bytes_total
-- process_network_transmit_bytes_total
-- process_open_fds
-- process_resident_memory_bytes
-- process_start_time_seconds
-- process_virtual_memory_bytes
-- process_virtual_memory_max_bytes
-- promhttp_metric_handler_errors_total
-- promhttp_metric_handler_requests_in_flight
-- promhttp_metric_handler_requests_total
-- scrape_duration_seconds
-- scrape_samples_post_metric_relabeling
-- scrape_samples_scraped
-- scrape_series_added
-- up
+## 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`.
-## Playout Metrics
+## 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.
-- ALERTS
-- ALERTS_FOR_STATE
-- linearvoid_playout_mode
-- linearvoid_playout_restarts_total
-- scrape_duration_seconds
-- scrape_samples_post_metric_relabeling
-- scrape_samples_scraped
-- scrape_series_added
-- up
+## 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.
-## Postgres Metrics
+## 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.
-- go_gc_duration_seconds
-- go_gc_duration_seconds_count
-- go_gc_duration_seconds_sum
-- go_gc_gogc_percent
-- go_gc_gomemlimit_bytes
-- go_goroutines
-- go_info
-- go_memstats_alloc_bytes
-- go_memstats_alloc_bytes_total
-- go_memstats_buck_hash_sys_bytes
-- go_memstats_frees_total
-- go_memstats_gc_sys_bytes
-- go_memstats_heap_alloc_bytes
-- go_memstats_heap_idle_bytes
-- go_memstats_heap_inuse_bytes
-- go_memstats_heap_objects
-- go_memstats_heap_released_bytes
-- go_memstats_heap_sys_bytes
-- go_memstats_last_gc_time_seconds
-- go_memstats_mallocs_total
-- go_memstats_mcache_inuse_bytes
-- go_memstats_mcache_sys_bytes
-- go_memstats_mspan_inuse_bytes
-- go_memstats_mspan_sys_bytes
-- go_memstats_next_gc_bytes
-- go_memstats_other_sys_bytes
-- go_memstats_stack_inuse_bytes
-- go_memstats_stack_sys_bytes
-- go_memstats_sys_bytes
-- go_sched_gomaxprocs_threads
-- go_threads
-- pg_database_connection_limit
-- pg_database_size_bytes
-- pg_exporter_last_scrape_duration_seconds
-- pg_exporter_last_scrape_error
-- pg_exporter_scrapes_total
-- pg_locks_count
-- pg_replication_is_replica
-- pg_replication_lag_seconds
-- pg_replication_last_replay_seconds
-- pg_roles_connection_limit
-- pg_scrape_collector_duration_seconds
-- pg_scrape_collector_success
-- pg_settings_allow_in_place_tablespaces
-- pg_settings_allow_system_table_mods
-- pg_settings_archive_timeout_seconds
-- pg_settings_array_nulls
-- pg_settings_authentication_timeout_seconds
-- pg_settings_autovacuum
-- pg_settings_autovacuum_analyze_scale_factor
-- pg_settings_autovacuum_analyze_threshold
-- pg_settings_autovacuum_freeze_max_age
-- pg_settings_autovacuum_max_workers
-- pg_settings_autovacuum_multixact_freeze_max_age
-- pg_settings_autovacuum_naptime_seconds
-- pg_settings_autovacuum_vacuum_cost_delay_seconds
-- pg_settings_autovacuum_vacuum_cost_limit
-- pg_settings_autovacuum_vacuum_insert_scale_factor
-- pg_settings_autovacuum_vacuum_insert_threshold
-- pg_settings_autovacuum_vacuum_scale_factor
-- pg_settings_autovacuum_vacuum_threshold
-- pg_settings_autovacuum_work_mem_bytes
-- pg_settings_backend_flush_after_bytes
-- pg_settings_bgwriter_delay_seconds
-- pg_settings_bgwriter_flush_after_bytes
-- pg_settings_bgwriter_lru_maxpages
-- pg_settings_bgwriter_lru_multiplier
-- pg_settings_block_size
-- pg_settings_bonjour
-- pg_settings_check_function_bodies
-- pg_settings_checkpoint_completion_target
-- pg_settings_checkpoint_flush_after_bytes
-- pg_settings_checkpoint_timeout_seconds
-- pg_settings_checkpoint_warning_seconds
-- pg_settings_client_connection_check_interval_seconds
-- pg_settings_commit_delay
-- pg_settings_commit_siblings
-- pg_settings_cpu_index_tuple_cost
-- pg_settings_cpu_operator_cost
-- pg_settings_cpu_tuple_cost
-- pg_settings_cursor_tuple_fraction
-- pg_settings_data_checksums
-- pg_settings_data_directory_mode
-- pg_settings_data_sync_retry
-- pg_settings_db_user_namespace
-- pg_settings_deadlock_timeout_seconds
-- pg_settings_debug_assertions
-- pg_settings_debug_discard_caches
-- pg_settings_debug_pretty_print
-- pg_settings_debug_print_parse
-- pg_settings_debug_print_plan
-- pg_settings_debug_print_rewritten
-- pg_settings_default_statistics_target
-- pg_settings_default_transaction_deferrable
-- pg_settings_default_transaction_read_only
-- pg_settings_effective_cache_size_bytes
-- pg_settings_effective_io_concurrency
-- pg_settings_enable_async_append
-- pg_settings_enable_bitmapscan
-- pg_settings_enable_gathermerge
-- pg_settings_enable_hashagg
-- pg_settings_enable_hashjoin
-- pg_settings_enable_incremental_sort
-- pg_settings_enable_indexonlyscan
-- pg_settings_enable_indexscan
-- pg_settings_enable_material
-- pg_settings_enable_memoize
-- pg_settings_enable_mergejoin
-- pg_settings_enable_nestloop
-- pg_settings_enable_parallel_append
-- pg_settings_enable_parallel_hash
-- pg_settings_enable_partition_pruning
-- pg_settings_enable_partitionwise_aggregate
-- pg_settings_enable_partitionwise_join
-- pg_settings_enable_seqscan
-- pg_settings_enable_sort
-- pg_settings_enable_tidscan
-- pg_settings_escape_string_warning
-- pg_settings_exit_on_error
-- pg_settings_extra_float_digits
-- pg_settings_from_collapse_limit
-- pg_settings_fsync
-- pg_settings_full_page_writes
-- pg_settings_geqo
-- pg_settings_geqo_effort
-- pg_settings_geqo_generations
-- pg_settings_geqo_pool_size
-- pg_settings_geqo_seed
-- pg_settings_geqo_selection_bias
-- pg_settings_geqo_threshold
-- pg_settings_gin_fuzzy_search_limit
-- pg_settings_gin_pending_list_limit_bytes
-- pg_settings_hash_mem_multiplier
-- pg_settings_hot_standby
-- pg_settings_hot_standby_feedback
-- pg_settings_huge_page_size_bytes
-- pg_settings_idle_in_transaction_session_timeout_seconds
-- pg_settings_idle_session_timeout_seconds
-- pg_settings_ignore_checksum_failure
-- pg_settings_ignore_invalid_pages
-- pg_settings_ignore_system_indexes
-- pg_settings_in_hot_standby
-- pg_settings_integer_datetimes
-- pg_settings_jit
-- pg_settings_jit_above_cost
-- pg_settings_jit_debugging_support
-- pg_settings_jit_dump_bitcode
-- pg_settings_jit_expressions
-- pg_settings_jit_inline_above_cost
-- pg_settings_jit_optimize_above_cost
-- pg_settings_jit_profiling_support
-- pg_settings_jit_tuple_deforming
-- pg_settings_join_collapse_limit
-- pg_settings_krb_caseins_users
-- pg_settings_lo_compat_privileges
-- pg_settings_lock_timeout_seconds
-- pg_settings_log_autovacuum_min_duration_seconds
-- pg_settings_log_checkpoints
-- pg_settings_log_connections
-- pg_settings_log_disconnections
-- pg_settings_log_duration
-- pg_settings_log_executor_stats
-- pg_settings_log_file_mode
-- pg_settings_log_hostname
-- pg_settings_log_lock_waits
-- pg_settings_log_min_duration_sample_seconds
-- pg_settings_log_min_duration_statement_seconds
-- pg_settings_log_parameter_max_length_bytes
-- pg_settings_log_parameter_max_length_on_error_bytes
-- pg_settings_log_parser_stats
-- pg_settings_log_planner_stats
-- pg_settings_log_recovery_conflict_waits
-- pg_settings_log_replication_commands
-- pg_settings_log_rotation_age_seconds
-- pg_settings_log_rotation_size_bytes
-- pg_settings_log_startup_progress_interval_seconds
-- pg_settings_log_statement_sample_rate
-- pg_settings_log_statement_stats
-- pg_settings_log_temp_files_bytes
-- pg_settings_log_transaction_sample_rate
-- pg_settings_log_truncate_on_rotation
-- pg_settings_logging_collector
-- pg_settings_logical_decoding_work_mem_bytes
-- pg_settings_maintenance_io_concurrency
-- pg_settings_maintenance_work_mem_bytes
-- pg_settings_max_connections
-- pg_settings_max_files_per_process
-- pg_settings_max_function_args
-- pg_settings_max_identifier_length
-- pg_settings_max_index_keys
-- pg_settings_max_locks_per_transaction
-- pg_settings_max_logical_replication_workers
-- pg_settings_max_parallel_maintenance_workers
-- pg_settings_max_parallel_workers
-- pg_settings_max_parallel_workers_per_gather
-- pg_settings_max_pred_locks_per_page
-- pg_settings_max_pred_locks_per_relation
-- pg_settings_max_pred_locks_per_transaction
-- pg_settings_max_prepared_transactions
-- pg_settings_max_replication_slots
-- pg_settings_max_slot_wal_keep_size_bytes
-- pg_settings_max_stack_depth_bytes
-- pg_settings_max_standby_archive_delay_seconds
-- pg_settings_max_standby_streaming_delay_seconds
-- pg_settings_max_sync_workers_per_subscription
-- pg_settings_max_wal_senders
-- pg_settings_max_wal_size_bytes
-- pg_settings_max_worker_processes
-- pg_settings_min_dynamic_shared_memory_bytes
-- pg_settings_min_parallel_index_scan_size_bytes
-- pg_settings_min_parallel_table_scan_size_bytes
-- pg_settings_min_wal_size_bytes
-- pg_settings_old_snapshot_threshold_seconds
-- pg_settings_parallel_leader_participation
-- pg_settings_parallel_setup_cost
-- pg_settings_parallel_tuple_cost
-- pg_settings_port
-- pg_settings_post_auth_delay_seconds
-- pg_settings_pre_auth_delay_seconds
-- pg_settings_quote_all_identifiers
-- pg_settings_random_page_cost
-- pg_settings_recovery_min_apply_delay_seconds
-- pg_settings_recovery_target_inclusive
-- pg_settings_recursive_worktable_factor
-- pg_settings_remove_temp_files_after_crash
-- pg_settings_restart_after_crash
-- pg_settings_row_security
-- pg_settings_segment_size_bytes
-- pg_settings_seq_page_cost
-- pg_settings_server_version_num
-- pg_settings_shared_buffers_bytes
-- pg_settings_shared_memory_size_bytes
-- pg_settings_shared_memory_size_in_huge_pages
-- pg_settings_ssl
-- pg_settings_ssl_passphrase_command_supports_reload
-- pg_settings_ssl_prefer_server_ciphers
-- pg_settings_standard_conforming_strings
-- pg_settings_statement_timeout_seconds
-- pg_settings_superuser_reserved_connections
-- pg_settings_synchronize_seqscans
-- pg_settings_syslog_sequence_numbers
-- pg_settings_syslog_split_messages
-- pg_settings_tcp_keepalives_count
-- pg_settings_tcp_keepalives_idle_seconds
-- pg_settings_tcp_keepalives_interval_seconds
-- pg_settings_tcp_user_timeout_seconds
-- pg_settings_temp_buffers_bytes
-- pg_settings_temp_file_limit_bytes
-- pg_settings_trace_notify
-- pg_settings_trace_sort
-- pg_settings_track_activities
-- pg_settings_track_activity_query_size_bytes
-- pg_settings_track_commit_timestamp
-- pg_settings_track_counts
-- pg_settings_track_io_timing
-- pg_settings_track_wal_io_timing
-- pg_settings_transaction_deferrable
-- pg_settings_transaction_read_only
-- pg_settings_transform_null_equals
-- pg_settings_unix_socket_permissions
-- pg_settings_update_process_title
-- pg_settings_vacuum_cost_delay_seconds
-- pg_settings_vacuum_cost_limit
-- pg_settings_vacuum_cost_page_dirty
-- pg_settings_vacuum_cost_page_hit
-- pg_settings_vacuum_cost_page_miss
-- pg_settings_vacuum_defer_cleanup_age
-- pg_settings_vacuum_failsafe_age
-- pg_settings_vacuum_freeze_min_age
-- pg_settings_vacuum_freeze_table_age
-- pg_settings_vacuum_multixact_failsafe_age
-- pg_settings_vacuum_multixact_freeze_min_age
-- pg_settings_vacuum_multixact_freeze_table_age
-- pg_settings_wal_block_size
-- pg_settings_wal_buffers_bytes
-- pg_settings_wal_decode_buffer_size_bytes
-- pg_settings_wal_init_zero
-- pg_settings_wal_keep_size_bytes
-- pg_settings_wal_log_hints
-- pg_settings_wal_receiver_create_temp_slot
-- pg_settings_wal_receiver_status_interval_seconds
-- pg_settings_wal_receiver_timeout_seconds
-- pg_settings_wal_recycle
-- pg_settings_wal_retrieve_retry_interval_seconds
-- pg_settings_wal_segment_size_bytes
-- pg_settings_wal_sender_timeout_seconds
-- pg_settings_wal_skip_threshold_bytes
-- pg_settings_wal_writer_delay_seconds
-- pg_settings_wal_writer_flush_after_bytes
-- pg_settings_work_mem_bytes
-- pg_settings_zero_damaged_pages
-- pg_stat_activity_count
-- pg_stat_activity_max_tx_duration
-- pg_stat_archiver_archived_count
-- pg_stat_archiver_failed_count
-- pg_stat_bgwriter_buffers_alloc_total
-- pg_stat_bgwriter_buffers_backend_fsync_total
-- pg_stat_bgwriter_buffers_backend_total
-- pg_stat_bgwriter_buffers_checkpoint_total
-- pg_stat_bgwriter_buffers_clean_total
-- pg_stat_bgwriter_checkpoint_sync_time_total
-- pg_stat_bgwriter_checkpoint_write_time_total
-- pg_stat_bgwriter_checkpoints_req_total
-- pg_stat_bgwriter_checkpoints_timed_total
-- pg_stat_bgwriter_maxwritten_clean_total
-- pg_stat_bgwriter_stats_reset_total
-- pg_stat_database_active_time_seconds_total
-- pg_stat_database_blk_read_time
-- pg_stat_database_blk_write_time
-- pg_stat_database_blks_hit
-- pg_stat_database_blks_read
-- pg_stat_database_conflicts
-- pg_stat_database_conflicts_confl_bufferpin
-- pg_stat_database_conflicts_confl_deadlock
-- pg_stat_database_conflicts_confl_lock
-- pg_stat_database_conflicts_confl_snapshot
-- pg_stat_database_conflicts_confl_tablespace
-- pg_stat_database_deadlocks
-- pg_stat_database_numbackends
-- pg_stat_database_stats_reset
-- pg_stat_database_temp_bytes
-- pg_stat_database_temp_files
-- pg_stat_database_tup_deleted
-- pg_stat_database_tup_fetched
-- pg_stat_database_tup_inserted
-- pg_stat_database_tup_returned
-- pg_stat_database_tup_updated
-- pg_stat_database_xact_commit
-- pg_stat_database_xact_rollback
-- pg_stat_user_tables_analyze_count
-- pg_stat_user_tables_autoanalyze_count
-- pg_stat_user_tables_autovacuum_count
-- pg_stat_user_tables_idx_scan
-- pg_stat_user_tables_idx_tup_fetch
-- pg_stat_user_tables_index_size_bytes
-- pg_stat_user_tables_last_analyze
-- pg_stat_user_tables_last_autoanalyze
-- pg_stat_user_tables_last_autovacuum
-- pg_stat_user_tables_last_vacuum
-- pg_stat_user_tables_n_dead_tup
-- pg_stat_user_tables_n_live_tup
-- pg_stat_user_tables_n_mod_since_analyze
-- pg_stat_user_tables_n_tup_del
-- pg_stat_user_tables_n_tup_hot_upd
-- pg_stat_user_tables_n_tup_ins
-- pg_stat_user_tables_n_tup_upd
-- pg_stat_user_tables_seq_scan
-- pg_stat_user_tables_seq_tup_read
-- pg_stat_user_tables_table_size_bytes
-- pg_stat_user_tables_vacuum_count
-- pg_static
-- pg_statio_user_tables_heap_blocks_hit
-- pg_statio_user_tables_heap_blocks_read
-- pg_statio_user_tables_idx_blocks_hit
-- pg_statio_user_tables_idx_blocks_read
-- pg_statio_user_tables_tidx_blocks_hit
-- pg_statio_user_tables_tidx_blocks_read
-- pg_statio_user_tables_toast_blocks_hit
-- pg_statio_user_tables_toast_blocks_read
-- pg_up
-- pg_wal_segments
-- pg_wal_size_bytes
-- postgres_exporter_build_info
-- postgres_exporter_config_last_reload_success_timestamp_seconds
-- postgres_exporter_config_last_reload_successful
-- process_cpu_seconds_total
-- process_max_fds
-- process_network_receive_bytes_total
-- process_network_transmit_bytes_total
-- process_open_fds
-- process_resident_memory_bytes
-- process_start_time_seconds
-- process_virtual_memory_bytes
-- process_virtual_memory_max_bytes
-- promhttp_metric_handler_requests_in_flight
-- promhttp_metric_handler_requests_total
-- scrape_duration_seconds
-- scrape_samples_post_metric_relabeling
-- scrape_samples_scraped
-- scrape_series_added
-- up
-
-## Prometheus Metrics
-
-- go_gc_cleanups_executed_cleanups_total
-- go_gc_cleanups_queued_cleanups_total
-- go_gc_cycles_automatic_gc_cycles_total
-- go_gc_cycles_forced_gc_cycles_total
-- go_gc_cycles_total_gc_cycles_total
-- go_gc_duration_seconds
-- go_gc_duration_seconds_count
-- go_gc_duration_seconds_sum
-- go_gc_finalizers_executed_finalizers_total
-- go_gc_finalizers_queued_finalizers_total
-- go_gc_gogc_percent
-- go_gc_gomemlimit_bytes
-- go_gc_heap_allocs_by_size_bytes_bucket
-- go_gc_heap_allocs_by_size_bytes_count
-- go_gc_heap_allocs_by_size_bytes_sum
-- go_gc_heap_allocs_bytes_total
-- go_gc_heap_allocs_objects_total
-- go_gc_heap_frees_by_size_bytes_bucket
-- go_gc_heap_frees_by_size_bytes_count
-- go_gc_heap_frees_by_size_bytes_sum
-- go_gc_heap_frees_bytes_total
-- go_gc_heap_frees_objects_total
-- go_gc_heap_goal_bytes
-- go_gc_heap_live_bytes
-- go_gc_heap_objects_objects
-- go_gc_heap_tiny_allocs_objects_total
-- go_gc_limiter_last_enabled_gc_cycle
-- go_gc_pauses_seconds_bucket
-- go_gc_pauses_seconds_count
-- go_gc_pauses_seconds_sum
-- go_gc_scan_globals_bytes
-- go_gc_scan_heap_bytes
-- go_gc_scan_stack_bytes
-- go_gc_scan_total_bytes
-- go_gc_stack_starting_size_bytes
-- go_goroutines
-- go_info
-- go_memstats_alloc_bytes
-- go_memstats_alloc_bytes_total
-- go_memstats_buck_hash_sys_bytes
-- go_memstats_frees_total
-- go_memstats_gc_sys_bytes
-- go_memstats_heap_alloc_bytes
-- go_memstats_heap_idle_bytes
-- go_memstats_heap_inuse_bytes
-- go_memstats_heap_objects
-- go_memstats_heap_released_bytes
-- go_memstats_heap_sys_bytes
-- go_memstats_last_gc_time_seconds
-- go_memstats_mallocs_total
-- go_memstats_mcache_inuse_bytes
-- go_memstats_mcache_sys_bytes
-- go_memstats_mspan_inuse_bytes
-- go_memstats_mspan_sys_bytes
-- go_memstats_next_gc_bytes
-- go_memstats_other_sys_bytes
-- go_memstats_stack_inuse_bytes
-- go_memstats_stack_sys_bytes
-- go_memstats_sys_bytes
-- go_sched_gomaxprocs_threads
-- go_sched_goroutines_created_goroutines_total
-- go_sched_goroutines_goroutines
-- go_sched_goroutines_not_in_go_goroutines
-- go_sched_goroutines_runnable_goroutines
-- go_sched_goroutines_running_goroutines
-- go_sched_goroutines_waiting_goroutines
-- go_sched_latencies_seconds_bucket
-- go_sched_latencies_seconds_count
-- go_sched_latencies_seconds_sum
-- go_sched_pauses_stopping_gc_seconds_bucket
-- go_sched_pauses_stopping_gc_seconds_count
-- go_sched_pauses_stopping_gc_seconds_sum
-- go_sched_pauses_stopping_other_seconds_bucket
-- go_sched_pauses_stopping_other_seconds_count
-- go_sched_pauses_stopping_other_seconds_sum
-- go_sched_pauses_total_gc_seconds_bucket
-- go_sched_pauses_total_gc_seconds_count
-- go_sched_pauses_total_gc_seconds_sum
-- go_sched_pauses_total_other_seconds_bucket
-- go_sched_pauses_total_other_seconds_count
-- go_sched_pauses_total_other_seconds_sum
-- go_sched_threads_total_threads
-- go_sync_mutex_wait_total_seconds_total
-- go_threads
-- net_conntrack_dialer_conn_attempted_total
-- net_conntrack_dialer_conn_closed_total
-- net_conntrack_dialer_conn_established_total
-- net_conntrack_dialer_conn_failed_total
-- net_conntrack_listener_conn_accepted_total
-- net_conntrack_listener_conn_closed_total
-- process_cpu_seconds_total
-- process_max_fds
-- process_network_receive_bytes_total
-- process_network_transmit_bytes_total
-- process_open_fds
-- process_resident_memory_bytes
-- process_start_time_seconds
-- process_virtual_memory_bytes
-- process_virtual_memory_max_bytes
-- prometheus_api_notification_active_subscribers
-- prometheus_api_notification_updates_dropped_total
-- prometheus_api_notification_updates_sent_total
-- prometheus_build_info
-- prometheus_config_last_reload_success_timestamp_seconds
-- prometheus_config_last_reload_successful
-- prometheus_engine_queries
-- prometheus_engine_queries_concurrent_max
-- prometheus_engine_query_duration_histogram_seconds_bucket
-- prometheus_engine_query_duration_histogram_seconds_count
-- prometheus_engine_query_duration_histogram_seconds_sum
-- prometheus_engine_query_duration_seconds
-- prometheus_engine_query_duration_seconds_count
-- prometheus_engine_query_duration_seconds_sum
-- prometheus_engine_query_log_enabled
-- prometheus_engine_query_log_failures_total
-- prometheus_engine_query_samples_read_total
-- prometheus_engine_query_samples_total
-- prometheus_http_request_duration_seconds_bucket
-- prometheus_http_request_duration_seconds_count
-- prometheus_http_request_duration_seconds_sum
-- prometheus_http_requests_total
-- prometheus_http_response_size_bytes_bucket
-- prometheus_http_response_size_bytes_count
-- prometheus_http_response_size_bytes_sum
-- prometheus_notifications_alertmanagers_discovered
-- prometheus_notifications_queue_capacity
-- prometheus_ready
-- prometheus_remote_read_handler_queries
-- prometheus_remote_storage_exemplars_in_total
-- prometheus_remote_storage_highest_timestamp_in_seconds
-- prometheus_remote_storage_histograms_in_total
-- prometheus_remote_storage_samples_in_total
-- prometheus_remote_storage_string_interner_zero_reference_releases_total
-- prometheus_rule_evaluation_duration_histogram_seconds_bucket
-- prometheus_rule_evaluation_duration_histogram_seconds_count
-- prometheus_rule_evaluation_duration_histogram_seconds_sum
-- prometheus_rule_evaluation_duration_seconds
-- prometheus_rule_evaluation_duration_seconds_count
-- prometheus_rule_evaluation_duration_seconds_sum
-- prometheus_rule_evaluation_failures_total
-- prometheus_rule_evaluations_total
-- prometheus_rule_group_duration_histogram_seconds_bucket
-- prometheus_rule_group_duration_histogram_seconds_count
-- prometheus_rule_group_duration_histogram_seconds_sum
-- prometheus_rule_group_duration_seconds
-- prometheus_rule_group_duration_seconds_count
-- prometheus_rule_group_duration_seconds_sum
-- prometheus_rule_group_interval_seconds
-- prometheus_rule_group_iterations_missed_total
-- prometheus_rule_group_iterations_total
-- prometheus_rule_group_last_duration_seconds
-- prometheus_rule_group_last_evaluation_samples
-- prometheus_rule_group_last_evaluation_timestamp_seconds
-- prometheus_rule_group_last_restore_duration_seconds
-- prometheus_rule_group_last_rule_duration_sum_seconds
-- prometheus_rule_group_rules
-- prometheus_sd_azure_cache_hit_total
-- prometheus_sd_azure_failures_total
-- prometheus_sd_consul_rpc_duration_seconds
-- prometheus_sd_consul_rpc_duration_seconds_count
-- prometheus_sd_consul_rpc_duration_seconds_sum
-- prometheus_sd_consul_rpc_failures_total
-- prometheus_sd_discovered_targets
-- prometheus_sd_dns_lookup_failures_total
-- prometheus_sd_dns_lookups_total
-- prometheus_sd_failed_configs
-- prometheus_sd_file_read_errors_total
-- prometheus_sd_file_scan_duration_seconds
-- prometheus_sd_file_scan_duration_seconds_count
-- prometheus_sd_file_scan_duration_seconds_sum
-- prometheus_sd_file_watcher_errors_total
-- prometheus_sd_http_failures_total
-- prometheus_sd_kubernetes_events_total
-- prometheus_sd_kubernetes_failures_total
-- prometheus_sd_kuma_fetch_duration_seconds
-- prometheus_sd_kuma_fetch_duration_seconds_count
-- prometheus_sd_kuma_fetch_duration_seconds_sum
-- prometheus_sd_kuma_fetch_failures_total
-- prometheus_sd_kuma_fetch_skipped_updates_total
-- prometheus_sd_last_update_timestamp_seconds
-- prometheus_sd_linode_failures_total
-- prometheus_sd_nomad_failures_total
-- prometheus_sd_received_updates_total
-- prometheus_sd_updates_delayed_total
-- prometheus_sd_updates_total
-- prometheus_target_interval_length_histogram_seconds_bucket
-- prometheus_target_interval_length_histogram_seconds_count
-- prometheus_target_interval_length_histogram_seconds_sum
-- prometheus_target_interval_length_seconds
-- prometheus_target_interval_length_seconds_count
-- prometheus_target_interval_length_seconds_sum
-- prometheus_target_metadata_cache_bytes
-- prometheus_target_metadata_cache_entries
-- prometheus_target_scrape_duration_seconds_bucket
-- prometheus_target_scrape_duration_seconds_count
-- prometheus_target_scrape_duration_seconds_sum
-- prometheus_target_scrape_pool_exceeded_label_limits_total
-- prometheus_target_scrape_pool_exceeded_target_limit_total
-- prometheus_target_scrape_pool_reloads_failed_total
-- prometheus_target_scrape_pool_reloads_total
-- prometheus_target_scrape_pool_symboltable_items
-- prometheus_target_scrape_pool_sync_total
-- prometheus_target_scrape_pool_target_limit
-- prometheus_target_scrape_pool_targets
-- prometheus_target_scrape_pools_failed_total
-- prometheus_target_scrape_pools_total
-- prometheus_target_scrapes_cache_flush_forced_total
-- prometheus_target_scrapes_exceeded_body_size_limit_total
-- prometheus_target_scrapes_exceeded_native_histogram_bucket_limit_total
-- prometheus_target_scrapes_exceeded_sample_limit_total
-- prometheus_target_scrapes_exemplar_out_of_order_total
-- prometheus_target_scrapes_sample_duplicate_timestamp_total
-- prometheus_target_scrapes_sample_out_of_bounds_total
-- prometheus_target_scrapes_sample_out_of_order_total
-- prometheus_target_sync_failed_total
-- prometheus_target_sync_length_histogram_seconds_bucket
-- prometheus_target_sync_length_histogram_seconds_count
-- prometheus_target_sync_length_histogram_seconds_sum
-- prometheus_target_sync_length_seconds
-- prometheus_target_sync_length_seconds_count
-- prometheus_target_sync_length_seconds_sum
-- prometheus_template_text_expansion_failures_total
-- prometheus_template_text_expansions_total
-- prometheus_treecache_watcher_goroutines
-- prometheus_treecache_zookeeper_failures_total
-- prometheus_tsdb_blocks_loaded
-- prometheus_tsdb_checkpoint_creations_failed_total
-- prometheus_tsdb_checkpoint_creations_total
-- prometheus_tsdb_checkpoint_deletions_failed_total
-- prometheus_tsdb_checkpoint_deletions_total
-- prometheus_tsdb_clean_start
-- prometheus_tsdb_compaction_chunk_range_seconds_bucket
-- prometheus_tsdb_compaction_chunk_range_seconds_count
-- prometheus_tsdb_compaction_chunk_range_seconds_sum
-- prometheus_tsdb_compaction_chunk_samples_bucket
-- prometheus_tsdb_compaction_chunk_samples_count
-- prometheus_tsdb_compaction_chunk_samples_sum
-- prometheus_tsdb_compaction_chunk_size_bytes_bucket
-- prometheus_tsdb_compaction_chunk_size_bytes_count
-- prometheus_tsdb_compaction_chunk_size_bytes_sum
-- prometheus_tsdb_compaction_duration_seconds_bucket
-- prometheus_tsdb_compaction_duration_seconds_count
-- prometheus_tsdb_compaction_duration_seconds_sum
-- prometheus_tsdb_compaction_populating_block
-- prometheus_tsdb_compactions_failed_total
-- prometheus_tsdb_compactions_skipped_total
-- prometheus_tsdb_compactions_total
-- prometheus_tsdb_compactions_triggered_total
-- prometheus_tsdb_data_replay_duration_seconds
-- prometheus_tsdb_exemplar_exemplars_appended_total
-- prometheus_tsdb_exemplar_exemplars_in_storage
-- prometheus_tsdb_exemplar_last_exemplars_timestamp_seconds
-- prometheus_tsdb_exemplar_max_exemplars
-- prometheus_tsdb_exemplar_out_of_order_exemplars_total
-- prometheus_tsdb_exemplar_series_with_exemplars_in_storage
-- prometheus_tsdb_head_active_appenders
-- prometheus_tsdb_head_chunks
-- prometheus_tsdb_head_chunks_created_total
-- prometheus_tsdb_head_chunks_removed_total
-- prometheus_tsdb_head_chunks_storage_size_bytes
-- prometheus_tsdb_head_gc_duration_seconds_count
-- prometheus_tsdb_head_gc_duration_seconds_sum
-- prometheus_tsdb_head_max_time
-- prometheus_tsdb_head_max_time_seconds
-- prometheus_tsdb_head_min_time
-- prometheus_tsdb_head_min_time_seconds
-- prometheus_tsdb_head_out_of_order_samples_appended_total
-- prometheus_tsdb_head_samples_appended_total
-- prometheus_tsdb_head_series
-- prometheus_tsdb_head_series_created_total
-- prometheus_tsdb_head_series_not_found_total
-- prometheus_tsdb_head_series_removed_total
-- prometheus_tsdb_head_stale_series
-- prometheus_tsdb_head_truncations_failed_total
-- prometheus_tsdb_head_truncations_total
-- prometheus_tsdb_isolation_high_watermark
-- prometheus_tsdb_isolation_low_watermark
-- prometheus_tsdb_lowest_timestamp
-- prometheus_tsdb_lowest_timestamp_seconds
-- prometheus_tsdb_mmap_chunk_corruptions_total
-- prometheus_tsdb_mmap_chunks_total
-- prometheus_tsdb_out_of_bound_samples_total
-- prometheus_tsdb_out_of_order_samples_total
-- prometheus_tsdb_reloads_failures_total
-- prometheus_tsdb_reloads_total
-- prometheus_tsdb_retention_limit_bytes
-- prometheus_tsdb_retention_limit_percentage
-- prometheus_tsdb_retention_limit_seconds
-- prometheus_tsdb_sample_ooo_delta_bucket
-- prometheus_tsdb_sample_ooo_delta_count
-- prometheus_tsdb_sample_ooo_delta_sum
-- prometheus_tsdb_size_retentions_total
-- prometheus_tsdb_snapshot_replay_error_total
-- prometheus_tsdb_stale_series_compaction_duration_seconds_bucket
-- prometheus_tsdb_stale_series_compaction_duration_seconds_count
-- prometheus_tsdb_stale_series_compaction_duration_seconds_sum
-- prometheus_tsdb_stale_series_compactions_failed_total
-- prometheus_tsdb_stale_series_compactions_triggered_total
-- prometheus_tsdb_storage_blocks_bytes
-- prometheus_tsdb_symbol_table_size_bytes
-- prometheus_tsdb_time_retentions_total
-- prometheus_tsdb_tombstone_cleanup_seconds_bucket
-- prometheus_tsdb_tombstone_cleanup_seconds_count
-- prometheus_tsdb_tombstone_cleanup_seconds_sum
-- prometheus_tsdb_too_old_samples_total
-- prometheus_tsdb_vertical_compactions_total
-- prometheus_tsdb_wal_completed_pages_total
-- prometheus_tsdb_wal_corruptions_total
-- prometheus_tsdb_wal_fsync_duration_seconds
-- prometheus_tsdb_wal_fsync_duration_seconds_count
-- prometheus_tsdb_wal_fsync_duration_seconds_sum
-- prometheus_tsdb_wal_page_flushes_total
-- prometheus_tsdb_wal_record_bytes_saved_total
-- prometheus_tsdb_wal_record_part_writes_total
-- prometheus_tsdb_wal_record_parts_bytes_written_total
-- prometheus_tsdb_wal_segment_current
-- prometheus_tsdb_wal_storage_size_bytes
-- prometheus_tsdb_wal_truncate_duration_seconds_count
-- prometheus_tsdb_wal_truncate_duration_seconds_sum
-- prometheus_tsdb_wal_truncations_failed_total
-- prometheus_tsdb_wal_truncations_total
-- prometheus_tsdb_wal_writes_failed_total
-- prometheus_web_federation_errors_total
-- prometheus_web_federation_warnings_total
-- promhttp_metric_handler_requests_in_flight
-- promhttp_metric_handler_requests_total
-- scrape_duration_seconds
-- scrape_samples_post_metric_relabeling
-- scrape_samples_scraped
-- scrape_series_added
-- up
-
-## Redis Metrics
-
-- process_cpu_seconds_total
-- process_max_fds
-- process_network_receive_bytes_total
-- process_network_transmit_bytes_total
-- process_open_fds
-- process_resident_memory_bytes
-- process_start_time_seconds
-- process_virtual_memory_bytes
-- process_virtual_memory_max_bytes
-- redis_acl_access_denied_auth_total
-- redis_acl_access_denied_channel_total
-- redis_acl_access_denied_cmd_total
-- redis_acl_access_denied_key_total
-- redis_active_defrag_running
-- redis_allocator_active_bytes
-- redis_allocator_allocated_bytes
-- redis_allocator_frag_bytes
-- redis_allocator_frag_ratio
-- redis_allocator_muzzy_bytes
-- redis_allocator_resident_bytes
-- redis_allocator_rss_bytes
-- redis_allocator_rss_ratio
-- redis_aof_base_size_bytes
-- redis_aof_buffer_length
-- redis_aof_current_rewrite_duration_sec
-- redis_aof_current_size_bytes
-- redis_aof_delayed_fsync
-- redis_aof_enabled
-- redis_aof_last_bgrewrite_status
-- redis_aof_last_cow_size_bytes
-- redis_aof_last_rewrite_duration_sec
-- redis_aof_last_write_status
-- redis_aof_pending_bio_fsync
-- redis_aof_pending_rewrite
-- redis_aof_rewrite_in_progress
-- redis_aof_rewrite_scheduled
-- redis_async_loading
-- redis_blocked_clients
-- redis_client_output_buffer_limit_disconnections_total
-- redis_client_query_buffer_limit_disconnections_total
-- redis_client_recent_max_input_buffer_bytes
-- redis_client_recent_max_output_buffer_bytes
-- redis_clients_in_timeout_table
-- redis_cluster_connections
-- redis_cluster_enabled
-- redis_commands_duration_seconds_total
-- redis_commands_failed_calls_total
-- redis_commands_latencies_usec_bucket
-- redis_commands_latencies_usec_count
-- redis_commands_latencies_usec_sum
-- redis_commands_processed_total
-- redis_commands_rejected_calls_total
-- redis_commands_total
-- redis_config_client_output_buffer_limit_bytes
-- redis_config_client_output_buffer_limit_overcome_seconds
-- redis_config_io_threads
-- redis_config_maxclients
-- redis_config_maxmemory
-- redis_config_repl_backlog_size
-- redis_configured_hz
-- redis_connected_clients
-- redis_connected_slaves
-- redis_connections_received_total
-- redis_cpu_sys_children_seconds_total
-- redis_cpu_sys_main_thread_seconds_total
-- redis_cpu_sys_seconds_total
-- redis_cpu_user_children_seconds_total
-- redis_cpu_user_main_thread_seconds_total
-- redis_cpu_user_seconds_total
-- redis_current_eviction_exceeded_time_ms
-- redis_db_avg_ttl_seconds
-- redis_db_keys
-- redis_db_keys_cached
-- redis_db_keys_expiring
-- redis_defrag_hits
-- redis_defrag_key_hits
-- redis_defrag_key_misses
-- redis_defrag_misses
-- redis_dump_payload_sanitizations
-- redis_errors_total
-- redis_eventloop_cycles_total
-- redis_eventloop_duration_cmd_sum_usec_total
-- redis_eventloop_duration_sum_usec_total
-- redis_evicted_clients_total
-- redis_evicted_keys_total
-- redis_evicted_scripts_total
-- redis_eviction_exceeded_time_ms_total
-- redis_expire_cycle_cpu_time_ms_total
-- redis_expired_keys_total
-- redis_expired_stale_percentage
-- redis_expired_subkeys_total
-- redis_expired_time_cap_reached_total
-- redis_exporter_build_info
-- redis_exporter_last_scrape_connect_time_seconds
-- redis_exporter_last_scrape_duration_seconds
-- redis_exporter_last_scrape_error
-- redis_exporter_scrape_duration_seconds_count
-- redis_exporter_scrape_duration_seconds_sum
-- redis_exporter_scrapes_total
-- redis_hz
-- redis_instance_info
-- redis_instantaneous_eventloop_cycles_per_sec
-- redis_instantaneous_eventloop_duration_usec
-- redis_io_threaded_reads_processed
-- redis_io_threaded_writes_processed
-- redis_io_threads_active
-- redis_keyspace_hits_total
-- redis_keyspace_misses_total
-- redis_last_key_groups_scrape_duration_milliseconds
-- redis_last_slow_execution_duration_seconds
-- redis_latency_percentiles_usec
-- redis_latency_percentiles_usec_count
-- redis_latency_percentiles_usec_sum
-- redis_latest_fork_seconds
-- redis_lazyfree_pending_objects
-- redis_lazyfreed_objects
-- redis_loading_dump_file
-- redis_master_repl_offset
-- redis_max_clients
-- redis_mem_aof_buffer_bytes
-- redis_mem_clients_normal
-- redis_mem_clients_slaves
-- redis_mem_cluster_links_bytes
-- redis_mem_fragmentation_bytes
-- redis_mem_fragmentation_ratio
-- redis_mem_not_counted_for_eviction_bytes
-- redis_mem_overhead_db_hashtable_rehashing_bytes
-- redis_mem_replication_backlog_bytes
-- redis_mem_total_replication_buffers_bytes
-- redis_memory_max_bytes
-- redis_memory_used_bytes
-- redis_memory_used_dataset_bytes
-- redis_memory_used_functions_bytes
-- redis_memory_used_lua_bytes
-- redis_memory_used_overhead_bytes
-- redis_memory_used_peak_bytes
-- redis_memory_used_rss_bytes
-- redis_memory_used_scripts_bytes
-- redis_memory_used_scripts_eval_bytes
-- redis_memory_used_startup_bytes
-- redis_memory_used_vm_eval_bytes
-- redis_memory_used_vm_functions_bytes
-- redis_memory_used_vm_total
-- redis_migrate_cached_sockets_total
-- redis_module_fork_in_progress
-- redis_module_fork_last_cow_size
-- redis_net_input_bytes_total
-- redis_net_output_bytes_total
-- redis_net_repl_input_bytes_total
-- redis_net_repl_output_bytes_total
-- redis_number_of_cached_scripts
-- redis_number_of_functions
-- redis_number_of_libraries
-- redis_process_id
-- redis_pubsub_channels
-- redis_pubsub_clients
-- redis_pubsub_patterns
-- redis_pubsubshard_channels
-- redis_rdb_bgsave_in_progress
-- redis_rdb_changes_since_last_save
-- redis_rdb_current_bgsave_duration_sec
-- redis_rdb_last_bgsave_duration_sec
-- redis_rdb_last_bgsave_status
-- redis_rdb_last_cow_size_bytes
-- redis_rdb_last_load_expired_keys
-- redis_rdb_last_load_loaded_keys
-- redis_rdb_last_save_timestamp_seconds
-- redis_rdb_saves_total
-- redis_rejected_connections_total
-- redis_repl_backlog_first_byte_offset
-- redis_repl_backlog_history_bytes
-- redis_repl_backlog_is_active
-- redis_replica_partial_resync_accepted
-- redis_replica_partial_resync_denied
-- redis_replica_resyncs_full
-- redis_replication_backlog_bytes
-- redis_reply_buffer_expands_total
-- redis_reply_buffer_shrinks_total
-- redis_second_repl_offset
-- redis_slave_expires_tracked_keys
-- redis_slowlog_last_id
-- redis_slowlog_length
-- redis_start_time_seconds
-- redis_target_scrape_request_errors_total
-- redis_total_blocking_keys
-- redis_total_blocking_keys_on_nokey
-- redis_total_error_replies
-- redis_total_reads_processed
-- redis_total_watched_keys
-- redis_total_writes_processed
-- redis_tracking_clients
-- redis_tracking_total_items
-- redis_tracking_total_keys
-- redis_tracking_total_prefixes
-- redis_unexpected_error_replies
-- redis_up
-- redis_uptime_in_seconds
-- redis_watching_clients
-- scrape_duration_seconds
-- scrape_samples_post_metric_relabeling
-- scrape_samples_scraped
-- scrape_series_added
-- up
+## 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.
diff --git a/docs/operations/scaling-performance-playbook.md b/docs/operations/scaling-performance-playbook.md
index bfd0028..e16eb83 100644
--- a/docs/operations/scaling-performance-playbook.md
+++ b/docs/operations/scaling-performance-playbook.md
@@ -1,345 +1,156 @@
# Scaling and Performance Playbook
-Purpose: define repeatable method to measure performance, capture baseline data, and decide scaling actions.
-
-Scope: planning and execution guide for TODO items under "Scaling & Performance (planning only)".
+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 `.m3u8` TTL to about `0.5x` segment duration while keeping playback stable.
+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`)
+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 from player logs
- - origin request increase/decrease
- - HIT ratio changes
-5. Select lowest TTL that keeps playback stable and origin load acceptable.
+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.
+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`.
-Suggested regions:
-
-- US East (Virginia)
-- US Central
-- US West (California/Oregon)
+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 table (date, region, median, p95, test URL).
+4. Store results in a table (date, region, median, p95, test URL).
-Acceptance:
-
-- Warm-cache median < 4s in all tested regions.
-- Outliers documented with likely cause.
+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.
-Use one row per region and keep the raw WebPageTest result URL with the summary so later runs can be compared against the same evidence.
+- US East (Virginia) / US Central / US West (California/Oregon): Date/time, Test URL `https://linearvoid.tv`, WebPageTest result URL, Median TTFB, p95 TTFB, Notes.
-- US East (Virginia)
- - Date/time:
- - Test URL:
- - WebPageTest result URL:
- - Median TTFB:
- - p95 TTFB:
- - Notes:
-- US Central
- - Date/time:
- - Test URL:
- - WebPageTest result URL:
- - Median TTFB:
- - p95 TTFB:
- - Notes:
-- US West (California/Oregon)
- - Date/time:
- - Test URL:
- - WebPageTest result URL:
- - Median TTFB:
- - p95 TTFB:
- - Notes:
-
-Suggested capture notes:
-
-- Use repeat view for the warm-cache measurement.
-- Keep the same browser profile and test URL across regions.
-- Note any CDN cache-rule changes or deploys that occurred between samples.
+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 service behavior across 10 -> 100 -> 500 -> 1000 concurrent clients.
+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):
+Tooling option B (custom Go harness): open N concurrent WS clients, send a message every X seconds, measure round-trip latency and failures.
-- open N concurrent WebSocket clients
-- send 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`.
-Metrics to capture:
-
-- 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.
+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:
-- CPU/memory notes for redis:
-- CPU/memory notes for nginx:
-- Raw results location:
-- Conclusion:
-- Next action:
+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.
-Tooling option A (`k6`):
+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:///api/admin/overview
```
-Endpoints:
-
-- `/api/admin/overview`
-- `/api/admin/analytics`
-- `/api/admin/chat-log`
-- `/api/admin/users`
-
-Acceptance:
-
-- p95 < 1s for primary admin endpoints.
-- slow endpoint list with remediation notes.
+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` p95:
-- `/api/admin/analytics` p95:
-- `/api/admin/chat-log` p95:
-- `/api/admin/users` p95:
-- Error rate:
-- Slowest endpoint:
-- Cache or DB notes:
-- Raw results location:
-- Conclusion:
-- Next action:
+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 outline:
-
+Implementation:
1. Pull Cloudflare GraphQL analytics on schedule.
-2. Export to Prometheus-compatible metric path.
-3. Add Grafana panel with 5m/1h/24h windows.
+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.ps1` for Windows scheduled tasks / manual runs
-- Example run: `CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_TAG=... CLOUDFLARE_HOSTNAME=media.linearvoid.tv node scripts/cache-hit-ratio.js`
-- Windows example: `pwsh -File scripts/cache-hit-ratio.ps1`
+- `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`
+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:
+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
-Define operational triggers:
-
+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:
+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 simple ops.
+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 guidance:
-
+Initial sizing:
- playout host: 4 vCPU / 8 GB
-- chat host: 2-4 vCPU / 4-8 GB
+- chat host: 2–4 vCPU / 4–8 GB
- db host: 2 vCPU / 4 GB + fast disk
-Exit criteria for Phase 2:
-
-- repeated trigger events over 7 days
-- capacity saturation despite tuning
+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` target host size:
-- `chat` target host size:
-- `db` target host size:
-- Monitoring separation needed:
-- Data/state migration notes:
-- Implementation owner:
-- Review decision:
-- Next action:
+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.
-Candidate layout:
-
+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)
-
-Failover drill requirements:
+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:
+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
+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
@@ -349,14 +160,9 @@ For each benchmark run, capture:
7. Phase 3 standby design review
## 11) Benchmark Run Sheet
+Use the same worksheet for every baseline/re-test so results stay comparable.
-Use the same worksheet for every baseline or re-test so results stay comparable.
-
-Preconditions:
-
-- Confirm the target environment and commit SHA/image tag.
-- Confirm Cloudflare cache rules are active when testing TTFB or manifest TTL.
-- Confirm the chat, admin, and playout services are healthy before load testing.
+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:
@@ -373,70 +179,47 @@ Run template:
| 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:///api/admin/overview`.
## 12) Scaling Decision Gate
-Treat the following as the default review gate after any benchmark batch:
-
+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 service boundary guidance in Phase 2 as the next action.
+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.
+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 this section when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
+Use when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
### WebSocket worksheet mapping
-
-Artifact sources:
-
-- `artifacts//ws-chat-report.json`
-- `artifacts//ws-chat-summary.txt`
+Artifact sources: `artifacts//ws-chat-report.json`, `artifacts//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=...`
-- Worksheet `Tool used` -> `artillery` (or `run-loadtests.ps1 -Scenario ws`)
-- Worksheet `Raw results location` -> artifact folder path for this run
-- Worksheet `Concurrency tiers reached` -> `ws-chat.yml` phases (`warmup-10`, `sustain-100`, `sustain-500`, `sustain-1000`)
-- Worksheet `Error rate` -> `vusers.failure_rate_percent` from `ws-chat-summary.txt`
-- Worksheet `Dropped connections` -> derive from `vusers.created - vusers.completed` (use `vusers.failed` as failure signal)
-- Worksheet `p95 message latency` -> first available latency line under `summary.*` in `ws-chat-summary.txt` that includes `p95=...`
-- Worksheet `p50 message latency` -> first available latency line under `summary.*` in `ws-chat-summary.txt` that includes `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.
+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//admin-api-summary.json`
-- `artifacts//admin-api-output.txt`
-- `artifacts//admin-api-endpoint-summary.txt`
+Artifact sources: `artifacts//admin-api-summary.json`, `artifacts//admin-api-output.txt`, `artifacts//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`
-- Worksheet `Tool used` -> `k6` (or `run-loadtests.ps1 -Scenario admin`)
-- Worksheet `Raw results location` -> artifact folder path for this run
-- Worksheet `/api/admin/overview` p95 -> `overview` row `P95Ms` in `admin-api-endpoint-summary.txt`
-- Worksheet `/api/admin/analytics` p95 -> `analytics` row `P95Ms` in `admin-api-endpoint-summary.txt`
-- Worksheet `/api/admin/chat-log` p95 -> `chat_log` row `P95Ms` in `admin-api-endpoint-summary.txt`
-- Worksheet `/api/admin/users` p95 -> `users` row `P95Ms` in `admin-api-endpoint-summary.txt`
-- Worksheet `Error rate` -> `Global failure rate` in `admin-api-endpoint-summary.txt`
-- Worksheet `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.
-Notes:
-
-- Keep the raw `admin-api-summary.json` file for audits and re-parsing.
-- If an endpoint row is missing, treat as test/data issue and re-run with full endpoint coverage.
+Summary helper (bash): run `scripts/loadtest/summarize-artillery.sh ` after an Artillery WS run, and `scripts/loadtest/summarize-k6.sh ` after a k6 admin run, to produce the artifact files used in the mappings above.
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
index e0818f5..17c9dfc 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,122 +1,122 @@
-
-
-
-
-
-
- Linear Void - 24/7 Synchronized Channel
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ Linear Void - 24/7 Synchronized Channel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 9e93eb8..a0d9b9c 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,2140 +1,2140 @@
-{
- "name": "frontend",
- "version": "1.0.11",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "frontend",
- "version": "1.0.11",
- "dependencies": {
- "hls.js": "^1.6.16"
- },
- "devDependencies": {
- "@vitest/coverage-v8": "^4.1.10",
- "@vitest/ui": "^4.1.10",
- "jsdom": "^30.0.1",
- "typescript": "~6.0.2",
- "vite": "^8.2.0",
- "vitest": "^4.1.10"
- }
- },
- "node_modules/@asamuzakjp/css-color": {
- "version": "6.0.5",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
- "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@csstools/css-calc": "^3.2.1",
- "@csstools/css-color-parser": "^4.1.9",
- "@csstools/css-parser-algorithms": "^4.0.0",
- "@csstools/css-tokenizer": "^4.0.0",
- "lru-cache": "^11.5.2"
- },
- "engines": {
- "node": "^22.13.0 || >=24.0.0"
- }
- },
- "node_modules/@asamuzakjp/dom-selector": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
- "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "bidi-js": "^1.0.3",
- "css-tree": "^3.2.1",
- "is-potential-custom-element-name": "^1.0.1",
- "lru-cache": "^11.5.2"
- },
- "engines": {
- "node": "^22.13.0 || >=24.0.0"
- }
- },
- "node_modules/@babel/helper-string-parser": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
- "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
- "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/parser": {
- "version": "7.29.8",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
- "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.29.8"
- },
- "bin": {
- "parser": "bin/babel-parser.js"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@babel/types": {
- "version": "7.29.8",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
- "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-string-parser": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@bcoe/v8-coverage": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
- "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@bramus/specificity": {
- "version": "2.4.2",
- "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
- "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "css-tree": "^3.0.0"
- },
- "bin": {
- "specificity": "bin/cli.js"
- }
- },
- "node_modules/@csstools/color-helpers": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
- "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT-0",
- "engines": {
- "node": ">=20.19.0"
- }
- },
- "node_modules/@csstools/css-calc": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
- "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=20.19.0"
- },
- "peerDependencies": {
- "@csstools/css-parser-algorithms": "^4.0.0",
- "@csstools/css-tokenizer": "^4.0.0"
- }
- },
- "node_modules/@csstools/css-color-parser": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
- "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "@csstools/color-helpers": "^6.1.0",
- "@csstools/css-calc": "^3.3.0"
- },
- "engines": {
- "node": ">=20.19.0"
- },
- "peerDependencies": {
- "@csstools/css-parser-algorithms": "^4.0.0",
- "@csstools/css-tokenizer": "^4.0.0"
- }
- },
- "node_modules/@csstools/css-parser-algorithms": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
- "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=20.19.0"
- },
- "peerDependencies": {
- "@csstools/css-tokenizer": "^4.0.0"
- }
- },
- "node_modules/@csstools/css-syntax-patches-for-csstree": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
- "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT-0",
- "peerDependencies": {
- "css-tree": "^3.2.1"
- },
- "peerDependenciesMeta": {
- "css-tree": {
- "optional": true
- }
- }
- },
- "node_modules/@csstools/css-tokenizer": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
- "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=20.19.0"
- }
- },
- "node_modules/@emnapi/core": {
- "version": "2.0.0-alpha.3",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
- "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "2.0.1",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "2.0.0-alpha.3",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
- "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/wasi-threads": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
- "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@exodus/bytes": {
- "version": "1.15.1",
- "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
- "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- },
- "peerDependencies": {
- "@noble/hashes": "^1.8.0 || ^2.0.0"
- },
- "peerDependenciesMeta": {
- "@noble/hashes": {
- "optional": true
- }
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@napi-rs/wasm-runtime": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
- "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@tybys/wasm-util": "^0.10.3"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Brooooooklyn"
- },
- "peerDependencies": {
- "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
- "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
- }
- },
- "node_modules/@oxc-project/types": {
- "version": "0.142.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
- "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/Boshen"
- }
- },
- "node_modules/@polka/url": {
- "version": "1.0.0-next.29",
- "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
- "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@rolldown/binding-android-arm64": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
- "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz",
- "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz",
- "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz",
- "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz",
- "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz",
- "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz",
- "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz",
- "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz",
- "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz",
- "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz",
- "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz",
- "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz",
- "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/core": "2.0.0-alpha.3",
- "@emnapi/runtime": "2.0.0-alpha.3",
- "@napi-rs/wasm-runtime": "^1.2.0"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
- }
- },
- "node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz",
- "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz",
- "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
- "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@standard-schema/spec": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
- "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@tybys/wasm-util": {
- "version": "0.10.3",
- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
- "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@types/chai": {
- "version": "5.2.3",
- "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
- "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/deep-eql": "*",
- "assertion-error": "^2.0.1"
- }
- },
- "node_modules/@types/deep-eql": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
- "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/estree": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
- "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@vitest/coverage-v8": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
- "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@bcoe/v8-coverage": "^1.0.2",
- "@vitest/utils": "4.1.10",
- "ast-v8-to-istanbul": "^1.0.0",
- "istanbul-lib-coverage": "^3.2.2",
- "istanbul-lib-report": "^3.0.1",
- "istanbul-reports": "^3.2.0",
- "magicast": "^0.5.2",
- "obug": "^2.1.1",
- "std-env": "^4.0.0-rc.1",
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "@vitest/browser": "4.1.10",
- "vitest": "4.1.10"
- },
- "peerDependenciesMeta": {
- "@vitest/browser": {
- "optional": true
- }
- }
- },
- "node_modules/@vitest/expect": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
- "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@standard-schema/spec": "^1.1.0",
- "@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.10",
- "@vitest/utils": "4.1.10",
- "chai": "^6.2.2",
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/mocker": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
- "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/spy": "4.1.10",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.21"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
- "node_modules/@vitest/pretty-format": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
- "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/runner": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
- "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/utils": "4.1.10",
- "pathe": "^2.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/snapshot": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
- "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "4.1.10",
- "@vitest/utils": "4.1.10",
- "magic-string": "^0.30.21",
- "pathe": "^2.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/spy": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
- "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/ui": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz",
- "integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/utils": "4.1.10",
- "fflate": "^0.8.2",
- "flatted": "^3.4.2",
- "pathe": "^2.0.3",
- "sirv": "^3.0.2",
- "tinyglobby": "^0.2.15",
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "vitest": "4.1.10"
- }
- },
- "node_modules/@vitest/utils": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
- "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "4.1.10",
- "convert-source-map": "^2.0.0",
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/assertion-error": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
- "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/ast-v8-to-istanbul": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
- "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/trace-mapping": "^0.3.31",
- "estree-walker": "^3.0.3",
- "js-tokens": "^10.0.0"
- }
- },
- "node_modules/bidi-js": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
- "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "require-from-string": "^2.0.2"
- }
- },
- "node_modules/chai": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
- "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/convert-source-map": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
- "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/css-tree": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
- "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mdn-data": "2.27.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
- }
- },
- "node_modules/data-urls": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
- "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "whatwg-mimetype": "^5.0.0",
- "whatwg-url": "^16.0.0"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- }
- },
- "node_modules/data-urls/node_modules/whatwg-url": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
- "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@exodus/bytes": "^1.11.0",
- "tr46": "^6.0.0",
- "webidl-conversions": "^8.0.1"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- }
- },
- "node_modules/decimal.js": {
- "version": "10.6.0",
- "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
- "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/entities": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
- "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=20.19.0"
- },
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
- }
- },
- "node_modules/es-module-lexer": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
- "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
- },
- "node_modules/expect-type": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
- "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/fflate": {
- "version": "0.8.3",
- "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
- "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/flatted": {
- "version": "3.4.4",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
- "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/has-flag": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
- "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/hls.js": {
- "version": "1.6.16",
- "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
- "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
- "license": "Apache-2.0"
- },
- "node_modules/html-encoding-sniffer": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
- "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@exodus/bytes": "^1.6.0"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- }
- },
- "node_modules/html-escaper": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
- "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/is-potential-custom-element-name": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
- "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/istanbul-lib-coverage": {
- "version": "3.2.2",
- "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
- "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/istanbul-lib-report": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
- "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "istanbul-lib-coverage": "^3.0.0",
- "make-dir": "^4.0.0",
- "supports-color": "^7.1.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/istanbul-reports": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
- "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "html-escaper": "^2.0.0",
- "istanbul-lib-report": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/js-tokens": {
- "version": "10.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
- "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/jsdom": {
- "version": "30.0.1",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
- "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@asamuzakjp/css-color": "^6.0.5",
- "@asamuzakjp/dom-selector": "^8.3.0",
- "@bramus/specificity": "^2.4.2",
- "@csstools/css-syntax-patches-for-csstree": "^1.1.7",
- "@exodus/bytes": "^1.15.1",
- "css-tree": "^3.2.1",
- "data-urls": "^7.0.0",
- "decimal.js": "^10.6.0",
- "html-encoding-sniffer": "^6.0.0",
- "is-potential-custom-element-name": "^1.0.1",
- "lru-cache": "^11.5.2",
- "parse5": "^8.0.1",
- "saxes": "^6.0.0",
- "symbol-tree": "^3.2.4",
- "tough-cookie": "^6.0.2",
- "undici": "^8.9.0",
- "w3c-xmlserializer": "^5.0.0",
- "webidl-conversions": "^8.0.1",
- "whatwg-mimetype": "^5.0.0",
- "whatwg-url": "^17.1.0",
- "xml-name-validator": "^5.0.0"
- },
- "engines": {
- "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
- },
- "peerDependencies": {
- "canvas": "^3.2.3"
- },
- "peerDependenciesMeta": {
- "canvas": {
- "optional": true
- }
- }
- },
- "node_modules/lightningcss": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
- "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
- "dev": true,
- "license": "MPL-2.0",
- "dependencies": {
- "detect-libc": "^2.0.3"
- },
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "lightningcss-android-arm64": "1.33.0",
- "lightningcss-darwin-arm64": "1.33.0",
- "lightningcss-darwin-x64": "1.33.0",
- "lightningcss-freebsd-x64": "1.33.0",
- "lightningcss-linux-arm-gnueabihf": "1.33.0",
- "lightningcss-linux-arm64-gnu": "1.33.0",
- "lightningcss-linux-arm64-musl": "1.33.0",
- "lightningcss-linux-x64-gnu": "1.33.0",
- "lightningcss-linux-x64-musl": "1.33.0",
- "lightningcss-win32-arm64-msvc": "1.33.0",
- "lightningcss-win32-x64-msvc": "1.33.0"
- }
- },
- "node_modules/lightningcss-android-arm64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
- "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-arm64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
- "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-x64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
- "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-freebsd-x64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
- "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
- "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
- "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
- "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
- "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-musl": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
- "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
- "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
- "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lru-cache": {
- "version": "11.5.2",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
- "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/magic-string": {
- "version": "0.30.21",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
- "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.5"
- }
- },
- "node_modules/magicast": {
- "version": "0.5.4",
- "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz",
- "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.29.7",
- "@babel/types": "^7.29.7",
- "source-map-js": "^1.2.1"
- }
- },
- "node_modules/make-dir": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
- "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "semver": "^7.5.3"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/mdn-data": {
- "version": "2.27.1",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
- "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
- "dev": true,
- "license": "CC0-1.0"
- },
- "node_modules/mrmime": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
- "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/nanoid": {
- "version": "3.3.18",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
- "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/obug": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
- "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
- "dev": true,
- "funding": [
- "https://github.com/sponsors/sxzz",
- "https://opencollective.com/debug"
- ],
- "license": "MIT",
- "engines": {
- "node": ">=12.20.0"
- }
- },
- "node_modules/parse5": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
- "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "entities": "^8.0.0"
- },
- "funding": {
- "url": "https://github.com/inikulin/parse5?sponsor=1"
- }
- },
- "node_modules/pathe": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
- "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
- "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/postcss": {
- "version": "8.5.25",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
- "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.16",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/punycode": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
- "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/require-from-string": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
- "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/rolldown": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz",
- "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@oxc-project/types": "=0.142.0",
- "@rolldown/pluginutils": "^1.0.0"
- },
- "bin": {
- "rolldown": "bin/cli.mjs"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.2.1",
- "@rolldown/binding-darwin-arm64": "1.2.1",
- "@rolldown/binding-darwin-x64": "1.2.1",
- "@rolldown/binding-freebsd-x64": "1.2.1",
- "@rolldown/binding-linux-arm-gnueabihf": "1.2.1",
- "@rolldown/binding-linux-arm64-gnu": "1.2.1",
- "@rolldown/binding-linux-arm64-musl": "1.2.1",
- "@rolldown/binding-linux-ppc64-gnu": "1.2.1",
- "@rolldown/binding-linux-s390x-gnu": "1.2.1",
- "@rolldown/binding-linux-x64-gnu": "1.2.1",
- "@rolldown/binding-linux-x64-musl": "1.2.1",
- "@rolldown/binding-openharmony-arm64": "1.2.1",
- "@rolldown/binding-wasm32-wasi": "1.2.1",
- "@rolldown/binding-win32-arm64-msvc": "1.2.1",
- "@rolldown/binding-win32-x64-msvc": "1.2.1"
- }
- },
- "node_modules/saxes": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
- "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "xmlchars": "^2.2.0"
- },
- "engines": {
- "node": ">=v12.22.7"
- }
- },
- "node_modules/semver": {
- "version": "7.8.5",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/siginfo": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
- "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/sirv": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
- "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@polka/url": "^1.0.0-next.24",
- "mrmime": "^2.0.0",
- "totalist": "^3.0.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/stackback": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
- "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/std-env": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
- "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/supports-color": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
- "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-flag": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/symbol-tree": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
- "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tinybench": {
- "version": "2.9.0",
- "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
- "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tinyexec": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
- "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.17",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
- "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.4"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/tinyrainbow": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
- "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/tldts": {
- "version": "7.4.10",
- "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
- "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tldts-core": "^7.4.10"
- },
- "bin": {
- "tldts": "bin/cli.js"
- }
- },
- "node_modules/tldts-core": {
- "version": "7.4.10",
- "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
- "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/totalist": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
- "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/tough-cookie": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
- "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "tldts": "^7.0.5"
- },
- "engines": {
- "node": ">=16"
- }
- },
- "node_modules/tr46": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
- "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "punycode": "^2.3.1"
- },
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/tslib": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
- "license": "0BSD",
- "optional": true
- },
- "node_modules/typescript": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
- "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/undici": {
- "version": "8.10.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
- "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=22.19.0"
- }
- },
- "node_modules/vite": {
- "version": "8.2.0",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
- "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "lightningcss": "^1.33.0",
- "picomatch": "^4.0.5",
- "postcss": "^8.5.23",
- "rolldown": "~1.2.0",
- "tinyglobby": "^0.2.17"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.4.0",
- "esbuild": "^0.27.0 || ^0.28.0",
- "jiti": ">=1.21.0",
- "less": "^4.0.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "@vitejs/devtools": {
- "optional": true
- },
- "esbuild": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- },
- "node_modules/vitest": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
- "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/expect": "4.1.10",
- "@vitest/mocker": "4.1.10",
- "@vitest/pretty-format": "4.1.10",
- "@vitest/runner": "4.1.10",
- "@vitest/snapshot": "4.1.10",
- "@vitest/spy": "4.1.10",
- "@vitest/utils": "4.1.10",
- "es-module-lexer": "^2.0.0",
- "expect-type": "^1.3.0",
- "magic-string": "^0.30.21",
- "obug": "^2.1.1",
- "pathe": "^2.0.3",
- "picomatch": "^4.0.3",
- "std-env": "^4.0.0-rc.1",
- "tinybench": "^2.9.0",
- "tinyexec": "^1.0.2",
- "tinyglobby": "^0.2.15",
- "tinyrainbow": "^3.1.0",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
- "why-is-node-running": "^2.3.0"
- },
- "bin": {
- "vitest": "vitest.mjs"
- },
- "engines": {
- "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "@edge-runtime/vm": "*",
- "@opentelemetry/api": "^1.9.0",
- "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.10",
- "@vitest/browser-preview": "4.1.10",
- "@vitest/browser-webdriverio": "4.1.10",
- "@vitest/coverage-istanbul": "4.1.10",
- "@vitest/coverage-v8": "4.1.10",
- "@vitest/ui": "4.1.10",
- "happy-dom": "*",
- "jsdom": "*",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "@edge-runtime/vm": {
- "optional": true
- },
- "@opentelemetry/api": {
- "optional": true
- },
- "@types/node": {
- "optional": true
- },
- "@vitest/browser-playwright": {
- "optional": true
- },
- "@vitest/browser-preview": {
- "optional": true
- },
- "@vitest/browser-webdriverio": {
- "optional": true
- },
- "@vitest/coverage-istanbul": {
- "optional": true
- },
- "@vitest/coverage-v8": {
- "optional": true
- },
- "@vitest/ui": {
- "optional": true
- },
- "happy-dom": {
- "optional": true
- },
- "jsdom": {
- "optional": true
- },
- "vite": {
- "optional": false
- }
- }
- },
- "node_modules/w3c-xmlserializer": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
- "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "xml-name-validator": "^5.0.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/webidl-conversions": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
- "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/whatwg-mimetype": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
- "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/whatwg-url": {
- "version": "17.1.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
- "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@exodus/bytes": "^1.15.1",
- "tr46": "^6.0.0",
- "webidl-conversions": "^8.0.1"
- },
- "engines": {
- "node": "^22.14.0 || >=24.0.0"
- }
- },
- "node_modules/why-is-node-running": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
- "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "siginfo": "^2.0.0",
- "stackback": "0.0.2"
- },
- "bin": {
- "why-is-node-running": "cli.js"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/xml-name-validator": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
- "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/xmlchars": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
- "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
- "dev": true,
- "license": "MIT"
- }
- }
-}
+{
+ "name": "frontend",
+ "version": "1.0.11",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "frontend",
+ "version": "1.0.11",
+ "dependencies": {
+ "hls.js": "^1.6.16"
+ },
+ "devDependencies": {
+ "@vitest/coverage-v8": "^4.1.10",
+ "@vitest/ui": "^4.1.10",
+ "jsdom": "^30.0.1",
+ "typescript": "~6.0.2",
+ "vite": "^8.2.0",
+ "vitest": "^4.1.10"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
+ "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.2.1",
+ "@csstools/css-color-parser": "^4.1.9",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
+ "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
+ "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+ "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
+ "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.0",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
+ "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "2.0.0-alpha.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
+ "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "2.0.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "2.0.0-alpha.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
+ "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
+ "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
+ "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
+ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.142.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
+ "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
+ "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz",
+ "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz",
+ "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz",
+ "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz",
+ "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz",
+ "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz",
+ "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz",
+ "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz",
+ "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz",
+ "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz",
+ "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz",
+ "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz",
+ "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "2.0.0-alpha.3",
+ "@emnapi/runtime": "2.0.0-alpha.3",
+ "@napi-rs/wasm-runtime": "^1.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz",
+ "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz",
+ "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
+ "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.10",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.10",
+ "vitest": "4.1.10"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/ui": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz",
+ "integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "fflate": "^0.8.2",
+ "flatted": "^3.4.2",
+ "pathe": "^2.0.3",
+ "sirv": "^3.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "vitest": "4.1.10"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
+ "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/hls.js": {
+ "version": "1.6.16",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
+ "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "30.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
+ "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^6.0.5",
+ "@asamuzakjp/dom-selector": "^8.3.0",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.7",
+ "@exodus/bytes": "^1.15.1",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.2",
+ "undici": "^8.9.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz",
+ "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz",
+ "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.142.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.2.1",
+ "@rolldown/binding-darwin-arm64": "1.2.1",
+ "@rolldown/binding-darwin-x64": "1.2.1",
+ "@rolldown/binding-freebsd-x64": "1.2.1",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.1",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.1",
+ "@rolldown/binding-linux-arm64-musl": "1.2.1",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.1",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.1",
+ "@rolldown/binding-linux-x64-gnu": "1.2.1",
+ "@rolldown/binding-linux-x64-musl": "1.2.1",
+ "@rolldown/binding-openharmony-arm64": "1.2.1",
+ "@rolldown/binding-wasm32-wasi": "1.2.1",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.1",
+ "@rolldown/binding-win32-x64-msvc": "1.2.1"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.10",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
+ "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.10"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.10",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
+ "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
+ "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
+ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.23",
+ "rolldown": "~1.2.0",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "17.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
+ "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
index 56ac16a..bf2fa33 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,29 +1,29 @@
-{
- "name": "frontend",
- "private": true,
- "version": "1.0.13",
- "type": "module",
- "engines": {
- "node": ">=22.0.0"
- },
- "scripts": {
- "dev": "vite",
- "build": "tsc && vite build",
- "preview": "vite preview",
- "lint": "tsc --noEmit",
- "test": "vitest run",
- "test:coverage": "vitest run --coverage",
- "test:watch": "vitest"
- },
- "devDependencies": {
- "@vitest/coverage-v8": "^4.1.10",
- "@vitest/ui": "^4.1.10",
- "jsdom": "^30.0.1",
- "typescript": "~6.0.2",
- "vite": "^8.2.0",
- "vitest": "^4.1.10"
- },
- "dependencies": {
- "hls.js": "^1.6.16"
- }
-}
+{
+ "name": "frontend",
+ "private": true,
+ "version": "1.0.13",
+ "type": "module",
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "lint": "tsc --noEmit",
+ "test": "vitest run",
+ "test:coverage": "vitest run --coverage",
+ "test:watch": "vitest"
+ },
+ "devDependencies": {
+ "@vitest/coverage-v8": "^4.1.10",
+ "@vitest/ui": "^4.1.10",
+ "jsdom": "^30.0.1",
+ "typescript": "~6.0.2",
+ "vite": "^8.2.0",
+ "vitest": "^4.1.10"
+ },
+ "dependencies": {
+ "hls.js": "^1.6.16"
+ }
+}
diff --git a/frontend/src/admin.test.ts b/frontend/src/admin.test.ts
index c8e57de..622a618 100644
--- a/frontend/src/admin.test.ts
+++ b/frontend/src/admin.test.ts
@@ -1,378 +1,378 @@
-///
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { refreshAnalytics, refreshOverview, renderPlaylistSortable, savePlaylist } from "../src/admin";
-
-// Mock fetch globally
-let fetchCalls: { url: string; options?: RequestInit }[] = [];
-let mockFetchResponse: any = { ok: true, payload: {} };
-
-beforeEach(() => {
- fetchCalls = [];
- globalThis.fetch = vi.fn(async (url: string, options?: RequestInit) => {
- fetchCalls.push({ url, options });
- const payload = mockFetchResponse.payload ?? {};
- return Promise.resolve(
- new Response(JSON.stringify(payload), {
- status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500,
- headers: { "Content-Type": "application/json" },
- }),
- );
- }) as any;
-
- // Mock localStorage
- const storage = new Map();
- Object.defineProperty(window, "localStorage", {
- value: {
- getItem: vi.fn((key: string) => storage.get(key) ?? null),
- setItem: vi.fn((key: string, val: string) => storage.set(key, val)),
- removeItem: vi.fn((key: string) => storage.delete(key)),
- },
- writable: true,
- });
-
- // Mock window.confirm/prompt
- vi.spyOn(window, "confirm").mockReturnValue(true);
- vi.spyOn(window, "prompt").mockReturnValue(null);
- vi.spyOn(console, "error").mockImplementation(() => {});
-});
-
-afterEach(() => {
- vi.restoreAllMocks();
-});
-
-// Import after mocks are set
-import "../src/admin";
-
-describe("admin.ts initialization", () => {
- it("loads and exposes tab switching", () => {
- // After import, init() runs on DOMContentLoaded
- // Test that the document has admin page structure
- expect(document.body).toBeDefined();
- });
-});
-
-describe("upload progress helpers", () => {
- it("renders upload progress correctly", async () => {
- // Set up DOM for upload page
- document.body.innerHTML = `
-
-
- `;
-
- // Re-import triggers init again — but we need fresh module state
- // For now test internal helper access via body
- const wrap = document.getElementById("media-upload-progress-wrap")!;
- const bar = document.getElementById("media-upload-progress") as HTMLProgressElement;
- const text = document.getElementById("media-upload-progress-text")!;
-
- expect(wrap.hidden).toBe(true);
-
- // Simulate what setUploadProgress would do
- wrap.hidden = false;
- bar.max = 100;
- bar.value = 50;
- text.textContent = "Uploading (50%)";
-
- expect(wrap.hidden).toBe(false);
- expect(bar.value).toBe(50);
- expect(text.textContent).toBe("Uploading (50%)");
- });
-});
-
-describe("file fingerprint", () => {
- it("generates unique fingerprints for different files", () => {
- const f1 = new File(["a"], "video1.mp4", { lastModified: 1 });
- const f2 = new File(["b"], "video2.mp4", { lastModified: 2 });
-
- const fp1 = `${f1.name}:${f1.size}:${f1.lastModified}`;
- const fp2 = `${f2.name}:${f2.size}:${f2.lastModified}`;
-
- expect(fp1).not.toBe(fp2);
- expect(fp1).toBe("video1.mp4:1:1");
- expect(fp2).toBe("video2.mp4:1:2");
- });
-});
-
-describe("MAX_UPLOAD_BYTES constant", () => {
- it("equals 2GB", () => {
- expect(2 * 1024 * 1024 * 1024).toBe(2147483648);
- });
-});
-
-describe("API calls on tab load", () => {
- beforeEach(() => {
- document.body.innerHTML = `
-
-
- Dashboard
- Analytics
- Playlist
- Schedule
- System
- Upload
- Users
- Moderation
- Chat Log
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
- });
-
- it("can be imported without crashing", () => {
- // admin.ts initialization should not throw
- expect(true).toBe(true);
- });
-});
-
-describe("admin data refresh", () => {
- beforeEach(() => {
- document.body.innerHTML = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
- });
-
- it("renders overview stats from the admin overview API", async () => {
- mockFetchResponse = {
- ok: true,
- payload: {
- viewers: 12,
- storedMessages: 345,
- moderationActions: 9,
- mediaTotalRuntime: "01:23:45",
- mediaCount: 7,
- redisClients: 4,
- redisEnabled: true,
- redisMemoryUsed: "18 MB",
- databaseDriver: "sqlite",
- generatedAt: "2026-08-08T12:00:00Z",
- },
- };
-
- await refreshOverview();
-
- expect(document.getElementById("stat-viewers")?.textContent).toBe("12");
- expect(document.getElementById("stat-stored-messages")?.textContent).toBe("345");
- expect(document.getElementById("system-redis-enabled")?.textContent).toBe("yes");
- expect(document.getElementById("overview-status")?.textContent).toContain("2026-08-08T12:00:00Z");
- });
-
- it("renders analytics rows from the analytics API", async () => {
- mockFetchResponse = {
- ok: true,
- payload: {
- totalMessages: 1000,
- messagesLast24Hours: 75,
- distinctRooms: 3,
- moderationActions: 11,
- generatedAt: "2026-08-08T12:30:00Z",
- topRooms: [
- { room: "main", messageCount: 55 },
- { room: "lobby", messageCount: 20 },
- ],
- },
- };
-
- await refreshAnalytics();
-
- expect(document.getElementById("analytics-total-messages")?.textContent).toBe("1000");
- expect(document.getElementById("analytics-messages-24h")?.textContent).toBe("75");
- expect(document.getElementById("analytics-table-body")?.textContent).toContain("main");
- expect(document.getElementById("analytics-table-body")?.textContent).toContain("lobby");
- });
-});
-
-describe("playlist management", () => {
- beforeEach(() => {
- document.body.innerHTML = `
-
-
-
-
- `;
- });
-
- it("saves playlist via PUT request", async () => {
- mockFetchResponse = { ok: true, payload: {} };
-
- await savePlaylist();
-
- const putCall = fetchCalls.find((c) => c.options?.method === "PUT");
- expect(putCall).toBeDefined();
- expect(putCall?.url).toBe("/api/admin/playlist");
- expect(putCall?.options?.headers).toEqual({ "Content-Type": "application/json" });
- const body = JSON.parse(putCall?.options?.body as string);
- expect(body.content).toContain("demo1.mp4");
- });
-
- it("sets error status on save failure", async () => {
- mockFetchResponse = { ok: false, status: 500, payload: {} };
-
- await savePlaylist();
-
- const status = document.getElementById("playlist-status");
- expect(status?.textContent).toContain("Failed");
- });
-
- it("renders sortable playlist with indexes and paths", () => {
- renderPlaylistSortable("file '/app/media/demo1.mp4'\nfile '/app/media/demo2.mp4'\n");
-
- const list = document.getElementById("playlist-sortable")!;
- expect(list.children).toHaveLength(2);
- expect(list.innerHTML).toContain("demo1.mp4");
- expect(list.innerHTML).toContain("demo2.mp4");
- expect(list.querySelector(".sortable-index")?.textContent).toBe("1");
- expect(list.querySelector("[data-remove]")).toBeTruthy();
- });
-
- it("skips comment and blank lines in sortable", () => {
- renderPlaylistSortable("# comment\n\nfile '/app/media/demo1.mp4'\n");
-
- const list = document.getElementById("playlist-sortable")!;
- expect(list.children).toHaveLength(1);
- expect(list.innerHTML).toContain("demo1.mp4");
- });
-});
-
-describe("admin accessibility", () => {
- beforeEach(() => {
- document.body.innerHTML = `
-
- Dashboard
- Playlist
- Users
- Moderation
-
-
- `;
- });
-
- it("nav has aria-label for screen readers", () => {
- const nav = document.querySelector(".admin-nav");
- expect(nav?.getAttribute("aria-label")).toBe("Admin navigation");
- });
-
- it("active tab has aria-current attribute", () => {
- const active = document.querySelector("[aria-current]");
- expect(active).toBeTruthy();
- expect(active?.getAttribute("aria-current")).toBe("page");
- });
-
- it("all nav buttons are focusable", () => {
- const buttons = document.querySelectorAll(".admin-nav button");
- buttons.forEach((btn) => {
- expect(btn.getAttribute("tabindex")).toBeNull();
- expect(btn.getAttribute("disabled")).toBeNull();
- });
- });
-});
-
-describe("admin responsive layout", () => {
- beforeEach(() => {
- document.body.innerHTML = `
- Dashboard
-
- `;
- });
-
- it("tab content is present and visible", () => {
- const tabContent = document.getElementById("tab-content");
- expect(tabContent).toBeTruthy();
- const style = window.getComputedStyle(tabContent!);
- expect(style.display).not.toBe("none");
- });
-
- it("admin nav contains essential tabs", () => {
- const buttons = document.querySelectorAll(".admin-nav button");
- const tabs = Array.from(buttons).map((b) => b.textContent?.trim());
- expect(tabs).toContain("Dashboard");
- });
-});
-
-describe("admin data refresh failures", () => {
- beforeEach(() => {
- document.body.innerHTML = `
-
-
- `;
- });
-
- it("sets error status when overview fails", async () => {
- mockFetchResponse = { ok: false, status: 500, payload: {} };
- await refreshOverview();
- expect(document.getElementById("overview-status")?.textContent).toContain("Failed");
- });
-
- it("sets error status when analytics fails", async () => {
- mockFetchResponse = { ok: false, status: 500, payload: {} };
- await refreshAnalytics();
- expect(document.getElementById("analytics-status")?.textContent).toContain("Failed");
- });
-});
-
-describe("playlist status badges", () => {
- beforeEach(() => {
- document.body.innerHTML = ``;
- });
-
- it("renders badges for every media status", () => {
- const statusMap = new Map([
- ["/app/media/ready.mp4", "ready"],
- ["/app/media/needs.mp4", "needs_normalization"],
- ["/app/media/normalizing.mp4", "normalizing"],
- ["/app/media/failed.mp4", "normalization_failed"],
- ["/app/media/other.mp4", "weird"],
- ]);
-
- renderPlaylistSortable(
- "file '/app/media/ready.mp4'\nfile '/app/media/needs.mp4'\nfile '/app/media/normalizing.mp4'\nfile '/app/media/failed.mp4'\nfile '/app/media/other.mp4'\nfile '/app/media/nopath.mp4'\n",
- statusMap,
- );
-
- const list = document.getElementById("playlist-sortable")!;
- expect(list.innerHTML).toContain("badge-ok");
- expect(list.innerHTML).toContain("badge-warn");
- expect(list.innerHTML).toContain("badge-info");
- expect(list.innerHTML).toContain("badge-danger");
- expect(list.innerHTML).toContain("badge-muted"); // default + unknown fall back to muted
- expect(list.children).toHaveLength(6);
- });
-});
+///
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { refreshAnalytics, refreshOverview, renderPlaylistSortable, savePlaylist } from "../src/admin";
+
+// Mock fetch globally
+let fetchCalls: { url: string; options?: RequestInit }[] = [];
+let mockFetchResponse: any = { ok: true, payload: {} };
+
+beforeEach(() => {
+ fetchCalls = [];
+ globalThis.fetch = vi.fn(async (url: string, options?: RequestInit) => {
+ fetchCalls.push({ url, options });
+ const payload = mockFetchResponse.payload ?? {};
+ return Promise.resolve(
+ new Response(JSON.stringify(payload), {
+ status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ }) as any;
+
+ // Mock localStorage
+ const storage = new Map();
+ Object.defineProperty(window, "localStorage", {
+ value: {
+ getItem: vi.fn((key: string) => storage.get(key) ?? null),
+ setItem: vi.fn((key: string, val: string) => storage.set(key, val)),
+ removeItem: vi.fn((key: string) => storage.delete(key)),
+ },
+ writable: true,
+ });
+
+ // Mock window.confirm/prompt
+ vi.spyOn(window, "confirm").mockReturnValue(true);
+ vi.spyOn(window, "prompt").mockReturnValue(null);
+ vi.spyOn(console, "error").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+// Import after mocks are set
+import "../src/admin";
+
+describe("admin.ts initialization", () => {
+ it("loads and exposes tab switching", () => {
+ // After import, init() runs on DOMContentLoaded
+ // Test that the document has admin page structure
+ expect(document.body).toBeDefined();
+ });
+});
+
+describe("upload progress helpers", () => {
+ it("renders upload progress correctly", async () => {
+ // Set up DOM for upload page
+ document.body.innerHTML = `
+
+
+ `;
+
+ // Re-import triggers init again — but we need fresh module state
+ // For now test internal helper access via body
+ const wrap = document.getElementById("media-upload-progress-wrap")!;
+ const bar = document.getElementById("media-upload-progress") as HTMLProgressElement;
+ const text = document.getElementById("media-upload-progress-text")!;
+
+ expect(wrap.hidden).toBe(true);
+
+ // Simulate what setUploadProgress would do
+ wrap.hidden = false;
+ bar.max = 100;
+ bar.value = 50;
+ text.textContent = "Uploading (50%)";
+
+ expect(wrap.hidden).toBe(false);
+ expect(bar.value).toBe(50);
+ expect(text.textContent).toBe("Uploading (50%)");
+ });
+});
+
+describe("file fingerprint", () => {
+ it("generates unique fingerprints for different files", () => {
+ const f1 = new File(["a"], "video1.mp4", { lastModified: 1 });
+ const f2 = new File(["b"], "video2.mp4", { lastModified: 2 });
+
+ const fp1 = `${f1.name}:${f1.size}:${f1.lastModified}`;
+ const fp2 = `${f2.name}:${f2.size}:${f2.lastModified}`;
+
+ expect(fp1).not.toBe(fp2);
+ expect(fp1).toBe("video1.mp4:1:1");
+ expect(fp2).toBe("video2.mp4:1:2");
+ });
+});
+
+describe("MAX_UPLOAD_BYTES constant", () => {
+ it("equals 2GB", () => {
+ expect(2 * 1024 * 1024 * 1024).toBe(2147483648);
+ });
+});
+
+describe("API calls on tab load", () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+ Dashboard
+ Analytics
+ Playlist
+ Schedule
+ System
+ Upload
+ Users
+ Moderation
+ Chat Log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ });
+
+ it("can be imported without crashing", () => {
+ // admin.ts initialization should not throw
+ expect(true).toBe(true);
+ });
+});
+
+describe("admin data refresh", () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ });
+
+ it("renders overview stats from the admin overview API", async () => {
+ mockFetchResponse = {
+ ok: true,
+ payload: {
+ viewers: 12,
+ storedMessages: 345,
+ moderationActions: 9,
+ mediaTotalRuntime: "01:23:45",
+ mediaCount: 7,
+ redisClients: 4,
+ redisEnabled: true,
+ redisMemoryUsed: "18 MB",
+ databaseDriver: "sqlite",
+ generatedAt: "2026-08-08T12:00:00Z",
+ },
+ };
+
+ await refreshOverview();
+
+ expect(document.getElementById("stat-viewers")?.textContent).toBe("12");
+ expect(document.getElementById("stat-stored-messages")?.textContent).toBe("345");
+ expect(document.getElementById("system-redis-enabled")?.textContent).toBe("yes");
+ expect(document.getElementById("overview-status")?.textContent).toContain("2026-08-08T12:00:00Z");
+ });
+
+ it("renders analytics rows from the analytics API", async () => {
+ mockFetchResponse = {
+ ok: true,
+ payload: {
+ totalMessages: 1000,
+ messagesLast24Hours: 75,
+ distinctRooms: 3,
+ moderationActions: 11,
+ generatedAt: "2026-08-08T12:30:00Z",
+ topRooms: [
+ { room: "main", messageCount: 55 },
+ { room: "lobby", messageCount: 20 },
+ ],
+ },
+ };
+
+ await refreshAnalytics();
+
+ expect(document.getElementById("analytics-total-messages")?.textContent).toBe("1000");
+ expect(document.getElementById("analytics-messages-24h")?.textContent).toBe("75");
+ expect(document.getElementById("analytics-table-body")?.textContent).toContain("main");
+ expect(document.getElementById("analytics-table-body")?.textContent).toContain("lobby");
+ });
+});
+
+describe("playlist management", () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+
+ `;
+ });
+
+ it("saves playlist via PUT request", async () => {
+ mockFetchResponse = { ok: true, payload: {} };
+
+ await savePlaylist();
+
+ const putCall = fetchCalls.find((c) => c.options?.method === "PUT");
+ expect(putCall).toBeDefined();
+ expect(putCall?.url).toBe("/api/admin/playlist");
+ expect(putCall?.options?.headers).toEqual({ "Content-Type": "application/json" });
+ const body = JSON.parse(putCall?.options?.body as string);
+ expect(body.content).toContain("demo1.mp4");
+ });
+
+ it("sets error status on save failure", async () => {
+ mockFetchResponse = { ok: false, status: 500, payload: {} };
+
+ await savePlaylist();
+
+ const status = document.getElementById("playlist-status");
+ expect(status?.textContent).toContain("Failed");
+ });
+
+ it("renders sortable playlist with indexes and paths", () => {
+ renderPlaylistSortable("file '/app/media/demo1.mp4'\nfile '/app/media/demo2.mp4'\n");
+
+ const list = document.getElementById("playlist-sortable")!;
+ expect(list.children).toHaveLength(2);
+ expect(list.innerHTML).toContain("demo1.mp4");
+ expect(list.innerHTML).toContain("demo2.mp4");
+ expect(list.querySelector(".sortable-index")?.textContent).toBe("1");
+ expect(list.querySelector("[data-remove]")).toBeTruthy();
+ });
+
+ it("skips comment and blank lines in sortable", () => {
+ renderPlaylistSortable("# comment\n\nfile '/app/media/demo1.mp4'\n");
+
+ const list = document.getElementById("playlist-sortable")!;
+ expect(list.children).toHaveLength(1);
+ expect(list.innerHTML).toContain("demo1.mp4");
+ });
+});
+
+describe("admin accessibility", () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+ Dashboard
+ Playlist
+ Users
+ Moderation
+
+
+ `;
+ });
+
+ it("nav has aria-label for screen readers", () => {
+ const nav = document.querySelector(".admin-nav");
+ expect(nav?.getAttribute("aria-label")).toBe("Admin navigation");
+ });
+
+ it("active tab has aria-current attribute", () => {
+ const active = document.querySelector("[aria-current]");
+ expect(active).toBeTruthy();
+ expect(active?.getAttribute("aria-current")).toBe("page");
+ });
+
+ it("all nav buttons are focusable", () => {
+ const buttons = document.querySelectorAll(".admin-nav button");
+ buttons.forEach((btn) => {
+ expect(btn.getAttribute("tabindex")).toBeNull();
+ expect(btn.getAttribute("disabled")).toBeNull();
+ });
+ });
+});
+
+describe("admin responsive layout", () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+ Dashboard
+
+ `;
+ });
+
+ it("tab content is present and visible", () => {
+ const tabContent = document.getElementById("tab-content");
+ expect(tabContent).toBeTruthy();
+ const style = window.getComputedStyle(tabContent!);
+ expect(style.display).not.toBe("none");
+ });
+
+ it("admin nav contains essential tabs", () => {
+ const buttons = document.querySelectorAll(".admin-nav button");
+ const tabs = Array.from(buttons).map((b) => b.textContent?.trim());
+ expect(tabs).toContain("Dashboard");
+ });
+});
+
+describe("admin data refresh failures", () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+ `;
+ });
+
+ it("sets error status when overview fails", async () => {
+ mockFetchResponse = { ok: false, status: 500, payload: {} };
+ await refreshOverview();
+ expect(document.getElementById("overview-status")?.textContent).toContain("Failed");
+ });
+
+ it("sets error status when analytics fails", async () => {
+ mockFetchResponse = { ok: false, status: 500, payload: {} };
+ await refreshAnalytics();
+ expect(document.getElementById("analytics-status")?.textContent).toContain("Failed");
+ });
+});
+
+describe("playlist status badges", () => {
+ beforeEach(() => {
+ document.body.innerHTML = ``;
+ });
+
+ it("renders badges for every media status", () => {
+ const statusMap = new Map([
+ ["/app/media/ready.mp4", "ready"],
+ ["/app/media/needs.mp4", "needs_normalization"],
+ ["/app/media/normalizing.mp4", "normalizing"],
+ ["/app/media/failed.mp4", "normalization_failed"],
+ ["/app/media/other.mp4", "weird"],
+ ]);
+
+ renderPlaylistSortable(
+ "file '/app/media/ready.mp4'\nfile '/app/media/needs.mp4'\nfile '/app/media/normalizing.mp4'\nfile '/app/media/failed.mp4'\nfile '/app/media/other.mp4'\nfile '/app/media/nopath.mp4'\n",
+ statusMap,
+ );
+
+ const list = document.getElementById("playlist-sortable")!;
+ expect(list.innerHTML).toContain("badge-ok");
+ expect(list.innerHTML).toContain("badge-warn");
+ expect(list.innerHTML).toContain("badge-info");
+ expect(list.innerHTML).toContain("badge-danger");
+ expect(list.innerHTML).toContain("badge-muted"); // default + unknown fall back to muted
+ expect(list.children).toHaveLength(6);
+ });
+});
diff --git a/frontend/src/counter.test.ts b/frontend/src/counter.test.ts
index 074ec19..02b962e 100644
--- a/frontend/src/counter.test.ts
+++ b/frontend/src/counter.test.ts
@@ -1,23 +1,23 @@
-///
-import { describe, it, expect } from "vitest";
-import { setupCounter } from "../src/counter";
-
-describe("setupCounter", () => {
- it("initializes the button with count 0", () => {
- const btn = document.createElement("button");
- setupCounter(btn);
- expect(btn.innerHTML).toBe("Count is 0");
- });
-
- it("increments the counter on each click", () => {
- const btn = document.createElement("button");
- setupCounter(btn);
-
- btn.click();
- expect(btn.innerHTML).toBe("Count is 1");
-
- btn.click();
- btn.click();
- expect(btn.innerHTML).toBe("Count is 3");
- });
-});
+///
+import { describe, it, expect } from "vitest";
+import { setupCounter } from "../src/counter";
+
+describe("setupCounter", () => {
+ it("initializes the button with count 0", () => {
+ const btn = document.createElement("button");
+ setupCounter(btn);
+ expect(btn.innerHTML).toBe("Count is 0");
+ });
+
+ it("increments the counter on each click", () => {
+ const btn = document.createElement("button");
+ setupCounter(btn);
+
+ btn.click();
+ expect(btn.innerHTML).toBe("Count is 1");
+
+ btn.click();
+ btn.click();
+ expect(btn.innerHTML).toBe("Count is 3");
+ });
+});
diff --git a/frontend/src/main.test.ts b/frontend/src/main.test.ts
index 9a86a52..eeb4acd 100644
--- a/frontend/src/main.test.ts
+++ b/frontend/src/main.test.ts
@@ -1,598 +1,598 @@
-///
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import {
- resetViewerStateForTests,
- resolveManifestUrl,
- resolveWebSocketBaseUrl,
- showReconnecting,
- hideReconnecting,
-} from "../src/main";
-
-const hlsInstance = {
- loadSource: vi.fn(),
- attachMedia: vi.fn(),
- on: vi.fn(),
- destroy: vi.fn(),
-};
-
-const HlsMock = Object.assign(
- vi.fn().mockImplementation(() => hlsInstance),
- {
- isSupported: vi.fn(() => true),
- Events: {
- MANIFEST_PARSED: "hlsManifestParsed",
- ERROR: "hlsError",
- LEVEL_UPDATED: "hlsLevelUpdated",
- },
- ErrorTypes: {
- NETWORK_ERROR: "networkError",
- MEDIA_ERROR: "mediaError",
- MUX_ERROR: "muxError",
- OTHER_ERROR: "otherError",
- },
- },
-);
-
-// Mock hls.js
-vi.mock("hls.js", () => ({
- default: HlsMock,
-}));
-
-// Mock WebSocket
-const websocketInstances: MockWebSocket[] = [];
-class MockWebSocket {
- url: string;
- readyState = 0;
- onopen: (() => void) | null = null;
- onmessage: ((e: { data: string }) => void) | null = null;
- onclose: (() => void) | null = null;
- send = vi.fn();
- close = vi.fn();
-
- constructor(url: string) {
- this.url = String(url);
- websocketInstances.push(this);
- }
-}
-
-// Suppress console noise
-beforeEach(() => {
- vi.spyOn(console, "warn").mockImplementation(() => {});
- vi.spyOn(console, "error").mockImplementation(() => {});
- vi.spyOn(console, "log").mockImplementation(() => {});
-});
-
-let origWebSocket: any;
-let origFetch: any;
-
-beforeEach(() => {
- origWebSocket = (globalThis as any).WebSocket;
- origFetch = globalThis.fetch;
- (globalThis as any).WebSocket = MockWebSocket;
- websocketInstances.length = 0;
- resetViewerStateForTests();
-
- // Mock fetch (schedule, etc.) — return empty schedule
- globalThis.fetch = vi.fn().mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ entries: [], total: 0, generatedAt: "" }),
- });
-
- // Set up DOM from index.html
- // Inject minimal CSS for jsdom layout tests (external style.css not loaded)
- document.head.innerHTML = `
-
- `;
-
- document.body.innerHTML = `
-
-
-
-
-
- Reconnecting...
-
-
- Sign in
- Logout
-
-
- `;
-
- // Stub prompt for DM
- (globalThis as any).prompt = vi.fn();
-
- vi.useRealTimers();
-});
-
-afterEach(() => {
- (globalThis as any).WebSocket = origWebSocket;
- globalThis.fetch = origFetch;
- vi.useRealTimers();
- vi.restoreAllMocks();
-});
-
-describe("player startup", () => {
- it("starts native HLS playback on loadedmetadata", async () => {
- const video = document.getElementById("player") as HTMLVideoElement;
- vi.spyOn(video, "canPlayType").mockReturnValue("probably");
- const playSpy = vi.spyOn(video, "play").mockResolvedValue(undefined as any);
-
- await import("../src/main").then((mod) => mod.initPlayerAndChat());
-
- expect(video.src).toContain("/hls/playlist.m3u8?stream=");
- video.dispatchEvent(new Event("loadedmetadata"));
- expect(playSpy).toHaveBeenCalled();
- });
-
- it("starts hls.js playback when native HLS is unavailable", async () => {
- const video = document.getElementById("player") as HTMLVideoElement;
- Object.defineProperty(video, "canPlayType", {
- value: vi.fn(() => ""),
- configurable: true,
- });
- vi.spyOn(video, "play").mockResolvedValue(undefined as any);
-
- await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
-
- expect(HlsMock.isSupported).toHaveBeenCalled();
- expect(HlsMock).toHaveBeenCalled();
- });
-});
-
-describe("chat toggle", () => {
- it("toggles chat panel visibility", () => {
- const toggle = document.getElementById("chat-toggle")!;
-
- expect(toggle.textContent).toBe("Hide Chat");
-
- window.toggleChatPanel();
- expect(toggle.textContent).toBe("Show Chat");
-
- window.toggleChatPanel();
- expect(toggle.textContent).toBe("Hide Chat");
- });
-});
-
-describe("sendMessage", () => {
- it("clears input after sending non-empty text", () => {
- const input = document.getElementById("message-input") as HTMLInputElement;
- input.value = "hello!";
-
- window.sendMessage();
- expect(input.value).toBe("hello!");
- });
-
- it("does nothing with empty input", () => {
- const input = document.getElementById("message-input") as HTMLInputElement;
- input.value = " ";
- window.sendMessage();
- // No error thrown = pass
- });
-});
-
-describe("switchRoom", () => {
- it("updates room name on switch", () => {
- window.switchRoom("lobby");
- const roomName = document.getElementById("room-name")!;
- expect(roomName.textContent).toBe("Lobby Room");
- });
-
- it("shows Main Channel for main room", () => {
- window.switchRoom("main");
- const roomName = document.getElementById("room-name")!;
- expect(roomName.textContent).toBe("Main Channel");
- });
-});
-
-describe("togglePrivateDM", () => {
- it("prompts for username and switches room", () => {
- // Stub prompt to return a value this time
- vi.spyOn(window, "prompt").mockReturnValue("bob");
-
- const switchRoomSpy = vi.spyOn(window, "switchRoom");
- window.togglePrivateDM();
-
- expect(window.prompt).toHaveBeenCalled();
- expect(switchRoomSpy).not.toHaveBeenCalled(); // username is "viewerNNN" which gets set randomly
-
- switchRoomSpy.mockRestore();
- });
-});
-
-describe("WebSocket reconnect", () => {
- it("reconnects after onclose", async () => {
- vi.useFakeTimers();
-
- window.switchRoom("lobby");
- expect(websocketInstances).toHaveLength(1);
-
- websocketInstances[0].onclose?.();
- await vi.advanceTimersByTimeAsync(3000);
-
- expect(websocketInstances).toHaveLength(2);
- expect(websocketInstances[1].url).toContain("room=lobby");
- });
-
- it("does not reconnect from a socket that was intentionally replaced", async () => {
- vi.useFakeTimers();
-
- window.switchRoom("lobby");
- expect(websocketInstances).toHaveLength(1);
- const replaced = websocketInstances[0];
-
- // A room switch intentionally replaces the socket; the old socket must not
- // schedule its own reconnect, otherwise history is re-pulled on a loop.
- window.switchRoom("main");
- expect(websocketInstances).toHaveLength(2);
- expect(replaced.onclose).toBeNull();
-
- replaced.onclose?.();
- await vi.advanceTimersByTimeAsync(3000);
-
- expect(websocketInstances).toHaveLength(2);
- });
-});
-
-describe("URL resolution", () => {
- it("falls back to same-origin manifest and websocket URLs", () => {
- vi.stubEnv("VITE_PUBLIC_HLS_URL", "");
- vi.stubEnv("VITE_PUBLIC_WS_URL", "");
- vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "");
-
- Object.defineProperty(window, "location", {
- value: new URL("https://linearvoid.tv/viewer"),
- writable: true,
- });
-
- expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
- expect(resolveWebSocketBaseUrl()).toBe("wss://linearvoid.tv/ws");
- });
-
- it("preserves an allowed cross-origin manifest URL", () => {
- vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
- vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "true");
-
- expect(resolveManifestUrl()).toMatch(/^https:\/\/media\.linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
- });
-
- it("falls back to same-origin manifest when cross-origin is not allowed", () => {
- vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
- vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "false");
-
- expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
- });
-
- it("uses configured websocket URL when present", () => {
- vi.stubEnv("VITE_PUBLIC_WS_URL", "wss://chat.linearvoid.tv/ws");
-
- expect(resolveWebSocketBaseUrl()).toBe("wss://chat.linearvoid.tv/ws");
- });
-});
-
-describe("presence updates", () => {
- it("adds user to presence list on join system message", async () => {
- await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
-
- const ws = websocketInstances[0];
- ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
-
- const presenceList = document.getElementById("presence-list")!;
- expect(presenceList.textContent).toContain("alice");
- });
-
- it("removes user from presence list on leave system message", async () => {
- await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
-
- const ws = websocketInstances[0];
- ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
- ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) });
-
- const presenceList = document.getElementById("presence-list")!;
- expect(presenceList.textContent).not.toContain("alice");
- });
-
- it("renders multiple users sorted", async () => {
- await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
-
- const ws = websocketInstances[0];
- ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "zoe joined the room", username: "system" }) });
- ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
-
- const presenceList = document.getElementById("presence-list")!;
- const text = presenceList.textContent ?? "";
- const aliceIdx = text.indexOf("alice");
- const zoeIdx = text.indexOf("zoe");
- expect(aliceIdx).toBeLessThan(zoeIdx);
- });
-});
-
-describe("private DM flow", () => {
- it("creates dm: room name from sorted usernames", async () => {
- await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
-
- vi.spyOn(window, "prompt").mockReturnValue("bob");
- window.togglePrivateDM();
-
- const roomName = document.getElementById("room-name")!;
- // username is "viewer" (reset by tests), bob is target -> sorted = "bob-viewer"
- // switchRoom capitalizes first char: "Dm:bob-viewer Room"
- expect(roomName.textContent).toContain("bob-viewer");
- });
-});
-
-describe("message rendering", () => {
- it("renders system messages", async () => {
- await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
-
- const ws = websocketInstances[0];
- ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "Server restarting", username: "system" }) });
-
- const messages = document.getElementById("messages")!;
- expect(messages.innerHTML).toContain("Server restarting");
- expect(messages.querySelector(".message-system")).toBeTruthy();
- });
-
- it("renders self messages as message-self", async () => {
- await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
-
- const ws = websocketInstances[0];
- ws.onmessage?.({
- data: JSON.stringify({ type: "public", text: "hello world", username: "viewer", timestamp: Date.now() }),
- });
-
- const messages = document.getElementById("messages")!;
- expect(messages.innerHTML).toContain("hello world");
- expect(messages.querySelector(".message-self")).toBeTruthy();
- });
-
- it("renders other-user messages as message-other", async () => {
- await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
-
- const ws = websocketInstances[0];
- ws.onmessage?.({
- data: JSON.stringify({ type: "public", text: "hey", username: "alice", timestamp: Date.now() }),
- });
-
- const messages = document.getElementById("messages")!;
- expect(messages.innerHTML).toContain("hey");
- expect(messages.innerHTML).toContain("alice");
- expect(messages.querySelector(".message-other")).toBeTruthy();
- });
-});
-
-describe("reconnecting overlay", () => {
- it("shows and hides the overlay with a custom message", () => {
- showReconnecting("Custom message");
- const overlay = document.getElementById("reconnecting-overlay")!;
- expect(overlay.classList.contains("hidden")).toBe(false);
- expect(document.getElementById("reconnecting-text")!.textContent).toBe("Custom message");
-
- hideReconnecting();
- expect(overlay.classList.contains("hidden")).toBe(true);
- });
-});
-
-describe("typing indicator", () => {
- it("shows typing on a typing event and clears on the next message", async () => {
- await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
-
- const ws = websocketInstances[0];
- ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "alice" }) });
-
- const indicator = document.getElementById("typing-indicator")!;
- expect(indicator.textContent).toBe("alice is typing...");
- expect(indicator.hidden).toBe(false);
-
- ws.onmessage?.({ data: JSON.stringify({ type: "public", text: "hi", username: "alice", timestamp: Date.now() }) });
- expect(indicator.hidden).toBe(true);
- expect(indicator.textContent).toBe("");
- });
-
- it("ignores typing events from the current user", async () => {
- await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
-
- const ws = websocketInstances[0];
- ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "viewer" }) });
-
- const indicator = document.getElementById("typing-indicator")!;
- expect(indicator.textContent).toBe("");
- expect(indicator.hidden).toBe(true);
- });
-});
-
-describe("sendMessage over websocket", () => {
- it("sends a JSON message and clears the input", async () => {
- await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
-
- const input = document.getElementById("message-input") as HTMLInputElement;
- input.value = "hello world";
- window.sendMessage();
-
- const ws = websocketInstances[0];
- expect(ws.send).toHaveBeenCalledTimes(1);
- expect(ws.send).toHaveBeenCalledWith(expect.stringContaining("hello world"));
- expect(input.value).toBe("");
- });
-});
-
-describe("auth wiring", () => {
- it("logs in and updates the auth UI", async () => {
- await import("../src/main");
-
- globalThis.fetch = vi.fn(async (url: any) => {
- const u = String(url);
- if (u.includes("/api/auth/login")) {
- return {
- ok: true,
- status: 200,
- json: async () => ({ username: "alice", authenticated: true }),
- text: async () => "",
- };
- }
- return { ok: true, status: 200, json: async () => ({ authenticated: false }), text: async () => "" };
- }) as any;
-
- document.dispatchEvent(new Event("DOMContentLoaded"));
-
- const usernameInput = document.getElementById("auth-username") as HTMLInputElement;
- const passwordInput = document.getElementById("auth-password") as HTMLInputElement;
- usernameInput.value = "alice";
- passwordInput.value = "secret";
-
- document.getElementById("auth-login-btn")!.click();
- await new Promise((r) => setTimeout(r, 0));
-
- expect(document.getElementById("auth-status")!.textContent).toContain("Signed in as alice");
- expect(document.getElementById("chat-user-label")!.textContent).toBe("alice");
- });
-});
-
-describe("player error recovery", () => {
- it("shows reconnecting overlay on fatal network error", async () => {
- const main = await import("../src/main");
- await main.initPlayerAndChat(async () => ({ default: HlsMock }) as any);
-
- // Find the error handler registered by initPlayerAndChat
- const errorHandler = (hlsInstance.on as any).mock.calls.find((c: any) => c[0] === "hlsError") as
- | [string, Function]
- | undefined;
-
- if (errorHandler) {
- const handler = errorHandler[1];
- handler("hlsError", {
- type: "networkError",
- details: "manifestLoadError",
- fatal: true,
- });
-
- const overlay = document.getElementById("reconnecting-overlay")!;
- expect(overlay.classList.contains("hidden")).toBe(false);
- expect(document.getElementById("reconnecting-text")?.textContent).toBe("Reconnecting...");
- }
- });
-});
-
-describe("accessibility", () => {
- it("player video has controls and aria attributes", () => {
- const video = document.getElementById("player") as HTMLVideoElement;
- expect(video.controls).toBe(true);
- expect(video.hasAttribute("autoplay")).toBe(true);
- expect(video.hasAttribute("playsinline")).toBe(true);
- });
-
- it("chat panel has aria-labels for live region", () => {
- const messages = document.getElementById("messages");
- expect(messages?.getAttribute("aria-live")).toBe("polite");
-
- const chatPanel = document.getElementById("chat-panel");
- expect(chatPanel?.getAttribute("aria-label")).toBe("Chat panel");
- });
-
- it("chat toggle exposes aria-expanded state", () => {
- const toggle = document.getElementById("chat-toggle")!;
- expect(toggle.getAttribute("aria-expanded")).toBe("true");
-
- window.toggleChatPanel();
- expect(toggle.getAttribute("aria-expanded")).toBe("false");
- });
-
- it("message input has associated label", () => {
- const input = document.getElementById("message-input") as HTMLInputElement;
- expect(input.getAttribute("type")).toBe("text");
- expect(input.placeholder).toBe("Type a message...");
- const label = document.querySelector("label[for='message-input']");
- expect(label).toBeTruthy();
- expect(label?.textContent).toBe("Message input");
- });
-
- it("reconnecting overlay is an aria-live assertive region", () => {
- const overlay = document.getElementById("reconnecting-overlay");
- expect(overlay?.getAttribute("role")).toBe("alert");
- expect(overlay?.getAttribute("aria-live")).toBe("assertive");
- });
-});
-
-describe("responsive layout", () => {
- it("chat-collapsed class hides sidebar at narrow viewport", () => {
- const shell = document.querySelector(".viewer-shell")!;
- expect(shell.classList.contains("chat-collapsed")).toBe(false);
-
- window.toggleChatPanel();
- expect(shell.classList.contains("chat-collapsed")).toBe(true);
-
- window.toggleChatPanel();
- expect(shell.classList.contains("chat-collapsed")).toBe(false);
- });
-
- it("viewer-shell uses CSS grid for content layout", () => {
- const content = document.querySelector(".viewer-content");
- expect(content).toBeTruthy();
- const style = window.getComputedStyle(content!);
- expect(style.display).toBe("grid");
- });
-
- it("now-playing overlay is accessible at small sizes", () => {
- const np = document.getElementById("now-playing");
- expect(np?.getAttribute("aria-live")).toBe("polite");
- });
-});
-
-describe("presence flash", () => {
- it("flashes join/leave messages instead of appending chat rows", async () => {
- await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
-
- const ws = websocketInstances[0];
- ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
-
- const flash = document.getElementById("chat-flash")!;
- expect(flash.textContent).toBe("alice joined");
- expect(flash.hidden).toBe(false);
-
- // Join/leave must not be rendered as chat rows.
- const messages = document.getElementById("messages")!;
- expect(messages.textContent).not.toContain("joined");
-
- ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) });
- expect(flash.textContent).toBe("alice left");
- });
-});
+///
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ resetViewerStateForTests,
+ resolveManifestUrl,
+ resolveWebSocketBaseUrl,
+ showReconnecting,
+ hideReconnecting,
+} from "../src/main";
+
+const hlsInstance = {
+ loadSource: vi.fn(),
+ attachMedia: vi.fn(),
+ on: vi.fn(),
+ destroy: vi.fn(),
+};
+
+const HlsMock = Object.assign(
+ vi.fn().mockImplementation(() => hlsInstance),
+ {
+ isSupported: vi.fn(() => true),
+ Events: {
+ MANIFEST_PARSED: "hlsManifestParsed",
+ ERROR: "hlsError",
+ LEVEL_UPDATED: "hlsLevelUpdated",
+ },
+ ErrorTypes: {
+ NETWORK_ERROR: "networkError",
+ MEDIA_ERROR: "mediaError",
+ MUX_ERROR: "muxError",
+ OTHER_ERROR: "otherError",
+ },
+ },
+);
+
+// Mock hls.js
+vi.mock("hls.js", () => ({
+ default: HlsMock,
+}));
+
+// Mock WebSocket
+const websocketInstances: MockWebSocket[] = [];
+class MockWebSocket {
+ url: string;
+ readyState = 0;
+ onopen: (() => void) | null = null;
+ onmessage: ((e: { data: string }) => void) | null = null;
+ onclose: (() => void) | null = null;
+ send = vi.fn();
+ close = vi.fn();
+
+ constructor(url: string) {
+ this.url = String(url);
+ websocketInstances.push(this);
+ }
+}
+
+// Suppress console noise
+beforeEach(() => {
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ vi.spyOn(console, "log").mockImplementation(() => {});
+});
+
+let origWebSocket: any;
+let origFetch: any;
+
+beforeEach(() => {
+ origWebSocket = (globalThis as any).WebSocket;
+ origFetch = globalThis.fetch;
+ (globalThis as any).WebSocket = MockWebSocket;
+ websocketInstances.length = 0;
+ resetViewerStateForTests();
+
+ // Mock fetch (schedule, etc.) — return empty schedule
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ entries: [], total: 0, generatedAt: "" }),
+ });
+
+ // Set up DOM from index.html
+ // Inject minimal CSS for jsdom layout tests (external style.css not loaded)
+ document.head.innerHTML = `
+
+ `;
+
+ document.body.innerHTML = `
+
+
+
+
+
+ Reconnecting...
+
+
+ Sign in
+ Logout
+
+
+ `;
+
+ // Stub prompt for DM
+ (globalThis as any).prompt = vi.fn();
+
+ vi.useRealTimers();
+});
+
+afterEach(() => {
+ (globalThis as any).WebSocket = origWebSocket;
+ globalThis.fetch = origFetch;
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+describe("player startup", () => {
+ it("starts native HLS playback on loadedmetadata", async () => {
+ const video = document.getElementById("player") as HTMLVideoElement;
+ vi.spyOn(video, "canPlayType").mockReturnValue("probably");
+ const playSpy = vi.spyOn(video, "play").mockResolvedValue(undefined as any);
+
+ await import("../src/main").then((mod) => mod.initPlayerAndChat());
+
+ expect(video.src).toContain("/hls/playlist.m3u8?stream=");
+ video.dispatchEvent(new Event("loadedmetadata"));
+ expect(playSpy).toHaveBeenCalled();
+ });
+
+ it("starts hls.js playback when native HLS is unavailable", async () => {
+ const video = document.getElementById("player") as HTMLVideoElement;
+ Object.defineProperty(video, "canPlayType", {
+ value: vi.fn(() => ""),
+ configurable: true,
+ });
+ vi.spyOn(video, "play").mockResolvedValue(undefined as any);
+
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ expect(HlsMock.isSupported).toHaveBeenCalled();
+ expect(HlsMock).toHaveBeenCalled();
+ });
+});
+
+describe("chat toggle", () => {
+ it("toggles chat panel visibility", () => {
+ const toggle = document.getElementById("chat-toggle")!;
+
+ expect(toggle.textContent).toBe("Hide Chat");
+
+ window.toggleChatPanel();
+ expect(toggle.textContent).toBe("Show Chat");
+
+ window.toggleChatPanel();
+ expect(toggle.textContent).toBe("Hide Chat");
+ });
+});
+
+describe("sendMessage", () => {
+ it("clears input after sending non-empty text", () => {
+ const input = document.getElementById("message-input") as HTMLInputElement;
+ input.value = "hello!";
+
+ window.sendMessage();
+ expect(input.value).toBe("hello!");
+ });
+
+ it("does nothing with empty input", () => {
+ const input = document.getElementById("message-input") as HTMLInputElement;
+ input.value = " ";
+ window.sendMessage();
+ // No error thrown = pass
+ });
+});
+
+describe("switchRoom", () => {
+ it("updates room name on switch", () => {
+ window.switchRoom("lobby");
+ const roomName = document.getElementById("room-name")!;
+ expect(roomName.textContent).toBe("Lobby Room");
+ });
+
+ it("shows Main Channel for main room", () => {
+ window.switchRoom("main");
+ const roomName = document.getElementById("room-name")!;
+ expect(roomName.textContent).toBe("Main Channel");
+ });
+});
+
+describe("togglePrivateDM", () => {
+ it("prompts for username and switches room", () => {
+ // Stub prompt to return a value this time
+ vi.spyOn(window, "prompt").mockReturnValue("bob");
+
+ const switchRoomSpy = vi.spyOn(window, "switchRoom");
+ window.togglePrivateDM();
+
+ expect(window.prompt).toHaveBeenCalled();
+ expect(switchRoomSpy).not.toHaveBeenCalled(); // username is "viewerNNN" which gets set randomly
+
+ switchRoomSpy.mockRestore();
+ });
+});
+
+describe("WebSocket reconnect", () => {
+ it("reconnects after onclose", async () => {
+ vi.useFakeTimers();
+
+ window.switchRoom("lobby");
+ expect(websocketInstances).toHaveLength(1);
+
+ websocketInstances[0].onclose?.();
+ await vi.advanceTimersByTimeAsync(3000);
+
+ expect(websocketInstances).toHaveLength(2);
+ expect(websocketInstances[1].url).toContain("room=lobby");
+ });
+
+ it("does not reconnect from a socket that was intentionally replaced", async () => {
+ vi.useFakeTimers();
+
+ window.switchRoom("lobby");
+ expect(websocketInstances).toHaveLength(1);
+ const replaced = websocketInstances[0];
+
+ // A room switch intentionally replaces the socket; the old socket must not
+ // schedule its own reconnect, otherwise history is re-pulled on a loop.
+ window.switchRoom("main");
+ expect(websocketInstances).toHaveLength(2);
+ expect(replaced.onclose).toBeNull();
+
+ replaced.onclose?.();
+ await vi.advanceTimersByTimeAsync(3000);
+
+ expect(websocketInstances).toHaveLength(2);
+ });
+});
+
+describe("URL resolution", () => {
+ it("falls back to same-origin manifest and websocket URLs", () => {
+ vi.stubEnv("VITE_PUBLIC_HLS_URL", "");
+ vi.stubEnv("VITE_PUBLIC_WS_URL", "");
+ vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "");
+
+ Object.defineProperty(window, "location", {
+ value: new URL("https://linearvoid.tv/viewer"),
+ writable: true,
+ });
+
+ expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
+ expect(resolveWebSocketBaseUrl()).toBe("wss://linearvoid.tv/ws");
+ });
+
+ it("preserves an allowed cross-origin manifest URL", () => {
+ vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
+ vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "true");
+
+ expect(resolveManifestUrl()).toMatch(/^https:\/\/media\.linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
+ });
+
+ it("falls back to same-origin manifest when cross-origin is not allowed", () => {
+ vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
+ vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "false");
+
+ expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
+ });
+
+ it("uses configured websocket URL when present", () => {
+ vi.stubEnv("VITE_PUBLIC_WS_URL", "wss://chat.linearvoid.tv/ws");
+
+ expect(resolveWebSocketBaseUrl()).toBe("wss://chat.linearvoid.tv/ws");
+ });
+});
+
+describe("presence updates", () => {
+ it("adds user to presence list on join system message", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ const ws = websocketInstances[0];
+ ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
+
+ const presenceList = document.getElementById("presence-list")!;
+ expect(presenceList.textContent).toContain("alice");
+ });
+
+ it("removes user from presence list on leave system message", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ const ws = websocketInstances[0];
+ ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
+ ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) });
+
+ const presenceList = document.getElementById("presence-list")!;
+ expect(presenceList.textContent).not.toContain("alice");
+ });
+
+ it("renders multiple users sorted", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ const ws = websocketInstances[0];
+ ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "zoe joined the room", username: "system" }) });
+ ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
+
+ const presenceList = document.getElementById("presence-list")!;
+ const text = presenceList.textContent ?? "";
+ const aliceIdx = text.indexOf("alice");
+ const zoeIdx = text.indexOf("zoe");
+ expect(aliceIdx).toBeLessThan(zoeIdx);
+ });
+});
+
+describe("private DM flow", () => {
+ it("creates dm: room name from sorted usernames", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ vi.spyOn(window, "prompt").mockReturnValue("bob");
+ window.togglePrivateDM();
+
+ const roomName = document.getElementById("room-name")!;
+ // username is "viewer" (reset by tests), bob is target -> sorted = "bob-viewer"
+ // switchRoom capitalizes first char: "Dm:bob-viewer Room"
+ expect(roomName.textContent).toContain("bob-viewer");
+ });
+});
+
+describe("message rendering", () => {
+ it("renders system messages", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ const ws = websocketInstances[0];
+ ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "Server restarting", username: "system" }) });
+
+ const messages = document.getElementById("messages")!;
+ expect(messages.innerHTML).toContain("Server restarting");
+ expect(messages.querySelector(".message-system")).toBeTruthy();
+ });
+
+ it("renders self messages as message-self", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ const ws = websocketInstances[0];
+ ws.onmessage?.({
+ data: JSON.stringify({ type: "public", text: "hello world", username: "viewer", timestamp: Date.now() }),
+ });
+
+ const messages = document.getElementById("messages")!;
+ expect(messages.innerHTML).toContain("hello world");
+ expect(messages.querySelector(".message-self")).toBeTruthy();
+ });
+
+ it("renders other-user messages as message-other", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ const ws = websocketInstances[0];
+ ws.onmessage?.({
+ data: JSON.stringify({ type: "public", text: "hey", username: "alice", timestamp: Date.now() }),
+ });
+
+ const messages = document.getElementById("messages")!;
+ expect(messages.innerHTML).toContain("hey");
+ expect(messages.innerHTML).toContain("alice");
+ expect(messages.querySelector(".message-other")).toBeTruthy();
+ });
+});
+
+describe("reconnecting overlay", () => {
+ it("shows and hides the overlay with a custom message", () => {
+ showReconnecting("Custom message");
+ const overlay = document.getElementById("reconnecting-overlay")!;
+ expect(overlay.classList.contains("hidden")).toBe(false);
+ expect(document.getElementById("reconnecting-text")!.textContent).toBe("Custom message");
+
+ hideReconnecting();
+ expect(overlay.classList.contains("hidden")).toBe(true);
+ });
+});
+
+describe("typing indicator", () => {
+ it("shows typing on a typing event and clears on the next message", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ const ws = websocketInstances[0];
+ ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "alice" }) });
+
+ const indicator = document.getElementById("typing-indicator")!;
+ expect(indicator.textContent).toBe("alice is typing...");
+ expect(indicator.hidden).toBe(false);
+
+ ws.onmessage?.({ data: JSON.stringify({ type: "public", text: "hi", username: "alice", timestamp: Date.now() }) });
+ expect(indicator.hidden).toBe(true);
+ expect(indicator.textContent).toBe("");
+ });
+
+ it("ignores typing events from the current user", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ const ws = websocketInstances[0];
+ ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "viewer" }) });
+
+ const indicator = document.getElementById("typing-indicator")!;
+ expect(indicator.textContent).toBe("");
+ expect(indicator.hidden).toBe(true);
+ });
+});
+
+describe("sendMessage over websocket", () => {
+ it("sends a JSON message and clears the input", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ const input = document.getElementById("message-input") as HTMLInputElement;
+ input.value = "hello world";
+ window.sendMessage();
+
+ const ws = websocketInstances[0];
+ expect(ws.send).toHaveBeenCalledTimes(1);
+ expect(ws.send).toHaveBeenCalledWith(expect.stringContaining("hello world"));
+ expect(input.value).toBe("");
+ });
+});
+
+describe("auth wiring", () => {
+ it("logs in and updates the auth UI", async () => {
+ await import("../src/main");
+
+ globalThis.fetch = vi.fn(async (url: any) => {
+ const u = String(url);
+ if (u.includes("/api/auth/login")) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ username: "alice", authenticated: true }),
+ text: async () => "",
+ };
+ }
+ return { ok: true, status: 200, json: async () => ({ authenticated: false }), text: async () => "" };
+ }) as any;
+
+ document.dispatchEvent(new Event("DOMContentLoaded"));
+
+ const usernameInput = document.getElementById("auth-username") as HTMLInputElement;
+ const passwordInput = document.getElementById("auth-password") as HTMLInputElement;
+ usernameInput.value = "alice";
+ passwordInput.value = "secret";
+
+ document.getElementById("auth-login-btn")!.click();
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(document.getElementById("auth-status")!.textContent).toContain("Signed in as alice");
+ expect(document.getElementById("chat-user-label")!.textContent).toBe("alice");
+ });
+});
+
+describe("player error recovery", () => {
+ it("shows reconnecting overlay on fatal network error", async () => {
+ const main = await import("../src/main");
+ await main.initPlayerAndChat(async () => ({ default: HlsMock }) as any);
+
+ // Find the error handler registered by initPlayerAndChat
+ const errorHandler = (hlsInstance.on as any).mock.calls.find((c: any) => c[0] === "hlsError") as
+ | [string, Function]
+ | undefined;
+
+ if (errorHandler) {
+ const handler = errorHandler[1];
+ handler("hlsError", {
+ type: "networkError",
+ details: "manifestLoadError",
+ fatal: true,
+ });
+
+ const overlay = document.getElementById("reconnecting-overlay")!;
+ expect(overlay.classList.contains("hidden")).toBe(false);
+ expect(document.getElementById("reconnecting-text")?.textContent).toBe("Reconnecting...");
+ }
+ });
+});
+
+describe("accessibility", () => {
+ it("player video has controls and aria attributes", () => {
+ const video = document.getElementById("player") as HTMLVideoElement;
+ expect(video.controls).toBe(true);
+ expect(video.hasAttribute("autoplay")).toBe(true);
+ expect(video.hasAttribute("playsinline")).toBe(true);
+ });
+
+ it("chat panel has aria-labels for live region", () => {
+ const messages = document.getElementById("messages");
+ expect(messages?.getAttribute("aria-live")).toBe("polite");
+
+ const chatPanel = document.getElementById("chat-panel");
+ expect(chatPanel?.getAttribute("aria-label")).toBe("Chat panel");
+ });
+
+ it("chat toggle exposes aria-expanded state", () => {
+ const toggle = document.getElementById("chat-toggle")!;
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
+
+ window.toggleChatPanel();
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("message input has associated label", () => {
+ const input = document.getElementById("message-input") as HTMLInputElement;
+ expect(input.getAttribute("type")).toBe("text");
+ expect(input.placeholder).toBe("Type a message...");
+ const label = document.querySelector("label[for='message-input']");
+ expect(label).toBeTruthy();
+ expect(label?.textContent).toBe("Message input");
+ });
+
+ it("reconnecting overlay is an aria-live assertive region", () => {
+ const overlay = document.getElementById("reconnecting-overlay");
+ expect(overlay?.getAttribute("role")).toBe("alert");
+ expect(overlay?.getAttribute("aria-live")).toBe("assertive");
+ });
+});
+
+describe("responsive layout", () => {
+ it("chat-collapsed class hides sidebar at narrow viewport", () => {
+ const shell = document.querySelector(".viewer-shell")!;
+ expect(shell.classList.contains("chat-collapsed")).toBe(false);
+
+ window.toggleChatPanel();
+ expect(shell.classList.contains("chat-collapsed")).toBe(true);
+
+ window.toggleChatPanel();
+ expect(shell.classList.contains("chat-collapsed")).toBe(false);
+ });
+
+ it("viewer-shell uses CSS grid for content layout", () => {
+ const content = document.querySelector(".viewer-content");
+ expect(content).toBeTruthy();
+ const style = window.getComputedStyle(content!);
+ expect(style.display).toBe("grid");
+ });
+
+ it("now-playing overlay is accessible at small sizes", () => {
+ const np = document.getElementById("now-playing");
+ expect(np?.getAttribute("aria-live")).toBe("polite");
+ });
+});
+
+describe("presence flash", () => {
+ it("flashes join/leave messages instead of appending chat rows", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ const ws = websocketInstances[0];
+ ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
+
+ const flash = document.getElementById("chat-flash")!;
+ expect(flash.textContent).toBe("alice joined");
+ expect(flash.hidden).toBe(false);
+
+ // Join/leave must not be rendered as chat rows.
+ const messages = document.getElementById("messages")!;
+ expect(messages.textContent).not.toContain("joined");
+
+ ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) });
+ expect(flash.textContent).toBe("alice left");
+ });
+});
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index ba8fc13..5fb3f21 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -1,519 +1,519 @@
-import "./style.css";
-
-type HlsClass = (typeof import("hls.js"))["default"];
-
-declare global {
- interface Window {
- switchRoom: (room: string) => void;
- sendMessage: () => void;
- togglePrivateDM: () => void;
- toggleChatPanel: () => void;
- }
-}
-
-let hls: InstanceType | null = null;
-let ws: WebSocket | null = null;
-let currentRoom = "main";
-let username = "viewer" + Math.floor(Math.random() * 1000);
-let authenticated = false;
-let chatVisible = true;
-let typingUser = "";
-let lastTypingSent = 0;
-let typingTimeout: number | undefined;
-let flashTimeout: number | undefined;
-let flashFadeTimeout: number | undefined;
-const onlineUsers = new Set();
-
-// Player error recovery state (STREAMING.md contract)
-let playerErrorCount = 0;
-const PLAYER_MAX_RETRIES = 5;
-const PLAYER_BASE_DELAY_MS = 2000;
-
-export function resetViewerStateForTests() {
- hls = null;
- ws = null;
- currentRoom = "main";
- username = "viewer";
- authenticated = false;
- chatVisible = true;
- typingUser = "";
- lastTypingSent = 0;
- onlineUsers.clear();
- playerErrorCount = 0;
-}
-
-export function resolveWebSocketBaseUrl() {
- const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
-
- if (configuredUrl) {
- return configuredUrl;
- }
-
- const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
- return `${protocol}//${window.location.host}/ws`;
-}
-
-export function resolveManifestUrl() {
- const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim();
- const cacheBust = `stream=${Date.now()}`;
- const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin);
- sameOriginManifestUrl.searchParams.set("stream", cacheBust);
-
- const allowCrossOriginManifest = import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() === "true";
-
- if (configuredUrl) {
- const manifestUrl = new URL(configuredUrl);
- manifestUrl.searchParams.set("stream", cacheBust);
-
- if (!allowCrossOriginManifest && manifestUrl.origin !== window.location.origin) {
- return sameOriginManifestUrl.toString();
- }
-
- return manifestUrl.toString();
- }
-
- return sameOriginManifestUrl.toString();
-}
-
-function connectWebSocket(room: string) {
- // Clear previous messages so reconnects render a fresh history instead of
- // appending duplicates on top of the old log.
- clearMessages();
-
- // Detach the old socket's close handler before closing it. Otherwise an
- // intentional reconnect (login/logout/room switch/session refresh) would
- // have the replaced socket schedule yet another reconnect, and each new
- // connection re-pulls the room's history — filling the panel with old
- // messages on a loop.
- if (ws) {
- ws.onclose = null;
- ws.close();
- }
-
- const socketUrl = new URL(resolveWebSocketBaseUrl());
- socketUrl.searchParams.set("username", username);
- socketUrl.searchParams.set("room", room);
- const socket = new WebSocket(socketUrl);
- ws = socket;
-
- socket.onopen = () => {
- console.log(`Connected to chat room: ${room}`);
- addMessage("system", "Connected to " + room);
- };
-
- socket.onmessage = (event) => {
- const msg = JSON.parse(event.data);
- if (msg.type === "typing") {
- showTyping(msg.username);
- return;
- }
- hideTyping(msg.username);
-
- // Presence join/leave announcements become transient flashes, not chat rows.
- if (msg.type === "system" && msg.username === "system") {
- const joined = msg.text?.match(/^(\S+) joined /);
- const left = msg.text?.match(/^(\S+) left /);
- if (joined) {
- onlineUsers.add(joined[1]);
- renderPresence();
- showFlash(`${joined[1]} joined`);
- return;
- } else if (left) {
- onlineUsers.delete(left[1]);
- renderPresence();
- showFlash(`${left[1]} left`);
- return;
- }
- }
-
- addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
- };
-
- socket.onclose = () => {
- // Ignore close events from sockets that were intentionally replaced.
- if (ws !== socket) return;
- ws = null;
- console.log("Chat disconnected. Reconnecting in 3s...");
- setTimeout(() => connectWebSocket(room), 3000);
- };
-}
-
-function clearMessages() {
- const messages = document.getElementById("messages");
- if (messages) messages.innerHTML = "";
-}
-
-function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
- const messages = document.getElementById("messages");
- if (!messages) return;
- const div = document.createElement("div");
- div.className = "message-row";
-
- if (type === "system") {
- div.classList.add("message-system-row");
- div.innerHTML = `${text}
`;
- } else {
- div.classList.add(sender === username ? "message-self-row" : "message-other-row");
- div.innerHTML = `
-
- ${sender && sender !== username ? `${sender}
` : ""}
- ${text}
- ${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
-
- `;
- }
-
- messages.appendChild(div);
- messages.scrollTop = messages.scrollHeight;
-}
-
-function showFlash(text: string) {
- const el = document.getElementById("chat-flash");
- if (!el) return;
- el.textContent = text;
- el.hidden = false;
- el.classList.remove("fade-out");
- window.clearTimeout(flashTimeout);
- window.clearTimeout(flashFadeTimeout);
- flashTimeout = window.setTimeout(() => {
- el.classList.add("fade-out");
- flashFadeTimeout = window.setTimeout(() => {
- el.hidden = true;
- el.classList.remove("fade-out");
- }, 500);
- }, 3000);
-}
-
-function renderPresence() {
- const list = document.getElementById("presence-list");
- if (!list) return;
- const sorted = [...onlineUsers].sort();
- list.innerHTML = sorted
- .map((u) => ` ${u} `)
- .join("");
-}
-
-async function refreshSchedule() {
- try {
- const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
- const res = await fetch(`${apiUrl}/api/schedule`);
- if (!res.ok) return;
- const data = await res.json();
- const entries = data.entries as Array<{ title: string; status: string }>;
- if (entries.length > 0) {
- const np = document.getElementById("np-title");
- const nn = document.getElementById("np-next");
- if (np) np.textContent = `Now: ${entries[0].title}`;
- if (nn && entries.length > 1) {
- nn.textContent = `Up next: ${entries[1].title}`;
- }
- }
- } catch {
- // Schedule unavailable — non-critical
- }
-}
-
-export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default: HlsClass }> = () => import("hls.js")) {
- const video = document.getElementById("player") as HTMLVideoElement;
- const manifestUrl = resolveManifestUrl();
-
- if (video.canPlayType("application/vnd.apple.mpegurl")) {
- video.src = manifestUrl;
- video.addEventListener("loadedmetadata", () => video.play());
- } else {
- try {
- const { default: Hls } = await loadHlsModule();
- if (Hls.isSupported()) {
- hls = new Hls({
- liveDurationInfinity: true,
- liveSyncDurationCount: 3,
- });
- hls.loadSource(manifestUrl);
- hls.attachMedia(video);
- hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
-
- // Player error recovery (STREAMING.md contract)
- hls.on(Hls.Events.ERROR, (_event, data) => {
- if (!data.fatal) return;
-
- playerErrorCount++;
-
- switch (data.type) {
- case Hls.ErrorTypes.NETWORK_ERROR:
- if (playerErrorCount <= PLAYER_MAX_RETRIES) {
- const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1);
- console.warn(
- `HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`,
- );
- showReconnecting("Reconnecting...");
- setTimeout(() => {
- hls?.startLoad();
- }, delay);
- } else {
- console.error("HLS network error: max retries exhausted");
- showReconnecting("Stream temporarily unavailable");
- }
- break;
-
- case Hls.ErrorTypes.MEDIA_ERROR:
- if (playerErrorCount <= 1) {
- console.warn("HLS media error, attempting recovery...");
- showReconnecting("Recovering...");
- hls!.recoverMediaError();
- } else {
- console.error("HLS media error: recovery failed");
- hls!.destroy();
- showReconnecting("Stream temporarily unavailable");
- }
- break;
-
- default:
- console.error("HLS fatal error:", data.type, data.details);
- hls!.destroy();
- showReconnecting("Stream temporarily unavailable");
- break;
- }
- });
-
- // Reset error count on successful manifest parse
- hls.on(Hls.Events.MANIFEST_PARSED, () => {
- playerErrorCount = 0;
- hideReconnecting();
- });
- }
- } catch (error) {
- console.error("Failed to load HLS player", error);
- addMessage("system", "Unable to initialize video player.");
- }
- }
-
- // Resolve the user session first so we connect once with the correct username
- // (avoids pushing chat history twice on initial load).
- await sessionReady;
- connectWebSocket("main");
-
- // Start schedule polling
- refreshSchedule();
- setInterval(refreshSchedule, 30000);
-
- console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready");
-}
-
-export function showReconnecting(message = "Reconnecting...") {
- const overlay = document.getElementById("reconnecting-overlay");
- const text = document.getElementById("reconnecting-text");
- if (overlay) {
- overlay.classList.remove("hidden");
- }
- if (text) {
- text.textContent = message;
- }
-}
-
-export function hideReconnecting() {
- const overlay = document.getElementById("reconnecting-overlay");
- if (overlay) {
- overlay.classList.add("hidden");
- }
-}
-
-function toggleChatPanel() {
- const shell = document.querySelector(".viewer-shell");
- const toggle = document.getElementById("chat-toggle");
- if (!shell || !toggle) return;
-
- chatVisible = !chatVisible;
- shell.classList.toggle("chat-collapsed", !chatVisible);
- toggle.textContent = chatVisible ? "Hide Chat" : "Show Chat";
- toggle.setAttribute("aria-expanded", String(chatVisible));
-}
-
-function sendMessage() {
- const input = document.getElementById("message-input") as HTMLInputElement;
- const text = input.value.trim();
- if (!text || !ws) return;
-
- const msg = {
- room: currentRoom,
- username: username,
- text: text,
- type: currentRoom.startsWith("dm:") ? "private" : "public",
- };
-
- ws.send(JSON.stringify(msg));
- input.value = "";
-}
-
-function switchRoom(room: string) {
- currentRoom = room;
- document.getElementById("room-name")!.textContent =
- room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
- connectWebSocket(room);
-}
-
-function togglePrivateDM() {
- const target = prompt("Enter username to start private chat with:");
- if (target && target !== username) {
- const dmRoom = `dm:${[username, target].sort().join("-")}`;
- switchRoom(dmRoom);
- }
-}
-
-window.switchRoom = switchRoom;
-window.sendMessage = sendMessage;
-window.togglePrivateDM = togglePrivateDM;
-window.toggleChatPanel = toggleChatPanel;
-
-function openAuthModal() {
- const panel = document.getElementById("auth-panel");
- if (panel) panel.hidden = false;
-}
-
-function closeAuthModal() {
- const panel = document.getElementById("auth-panel");
- if (panel) panel.hidden = true;
-}
-
-function updateAuthUI() {
- const label = document.getElementById("chat-user-label");
- const toggle = document.getElementById("auth-toggle-btn");
- const logoutBtn = document.getElementById("auth-logout-btn");
- const panel = document.getElementById("auth-panel");
- if (label) {
- label.textContent = authenticated ? username : "";
- label.hidden = !authenticated;
- }
- if (toggle) toggle.hidden = authenticated;
- if (logoutBtn) logoutBtn.hidden = !authenticated;
- if (panel) panel.hidden = true;
-}
-
-let sessionReady: Promise = Promise.resolve();
-
-function refreshUserSession(): void {
- sessionReady = (async () => {
- try {
- const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
- const res = await fetch(`${apiUrl}/api/auth/session`, { credentials: "include" });
- if (!res.ok) return;
- const data = await res.json();
- if (data.authenticated && data.username) {
- authenticated = true;
- username = data.username;
- } else {
- authenticated = false;
- }
- updateAuthUI();
- } catch {
- /* non-critical */
- }
- })();
-}
-
-async function submitAuth(path: string) {
- const u = (document.getElementById("auth-username") as HTMLInputElement).value.trim();
- const p = (document.getElementById("auth-password") as HTMLInputElement).value;
- const status = document.getElementById("auth-status");
- if (!u || !p) {
- if (status) {
- status.textContent = "Username and password required.";
- status.className = "status-line status-error";
- }
- return;
- }
- try {
- const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
- const res = await fetch(`${apiUrl}${path}`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ username: u, password: p }),
- });
- if (!res.ok) {
- const text = (await res.text()).trim();
- throw new Error(text || `HTTP ${res.status}`);
- }
- const data = await res.json();
- username = data.username || u;
- authenticated = true;
- updateAuthUI();
- if (status) {
- status.textContent = `Signed in as ${username}.`;
- status.className = "status-line";
- }
- connectWebSocket(currentRoom);
- } catch (err) {
- if (status) {
- status.textContent = `Failed: ${err instanceof Error ? err.message : String(err)}`;
- status.className = "status-line status-error";
- }
- }
-}
-
-function login() {
- void submitAuth("/api/auth/login");
-}
-
-function register() {
- void submitAuth("/api/auth/register");
-}
-
-async function logout() {
- try {
- const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
- await fetch(`${apiUrl}/api/auth/logout`, { method: "POST", credentials: "include" });
- } catch {
- /* ignore */
- }
- authenticated = false;
- username = "viewer" + Math.floor(Math.random() * 1000);
- updateAuthUI();
- connectWebSocket(currentRoom);
-}
-
-function showTyping(name: string) {
- if (name === username) return;
- const el = document.getElementById("typing-indicator");
- if (!el) return;
- typingUser = name;
- el.textContent = `${name} is typing...`;
- el.hidden = false;
- window.clearTimeout(typingTimeout);
- typingTimeout = window.setTimeout(() => {
- typingUser = "";
- el.hidden = true;
- el.textContent = "";
- }, 3000);
-}
-
-function hideTyping(name: string) {
- if (name !== typingUser) return;
- const el = document.getElementById("typing-indicator");
- typingUser = "";
- window.clearTimeout(typingTimeout);
- if (el) {
- el.hidden = true;
- el.textContent = "";
- }
-}
-
-function sendTyping() {
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
- const now = Date.now();
- if (now - lastTypingSent < 2000) return;
- lastTypingSent = now;
- ws.send(JSON.stringify({ room: currentRoom, username, text: "", type: "typing" }));
-}
-
-document.addEventListener("DOMContentLoaded", () => {
- void initPlayerAndChat();
- refreshUserSession();
- document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
- if (!authenticated) openAuthModal();
- });
- document.getElementById("auth-close-btn")?.addEventListener("click", closeAuthModal);
- document.getElementById("auth-backdrop")?.addEventListener("click", closeAuthModal);
- document.getElementById("auth-login-btn")?.addEventListener("click", login);
- document.getElementById("auth-register-btn")?.addEventListener("click", register);
- document.getElementById("auth-logout-btn")?.addEventListener("click", logout);
- document.getElementById("message-input")?.addEventListener("input", sendTyping);
-});
+import "./style.css";
+
+type HlsClass = (typeof import("hls.js"))["default"];
+
+declare global {
+ interface Window {
+ switchRoom: (room: string) => void;
+ sendMessage: () => void;
+ togglePrivateDM: () => void;
+ toggleChatPanel: () => void;
+ }
+}
+
+let hls: InstanceType | null = null;
+let ws: WebSocket | null = null;
+let currentRoom = "main";
+let username = "viewer" + Math.floor(Math.random() * 1000);
+let authenticated = false;
+let chatVisible = true;
+let typingUser = "";
+let lastTypingSent = 0;
+let typingTimeout: number | undefined;
+let flashTimeout: number | undefined;
+let flashFadeTimeout: number | undefined;
+const onlineUsers = new Set();
+
+// Player error recovery state (STREAMING.md contract)
+let playerErrorCount = 0;
+const PLAYER_MAX_RETRIES = 5;
+const PLAYER_BASE_DELAY_MS = 2000;
+
+export function resetViewerStateForTests() {
+ hls = null;
+ ws = null;
+ currentRoom = "main";
+ username = "viewer";
+ authenticated = false;
+ chatVisible = true;
+ typingUser = "";
+ lastTypingSent = 0;
+ onlineUsers.clear();
+ playerErrorCount = 0;
+}
+
+export function resolveWebSocketBaseUrl() {
+ const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
+
+ if (configuredUrl) {
+ return configuredUrl;
+ }
+
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ return `${protocol}//${window.location.host}/ws`;
+}
+
+export function resolveManifestUrl() {
+ const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim();
+ const cacheBust = `stream=${Date.now()}`;
+ const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin);
+ sameOriginManifestUrl.searchParams.set("stream", cacheBust);
+
+ const allowCrossOriginManifest = import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() === "true";
+
+ if (configuredUrl) {
+ const manifestUrl = new URL(configuredUrl);
+ manifestUrl.searchParams.set("stream", cacheBust);
+
+ if (!allowCrossOriginManifest && manifestUrl.origin !== window.location.origin) {
+ return sameOriginManifestUrl.toString();
+ }
+
+ return manifestUrl.toString();
+ }
+
+ return sameOriginManifestUrl.toString();
+}
+
+function connectWebSocket(room: string) {
+ // Clear previous messages so reconnects render a fresh history instead of
+ // appending duplicates on top of the old log.
+ clearMessages();
+
+ // Detach the old socket's close handler before closing it. Otherwise an
+ // intentional reconnect (login/logout/room switch/session refresh) would
+ // have the replaced socket schedule yet another reconnect, and each new
+ // connection re-pulls the room's history — filling the panel with old
+ // messages on a loop.
+ if (ws) {
+ ws.onclose = null;
+ ws.close();
+ }
+
+ const socketUrl = new URL(resolveWebSocketBaseUrl());
+ socketUrl.searchParams.set("username", username);
+ socketUrl.searchParams.set("room", room);
+ const socket = new WebSocket(socketUrl);
+ ws = socket;
+
+ socket.onopen = () => {
+ console.log(`Connected to chat room: ${room}`);
+ addMessage("system", "Connected to " + room);
+ };
+
+ socket.onmessage = (event) => {
+ const msg = JSON.parse(event.data);
+ if (msg.type === "typing") {
+ showTyping(msg.username);
+ return;
+ }
+ hideTyping(msg.username);
+
+ // Presence join/leave announcements become transient flashes, not chat rows.
+ if (msg.type === "system" && msg.username === "system") {
+ const joined = msg.text?.match(/^(\S+) joined /);
+ const left = msg.text?.match(/^(\S+) left /);
+ if (joined) {
+ onlineUsers.add(joined[1]);
+ renderPresence();
+ showFlash(`${joined[1]} joined`);
+ return;
+ } else if (left) {
+ onlineUsers.delete(left[1]);
+ renderPresence();
+ showFlash(`${left[1]} left`);
+ return;
+ }
+ }
+
+ addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
+ };
+
+ socket.onclose = () => {
+ // Ignore close events from sockets that were intentionally replaced.
+ if (ws !== socket) return;
+ ws = null;
+ console.log("Chat disconnected. Reconnecting in 3s...");
+ setTimeout(() => connectWebSocket(room), 3000);
+ };
+}
+
+function clearMessages() {
+ const messages = document.getElementById("messages");
+ if (messages) messages.innerHTML = "";
+}
+
+function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
+ const messages = document.getElementById("messages");
+ if (!messages) return;
+ const div = document.createElement("div");
+ div.className = "message-row";
+
+ if (type === "system") {
+ div.classList.add("message-system-row");
+ div.innerHTML = `${text}
`;
+ } else {
+ div.classList.add(sender === username ? "message-self-row" : "message-other-row");
+ div.innerHTML = `
+
+ ${sender && sender !== username ? `${sender}
` : ""}
+ ${text}
+ ${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
+
+ `;
+ }
+
+ messages.appendChild(div);
+ messages.scrollTop = messages.scrollHeight;
+}
+
+function showFlash(text: string) {
+ const el = document.getElementById("chat-flash");
+ if (!el) return;
+ el.textContent = text;
+ el.hidden = false;
+ el.classList.remove("fade-out");
+ window.clearTimeout(flashTimeout);
+ window.clearTimeout(flashFadeTimeout);
+ flashTimeout = window.setTimeout(() => {
+ el.classList.add("fade-out");
+ flashFadeTimeout = window.setTimeout(() => {
+ el.hidden = true;
+ el.classList.remove("fade-out");
+ }, 500);
+ }, 3000);
+}
+
+function renderPresence() {
+ const list = document.getElementById("presence-list");
+ if (!list) return;
+ const sorted = [...onlineUsers].sort();
+ list.innerHTML = sorted
+ .map((u) => ` ${u} `)
+ .join("");
+}
+
+async function refreshSchedule() {
+ try {
+ const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
+ const res = await fetch(`${apiUrl}/api/schedule`);
+ if (!res.ok) return;
+ const data = await res.json();
+ const entries = data.entries as Array<{ title: string; status: string }>;
+ if (entries.length > 0) {
+ const np = document.getElementById("np-title");
+ const nn = document.getElementById("np-next");
+ if (np) np.textContent = `Now: ${entries[0].title}`;
+ if (nn && entries.length > 1) {
+ nn.textContent = `Up next: ${entries[1].title}`;
+ }
+ }
+ } catch {
+ // Schedule unavailable — non-critical
+ }
+}
+
+export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default: HlsClass }> = () => import("hls.js")) {
+ const video = document.getElementById("player") as HTMLVideoElement;
+ const manifestUrl = resolveManifestUrl();
+
+ if (video.canPlayType("application/vnd.apple.mpegurl")) {
+ video.src = manifestUrl;
+ video.addEventListener("loadedmetadata", () => video.play());
+ } else {
+ try {
+ const { default: Hls } = await loadHlsModule();
+ if (Hls.isSupported()) {
+ hls = new Hls({
+ liveDurationInfinity: true,
+ liveSyncDurationCount: 3,
+ });
+ hls.loadSource(manifestUrl);
+ hls.attachMedia(video);
+ hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
+
+ // Player error recovery (STREAMING.md contract)
+ hls.on(Hls.Events.ERROR, (_event, data) => {
+ if (!data.fatal) return;
+
+ playerErrorCount++;
+
+ switch (data.type) {
+ case Hls.ErrorTypes.NETWORK_ERROR:
+ if (playerErrorCount <= PLAYER_MAX_RETRIES) {
+ const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1);
+ console.warn(
+ `HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`,
+ );
+ showReconnecting("Reconnecting...");
+ setTimeout(() => {
+ hls?.startLoad();
+ }, delay);
+ } else {
+ console.error("HLS network error: max retries exhausted");
+ showReconnecting("Stream temporarily unavailable");
+ }
+ break;
+
+ case Hls.ErrorTypes.MEDIA_ERROR:
+ if (playerErrorCount <= 1) {
+ console.warn("HLS media error, attempting recovery...");
+ showReconnecting("Recovering...");
+ hls!.recoverMediaError();
+ } else {
+ console.error("HLS media error: recovery failed");
+ hls!.destroy();
+ showReconnecting("Stream temporarily unavailable");
+ }
+ break;
+
+ default:
+ console.error("HLS fatal error:", data.type, data.details);
+ hls!.destroy();
+ showReconnecting("Stream temporarily unavailable");
+ break;
+ }
+ });
+
+ // Reset error count on successful manifest parse
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
+ playerErrorCount = 0;
+ hideReconnecting();
+ });
+ }
+ } catch (error) {
+ console.error("Failed to load HLS player", error);
+ addMessage("system", "Unable to initialize video player.");
+ }
+ }
+
+ // Resolve the user session first so we connect once with the correct username
+ // (avoids pushing chat history twice on initial load).
+ await sessionReady;
+ connectWebSocket("main");
+
+ // Start schedule polling
+ refreshSchedule();
+ setInterval(refreshSchedule, 30000);
+
+ console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready");
+}
+
+export function showReconnecting(message = "Reconnecting...") {
+ const overlay = document.getElementById("reconnecting-overlay");
+ const text = document.getElementById("reconnecting-text");
+ if (overlay) {
+ overlay.classList.remove("hidden");
+ }
+ if (text) {
+ text.textContent = message;
+ }
+}
+
+export function hideReconnecting() {
+ const overlay = document.getElementById("reconnecting-overlay");
+ if (overlay) {
+ overlay.classList.add("hidden");
+ }
+}
+
+function toggleChatPanel() {
+ const shell = document.querySelector(".viewer-shell");
+ const toggle = document.getElementById("chat-toggle");
+ if (!shell || !toggle) return;
+
+ chatVisible = !chatVisible;
+ shell.classList.toggle("chat-collapsed", !chatVisible);
+ toggle.textContent = chatVisible ? "Hide Chat" : "Show Chat";
+ toggle.setAttribute("aria-expanded", String(chatVisible));
+}
+
+function sendMessage() {
+ const input = document.getElementById("message-input") as HTMLInputElement;
+ const text = input.value.trim();
+ if (!text || !ws) return;
+
+ const msg = {
+ room: currentRoom,
+ username: username,
+ text: text,
+ type: currentRoom.startsWith("dm:") ? "private" : "public",
+ };
+
+ ws.send(JSON.stringify(msg));
+ input.value = "";
+}
+
+function switchRoom(room: string) {
+ currentRoom = room;
+ document.getElementById("room-name")!.textContent =
+ room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
+ connectWebSocket(room);
+}
+
+function togglePrivateDM() {
+ const target = prompt("Enter username to start private chat with:");
+ if (target && target !== username) {
+ const dmRoom = `dm:${[username, target].sort().join("-")}`;
+ switchRoom(dmRoom);
+ }
+}
+
+window.switchRoom = switchRoom;
+window.sendMessage = sendMessage;
+window.togglePrivateDM = togglePrivateDM;
+window.toggleChatPanel = toggleChatPanel;
+
+function openAuthModal() {
+ const panel = document.getElementById("auth-panel");
+ if (panel) panel.hidden = false;
+}
+
+function closeAuthModal() {
+ const panel = document.getElementById("auth-panel");
+ if (panel) panel.hidden = true;
+}
+
+function updateAuthUI() {
+ const label = document.getElementById("chat-user-label");
+ const toggle = document.getElementById("auth-toggle-btn");
+ const logoutBtn = document.getElementById("auth-logout-btn");
+ const panel = document.getElementById("auth-panel");
+ if (label) {
+ label.textContent = authenticated ? username : "";
+ label.hidden = !authenticated;
+ }
+ if (toggle) toggle.hidden = authenticated;
+ if (logoutBtn) logoutBtn.hidden = !authenticated;
+ if (panel) panel.hidden = true;
+}
+
+let sessionReady: Promise = Promise.resolve();
+
+function refreshUserSession(): void {
+ sessionReady = (async () => {
+ try {
+ const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
+ const res = await fetch(`${apiUrl}/api/auth/session`, { credentials: "include" });
+ if (!res.ok) return;
+ const data = await res.json();
+ if (data.authenticated && data.username) {
+ authenticated = true;
+ username = data.username;
+ } else {
+ authenticated = false;
+ }
+ updateAuthUI();
+ } catch {
+ /* non-critical */
+ }
+ })();
+}
+
+async function submitAuth(path: string) {
+ const u = (document.getElementById("auth-username") as HTMLInputElement).value.trim();
+ const p = (document.getElementById("auth-password") as HTMLInputElement).value;
+ const status = document.getElementById("auth-status");
+ if (!u || !p) {
+ if (status) {
+ status.textContent = "Username and password required.";
+ status.className = "status-line status-error";
+ }
+ return;
+ }
+ try {
+ const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
+ const res = await fetch(`${apiUrl}${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username: u, password: p }),
+ });
+ if (!res.ok) {
+ const text = (await res.text()).trim();
+ throw new Error(text || `HTTP ${res.status}`);
+ }
+ const data = await res.json();
+ username = data.username || u;
+ authenticated = true;
+ updateAuthUI();
+ if (status) {
+ status.textContent = `Signed in as ${username}.`;
+ status.className = "status-line";
+ }
+ connectWebSocket(currentRoom);
+ } catch (err) {
+ if (status) {
+ status.textContent = `Failed: ${err instanceof Error ? err.message : String(err)}`;
+ status.className = "status-line status-error";
+ }
+ }
+}
+
+function login() {
+ void submitAuth("/api/auth/login");
+}
+
+function register() {
+ void submitAuth("/api/auth/register");
+}
+
+async function logout() {
+ try {
+ const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
+ await fetch(`${apiUrl}/api/auth/logout`, { method: "POST", credentials: "include" });
+ } catch {
+ /* ignore */
+ }
+ authenticated = false;
+ username = "viewer" + Math.floor(Math.random() * 1000);
+ updateAuthUI();
+ connectWebSocket(currentRoom);
+}
+
+function showTyping(name: string) {
+ if (name === username) return;
+ const el = document.getElementById("typing-indicator");
+ if (!el) return;
+ typingUser = name;
+ el.textContent = `${name} is typing...`;
+ el.hidden = false;
+ window.clearTimeout(typingTimeout);
+ typingTimeout = window.setTimeout(() => {
+ typingUser = "";
+ el.hidden = true;
+ el.textContent = "";
+ }, 3000);
+}
+
+function hideTyping(name: string) {
+ if (name !== typingUser) return;
+ const el = document.getElementById("typing-indicator");
+ typingUser = "";
+ window.clearTimeout(typingTimeout);
+ if (el) {
+ el.hidden = true;
+ el.textContent = "";
+ }
+}
+
+function sendTyping() {
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ const now = Date.now();
+ if (now - lastTypingSent < 2000) return;
+ lastTypingSent = now;
+ ws.send(JSON.stringify({ room: currentRoom, username, text: "", type: "typing" }));
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ void initPlayerAndChat();
+ refreshUserSession();
+ document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
+ if (!authenticated) openAuthModal();
+ });
+ document.getElementById("auth-close-btn")?.addEventListener("click", closeAuthModal);
+ document.getElementById("auth-backdrop")?.addEventListener("click", closeAuthModal);
+ document.getElementById("auth-login-btn")?.addEventListener("click", login);
+ document.getElementById("auth-register-btn")?.addEventListener("click", register);
+ document.getElementById("auth-logout-btn")?.addEventListener("click", logout);
+ document.getElementById("message-input")?.addEventListener("input", sendTyping);
+});
diff --git a/frontend/src/style.css b/frontend/src/style.css
index e878312..a637185 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1,1244 +1,1244 @@
-:root {
- color-scheme: dark;
-
- --font-body: "Tektur", "Segoe UI", "Helvetica Neue", sans-serif;
- --font-heading: "Doto", "Segoe UI", "Helvetica Neue", sans-serif;
- --font-display: "Space Grotesk", "Segoe UI", sans-serif;
- --font-mono: "IBM Plex Mono", Consolas, monospace;
-
- --bg-0: #000000;
- --bg-1: #0a0a0a;
- --bg-2: #000000;
- --ink-0: #ede8e6;
- --ink-1: #b0a8a4;
- --ink-soft: #78706c;
-
- --panel: #111111;
- --panel-2: #1a1a1a;
- --line: #2a2a2a;
- --line-soft: #1f1f1f;
-
- --brand: #587792;
- --brand-strong: #4a6780;
- --brand-contrast: #f4f8fb;
- --ok: #04650d;
- --warn: #d49624;
- --danger: #c44a5e;
- --danger-soft: #1f1216;
-
- --focus: #2ebfa5;
- --shadow-1: 0 14px 30px rgba(0, 0, 0, 0.5);
- --shadow-2: 0 22px 44px rgba(0, 0, 0, 0.6);
-
- --bg-grad-a: #000000;
- --bg-grad-b: #000000;
- --bg-grad-c: #000000;
-
- --live-dot: #ff6b6b;
- --overlay-bg: rgba(0, 0, 0, 0.88);
-
- --input-bg: #0d0d0d;
- --input-border: var(--line-soft);
- --placeholder: #6a6260;
-
- --badge-start: #2ebfa5;
- --badge-end: #1a8c78;
-
- --radius-1: 0;
- --radius-2: 0;
- --radius-3: 0;
-
- --space-1: 0.25rem;
- --space-2: 0.5rem;
- --space-3: 0.75rem;
- --space-4: 1rem;
- --space-5: 1.5rem;
- --space-6: 2rem;
- --space-7: 3rem;
-
- --fast: 160ms ease;
-}
-
-@media (prefers-color-scheme: light) {
- :root {
- color-scheme: light;
-
- --bg-0: #e8e8e8;
- --bg-1: #dcdcdc;
- --bg-2: #f0f0f0;
- --ink-0: #0d0d0d;
- --ink-1: #2a2a2a;
- --ink-soft: #555555;
-
- --panel: #ffffff;
- --panel-2: #f5f5f5;
- --line: #cccccc;
- --line-soft: #dddddd;
-
- --brand: #587792;
- --brand-strong: #4a6780;
- --brand-contrast: #f4f8fb;
- --ok: #04650d;
- --warn: #a86200;
- --danger: #a7283f;
- --danger-soft: #fff3f6;
-
- --focus: #2ebfa5;
- --shadow-1: 0 8px 20px rgba(0, 0, 0, 0.08);
- --shadow-2: 0 16px 36px rgba(0, 0, 0, 0.12);
-
- --bg-grad-a: #e8e8e8;
- --bg-grad-b: var(--bg-0);
- --bg-grad-c: #f0f0f0;
-
- --live-dot: #cb271a;
- --overlay-bg: rgba(0, 0, 0, 0.88);
-
- --input-bg: #ffffff;
- --input-border: #cccccc;
- --placeholder: #888888;
-
- --badge-start: #2ebfa5;
- --badge-end: #1a8c78;
- }
-}
-
-* {
- box-sizing: border-box;
-}
-
-html,
-body {
- margin: 0;
- padding: 0;
- height: 100%;
- min-height: 100%;
-}
-
-body {
- font-family: var(--font-body);
- color: var(--ink-0);
- background: radial-gradient(circle at 20% 10%, var(--bg-grad-a) 0%, var(--bg-grad-b) 50%, var(--bg-grad-c) 100%);
- line-height: 1.45;
-}
-
-h1,
-h2,
-h3 {
- margin: 0;
- font-family: var(--font-heading);
- color: var(--ink-0);
- letter-spacing: -0.02em;
-}
-
-p {
- margin: 0;
-}
-
-a {
- color: var(--brand-strong);
-}
-
-a:hover {
- color: var(--brand);
-}
-
-button,
-input,
-textarea,
-select {
- font: inherit;
-}
-
-:focus-visible {
- outline: 3px solid var(--focus);
- outline-offset: 2px;
-}
-
-.hidden {
- display: none !important;
-}
-
-.sr-only {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- white-space: nowrap;
- border: 0;
-}
-
-.small-muted {
- color: var(--ink-soft);
- font-size: 0.9rem;
-}
-
-.status-line {
- margin-top: var(--space-2);
- color: var(--ink-soft);
- min-height: 1.4rem;
-}
-
-.status-error {
- color: var(--danger);
-}
-
-.sortable-list {
- list-style: none;
- margin: 0 0 0.75rem;
- padding: 0;
- max-height: 300px;
- overflow-y: auto;
- border: 1px solid var(--line-soft);
- border-radius: 4px;
-}
-
-.sortable-item {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.4rem 0.6rem;
- border-bottom: 1px solid var(--line-soft);
- cursor: grab;
- transition: background 0.15s;
-}
-
-.sortable-item:last-child {
- border-bottom: none;
-}
-.sortable-item:hover {
- background: var(--panel-2);
-}
-.sortable-item.dragging {
- opacity: 0.4;
-}
-
-.drag-handle {
- color: var(--ink-soft);
- font-size: 0.8rem;
- letter-spacing: 1px;
- cursor: grab;
- user-select: none;
-}
-
-.sortable-index {
- font-size: 0.7rem;
- color: var(--ink-soft);
- min-width: 1.5rem;
- text-align: right;
-}
-
-.sortable-path {
- flex: 1;
- font-size: 0.8rem;
- color: var(--ink-0);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.status-warnings {
- background: rgba(212, 150, 36, 0.1);
- border: 1px solid var(--warn);
- padding: 0.5rem 0.75rem;
- margin-bottom: 0.75rem;
- border-radius: 4px;
-}
-
-.warning-msg {
- font-size: 0.8rem;
- color: var(--warn);
- line-height: 1.5;
-}
-
-.badge {
- display: inline-block;
- padding: 0.1rem 0.5rem;
- border-radius: 999px;
- font-size: 0.72rem;
- font-weight: 600;
- line-height: 1.5;
- white-space: nowrap;
-}
-
-.badge-ok {
- color: var(--ok);
- background: rgba(4, 101, 13, 0.12);
-}
-
-.badge-warn {
- color: var(--warn);
- background: rgba(212, 150, 36, 0.12);
-}
-
-.badge-info {
- color: var(--brand);
- background: rgba(88, 119, 146, 0.14);
-}
-
-.badge-danger {
- color: var(--danger);
- background: rgba(196, 74, 94, 0.12);
-}
-
-.badge-muted {
- color: var(--ink-soft);
- background: var(--bg-2);
-}
-
-.chat-header-actions {
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.chat-user-label {
- font-size: 0.78rem;
- font-weight: 600;
- color: var(--ink-soft);
- max-width: 10rem;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.auth-modal {
- position: fixed;
- inset: 0;
- z-index: 100;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.auth-modal[hidden] {
- display: none;
-}
-
-.auth-modal-backdrop {
- position: absolute;
- inset: 0;
- background: rgba(0, 0, 0, 0.6);
-}
-
-.auth-modal-content {
- position: relative;
- z-index: 1;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- width: min(20rem, calc(100vw - 2rem));
- padding: 1.25rem;
- border: 1px solid var(--bg-2);
- border-radius: 0.5rem;
- background: var(--panel-2);
- box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
-}
-
-.auth-close {
- position: absolute;
- top: 0.5rem;
- right: 0.5rem;
- padding: 0.25rem 0.5rem;
- font-size: 1.2rem;
- line-height: 1;
-}
-
-.auth-modal-title {
- margin: 0;
- font-size: 1rem;
- font-weight: 600;
- color: var(--ink-0);
-}
-
-.typing-indicator {
- font-size: 0.78rem;
- font-style: italic;
- color: var(--ink-soft);
- padding: 0.25rem 0.75rem 0;
-}
-
-.chat-flash {
- position: absolute;
- top: 0.5rem;
- left: 0.5rem;
- right: 0.5rem;
- z-index: 5;
- padding: 0.4rem 0.75rem;
- font-size: 0.83rem;
- text-align: center;
- color: var(--ink-0);
- background: color-mix(in srgb, var(--brand) 30%, var(--panel-2));
- border: 1px solid var(--line);
- border-radius: var(--radius-1);
- opacity: 1;
- transition: opacity 0.5s ease;
- pointer-events: none;
-}
-
-.chat-flash.fade-out {
- opacity: 0;
-}
-
-.btn {
- border: 1px solid transparent;
- border-radius: var(--radius-1);
- padding: 0.2rem 0.5rem;
- font-weight: 650;
- cursor: pointer;
- transition:
- transform var(--fast),
- background-color var(--fast),
- color var(--fast),
- border-color var(--fast);
-}
-
-.btn:hover {
- transform: translateY(-1px);
-}
-
-.btn:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- transform: none;
-}
-
-.btn-primary {
- background: var(--brand);
- color: var(--brand-contrast);
- border-color: var(--brand);
-}
-
-.btn-primary:hover {
- background: var(--brand-strong);
- border-color: var(--brand-strong);
-}
-
-.btn-secondary {
- background: var(--panel);
- color: var(--ink-1);
- border-color: var(--line);
-}
-
-.btn-secondary:hover {
- background: var(--panel-2);
-}
-
-.btn-danger {
- background: var(--danger-soft);
- color: var(--danger);
- border-color: color-mix(in srgb, var(--danger) 45%, transparent);
-}
-
-.btn-danger:hover {
- background: color-mix(in srgb, var(--danger-soft) 70%, var(--danger));
-}
-
-.btn-block {
- width: 100%;
-}
-
-.btn-large {
- padding: 0.85rem 1.2rem;
-}
-
-.btn-small {
- padding: 0.3rem 0.58rem;
- font-size: 0.85rem;
-}
-
-.text-input {
- width: 100%;
- border: 1px solid var(--line);
- border-radius: var(--radius-1);
- background: var(--input-bg);
- color: var(--ink-0);
- padding: 0.65rem 0.8rem;
-}
-
-.text-input::placeholder {
- color: var(--placeholder);
-}
-
-.text-area {
- min-height: 160px;
- resize: vertical;
-}
-
-.form-stack {
- display: grid;
- gap: var(--space-3);
-}
-
-.form-stack label {
- display: grid;
- gap: var(--space-2);
- font-weight: 600;
- color: var(--ink-1);
-}
-
-.chat-log-filters input {
- padding: 0.2rem 0.5rem;
-}
-
-.table-wrap {
- width: 100%;
- overflow-x: auto;
-}
-
-.data-table {
- width: 100%;
- border-collapse: collapse;
- min-width: 520px;
-}
-
-.data-table th,
-.data-table td {
- text-align: left;
- padding: 0.72rem 0.65rem;
- border-bottom: 1px solid var(--line-soft);
- vertical-align: top;
-}
-
-.data-table th {
- color: var(--ink-soft);
- font-size: 0.83rem;
- text-transform: uppercase;
- letter-spacing: 0.05em;
-}
-
-#chat-log-table-body td {
- font-size: 0.72rem;
- color: var(--ink-0);
-}
-
-.action-cell {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-2);
-}
-
-.kv-list {
- margin: 0;
- display: grid;
- gap: var(--space-3);
-}
-
-.kv-list div {
- display: grid;
- gap: var(--space-1);
-}
-
-.kv-list dt {
- font-size: 0.88rem;
- color: var(--ink-soft);
-}
-
-.kv-list dd {
- margin: 0;
- font-weight: 700;
-}
-
-.lv-body {
- min-height: 100vh;
- width: 100%;
- height: 100vh;
- padding: 0;
- overflow: hidden;
-}
-
-.viewer-shell {
- width: 100%;
- height: 100vh;
- margin: 0;
- display: grid;
- grid-template-rows: auto minmax(0, 1fr);
- background: linear-gradient(
- 170deg,
- color-mix(in srgb, var(--panel-2) 72%, var(--panel)) 0%,
- var(--panel) 45%,
- color-mix(in srgb, var(--panel) 75%, var(--bg-1)) 100%
- );
- border: 1px solid var(--line);
- border-radius: 0;
- box-shadow: var(--shadow-2);
- overflow: hidden;
-}
-
-.viewer-content {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(300px, 420px);
- min-height: 0;
- height: 100%;
-}
-
-.viewer-header {
- padding: var(--space-3) var(--space-4);
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--space-4);
- border-bottom: 1px solid var(--line);
-}
-
-.viewer-brand-group {
- display: flex;
- align-items: center;
- gap: var(--space-4);
-}
-
-.brand-badge {
- width: 2.5rem;
- height: 2.5rem;
- border-radius: 0;
- display: grid;
- place-items: center;
- font-weight: 800;
- font-size: 0.95rem;
- letter-spacing: 0.05em;
- color: var(--brand-contrast);
- background: linear-gradient(140deg, var(--badge-start) 0%, var(--badge-end) 100%);
-}
-
-.brand-text,
-.viewer-title {
- font-family: var(--font-heading);
-}
-
-.viewer-title {
- font-size: clamp(1.35rem, 2.2vw, 2rem);
-}
-
-.viewer-subtitle {
- color: var(--ink-soft);
- font-size: 0.95rem;
-}
-
-.now-playing-pill {
- display: inline-flex;
- align-items: center;
- gap: var(--space-2);
- border-radius: 0;
- background: var(--panel);
- border: 1px solid var(--line);
- color: var(--ink-1);
- padding: 0.35rem 0.8rem;
-}
-
-.live-dot,
-.chat-live-marker {
- color: var(--live-dot);
- font-weight: 800;
-}
-
-.player-stage {
- min-height: 0;
- height: 100%;
- padding: var(--space-4);
- display: grid;
-}
-
-.player {
- width: 100%;
- height: 100%;
- border-radius: 0;
- border: 1px solid var(--line-soft);
- background: #000;
- box-shadow: var(--shadow-1);
- object-fit: contain;
-}
-
-.now-playing-overlay {
- padding: 0.5rem 0.75rem;
- background: var(--panel);
- border: 1px solid var(--line-soft);
- display: flex;
- justify-content: space-between;
- align-items: center;
- font-size: 0.8rem;
- color: var(--ink-0);
-}
-
-.np-next {
- color: var(--ink-soft);
-}
-
-.now-playing-overlay {
- position: absolute;
- right: calc(var(--space-5) + 0.7rem);
- bottom: calc(var(--space-5) + 0.7rem);
-}
-
-.now-playing-overlay-inner {
- display: flex;
- align-items: center;
- gap: var(--space-2);
- background: var(--overlay-bg);
- color: #f0fffb;
- padding: 0.44rem 0.72rem;
- border-radius: 0;
- backdrop-filter: blur(3px);
-}
-
-.live-label {
- background: var(--live-dot);
- color: #fff;
- border-radius: 0;
- padding: 0.1rem 0.45rem;
- font-size: 0.72rem;
- font-weight: 700;
-}
-
-.overlay-title {
- font-size: 0.86rem;
-}
-
-.chat-toggle-btn {
- white-space: nowrap;
-}
-
-.chat-sidebar {
- background: color-mix(in srgb, var(--panel) 82%, var(--bg-1));
- border-left: 1px solid var(--line);
- display: grid;
- grid-template-rows: auto 1fr auto;
- min-height: 0;
- position: relative;
-}
-
-.chat-header,
-.chat-input-area {
- padding: var(--space-4);
-}
-
-.chat-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- border-bottom: 1px solid var(--line-soft);
- gap: var(--space-3);
-}
-
-.chat-room-title {
- font-weight: 700;
- display: inline-flex;
- gap: var(--space-2);
- align-items: center;
-}
-
-.chat-messages {
- padding: var(--space-4);
- overflow-y: auto;
- display: grid;
- align-content: start;
- gap: var(--space-2);
-}
-
-.presence-panel {
- padding: 0.5rem 1rem;
- border-top: 1px solid var(--line-soft);
- max-height: 120px;
- overflow-y: auto;
-}
-
-.presence-list {
- display: flex;
- flex-wrap: wrap;
- gap: 0.4rem;
-}
-
-.presence-dot {
- display: inline-flex;
- align-items: center;
- gap: 0.3rem;
- font-size: 0.75rem;
- color: var(--ink-1);
-}
-
-.presence-indicator {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--ok);
- flex-shrink: 0;
-}
-
-.message-row {
- display: flex;
-}
-
-.message-self-row {
- justify-content: flex-end;
-}
-
-.message-other-row {
- justify-content: flex-start;
-}
-
-.message-system-row {
- justify-content: center;
-}
-
-.message-bubble {
- max-width: 83%;
- border-radius: 0;
- padding: 0.58rem 0.72rem;
- box-shadow: var(--shadow-1);
- animation: messageIn var(--fast);
-}
-
-.message-other {
- background: var(--panel);
- border: 1px solid var(--line-soft);
-}
-
-.message-self {
- background: var(--brand);
- color: var(--brand-contrast);
-}
-
-.message-system {
- font-size: 0.7rem;
- color: var(--ink-1);
- background: color-mix(in srgb, var(--brand) 22%, var(--panel-2));
- border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line));
- border-radius: 0;
- padding: 0.2rem 0.5rem;
- animation: messageIn var(--fast);
-}
-
-.message-sender {
- font-size: 0.75rem;
- opacity: 0.85;
- margin-bottom: 0.1rem;
-}
-
-.message-text {
- word-break: break-word;
- font-size: 0.95rem;
-}
-
-.message-time {
- display: block;
- margin-top: 0.2rem;
- font-size: 0.7rem;
- opacity: 0.75;
-}
-
-.chat-input-row {
- display: grid;
- grid-template-columns: 1fr auto;
- gap: var(--space-2);
-}
-
-.chat-note {
- margin-top: var(--space-2);
- color: var(--ink-soft);
- font-size: 0.85rem;
-}
-
-.input {
- width: 100%;
- border: 1px solid var(--input-border);
- border-radius: var(--radius-1);
- padding: 0.65rem 0.8rem;
- background: var(--input-bg);
- color: var(--ink-0);
-}
-
-.admin-body {
- min-height: 100vh;
- padding: var(--space-4);
- background: #000000;
-}
-
-.admin-shell {
- width: min(1320px, 100%);
- margin: 0 auto;
- display: grid;
- grid-template-columns: 260px minmax(0, 1fr);
- border-radius: var(--radius-3);
- overflow: hidden;
- border: 1px solid var(--line);
- background: color-mix(in srgb, var(--panel) 88%, var(--bg-1));
- box-shadow: var(--shadow-2);
- min-height: calc(100vh - 2 * var(--space-4));
-}
-
-.admin-sidebar {
- background: var(--bg-2);
- color: var(--ink-0);
- display: grid;
- grid-template-rows: auto auto 1fr;
- gap: var(--space-4);
- padding: var(--space-5);
-}
-
-.brand-block {
- display: flex;
- align-items: center;
- gap: var(--space-3);
-}
-
-.brand-text {
- font-weight: 700;
- letter-spacing: 0.03em;
-}
-
-.admin-nav {
- display: grid;
- gap: var(--space-2);
- align-content: start;
-}
-
-.tab-trigger {
- width: 100%;
- text-align: left;
- background: transparent;
- color: var(--ink-1);
- border: 1px solid transparent;
- border-radius: var(--radius-1);
- padding: 0.62rem 0.72rem;
- cursor: pointer;
- transition:
- background-color var(--fast),
- border-color var(--fast);
-}
-
-.tab-trigger:hover {
- background: rgba(255, 255, 255, 0.08);
-}
-
-.tab-trigger.is-active {
- background: var(--brand);
- color: var(--brand-contrast);
- border-color: color-mix(in srgb, var(--brand) 55%, #fff);
-}
-
-.admin-main {
- padding: var(--space-6);
- overflow: auto;
-}
-
-.viewer-shell.chat-collapsed .viewer-content {
- grid-template-columns: 1fr;
-}
-
-.viewer-shell.chat-collapsed .chat-sidebar {
- display: none;
-}
-
-.tab-panel {
- display: grid;
- gap: var(--space-4);
-}
-
-.tab-panel[hidden] {
- display: none;
-}
-
-.panel-header-row {
- display: flex;
- justify-content: space-between;
- align-items: end;
- gap: var(--space-4);
- flex-wrap: wrap;
-}
-
-.panel-card {
- border: 1px solid var(--line-soft);
- border-radius: var(--radius-2);
- background: var(--panel);
- padding: var(--space-2);
- box-shadow: var(--shadow-1);
-}
-
-.button-row {
- display: flex;
- gap: var(--space-2);
- flex-wrap: wrap;
-}
-
-.upload-dropzone {
- border: 2px dashed color-mix(in srgb, var(--brand) 55%, var(--line));
- border-radius: var(--radius-2);
- padding: var(--space-5);
- background: color-mix(in srgb, var(--brand) 12%, var(--panel-2));
- display: grid;
- justify-items: start;
- gap: var(--space-2);
-}
-
-.upload-dropzone.is-dragover {
- border-color: var(--brand);
- background: color-mix(in srgb, var(--brand) 20%, var(--panel-2));
-}
-
-.upload-progress {
- display: grid;
- gap: var(--space-1);
- width: min(540px, 100%);
-}
-
-.upload-progress progress {
- width: 100%;
- height: 0.75rem;
-}
-
-.stat-grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: var(--space-3);
-}
-
-.stat-card {
- border: 1px solid var(--line-soft);
- border-radius: var(--radius-2);
- background: var(--panel);
- padding: var(--space-4);
- box-shadow: var(--shadow-1);
-}
-
-.stat-value {
- margin-top: var(--space-2);
- font-size: clamp(1.35rem, 2.8vw, 2rem);
- font-family: var(--font-mono);
-}
-
-.stat-caption {
- margin-top: var(--space-1);
- color: var(--ink-soft);
-}
-
-.moderation-grid {
- display: grid;
- grid-template-columns: minmax(300px, 1fr) minmax(0, 1.5fr);
- gap: var(--space-4);
-}
-
-.split-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: var(--space-4);
-}
-
-.service-list {
- display: grid;
- gap: var(--space-2);
-}
-
-.service-row {
- border: 1px solid var(--line-soft);
- border-radius: var(--radius-1);
- padding: 0.75rem;
- display: flex;
- justify-content: space-between;
- gap: var(--space-3);
- flex-wrap: wrap;
-}
-
-.service-row-right {
- text-align: right;
-}
-
-.service-detail-table {
- width: 100%;
- border-collapse: collapse;
- text-align: left;
-}
-
-.service-status {
- margin: 0;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.05em;
-}
-
-.service-status-healthy {
- color: var(--ok);
-}
-
-.service-status-degraded {
- color: var(--warn);
-}
-
-.service-status-unhealthy {
- color: var(--danger);
-}
-
-.auth-body {
- min-height: 100vh;
- display: grid;
- place-items: center;
- padding: var(--space-4);
-}
-
-.auth-shell {
- width: min(460px, 100%);
-}
-
-.auth-card {
- border: 1px solid var(--line);
- border-radius: var(--radius-3);
- background: var(--panel);
- box-shadow: var(--shadow-2);
- padding: var(--space-6);
- display: grid;
- gap: var(--space-3);
-}
-
-.auth-kicker {
- font-family: var(--font-display);
- color: var(--brand);
- text-transform: uppercase;
- letter-spacing: 0.2em;
- font-size: 0.72rem;
-}
-
-.auth-subtitle {
- color: var(--ink-soft);
-}
-
-@keyframes messageIn {
- from {
- opacity: 0;
- transform: translateY(5px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-@media (max-width: 1080px) {
- .admin-shell {
- grid-template-columns: 1fr;
- }
-
- .viewer-content {
- grid-template-columns: 1fr;
- grid-template-rows: minmax(320px, 52vh) minmax(0, 1fr);
- }
-
- .chat-sidebar {
- border-left: 0;
- border-top: 1px solid var(--line);
- }
-
- .admin-sidebar {
- grid-template-rows: auto;
- }
-
- .admin-nav {
- grid-template-columns: repeat(4, minmax(0, 1fr));
- }
-
- .admin-main {
- padding: var(--space-5);
- }
-
- .moderation-grid,
- .split-grid,
- .stat-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .service-row-right {
- text-align: left;
- }
-}
-
-@media (max-width: 760px) {
- .lv-body,
- .admin-body {
- padding: var(--space-2);
- }
-
- .viewer-header,
- .player-stage,
- .chat-header,
- .chat-input-area {
- padding: var(--space-3);
- }
-
- .viewer-header {
- align-items: flex-start;
- flex-direction: column;
- }
-
- .admin-nav {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .admin-main {
- padding: var(--space-3);
- }
-
- .moderation-grid,
- .split-grid,
- .stat-grid,
- .chat-input-row {
- grid-template-columns: 1fr;
- }
-
- .viewer-shell.chat-collapsed .viewer-content {
- grid-template-rows: 1fr;
- }
-
- .auth-card {
- padding: var(--space-5);
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- animation-duration: 1ms !important;
- animation-iteration-count: 1 !important;
- transition-duration: 1ms !important;
- scroll-behavior: auto !important;
- }
-}
-
-/* Reconnecting overlay (player error recovery) */
-.reconnecting-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.85);
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: var(--space-4, 1rem);
- z-index: 1000;
- backdrop-filter: blur(8px);
-}
-
-.reconnecting-overlay.hidden {
- display: none;
-}
-
-.reconnecting-spinner {
- width: 48px;
- height: 48px;
- border: 3px solid var(--line, #2a2a2a);
- border-top-color: var(--brand, #587792);
- border-radius: 50%;
- animation: lv-spin 0.8s linear infinite;
-}
-
-@keyframes lv-spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-#reconnecting-text {
- color: var(--ink-0, #ede8e6);
- font-family: var(--font-body);
- font-size: 1.125rem;
-}
+:root {
+ color-scheme: dark;
+
+ --font-body: "Tektur", "Segoe UI", "Helvetica Neue", sans-serif;
+ --font-heading: "Doto", "Segoe UI", "Helvetica Neue", sans-serif;
+ --font-display: "Space Grotesk", "Segoe UI", sans-serif;
+ --font-mono: "IBM Plex Mono", Consolas, monospace;
+
+ --bg-0: #000000;
+ --bg-1: #0a0a0a;
+ --bg-2: #000000;
+ --ink-0: #ede8e6;
+ --ink-1: #b0a8a4;
+ --ink-soft: #78706c;
+
+ --panel: #111111;
+ --panel-2: #1a1a1a;
+ --line: #2a2a2a;
+ --line-soft: #1f1f1f;
+
+ --brand: #587792;
+ --brand-strong: #4a6780;
+ --brand-contrast: #f4f8fb;
+ --ok: #04650d;
+ --warn: #d49624;
+ --danger: #c44a5e;
+ --danger-soft: #1f1216;
+
+ --focus: #2ebfa5;
+ --shadow-1: 0 14px 30px rgba(0, 0, 0, 0.5);
+ --shadow-2: 0 22px 44px rgba(0, 0, 0, 0.6);
+
+ --bg-grad-a: #000000;
+ --bg-grad-b: #000000;
+ --bg-grad-c: #000000;
+
+ --live-dot: #ff6b6b;
+ --overlay-bg: rgba(0, 0, 0, 0.88);
+
+ --input-bg: #0d0d0d;
+ --input-border: var(--line-soft);
+ --placeholder: #6a6260;
+
+ --badge-start: #2ebfa5;
+ --badge-end: #1a8c78;
+
+ --radius-1: 0;
+ --radius-2: 0;
+ --radius-3: 0;
+
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.5rem;
+ --space-6: 2rem;
+ --space-7: 3rem;
+
+ --fast: 160ms ease;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color-scheme: light;
+
+ --bg-0: #e8e8e8;
+ --bg-1: #dcdcdc;
+ --bg-2: #f0f0f0;
+ --ink-0: #0d0d0d;
+ --ink-1: #2a2a2a;
+ --ink-soft: #555555;
+
+ --panel: #ffffff;
+ --panel-2: #f5f5f5;
+ --line: #cccccc;
+ --line-soft: #dddddd;
+
+ --brand: #587792;
+ --brand-strong: #4a6780;
+ --brand-contrast: #f4f8fb;
+ --ok: #04650d;
+ --warn: #a86200;
+ --danger: #a7283f;
+ --danger-soft: #fff3f6;
+
+ --focus: #2ebfa5;
+ --shadow-1: 0 8px 20px rgba(0, 0, 0, 0.08);
+ --shadow-2: 0 16px 36px rgba(0, 0, 0, 0.12);
+
+ --bg-grad-a: #e8e8e8;
+ --bg-grad-b: var(--bg-0);
+ --bg-grad-c: #f0f0f0;
+
+ --live-dot: #cb271a;
+ --overlay-bg: rgba(0, 0, 0, 0.88);
+
+ --input-bg: #ffffff;
+ --input-border: #cccccc;
+ --placeholder: #888888;
+
+ --badge-start: #2ebfa5;
+ --badge-end: #1a8c78;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ min-height: 100%;
+}
+
+body {
+ font-family: var(--font-body);
+ color: var(--ink-0);
+ background: radial-gradient(circle at 20% 10%, var(--bg-grad-a) 0%, var(--bg-grad-b) 50%, var(--bg-grad-c) 100%);
+ line-height: 1.45;
+}
+
+h1,
+h2,
+h3 {
+ margin: 0;
+ font-family: var(--font-heading);
+ color: var(--ink-0);
+ letter-spacing: -0.02em;
+}
+
+p {
+ margin: 0;
+}
+
+a {
+ color: var(--brand-strong);
+}
+
+a:hover {
+ color: var(--brand);
+}
+
+button,
+input,
+textarea,
+select {
+ font: inherit;
+}
+
+:focus-visible {
+ outline: 3px solid var(--focus);
+ outline-offset: 2px;
+}
+
+.hidden {
+ display: none !important;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.small-muted {
+ color: var(--ink-soft);
+ font-size: 0.9rem;
+}
+
+.status-line {
+ margin-top: var(--space-2);
+ color: var(--ink-soft);
+ min-height: 1.4rem;
+}
+
+.status-error {
+ color: var(--danger);
+}
+
+.sortable-list {
+ list-style: none;
+ margin: 0 0 0.75rem;
+ padding: 0;
+ max-height: 300px;
+ overflow-y: auto;
+ border: 1px solid var(--line-soft);
+ border-radius: 4px;
+}
+
+.sortable-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4rem 0.6rem;
+ border-bottom: 1px solid var(--line-soft);
+ cursor: grab;
+ transition: background 0.15s;
+}
+
+.sortable-item:last-child {
+ border-bottom: none;
+}
+.sortable-item:hover {
+ background: var(--panel-2);
+}
+.sortable-item.dragging {
+ opacity: 0.4;
+}
+
+.drag-handle {
+ color: var(--ink-soft);
+ font-size: 0.8rem;
+ letter-spacing: 1px;
+ cursor: grab;
+ user-select: none;
+}
+
+.sortable-index {
+ font-size: 0.7rem;
+ color: var(--ink-soft);
+ min-width: 1.5rem;
+ text-align: right;
+}
+
+.sortable-path {
+ flex: 1;
+ font-size: 0.8rem;
+ color: var(--ink-0);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.status-warnings {
+ background: rgba(212, 150, 36, 0.1);
+ border: 1px solid var(--warn);
+ padding: 0.5rem 0.75rem;
+ margin-bottom: 0.75rem;
+ border-radius: 4px;
+}
+
+.warning-msg {
+ font-size: 0.8rem;
+ color: var(--warn);
+ line-height: 1.5;
+}
+
+.badge {
+ display: inline-block;
+ padding: 0.1rem 0.5rem;
+ border-radius: 999px;
+ font-size: 0.72rem;
+ font-weight: 600;
+ line-height: 1.5;
+ white-space: nowrap;
+}
+
+.badge-ok {
+ color: var(--ok);
+ background: rgba(4, 101, 13, 0.12);
+}
+
+.badge-warn {
+ color: var(--warn);
+ background: rgba(212, 150, 36, 0.12);
+}
+
+.badge-info {
+ color: var(--brand);
+ background: rgba(88, 119, 146, 0.14);
+}
+
+.badge-danger {
+ color: var(--danger);
+ background: rgba(196, 74, 94, 0.12);
+}
+
+.badge-muted {
+ color: var(--ink-soft);
+ background: var(--bg-2);
+}
+
+.chat-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.chat-user-label {
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: var(--ink-soft);
+ max-width: 10rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.auth-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.auth-modal[hidden] {
+ display: none;
+}
+
+.auth-modal-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+}
+
+.auth-modal-content {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ width: min(20rem, calc(100vw - 2rem));
+ padding: 1.25rem;
+ border: 1px solid var(--bg-2);
+ border-radius: 0.5rem;
+ background: var(--panel-2);
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
+}
+
+.auth-close {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+ padding: 0.25rem 0.5rem;
+ font-size: 1.2rem;
+ line-height: 1;
+}
+
+.auth-modal-title {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--ink-0);
+}
+
+.typing-indicator {
+ font-size: 0.78rem;
+ font-style: italic;
+ color: var(--ink-soft);
+ padding: 0.25rem 0.75rem 0;
+}
+
+.chat-flash {
+ position: absolute;
+ top: 0.5rem;
+ left: 0.5rem;
+ right: 0.5rem;
+ z-index: 5;
+ padding: 0.4rem 0.75rem;
+ font-size: 0.83rem;
+ text-align: center;
+ color: var(--ink-0);
+ background: color-mix(in srgb, var(--brand) 30%, var(--panel-2));
+ border: 1px solid var(--line);
+ border-radius: var(--radius-1);
+ opacity: 1;
+ transition: opacity 0.5s ease;
+ pointer-events: none;
+}
+
+.chat-flash.fade-out {
+ opacity: 0;
+}
+
+.btn {
+ border: 1px solid transparent;
+ border-radius: var(--radius-1);
+ padding: 0.2rem 0.5rem;
+ font-weight: 650;
+ cursor: pointer;
+ transition:
+ transform var(--fast),
+ background-color var(--fast),
+ color var(--fast),
+ border-color var(--fast);
+}
+
+.btn:hover {
+ transform: translateY(-1px);
+}
+
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.btn-primary {
+ background: var(--brand);
+ color: var(--brand-contrast);
+ border-color: var(--brand);
+}
+
+.btn-primary:hover {
+ background: var(--brand-strong);
+ border-color: var(--brand-strong);
+}
+
+.btn-secondary {
+ background: var(--panel);
+ color: var(--ink-1);
+ border-color: var(--line);
+}
+
+.btn-secondary:hover {
+ background: var(--panel-2);
+}
+
+.btn-danger {
+ background: var(--danger-soft);
+ color: var(--danger);
+ border-color: color-mix(in srgb, var(--danger) 45%, transparent);
+}
+
+.btn-danger:hover {
+ background: color-mix(in srgb, var(--danger-soft) 70%, var(--danger));
+}
+
+.btn-block {
+ width: 100%;
+}
+
+.btn-large {
+ padding: 0.85rem 1.2rem;
+}
+
+.btn-small {
+ padding: 0.3rem 0.58rem;
+ font-size: 0.85rem;
+}
+
+.text-input {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-1);
+ background: var(--input-bg);
+ color: var(--ink-0);
+ padding: 0.65rem 0.8rem;
+}
+
+.text-input::placeholder {
+ color: var(--placeholder);
+}
+
+.text-area {
+ min-height: 160px;
+ resize: vertical;
+}
+
+.form-stack {
+ display: grid;
+ gap: var(--space-3);
+}
+
+.form-stack label {
+ display: grid;
+ gap: var(--space-2);
+ font-weight: 600;
+ color: var(--ink-1);
+}
+
+.chat-log-filters input {
+ padding: 0.2rem 0.5rem;
+}
+
+.table-wrap {
+ width: 100%;
+ overflow-x: auto;
+}
+
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ min-width: 520px;
+}
+
+.data-table th,
+.data-table td {
+ text-align: left;
+ padding: 0.72rem 0.65rem;
+ border-bottom: 1px solid var(--line-soft);
+ vertical-align: top;
+}
+
+.data-table th {
+ color: var(--ink-soft);
+ font-size: 0.83rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+#chat-log-table-body td {
+ font-size: 0.72rem;
+ color: var(--ink-0);
+}
+
+.action-cell {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.kv-list {
+ margin: 0;
+ display: grid;
+ gap: var(--space-3);
+}
+
+.kv-list div {
+ display: grid;
+ gap: var(--space-1);
+}
+
+.kv-list dt {
+ font-size: 0.88rem;
+ color: var(--ink-soft);
+}
+
+.kv-list dd {
+ margin: 0;
+ font-weight: 700;
+}
+
+.lv-body {
+ min-height: 100vh;
+ width: 100%;
+ height: 100vh;
+ padding: 0;
+ overflow: hidden;
+}
+
+.viewer-shell {
+ width: 100%;
+ height: 100vh;
+ margin: 0;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ background: linear-gradient(
+ 170deg,
+ color-mix(in srgb, var(--panel-2) 72%, var(--panel)) 0%,
+ var(--panel) 45%,
+ color-mix(in srgb, var(--panel) 75%, var(--bg-1)) 100%
+ );
+ border: 1px solid var(--line);
+ border-radius: 0;
+ box-shadow: var(--shadow-2);
+ overflow: hidden;
+}
+
+.viewer-content {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(300px, 420px);
+ min-height: 0;
+ height: 100%;
+}
+
+.viewer-header {
+ padding: var(--space-3) var(--space-4);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ border-bottom: 1px solid var(--line);
+}
+
+.viewer-brand-group {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+}
+
+.brand-badge {
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 0;
+ display: grid;
+ place-items: center;
+ font-weight: 800;
+ font-size: 0.95rem;
+ letter-spacing: 0.05em;
+ color: var(--brand-contrast);
+ background: linear-gradient(140deg, var(--badge-start) 0%, var(--badge-end) 100%);
+}
+
+.brand-text,
+.viewer-title {
+ font-family: var(--font-heading);
+}
+
+.viewer-title {
+ font-size: clamp(1.35rem, 2.2vw, 2rem);
+}
+
+.viewer-subtitle {
+ color: var(--ink-soft);
+ font-size: 0.95rem;
+}
+
+.now-playing-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ border-radius: 0;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ color: var(--ink-1);
+ padding: 0.35rem 0.8rem;
+}
+
+.live-dot,
+.chat-live-marker {
+ color: var(--live-dot);
+ font-weight: 800;
+}
+
+.player-stage {
+ min-height: 0;
+ height: 100%;
+ padding: var(--space-4);
+ display: grid;
+}
+
+.player {
+ width: 100%;
+ height: 100%;
+ border-radius: 0;
+ border: 1px solid var(--line-soft);
+ background: #000;
+ box-shadow: var(--shadow-1);
+ object-fit: contain;
+}
+
+.now-playing-overlay {
+ padding: 0.5rem 0.75rem;
+ background: var(--panel);
+ border: 1px solid var(--line-soft);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 0.8rem;
+ color: var(--ink-0);
+}
+
+.np-next {
+ color: var(--ink-soft);
+}
+
+.now-playing-overlay {
+ position: absolute;
+ right: calc(var(--space-5) + 0.7rem);
+ bottom: calc(var(--space-5) + 0.7rem);
+}
+
+.now-playing-overlay-inner {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ background: var(--overlay-bg);
+ color: #f0fffb;
+ padding: 0.44rem 0.72rem;
+ border-radius: 0;
+ backdrop-filter: blur(3px);
+}
+
+.live-label {
+ background: var(--live-dot);
+ color: #fff;
+ border-radius: 0;
+ padding: 0.1rem 0.45rem;
+ font-size: 0.72rem;
+ font-weight: 700;
+}
+
+.overlay-title {
+ font-size: 0.86rem;
+}
+
+.chat-toggle-btn {
+ white-space: nowrap;
+}
+
+.chat-sidebar {
+ background: color-mix(in srgb, var(--panel) 82%, var(--bg-1));
+ border-left: 1px solid var(--line);
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ min-height: 0;
+ position: relative;
+}
+
+.chat-header,
+.chat-input-area {
+ padding: var(--space-4);
+}
+
+.chat-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--line-soft);
+ gap: var(--space-3);
+}
+
+.chat-room-title {
+ font-weight: 700;
+ display: inline-flex;
+ gap: var(--space-2);
+ align-items: center;
+}
+
+.chat-messages {
+ padding: var(--space-4);
+ overflow-y: auto;
+ display: grid;
+ align-content: start;
+ gap: var(--space-2);
+}
+
+.presence-panel {
+ padding: 0.5rem 1rem;
+ border-top: 1px solid var(--line-soft);
+ max-height: 120px;
+ overflow-y: auto;
+}
+
+.presence-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+
+.presence-dot {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-size: 0.75rem;
+ color: var(--ink-1);
+}
+
+.presence-indicator {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--ok);
+ flex-shrink: 0;
+}
+
+.message-row {
+ display: flex;
+}
+
+.message-self-row {
+ justify-content: flex-end;
+}
+
+.message-other-row {
+ justify-content: flex-start;
+}
+
+.message-system-row {
+ justify-content: center;
+}
+
+.message-bubble {
+ max-width: 83%;
+ border-radius: 0;
+ padding: 0.58rem 0.72rem;
+ box-shadow: var(--shadow-1);
+ animation: messageIn var(--fast);
+}
+
+.message-other {
+ background: var(--panel);
+ border: 1px solid var(--line-soft);
+}
+
+.message-self {
+ background: var(--brand);
+ color: var(--brand-contrast);
+}
+
+.message-system {
+ font-size: 0.7rem;
+ color: var(--ink-1);
+ background: color-mix(in srgb, var(--brand) 22%, var(--panel-2));
+ border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line));
+ border-radius: 0;
+ padding: 0.2rem 0.5rem;
+ animation: messageIn var(--fast);
+}
+
+.message-sender {
+ font-size: 0.75rem;
+ opacity: 0.85;
+ margin-bottom: 0.1rem;
+}
+
+.message-text {
+ word-break: break-word;
+ font-size: 0.95rem;
+}
+
+.message-time {
+ display: block;
+ margin-top: 0.2rem;
+ font-size: 0.7rem;
+ opacity: 0.75;
+}
+
+.chat-input-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: var(--space-2);
+}
+
+.chat-note {
+ margin-top: var(--space-2);
+ color: var(--ink-soft);
+ font-size: 0.85rem;
+}
+
+.input {
+ width: 100%;
+ border: 1px solid var(--input-border);
+ border-radius: var(--radius-1);
+ padding: 0.65rem 0.8rem;
+ background: var(--input-bg);
+ color: var(--ink-0);
+}
+
+.admin-body {
+ min-height: 100vh;
+ padding: var(--space-4);
+ background: #000000;
+}
+
+.admin-shell {
+ width: min(1320px, 100%);
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: 260px minmax(0, 1fr);
+ border-radius: var(--radius-3);
+ overflow: hidden;
+ border: 1px solid var(--line);
+ background: color-mix(in srgb, var(--panel) 88%, var(--bg-1));
+ box-shadow: var(--shadow-2);
+ min-height: calc(100vh - 2 * var(--space-4));
+}
+
+.admin-sidebar {
+ background: var(--bg-2);
+ color: var(--ink-0);
+ display: grid;
+ grid-template-rows: auto auto 1fr;
+ gap: var(--space-4);
+ padding: var(--space-5);
+}
+
+.brand-block {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.brand-text {
+ font-weight: 700;
+ letter-spacing: 0.03em;
+}
+
+.admin-nav {
+ display: grid;
+ gap: var(--space-2);
+ align-content: start;
+}
+
+.tab-trigger {
+ width: 100%;
+ text-align: left;
+ background: transparent;
+ color: var(--ink-1);
+ border: 1px solid transparent;
+ border-radius: var(--radius-1);
+ padding: 0.62rem 0.72rem;
+ cursor: pointer;
+ transition:
+ background-color var(--fast),
+ border-color var(--fast);
+}
+
+.tab-trigger:hover {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.tab-trigger.is-active {
+ background: var(--brand);
+ color: var(--brand-contrast);
+ border-color: color-mix(in srgb, var(--brand) 55%, #fff);
+}
+
+.admin-main {
+ padding: var(--space-6);
+ overflow: auto;
+}
+
+.viewer-shell.chat-collapsed .viewer-content {
+ grid-template-columns: 1fr;
+}
+
+.viewer-shell.chat-collapsed .chat-sidebar {
+ display: none;
+}
+
+.tab-panel {
+ display: grid;
+ gap: var(--space-4);
+}
+
+.tab-panel[hidden] {
+ display: none;
+}
+
+.panel-header-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: end;
+ gap: var(--space-4);
+ flex-wrap: wrap;
+}
+
+.panel-card {
+ border: 1px solid var(--line-soft);
+ border-radius: var(--radius-2);
+ background: var(--panel);
+ padding: var(--space-2);
+ box-shadow: var(--shadow-1);
+}
+
+.button-row {
+ display: flex;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+
+.upload-dropzone {
+ border: 2px dashed color-mix(in srgb, var(--brand) 55%, var(--line));
+ border-radius: var(--radius-2);
+ padding: var(--space-5);
+ background: color-mix(in srgb, var(--brand) 12%, var(--panel-2));
+ display: grid;
+ justify-items: start;
+ gap: var(--space-2);
+}
+
+.upload-dropzone.is-dragover {
+ border-color: var(--brand);
+ background: color-mix(in srgb, var(--brand) 20%, var(--panel-2));
+}
+
+.upload-progress {
+ display: grid;
+ gap: var(--space-1);
+ width: min(540px, 100%);
+}
+
+.upload-progress progress {
+ width: 100%;
+ height: 0.75rem;
+}
+
+.stat-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: var(--space-3);
+}
+
+.stat-card {
+ border: 1px solid var(--line-soft);
+ border-radius: var(--radius-2);
+ background: var(--panel);
+ padding: var(--space-4);
+ box-shadow: var(--shadow-1);
+}
+
+.stat-value {
+ margin-top: var(--space-2);
+ font-size: clamp(1.35rem, 2.8vw, 2rem);
+ font-family: var(--font-mono);
+}
+
+.stat-caption {
+ margin-top: var(--space-1);
+ color: var(--ink-soft);
+}
+
+.moderation-grid {
+ display: grid;
+ grid-template-columns: minmax(300px, 1fr) minmax(0, 1.5fr);
+ gap: var(--space-4);
+}
+
+.split-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--space-4);
+}
+
+.service-list {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.service-row {
+ border: 1px solid var(--line-soft);
+ border-radius: var(--radius-1);
+ padding: 0.75rem;
+ display: flex;
+ justify-content: space-between;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+
+.service-row-right {
+ text-align: right;
+}
+
+.service-detail-table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+}
+
+.service-status {
+ margin: 0;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.service-status-healthy {
+ color: var(--ok);
+}
+
+.service-status-degraded {
+ color: var(--warn);
+}
+
+.service-status-unhealthy {
+ color: var(--danger);
+}
+
+.auth-body {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: var(--space-4);
+}
+
+.auth-shell {
+ width: min(460px, 100%);
+}
+
+.auth-card {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-3);
+ background: var(--panel);
+ box-shadow: var(--shadow-2);
+ padding: var(--space-6);
+ display: grid;
+ gap: var(--space-3);
+}
+
+.auth-kicker {
+ font-family: var(--font-display);
+ color: var(--brand);
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ font-size: 0.72rem;
+}
+
+.auth-subtitle {
+ color: var(--ink-soft);
+}
+
+@keyframes messageIn {
+ from {
+ opacity: 0;
+ transform: translateY(5px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (max-width: 1080px) {
+ .admin-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .viewer-content {
+ grid-template-columns: 1fr;
+ grid-template-rows: minmax(320px, 52vh) minmax(0, 1fr);
+ }
+
+ .chat-sidebar {
+ border-left: 0;
+ border-top: 1px solid var(--line);
+ }
+
+ .admin-sidebar {
+ grid-template-rows: auto;
+ }
+
+ .admin-nav {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+
+ .admin-main {
+ padding: var(--space-5);
+ }
+
+ .moderation-grid,
+ .split-grid,
+ .stat-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .service-row-right {
+ text-align: left;
+ }
+}
+
+@media (max-width: 760px) {
+ .lv-body,
+ .admin-body {
+ padding: var(--space-2);
+ }
+
+ .viewer-header,
+ .player-stage,
+ .chat-header,
+ .chat-input-area {
+ padding: var(--space-3);
+ }
+
+ .viewer-header {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .admin-nav {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .admin-main {
+ padding: var(--space-3);
+ }
+
+ .moderation-grid,
+ .split-grid,
+ .stat-grid,
+ .chat-input-row {
+ grid-template-columns: 1fr;
+ }
+
+ .viewer-shell.chat-collapsed .viewer-content {
+ grid-template-rows: 1fr;
+ }
+
+ .auth-card {
+ padding: var(--space-5);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 1ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 1ms !important;
+ scroll-behavior: auto !important;
+ }
+}
+
+/* Reconnecting overlay (player error recovery) */
+.reconnecting-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.85);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-4, 1rem);
+ z-index: 1000;
+ backdrop-filter: blur(8px);
+}
+
+.reconnecting-overlay.hidden {
+ display: none;
+}
+
+.reconnecting-spinner {
+ width: 48px;
+ height: 48px;
+ border: 3px solid var(--line, #2a2a2a);
+ border-top-color: var(--brand, #587792);
+ border-radius: 50%;
+ animation: lv-spin 0.8s linear infinite;
+}
+
+@keyframes lv-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+#reconnecting-text {
+ color: var(--ink-0, #ede8e6);
+ font-family: var(--font-body);
+ font-size: 1.125rem;
+}
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index 783a0ee..d76351c 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -1,20 +1,20 @@
-import { defineConfig } from "vitest/config";
-
-export default defineConfig({
- test: {
- environment: "jsdom",
- include: ["src/**/*.test.ts"],
- coverage: {
- provider: "v8",
- reporter: ["text", "text-summary"],
- include: ["src/**/*.ts"],
- exclude: ["src/**/*.test.ts"],
- thresholds: {
- statements: 20,
- branches: 15,
- functions: 15,
- lines: 20,
- },
- },
- },
-});
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "jsdom",
+ include: ["src/**/*.test.ts"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "text-summary"],
+ include: ["src/**/*.ts"],
+ exclude: ["src/**/*.test.ts"],
+ thresholds: {
+ statements: 20,
+ branches: 15,
+ functions: 15,
+ lines: 20,
+ },
+ },
+ },
+});
diff --git a/frontend/wrangler.toml b/frontend/wrangler.toml
index fdc4481..17a6a52 100644
--- a/frontend/wrangler.toml
+++ b/frontend/wrangler.toml
@@ -1,6 +1,6 @@
-name = "linearvoid-frontend"
-compatibility_date = "2025-08-01"
-
-[assets]
-directory = "./dist"
-not_found_handling = "single-page-application"
+name = "linearvoid-frontend"
+compatibility_date = "2025-08-01"
+
+[assets]
+directory = "./dist"
+not_found_handling = "single-page-application"
diff --git a/infrastructure/nginx/Dockerfile b/infrastructure/nginx/Dockerfile
index 4d2776f..78dc01e 100644
--- a/infrastructure/nginx/Dockerfile
+++ b/infrastructure/nginx/Dockerfile
@@ -1,21 +1,21 @@
-# Stage 1: Build frontend
-FROM node:20-alpine AS builder
-WORKDIR /build
-COPY frontend/package*.json ./
-RUN npm ci
-COPY frontend/ .
-RUN npm run build
-
-# Stage 2: Nginx with bundled frontend
-FROM nginx:alpine
-
-COPY infrastructure/nginx/nginx.conf /etc/nginx/nginx.conf
-
-# Copy prebuilt frontend from builder stage
-COPY --from=builder /build/dist/ /srv/frontend/
-
-# Create HLS directory
-RUN mkdir -p /srv/hls
-
-EXPOSE 80
+# Stage 1: Build frontend
+FROM node:20-alpine AS builder
+WORKDIR /build
+COPY frontend/package*.json ./
+RUN npm ci
+COPY frontend/ .
+RUN npm run build
+
+# Stage 2: Nginx with bundled frontend
+FROM nginx:alpine
+
+COPY infrastructure/nginx/nginx.conf /etc/nginx/nginx.conf
+
+# Copy prebuilt frontend from builder stage
+COPY --from=builder /build/dist/ /srv/frontend/
+
+# Create HLS directory
+RUN mkdir -p /srv/hls
+
+EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
\ No newline at end of file
diff --git a/infrastructure/nginx/nginx.conf b/infrastructure/nginx/nginx.conf
index 2ca415e..c14444c 100644
--- a/infrastructure/nginx/nginx.conf
+++ b/infrastructure/nginx/nginx.conf
@@ -1,285 +1,285 @@
-events {
- worker_connections 1024;
-}
-
-http {
- include /etc/nginx/mime.types;
- default_type application/octet-stream;
-
- resolver 127.0.0.11 ipv6=off valid=30s;
-
- map_hash_bucket_size 128;
-
- map $http_upgrade $connection_upgrade {
- default upgrade;
- '' close;
- }
-
- map $http_origin $cors_origin {
- default "";
- "https://linearvoid.tv" $http_origin;
- "https://www.linearvoid.tv" $http_origin;
- "https://linearvoid-frontend.zwitschi82.workers.dev" $http_origin;
- "~^https://[a-zA-Z0-9.-]+\.linearvoid\.tv$" $http_origin;
- }
-
- server {
- listen 80;
- server_name www.linearvoid.tv;
-
- return 301 https://linearvoid.tv$request_uri;
- }
-
- server {
- listen 80 default_server;
- server_name linearvoid.tv localhost 127.0.0.1;
-
- # Resumable admin upload chunks are 8MB by default; allow safe headroom.
- client_max_body_size 64m;
-
- root /srv/frontend;
- index index.html;
-
- location = /admin {
- return 301 /admin/;
- }
-
- location = /admin/login {
- return 301 /admin/login/;
- }
-
- location = /_admin_auth {
- internal;
- set $chat_upstream http://chat:3000;
- proxy_pass $chat_upstream/api/admin/session;
- proxy_http_version 1.1;
- proxy_pass_request_body off;
- proxy_set_header Content-Length "";
- proxy_set_header Cookie $http_cookie;
- proxy_set_header Host $host;
- proxy_set_header X-Forwarded-Host $host;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- }
-
- location /admin/login/ {
- try_files $uri $uri/ /admin/login/index.html;
- }
-
- location /admin/ {
- auth_request /_admin_auth;
- error_page 401 = @admin_login_redirect;
- try_files $uri $uri/ /admin/index.html;
- }
-
- location /assets/ {
- try_files $uri =404;
- }
-
- # Serve static frontend
- location / {
- try_files $uri $uri/ /index.html;
- }
-
- # Proxy chat WebSocket
- location /ws {
- set $chat_upstream http://chat:3000;
- proxy_pass $chat_upstream;
- proxy_http_version 1.1;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection $connection_upgrade;
- proxy_set_header Host $host;
- proxy_set_header X-Forwarded-Host $host;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- }
-
- location = /api/admin/login {
- if ($request_method = OPTIONS) {
- add_header Access-Control-Allow-Origin $cors_origin;
- add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
- add_header Access-Control-Allow-Headers "Content-Type, Authorization";
- add_header Access-Control-Allow-Credentials true;
- add_header Access-Control-Max-Age 86400;
- return 204;
- }
- set $chat_upstream http://chat:3000;
- proxy_pass $chat_upstream;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Forwarded-Host $host;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header Cookie $http_cookie;
- add_header Access-Control-Allow-Origin $cors_origin always;
- add_header Access-Control-Allow-Credentials true always;
- }
-
- location = /api/admin/logout {
- if ($request_method = OPTIONS) {
- add_header Access-Control-Allow-Origin $cors_origin;
- add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
- add_header Access-Control-Allow-Headers "Content-Type, Authorization";
- add_header Access-Control-Allow-Credentials true;
- add_header Access-Control-Max-Age 86400;
- return 204;
- }
- set $chat_upstream http://chat:3000;
- proxy_pass $chat_upstream;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Forwarded-Host $host;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header Cookie $http_cookie;
- add_header Access-Control-Allow-Origin $cors_origin always;
- add_header Access-Control-Allow-Credentials true always;
- }
-
- location = /api/admin/session {
- if ($request_method = OPTIONS) {
- add_header Access-Control-Allow-Origin $cors_origin;
- add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
- add_header Access-Control-Allow-Headers "Content-Type, Authorization";
- add_header Access-Control-Allow-Credentials true;
- add_header Access-Control-Max-Age 86400;
- return 204;
- }
- set $chat_upstream http://chat:3000;
- proxy_pass $chat_upstream;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Forwarded-Host $host;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header Cookie $http_cookie;
- add_header Access-Control-Allow-Origin $cors_origin always;
- add_header Access-Control-Allow-Credentials true always;
- }
-
- location /api/admin/ {
- if ($request_method = OPTIONS) {
- add_header Access-Control-Allow-Origin $cors_origin;
- add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
- add_header Access-Control-Allow-Headers "Content-Type, Authorization";
- add_header Access-Control-Allow-Credentials true;
- add_header Access-Control-Max-Age 86400;
- return 204;
- }
- auth_request /_admin_auth;
- error_page 401 = @admin_api_unauthorized;
- set $chat_upstream http://chat:3000;
- proxy_pass $chat_upstream;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Forwarded-Host $host;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header Cookie $http_cookie;
- add_header Access-Control-Allow-Origin $cors_origin always;
- add_header Access-Control-Allow-Credentials true always;
- }
-
- location /api/ {
- if ($request_method = OPTIONS) {
- add_header Access-Control-Allow-Origin $cors_origin;
- add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
- add_header Access-Control-Allow-Headers "Content-Type, Authorization";
- add_header Access-Control-Allow-Credentials true;
- add_header Access-Control-Max-Age 86400;
- return 204;
- }
- set $chat_upstream http://chat:3000;
- proxy_pass $chat_upstream;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Forwarded-Host $host;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- add_header Access-Control-Allow-Origin $cors_origin always;
- add_header Access-Control-Allow-Credentials true always;
- }
-
- location = /metrics {
- set $chat_upstream http://chat:3000;
- proxy_pass $chat_upstream;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Forwarded-Host $host;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- }
-
- location @admin_api_unauthorized {
- add_header Content-Type application/json;
- return 401 '{"error":"unauthorized"}';
- }
-
- location @admin_login_redirect {
- return 302 /admin/login/;
- }
-
- location = /hls/playlist.m3u8 {
- if ($request_method = OPTIONS) {
- add_header Access-Control-Allow-Origin "*";
- add_header Access-Control-Allow-Methods "GET, OPTIONS";
- add_header Access-Control-Max-Age 86400;
- return 204;
- }
- alias /srv/hls/playlist.m3u8;
- add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
- add_header Access-Control-Allow-Origin "*";
- }
-
- location = /hls/init.mp4 {
- if ($request_method = OPTIONS) {
- add_header Access-Control-Allow-Origin "*";
- add_header Access-Control-Allow-Methods "GET, OPTIONS";
- add_header Access-Control-Max-Age 86400;
- return 204;
- }
- alias /srv/hls/init.mp4;
- add_header Cache-Control "public, max-age=31536000, immutable";
- add_header Access-Control-Allow-Origin "*";
- }
-
- location ~* ^/hls/(.+\.(ts|m4s))$ {
- if ($request_method = OPTIONS) {
- add_header Access-Control-Allow-Origin "*";
- add_header Access-Control-Allow-Methods "GET, OPTIONS";
- add_header Access-Control-Max-Age 86400;
- return 204;
- }
- alias /srv/hls/$1;
- add_header Cache-Control "public, max-age=31536000, immutable";
- add_header Access-Control-Allow-Origin "*";
- }
-
- # Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN.
- location /hls/ {
- if ($request_method = OPTIONS) {
- add_header Access-Control-Allow-Origin "*";
- add_header Access-Control-Allow-Methods "GET, OPTIONS";
- add_header Access-Control-Max-Age 86400;
- return 204;
- }
- alias /srv/hls/;
- add_header Cache-Control "public, max-age=60";
- add_header Access-Control-Allow-Origin "*";
- }
-
- # Health checks — aggregated from upstream services
- location /health {
- default_type application/json;
- return 200 '{"status":"ok","service":"nginx","timestamp":"$time_iso8601"}';
- }
-
- location /nginx-status {
- stub_status on;
- allow 127.0.0.1;
- allow 172.16.0.0/12;
- allow 192.168.0.0/16;
- deny all;
- }
- }
+events {
+ worker_connections 1024;
+}
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ resolver 127.0.0.11 ipv6=off valid=30s;
+
+ map_hash_bucket_size 128;
+
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+ map $http_origin $cors_origin {
+ default "";
+ "https://linearvoid.tv" $http_origin;
+ "https://www.linearvoid.tv" $http_origin;
+ "https://linearvoid-frontend.zwitschi82.workers.dev" $http_origin;
+ "~^https://[a-zA-Z0-9.-]+\.linearvoid\.tv$" $http_origin;
+ }
+
+ server {
+ listen 80;
+ server_name www.linearvoid.tv;
+
+ return 301 https://linearvoid.tv$request_uri;
+ }
+
+ server {
+ listen 80 default_server;
+ server_name linearvoid.tv localhost 127.0.0.1;
+
+ # Resumable admin upload chunks are 8MB by default; allow safe headroom.
+ client_max_body_size 64m;
+
+ root /srv/frontend;
+ index index.html;
+
+ location = /admin {
+ return 301 /admin/;
+ }
+
+ location = /admin/login {
+ return 301 /admin/login/;
+ }
+
+ location = /_admin_auth {
+ internal;
+ set $chat_upstream http://chat:3000;
+ proxy_pass $chat_upstream/api/admin/session;
+ proxy_http_version 1.1;
+ proxy_pass_request_body off;
+ proxy_set_header Content-Length "";
+ proxy_set_header Cookie $http_cookie;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+
+ location /admin/login/ {
+ try_files $uri $uri/ /admin/login/index.html;
+ }
+
+ location /admin/ {
+ auth_request /_admin_auth;
+ error_page 401 = @admin_login_redirect;
+ try_files $uri $uri/ /admin/index.html;
+ }
+
+ location /assets/ {
+ try_files $uri =404;
+ }
+
+ # Serve static frontend
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Proxy chat WebSocket
+ location /ws {
+ set $chat_upstream http://chat:3000;
+ proxy_pass $chat_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+
+ location = /api/admin/login {
+ if ($request_method = OPTIONS) {
+ add_header Access-Control-Allow-Origin $cors_origin;
+ add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
+ add_header Access-Control-Allow-Headers "Content-Type, Authorization";
+ add_header Access-Control-Allow-Credentials true;
+ add_header Access-Control-Max-Age 86400;
+ return 204;
+ }
+ set $chat_upstream http://chat:3000;
+ proxy_pass $chat_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Cookie $http_cookie;
+ add_header Access-Control-Allow-Origin $cors_origin always;
+ add_header Access-Control-Allow-Credentials true always;
+ }
+
+ location = /api/admin/logout {
+ if ($request_method = OPTIONS) {
+ add_header Access-Control-Allow-Origin $cors_origin;
+ add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
+ add_header Access-Control-Allow-Headers "Content-Type, Authorization";
+ add_header Access-Control-Allow-Credentials true;
+ add_header Access-Control-Max-Age 86400;
+ return 204;
+ }
+ set $chat_upstream http://chat:3000;
+ proxy_pass $chat_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Cookie $http_cookie;
+ add_header Access-Control-Allow-Origin $cors_origin always;
+ add_header Access-Control-Allow-Credentials true always;
+ }
+
+ location = /api/admin/session {
+ if ($request_method = OPTIONS) {
+ add_header Access-Control-Allow-Origin $cors_origin;
+ add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
+ add_header Access-Control-Allow-Headers "Content-Type, Authorization";
+ add_header Access-Control-Allow-Credentials true;
+ add_header Access-Control-Max-Age 86400;
+ return 204;
+ }
+ set $chat_upstream http://chat:3000;
+ proxy_pass $chat_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Cookie $http_cookie;
+ add_header Access-Control-Allow-Origin $cors_origin always;
+ add_header Access-Control-Allow-Credentials true always;
+ }
+
+ location /api/admin/ {
+ if ($request_method = OPTIONS) {
+ add_header Access-Control-Allow-Origin $cors_origin;
+ add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
+ add_header Access-Control-Allow-Headers "Content-Type, Authorization";
+ add_header Access-Control-Allow-Credentials true;
+ add_header Access-Control-Max-Age 86400;
+ return 204;
+ }
+ auth_request /_admin_auth;
+ error_page 401 = @admin_api_unauthorized;
+ set $chat_upstream http://chat:3000;
+ proxy_pass $chat_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Cookie $http_cookie;
+ add_header Access-Control-Allow-Origin $cors_origin always;
+ add_header Access-Control-Allow-Credentials true always;
+ }
+
+ location /api/ {
+ if ($request_method = OPTIONS) {
+ add_header Access-Control-Allow-Origin $cors_origin;
+ add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
+ add_header Access-Control-Allow-Headers "Content-Type, Authorization";
+ add_header Access-Control-Allow-Credentials true;
+ add_header Access-Control-Max-Age 86400;
+ return 204;
+ }
+ set $chat_upstream http://chat:3000;
+ proxy_pass $chat_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ add_header Access-Control-Allow-Origin $cors_origin always;
+ add_header Access-Control-Allow-Credentials true always;
+ }
+
+ location = /metrics {
+ set $chat_upstream http://chat:3000;
+ proxy_pass $chat_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+
+ location @admin_api_unauthorized {
+ add_header Content-Type application/json;
+ return 401 '{"error":"unauthorized"}';
+ }
+
+ location @admin_login_redirect {
+ return 302 /admin/login/;
+ }
+
+ location = /hls/playlist.m3u8 {
+ if ($request_method = OPTIONS) {
+ add_header Access-Control-Allow-Origin "*";
+ add_header Access-Control-Allow-Methods "GET, OPTIONS";
+ add_header Access-Control-Max-Age 86400;
+ return 204;
+ }
+ alias /srv/hls/playlist.m3u8;
+ add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
+ add_header Access-Control-Allow-Origin "*";
+ }
+
+ location = /hls/init.mp4 {
+ if ($request_method = OPTIONS) {
+ add_header Access-Control-Allow-Origin "*";
+ add_header Access-Control-Allow-Methods "GET, OPTIONS";
+ add_header Access-Control-Max-Age 86400;
+ return 204;
+ }
+ alias /srv/hls/init.mp4;
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ add_header Access-Control-Allow-Origin "*";
+ }
+
+ location ~* ^/hls/(.+\.(ts|m4s))$ {
+ if ($request_method = OPTIONS) {
+ add_header Access-Control-Allow-Origin "*";
+ add_header Access-Control-Allow-Methods "GET, OPTIONS";
+ add_header Access-Control-Max-Age 86400;
+ return 204;
+ }
+ alias /srv/hls/$1;
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ add_header Access-Control-Allow-Origin "*";
+ }
+
+ # Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN.
+ location /hls/ {
+ if ($request_method = OPTIONS) {
+ add_header Access-Control-Allow-Origin "*";
+ add_header Access-Control-Allow-Methods "GET, OPTIONS";
+ add_header Access-Control-Max-Age 86400;
+ return 204;
+ }
+ alias /srv/hls/;
+ add_header Cache-Control "public, max-age=60";
+ add_header Access-Control-Allow-Origin "*";
+ }
+
+ # Health checks — aggregated from upstream services
+ location /health {
+ default_type application/json;
+ return 200 '{"status":"ok","service":"nginx","timestamp":"$time_iso8601"}';
+ }
+
+ location /nginx-status {
+ stub_status on;
+ allow 127.0.0.1;
+ allow 172.16.0.0/12;
+ allow 192.168.0.0/16;
+ deny all;
+ }
+ }
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..23d9456
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1148 @@
+{
+ "name": "linearvoid",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "vitest": "^4.1.11"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "license": "MIT"
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
+ "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz",
+ "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz",
+ "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz",
+ "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz",
+ "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz",
+ "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz",
+ "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz",
+ "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz",
+ "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz",
+ "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz",
+ "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz",
+ "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz",
+ "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz",
+ "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz",
+ "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz",
+ "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.11",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.11",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz",
+ "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==",
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.148.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.7",
+ "@rolldown/binding-android-arm64": "1.2.7",
+ "@rolldown/binding-darwin-arm64": "1.2.7",
+ "@rolldown/binding-darwin-x64": "1.2.7",
+ "@rolldown/binding-freebsd-x64": "1.2.7",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.7",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.7",
+ "@rolldown/binding-linux-arm64-musl": "1.2.7",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.7",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.7",
+ "@rolldown/binding-linux-x64-gnu": "1.2.7",
+ "@rolldown/binding-linux-x64-musl": "1.2.7",
+ "@rolldown/binding-openharmony-arm64": "1.2.7",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.7",
+ "@rolldown/binding-win32-x64-msvc": "1.2.7"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.11",
+ "@vitest/mocker": "4.1.11",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.11",
+ "@vitest/browser-preview": "4.1.11",
+ "@vitest/browser-webdriverio": "4.1.11",
+ "@vitest/coverage-istanbul": "4.1.11",
+ "@vitest/coverage-v8": "4.1.11",
+ "@vitest/ui": "4.1.11",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d75f656
--- /dev/null
+++ b/package.json
@@ -0,0 +1,5 @@
+{
+ "dependencies": {
+ "vitest": "^4.1.11"
+ }
+}
diff --git a/scripts/cache-hit-ratio.js b/scripts/cache-hit-ratio.js
index 1092ad9..0987921 100644
--- a/scripts/cache-hit-ratio.js
+++ b/scripts/cache-hit-ratio.js
@@ -1,106 +1,106 @@
-#!/usr/bin/env node
-
-const apiToken = process.env.CLOUDFLARE_API_TOKEN;
-const zoneTag = process.env.CLOUDFLARE_ZONE_TAG;
-const hostname = process.env.CLOUDFLARE_HOSTNAME || "linearvoid.tv";
-const windowMinutes = Number.parseInt(process.env.CLOUDFLARE_CACHE_HIT_RATIO_WINDOW_MINUTES || "60", 10);
-const endpoint = process.env.CLOUDFLARE_GRAPHQL_ENDPOINT || "https://api.cloudflare.com/client/v4/graphql";
-
-if (!apiToken || !zoneTag) {
- console.error("Missing required environment variables: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_TAG");
- process.exit(1);
-}
-
-const end = new Date();
-const start = new Date(end.getTime() - windowMinutes * 60 * 1000);
-
-const iso = (value) => value.toISOString();
-
-const query = `
- query CacheHitRatio($zoneTag: string, $start: Time, $end: Time, $hostname: String) {
- viewer {
- zones(filter: { zoneTag: $zoneTag }) {
- requests: httpRequestsAdaptiveGroups(
- limit: 1000
- filter: {
- datetime_geq: $start
- datetime_lt: $end
- clientRequestHTTPHost: $hostname
- requestSource: "eyeball"
- }
- ) {
- count
- dimensions {
- cacheStatus
- }
- }
- }
- }
- }
-`;
-
-async function main() {
- const response = await fetch(endpoint, {
- method: "POST",
- headers: {
- Authorization: `Bearer ${apiToken}`,
- "Content-Type": "application/json",
- Accept: "application/json",
- },
- body: JSON.stringify({
- query,
- variables: {
- zoneTag,
- hostname,
- start: iso(start),
- end: iso(end),
- },
- }),
- });
-
- const payload = await response.json();
- if (!response.ok || payload.errors) {
- const message = payload.errors ? JSON.stringify(payload.errors) : `HTTP ${response.status}`;
- throw new Error(message);
- }
-
- const groups = payload?.data?.viewer?.zones?.[0]?.requests || [];
- let totalRequests = 0;
- let hitRequests = 0;
-
- for (const group of groups) {
- const count = Number(group?.count || 0);
- const status = String(group?.dimensions?.cacheStatus || "").toUpperCase();
- totalRequests += count;
- if (status === "HIT") {
- hitRequests += count;
- }
- }
-
- const ratio = totalRequests > 0 ? hitRequests / totalRequests : 0;
-
- console.log(
- `# HELP linearvoid_cache_hit_ratio Cloudflare cache hit ratio for the selected hostname and time window.`,
- );
- console.log(`# TYPE linearvoid_cache_hit_ratio gauge`);
- console.log(
- `linearvoid_cache_hit_ratio{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${ratio.toFixed(6)}`,
- );
- console.log(`# HELP linearvoid_cache_hit_requests_total Cloudflare cache HIT requests counted in the time window.`);
- console.log(`# TYPE linearvoid_cache_hit_requests_total counter`);
- console.log(
- `linearvoid_cache_hit_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${hitRequests}`,
- );
- console.log(
- `# HELP linearvoid_cache_total_requests_total Cloudflare total eyeball requests counted in the time window.`,
- );
- console.log(`# TYPE linearvoid_cache_total_requests_total counter`);
- console.log(
- `linearvoid_cache_total_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${totalRequests}`,
- );
-}
-
-main().catch((error) => {
- console.error(error instanceof Error ? error.message : String(error));
- process.exit(1);
-});
+#!/usr/bin/env node
+
+const apiToken = process.env.CLOUDFLARE_API_TOKEN;
+const zoneTag = process.env.CLOUDFLARE_ZONE_TAG;
+const hostname = process.env.CLOUDFLARE_HOSTNAME || "linearvoid.tv";
+const windowMinutes = Number.parseInt(process.env.CLOUDFLARE_CACHE_HIT_RATIO_WINDOW_MINUTES || "60", 10);
+const endpoint = process.env.CLOUDFLARE_GRAPHQL_ENDPOINT || "https://api.cloudflare.com/client/v4/graphql";
+
+if (!apiToken || !zoneTag) {
+ console.error("Missing required environment variables: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_TAG");
+ process.exit(1);
+}
+
+const end = new Date();
+const start = new Date(end.getTime() - windowMinutes * 60 * 1000);
+
+const iso = (value) => value.toISOString();
+
+const query = `
+ query CacheHitRatio($zoneTag: string, $start: Time, $end: Time, $hostname: String) {
+ viewer {
+ zones(filter: { zoneTag: $zoneTag }) {
+ requests: httpRequestsAdaptiveGroups(
+ limit: 1000
+ filter: {
+ datetime_geq: $start
+ datetime_lt: $end
+ clientRequestHTTPHost: $hostname
+ requestSource: "eyeball"
+ }
+ ) {
+ count
+ dimensions {
+ cacheStatus
+ }
+ }
+ }
+ }
+ }
+`;
+
+async function main() {
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${apiToken}`,
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify({
+ query,
+ variables: {
+ zoneTag,
+ hostname,
+ start: iso(start),
+ end: iso(end),
+ },
+ }),
+ });
+
+ const payload = await response.json();
+ if (!response.ok || payload.errors) {
+ const message = payload.errors ? JSON.stringify(payload.errors) : `HTTP ${response.status}`;
+ throw new Error(message);
+ }
+
+ const groups = payload?.data?.viewer?.zones?.[0]?.requests || [];
+ let totalRequests = 0;
+ let hitRequests = 0;
+
+ for (const group of groups) {
+ const count = Number(group?.count || 0);
+ const status = String(group?.dimensions?.cacheStatus || "").toUpperCase();
+ totalRequests += count;
+ if (status === "HIT") {
+ hitRequests += count;
+ }
+ }
+
+ const ratio = totalRequests > 0 ? hitRequests / totalRequests : 0;
+
+ console.log(
+ `# HELP linearvoid_cache_hit_ratio Cloudflare cache hit ratio for the selected hostname and time window.`,
+ );
+ console.log(`# TYPE linearvoid_cache_hit_ratio gauge`);
+ console.log(
+ `linearvoid_cache_hit_ratio{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${ratio.toFixed(6)}`,
+ );
+ console.log(`# HELP linearvoid_cache_hit_requests_total Cloudflare cache HIT requests counted in the time window.`);
+ console.log(`# TYPE linearvoid_cache_hit_requests_total counter`);
+ console.log(
+ `linearvoid_cache_hit_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${hitRequests}`,
+ );
+ console.log(
+ `# HELP linearvoid_cache_total_requests_total Cloudflare total eyeball requests counted in the time window.`,
+ );
+ console.log(`# TYPE linearvoid_cache_total_requests_total counter`);
+ console.log(
+ `linearvoid_cache_total_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${totalRequests}`,
+ );
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+});
diff --git a/scripts/cache-hit-ratio.ps1 b/scripts/cache-hit-ratio.ps1
index 0e9f7c2..5bc16d3 100644
--- a/scripts/cache-hit-ratio.ps1
+++ b/scripts/cache-hit-ratio.ps1
@@ -1,8 +1,8 @@
-#!/usr/bin/env pwsh
-$ErrorActionPreference = 'Stop'
-
-$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
-$nodeScript = Join-Path $scriptDir 'cache-hit-ratio.js'
-
-& node $nodeScript @args
-exit $LASTEXITCODE
+#!/usr/bin/env pwsh
+$ErrorActionPreference = 'Stop'
+
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$nodeScript = Join-Path $scriptDir 'cache-hit-ratio.js'
+
+& node $nodeScript @args
+exit $LASTEXITCODE
diff --git a/scripts/cache-hit-ratio.sh b/scripts/cache-hit-ratio.sh
new file mode 100644
index 0000000..7ee12f0
--- /dev/null
+++ b/scripts/cache-hit-ratio.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+# cache-hit-ratio.sh — Thin wrapper that runs the Node.js cache-hit-ratio tool.
+# Mirrors scripts/cache-hit-ratio.ps1.
+set -euo pipefail
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+exec node "$script_dir/cache-hit-ratio.js" "$@"
diff --git a/scripts/check-env.sh b/scripts/check-env.sh
new file mode 100644
index 0000000..5ba216a
--- /dev/null
+++ b/scripts/check-env.sh
@@ -0,0 +1,132 @@
+#!/usr/bin/env bash
+# check-env.sh — Verify the deployment environment for Linear Void.
+# Mirrors scripts/check-env.ps1.
+#
+# Checks, against the target Docker host (default test lab 192.168.88.40):
+# 1. Docker Engine reachability over the TCP socket (port 2375).
+# 2. Coolify helper stack (proxy/Traefik, sentinel, cloudflared).
+# 3. Linear Void containers (playout, chat, nginx, redis, db, prometheus, grafana).
+# 4. Public endpoints (app, HLS manifest, admin).
+# 5. Health endpoints exposed by the services.
+#
+# Usage:
+# ./scripts/check-env.sh
+# ./scripts/check-env.sh -t 192.168.88.40
+# ./scripts/check-env.sh -t 192.168.88.40 -p 2375 --only-containers
+#
+# Exit code is 0 when all required checks pass, 1 otherwise.
+set -uo pipefail
+
+TARGET=192.168.88.40
+DOCKER_PORT=2375
+ONLY_CONTAINERS=0
+ONLY_ENDPOINTS=0
+TIMEOUT=10
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -t|--target) TARGET="$2"; shift 2;;
+ -p|--docker-port) DOCKER_PORT="$2"; shift 2;;
+ --only-containers) ONLY_CONTAINERS=1; shift;;
+ --only-endpoints) ONLY_ENDPOINTS=1; shift;;
+ --timeout) TIMEOUT="$2"; shift 2;;
+ *) echo "Unknown option: $1" >&2; exit 2;;
+ esac
+done
+
+export DOCKER_HOST="tcp://${TARGET}:${DOCKER_PORT}"
+
+FAILED=0
+fail() { printf ' [FAIL] %s\n' "$1"; FAILED=1; }
+ok() { printf ' [OK] %s\n' "$1"; }
+warn() { printf ' [WARN] %s\n' "$1"; }
+
+echo "== Linear Void environment check =="
+echo "Target Docker host : $DOCKER_HOST"
+echo "Date : $(date -Iseconds)"
+
+# ---------------------------------------------------------------------------
+# 1. Docker Engine reachability
+# ---------------------------------------------------------------------------
+if [[ "$ONLY_ENDPOINTS" -eq 0 ]]; then
+ echo ""
+ echo "-- Docker Engine (${TARGET}:${DOCKER_PORT}) --"
+ if ver=$(docker version --format '{{.Server.Version}}' 2>&1) && [[ "$ver" =~ [0-9] ]]; then
+ ok "Docker Engine reachable (server version $ver)"
+ else
+ fail "Docker Engine not reachable: $ver"
+ fi
+
+ # -------------------------------------------------------------------------
+ # 2. Coolify helper stack
+ # -------------------------------------------------------------------------
+ echo ""
+ echo "-- Coolify managed-server helpers --"
+ for c in coolify-proxy coolify-sentinel coolify-cloudflared; do
+ if state=$(docker inspect "$c" --format '{{.State.Status}} ({{.State.Health.Status}})' 2>/dev/null); then
+ if [[ "$state" =~ running.*(healthy|running) ]]; then ok "coolify helper: $c -> $state"; else warn "coolify helper: $c -> $state"; fi
+ else
+ warn "coolify helper: $c -> NOT FOUND (is this a Coolify-managed server?)"
+ fi
+ done
+
+ # -------------------------------------------------------------------------
+ # 3. Linear Void containers
+ # -------------------------------------------------------------------------
+ echo ""
+ echo "-- Linear Void containers --"
+ required=(linearvoid-playout linearvoid-chat linearvoid-nginx linearvoid-redis linearvoid-db linearvoid-prometheus linearvoid-grafana)
+ running="$(docker ps --format '{{.Names}}' 2>/dev/null)"
+ all="$(docker ps -a --format '{{.Names}}\t{{.Status}}' 2>/dev/null)"
+ for name in "${required[@]}"; do
+ if grep -qx "$name" <<<"$running"; then
+ ok "container: $name is running"
+ elif grep -q "^$name[[:space:]]" <<<"$all"; then
+ status="$(grep "^$name[[:space:]]" <<<"$all" | head -1)"
+ fail "container: $name EXISTS but not running -> $status"
+ else
+ fail "container: $name MISSING"
+ fi
+ done
+fi
+
+# ---------------------------------------------------------------------------
+# 4. Health endpoints (via Docker host)
+# ---------------------------------------------------------------------------
+if [[ "$ONLY_CONTAINERS" -eq 0 ]]; then
+ echo ""
+ echo "-- Health endpoints --"
+ checks=(
+ "chat /health|http://${TARGET}:3000/health"
+ "nginx /health|http://${TARGET}:8081/health"
+ "prometheus|http://${TARGET}:9090/-/healthy"
+ "grafana /api/health|http://${TARGET}:3001/api/health"
+ )
+ for entry in "${checks[@]}"; do
+ name="${entry%%|*}"; url="${entry#*|}"
+ code=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" "$url" 2>/dev/null)
+ if [[ "$code" == "200" ]]; then ok "$name -> 200"; elif [[ "$code" =~ ^[0-9]{3}$ ]]; then warn "$name -> $code"; else fail "$name -> unreachable"; fi
+ done
+
+ # -------------------------------------------------------------------------
+ # 5. Public endpoints (routed through the Docker host for reliable TLS)
+ # -------------------------------------------------------------------------
+ echo ""
+ echo "-- Public endpoints (via Docker host) --"
+ pub=(
+ "linearvoid.tv app|https://linearvoid.tv/"
+ "media HLS manifest|https://media.linearvoid.tv/hls/playlist.m3u8"
+ "coolify dashboard|https://coolify.allucanget.biz/"
+ )
+ for entry in "${pub[@]}"; do
+ name="${entry%%|*}"; url="${entry#*|}"
+ code=$(docker run --rm curlimages/curl:latest -sS -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" -L "$url" 2>/dev/null | tr -d '[:space:]')
+ if [[ "$code" =~ ^[0-9]{3}$ ]]; then ok "$name -> HTTP $code"; else warn "$name -> $code"; fi
+ done
+fi
+
+# ---------------------------------------------------------------------------
+echo ""
+if [[ "$FAILED" -ne 0 ]]; then echo "RESULT: FAILED"; exit 1; fi
+echo "RESULT: OK"
+exit 0
diff --git a/scripts/check-tokens.sh b/scripts/check-tokens.sh
new file mode 100644
index 0000000..1c3ecf8
--- /dev/null
+++ b/scripts/check-tokens.sh
@@ -0,0 +1,177 @@
+#!/usr/bin/env bash
+# check-tokens.sh — Verify that the credentials in the environment actually work.
+# Mirrors scripts/check-tokens.ps1.
+#
+# Reads .env (or a supplied file) and validates access against live services:
+# 1. Gitea container registry (CI_REGISTRY_TOKEN / CI_REGISTRY_USER).
+# 2. Cloudflare API token(s) (CLOUDFLARE_API_TOKEN, and any R2 admin token).
+# 3. R2/S3 bucket access (R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY) — lists buckets.
+# 4. Linear Void admin login (ADMIN_USERNAME / ADMIN_PASSWORD) against the app origin.
+#
+# NOTE: Runs the checks through the target Docker host (default 192.168.88.40) so
+# network + TLS work regardless of this machine's proxy/firewall. Uses the
+# curlimages/curl and amazon/aws-cli images, which are pulled on demand.
+#
+# Usage:
+# ./scripts/check-tokens.sh
+# ./scripts/check-tokens.sh -e .env
+# ./scripts/check-tokens.sh -t 192.168.88.40
+#
+# Exit code 0 = all credentials valid; 1 = at least one check failed.
+set -uo pipefail
+
+ENV_FILE=""
+TARGET=192.168.88.40
+DOCKER_PORT=2375
+TIMEOUT=20
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -e|--env-file) ENV_FILE="$2"; shift 2;;
+ -t|--target) TARGET="$2"; shift 2;;
+ -p|--docker-port) DOCKER_PORT="$2"; shift 2;;
+ --timeout) TIMEOUT="$2"; shift 2;;
+ *) echo "Unknown option: $1" >&2; exit 2;;
+ esac
+done
+
+export DOCKER_HOST="tcp://${TARGET}:${DOCKER_PORT}"
+
+FAILED=0
+fail() { printf ' [FAIL] %s\n' "$1"; FAILED=1; }
+ok() { printf ' [OK] %s\n' "$1"; }
+warn() { printf ' [WARN] %s\n' "$1"; }
+
+# Resolve env file: explicit, or $PWD/.env, or repo root.
+if [[ -z "$ENV_FILE" ]]; then
+ for cand in "$PWD/.env" "$(dirname "$PWD")/.env"; do
+ if [[ -f "$cand" ]]; then ENV_FILE="$cand"; break; fi
+ done
+fi
+if [[ -z "$ENV_FILE" || ! -f "$ENV_FILE" ]]; then
+ echo "No .env file found. Pass one with -e." >&2
+ exit 1
+fi
+
+# Parse KEY=VALUE lines into an associative array.
+declare -A envs
+while IFS= read -r line || [[ -n "$line" ]]; do
+ if [[ "$line" =~ ^[[:space:]]*([A-Za-z0-9_]+)[[:space:]]*=[[:space:]]*(.*)[[:space:]]*$ ]]; then
+ envs["${BASH_REMATCH[1]}"]="${BASH_REMATCH[2]}"
+ fi
+done < "$ENV_FILE"
+
+get_val() { [[ -n "${envs[$1]+x}" ]] && echo "${envs[$1]}"; }
+
+# Pull helper images once.
+echo "Preparing helper images (curl, aws-cli) on $TARGET..."
+docker pull curlimages/curl:latest >/dev/null 2>&1 || true
+docker pull amazon/aws-cli:latest >/dev/null 2>&1 || true
+
+# dc_curl URL [bearer] [token] — run curl via the curlimages/curl container.
+dc_curl() {
+ local url="$1" bearer="${2:-}" token="${3:-}"
+ local -a args=(run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT")
+ [[ -n "$bearer" ]] && args+=( -H "Authorization: Bearer $bearer" )
+ [[ -n "$token" ]] && args+=( -H "Authorization: token $token" )
+ args+=( "$url" )
+ docker "${args[@]}" 2>&1 | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'
+}
+
+# ---------------------------------------------------------------------------
+echo "== Linear Void credential / token check =="
+echo "Env file : $ENV_FILE"
+echo "Docker host: $DOCKER_HOST"
+
+# --- 1. Gitea registry token ---
+echo ""
+echo "-- Gitea container registry --"
+reg_user="$(get_val CI_REGISTRY_USER)"
+reg_token="$(get_val CI_REGISTRY_TOKEN)"
+if [[ -n "$reg_user" && -n "$reg_token" ]]; then
+ # Non-sensitive probe: /api/v1/user returns a scope message for token-based creds.
+ out="$(dc_curl 'https://git.allucanget.biz/api/v1/user' "" "$reg_token")"
+ if [[ "$out" =~ token.does.not.have.at.least.one.of.required.scope || "$out" =~ '"login"' || "$out" =~ invalid_grant ]]; then
+ if [[ "$out" =~ invalid_grant|Unauthorized|invalid ]]; then
+ fail "Gitea registry token rejected ($reg_user): $out"
+ else
+ ok "Gitea registry token valid for '$reg_user' (scope: write:package/write:repository)"
+ fi
+ else
+ warn "Gitea registry token response unexpected: $out"
+ fi
+else
+ warn "CI_REGISTRY_USER / CI_REGISTRY_TOKEN not set; skipping Gitea registry check"
+fi
+
+# --- 2. Cloudflare API token ---
+echo ""
+echo "-- Cloudflare API tokens --"
+cf_token="$(get_val CLOUDFLARE_API_TOKEN)"
+if [[ -n "$cf_token" ]]; then
+ out="$(dc_curl 'https://api.cloudflare.com/client/v4/user/tokens/verify' "$cf_token")"
+ if [[ "$out" =~ '"success":true' ]]; then
+ if [[ "$out" =~ '"status":"active"' ]]; then ok 'Cloudflare API token valid and ACTIVE'; else warn "Cloudflare API token valid but status: $out"; fi
+ else
+ fail "Cloudflare API token rejected: $out"
+ fi
+else
+ warn "CLOUDFLARE_API_TOKEN not set; skipping Cloudflare check"
+fi
+
+# --- 3. Cloudflare R2 / S3 bucket access ---
+echo ""
+echo "-- Cloudflare R2 (S3-compatible) --"
+r2_key="$(get_val R2_ACCESS_KEY_ID)"
+r2_secret="$(get_val R2_SECRET_ACCESS_KEY)"
+r2_ep="$(get_val R2_ENDPOINT)"
+bucket="$(get_val R2_BUCKET_NAME)"
+
+# Use the admin key pair (which has ListBuckets) when present, else the regular key.
+list_k="$(get_val R2_ADMIN_ACCESS_KEY_ID)"; list_s="$(get_val R2_ADMIN_SECRET_ACCESS_KEY)"
+if [[ -z "$list_k" || -z "$list_s" ]]; then list_k="$r2_key"; list_s="$r2_secret"; fi
+
+if [[ -n "$list_k" && -n "$list_s" && -n "$r2_ep" ]]; then
+ out="$(docker run --rm --entrypoint sh amazon/aws-cli:latest -c "export AWS_ACCESS_KEY_ID='$list_k'; export AWS_SECRET_ACCESS_KEY='$list_s'; aws s3 ls --endpoint-url '$r2_ep' --region auto 2>&1" 2>&1)"
+ if grep -q "$bucket" <<<"$out"; then ok "R2 bucket listing OK, '$bucket' present"
+ elif grep -q 'AccessDenied' <<<"$out"; then fail "R2 listing denied (no ListBuckets permission): $out"
+ elif grep -qE '^[^[:space:]]+[[:space:]]+' <<<"$out"; then warn "R2 listing returned (unexpected): $out"
+ else fail "R2 access failed: $out"; fi
+else
+ warn "R2 credentials / endpoint not fully set; skipping R2 check"
+fi
+
+# Verify the specific application bucket is reachable with the primary key (read).
+if [[ -n "$r2_key" && -n "$r2_secret" && -n "$r2_ep" && -n "$bucket" ]]; then
+ out="$(docker run --rm --entrypoint sh amazon/aws-cli:latest -c "export AWS_ACCESS_KEY_ID='$r2_key'; export AWS_SECRET_ACCESS_KEY='$r2_secret'; aws s3 ls s3://$bucket/ --endpoint-url '$r2_ep' --region auto --max-keys 5 2>&1 | head -5" 2>&1)"
+ if grep -q 'AccessDenied' <<<"$out"; then fail "Bucket '$bucket' read denied with primary key"
+ elif [[ -z "$out" ]]; then ok "Bucket '$bucket' reachable (empty listing, expected)"
+ elif grep -q 'NoSuchBucket' <<<"$out"; then fail "Bucket '$bucket' NOT FOUND"
+ else ok "Bucket '$bucket' reachable"; fi
+elif [[ -z "$bucket" ]]; then
+ warn "R2_BUCKET_NAME not set; skipping bucket-level check"
+fi
+
+# --- 4. Linear Void admin login ---
+echo ""
+echo "-- Linear Void admin login --"
+admin_user="$(get_val ADMIN_USERNAME)"
+admin_pass="$(get_val ADMIN_PASSWORD)"
+if [[ -n "$admin_user" && -n "$admin_pass" ]]; then
+ # Hit the chat backend directly (public origin is caught by nginx SPA fallback).
+ url="http://${TARGET}:3000/api/admin/login"
+ body="{\"username\":\"$admin_user\",\"password\":\"$admin_pass\"}"
+ wrong_body="{\"username\":\"$admin_user\",\"password\":\"__definitely_wrong__\"}"
+ ok_code="$(docker run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT" -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null | tr -d '[:space:]')"
+ wrong_code="$(docker run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT" -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$wrong_body" "$url" 2>/dev/null | tr -d '[:space:]')"
+ if [[ "$ok_code" == "200" && "$wrong_code" == "401" ]]; then ok "Admin credentials valid (correct=200, wrong=401)"
+ else fail "Admin login unexpected (correct=$ok_code, wrong=$wrong_code)"; fi
+else
+ warn "ADMIN_USERNAME / ADMIN_PASSWORD not set; skipping admin check"
+fi
+
+# ---------------------------------------------------------------------------
+echo ""
+if [[ "$FAILED" -ne 0 ]]; then echo "RESULT: FAILED"; exit 1; fi
+echo "RESULT: OK (no failing checks)"
+exit 0
diff --git a/scripts/loadtest/README.md b/scripts/loadtest/README.md
index 844346f..e0490de 100644
--- a/scripts/loadtest/README.md
+++ b/scripts/loadtest/README.md
@@ -2,66 +2,42 @@
These files back the [scaling/performance playbook](../../docs/operations/scaling-performance-playbook.md).
-## WebSocket chat
+## WebSocket chat (artillery)
-Run with an explicit WebSocket URL, for example:
+Run with an explicit WebSocket URL, capturing output to an artifact file:
```bash
-WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml
-```
-
-Capture output to an artifact file for the worksheet:
-
-```bash
-WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
-```
-
-PowerShell equivalent:
-
-```powershell
-$env:WS_URL = 'wss://linearvoid.tv/ws?username=loadtest&room=main'
-New-Item -ItemType Directory -Force artifacts | Out-Null
+export WS_URL='wss://linearvoid.tv/ws?username=loadtest&room=main'
+mkdir -p artifacts
npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
```
Summarize key counters, rates, and available percentile stats from a saved Artillery JSON report:
-```powershell
-pwsh -File scripts/loadtest/summarize-artillery.ps1 -ReportFile artifacts/ws-chat-report.json
+```bash
+scripts/loadtest/summarize-artillery.sh artifacts/ws-chat-report.json
```
-## Admin API
+## Admin API (k6)
-Run with a cookie or session header that can access admin endpoints:
+Run with a cookie or session header that can access admin endpoints, capturing machine-readable metrics and a text summary:
```bash
-ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run scripts/loadtest/admin-api.js
-```
-
-Capture machine-readable metrics and a text summary:
-
-```bash
-ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js > artifacts/admin-api-output.txt
-```
-
-PowerShell equivalent:
-
-```powershell
-$env:ADMIN_BASE_URL = 'https://linearvoid.tv'
-$env:ADMIN_SESSION_COOKIE = 'session=...'
-New-Item -ItemType Directory -Force artifacts | Out-Null
-k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | Tee-Object -FilePath artifacts/admin-api-output.txt
+export ADMIN_BASE_URL='https://linearvoid.tv'
+export ADMIN_SESSION_COOKIE='session=...'
+mkdir -p artifacts
+k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | tee artifacts/admin-api-output.txt
```
The script emits global and per-endpoint thresholds (overview, analytics, chat-log, users) so p95 and error-rate regressions are visible per route.
Summarize endpoint p95 and failure rates from a saved k6 JSON summary:
-```powershell
-pwsh -File scripts/loadtest/summarize-k6.ps1 -SummaryFile artifacts/admin-api-summary.json
+```bash
+scripts/loadtest/summarize-k6.sh artifacts/admin-api-summary.json
```
-## PowerShell helper
+## Runner helper
Use the helper to run one scenario or both and automatically write outputs into a dated artifact folder:
@@ -69,16 +45,11 @@ Use the helper to run one scenario or both and automatically write outputs into
- WebSocket runs also emit `ws-chat-summary.txt` with connection/error counters and available summary percentiles.
- Admin runs also emit `admin-api-endpoint-summary.txt` with per-endpoint p95/error values.
-```powershell
-pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario ws -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main'
-```
-
-```powershell
-pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario admin -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
-```
-
-```powershell
-pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario all -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main' -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
+```bash
+scripts/loadtest/run-loadtests.sh -s ws -w 'wss://linearvoid.tv/ws?username=loadtest&room=main'
+scripts/loadtest/run-loadtests.sh -s admin -a 'https://linearvoid.tv' -c 'session=...'
+scripts/loadtest/run-loadtests.sh -s all -w 'wss://linearvoid.tv/ws?username=loadtest&room=main' \
+ -a 'https://linearvoid.tv' -c 'session=...'
```
## Notes
diff --git a/scripts/loadtest/admin-api.js b/scripts/loadtest/admin-api.js
index 884fad3..d2b1a2a 100644
--- a/scripts/loadtest/admin-api.js
+++ b/scripts/loadtest/admin-api.js
@@ -1,58 +1,58 @@
-import http from "k6/http";
-import { check, sleep } from "k6";
-
-const baseUrl = __ENV.ADMIN_BASE_URL || "https://linearvoid.tv";
-const adminCookie = __ENV.ADMIN_SESSION_COOKIE || "";
-const requestParams = adminCookie ? { headers: { Cookie: adminCookie } } : {};
-
-const endpoints = [
- { name: "overview", path: "/api/admin/overview" },
- { name: "analytics", path: "/api/admin/analytics" },
- { name: "chat_log", path: "/api/admin/chat-log" },
- { name: "users", path: "/api/admin/users" },
-];
-
-export const options = {
- stages: [
- { duration: "30s", target: 10 },
- { duration: "60s", target: 50 },
- { duration: "60s", target: 100 },
- { duration: "30s", target: 0 },
- ],
- thresholds: {
- http_req_duration: ["p(95)<1000"],
- http_req_failed: ["rate<0.01"],
- "http_req_duration{endpoint:overview}": ["p(95)<1000"],
- "http_req_duration{endpoint:analytics}": ["p(95)<1000"],
- "http_req_duration{endpoint:chat_log}": ["p(95)<1000"],
- "http_req_duration{endpoint:users}": ["p(95)<1000"],
- "http_req_failed{endpoint:overview}": ["rate<0.01"],
- "http_req_failed{endpoint:analytics}": ["rate<0.01"],
- "http_req_failed{endpoint:chat_log}": ["rate<0.01"],
- "http_req_failed{endpoint:users}": ["rate<0.01"],
- },
-};
-
-export default function () {
- const requests = endpoints.map((endpoint) => [
- "GET",
- `${baseUrl}${endpoint.path}`,
- null,
- {
- ...requestParams,
- tags: {
- endpoint: endpoint.name,
- },
- },
- ]);
- const responses = http.batch(requests);
-
- responses.forEach((response, index) => {
- const endpointPath = endpoints[index].path;
- check(response, {
- [`status is 200 for ${endpointPath}`]: (r) => r.status === 200,
- });
- });
-
- sleep(1);
-}
+import http from "k6/http";
+import { check, sleep } from "k6";
+
+const baseUrl = __ENV.ADMIN_BASE_URL || "https://linearvoid.tv";
+const adminCookie = __ENV.ADMIN_SESSION_COOKIE || "";
+const requestParams = adminCookie ? { headers: { Cookie: adminCookie } } : {};
+
+const endpoints = [
+ { name: "overview", path: "/api/admin/overview" },
+ { name: "analytics", path: "/api/admin/analytics" },
+ { name: "chat_log", path: "/api/admin/chat-log" },
+ { name: "users", path: "/api/admin/users" },
+];
+
+export const options = {
+ stages: [
+ { duration: "30s", target: 10 },
+ { duration: "60s", target: 50 },
+ { duration: "60s", target: 100 },
+ { duration: "30s", target: 0 },
+ ],
+ thresholds: {
+ http_req_duration: ["p(95)<1000"],
+ http_req_failed: ["rate<0.01"],
+ "http_req_duration{endpoint:overview}": ["p(95)<1000"],
+ "http_req_duration{endpoint:analytics}": ["p(95)<1000"],
+ "http_req_duration{endpoint:chat_log}": ["p(95)<1000"],
+ "http_req_duration{endpoint:users}": ["p(95)<1000"],
+ "http_req_failed{endpoint:overview}": ["rate<0.01"],
+ "http_req_failed{endpoint:analytics}": ["rate<0.01"],
+ "http_req_failed{endpoint:chat_log}": ["rate<0.01"],
+ "http_req_failed{endpoint:users}": ["rate<0.01"],
+ },
+};
+
+export default function () {
+ const requests = endpoints.map((endpoint) => [
+ "GET",
+ `${baseUrl}${endpoint.path}`,
+ null,
+ {
+ ...requestParams,
+ tags: {
+ endpoint: endpoint.name,
+ },
+ },
+ ]);
+ const responses = http.batch(requests);
+
+ responses.forEach((response, index) => {
+ const endpointPath = endpoints[index].path;
+ check(response, {
+ [`status is 200 for ${endpointPath}`]: (r) => r.status === 200,
+ });
+ });
+
+ sleep(1);
+}
diff --git a/scripts/loadtest/run-loadtests.ps1 b/scripts/loadtest/run-loadtests.ps1
index b09ec7b..f0f3d11 100644
--- a/scripts/loadtest/run-loadtests.ps1
+++ b/scripts/loadtest/run-loadtests.ps1
@@ -1,99 +1,99 @@
-#!/usr/bin/env pwsh
-param(
- [ValidateSet('ws', 'admin', 'all')]
- [string]$Scenario = 'all',
- [string]$ArtifactRoot = 'artifacts',
- [string]$WsUrl = $env:WS_URL,
- [string]$AdminBaseUrl = $env:ADMIN_BASE_URL,
- [string]$AdminSessionCookie = $env:ADMIN_SESSION_COOKIE
-)
-
-$ErrorActionPreference = 'Stop'
-
-$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
-$repoRoot = Resolve-Path (Join-Path $scriptDir '..\..')
-$wsScenarioPath = Join-Path $scriptDir 'ws-chat.yml'
-$wsSummaryScriptPath = Join-Path $scriptDir 'summarize-artillery.ps1'
-$adminScriptPath = Join-Path $scriptDir 'admin-api.js'
-$adminSummaryScriptPath = Join-Path $scriptDir 'summarize-k6.ps1'
-
-if ([System.IO.Path]::IsPathRooted($ArtifactRoot)) {
- $artifactBase = $ArtifactRoot
-} else {
- $artifactBase = Join-Path $repoRoot $ArtifactRoot
-}
-
-if ($Scenario -in @('ws', 'all')) {
- if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
- throw 'npx is required for ws scenario but was not found in PATH.'
- }
-}
-
-if ($Scenario -in @('admin', 'all')) {
- if (-not (Get-Command k6 -ErrorAction SilentlyContinue)) {
- throw 'k6 is required for admin scenario but was not found in PATH.'
- }
-}
-
-$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss'
-$artifactDir = Join-Path $artifactBase $timestamp
-New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
-
-function Invoke-WebSocketLoadTest {
- param(
- [string]$TargetWsUrl,
- [string]$OutputPath
- )
-
- if ([string]::IsNullOrWhiteSpace($TargetWsUrl)) {
- throw 'WS_URL is required for ws scenario. Pass -WsUrl or set $env:WS_URL.'
- }
-
- $env:WS_URL = $TargetWsUrl
- & npx artillery run $wsScenarioPath --output $OutputPath
-}
-
-function Invoke-AdminApiLoadTest {
- param(
- [string]$BaseUrl,
- [string]$SessionCookie,
- [string]$SummaryPath,
- [string]$OutputPath
- )
-
- if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
- throw 'ADMIN_BASE_URL is required for admin scenario. Pass -AdminBaseUrl or set $env:ADMIN_BASE_URL.'
- }
- if ([string]::IsNullOrWhiteSpace($SessionCookie)) {
- throw 'ADMIN_SESSION_COOKIE is required for admin scenario. Pass -AdminSessionCookie or set $env:ADMIN_SESSION_COOKIE.'
- }
-
- $env:ADMIN_BASE_URL = $BaseUrl
- $env:ADMIN_SESSION_COOKIE = $SessionCookie
- & k6 run --summary-export $SummaryPath $adminScriptPath | Tee-Object -FilePath $OutputPath
-}
-
-if ($Scenario -in @('ws', 'all')) {
- if (-not (Test-Path $wsSummaryScriptPath)) {
- throw "Missing summary helper: $wsSummaryScriptPath"
- }
-
- $wsOutput = Join-Path $artifactDir 'ws-chat-report.json'
- $wsSummary = Join-Path $artifactDir 'ws-chat-summary.txt'
- Invoke-WebSocketLoadTest -TargetWsUrl $WsUrl -OutputPath $wsOutput
- & pwsh -File $wsSummaryScriptPath -ReportFile $wsOutput | Tee-Object -FilePath $wsSummary
-}
-
-if ($Scenario -in @('admin', 'all')) {
- if (-not (Test-Path $adminSummaryScriptPath)) {
- throw "Missing summary helper: $adminSummaryScriptPath"
- }
-
- $adminSummary = Join-Path $artifactDir 'admin-api-summary.json'
- $adminOutput = Join-Path $artifactDir 'admin-api-output.txt'
- $adminEndpointSummary = Join-Path $artifactDir 'admin-api-endpoint-summary.txt'
- Invoke-AdminApiLoadTest -BaseUrl $AdminBaseUrl -SessionCookie $AdminSessionCookie -SummaryPath $adminSummary -OutputPath $adminOutput
- & pwsh -File $adminSummaryScriptPath -SummaryFile $adminSummary | Tee-Object -FilePath $adminEndpointSummary
-}
-
+#!/usr/bin/env pwsh
+param(
+ [ValidateSet('ws', 'admin', 'all')]
+ [string]$Scenario = 'all',
+ [string]$ArtifactRoot = 'artifacts',
+ [string]$WsUrl = $env:WS_URL,
+ [string]$AdminBaseUrl = $env:ADMIN_BASE_URL,
+ [string]$AdminSessionCookie = $env:ADMIN_SESSION_COOKIE
+)
+
+$ErrorActionPreference = 'Stop'
+
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$repoRoot = Resolve-Path (Join-Path $scriptDir '..\..')
+$wsScenarioPath = Join-Path $scriptDir 'ws-chat.yml'
+$wsSummaryScriptPath = Join-Path $scriptDir 'summarize-artillery.ps1'
+$adminScriptPath = Join-Path $scriptDir 'admin-api.js'
+$adminSummaryScriptPath = Join-Path $scriptDir 'summarize-k6.ps1'
+
+if ([System.IO.Path]::IsPathRooted($ArtifactRoot)) {
+ $artifactBase = $ArtifactRoot
+} else {
+ $artifactBase = Join-Path $repoRoot $ArtifactRoot
+}
+
+if ($Scenario -in @('ws', 'all')) {
+ if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
+ throw 'npx is required for ws scenario but was not found in PATH.'
+ }
+}
+
+if ($Scenario -in @('admin', 'all')) {
+ if (-not (Get-Command k6 -ErrorAction SilentlyContinue)) {
+ throw 'k6 is required for admin scenario but was not found in PATH.'
+ }
+}
+
+$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss'
+$artifactDir = Join-Path $artifactBase $timestamp
+New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
+
+function Invoke-WebSocketLoadTest {
+ param(
+ [string]$TargetWsUrl,
+ [string]$OutputPath
+ )
+
+ if ([string]::IsNullOrWhiteSpace($TargetWsUrl)) {
+ throw 'WS_URL is required for ws scenario. Pass -WsUrl or set $env:WS_URL.'
+ }
+
+ $env:WS_URL = $TargetWsUrl
+ & npx artillery run $wsScenarioPath --output $OutputPath
+}
+
+function Invoke-AdminApiLoadTest {
+ param(
+ [string]$BaseUrl,
+ [string]$SessionCookie,
+ [string]$SummaryPath,
+ [string]$OutputPath
+ )
+
+ if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
+ throw 'ADMIN_BASE_URL is required for admin scenario. Pass -AdminBaseUrl or set $env:ADMIN_BASE_URL.'
+ }
+ if ([string]::IsNullOrWhiteSpace($SessionCookie)) {
+ throw 'ADMIN_SESSION_COOKIE is required for admin scenario. Pass -AdminSessionCookie or set $env:ADMIN_SESSION_COOKIE.'
+ }
+
+ $env:ADMIN_BASE_URL = $BaseUrl
+ $env:ADMIN_SESSION_COOKIE = $SessionCookie
+ & k6 run --summary-export $SummaryPath $adminScriptPath | Tee-Object -FilePath $OutputPath
+}
+
+if ($Scenario -in @('ws', 'all')) {
+ if (-not (Test-Path $wsSummaryScriptPath)) {
+ throw "Missing summary helper: $wsSummaryScriptPath"
+ }
+
+ $wsOutput = Join-Path $artifactDir 'ws-chat-report.json'
+ $wsSummary = Join-Path $artifactDir 'ws-chat-summary.txt'
+ Invoke-WebSocketLoadTest -TargetWsUrl $WsUrl -OutputPath $wsOutput
+ & pwsh -File $wsSummaryScriptPath -ReportFile $wsOutput | Tee-Object -FilePath $wsSummary
+}
+
+if ($Scenario -in @('admin', 'all')) {
+ if (-not (Test-Path $adminSummaryScriptPath)) {
+ throw "Missing summary helper: $adminSummaryScriptPath"
+ }
+
+ $adminSummary = Join-Path $artifactDir 'admin-api-summary.json'
+ $adminOutput = Join-Path $artifactDir 'admin-api-output.txt'
+ $adminEndpointSummary = Join-Path $artifactDir 'admin-api-endpoint-summary.txt'
+ Invoke-AdminApiLoadTest -BaseUrl $AdminBaseUrl -SessionCookie $AdminSessionCookie -SummaryPath $adminSummary -OutputPath $adminOutput
+ & pwsh -File $adminSummaryScriptPath -SummaryFile $adminSummary | Tee-Object -FilePath $adminEndpointSummary
+}
+
Write-Host "Artifacts written to $artifactDir"
\ No newline at end of file
diff --git a/scripts/loadtest/run-loadtests.sh b/scripts/loadtest/run-loadtests.sh
new file mode 100644
index 0000000..3d9e5ad
--- /dev/null
+++ b/scripts/loadtest/run-loadtests.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# run-loadtests.sh — Run an artillery (ws) and/or k6 (admin) load-test scenario
+# and write outputs into a dated artifacts folder.
+# Mirrors scripts/loadtest/run-loadtests.ps1.
+#
+# Usage:
+# ./scripts/loadtest/run-loadtests.sh -s ws -w 'wss://...'
+# ./scripts/loadtest/run-loadtests.sh -s admin -a 'https://...' -c 'session=...'
+# ./scripts/loadtest/run-loadtests.sh -s all -w 'wss://...' -a 'https://...' -c 'session=...'
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "$script_dir/../.." && pwd)"
+
+SCENARIO="all"
+ARTIFACT_ROOT="artifacts"
+WS_URL="${WS_URL:-}"
+ADMIN_BASE_URL="${ADMIN_BASE_URL:-}"
+ADMIN_SESSION_COOKIE="${ADMIN_SESSION_COOKIE:-}"
+
+usage() {
+ echo "Usage: $0 [-s ws|admin|all] [-w ws_url] [-a admin_base_url] [-c admin_session_cookie] [-r artifact_root]" >&2
+ exit 2
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -s) SCENARIO="$2"; shift 2;;
+ -w) WS_URL="$2"; shift 2;;
+ -a) ADMIN_BASE_URL="$2"; shift 2;;
+ -c) ADMIN_SESSION_COOKIE="$2"; shift 2;;
+ -r) ARTIFACT_ROOT="$2"; shift 2;;
+ -h|--help) usage;;
+ *) echo "Unknown option: $1" >&2; usage;;
+ esac
+done
+
+case "$SCENARIO" in ws|admin|all) ;; *) echo "Scenario must be ws|admin|all" >&2; exit 2;; esac
+
+if [[ "$SCENARIO" == ws || "$SCENARIO" == all ]] && ! command -v npx >/dev/null 2>&1; then
+ echo "npx is required for ws scenario but was not found in PATH." >&2; exit 1
+fi
+if [[ "$SCENARIO" == admin || "$SCENARIO" == all ]] && ! command -v k6 >/dev/null 2>&1; then
+ echo "k6 is required for admin scenario but was not found in PATH." >&2; exit 1
+fi
+
+if [[ "$ARTIFACT_ROOT" = /* ]]; then artifact_base="$ARTIFACT_ROOT"; else artifact_base="$repo_root/$ARTIFACT_ROOT"; fi
+artifact_dir="$artifact_base/$(date +%Y-%m-%d_%H%M%S)"
+mkdir -p "$artifact_dir"
+
+if [[ "$SCENARIO" == ws || "$SCENARIO" == all ]]; then
+ [[ -n "$WS_URL" ]] || { echo "WS_URL is required for ws scenario. Pass -w or set \$WS_URL." >&2; exit 1; }
+ ws_out="$artifact_dir/ws-chat-report.json"
+ ws_sum="$artifact_dir/ws-chat-summary.txt"
+ echo "== Running artillery ws scenario =="
+ WS_URL="$WS_URL" npx artillery run "$script_dir/ws-chat.yml" --output "$ws_out"
+ "$script_dir/summarize-artillery.sh" "$ws_out" | tee "$ws_sum"
+fi
+
+if [[ "$SCENARIO" == admin || "$SCENARIO" == all ]]; then
+ [[ -n "$ADMIN_BASE_URL" ]] || { echo "ADMIN_BASE_URL is required for admin scenario. Pass -a or set \$ADMIN_BASE_URL." >&2; exit 1; }
+ [[ -n "$ADMIN_SESSION_COOKIE" ]] || { echo "ADMIN_SESSION_COOKIE is required for admin scenario. Pass -c or set \$ADMIN_SESSION_COOKIE." >&2; exit 1; }
+ admin_sum="$artifact_dir/admin-api-summary.json"
+ admin_out="$artifact_dir/admin-api-output.txt"
+ admin_ep_sum="$artifact_dir/admin-api-endpoint-summary.txt"
+ echo "== Running k6 admin scenario =="
+ ADMIN_BASE_URL="$ADMIN_BASE_URL" ADMIN_SESSION_COOKIE="$ADMIN_SESSION_COOKIE" \
+ k6 run --summary-export "$admin_sum" "$script_dir/admin-api.js" | tee "$admin_out"
+ "$script_dir/summarize-k6.sh" "$admin_sum" | tee "$admin_ep_sum"
+fi
+
+echo "Artifacts written to $artifact_dir"
diff --git a/scripts/loadtest/summarize-artillery.ps1 b/scripts/loadtest/summarize-artillery.ps1
index 6422a7d..3c02066 100644
--- a/scripts/loadtest/summarize-artillery.ps1
+++ b/scripts/loadtest/summarize-artillery.ps1
@@ -1,115 +1,115 @@
-#!/usr/bin/env pwsh
-param(
- [Parameter(Mandatory = $true)]
- [string]$ReportFile
-)
-
-$ErrorActionPreference = 'Stop'
-
-if (-not (Test-Path $ReportFile)) {
- throw "Report file not found: $ReportFile"
-}
-
-$report = Get-Content -Raw -Path $ReportFile | ConvertFrom-Json -Depth 100
-$aggregate = $report.aggregate
-
-if (-not $aggregate) {
- throw 'Artillery report is missing aggregate data.'
-}
-
-function Get-PropertyValue {
- param(
- [Parameter(Mandatory = $true)]
- [object]$Object,
- [Parameter(Mandatory = $true)]
- [string]$Name
- )
-
- if (-not $Object) {
- return $null
- }
-
- $property = $Object.PSObject.Properties[$Name]
- if ($property) {
- return $property.Value
- }
-
- return $null
-}
-
-function Format-StatLine {
- param(
- [string]$Prefix,
- [object]$Stats
- )
-
- if (-not $Stats) {
- return $null
- }
-
- $parts = @()
- foreach ($name in @('count', 'min', 'max', 'mean', 'median', 'p95', 'p99')) {
- $value = Get-PropertyValue -Object $Stats -Name $name
- if ($null -ne $value) {
- $parts += "$name=$value"
- }
- }
-
- if ($parts.Count -eq 0) {
- return $null
- }
-
- return "$Prefix: $($parts -join ', ')"
-}
-
-$counters = Get-PropertyValue -Object $aggregate -Name 'counters'
-$rates = Get-PropertyValue -Object $aggregate -Name 'rates'
-$summaries = Get-PropertyValue -Object $aggregate -Name 'summaries'
-
-$created = [double](Get-PropertyValue -Object $counters -Name 'vusers.created' ?? 0)
-$failed = [double](Get-PropertyValue -Object $counters -Name 'vusers.failed' ?? 0)
-$completed = [double](Get-PropertyValue -Object $counters -Name 'vusers.completed' ?? 0)
-$errorRate = if ($created -gt 0) { [math]::Round(($failed / $created) * 100, 2) } else { 0 }
-
-Write-Output '=== Artillery WebSocket Summary ==='
-Write-Output "source: $ReportFile"
-
-$timestamp = Get-PropertyValue -Object $report -Name 'timestamp'
-if ($timestamp) {
- Write-Output "timestamp: $timestamp"
-}
-
-if ($null -ne $created) {
- Write-Output "vusers.created: $created"
-}
-if ($null -ne $completed) {
- Write-Output "vusers.completed: $completed"
-}
-if ($null -ne $failed) {
- Write-Output "vusers.failed: $failed"
-}
-Write-Output "vusers.failure_rate_percent: $errorRate"
-
-if ($counters) {
- foreach ($metric in @('ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time')) {
- $value = Get-PropertyValue -Object $counters -Name $metric
- if ($null -ne $value) {
- Write-Output "$metric: $value"
- }
- }
-}
-
-if ($rates) {
- foreach ($property in $rates.PSObject.Properties) {
- Write-Output "rate.$($property.Name): $($property.Value)"
- }
-}
-
-if ($summaries) {
- foreach ($property in $summaries.PSObject.Properties) {
- $line = Format-StatLine -Prefix "summary.$($property.Name)" -Stats $property.Value
- if ($line) {
- Write-Output $line
- }
- }
-}
+#!/usr/bin/env pwsh
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$ReportFile
+)
+
+$ErrorActionPreference = 'Stop'
+
+if (-not (Test-Path $ReportFile)) {
+ throw "Report file not found: $ReportFile"
+}
+
+$report = Get-Content -Raw -Path $ReportFile | ConvertFrom-Json -Depth 100
+$aggregate = $report.aggregate
+
+if (-not $aggregate) {
+ throw 'Artillery report is missing aggregate data.'
+}
+
+function Get-PropertyValue {
+ param(
+ [Parameter(Mandatory = $true)]
+ [object]$Object,
+ [Parameter(Mandatory = $true)]
+ [string]$Name
+ )
+
+ if (-not $Object) {
+ return $null
+ }
+
+ $property = $Object.PSObject.Properties[$Name]
+ if ($property) {
+ return $property.Value
+ }
+
+ return $null
+}
+
+function Format-StatLine {
+ param(
+ [string]$Prefix,
+ [object]$Stats
+ )
+
+ if (-not $Stats) {
+ return $null
+ }
+
+ $parts = @()
+ foreach ($name in @('count', 'min', 'max', 'mean', 'median', 'p95', 'p99')) {
+ $value = Get-PropertyValue -Object $Stats -Name $name
+ if ($null -ne $value) {
+ $parts += "$name=$value"
+ }
+ }
+
+ if ($parts.Count -eq 0) {
+ return $null
+ }
+
+ return "$Prefix: $($parts -join ', ')"
+}
+
+$counters = Get-PropertyValue -Object $aggregate -Name 'counters'
+$rates = Get-PropertyValue -Object $aggregate -Name 'rates'
+$summaries = Get-PropertyValue -Object $aggregate -Name 'summaries'
+
+$created = [double](Get-PropertyValue -Object $counters -Name 'vusers.created' ?? 0)
+$failed = [double](Get-PropertyValue -Object $counters -Name 'vusers.failed' ?? 0)
+$completed = [double](Get-PropertyValue -Object $counters -Name 'vusers.completed' ?? 0)
+$errorRate = if ($created -gt 0) { [math]::Round(($failed / $created) * 100, 2) } else { 0 }
+
+Write-Output '=== Artillery WebSocket Summary ==='
+Write-Output "source: $ReportFile"
+
+$timestamp = Get-PropertyValue -Object $report -Name 'timestamp'
+if ($timestamp) {
+ Write-Output "timestamp: $timestamp"
+}
+
+if ($null -ne $created) {
+ Write-Output "vusers.created: $created"
+}
+if ($null -ne $completed) {
+ Write-Output "vusers.completed: $completed"
+}
+if ($null -ne $failed) {
+ Write-Output "vusers.failed: $failed"
+}
+Write-Output "vusers.failure_rate_percent: $errorRate"
+
+if ($counters) {
+ foreach ($metric in @('ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time')) {
+ $value = Get-PropertyValue -Object $counters -Name $metric
+ if ($null -ne $value) {
+ Write-Output "$metric: $value"
+ }
+ }
+}
+
+if ($rates) {
+ foreach ($property in $rates.PSObject.Properties) {
+ Write-Output "rate.$($property.Name): $($property.Value)"
+ }
+}
+
+if ($summaries) {
+ foreach ($property in $summaries.PSObject.Properties) {
+ $line = Format-StatLine -Prefix "summary.$($property.Name)" -Stats $property.Value
+ if ($line) {
+ Write-Output $line
+ }
+ }
+}
diff --git a/scripts/loadtest/summarize-artillery.sh b/scripts/loadtest/summarize-artillery.sh
new file mode 100644
index 0000000..ea006ff
--- /dev/null
+++ b/scripts/loadtest/summarize-artillery.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# summarize-artillery.sh — Extract counters, rates and percentile stats from an
+# Artillery JSON report. Mirrors scripts/loadtest/summarize-artillery.ps1.
+# Usage: ./scripts/loadtest/summarize-artillery.sh
+set -euo pipefail
+
+if [[ $# -lt 1 ]]; then
+ echo "Usage: $0 " >&2
+ exit 2
+fi
+report_file="$1"
+[[ -f "$report_file" ]] || { echo "Report file not found: $report_file" >&2; exit 1; }
+
+REPORT_FILE="$report_file" node <<'EOF'
+const fs = require('fs');
+const report = JSON.parse(fs.readFileSync(process.env.REPORT_FILE, 'utf8'));
+const aggregate = report.aggregate;
+if (!aggregate) throw new Error('Artillery report is missing aggregate data.');
+
+const get = (obj, name) => (obj && obj[name] !== undefined) ? obj[name] : undefined;
+const counters = aggregate.counters || {};
+const rates = aggregate.rates || {};
+const summaries = aggregate.summaries || {};
+
+const created = Number(get(counters, 'vusers.created') || 0);
+const failed = Number(get(counters, 'vusers.failed') || 0);
+const completed = Number(get(counters, 'vusers.completed') || 0);
+const errorRate = created > 0 ? +((failed / created) * 100).toFixed(2) : 0;
+
+console.log('=== Artillery WebSocket Summary ===');
+console.log(`source: ${process.env.REPORT_FILE}`);
+if (report.timestamp) console.log(`timestamp: ${report.timestamp}`);
+console.log(`vusers.created: ${created}`);
+console.log(`vusers.completed: ${completed}`);
+console.log(`vusers.failed: ${failed}`);
+console.log(`vusers.failure_rate_percent: ${errorRate}`);
+
+for (const metric of ['ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time']) {
+ const v = get(counters, metric);
+ if (v !== undefined) console.log(`${metric}: ${v}`);
+}
+for (const [k, v] of Object.entries(rates || {})) console.log(`rate.${k}: ${v}`);
+
+const fmt = (prefix, stats) => {
+ if (!stats) return;
+ const parts = [];
+ for (const name of ['count', 'min', 'max', 'mean', 'median', 'p95', 'p99']) {
+ const v = get(stats, name);
+ if (v !== undefined) parts.push(`${name}=${v}`);
+ }
+ if (parts.length) console.log(`${prefix}: ${parts.join(', ')}`);
+};
+for (const [k, v] of Object.entries(summaries)) fmt(`summary.${k}`, v);
+EOF
diff --git a/scripts/loadtest/summarize-k6.ps1 b/scripts/loadtest/summarize-k6.ps1
index 726233c..8a21df0 100644
--- a/scripts/loadtest/summarize-k6.ps1
+++ b/scripts/loadtest/summarize-k6.ps1
@@ -1,68 +1,68 @@
-#!/usr/bin/env pwsh
-param(
- [Parameter(Mandatory = $true)]
- [string]$SummaryFile
-)
-
-$ErrorActionPreference = 'Stop'
-
-if (-not (Test-Path $SummaryFile)) {
- throw "Summary file not found: $SummaryFile"
-}
-
-$summary = Get-Content -Raw -Path $SummaryFile | ConvertFrom-Json
-$metrics = $summary.metrics
-
-if (-not $metrics) {
- throw 'Invalid k6 summary file: missing metrics object.'
-}
-
-$endpoints = @('overview', 'analytics', 'chat_log', 'users')
-
-function Get-MetricValue {
- param(
- [object]$Metric,
- [string]$Key
- )
-
- if (-not $Metric) {
- return $null
- }
- if (-not $Metric.values) {
- return $null
- }
-
- $value = $Metric.values.$Key
- if ($null -ne $value) {
- return [double]$value
- }
- return $null
-}
-
-$globalDuration = Get-MetricValue -Metric $metrics.http_req_duration -Key 'p(95)'
-$globalFailRate = Get-MetricValue -Metric $metrics.http_req_failed -Key 'rate'
-
-Write-Output "k6 Summary: $SummaryFile"
-if ($null -ne $globalDuration) {
- Write-Output ("Global p95 duration (ms): {0:N2}" -f $globalDuration)
-}
-if ($null -ne $globalFailRate) {
- Write-Output ("Global failure rate: {0:P2}" -f $globalFailRate)
-}
-Write-Output ''
-
-$rows = foreach ($endpoint in $endpoints) {
- $durationMetricName = "http_req_duration{endpoint:$endpoint}"
- $failMetricName = "http_req_failed{endpoint:$endpoint}"
-
- $durationP95 = Get-MetricValue -Metric $metrics.$durationMetricName -Key 'p(95)'
- $failRate = Get-MetricValue -Metric $metrics.$failMetricName -Key 'rate'
-
- [PSCustomObject]@{
- Endpoint = $endpoint
- P95Ms = if ($null -ne $durationP95) { [math]::Round($durationP95, 2) } else { $null }
- FailurePct = if ($null -ne $failRate) { [math]::Round($failRate * 100, 4) } else { $null }
- }
-}
-
-$rows | Format-Table -AutoSize | Out-String | Write-Output
+#!/usr/bin/env pwsh
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$SummaryFile
+)
+
+$ErrorActionPreference = 'Stop'
+
+if (-not (Test-Path $SummaryFile)) {
+ throw "Summary file not found: $SummaryFile"
+}
+
+$summary = Get-Content -Raw -Path $SummaryFile | ConvertFrom-Json
+$metrics = $summary.metrics
+
+if (-not $metrics) {
+ throw 'Invalid k6 summary file: missing metrics object.'
+}
+
+$endpoints = @('overview', 'analytics', 'chat_log', 'users')
+
+function Get-MetricValue {
+ param(
+ [object]$Metric,
+ [string]$Key
+ )
+
+ if (-not $Metric) {
+ return $null
+ }
+ if (-not $Metric.values) {
+ return $null
+ }
+
+ $value = $Metric.values.$Key
+ if ($null -ne $value) {
+ return [double]$value
+ }
+ return $null
+}
+
+$globalDuration = Get-MetricValue -Metric $metrics.http_req_duration -Key 'p(95)'
+$globalFailRate = Get-MetricValue -Metric $metrics.http_req_failed -Key 'rate'
+
+Write-Output "k6 Summary: $SummaryFile"
+if ($null -ne $globalDuration) {
+ Write-Output ("Global p95 duration (ms): {0:N2}" -f $globalDuration)
+}
+if ($null -ne $globalFailRate) {
+ Write-Output ("Global failure rate: {0:P2}" -f $globalFailRate)
+}
+Write-Output ''
+
+$rows = foreach ($endpoint in $endpoints) {
+ $durationMetricName = "http_req_duration{endpoint:$endpoint}"
+ $failMetricName = "http_req_failed{endpoint:$endpoint}"
+
+ $durationP95 = Get-MetricValue -Metric $metrics.$durationMetricName -Key 'p(95)'
+ $failRate = Get-MetricValue -Metric $metrics.$failMetricName -Key 'rate'
+
+ [PSCustomObject]@{
+ Endpoint = $endpoint
+ P95Ms = if ($null -ne $durationP95) { [math]::Round($durationP95, 2) } else { $null }
+ FailurePct = if ($null -ne $failRate) { [math]::Round($failRate * 100, 4) } else { $null }
+ }
+}
+
+$rows | Format-Table -AutoSize | Out-String | Write-Output
diff --git a/scripts/loadtest/summarize-k6.sh b/scripts/loadtest/summarize-k6.sh
new file mode 100644
index 0000000..0375831
--- /dev/null
+++ b/scripts/loadtest/summarize-k6.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# summarize-k6.sh — Extract per-endpoint p95 and failure rates from a k6 JSON
+# summary export. Mirrors scripts/loadtest/summarize-k6.ps1.
+# Usage: ./scripts/loadtest/summarize-k6.sh
+set -euo pipefail
+
+if [[ $# -lt 1 ]]; then
+ echo "Usage: $0 " >&2
+ exit 2
+fi
+summary_file="$1"
+[[ -f "$summary_file" ]] || { echo "Summary file not found: $summary_file" >&2; exit 1; }
+
+SUMMARY_FILE="$summary_file" node <<'EOF'
+const fs = require('fs');
+const summary = JSON.parse(fs.readFileSync(process.env.SUMMARY_FILE, 'utf8'));
+const metrics = summary.metrics;
+if (!metrics) throw new Error('Invalid k6 summary file: missing metrics object.');
+
+const val = (m, key) => (m && m.values && m.values[key] !== undefined) ? Number(m.values[key]) : undefined;
+const globalDuration = val(metrics.http_req_duration, 'p(95)');
+const globalFailRate = val(metrics.http_req_failed, 'rate');
+
+console.log(`k6 Summary: ${process.env.SUMMARY_FILE}`);
+if (globalDuration !== undefined) console.log(`Global p95 duration (ms): ${globalDuration.toFixed(2)}`);
+if (globalFailRate !== undefined) console.log(`Global failure rate: ${(globalFailRate * 100).toFixed(2)}%`);
+console.log('');
+
+const endpoints = ['overview', 'analytics', 'chat_log', 'users'];
+const rows = endpoints.map((ep) => {
+ const dP95 = val(metrics[`http_req_duration{endpoint:${ep}}`], 'p(95)');
+ const fRate = val(metrics[`http_req_failed{endpoint:${ep}}`], 'rate');
+ return {
+ Endpoint: ep,
+ P95Ms: dP95 !== undefined ? dP95.toFixed(2) : 'n/a',
+ FailurePct: fRate !== undefined ? (fRate * 100).toFixed(4) : 'n/a',
+ };
+});
+
+const pad = (s, n) => String(s).padEnd(n);
+console.log(pad('ENDPOINT', 12) + pad('P95_MS', 10) + 'FAILURE_PCT');
+for (const r of rows) console.log(pad(r.Endpoint, 12) + pad(r.P95Ms, 10) + r.FailurePct);
+EOF
diff --git a/scripts/loadtest/ws-chat.yml b/scripts/loadtest/ws-chat.yml
index 8044df8..e1657d9 100644
--- a/scripts/loadtest/ws-chat.yml
+++ b/scripts/loadtest/ws-chat.yml
@@ -1,28 +1,28 @@
-config:
- target: "{{ $processEnvironment.WS_URL }}"
- phases:
- - duration: 60
- arrivalRate: 10
- name: warmup-10
- - duration: 120
- arrivalRate: 100
- name: sustain-100
- - duration: 180
- arrivalRate: 500
- name: sustain-500
- - duration: 240
- arrivalRate: 1000
- name: sustain-1000
- engines:
- ws: {}
-scenarios:
- - engine: ws
- name: chat-presence-and-send
- flow:
- - think: 1
- - send:
- text: '{"type":"ping","text":"loadtest"}'
- - think: 1
- - send:
- text: '{"type":"ping","text":"loadtest-2"}'
- - think: 1
+config:
+ target: "{{ $processEnvironment.WS_URL }}"
+ phases:
+ - duration: 60
+ arrivalRate: 10
+ name: warmup-10
+ - duration: 120
+ arrivalRate: 100
+ name: sustain-100
+ - duration: 180
+ arrivalRate: 500
+ name: sustain-500
+ - duration: 240
+ arrivalRate: 1000
+ name: sustain-1000
+ engines:
+ ws: {}
+scenarios:
+ - engine: ws
+ name: chat-presence-and-send
+ flow:
+ - think: 1
+ - send:
+ text: '{"type":"ping","text":"loadtest"}'
+ - think: 1
+ - send:
+ text: '{"type":"ping","text":"loadtest-2"}'
+ - think: 1