Compare commits
36
Commits
e15d89edfe
..
1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8cf853842 | ||
|
|
8c5e70959b | ||
|
|
5f1f796a20 | ||
|
|
11d48c8e0d | ||
|
|
242fc8da67 | ||
|
|
33b6296c5e | ||
|
|
9f60087a4c | ||
|
|
474da7418e | ||
|
|
d267173e30 | ||
|
|
e7bff8b9c0 | ||
|
|
9a0511b36b | ||
|
|
c2f9f9ed62 | ||
|
|
bde3dc3a46 | ||
|
|
21a70dcf0f | ||
|
|
4fe09a0a3d | ||
|
|
85454610f4 | ||
|
|
5585a6ecc3 | ||
|
|
53a0c3c825 | ||
|
|
9e56ff7859 | ||
|
|
9ac1a8b79d | ||
|
|
6950bba85d | ||
|
|
06b277e9c1 | ||
|
|
1cc44282bc | ||
|
|
b6705e6dbd | ||
|
|
cef002862e | ||
|
|
4eb88a4594 | ||
|
|
9cf3956c28 | ||
|
|
a15a9f2df0 | ||
|
|
76397d52d7 | ||
|
|
70a5159f81 | ||
|
|
568112b85a | ||
|
|
02ff53229c | ||
|
|
68a4d35334 | ||
|
|
7afab6658b | ||
|
|
121fdbacdc | ||
|
|
06ab5b4655 |
No files matched your search
+12
-4
@@ -8,6 +8,8 @@ R2_SECRET_ACCESS_KEY=your-r2-secret-key
|
|||||||
R2_BUCKET_NAME=linearvoid-media
|
R2_BUCKET_NAME=linearvoid-media
|
||||||
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
||||||
R2_REGION=auto
|
R2_REGION=auto
|
||||||
|
R2_KEEP_RUNS=2
|
||||||
|
R2_MAX_RETENTION_BYTES=10737418240
|
||||||
|
|
||||||
# Cloudflare (for custom domain, cache rules, Workers if used)
|
# Cloudflare (for custom domain, cache rules, Workers if used)
|
||||||
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
||||||
@@ -47,14 +49,20 @@ DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
|
|||||||
REDIS_URL=redis://redis:6379/0
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
# Service Ports (for local docker-compose)
|
# Service Ports (for local docker-compose)
|
||||||
PLAYOUT_PORT=8080
|
# Coolify env: 80/443 (Traefik proxy), 8000 (dashboard/Portainer), 8080 taken.
|
||||||
|
PLAYOUT_PORT=8081
|
||||||
CHAT_PORT=3000
|
CHAT_PORT=3000
|
||||||
NGINX_PORT=80
|
NGINX_PORT=8082
|
||||||
|
|
||||||
# Playout configuration
|
# Playout configuration
|
||||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||||
HLS_SEGMENT_DURATION=6
|
HLS_SEGMENT_DURATION=2
|
||||||
HLS_WINDOW_SIZE=24
|
HLS_WINDOW_SIZE=12
|
||||||
|
|
||||||
|
# Shared runtime config (written by chat admin via /api/admin/config, read by playout).
|
||||||
|
# Both resolve to the same host file through the ./data/playlist volume.
|
||||||
|
CONFIG_PATH=/app/playlist/config.json
|
||||||
|
PLAYOUT_CONFIG_PATH=/app/playlist/config.json
|
||||||
|
|
||||||
# Logging / Monitoring
|
# Logging / Monitoring
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ jobs:
|
|||||||
|
|
||||||
# Playout
|
# Playout
|
||||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||||
HLS_SEGMENT_DURATION=6
|
HLS_SEGMENT_DURATION=2
|
||||||
HLS_WINDOW_SIZE=24
|
HLS_WINDOW_SIZE=12
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# Cloudflare R2 (required for HLS segment sync)
|
# Cloudflare R2 (required for HLS segment sync)
|
||||||
@@ -69,7 +69,8 @@ jobs:
|
|||||||
ADMIN_SESSION_TTL_HOURS=12
|
ADMIN_SESSION_TTL_HOURS=12
|
||||||
|
|
||||||
# Nginx
|
# Nginx
|
||||||
NGINX_PORT=80
|
# Host port 80/443 (Coolify Traefik) and 8080 are taken; nginx publishes on 8082.
|
||||||
|
NGINX_PORT=8082
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Seed Prometheus config on remote host
|
- name: Seed Prometheus config on remote host
|
||||||
@@ -245,7 +246,7 @@ jobs:
|
|||||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||||
run: |
|
run: |
|
||||||
# Wait for services to stabilize
|
# Wait for services to stabilize
|
||||||
sleep 20
|
sleep 30
|
||||||
|
|
||||||
# Check chat health (via docker exec — IP-agnostic, works on any DOCKER_HOST)
|
# Check chat health (via docker exec — IP-agnostic, works on any DOCKER_HOST)
|
||||||
echo "Checking chat health..."
|
echo "Checking chat health..."
|
||||||
@@ -270,8 +271,8 @@ jobs:
|
|||||||
echo "Playout is healthy"
|
echo "Playout is healthy"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Playout not ready yet (attempt $i/6), waiting 5s..."
|
echo "Playout not ready yet (attempt $i/6), waiting 20s..."
|
||||||
sleep 5
|
sleep 20
|
||||||
done
|
done
|
||||||
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
||||||
echo "Playout failed to become healthy after 6 attempts"
|
echo "Playout failed to become healthy after 6 attempts"
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
name: Deploy to Cloudflare Pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: [CI]
|
|
||||||
types: [completed]
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: cd frontend && npm ci
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: cd frontend && npm run build
|
|
||||||
|
|
||||||
- name: Generate deployment manifest
|
|
||||||
run: |
|
|
||||||
cd frontend/dist
|
|
||||||
form_args="-F manifest={"
|
|
||||||
for file in $(find . -type f ! -name manifest.json | sed 's|^\./||'); do
|
|
||||||
hash=$(sha256sum "$file" | awk '{print $1}')
|
|
||||||
form_args="$form_args\"$file\":\"$hash\","
|
|
||||||
done
|
|
||||||
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
|
||||||
echo "Form arguments: $form_args"
|
|
||||||
echo "FORM_ARGS=$form_args" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Deploy to Cloudflare Pages
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
run: |
|
|
||||||
cd frontend/dist
|
|
||||||
form_args="$FORM_ARGS" # use form arguments generated in previous step
|
|
||||||
# Get short commit hash for deployment metadata
|
|
||||||
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
|
||||||
COMMIT_MSG=$(git log -1 --pretty=%B 2>/dev/null | head -1 || echo "Gitea Actions deploy")
|
|
||||||
|
|
||||||
curl -sS -X POST \
|
|
||||||
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/linearvoid/deployments" \
|
|
||||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
|
||||||
-F "commit_hash=${COMMIT_HASH}" \
|
|
||||||
-F "commit_message=${COMMIT_MSG}" \
|
|
||||||
-F "commit_dirty=false" \
|
|
||||||
$form_args
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Deploy to Cloudflare Workers
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
VITE_PUBLIC_APP_URL: ${{ secrets.VITE_PUBLIC_APP_URL }}
|
||||||
|
VITE_PUBLIC_WS_URL: ${{ secrets.VITE_PUBLIC_WS_URL }}
|
||||||
|
VITE_PUBLIC_HLS_URL: ${{ secrets.VITE_PUBLIC_HLS_URL }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_ROOM: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }}
|
||||||
|
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT: "50"
|
||||||
|
run: cd frontend && npm run build
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Workers
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npx wrangler deploy
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Deploy Media Worker to Cloudflare
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
# Production deploy: only on pushes to main (via CI success) or manual main runs.
|
||||||
|
if: ${{ (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd infrastructure/cloudflare && npm ci
|
||||||
|
|
||||||
|
- name: Test worker
|
||||||
|
run: cd infrastructure/cloudflare && npm test
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Workers (production)
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: |
|
||||||
|
cd infrastructure/cloudflare
|
||||||
|
npx wrangler deploy --env production
|
||||||
+19
-1
@@ -109,7 +109,7 @@
|
|||||||
- Total: 45 frontend tests passing (2 test files).
|
- 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 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`.
|
- Added `test:coverage` script to `package.json`.
|
||||||
- Fixed CI: bumped Node from 20 to 22 in `ci.yml` and `pages-deploy.yml` (jsdom@30 requires Node 22+). Added `engines` 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.
|
- Updated AGENTS.md: added Required tracking files table with 13 docs and update cadence.
|
||||||
- All Go vet, Go tests, frontend test:coverage passing.
|
- 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).
|
- CORS on media.linearvoid.tv remains the only streaming pipeline gap (Cloudflare R2 config, not code).
|
||||||
@@ -134,3 +134,21 @@
|
|||||||
- 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 `.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.
|
- 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.
|
- All playout tests pass (20 tests), `go vet` clean.
|
||||||
|
|
||||||
|
## 2026-08-13
|
||||||
|
|
||||||
|
- Completed all remaining enhancement tasks (only manual/infrastructure phases left):
|
||||||
|
- **Playout**: automatic media normalization on upload (`media.NormalizeVideo`, `*.normalized.mp4`, statuses `ready`/`needs_normalization`/`normalizing`/`normalization_failed`, `POST /api/admin/media/{id}/normalize`); R2 segment retention (`cleanupOldRuns`, keep last `R2_KEEP_RUNS` runs, `R2_MAX_RETENTION_BYTES` cap); seamless looping (playlist SHA-256 fingerprint + `-start_number`); single `start-playout.sh` entrypoint + `-validate` mode; loop/cleanup metrics.
|
||||||
|
- **Application configuration**: thread-safe JSON runtime config store (`backend/chat/src/config/runtime.go`) shared with playout via `/app/playlist/config.json`; `GET/PUT /api/admin/config`; admin UI config form.
|
||||||
|
- **Matomo analytics**: public `GET /api/matomo` + viewer pageview snippet in `index.html`.
|
||||||
|
- **Chat user accounts**: register/login/logout/session endpoints, bcrypt hashing, `linearvoid_user_session` cookie, viewer sign-in UI.
|
||||||
|
- **DM history & search**: `GET /api/dm/history` (paginated) + `GET /api/dm/search` + DM search UI.
|
||||||
|
- **Chat UI**: typing indicator, user-list toggle, media status badges, "Add to playlist" gated to `ready`.
|
||||||
|
- **Prometheus TSDB retention**: `--storage.tsdb.retention.time=30d` + `--storage.tsdb.retention.size=15GB` in both compose files; alert rule count corrected 8 → 10.
|
||||||
|
- Updated `docs/architecture/README.md` to reflect all `[Unreleased]` changes (normalization, looping, retention, runtime config, Matomo, user accounts, DM history/search, new API endpoints, module tables).
|
||||||
|
- Released **1.0.13**: promoted changelog, bumped version, tagged and pushed.
|
||||||
|
- Extended frontend test coverage: added `counter.test.ts`, reconnecting/typing/auth/websocket tests, admin failure + status-badge tests → 56 tests; statements 19.89% → 28.58% (CI coverage threshold passing).
|
||||||
|
- Fixed Go test data race in `TestHandleAdminMediaByID_Normalize` (waits for async normalization goroutine) + shared in-memory SQLite via `SetMaxOpenConns(1)`.
|
||||||
|
- Fixed viewer crash: `index.html` was missing the `#messages` chat container (stray `</section>`); restored structure + made `addMessage` defensive.
|
||||||
|
- Set `Doto` as the body font (`--font-body`) and added Google Fonts links to admin + login pages.
|
||||||
|
- Files changed: across `backend/chat`, `backend/playout`, `frontend`, `docs`, `docker-compose*.yml`, `.github/instructions/*`.
|
||||||
+57
-112
@@ -1,33 +1,67 @@
|
|||||||
# BACKLOG
|
# BACKLOG
|
||||||
|
|
||||||
## Remaining from 2026-08-11 (summary)
|
> All current work is planned in [TODO](./TODO.md) (2026-08-13). This file lists backlog items; new unplanned work is added here.
|
||||||
|
|
||||||
- **Test Fixtures**: media + playlist fixtures created; ffmpeg generation commands documented in test headers
|
## Setup phases remaining
|
||||||
- **User Frontend Tests**: video player, chat integration, responsive, accessibility, coverage
|
|
||||||
- **Admin Frontend Tests**: playlist UI, moderation UI, metrics dashboards, coverage
|
### Phase 5: complete manual Cloudflare and external-service setup
|
||||||
- **Cloudflare R2 Integration Test**: upload + cache header validation done (SyncToR2 with CacheControl + ContentType); edge cache hit/miss validation remains manual
|
|
||||||
- **Cloudflare Config**: Cloudflare Pages deploy workflow and setup docs created; manual dashboard setup (Tiered Cache, WAF, rate limiting, HTTPS/HSTS) remains pending; Pages project must be created in dashboard first.
|
- [ ] Apply Cloudflare Workers, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
|
||||||
- **External Services**: UptimeRobot monitors + alerts
|
- [ ] Cloudflare Workers: link custom domain and cut over frontend hosting
|
||||||
- **Production Deployment**: Hetzner provisioning, CD pipeline, DNS cutover, smoke test
|
- [ ] Enable Smart Tiered Cache; validate `Cf-Cache-Status: HIT` from upper tier
|
||||||
- **Scaling & Performance**: TTFB baseline and live benchmark runs still pending; loadtest scaffolding now includes Artillery/k6 helpers with auto summaries plus worksheet field mapping; chat p95, Redis saturation, and cache-hit threshold alerting are implemented
|
- [ ] 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
|
## Cloudflare Configuration
|
||||||
|
|
||||||
### DNS & Pages
|
### DNS & Workers
|
||||||
|
|
||||||
- [x] Configure DNS for linearvoid.tv apex and www redirect (manual)
|
- [ ] Cloudflare Workers: link custom domain (manual)
|
||||||
- [x] Configure media.linearvoid.tv for split-domain HLS delivery (manual)
|
- [ ] Create Cloudflare Worker and cut over frontend hosting (manual)
|
||||||
- [x] Configure Cloudflare Pages deploy workflow (`.gitea/workflows/pages-deploy.yml`) and setup docs (`docs/operations/cloudflare-pages-setup.md`, `docs/operations/gitea-cloudflare-pages.md`)
|
|
||||||
- [x] Create Cloudflare Pages project in dashboard (unavailable, created `linearvoid` via API)
|
|
||||||
- [ ] Cloudflare Pages: link custom domain (manual)
|
|
||||||
- [ ] Create Cloudflare Pages project and cut over frontend hosting (manual)
|
|
||||||
|
|
||||||
### Storage & Cache
|
|
||||||
|
|
||||||
- [x] Create R2 bucket (manual)
|
|
||||||
- [x] Configure Cache Rules: .ts/.m4s + .m3u8 (manual)
|
|
||||||
- [x] Implement Worker for immutable cache headers on segment paths
|
|
||||||
- [ ] CORS headers on HLS responses from media.linearvoid.tv (MEDIUM) — needs Cloudflare R2 CORS config
|
|
||||||
|
|
||||||
### Security & TLS
|
### Security & TLS
|
||||||
|
|
||||||
@@ -40,13 +74,6 @@
|
|||||||
|
|
||||||
## Monitoring & Observability
|
## Monitoring & Observability
|
||||||
|
|
||||||
### Prometheus & Grafana
|
|
||||||
|
|
||||||
- [x] Set up Prometheus with chat/playout/nginx/redis/postgres/node scrape targets
|
|
||||||
- [x] Add nginx-exporter, redis-exporter, postgres-exporter, node-exporter
|
|
||||||
- [x] Import Grafana app + operational dashboards
|
|
||||||
- [x] Define Prometheus alerting rules (8 rules: ChatDown, PlayoutDown, RedisDown, etc.)
|
|
||||||
|
|
||||||
### External Monitoring
|
### External Monitoring
|
||||||
|
|
||||||
- [ ] Set up UptimeRobot external monitoring
|
- [ ] Set up UptimeRobot external monitoring
|
||||||
@@ -54,76 +81,6 @@
|
|||||||
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
|
- [ ] 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
|
- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- [x] Implement admin cookie-session auth (HttpOnly, SameSite=Strict, Secure when TLS)
|
|
||||||
- [x] Implement server-side session expiration
|
|
||||||
- [x] Implement rate limiting for chat messages (Redis windowing)
|
|
||||||
- [x] Implement WebSocket connection rate limiting (5/IP/60s)
|
|
||||||
- [x] Implement admin login rate limiting (5/IP/5min)
|
|
||||||
- [ ] Enforce HTTPS/WSS at edge and origin
|
|
||||||
|
|
||||||
## Test Fixtures
|
|
||||||
|
|
||||||
- [x] Create test media fixtures in `backend/playout/testdata/`: valid H.264/AAC, invalid codec, invalid framerate
|
|
||||||
- [x] Create test playlist fixtures: `valid_playlist.txt`, `empty_playlist.txt`, `comments_only.txt`, `mixed_valid_invalid.txt`
|
|
||||||
- [x] Document ffmpeg fixture-generation commands in test file headers
|
|
||||||
|
|
||||||
## Frontend Tests
|
|
||||||
|
|
||||||
### User Frontend
|
|
||||||
|
|
||||||
- [x] Video player behavior (play, pause, seek, buffering, error handling) — HLS player startup, native/fallback, URL resolution, error recovery with retries/backoff/reconnecting overlay
|
|
||||||
- [x] Chat functionality (send/receive messages, presence, rate limiting, moderation) — send, receive, presence join/leave, DM flow, reconnect covered
|
|
||||||
- [x] State management and data flow
|
|
||||||
- [x] Responsive design and layout across different screen sizes — chat-collapsed toggle, CSS grid, now-playing aria
|
|
||||||
- [x] Accessibility testing (keyboard navigation, screen reader support) — video controls, aria-labels, aria-expanded, input labels, reconnecting overlay aria-live/role
|
|
||||||
- [x] Integration with backend APIs (mocking API responses for testing)
|
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
- [x] Playlist management UI (add/remove videos, reorder, normalize) — save/error handling, sortable rendering
|
|
||||||
- [ ] User moderation tools (mute/ban/report users, view moderation logs)
|
|
||||||
- [x] Metrics and monitoring dashboards (Prometheus/Grafana integration) — overview + analytics rendering
|
|
||||||
- [x] State management and data flow
|
|
||||||
- [x] Responsive design and layout across different screen sizes — tab content visibility, essential tabs
|
|
||||||
- [x] Accessibility testing (keyboard navigation, screen reader support) — nav aria-label, active tab aria-current, nav button focusability
|
|
||||||
- [x] Integration with backend APIs (mocking API responses for testing)
|
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
- [x] Upload test HLS segments and manifests to R2 bucket (SyncToR2 with mock, verified bucket/key/body)
|
|
||||||
- [x] Verify correct cache headers are applied (immutable segments via segmentCacheControl, short-lived manifests via manifestCacheControl)
|
|
||||||
- [ ] 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
|
|
||||||
- [x] Ensure integration test can run in CI/CD pipeline with proper mocking/stubbing of R2 API (putObject mock, no network calls)
|
|
||||||
- [x] Document test cases and expected outcomes
|
|
||||||
- [x] Document required stubs or mocks (putObject variable injection pattern)
|
|
||||||
|
|
||||||
## Streaming Pipeline
|
|
||||||
|
|
||||||
- [x] **Continuous R2 sync during playout (HIGH)**: fsnotify watcher uploads segments in real-time (~7s delay)
|
|
||||||
- [x] **Globally unique segment naming (HIGH)**: `r-{ISOTIME}/seg-{seq}.m4s` via run ID + ffmpeg monotonic sequence
|
|
||||||
- [x] **Atomic manifest publication (MEDIUM)**: Upload to `playlist.m3u8.tmp`, then `CopyObject` to final key
|
|
||||||
- [x] **Manifest Cache-Control alignment (LOW)**: `public, max-age=1, s-maxage=2, must-revalidate`
|
|
||||||
- [ ] **CORS headers on HLS responses (MEDIUM)**: Needs Cloudflare R2 bucket CORS config (infrastructure, not code)
|
|
||||||
- [x] **Player error recovery (MEDIUM)**: `hls.on(Hls.Events.ERROR)` handler with bounded retries (5x), exponential backoff, reconnecting overlay
|
|
||||||
- [x] **Health endpoint detail (LOW)**: Added active item, run ID, playlist revision, last segment seq, last R2 pub, consecutive failures
|
|
||||||
- [x] Source media contract + validation (H.264/AAC, 30fps, keyframe check)
|
|
||||||
- [x] fMP4 HLS packaging (6s segments, 24 window)
|
|
||||||
- [x] Slate fallback + recovery (`shouldEscalateToSlate`)
|
|
||||||
- [x] R2 cache headers (immutable segments, short-lived manifest)
|
|
||||||
- [x] Cloudflare Worker immutable `.m4s` headers
|
|
||||||
|
|
||||||
## External Services
|
## External Services
|
||||||
|
|
||||||
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||||
@@ -162,9 +119,6 @@
|
|||||||
|
|
||||||
### Load Testing
|
### Load Testing
|
||||||
|
|
||||||
- [x] Load-test scaffolding: `ws-chat.yml`, `admin-api.js`, `run-loadtests.ps1`, summarizers, README
|
|
||||||
- [x] Cache-hit ratio exporter (`scripts/cache-hit-ratio.js` + `.ps1`)
|
|
||||||
- [x] Saturation alerts: chat p95 latency, Redis memory usage, cache-hit ratio
|
|
||||||
- [ ] Execute WebSocket load tests with real infrastructure — ramp 10→100→500→1000 connections, measure p95 latency + errors
|
- [ ] 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
|
- [ ] Execute admin API benchmarks with real infrastructure — `wrk`/`k6` against `/api/admin/*`, target p95 <1s
|
||||||
|
|
||||||
@@ -180,12 +134,3 @@
|
|||||||
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
|
- [ ] 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 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
|
- [ ] Plan Phase 3 multi-origin resilience — research second Hetzner location, design warm standby + DNS failover + DB/Redis replication
|
||||||
|
|
||||||
## Feature List (Future Enhancements)
|
|
||||||
|
|
||||||
- [ ] Admin backend
|
|
||||||
- [ ] Analytics: implement a settings entry to set matomo url and page id
|
|
||||||
- [ ] User frontend
|
|
||||||
- [ ] Chat: add user registration and login flows
|
|
||||||
- [ ] Chat: private DM history and search
|
|
||||||
- [ ] Analytics: add javascript snippet to send pageview events to matomo
|
|
||||||
+51
-973
File diff suppressed because it is too large.
Load diff
@@ -1,80 +1,39 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
> Completed tasks moved to [DONE](./DONE.md). Full remaining task list in [BACKLOG](./BACKLOG.md).
|
> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
|
||||||
|
|
||||||
## Plan
|
## Playout / R2 manifest–segment layout mismatch
|
||||||
|
|
||||||
### Phase 1: unblock playout test fixtures and lock down the source-media contract
|
**Chosen approach: candidate (a)** — rewrite the live manifest so its media URIs point into the current run-scoped folder before the atomic R2 publish. Go change in `backend/playout/src/playlist.go`. The `linearvoid-media` Worker stays a faithful path→R2 mapper (no serve-time rewrite).
|
||||||
|
|
||||||
- [x] Create small compliant fixture media in `backend/playout/testdata/` for the validator and playout tests
|
### Remaining — Validation
|
||||||
- [x] Create invalid fixture media for codec, framerate, and audio rejection paths
|
|
||||||
- [x] Create playlist fixtures for empty, comment-only, mixed-validity, and valid playlists
|
|
||||||
- [x] Document the ffmpeg fixture-generation commands in the relevant test headers
|
|
||||||
|
|
||||||
### Phase 2: finish frontend coverage for the live viewer experience
|
- [ ] Run playout (local or test lab), then:
|
||||||
|
- `curl https://media.linearvoid.tv/hls/playlist.m3u8` → 200, short TTL.
|
||||||
|
- Every URI in the manifest resolves `200` with `Cache-Control: public, max-age=31536000, immutable` (via the `linearvoid-media` Worker).
|
||||||
|
- Confirm manifest refresh + `CF-Cache-Status` progression and no 404 on segment fetch across a run loop.
|
||||||
|
- [ ] Verify slate fallback still serves correctly after the rewrite change.
|
||||||
|
|
||||||
- [x] Add tests for HLS player startup, manifest resolution, and cross-origin/local fallback behavior
|
> Implementation (candidate a), Go unit tests, and documentation are complete — see [DONE.md](./DONE.md).
|
||||||
- [x] Add tests for chat send/receive, presence updates, private DM flow, and reconnect behavior
|
|
||||||
- [x] Add responsive-layout and accessibility checks for the viewer shell and chat sidebar
|
|
||||||
- [x] Add coverage reporting and enforce a minimum threshold in CI
|
|
||||||
|
|
||||||
### Phase 3: finish frontend coverage for the admin experience
|
## Frontend Tests
|
||||||
|
|
||||||
- [x] Add tests for playlist management, moderation flows, and dashboard rendering
|
### User Frontend
|
||||||
- [x] Add tests for API integration points with mocked backend responses
|
|
||||||
- [x] Add responsive-layout and accessibility checks for admin screens
|
|
||||||
- [x] Add coverage reporting and enforce a minimum threshold in CI
|
|
||||||
|
|
||||||
### Phase 4: validate the R2 and edge-delivery path
|
- [ ] 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
|
||||||
|
|
||||||
- [x] Add an integration test for upload and retrieval of HLS objects through the R2 path
|
### Admin Frontend
|
||||||
- [x] Verify segment and manifest cache headers, including immutable media and live manifest freshness
|
|
||||||
- [x] Validate edge cache hit/miss behavior with repeat requests
|
|
||||||
- [x] Document any required stubs or mocks so the test can run in CI (putObject mock pattern documented in test)
|
|
||||||
|
|
||||||
### Phase 5: complete manual Cloudflare and external-service setup
|
- [ ] 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
|
||||||
|
|
||||||
- [ ] Apply Cloudflare Pages, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
|
## Backend Integration Tests
|
||||||
- [ ] Create or verify UptimeRobot monitors for health and HLS manifest availability
|
|
||||||
- [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`
|
|
||||||
|
|
||||||
### Phase 6: rehearse production deployment end to end
|
### Cloudflare R2
|
||||||
|
|
||||||
- [ ] Provision the Hetzner host and prepare the Docker/runtime baseline
|
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
|
||||||
- [ ] Configure production secrets, remote deployment, and DNS cutover
|
- [ ] Validate cache hit/miss behavior and performance metrics
|
||||||
- [ ] Run health, HLS, chat, and admin smoke tests against production-like infrastructure
|
- [ ] Validate edge cache hit/miss behavior with repeat requests — script `scripts/validate-edge-cache.sh` created
|
||||||
- [ ] Verify backup creation, restore, and integrity checks on the production host
|
|
||||||
|
|
||||||
### Phase 7: baseline scaling and performance
|
|
||||||
|
|
||||||
- [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions
|
|
||||||
- [x] Prepare WebSocket load-test scaffolding and summary artifacts (`ws-chat.yml`, `run-loadtests.ps1`, `summarize-artillery.ps1`)
|
|
||||||
- [ ] Execute WebSocket throughput and chat-latency benchmarks under growing connection counts
|
|
||||||
- [x] Prepare Admin API benchmark scaffolding and endpoint summary artifacts (`admin-api.js`, `run-loadtests.ps1`, `summarize-k6.ps1`)
|
|
||||||
- [ ] Execute Admin API benchmarks and identify slow paths
|
|
||||||
- [x] Add saturation thresholds to monitoring (chat p95 latency + Redis memory usage)
|
|
||||||
- [x] Add cache-hit threshold alert operationalization to monitoring
|
|
||||||
|
|
||||||
## Status Update (2026-08-10)
|
|
||||||
|
|
||||||
- [x] Phase 1 (test fixtures) complete.
|
|
||||||
- [x] Phase 2 (viewer frontend): player startup, URL resolution, chat send/receive, reconnect, presence, DM flow, message rendering all covered (27 tests).
|
|
||||||
- [x] Phase 3 (admin frontend): dashboard rendering, API integration, playlist management all covered (31 tests total).
|
|
||||||
- [x] Phase 4 (R2 validation) partially complete: SyncToR2 cache headers + integration tests done; edge cache hit/miss remains manual.
|
|
||||||
- [ ] Phase 5 (Cloudflare): Pages deploy workflow (`.gitea/workflows/pages-deploy.yml`) created; Pages/Workers setup docs written; manual dashboard tasks remain.
|
|
||||||
- [ ] Phase 6 (production deployment) pending: Hetzner provisioning, secrets, DNS cutover, smoke tests, backup/restore.
|
|
||||||
- [ ] Phase 7: Loadtest scaffolding complete; `run-loadtests.ps1` auto-generates ws-chat-summary.txt and admin-api-endpoint-summary.txt; live benchmark execution pending.
|
|
||||||
- Version tagged 1.0.11; go vet clean.
|
|
||||||
|
|
||||||
## Streaming Pipeline Gaps (2026-08-10 audit → 2026-08-10 implemented)
|
|
||||||
|
|
||||||
STREAMING.md contract vs playlist.go/main.ts implementation. See Backlog for full details.
|
|
||||||
|
|
||||||
- [x] Continuous R2 sync during playout (HIGH) — implemented: fsnotify watcher uploads segments within ~7s
|
|
||||||
- [x] Globally unique segment naming with run ID (HIGH) — implemented: `r-{ISOTIME}/seg-{seq}.m4s`
|
|
||||||
- [x] Atomic manifest publication with temp key (MEDIUM) — implemented: upload to .tmp, then CopyObject
|
|
||||||
- [x] Manifest Cache-Control: add must-revalidate + s-maxage split (LOW) — implemented: `public, max-age=1, s-maxage=2, must-revalidate`
|
|
||||||
- [ ] CORS headers on HLS responses from media.linearvoid.tv (MEDIUM) — infrastructure, needs Cloudflare R2 CORS config
|
|
||||||
- [x] Player error recovery: retries, backoff, reconnecting state (MEDIUM) — implemented: hls.on(ERROR) handler with exponential backoff
|
|
||||||
- [x] Health endpoint: add active item, revision, run ID, segment info (LOW) — implemented: full STREAMING.md contract fields
|
|
||||||
- [x] Phase 4 edge cache validation — script `scripts/validate-edge-cache.sh` created
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"proseWrap": "preserve"
|
||||||
|
}
|
||||||
+59
-2
@@ -2,7 +2,64 @@
|
|||||||
|
|
||||||
<!-- markdownlint-disable MD024 -->
|
<!-- markdownlint-disable MD024 -->
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.1.0] - 2026-09-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Cloudflare media edge worker (`linearvoid-media`)**: new self-contained project under `infrastructure/cloudflare/` — an R2-bound Worker owning `media.linearvoid.tv` that enforces immutable segment caching (`public, max-age=31536000, immutable`) and short-TTL live `.m3u8`, with HLS CORS/OPTIONS, byte-Range, and a `/health` probe. Includes Vitest unit tests + Miniflare/workerd R2 integration tests and production deploy via `.gitea/workflows/workers-media-deploy.yml`. Replaces the ad-hoc `worker.js`.
|
||||||
|
- **Bash script ports**: Linux/macOS equivalents of the Windows ops/load-test scripts (`cache-hit-ratio`, `check-env`, `check-tokens`, loadtest run/summarize); `.ps1` files retained.
|
||||||
|
- **Transient presence flash**: join/leave events show as a 3-second `#chat-flash` instead of permanent chat rows.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Cloudflare Workers hosting**: frontend moved from Cloudflare Pages to the `linearvoid-frontend` static-assets Worker; docs now describe `linearvoid-frontend` (SPA) and `linearvoid-media` (edge HLS), correcting stale `worker.js` and R2 custom-domain wording.
|
||||||
|
- **Docs de-Windows'd & condensed**: bash-first guides for `SETUP`, `DEPLOYMENT`, `API`, `MONITORING`, `STREAMING`, `COOLIFY`, incident runbook, scaling playbook, architecture README, and Prometheus metrics inventory.
|
||||||
|
- **Compose port de-collision**: playout on host port `8081`, nginx moved to `8082` (`NGINX_PORT` default updated) to avoid Coolify-reserved ports.
|
||||||
|
- **Playout**: R2 retention now by run count + byte cap (`R2_KEEP_RUNS` default 2, `R2_MAX_RETENTION_BYTES` default 10 GiB, new gauges) replacing the 24h time-based cleanup; default `HLS_SEGMENT_DURATION` 2s / `HLS_WINDOW_SIZE` 12; deduplicated, concurrent startup validation.
|
||||||
|
- **Chat/UX**: 5s reconnect grace window (no join/leave spam); `system` messages no longer persisted; smaller `.message-text`; sidebar cleanup (removed DM search + Users toggle, login/register now a modal).
|
||||||
|
- **Auth**: viewer session cookie uses `SameSite=None; Secure` over HTTPS (cross-site SPA) and `Lax` over plain HTTP.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Run-scoped R2 manifest URIs (playout)**: `publishManifest` now rewrites bare relative media URIs to the current run folder (`r-{runID}/…`) via `rewriteManifestToRun` before the atomic `.tmp` → `CopyObject` publish, so the live `hls/playlist.m3u8` references resolve against the run-scoped segment keys on `media.linearvoid.tv`. Skipped in slate mode. (Live validation pending — see `TODO.md`.)
|
||||||
|
- **Cross-origin auth**: login/register API calls now prefix `VITE_PUBLIC_APP_URL` so the Workers-served SPA reaches the app origin.
|
||||||
|
- **Duplicate chat history**: `connectWebSocket` clears messages on each (re)connect and the initial connect waits for session resolution.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- WebSocket (`TestHub_Broadcast_SystemNotPersisted`, `TestHub_ReconnectGracePeriod`), handlers (`TestHandleAdminChatLog_ExcludesSystem`), frontend presence flash.
|
||||||
|
- Playout: `TestCleanupOldRuns(_SizeCap)`, `TestR2KeepRuns`, `TestR2MaxRetentionBytes`, `TestR2RunFromKey`, `TestValidateWorkersConfig`, `TestValidatePlaylist_Dedup`.
|
||||||
|
- Playout (run-scoped manifest rewrite): `TestRewriteManifestToRun`, `TestRewriteManifestToRun_AlreadyPrefixedUnchanged`, `TestRewriteManifestToRun_HandlesTSAndComments`, `TestRewriteManifestToRun_EmptyRunID`, `TestIsMediaURI`, `TestPublishManifest_RewritesToRunScopedURIs`, `TestPublishManifest_SlateNotRewritten`.
|
||||||
|
- Cloudflare media worker: Vitest unit (`cacheControlForPath`, `contentTypeForPath`, `parseByteRange`) + Miniflare R2 integration (immutable segment, short-TTL manifest, Range `206`, `404`, OPTIONS, method guard).
|
||||||
|
|
||||||
|
## [1.0.13] - 2026-08-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Automatic media normalization on upload**: Chat backend auto-transcodes non-conforming uploads. New `media.NormalizeVideo` wraps ffmpeg with the Linear Void profile (H.264/AAC, 30fps, 2s keyframes, yuv420p, 48kHz stereo), re-verifies output with ffprobe, and writes `*.normalized.mp4`. Media records gain `normalizing` / `normalization_failed` statuses; `POST /api/admin/media/{id}/normalize` re-triggers manually. Admin UI shows a "Normalize" action with status polling.
|
||||||
|
- **R2 segment retention**: Playout `cleanupOldSegments` deletes `hls/r-*/*` objects older than `R2_SEGMENT_RETENTION_HOURS` (default 24h), run hourly + at startup, never touching the live manifest. Exposes `linearvoid_playout_r2_cleanup_objects_total` metric. `.env.example` adds `R2_SEGMENT_RETENTION_HOURS`.
|
||||||
|
- **Playout loop metrics**: `linearvoid_playout_loops_total` (clean completions) and `linearvoid_playout_run_duration_seconds` (last run duration).
|
||||||
|
- **Runtime configuration store**: Thread-safe, JSON-persisted runtime config (`HLS_SEGMENT_DURATION`, `HLS_WINDOW_SIZE`, `MATOMO_URL`, `MATOMO_SITE_ID`) with typed getters/setters + validation, editable via `GET/PUT /api/admin/config`. Playout reads the shared `config.json` as env fallback.
|
||||||
|
- **Matomo analytics**: Viewer pageview snippet in `index.html` driven by the public `GET /api/matomo` endpoint.
|
||||||
|
- **Chat user accounts**: Register/login/logout (`POST /api/auth/register`, `/login`, `/logout`, `GET /api/auth/session`), bcrypt password hashing, `linearvoid_user_session` cookie, and a viewer sign-in UI.
|
||||||
|
- **Private DM history & search**: `GET /api/dm/history` (paginated) and `GET /api/dm/search`, with a DM search UI in the viewer.
|
||||||
|
- **Chat UI polish**: Typing indicator, user-list toggle, media status badges, and an "Add to playlist" action gated to `ready` media.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Playout seamless looping**: `main()` now only resets HLS output when the playlist changed (SHA-256 `playlistRevision`), on crash/slate recovery, or first run; otherwise it continues segment numbering via `-start_number` and prunes stale segments. Eliminates the viewer gap on clean playlist loops.
|
||||||
|
- **Single playout entrypoint**: `start-playout.sh` now runs `/app/playlist-manager -validate` then `exec`s the Go binary, removing the duplicate shell ffmpeg loop. Added a `-validate` mode to `main()`.
|
||||||
|
- **Admin media library**: Shows status badges (`ready`/`needs_normalization`/`normalizing`/`failed`) and only offers "Add to playlist" for `ready` media; playlist entries show per-item status badges.
|
||||||
|
- **Prometheus TSDB retention**: The `prometheus` service now sets `--storage.tsdb.retention.time=30d` and `--storage.tsdb.retention.size=15GB` (in both `docker-compose.yml` and `docker-compose.prod.yml`), so metrics older than 30 days are deleted.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Media: `TestNormalizationArgs`, `TestNormalizeVideo_FFmpegError`.
|
||||||
|
- Handlers: `TestNormalizeMediaRecord_Success`, `TestNormalizeMediaRecord_Failure`, `TestHandleAdminMediaByID_Normalize`.
|
||||||
|
- Playout: `TestCleanupOldSegments`, `TestR2SegmentRetentionHours`, `TestPlaylistFingerprint`, `TestHLSNextStartNumber`, `TestLoadSharedHLSConfig`.
|
||||||
|
- Config: `runtime_test.go` (get/set, persist/reload, env fallback, validation).
|
||||||
|
- Auth: `TestUserSessionStore`.
|
||||||
|
- Handlers: config GET/PUT + validation, DM history/search, user register/login/session.
|
||||||
|
|
||||||
## [1.0.12] - 2026-08-11
|
## [1.0.12] - 2026-08-11
|
||||||
|
|
||||||
@@ -13,7 +70,7 @@
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Playout R2 endpoint validation**: `initR2Client()` now validates all required R2 environment variables (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`) before creating the S3 client. Previously, an empty `R2_ENDPOINT` caused cryptic `Custom endpoint '' was not a valid URI` errors on every segment upload attempt. Playout now gracefully degrades when R2 is not configured — local HLS via nginx continues to work.
|
- **Playout R2 endpoint validation**: `initR2Client()` now validates all required R2 environment variables (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`) before creating the S3 client. Previously, an empty `R2_ENDPOINT` caused cryptic `Custom endpoint '' was not a valid URI` errors on every segment upload attempt. Playout now gracefully degrades when R2 is not configured — local HLS via nginx continues to work.
|
||||||
- **CD workflow missing R2 secrets**: `.gitea/workflows/cd.yml` "Prepare environment" step now injects all R2_ variables (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, plus admin variants) from Gitea secrets. Previously the generated `.env` was missing R2 entirely, causing the playout container to start with an empty endpoint.
|
- **CD workflow missing R2 secrets**: `.gitea/workflows/cd.yml` "Prepare environment" step now injects all R2\_ variables (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, plus admin variants) from Gitea secrets. Previously the generated `.env` was missing R2 entirely, causing the playout container to start with an empty endpoint.
|
||||||
- **Deployment docs**: `docs/DEPLOYMENT.md` now includes explicit R2 and Cloudflare variable lists plus a "Gitea Secrets" table documenting all 15 required secrets.
|
- **Deployment docs**: `docs/DEPLOYMENT.md` now includes explicit R2 and Cloudflare variable lists plus a "Gitea Secrets" table documenting all 15 required secrets.
|
||||||
|
|
||||||
## [1.0.11] - 2026-08-10
|
## [1.0.11] - 2026-08-10
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||||
[](https://git.allucanget.biz)
|
[](https://git.allucanget.biz)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -46,6 +46,8 @@ flowchart TD
|
|||||||
P -->|sync| R2
|
P -->|sync| R2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A detailed description of the Streaming architecture, playout, and delivery is available in [`docs/STREAMING.md`](docs/STREAMING.md).
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
This structure directly reflects the building blocks, runtime flows, deployment view, and cross-cutting concepts defined in the arc42 documentation.
|
This structure directly reflects the building blocks, runtime flows, deployment view, and cross-cutting concepts defined in the arc42 documentation.
|
||||||
@@ -185,7 +187,8 @@ frontend/
|
|||||||
Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
||||||
|
|
||||||
- Cloudflare
|
- Cloudflare
|
||||||
- [worker.js](infrastructure/cloudflare/worker.js) — adds immutable cache headers to HLS segments
|
- [linearvoid-media worker](infrastructure/cloudflare/) — R2-bound edge Worker serving HLS on `media.linearvoid.tv` (immutable segment caching, CORS, Range)
|
||||||
|
- [linearvoid-frontend worker](frontend/wrangler.toml) — serves the static SPA on `linearvoid.tv`
|
||||||
- Nginx
|
- Nginx
|
||||||
- [Dockerfile](infrastructure/nginx/Dockerfile) — builds Nginx with custom config
|
- [Dockerfile](infrastructure/nginx/Dockerfile) — builds Nginx with custom config
|
||||||
- [nginx.conf](infrastructure/nginx/nginx.conf) — reverse proxy, static SPA, HLS delivery
|
- [nginx.conf](infrastructure/nginx/nginx.conf) — reverse proxy, static SPA, HLS delivery
|
||||||
@@ -211,11 +214,13 @@ One-off and CI-friendly scripts for video preparation, R2 sync, backups, and pla
|
|||||||
|
|
||||||
- loadtest/
|
- loadtest/
|
||||||
- [admin-api.js](scripts/loadtest/admin-api.js) — admin API load test script
|
- [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
|
- [run-loadtests.sh](scripts/loadtest/run-loadtests.sh) — bash script to run ws and/or admin load tests
|
||||||
- [summarize-artillery.ps1](scripts/loadtest/summarize-artillery.ps1) — PowerShell script to summarize Artillery load test results
|
- [summarize-artillery.sh](scripts/loadtest/summarize-artillery.sh) — summarize Artillery JSON results
|
||||||
- [summarize-k6.ps1](scripts/loadtest/summarize-k6.ps1) — PowerShell script to summarize k6 load test 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
|
- [ws-chat.yml](scripts/loadtest/ws-chat.yml) — WebSocket chat load test configuration
|
||||||
|
|
||||||
|
PowerShell equivalents (`*.ps1`) of the three load-test scripts are kept alongside them.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
> **Note:** The `docker compose up` below targets local development. The test lab (192.168.88.40) receives automated CD deploys. Production Hetzner VPS is not yet provisioned — see `DEPLOYMENT.md`.
|
> **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`.
|
||||||
@@ -260,8 +265,8 @@ See the [architecture docs](docs/architecture/README.md) for detailed architectu
|
|||||||
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` | HLS playlist URL |
|
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` | HLS playlist URL |
|
||||||
| `ADMIN_USERNAME` | `admin` | Admin panel username |
|
| `ADMIN_USERNAME` | `admin` | Admin panel username |
|
||||||
| `ADMIN_PASSWORD` | — | Admin panel password |
|
| `ADMIN_PASSWORD` | — | Admin panel password |
|
||||||
| `HLS_SEGMENT_DURATION` | `6` | HLS segment duration in seconds |
|
| `HLS_SEGMENT_DURATION` | `2` | HLS segment duration in seconds |
|
||||||
| `HLS_WINDOW_SIZE` | `24` | HLS playlist window size |
|
| `HLS_WINDOW_SIZE` | `12` | HLS playlist window size |
|
||||||
|
|
||||||
Full reference: [`.env.example`](.env.example).
|
Full reference: [`.env.example`](.env.example).
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/redis/go-redis/v9 v9.7.0
|
github.com/redis/go-redis/v9 v9.7.0
|
||||||
|
golang.org/x/crypto v0.37.0
|
||||||
gorm.io/driver/postgres v1.5.11
|
gorm.io/driver/postgres v1.5.11
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
@@ -24,7 +25,6 @@ require (
|
|||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/crypto v0.37.0 // indirect
|
|
||||||
golang.org/x/sync v0.13.0 // indirect
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/text v0.24.0 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
|
|||||||
@@ -88,29 +88,48 @@ func CookieSecure(r *http.Request) bool {
|
|||||||
return r.TLS != nil
|
return r.TLS != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCookie writes the admin session cookie.
|
// SameSiteForUserCookie returns the SameSite policy for the viewer session cookie.
|
||||||
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time) {
|
// 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{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieName,
|
Name: cookieName,
|
||||||
Value: token,
|
Value: token,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: sameSite,
|
||||||
Secure: CookieSecure(r),
|
Secure: secure,
|
||||||
Expires: expiresAt,
|
Expires: expiresAt,
|
||||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearCookie removes the admin session cookie.
|
// ClearCookie removes a session cookie with the given SameSite policy.
|
||||||
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string) {
|
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{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieName,
|
Name: cookieName,
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: sameSite,
|
||||||
Secure: CookieSecure(r),
|
Secure: secure,
|
||||||
Expires: time.Unix(0, 0),
|
Expires: time.Unix(0, 0),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
})
|
})
|
||||||
@@ -138,3 +157,51 @@ func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@ func TestSetCookie(t *testing.T) {
|
|||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
expiresAt := time.Now().Add(1 * time.Hour)
|
expiresAt := time.Now().Add(1 * time.Hour)
|
||||||
SetCookie(w, req, "test_cookie", "abc123", expiresAt)
|
SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteStrictMode)
|
||||||
|
|
||||||
cookies := w.Result().Cookies()
|
cookies := w.Result().Cookies()
|
||||||
if len(cookies) != 1 {
|
if len(cookies) != 1 {
|
||||||
@@ -165,7 +165,7 @@ func TestClearCookie(t *testing.T) {
|
|||||||
req := httptest.NewRequest("GET", "/", nil)
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ClearCookie(w, req, "test_cookie")
|
ClearCookie(w, req, "test_cookie", http.SameSiteStrictMode)
|
||||||
|
|
||||||
cookies := w.Result().Cookies()
|
cookies := w.Result().Cookies()
|
||||||
if len(cookies) != 1 {
|
if len(cookies) != 1 {
|
||||||
@@ -183,6 +183,38 @@ func TestClearCookie(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestGetTokenFromCookie(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
|
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
|
||||||
@@ -246,3 +278,32 @@ func TestRequireAdmin(t *testing.T) {
|
|||||||
t.Errorf("status = %d, want 401", w3.Code)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ const (
|
|||||||
DefaultRateLimitMax = 10
|
DefaultRateLimitMax = 10
|
||||||
DefaultRateLimitWindow = 10 * time.Second
|
DefaultRateLimitWindow = 10 * time.Second
|
||||||
DefaultAdminSessionTTL = 12 * time.Hour
|
DefaultAdminSessionTTL = 12 * time.Hour
|
||||||
|
DefaultUserSessionTTL = 30 * 24 * time.Hour
|
||||||
DefaultChatPort = "3000"
|
DefaultChatPort = "3000"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,6 +69,19 @@ func AdminSessionTTL() time.Duration {
|
|||||||
return time.Duration(hours) * time.Hour
|
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.
|
// RateLimitMax returns the chat rate limit max messages per window.
|
||||||
func RateLimitMax() int {
|
func RateLimitMax() int {
|
||||||
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
|
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Runtime configuration keys stored in the runtime config file.
|
||||||
|
const (
|
||||||
|
keyMatomoURL = "matomo_url"
|
||||||
|
keyMatomoSiteID = "matomo_site_id"
|
||||||
|
keyHLSSegmentDur = "hls_segment_duration"
|
||||||
|
keyHLSWindowSize = "hls_window_size"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default HLS values used when neither env nor persisted config is set.
|
||||||
|
const (
|
||||||
|
defaultHLSSegmentDuration = 2
|
||||||
|
defaultHLSWindowSize = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
runtimeMu sync.RWMutex
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigPath returns the runtime config JSON file path from env or default.
|
||||||
|
// The default lives in the shared playlist volume so the playout service can
|
||||||
|
// read the same file (chat has read-write, playout read-only).
|
||||||
|
func ConfigPath() string {
|
||||||
|
p := strings.TrimSpace(os.Getenv("CONFIG_PATH"))
|
||||||
|
if p == "" {
|
||||||
|
return "/app/playlist/config.json"
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRuntime returns a value from the runtime store and whether it is present.
|
||||||
|
func GetRuntime(key string) (string, bool) {
|
||||||
|
runtimeMu.RLock()
|
||||||
|
defer runtimeMu.RUnlock()
|
||||||
|
v, ok := runtimeStore[key]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRuntime stores a value in the runtime store (in-memory only).
|
||||||
|
func SetRuntime(key, value string) {
|
||||||
|
runtimeMu.Lock()
|
||||||
|
defer runtimeMu.Unlock()
|
||||||
|
runtimeStore[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadRuntimeConfig loads the persisted runtime config from disk. If the file does
|
||||||
|
// not exist, it seeds the store from environment variables.
|
||||||
|
func LoadRuntimeConfig() error {
|
||||||
|
data, err := os.ReadFile(ConfigPath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
runtimeMu.Lock()
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
runtimeMu.Unlock()
|
||||||
|
seedRuntimeDefaults()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read runtime config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := map[string]string{}
|
||||||
|
if err := json.Unmarshal(data, &values); err != nil {
|
||||||
|
return fmt.Errorf("parse runtime config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeMu.Lock()
|
||||||
|
defer runtimeMu.Unlock()
|
||||||
|
runtimeStore = values
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRuntimeConfig persists the runtime store to disk as JSON.
|
||||||
|
func SaveRuntimeConfig() error {
|
||||||
|
runtimeMu.RLock()
|
||||||
|
data, err := json.MarshalIndent(runtimeStore, "", " ")
|
||||||
|
runtimeMu.RUnlock()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal runtime config: %w", err)
|
||||||
|
}
|
||||||
|
if dir := filepath.Dir(ConfigPath()); dir != "" && dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create runtime config dir: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(ConfigPath(), append(data, '\n'), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write runtime config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedRuntimeDefaults populates the store from environment variables.
|
||||||
|
func seedRuntimeDefaults() {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("MATOMO_URL")); v != "" {
|
||||||
|
SetRuntime(keyMatomoURL, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("MATOMO_SITE_ID")); v != "" {
|
||||||
|
SetRuntime(keyMatomoSiteID, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION")); v != "" {
|
||||||
|
SetRuntime(keyHLSSegmentDur, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE")); v != "" {
|
||||||
|
SetRuntime(keyHLSWindowSize, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatomoURL returns the Matomo analytics URL.
|
||||||
|
func MatomoURL() string {
|
||||||
|
if v, ok := GetRuntime(keyMatomoURL); ok && strings.TrimSpace(v) != "" {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(os.Getenv("MATOMO_URL"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatomoSiteID returns the Matomo site ID.
|
||||||
|
func MatomoSiteID() string {
|
||||||
|
if v, ok := GetRuntime(keyMatomoSiteID); ok && strings.TrimSpace(v) != "" {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(os.Getenv("MATOMO_SITE_ID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLSSegmentDuration returns the HLS segment duration in seconds.
|
||||||
|
func HLSSegmentDuration() int {
|
||||||
|
raw := ""
|
||||||
|
if v, ok := GetRuntime(keyHLSSegmentDur); ok {
|
||||||
|
raw = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
raw = strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION"))
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return defaultHLSSegmentDuration
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLSWindowSize returns the HLS playlist window size in segments.
|
||||||
|
func HLSWindowSize() int {
|
||||||
|
raw := ""
|
||||||
|
if v, ok := GetRuntime(keyHLSWindowSize); ok {
|
||||||
|
raw = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
raw = strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE"))
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return defaultHLSWindowSize
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMatomoURL validates and stores the Matomo URL.
|
||||||
|
func SetMatomoURL(raw string) error {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
SetRuntime(keyMatomoURL, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return fmt.Errorf("matomo URL must be an absolute http(s) URL")
|
||||||
|
}
|
||||||
|
SetRuntime(keyMatomoURL, raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMatomoSiteID validates and stores the Matomo site ID.
|
||||||
|
func SetMatomoSiteID(raw string) error {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw != "" {
|
||||||
|
if _, err := strconv.Atoi(raw); err != nil {
|
||||||
|
return fmt.Errorf("matomo site ID must be numeric")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SetRuntime(keyMatomoSiteID, raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHLSSegmentDuration validates (2–15s) and stores the HLS segment duration.
|
||||||
|
func SetHLSSegmentDuration(seconds int) error {
|
||||||
|
if seconds < 2 || seconds > 15 {
|
||||||
|
return fmt.Errorf("HLS segment duration must be between 2 and 15 seconds")
|
||||||
|
}
|
||||||
|
SetRuntime(keyHLSSegmentDur, strconv.Itoa(seconds))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHLSWindowSize validates (3–100) and stores the HLS window size.
|
||||||
|
func SetHLSWindowSize(size int) error {
|
||||||
|
if size < 3 || size > 100 {
|
||||||
|
return fmt.Errorf("HLS window size must be between 3 and 100 segments")
|
||||||
|
}
|
||||||
|
SetRuntime(keyHLSWindowSize, strconv.Itoa(size))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeConfigSnapshot returns the resolved runtime configuration values.
|
||||||
|
func RuntimeConfigSnapshot() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"matomoUrl": MatomoURL(),
|
||||||
|
"matomoSiteId": MatomoSiteID(),
|
||||||
|
"hlsSegmentDuration": HLSSegmentDuration(),
|
||||||
|
"hlsWindowSize": HLSWindowSize(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resetRuntime(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
runtimeMu.Lock()
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
runtimeMu.Unlock()
|
||||||
|
os.Unsetenv("CONFIG_PATH")
|
||||||
|
os.Unsetenv("MATOMO_URL")
|
||||||
|
os.Unsetenv("MATOMO_SITE_ID")
|
||||||
|
os.Unsetenv("HLS_SEGMENT_DURATION")
|
||||||
|
os.Unsetenv("HLS_WINDOW_SIZE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigPath(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
if got := ConfigPath(); got != "/app/playlist/config.json" {
|
||||||
|
t.Errorf("ConfigPath() = %q, want default /app/playlist/config.json", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("CONFIG_PATH", "/tmp/custom.json")
|
||||||
|
defer os.Unsetenv("CONFIG_PATH")
|
||||||
|
if got := ConfigPath(); got != "/tmp/custom.json" {
|
||||||
|
t.Errorf("ConfigPath() = %q, want /tmp/custom.json", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSetRuntime(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
if _, ok := GetRuntime("foo"); ok {
|
||||||
|
t.Errorf("expected missing key")
|
||||||
|
}
|
||||||
|
SetRuntime("foo", "bar")
|
||||||
|
v, ok := GetRuntime("foo")
|
||||||
|
if !ok || v != "bar" {
|
||||||
|
t.Errorf("GetRuntime() = %q, %v; want bar, true", v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGettersDefaults(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
if got := MatomoURL(); got != "" {
|
||||||
|
t.Errorf("MatomoURL() = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := MatomoSiteID(); got != "" {
|
||||||
|
t.Errorf("MatomoSiteID() = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := HLSSegmentDuration(); got != 2 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 2", got)
|
||||||
|
}
|
||||||
|
if got := HLSWindowSize(); got != 12 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 12", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGettersEnvFallback(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
os.Setenv("HLS_SEGMENT_DURATION", "4")
|
||||||
|
os.Setenv("HLS_WINDOW_SIZE", "30")
|
||||||
|
os.Setenv("MATOMO_URL", "https://analytics.example.com")
|
||||||
|
os.Setenv("MATOMO_SITE_ID", "7")
|
||||||
|
|
||||||
|
if got := HLSSegmentDuration(); got != 4 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 4", got)
|
||||||
|
}
|
||||||
|
if got := HLSWindowSize(); got != 30 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 30", got)
|
||||||
|
}
|
||||||
|
if got := MatomoURL(); got != "https://analytics.example.com" {
|
||||||
|
t.Errorf("MatomoURL() = %q", got)
|
||||||
|
}
|
||||||
|
if got := MatomoSiteID(); got != "7" {
|
||||||
|
t.Errorf("MatomoSiteID() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRuntimeConfigSeedsFromEnv(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json"))
|
||||||
|
os.Setenv("HLS_SEGMENT_DURATION", "8")
|
||||||
|
|
||||||
|
if err := LoadRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("LoadRuntimeConfig error: %v", err)
|
||||||
|
}
|
||||||
|
if got := HLSSegmentDuration(); got != 8 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 8 (seeded from env)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAndLoadRuntimeConfig(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json"))
|
||||||
|
|
||||||
|
if err := SetHLSSegmentDuration(5); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SetHLSWindowSize(30); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SetMatomoURL("https://stats.example.com"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SetMatomoSiteID("42"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("SaveRuntimeConfig error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the store and reload from disk.
|
||||||
|
runtimeMu.Lock()
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
runtimeMu.Unlock()
|
||||||
|
|
||||||
|
if err := LoadRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("LoadRuntimeConfig error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := HLSSegmentDuration(); got != 5 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 5", got)
|
||||||
|
}
|
||||||
|
if got := HLSWindowSize(); got != 30 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 30", got)
|
||||||
|
}
|
||||||
|
if got := MatomoURL(); got != "https://stats.example.com" {
|
||||||
|
t.Errorf("MatomoURL() = %q", got)
|
||||||
|
}
|
||||||
|
if got := MatomoSiteID(); got != "42" {
|
||||||
|
t.Errorf("MatomoSiteID() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettersValidation(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
|
||||||
|
if err := SetHLSSegmentDuration(1); err == nil {
|
||||||
|
t.Error("expected error for duration 1")
|
||||||
|
}
|
||||||
|
if err := SetHLSSegmentDuration(20); err == nil {
|
||||||
|
t.Error("expected error for duration 20")
|
||||||
|
}
|
||||||
|
if err := SetHLSSegmentDuration(6); err != nil {
|
||||||
|
t.Errorf("unexpected error for duration 6: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetHLSWindowSize(2); err == nil {
|
||||||
|
t.Error("expected error for window 2")
|
||||||
|
}
|
||||||
|
if err := SetHLSWindowSize(101); err == nil {
|
||||||
|
t.Error("expected error for window 101")
|
||||||
|
}
|
||||||
|
if err := SetHLSWindowSize(24); err != nil {
|
||||||
|
t.Errorf("unexpected error for window 24: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetMatomoURL("not-a-url"); err == nil {
|
||||||
|
t.Error("expected error for invalid URL")
|
||||||
|
}
|
||||||
|
if err := SetMatomoURL("ftp://example.com"); err == nil {
|
||||||
|
t.Error("expected error for non-http URL")
|
||||||
|
}
|
||||||
|
if err := SetMatomoURL("https://stats.example.com"); err != nil {
|
||||||
|
t.Errorf("unexpected error for valid URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetMatomoSiteID("abc"); err == nil {
|
||||||
|
t.Error("expected error for non-numeric site ID")
|
||||||
|
}
|
||||||
|
if err := SetMatomoSiteID("99"); err != nil {
|
||||||
|
t.Errorf("unexpected error for numeric site ID: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ func InitDB(databaseURL string) (*DBClient, string, error) {
|
|||||||
return nil, driver, err
|
return nil, driver, err
|
||||||
}
|
}
|
||||||
|
|
||||||
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
|
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{}, &User{})
|
||||||
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
|
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
|
||||||
|
|
||||||
return &DBClient{DB: gormDB}, driver, nil
|
return &DBClient{DB: gormDB}, driver, nil
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ type Media struct {
|
|||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
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)
|
// ScheduleOverride represents a scheduled playlist override (ADR-004)
|
||||||
type ScheduleOverride struct {
|
type ScheduleOverride struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"chat/src/auth"
|
"chat/src/auth"
|
||||||
@@ -197,6 +198,7 @@ type Dependencies struct {
|
|||||||
Redis db.RedisClient
|
Redis db.RedisClient
|
||||||
Hub *ws.Hub
|
Hub *ws.Hub
|
||||||
SessionStore *auth.SessionStore
|
SessionStore *auth.SessionStore
|
||||||
|
UserSessions *auth.UserSessionStore
|
||||||
DBDriver string
|
DBDriver string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +291,7 @@ func HandleAdminLogin(deps *Dependencies) http.HandlerFunc {
|
|||||||
|
|
||||||
expiresAt := time.Now().Add(config.AdminSessionTTL())
|
expiresAt := time.Now().Add(config.AdminSessionTTL())
|
||||||
deps.SessionStore.Add(token, expiresAt)
|
deps.SessionStore.Add(token, expiresAt)
|
||||||
auth.SetCookie(w, r, config.AdminSessionCookieName, token, expiresAt)
|
auth.SetCookie(w, r, config.AdminSessionCookieName, token, expiresAt, http.SameSiteStrictMode)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: config.AdminUsername()})
|
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: config.AdminUsername()})
|
||||||
@@ -307,7 +309,7 @@ func HandleAdminLogout(deps *Dependencies) http.HandlerFunc {
|
|||||||
if token, ok := auth.GetTokenFromCookie(r, config.AdminSessionCookieName); ok {
|
if token, ok := auth.GetTokenFromCookie(r, config.AdminSessionCookieName); ok {
|
||||||
deps.SessionStore.Remove(token)
|
deps.SessionStore.Remove(token)
|
||||||
}
|
}
|
||||||
auth.ClearCookie(w, r, config.AdminSessionCookieName)
|
auth.ClearCookie(w, r, config.AdminSessionCookieName, http.SameSiteStrictMode)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: false})
|
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: false})
|
||||||
@@ -333,6 +335,251 @@ func HandleAdminSession(deps *Dependencies) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// HandleAdminOverview returns the admin dashboard overview.
|
||||||
func HandleAdminOverview(deps *Dependencies) http.HandlerFunc {
|
func HandleAdminOverview(deps *Dependencies) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -757,29 +1004,83 @@ func HandleAdminSystemSummary(deps *Dependencies) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// HandleAdminConfig returns/updates runtime configuration.
|
||||||
func HandleAdminConfig(deps *Dependencies) http.HandlerFunc {
|
func HandleAdminConfig(deps *Dependencies) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(adminConfigResponse(deps))
|
||||||
"rateLimitMax": config.RateLimitMax(),
|
|
||||||
"rateLimitWindowSec": int(config.RateLimitWindow().Seconds()),
|
|
||||||
"databaseDriver": deps.DBDriver,
|
|
||||||
"redisEnabled": deps.Redis != nil,
|
|
||||||
"sessionTTLHours": int(config.AdminSessionTTL().Hours()),
|
|
||||||
})
|
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
var payload map[string]any
|
// 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 {
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Note: rate limit config is read-only at runtime in this refactored version.
|
if payload.MatomoURL != nil {
|
||||||
// To change, restart with env vars.
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
json.NewEncoder(w).Encode(adminConfigResponse(deps))
|
||||||
default:
|
default:
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
@@ -976,9 +1277,9 @@ func HandleAdminMedia(deps *Dependencies) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
normResult := media.ValidateNormalization(targetPath)
|
normResult := media.ValidateNormalization(targetPath)
|
||||||
status := "ready"
|
status := media.StatusReady
|
||||||
if !normResult.Valid {
|
if !normResult.Valid {
|
||||||
status = "needs_normalization"
|
status = media.StatusNeedsNormalization
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := filepath.Base(targetPath)
|
filename := filepath.Base(targetPath)
|
||||||
@@ -998,6 +1299,10 @@ func HandleAdminMedia(deps *Dependencies) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !normResult.Valid {
|
||||||
|
go normalizeMediaRecord(deps, mediaRecord)
|
||||||
|
}
|
||||||
|
|
||||||
files, err := media.ListFiles(deps.DB)
|
files, err := media.ListFiles(deps.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
|
||||||
@@ -1307,9 +1612,9 @@ func HandleAdminMediaUploads(deps *Dependencies) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
normResult := media.ValidateNormalization(targetPath)
|
normResult := media.ValidateNormalization(targetPath)
|
||||||
status := "ready"
|
status := media.StatusReady
|
||||||
if !normResult.Valid {
|
if !normResult.Valid {
|
||||||
status = "needs_normalization"
|
status = media.StatusNeedsNormalization
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := filepath.Base(targetPath)
|
filename := filepath.Base(targetPath)
|
||||||
@@ -1329,6 +1634,10 @@ func HandleAdminMediaUploads(deps *Dependencies) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !normResult.Valid {
|
||||||
|
go normalizeMediaRecord(deps, mediaRecord)
|
||||||
|
}
|
||||||
|
|
||||||
_ = os.RemoveAll(media.UploadSessionDir(mediaDir, uploadID))
|
_ = os.RemoveAll(media.UploadSessionDir(mediaDir, uploadID))
|
||||||
|
|
||||||
files, err := media.ListFiles(deps.DB)
|
files, err := media.ListFiles(deps.DB)
|
||||||
@@ -1348,6 +1657,11 @@ func HandleAdminMediaUploads(deps *Dependencies) http.HandlerFunc {
|
|||||||
func HandleAdminMediaByID(deps *Dependencies) http.HandlerFunc {
|
func HandleAdminMediaByID(deps *Dependencies) http.HandlerFunc {
|
||||||
mediaDir := config.MediaDir()
|
mediaDir := config.MediaDir()
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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/")
|
mediaID, err := media.GetMediaIDFromPath(r.URL.Path, "/api/admin/media/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -1424,6 +1738,75 @@ func HandleAdminMediaByID(deps *Dependencies) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// HandleAdminChatLog lists chat log entries.
|
||||||
func HandleAdminChatLog(deps *Dependencies) http.HandlerFunc {
|
func HandleAdminChatLog(deps *Dependencies) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1446,7 +1829,7 @@ func HandleAdminChatLog(deps *Dependencies) http.HandlerFunc {
|
|||||||
limit = parsedLimit
|
limit = parsedLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
dbQuery := deps.DB.Model(&db.Message{})
|
dbQuery := deps.DB.Model(&db.Message{}).Where("type <> ?", "system")
|
||||||
if room != "" {
|
if room != "" {
|
||||||
dbQuery = dbQuery.Where("room = ?", room)
|
dbQuery = dbQuery.Where("room = ?", room)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -14,11 +15,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"chat/src/auth"
|
"chat/src/auth"
|
||||||
"chat/src/config"
|
"chat/src/config"
|
||||||
"chat/src/db"
|
"chat/src/db"
|
||||||
|
"chat/src/media"
|
||||||
"chat/src/ws"
|
"chat/src/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,7 +103,14 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to open test DB: %v", err)
|
t.Fatalf("failed to open test DB: %v", err)
|
||||||
}
|
}
|
||||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{})
|
// 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()
|
mr := newMockRedis()
|
||||||
sessionStore := auth.NewSessionStore()
|
sessionStore := auth.NewSessionStore()
|
||||||
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
|
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
|
||||||
@@ -110,6 +120,7 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
|
|||||||
Redis: mr,
|
Redis: mr,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
SessionStore: sessionStore,
|
SessionStore: sessionStore,
|
||||||
|
UserSessions: auth.NewUserSessionStore(),
|
||||||
DBDriver: "sqlite",
|
DBDriver: "sqlite",
|
||||||
}
|
}
|
||||||
return deps, gormDB, sessionStore
|
return deps, gormDB, sessionStore
|
||||||
@@ -474,6 +485,7 @@ func TestHandleAdminConfig_Get(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandleAdminConfig_Put(t *testing.T) {
|
func TestHandleAdminConfig_Put(t *testing.T) {
|
||||||
deps, _, _ := setupTestDeps(t)
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
setupConfigTest(t)
|
||||||
|
|
||||||
handler := HandleAdminConfig(deps)
|
handler := HandleAdminConfig(deps)
|
||||||
body := `{"rateLimitMax":20}`
|
body := `{"rateLimitMax":20}`
|
||||||
@@ -488,6 +500,289 @@ func TestHandleAdminConfig_Put(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func TestHandleAdminRestart(t *testing.T) {
|
||||||
deps, _, _ := setupTestDeps(t)
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
|
||||||
@@ -656,6 +951,32 @@ func TestHandleAdminChatLog_FilterByRoom(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestHandleAdminChatLogByID_Get(t *testing.T) {
|
||||||
deps, gormDB, _ := setupTestDeps(t)
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
@@ -945,3 +1266,158 @@ func TestHandleAdminMediaUploads_InvalidFile(t *testing.T) {
|
|||||||
t.Errorf("status = %d, want 400", w.Code)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ func main() {
|
|||||||
rateLimitWindow := config.RateLimitWindow()
|
rateLimitWindow := config.RateLimitWindow()
|
||||||
log.Printf("Rate limit: %d messages per %v", rateLimitMax, 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
|
// Initialize Redis
|
||||||
redisWrapper := db.InitRedis(config.RedisAddress())
|
redisWrapper := db.InitRedis(config.RedisAddress())
|
||||||
|
|
||||||
@@ -39,6 +44,7 @@ func main() {
|
|||||||
|
|
||||||
// Initialize session store
|
// Initialize session store
|
||||||
sessionStore := auth.NewSessionStore()
|
sessionStore := auth.NewSessionStore()
|
||||||
|
userSessions := auth.NewUserSessionStore()
|
||||||
|
|
||||||
// Initialize WebSocket hub
|
// Initialize WebSocket hub
|
||||||
hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername())
|
hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername())
|
||||||
@@ -54,6 +60,7 @@ func main() {
|
|||||||
Redis: redisWrapper,
|
Redis: redisWrapper,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
SessionStore: sessionStore,
|
SessionStore: sessionStore,
|
||||||
|
UserSessions: userSessions,
|
||||||
DBDriver: dbDriver,
|
DBDriver: dbDriver,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +69,12 @@ func main() {
|
|||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
http.HandleFunc("/ws", hub.HandleConnection)
|
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/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps)))
|
||||||
http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(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/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(deps)))
|
||||||
@@ -77,6 +90,7 @@ func main() {
|
|||||||
http.HandleFunc("/api/admin/schedule", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(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/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(deps))))
|
||||||
http.HandleFunc("/api/schedule", metrics.WrapRequestTiming(handlers.HandlePublicSchedule(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", 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/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
|
http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
|
||||||
|
|||||||
@@ -155,6 +155,71 @@ func ProbeDurationSeconds(mediaPath string) (int, error) {
|
|||||||
return seconds, nil
|
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.
|
// DetectMime reads the first 512 bytes of a file to detect its MIME type.
|
||||||
func DetectMime(filePath string) (string, error) {
|
func DetectMime(filePath string) (string, error) {
|
||||||
f, err := os.Open(filePath)
|
f, err := os.Open(filePath)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package media
|
package media
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -202,3 +205,42 @@ func TestUniqueTargetPath(t *testing.T) {
|
|||||||
t.Errorf("UniqueTargetPath() = %q, want %q", 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
-14
@@ -41,19 +41,25 @@ type Hub struct {
|
|||||||
RateLimitWindow time.Duration
|
RateLimitWindow time.Duration
|
||||||
AdminUsername string
|
AdminUsername string
|
||||||
DMsSent int64
|
DMsSent int64
|
||||||
|
|
||||||
|
presenceMu sync.Mutex
|
||||||
|
presenceTimers map[string]*time.Timer
|
||||||
|
PresenceGracePeriod time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHub creates a new WebSocket hub.
|
// NewHub creates a new WebSocket hub.
|
||||||
func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax int, rateWindow time.Duration, adminUser string) *Hub {
|
func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax int, rateWindow time.Duration, adminUser string) *Hub {
|
||||||
return &Hub{
|
return &Hub{
|
||||||
clients: make(map[*websocket.Conn]bool),
|
clients: make(map[*websocket.Conn]bool),
|
||||||
clientMeta: make(map[*websocket.Conn]ClientInfo),
|
clientMeta: make(map[*websocket.Conn]ClientInfo),
|
||||||
DB: gormDB,
|
DB: gormDB,
|
||||||
Redis: redis,
|
Redis: redis,
|
||||||
InstanceID: instanceID,
|
InstanceID: instanceID,
|
||||||
RateLimitMax: rateMax,
|
RateLimitMax: rateMax,
|
||||||
RateLimitWindow: rateWindow,
|
RateLimitWindow: rateWindow,
|
||||||
AdminUsername: adminUser,
|
AdminUsername: adminUser,
|
||||||
|
presenceTimers: make(map[string]*time.Timer),
|
||||||
|
PresenceGracePeriod: 5 * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,12 +71,14 @@ func (h *Hub) ConnectedClients() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast sends a message to all connected clients and persists to DB.
|
// 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) {
|
func (h *Hub) Broadcast(msg db.Message) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
// Persist to configured SQL backend.
|
// Persist to configured SQL backend (chat messages only).
|
||||||
if h.DB != nil {
|
if h.DB != nil && msg.Type != "system" {
|
||||||
h.DB.Create(&msg)
|
h.DB.Create(&msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,13 +101,59 @@ func (h *Hub) Broadcast(msg db.Message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// PushChatHistory sends the last N messages for a room via WebSocket.
|
||||||
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
|
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
|
||||||
if h.DB == nil || room == "" {
|
if h.DB == nil || room == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var messages []db.Message
|
var messages []db.Message
|
||||||
h.DB.Where("room = ?", room).Order("timestamp desc").Limit(limit).Find(&messages)
|
h.DB.Where("room = ? AND type <> ?", room, "system").Order("timestamp desc").Limit(limit).Find(&messages)
|
||||||
// Reverse to chronological order
|
// Reverse to chronological order
|
||||||
for i := len(messages)/2 - 1; i >= 0; i-- {
|
for i := len(messages)/2 - 1; i >= 0; i-- {
|
||||||
opp := len(messages) - 1 - i
|
opp := len(messages) - 1 - i
|
||||||
@@ -147,10 +201,14 @@ func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.clientMeta[conn] = ClientInfo{Username: username, Room: room}
|
h.clientMeta[conn] = ClientInfo{Username: username, Room: room}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
// Announce presence (ADR-007)
|
// 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 != "" {
|
if h.Redis != nil && room != "" {
|
||||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||||
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()})
|
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)
|
// Push chat history on join (last 50 messages)
|
||||||
@@ -185,7 +243,7 @@ func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
if h.Redis != nil && room != "" {
|
if h.Redis != nil && room != "" {
|
||||||
_ = h.Redis.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
|
_ = h.Redis.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
|
||||||
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()})
|
h.scheduleLeaveAnnouncement(room, username)
|
||||||
}
|
}
|
||||||
conn.Close()
|
conn.Close()
|
||||||
}()
|
}()
|
||||||
@@ -224,6 +282,12 @@ func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle typing indicator (ephemeral — not persisted, not rate-limited)
|
||||||
|
if msg.Type == "typing" {
|
||||||
|
h.BroadcastTyping(msg.Username, msg.Room)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Handle private DMs
|
// Handle private DMs
|
||||||
if strings.HasPrefix(msg.Room, "dm:") {
|
if strings.HasPrefix(msg.Room, "dm:") {
|
||||||
h.handlePrivateDM(msg)
|
h.handlePrivateDM(msg)
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ func setupTestHub(t *testing.T) (*Hub, *gorm.DB, *mockRedis) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to open test DB: %v", err)
|
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{})
|
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
||||||
mr := newMockRedis()
|
mr := newMockRedis()
|
||||||
hub := NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
|
hub := NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
|
||||||
@@ -561,3 +566,46 @@ func TestHub_SubscribeRoomMessages(t *testing.T) {
|
|||||||
mr.Publish(ctx, "room:main:messages", string(ownPayload))
|
mr.Publish(ctx, "room:main:messages", string(ownPayload))
|
||||||
time.Sleep(50 * time.Millisecond)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+579
-34
@@ -3,19 +3,25 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
"github.com/aws/aws-sdk-go-v2/config"
|
"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"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,12 +38,23 @@ var (
|
|||||||
putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
return client.PutObject(context.TODO(), input)
|
return client.PutObject(context.TODO(), input)
|
||||||
}
|
}
|
||||||
bucket = os.Getenv("R2_BUCKET_NAME")
|
listObjects = func(client *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||||
playoutMode = "starting" // "live", "slate", "degraded"
|
return client.ListObjectsV2(context.TODO(), input)
|
||||||
restartCount = 0
|
}
|
||||||
restartWindow = time.Now()
|
deleteObjects = func(client *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||||
slateDir = "/app/slate"
|
return client.DeleteObjects(context.TODO(), input)
|
||||||
hlsDir = "/app/hls"
|
}
|
||||||
|
copyObject = func(client *s3.Client, input *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||||
|
return client.CopyObject(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)
|
// Unique segment naming (STREAMING.md contract: r-{runID}/seg-{seq}.m4s)
|
||||||
runID string
|
runID string
|
||||||
@@ -51,6 +68,18 @@ var (
|
|||||||
consecutiveFails int
|
consecutiveFails int
|
||||||
lastFFmpegStartTime time.Time
|
lastFFmpegStartTime time.Time
|
||||||
lastFFmpegExitError error
|
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 (
|
const (
|
||||||
@@ -59,8 +88,20 @@ const (
|
|||||||
// Cache-Control header values for R2 uploads.
|
// Cache-Control header values for R2 uploads.
|
||||||
segmentCacheControl = "public, max-age=31536000, immutable"
|
segmentCacheControl = "public, max-age=31536000, immutable"
|
||||||
manifestCacheControl = "public, max-age=1, s-maxage=2, must-revalidate"
|
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.
|
// isR2Configured returns true when the required R2 environment variables are set.
|
||||||
func isR2Configured() bool {
|
func isR2Configured() bool {
|
||||||
return os.Getenv("R2_ENDPOINT") != "" &&
|
return os.Getenv("R2_ENDPOINT") != "" &&
|
||||||
@@ -147,6 +188,8 @@ func watchAndPublishSegments(ctx context.Context, hlsOutputDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// publishManifest uploads the manifest atomically: temp key first, then copy to final key (STREAMING.md contract).
|
// publishManifest uploads the manifest atomically: temp key first, then copy to final key (STREAMING.md contract).
|
||||||
|
// Bare media URIs in the manifest are rewritten to the run-scoped R2 layout
|
||||||
|
// (r-{runID}/…) so that every referenced segment/init resolves on the media host.
|
||||||
func publishManifest(localManifestPath string) error {
|
func publishManifest(localManifestPath string) error {
|
||||||
if r2Client == nil {
|
if r2Client == nil {
|
||||||
if err := initR2Client(); err != nil {
|
if err := initR2Client(); err != nil {
|
||||||
@@ -154,13 +197,33 @@ func publishManifest(localManifestPath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(localManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read manifest failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite bare relative media URIs to the current run folder so the manifest
|
||||||
|
// matches the run-scoped segment keys uploaded by watchAndPublishSegments /
|
||||||
|
// syncLatestToR2 (candidate (a)). Slate mode is skipped: its manifest references
|
||||||
|
// slate assets that are not published under the live run prefix.
|
||||||
|
if playoutMode != "slate" {
|
||||||
|
data = rewriteManifestToRun(runID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage the (rewritten) manifest locally so the existing SyncToR2 path is reused.
|
||||||
|
staging := localManifestPath + ".r2staging"
|
||||||
|
if err := os.WriteFile(staging, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write staging manifest failed: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(staging)
|
||||||
|
|
||||||
// 1. Upload to temp key
|
// 1. Upload to temp key
|
||||||
if err := SyncToR2(localManifestPath, "hls/playlist.m3u8.tmp"); err != nil {
|
if err := SyncToR2(staging, "hls/playlist.m3u8.tmp"); err != nil {
|
||||||
return fmt.Errorf("temp manifest upload failed: %w", err)
|
return fmt.Errorf("temp manifest upload failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Atomic copy to final key
|
// 2. Atomic copy to final key
|
||||||
_, err := r2Client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
_, err = copyObject(r2Client, &s3.CopyObjectInput{
|
||||||
Bucket: aws.String(bucket),
|
Bucket: aws.String(bucket),
|
||||||
CopySource: aws.String(bucket + "/hls/playlist.m3u8.tmp"),
|
CopySource: aws.String(bucket + "/hls/playlist.m3u8.tmp"),
|
||||||
Key: aws.String("hls/playlist.m3u8"),
|
Key: aws.String("hls/playlist.m3u8"),
|
||||||
@@ -172,6 +235,55 @@ func publishManifest(localManifestPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isMediaURI reports whether s names an HLS media file (a segment or init file).
|
||||||
|
func isMediaURI(s string) bool {
|
||||||
|
name := s
|
||||||
|
if i := strings.IndexByte(name, '?'); i >= 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
switch strings.ToLower(filepath.Ext(name)) {
|
||||||
|
case ".m4s", ".ts", ".mp4", ".m4a":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteManifestToRun prefixes bare relative media URIs in an HLS manifest with the
|
||||||
|
// run folder, e.g. "seg-000001.m4s" -> "r-20260903T…/seg-000001.m4s" and
|
||||||
|
// #EXT-X-MAP:URI="init.mp4" -> URI="r-20260903T…/init.mp4". This keeps the published
|
||||||
|
// manifest consistent with the run-scoped R2 layout (STREAMING.md contract). Directives,
|
||||||
|
// comments, blank lines, and URIs that already contain a path are left unchanged.
|
||||||
|
// Returns data unchanged when runID is empty.
|
||||||
|
func rewriteManifestToRun(runID string, data []byte) []byte {
|
||||||
|
if runID == "" {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
prefix := runID + "/"
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
out := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(trimmed, "#EXT-X-MAP:URI="):
|
||||||
|
if open := strings.Index(trimmed, `URI="`); open >= 0 {
|
||||||
|
rest := trimmed[open+len(`URI="`):]
|
||||||
|
if closeIdx := strings.IndexByte(rest, '"'); closeIdx > 0 {
|
||||||
|
uri := rest[:closeIdx]
|
||||||
|
if uri != "" && !strings.Contains(uri, "/") {
|
||||||
|
line = strings.Replace(line, `URI="`+uri+`"`, `URI="`+prefix+uri+`"`, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
case trimmed != "" && !strings.HasPrefix(trimmed, "#") && !strings.Contains(trimmed, "/") && isMediaURI(trimmed):
|
||||||
|
out = append(out, prefix+trimmed)
|
||||||
|
default:
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []byte(strings.Join(out, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
// LoadPlaylist reads a playlist file and returns a list of VideoFile entries
|
// LoadPlaylist reads a playlist file and returns a list of VideoFile entries
|
||||||
func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
||||||
file, err := os.Open(playlistPath)
|
file, err := os.Open(playlistPath)
|
||||||
@@ -202,16 +314,32 @@ func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
|||||||
// ValidateVideo uses ffprobe to enforce codec, fps, and keyframe interval (ADR-001)
|
// ValidateVideo uses ffprobe to enforce codec, fps, and keyframe interval (ADR-001)
|
||||||
func ValidateVideo(videoPath string) (VideoFile, error) {
|
func ValidateVideo(videoPath string) (VideoFile, error) {
|
||||||
v := VideoFile{Path: videoPath}
|
v := VideoFile{Path: videoPath}
|
||||||
var errors []string
|
|
||||||
|
|
||||||
// Run ffprobe
|
// Run the stream/format probe and the keyframe probe concurrently: they are
|
||||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath)
|
// independent, and the keyframe probe (-show_frames) is the expensive one.
|
||||||
output, err := cmd.Output()
|
var streamOut []byte
|
||||||
if err != nil {
|
var streamErr error
|
||||||
v.Validation = "ffprobe failed: " + err.Error()
|
var keyframeErr error
|
||||||
return v, fmt.Errorf("ffprobe: %w", err)
|
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
|
// Parse JSON
|
||||||
var probe struct {
|
var probe struct {
|
||||||
Streams []struct {
|
Streams []struct {
|
||||||
@@ -229,7 +357,7 @@ func ValidateVideo(videoPath string) (VideoFile, error) {
|
|||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
} `json:"format"`
|
} `json:"format"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(output, &probe); err != nil {
|
if err := json.Unmarshal(streamOut, &probe); err != nil {
|
||||||
v.Validation = "ffprobe JSON parse failed: " + err.Error()
|
v.Validation = "ffprobe JSON parse failed: " + err.Error()
|
||||||
return v, fmt.Errorf("ffprobe json: %w", err)
|
return v, fmt.Errorf("ffprobe json: %w", err)
|
||||||
}
|
}
|
||||||
@@ -290,8 +418,7 @@ func ValidateVideo(videoPath string) (VideoFile, error) {
|
|||||||
return v, fmt.Errorf("validation failed: %s", v.Validation)
|
return v, fmt.Errorf("validation failed: %s", v.Validation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate keyframe interval <=2s (60 frames at 30fps)
|
// Keyframe interval check (probe already completed concurrently above).
|
||||||
keyframeErr := validateKeyframeInterval(videoPath)
|
|
||||||
if keyframeErr != nil {
|
if keyframeErr != nil {
|
||||||
errors = append(errors, keyframeErr.Error())
|
errors = append(errors, keyframeErr.Error())
|
||||||
}
|
}
|
||||||
@@ -385,21 +512,62 @@ func ValidateVideoStrict(videoPath string) map[string]any {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidatePlaylist runs normalization validation on all entries
|
// 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) {
|
func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
|
||||||
videos, err := LoadPlaylist(playlistPath)
|
videos, err := LoadPlaylist(playlistPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range videos {
|
// Collect unique paths in order of first appearance.
|
||||||
validated, err := ValidateVideo(videos[i].Path)
|
var order []string
|
||||||
videos[i] = validated
|
seen := make(map[string]bool, len(videos))
|
||||||
if err != nil {
|
for _, v := range videos {
|
||||||
fmt.Printf("Warning: %s - %s\n", videos[i].Path, err)
|
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
|
return videos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,10 +736,26 @@ func resetHLSOutput(hlsDir string) error {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
playlistPath := "/app/playlist/playlist.txt"
|
playlistPath := "/app/playlist/playlist.txt"
|
||||||
if len(os.Args) > 1 {
|
validateOnly := false
|
||||||
playlistPath = os.Args[1]
|
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("=== Linear Void Playout Service Starting ===")
|
||||||
fmt.Println("Playlist:", playlistPath)
|
fmt.Println("Playlist:", playlistPath)
|
||||||
fmt.Println("R2 Bucket:", bucket)
|
fmt.Println("R2 Bucket:", bucket)
|
||||||
@@ -580,17 +764,35 @@ func main() {
|
|||||||
initRunID()
|
initRunID()
|
||||||
fmt.Println("Run ID:", runID)
|
fmt.Println("Run ID:", runID)
|
||||||
|
|
||||||
// Validate playlist before starting (ADR-001 normalization enforcement)
|
// Validate playlist before starting (ADR-001 normalization enforcement).
|
||||||
if _, err := ValidatePlaylist(playlistPath); err != nil {
|
// 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)
|
fmt.Println("ERROR: Playlist validation failed:", err)
|
||||||
os.Exit(1)
|
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)
|
// Initialize R2 client (skip if not configured — local HLS via nginx still works)
|
||||||
if err := initR2Client(); err != nil {
|
if err := initR2Client(); err != nil {
|
||||||
fmt.Println("R2 sync disabled:", err)
|
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
|
// Start HTTP health check server
|
||||||
http.HandleFunc("/health", HealthHandler)
|
http.HandleFunc("/health", HealthHandler)
|
||||||
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -607,6 +809,21 @@ func main() {
|
|||||||
modeVal = 2
|
modeVal = 2
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "linearvoid_playout_mode %d\n", modeVal)
|
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() {
|
go func() {
|
||||||
fmt.Println("Health check server listening on :8081/health")
|
fmt.Println("Health check server listening on :8081/health")
|
||||||
@@ -625,10 +842,43 @@ func main() {
|
|||||||
// New run ID on each restart for unique R2 segment keys (STREAMING.md)
|
// New run ID on each restart for unique R2 segment keys (STREAMING.md)
|
||||||
initRunID()
|
initRunID()
|
||||||
|
|
||||||
if err := resetHLSOutput(hlsDir); err != nil {
|
// Remove run folders older than the retention policy (keep the last N runs,
|
||||||
fmt.Println("Failed to reset HLS output:", err)
|
// cap total bytes) now that the next run has started. This never touches the
|
||||||
time.Sleep(5 * time.Second)
|
// current or previous run folders.
|
||||||
continue
|
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).
|
// Start filesystem watcher for real-time R2 segment upload (STREAMING.md).
|
||||||
@@ -652,17 +902,20 @@ func main() {
|
|||||||
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
||||||
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k",
|
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k",
|
||||||
"-f", "hls",
|
"-f", "hls",
|
||||||
"-hls_time", "6",
|
"-hls_time", hlsSegmentDuration,
|
||||||
"-hls_list_size", "24",
|
"-hls_list_size", hlsWindowSize,
|
||||||
"-hls_segment_type", "fmp4",
|
"-hls_segment_type", "fmp4",
|
||||||
"-hls_fmp4_init_filename", "init.mp4",
|
"-hls_fmp4_init_filename", "init.mp4",
|
||||||
"-hls_flags", "delete_segments+independent_segments",
|
"-hls_flags", "delete_segments+independent_segments",
|
||||||
"-hls_segment_filename", filepath.Join(hlsDir, "seg-%06d.m4s"),
|
"-hls_segment_filename", filepath.Join(hlsDir, "seg-%06d.m4s"),
|
||||||
|
"-start_number", strconv.Itoa(startNumber),
|
||||||
filepath.Join(hlsDir, "playlist.m3u8"))
|
filepath.Join(hlsDir, "playlist.m3u8"))
|
||||||
|
|
||||||
runErr := cmd.Run()
|
runErr := cmd.Run()
|
||||||
runDuration := time.Since(lastFFmpegStartTime)
|
runDuration := time.Since(lastFFmpegStartTime)
|
||||||
lastFFmpegExitError = runErr
|
lastFFmpegExitError = runErr
|
||||||
|
lastRunDuration = runDuration
|
||||||
|
lastRunClean = runErr == nil
|
||||||
|
|
||||||
if watchCancel != nil {
|
if watchCancel != nil {
|
||||||
watchCancel() // Stop the filesystem watcher
|
watchCancel() // Stop the filesystem watcher
|
||||||
@@ -685,6 +938,7 @@ func main() {
|
|||||||
// Differentiate clean playlist completion from FFmpeg crash.
|
// Differentiate clean playlist completion from FFmpeg crash.
|
||||||
// A nil error means FFmpeg completed the playlist normally — not a crash.
|
// A nil error means FFmpeg completed the playlist normally — not a crash.
|
||||||
if runErr == nil {
|
if runErr == nil {
|
||||||
|
loopsTotal++
|
||||||
fmt.Printf("[%s] Playlist completed normally after %s. Looping...\n",
|
fmt.Printf("[%s] Playlist completed normally after %s. Looping...\n",
|
||||||
time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second))
|
time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second))
|
||||||
// If we were in slate mode, a successful run means recovery
|
// If we were in slate mode, a successful run means recovery
|
||||||
@@ -748,3 +1002,294 @@ func syncLatestToR2(hlsDir string) error {
|
|||||||
|
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleManifest = `#EXTM3U
|
||||||
|
#EXT-X-VERSION:7
|
||||||
|
#EXT-X-TARGETDURATION:2
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXT-X-INDEPENDENT-SEGMENTS
|
||||||
|
#EXT-X-MAP:URI="init.mp4"
|
||||||
|
#EXTINF:2.000000,
|
||||||
|
seg-000000.m4s
|
||||||
|
#EXTINF:2.000000,
|
||||||
|
seg-000001.m4s
|
||||||
|
#EXT-X-ENDLIST
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun(t *testing.T) {
|
||||||
|
runID := "r-20260903T120000Z"
|
||||||
|
out := string(rewriteManifestToRun(runID, []byte(sampleManifest)))
|
||||||
|
|
||||||
|
wants := []string{
|
||||||
|
`#EXT-X-MAP:URI="r-20260903T120000Z/init.mp4"`,
|
||||||
|
"r-20260903T120000Z/seg-000000.m4s",
|
||||||
|
"r-20260903T120000Z/seg-000001.m4s",
|
||||||
|
"#EXTM3U",
|
||||||
|
"#EXT-X-VERSION:7",
|
||||||
|
"#EXT-X-TARGETDURATION:2",
|
||||||
|
"#EXT-X-MEDIA-SEQUENCE:0",
|
||||||
|
"#EXT-X-INDEPENDENT-SEGMENTS",
|
||||||
|
"#EXT-X-ENDLIST",
|
||||||
|
"#EXTINF:2.000000,",
|
||||||
|
}
|
||||||
|
for _, want := range wants {
|
||||||
|
if !contains(out, want) {
|
||||||
|
t.Errorf("output missing %q\noutput:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media URIs must be prefixed (no bare segments left).
|
||||||
|
for _, bare := range []string{"\nseg-000000.m4s\n", "\nseg-000001.m4s\n", `URI="init.mp4"`} {
|
||||||
|
if contains(out, bare) {
|
||||||
|
t.Errorf("output still contains bare %q\noutput:\n%s", bare, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun_AlreadyPrefixedUnchanged(t *testing.T) {
|
||||||
|
manifest := "#EXTM3U\n#EXT-X-MAP:URI=\"r-old/init.mp4\"\nseg-000000.m4s\nr-old/seg-000001.m4s\n#EXT-X-ENDLIST\n"
|
||||||
|
out := string(rewriteManifestToRun("r-new", []byte(manifest)))
|
||||||
|
|
||||||
|
if !contains(out, `#EXT-X-MAP:URI="r-old/init.mp4"`) {
|
||||||
|
t.Errorf("EXT-X-MAP already-prefixed URI was rewritten: %s", out)
|
||||||
|
}
|
||||||
|
// A URI line that already contains a path is left alone.
|
||||||
|
if !contains(out, "r-old/seg-000001.m4s") {
|
||||||
|
t.Errorf("already-prefixed segment URI was changed:\n%s", out)
|
||||||
|
}
|
||||||
|
// Bare URI still gets the run prefix.
|
||||||
|
if !contains(out, "r-new/seg-000000.m4s") {
|
||||||
|
t.Errorf("bare segment not prefixed:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun_HandlesTSAndComments(t *testing.T) {
|
||||||
|
manifest := "#EXTM3U\n# a comment\nvideo-1.ts\ninit.mp4\n#EXT-X-ENDLIST\n"
|
||||||
|
out := string(rewriteManifestToRun("r-x", []byte(manifest)))
|
||||||
|
|
||||||
|
for _, want := range []string{"r-x/video-1.ts", "r-x/init.mp4", "# a comment", "#EXTM3U", "#EXT-X-ENDLIST"} {
|
||||||
|
if !contains(out, want) {
|
||||||
|
t.Errorf("output missing %q\noutput:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun_EmptyRunID(t *testing.T) {
|
||||||
|
out := rewriteManifestToRun("", []byte(sampleManifest))
|
||||||
|
if string(out) != sampleManifest {
|
||||||
|
t.Errorf("empty runID should return input unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsMediaURI(t *testing.T) {
|
||||||
|
for _, ok := range []string{"seg-000001.m4s", "init.mp4", "a.ts", "x.m4a"} {
|
||||||
|
if !isMediaURI(ok) {
|
||||||
|
t.Errorf("isMediaURI(%q) = false, want true", ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, notOK := range []string{"playlist.m3u8", "readme.txt", "seg-000001.m4s?x=1"} {
|
||||||
|
// Note: query strings are stripped; '?x=1' on m4s should still be media.
|
||||||
|
if notOK == "seg-000001.m4s?x=1" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isMediaURI(notOK) {
|
||||||
|
t.Errorf("isMediaURI(%q) = true, want false", notOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isMediaURI("seg-000001.m4s?token=abc") {
|
||||||
|
t.Errorf("isMediaURI with query should strip query and be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishManifest_RewritesToRunScopedURIs(t *testing.T) {
|
||||||
|
prevClient, prevBucket := r2Client, bucket
|
||||||
|
prevRunID, prevMode := runID, playoutMode
|
||||||
|
prevPut, prevCopy := putObject, copyObject
|
||||||
|
defer func() {
|
||||||
|
r2Client, bucket = prevClient, prevBucket
|
||||||
|
runID, playoutMode = prevRunID, prevMode
|
||||||
|
putObject, copyObject = prevPut, prevCopy
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mp := filepath.Join(tmpDir, "playlist.m3u8")
|
||||||
|
if err := os.WriteFile(mp, []byte(sampleManifest), 0o644); err != nil {
|
||||||
|
t.Fatalf("write manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
runID = "r-20260903T120000Z"
|
||||||
|
playoutMode = "live"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
var gotKey, gotCopySource, gotCopyKey string
|
||||||
|
var gotBody []byte
|
||||||
|
putObject = func(_ *s3.Client, in *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
gotKey = aws.ToString(in.Key)
|
||||||
|
b, err := io.ReadAll(in.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gotBody = b
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
copyObject = func(_ *s3.Client, in *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||||
|
gotCopySource = aws.ToString(in.CopySource)
|
||||||
|
gotCopyKey = aws.ToString(in.Key)
|
||||||
|
return &s3.CopyObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := publishManifest(mp); err != nil {
|
||||||
|
t.Fatalf("publishManifest error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotKey != "hls/playlist.m3u8.tmp" {
|
||||||
|
t.Errorf("temp upload key = %q, want hls/playlist.m3u8.tmp", gotKey)
|
||||||
|
}
|
||||||
|
if gotCopySource != "linearvoid-media/hls/playlist.m3u8.tmp" {
|
||||||
|
t.Errorf("CopySource = %q, want linearvoid-media/hls/playlist.m3u8.tmp", gotCopySource)
|
||||||
|
}
|
||||||
|
if gotCopyKey != "hls/playlist.m3u8" {
|
||||||
|
t.Errorf("final key = %q, want hls/playlist.m3u8", gotCopyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := string(gotBody)
|
||||||
|
for _, want := range []string{
|
||||||
|
"r-20260903T120000Z/seg-000000.m4s",
|
||||||
|
"r-20260903T120000Z/seg-000001.m4s",
|
||||||
|
`#EXT-X-MAP:URI="r-20260903T120000Z/init.mp4"`,
|
||||||
|
} {
|
||||||
|
if !contains(body, want) {
|
||||||
|
t.Errorf("published manifest missing %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bare := range []string{"\nseg-000000.m4s\n", "\nseg-000001.m4s\n"} {
|
||||||
|
if contains(body, bare) {
|
||||||
|
t.Errorf("published manifest still contains bare %q:\n%s", bare, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishManifest_SlateNotRewritten(t *testing.T) {
|
||||||
|
prevClient, prevBucket := r2Client, bucket
|
||||||
|
prevRunID, prevMode := runID, playoutMode
|
||||||
|
prevPut, prevCopy := putObject, copyObject
|
||||||
|
defer func() {
|
||||||
|
r2Client, bucket = prevClient, prevBucket
|
||||||
|
runID, playoutMode = prevRunID, prevMode
|
||||||
|
putObject, copyObject = prevPut, prevCopy
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mp := filepath.Join(tmpDir, "playlist.m3u8")
|
||||||
|
if err := os.WriteFile(mp, []byte(sampleManifest), 0o644); err != nil {
|
||||||
|
t.Fatalf("write manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
runID = "r-20260903T120000Z"
|
||||||
|
playoutMode = "slate"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
var gotBody []byte
|
||||||
|
putObject = func(_ *s3.Client, in *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
b, _ := io.ReadAll(in.Body)
|
||||||
|
gotBody = b
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
copyObject = func(_ *s3.Client, _ *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||||
|
return &s3.CopyObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := publishManifest(mp); err != nil {
|
||||||
|
t.Fatalf("publishManifest error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(gotBody) != sampleManifest {
|
||||||
|
t.Errorf("slate manifest should be published unmodified; got:\n%s", string(gotBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ package main
|
|||||||
// See backend/playout/testdata/*.txt.
|
// See backend/playout/testdata/*.txt.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -39,6 +41,7 @@ import (
|
|||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"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"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ensureFFProbeInPath(t *testing.T) {
|
func ensureFFProbeInPath(t *testing.T) {
|
||||||
@@ -265,7 +268,7 @@ func TestHealthHandler(t *testing.T) {
|
|||||||
`"status":"healthy"`,
|
`"status":"healthy"`,
|
||||||
`"service":"playout"`,
|
`"service":"playout"`,
|
||||||
`"mode":"live"`,
|
`"mode":"live"`,
|
||||||
`"restarts":3`,
|
`"crash_count":3`,
|
||||||
`"run_id":"r-20260810T120000Z"`,
|
`"run_id":"r-20260810T120000Z"`,
|
||||||
`"last_segment_seq"`,
|
`"last_segment_seq"`,
|
||||||
`"consecutive_failures"`,
|
`"consecutive_failures"`,
|
||||||
@@ -462,6 +465,54 @@ func TestValidatePlaylist(t *testing.T) {
|
|||||||
t.Skip("requires ffprobe mock or test media fixtures")
|
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) {
|
func TestSyncToR2(t *testing.T) {
|
||||||
prevClient := r2Client
|
prevClient := r2Client
|
||||||
prevBucket := bucket
|
prevBucket := bucket
|
||||||
@@ -589,3 +640,308 @@ func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
|
|||||||
t.Fatalf("ContentType = %q, want empty", gotContentType)
|
t.Fatalf("ContentType = %q, want empty", gotContentType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestR2KeepRuns(t *testing.T) {
|
||||||
|
os.Setenv("R2_KEEP_RUNS", "")
|
||||||
|
if got := r2KeepRuns(); got != 2 {
|
||||||
|
t.Errorf("default = %d, want 2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_KEEP_RUNS", "5")
|
||||||
|
if got := r2KeepRuns(); got != 5 {
|
||||||
|
t.Errorf("override = %d, want 5", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_KEEP_RUNS", "0")
|
||||||
|
if got := r2KeepRuns(); got != 2 {
|
||||||
|
t.Errorf("invalid = %d, want 2 fallback", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Unsetenv("R2_KEEP_RUNS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2MaxRetentionBytes(t *testing.T) {
|
||||||
|
os.Setenv("R2_MAX_RETENTION_BYTES", "")
|
||||||
|
if got := r2MaxRetentionBytes(); got != 10*1024*1024*1024 {
|
||||||
|
t.Errorf("default = %d, want 10GiB", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_MAX_RETENTION_BYTES", "100")
|
||||||
|
if got := r2MaxRetentionBytes(); got != 100 {
|
||||||
|
t.Errorf("override = %d, want 100", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_MAX_RETENTION_BYTES", "invalid")
|
||||||
|
if got := r2MaxRetentionBytes(); got != 10*1024*1024*1024 {
|
||||||
|
t.Errorf("invalid = %d, want 10GiB fallback", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Unsetenv("R2_MAX_RETENTION_BYTES")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2RunFromKey(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
key string
|
||||||
|
run string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"hls/r-20260815T120000Z/seg-1.m4s", "r-20260815T120000Z", true},
|
||||||
|
{"hls/r-20260815T120000Z/init.mp4", "r-20260815T120000Z", true},
|
||||||
|
{"hls/playlist.m3u8", "", false},
|
||||||
|
{"hls/playlist.m3u8.tmp", "", false},
|
||||||
|
{"other/r-x/seg.m4s", "", false},
|
||||||
|
{"hls/r-noslash", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
run, ok := r2RunFromKey(c.key)
|
||||||
|
if run != c.run || ok != c.ok {
|
||||||
|
t.Errorf("r2RunFromKey(%q) = (%q, %v), want (%q, %v)", c.key, run, ok, c.run, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupOldRuns(t *testing.T) {
|
||||||
|
prevClient := r2Client
|
||||||
|
prevBucket := bucket
|
||||||
|
prevList := listObjects
|
||||||
|
prevDelete := deleteObjects
|
||||||
|
prevTotal := r2CleanupObjectsTotal
|
||||||
|
prevRuns := r2RunsRetained
|
||||||
|
prevBytes := r2BytesRetained
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
listObjects = prevList
|
||||||
|
deleteObjects = prevDelete
|
||||||
|
r2CleanupObjectsTotal = prevTotal
|
||||||
|
r2RunsRetained = prevRuns
|
||||||
|
r2BytesRetained = prevBytes
|
||||||
|
}()
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
size := int64(1024)
|
||||||
|
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||||
|
return &s3.ListObjectsV2Output{
|
||||||
|
Contents: []types.Object{
|
||||||
|
{Key: aws.String("hls/r-20260813T000000Z/seg-1.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/r-20260814T000000Z/seg-2.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/r-20260815T000000Z/seg-3.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/playlist.m3u8"), Size: &size},
|
||||||
|
{Key: aws.String("hls/playlist.m3u8.tmp"), Size: &size},
|
||||||
|
},
|
||||||
|
IsTruncated: aws.Bool(false),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedKeys []string
|
||||||
|
deleteObjects = func(_ *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||||
|
for _, obj := range input.Delete.Objects {
|
||||||
|
deletedKeys = append(deletedKeys, aws.ToString(obj.Key))
|
||||||
|
}
|
||||||
|
return &s3.DeleteObjectsOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := cleanupOldRuns(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cleanupOldRuns error: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("deleted = %d, want 1 (oldest run)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.Join(deletedKeys, ",")
|
||||||
|
if !strings.Contains(joined, "hls/r-20260813T000000Z/seg-1.m4s") {
|
||||||
|
t.Fatalf("expected oldest run deleted, got: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if strings.Contains(joined, "playlist.m3u8") {
|
||||||
|
t.Fatalf("manifest must not be deleted: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if r2RunsRetained != 2 {
|
||||||
|
t.Fatalf("r2RunsRetained = %d, want 2", r2RunsRetained)
|
||||||
|
}
|
||||||
|
if r2BytesRetained != 2*size {
|
||||||
|
t.Fatalf("r2BytesRetained = %d, want %d", r2BytesRetained, 2*size)
|
||||||
|
}
|
||||||
|
if r2CleanupObjectsTotal != 1 {
|
||||||
|
t.Fatalf("r2CleanupObjectsTotal = %d, want 1", r2CleanupObjectsTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupOldRuns_SizeCap(t *testing.T) {
|
||||||
|
prevClient := r2Client
|
||||||
|
prevBucket := bucket
|
||||||
|
prevList := listObjects
|
||||||
|
prevDelete := deleteObjects
|
||||||
|
prevTotal := r2CleanupObjectsTotal
|
||||||
|
prevRuns := r2RunsRetained
|
||||||
|
prevBytes := r2BytesRetained
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
listObjects = prevList
|
||||||
|
deleteObjects = prevDelete
|
||||||
|
r2CleanupObjectsTotal = prevTotal
|
||||||
|
r2RunsRetained = prevRuns
|
||||||
|
r2BytesRetained = prevBytes
|
||||||
|
}()
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
// Force a small cap so the size policy trims even the last 2 runs.
|
||||||
|
os.Setenv("R2_KEEP_RUNS", "2")
|
||||||
|
os.Setenv("R2_MAX_RETENTION_BYTES", "1024")
|
||||||
|
defer os.Unsetenv("R2_KEEP_RUNS")
|
||||||
|
defer os.Unsetenv("R2_MAX_RETENTION_BYTES")
|
||||||
|
|
||||||
|
size := int64(1024)
|
||||||
|
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||||
|
return &s3.ListObjectsV2Output{
|
||||||
|
Contents: []types.Object{
|
||||||
|
{Key: aws.String("hls/r-20260813T000000Z/seg-1.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/r-20260814T000000Z/seg-2.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/r-20260815T000000Z/seg-3.m4s"), Size: &size},
|
||||||
|
},
|
||||||
|
IsTruncated: aws.Bool(false),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedKeys []string
|
||||||
|
deleteObjects = func(_ *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||||
|
for _, obj := range input.Delete.Objects {
|
||||||
|
deletedKeys = append(deletedKeys, aws.ToString(obj.Key))
|
||||||
|
}
|
||||||
|
return &s3.DeleteObjectsOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := cleanupOldRuns(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cleanupOldRuns error: %v", err)
|
||||||
|
}
|
||||||
|
// 3 runs -> keep 2 -> oldest deleted by count; remaining 2 bytes (2048) exceed
|
||||||
|
// the 1024 cap -> the older of the kept two is deleted, leaving only the newest.
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("deleted = %d, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.Join(deletedKeys, ",")
|
||||||
|
if !strings.Contains(joined, "hls/r-20260813T000000Z/seg-1.m4s") {
|
||||||
|
t.Fatalf("expected oldest run deleted, got: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, "hls/r-20260814T000000Z/seg-2.m4s") {
|
||||||
|
t.Fatalf("expected second-oldest run deleted by size cap, got: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if strings.Contains(joined, "hls/r-20260815T000000Z/seg-3.m4s") {
|
||||||
|
t.Fatalf("newest run must be kept, got: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if r2RunsRetained != 1 {
|
||||||
|
t.Fatalf("r2RunsRetained = %d, want 1", r2RunsRetained)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaylistFingerprint(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
p := filepath.Join(tmpDir, "playlist.txt")
|
||||||
|
if err := os.WriteFile(p, []byte("file '/app/media/a.mp4'\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fp1, err := playlistFingerprint(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playlistFingerprint error: %v", err)
|
||||||
|
}
|
||||||
|
fp2, err := playlistFingerprint(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playlistFingerprint error: %v", err)
|
||||||
|
}
|
||||||
|
if fp1 != fp2 {
|
||||||
|
t.Errorf("fingerprint not stable: %q vs %q", fp1, fp2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(p, []byte("file '/app/media/b.mp4'\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fp3, err := playlistFingerprint(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playlistFingerprint error: %v", err)
|
||||||
|
}
|
||||||
|
if fp1 == fp3 {
|
||||||
|
t.Errorf("fingerprint should change when playlist content changes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSNextStartNumber(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
// Create segments 0..29 with a live window of 10.
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i)), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next, err := hlsNextStartNumber(tmpDir, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hlsNextStartNumber error: %v", err)
|
||||||
|
}
|
||||||
|
if next != 30 {
|
||||||
|
t.Errorf("next = %d, want 30", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segments < 20 should be pruned; 20..29 kept (the live window).
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("seg-%06d should have been pruned", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 20; i < 30; i++ {
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); err != nil {
|
||||||
|
t.Errorf("seg-%06d should be kept: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSharedHLSConfig(t *testing.T) {
|
||||||
|
prevDur := hlsSegmentDuration
|
||||||
|
prevWin := hlsWindowSize
|
||||||
|
defer func() {
|
||||||
|
hlsSegmentDuration = prevDur
|
||||||
|
hlsWindowSize = prevWin
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(`{"hls_segment_duration":"4","hls_window_size":"12"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.Setenv("PLAYOUT_CONFIG_PATH", cfgPath)
|
||||||
|
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
|
||||||
|
|
||||||
|
loadSharedHLSConfig()
|
||||||
|
|
||||||
|
if hlsSegmentDuration != "4" {
|
||||||
|
t.Errorf("hlsSegmentDuration = %q, want 4", hlsSegmentDuration)
|
||||||
|
}
|
||||||
|
if hlsWindowSize != "12" {
|
||||||
|
t.Errorf("hlsWindowSize = %q, want 12", hlsWindowSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSharedHLSConfig_MissingFile(t *testing.T) {
|
||||||
|
prevDur := hlsSegmentDuration
|
||||||
|
prevWin := hlsWindowSize
|
||||||
|
defer func() {
|
||||||
|
hlsSegmentDuration = prevDur
|
||||||
|
hlsWindowSize = prevWin
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("PLAYOUT_CONFIG_PATH", filepath.Join(t.TempDir(), "nope.json"))
|
||||||
|
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
|
||||||
|
|
||||||
|
loadSharedHLSConfig() // should be a no-op without erroring
|
||||||
|
|
||||||
|
if hlsSegmentDuration != prevDur {
|
||||||
|
t.Errorf("hlsSegmentDuration changed to %q, want unchanged %q", hlsSegmentDuration, prevDur)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ set -e
|
|||||||
|
|
||||||
echo "=== Linear Void Playout Service Starting ==="
|
echo "=== Linear Void Playout Service Starting ==="
|
||||||
echo "Playlist path: ${PLAYLIST_PATH:=/app/playlist/playlist.txt}"
|
echo "Playlist path: ${PLAYLIST_PATH:=/app/playlist/playlist.txt}"
|
||||||
echo "HLS segment duration: ${HLS_SEGMENT_DURATION:-6}s"
|
echo "HLS segment duration: ${HLS_SEGMENT_DURATION:-2}s"
|
||||||
echo "HLS window size: ${HLS_WINDOW_SIZE:-24}"
|
echo "HLS window size: ${HLS_WINDOW_SIZE:-12}"
|
||||||
|
|
||||||
# Ensure playlist directory and file exist
|
# Ensure playlist directory and file exist
|
||||||
mkdir -p "$(dirname "$PLAYLIST_PATH")"
|
mkdir -p "$(dirname "$PLAYLIST_PATH")"
|
||||||
@@ -17,38 +17,7 @@ file '/app/media/demo2.mp4'
|
|||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run normalization validation using the Go tool
|
# Single source of truth: the Go binary validates the playlist (ADR-001/ADR-003
|
||||||
echo "Validating playlist and enforcing normalization (ADR-001/ADR-003)..."
|
# normalization enforcement) and runs the full loop (HLS, R2 sync, slate
|
||||||
if ! /app/playlist-manager "$PLAYLIST_PATH"; then
|
# fallback, health server). Validation runs once inside the binary.
|
||||||
echo "ERROR: Playlist validation failed. Check logs."
|
exec /app/playlist-manager "$PLAYLIST_PATH"
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Playlist validated successfully. Starting continuous HLS playout..."
|
|
||||||
|
|
||||||
mkdir -p /app/hls
|
|
||||||
rm -f /app/hls/playlist.m3u8 /app/hls/init.mp4 /app/hls/segment_*.ts /app/hls/segment_*.m4s
|
|
||||||
|
|
||||||
# Run FFmpeg in a loop for continuous playback
|
|
||||||
while true; do
|
|
||||||
echo "[$(date)] Starting FFmpeg playout with browser-safe HLS encoding..."
|
|
||||||
ffmpeg -re -f concat -safe 0 -i "$PLAYLIST_PATH" \
|
|
||||||
-map 0:v:0 -map 0:a:0? \
|
|
||||||
-vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p" \
|
|
||||||
-c:v libx264 -preset veryfast -pix_fmt yuv420p \
|
|
||||||
-profile:v high -level 4.0 \
|
|
||||||
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
|
||||||
-c:a aac -ar 48000 -ac 2 -b:a 128k \
|
|
||||||
-f hls \
|
|
||||||
-hls_time "${HLS_SEGMENT_DURATION}" \
|
|
||||||
-hls_list_size "${HLS_WINDOW_SIZE}" \
|
|
||||||
-hls_segment_type fmp4 \
|
|
||||||
-hls_fmp4_init_filename init.mp4 \
|
|
||||||
-strftime 1 \
|
|
||||||
-hls_flags delete_segments+independent_segments \
|
|
||||||
-hls_segment_filename "/app/hls/segment_%Y%m%d_%H%M%S.m4s" \
|
|
||||||
"/app/hls/playlist.m3u8" || echo "FFmpeg exited with error (will restart in 5s per ADR-001 resilience requirements)..."
|
|
||||||
|
|
||||||
echo "Playout loop crashed or ended. Restarting in 5 seconds..."
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
@@ -11,6 +11,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
ports:
|
||||||
|
- "${PLAYOUT_PORT:-8081}:8081"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/playlist:/app/playlist:rw
|
- ./data/playlist:/app/playlist:rw
|
||||||
- ./data/media:/app/media:ro
|
- ./data/media:/app/media:ro
|
||||||
@@ -56,7 +58,7 @@ services:
|
|||||||
container_name: linearvoid-nginx
|
container_name: linearvoid-nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${NGINX_PORT:-8080}:80"
|
- "${NGINX_PORT:-8082}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/hls:/srv/hls:ro
|
- ./data/hls:/srv/hls:ro
|
||||||
networks:
|
networks:
|
||||||
@@ -168,6 +170,8 @@ services:
|
|||||||
command:
|
command:
|
||||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||||
- "--storage.tsdb.path=/prometheus"
|
- "--storage.tsdb.path=/prometheus"
|
||||||
|
- "--storage.tsdb.retention.time=30d"
|
||||||
|
- "--storage.tsdb.retention.size=15GB"
|
||||||
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
||||||
- "--web.console.templates=/etc/prometheus/consoles"
|
- "--web.console.templates=/etc/prometheus/consoles"
|
||||||
- "--web.enable-lifecycle"
|
- "--web.enable-lifecycle"
|
||||||
|
|||||||
+5
-1
@@ -13,6 +13,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
ports:
|
||||||
|
- "${PLAYOUT_PORT:-8081}:8081"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/playlist:/app/playlist:ro
|
- ./data/playlist:/app/playlist:ro
|
||||||
- ./data/media:/app/media:ro
|
- ./data/media:/app/media:ro
|
||||||
@@ -64,7 +66,7 @@ services:
|
|||||||
container_name: linearvoid-nginx
|
container_name: linearvoid-nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${NGINX_PORT:-8080}:80"
|
- "${NGINX_PORT:-8082}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/dist:/srv/frontend:ro
|
- ./frontend/dist:/srv/frontend:ro
|
||||||
- ./data/hls:/srv/hls:ro
|
- ./data/hls:/srv/hls:ro
|
||||||
@@ -162,6 +164,8 @@ services:
|
|||||||
command:
|
command:
|
||||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||||
- "--storage.tsdb.path=/prometheus"
|
- "--storage.tsdb.path=/prometheus"
|
||||||
|
- "--storage.tsdb.retention.time=30d"
|
||||||
|
- "--storage.tsdb.retention.size=15GB"
|
||||||
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
||||||
- "--web.console.templates=/etc/prometheus/consoles"
|
- "--web.console.templates=/etc/prometheus/consoles"
|
||||||
- "--web.enable-lifecycle"
|
- "--web.enable-lifecycle"
|
||||||
|
|||||||
+68
-216
@@ -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: <https://linearvoid.tv>
|
Admin REST endpoints use a cookie session:
|
||||||
- WebSocket endpoint: <wss://linearvoid.tv/ws>
|
|
||||||
- Local fallback: <http://localhost> and <ws://localhost/ws>
|
|
||||||
|
|
||||||
## Authentication Model
|
- 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)
|
||||||
Admin REST endpoints use a cookie session.
|
|
||||||
|
|
||||||
- Cookie name: linearvoid_admin_session
|
|
||||||
- Cookie attributes: HttpOnly, SameSite=Strict, Secure when request is HTTPS (or X-Forwarded-Proto=https)
|
|
||||||
- Session TTL: controlled by ADMIN_SESSION_TTL_HOURS (default 12h)
|
|
||||||
|
|
||||||
## Chat WebSocket API
|
## 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}
|
Server→client envelope adds `"timestamp"` (ISO-8601, e.g. `2026-08-04T12:34:56Z`).
|
||||||
|
|
||||||
Client sends JSON messages over the socket.
|
|
||||||
|
|
||||||
### Message Envelope (client -> server)
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "room": "main", "username": "viewer123", "text": "hello world", "type": "public" }
|
||||||
"room": "main",
|
|
||||||
"username": "viewer123",
|
|
||||||
"text": "hello world",
|
|
||||||
"type": "public"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- type is usually public for room chat and private for DM room routing.
|
|
||||||
- Private room names are formatted like dm:userA-userB (sorted usernames).
|
|
||||||
- Moderation/system events are sent as type=system messages.
|
|
||||||
|
|
||||||
### Message Envelope (server -> client)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"room": "main",
|
|
||||||
"username": "viewer123",
|
|
||||||
"text": "hello world",
|
|
||||||
"type": "public",
|
|
||||||
"timestamp": "2026-08-04T12:34:56Z"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Admin Session API
|
## 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
|
Login request:
|
||||||
- Auth: none
|
|
||||||
|
|
||||||
Request:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "username": "admin", "password": "your-password" }
|
||||||
"username": "admin",
|
|
||||||
"password": "your-password"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Success response:
|
Login/logout/session success response (session returns 401 on failure):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "authenticated": true, "username": "admin" }
|
||||||
"authenticated": true,
|
|
||||||
"username": "admin"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Logout
|
Logout response: `{ "authenticated": false }`
|
||||||
|
|
||||||
- POST /api/admin/logout
|
## Admin Data API (auth required)
|
||||||
- Auth: optional (clears cookie either way)
|
|
||||||
|
|
||||||
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
|
```json
|
||||||
{
|
{ "action": "mute", "target": "username-or-id", "moderator": "admin", "reason": "spam", "room": "main" }
|
||||||
"authenticated": false
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Session Check
|
|
||||||
|
|
||||||
- GET /api/admin/session
|
|
||||||
- Auth: cookie required
|
|
||||||
|
|
||||||
Success response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"authenticated": true,
|
|
||||||
"username": "admin"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Failure:
|
|
||||||
|
|
||||||
- 401 unauthorized
|
|
||||||
|
|
||||||
## Admin Data API
|
|
||||||
|
|
||||||
All endpoints below require admin authentication.
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
|
|
||||||
- GET /api/admin/overview
|
|
||||||
|
|
||||||
Returns aggregate counters and runtime/system snapshot fields.
|
|
||||||
|
|
||||||
### Playlist
|
|
||||||
|
|
||||||
- GET /api/admin/playlist
|
|
||||||
- PUT /api/admin/playlist
|
|
||||||
|
|
||||||
PUT request:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"content": "file '/app/media/video1.mp4'\nfile '/app/media/video2.mp4'\n"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Schedule
|
|
||||||
|
|
||||||
- GET /api/admin/schedule
|
|
||||||
|
|
||||||
Returns parsed playlist entries plus matched media metadata/runtime.
|
|
||||||
|
|
||||||
### Moderation
|
|
||||||
|
|
||||||
- GET /api/admin/moderation/logs?limit=50
|
|
||||||
- POST /api/admin/moderation/actions
|
|
||||||
|
|
||||||
Action request:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "mute",
|
|
||||||
"target": "username-or-id",
|
|
||||||
"moderator": "admin",
|
|
||||||
"reason": "spam",
|
|
||||||
"room": "main"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Analytics
|
|
||||||
|
|
||||||
- GET /api/admin/analytics
|
|
||||||
|
|
||||||
Returns totals (messages, moderation actions, rooms) and top room summary.
|
|
||||||
|
|
||||||
### System
|
|
||||||
|
|
||||||
- GET /api/admin/system
|
|
||||||
|
|
||||||
Returns service health snapshot for chat/playout/redis/database and config metadata.
|
|
||||||
|
|
||||||
### Active Users
|
|
||||||
|
|
||||||
- GET /api/admin/users
|
|
||||||
|
|
||||||
Returns currently discovered presence users (room + username) when Redis is available.
|
|
||||||
|
|
||||||
## Media Library API
|
## Media 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
|
PUT media body:
|
||||||
- POST /api/admin/media (multipart upload, legacy path)
|
|
||||||
|
|
||||||
Multipart form field name: file
|
|
||||||
|
|
||||||
### Media Item
|
|
||||||
|
|
||||||
- GET /api/admin/media/{id}
|
|
||||||
- PUT /api/admin/media/{id}
|
|
||||||
- DELETE /api/admin/media/{id}
|
|
||||||
|
|
||||||
PUT request:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "status": "ready", "title": "My Clip", "tags": "music,live" }
|
||||||
"status": "ready",
|
|
||||||
"title": "My Clip",
|
|
||||||
"tags": "music,live"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Resumable Upload API
|
## 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
|
Initialize request:
|
||||||
|
|
||||||
- POST /api/admin/media/uploads
|
|
||||||
|
|
||||||
Request:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -223,7 +108,7 @@ Request:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Response:
|
Status envelope (returned by init, status check, and chunk upload):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -237,61 +122,33 @@ Response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Upload Status
|
## Metrics & Health
|
||||||
|
|
||||||
- GET /api/admin/media/uploads/{uploadId}
|
- `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
|
||||||
Returns same status envelope as initialize.
|
|
||||||
|
|
||||||
### Upload Chunk
|
|
||||||
|
|
||||||
- PUT /api/admin/media/uploads/{uploadId}/chunk?index={chunkIndex}
|
|
||||||
- Content-Type: application/octet-stream
|
|
||||||
|
|
||||||
Response returns updated upload status envelope.
|
|
||||||
|
|
||||||
### Complete Upload
|
|
||||||
|
|
||||||
- POST /api/admin/media/uploads/{uploadId}/complete
|
|
||||||
|
|
||||||
On success returns admin media listing payload (files + generatedAt).
|
|
||||||
|
|
||||||
## Metrics and Health
|
|
||||||
|
|
||||||
### Chat metrics
|
|
||||||
|
|
||||||
- GET /metrics
|
|
||||||
|
|
||||||
Prometheus exposition includes connected clients, stored messages, moderation actions, Redis indicators, active admin sessions, and build info.
|
|
||||||
|
|
||||||
### Health
|
|
||||||
|
|
||||||
- GET /health on chat service
|
|
||||||
- GET /health on playout service
|
|
||||||
- GET /health on nginx reverse proxy
|
|
||||||
|
|
||||||
## HTTP Status Conventions
|
## HTTP Status Conventions
|
||||||
|
|
||||||
| Code | Meaning | When |
|
| Code | Meaning | When |
|
||||||
| ---- | --------------------- | ----------------------------------------------------------- |
|
| ---- | --------------------- | ----------------------------------------------------------- |
|
||||||
| 200 | OK | Successful GET, PUT, or action |
|
| 200 | OK | Successful GET, PUT, action |
|
||||||
| 201 | Created | Successful POST (upload init, override created) |
|
| 201 | Created | Successful POST (upload init, override created) |
|
||||||
| 204 | No Content | Successful DELETE, CORS preflight |
|
| 204 | No Content | Successful DELETE, CORS preflight |
|
||||||
| 400 | Bad Request | Malformed JSON, missing required fields, invalid input |
|
| 400 | Bad Request | Malformed JSON, missing required fields, invalid input |
|
||||||
| 401 | Unauthorized | Missing or expired admin session |
|
| 401 | Unauthorized | Missing/expired admin session |
|
||||||
| 403 | Forbidden | Valid session but insufficient permissions |
|
| 403 | Forbidden | Valid session, insufficient permissions |
|
||||||
| 404 | Not Found | Resource doesn't exist |
|
| 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 |
|
| 409 | Conflict | Schedule override overlap, upload ID mismatch |
|
||||||
| 413 | Payload Too Large | Upload exceeds 2 GB limit |
|
| 413 | Payload Too Large | Upload exceeds 2 GB limit |
|
||||||
| 422 | Unprocessable | File fails normalization check (codec, fps, resolution) |
|
| 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 |
|
| 500 | Internal Server Error | Unexpected server failure |
|
||||||
| 503 | Service Unavailable | Redis unavailable for user actions, playout health degraded |
|
| 503 | Service Unavailable | Redis unavailable for user actions, playout health degraded |
|
||||||
|
|
||||||
## Rate Limiting
|
## 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 |
|
| 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"` |
|
| WebSocket connections | 5 | 60s | 429 + `"too many connections from this IP"` |
|
||||||
| Admin login | 5 | 5min | 429 + `"too many login attempts, try again later"` |
|
| Admin login | 5 | 5min | 429 + `"too many login attempts, try again later"` |
|
||||||
|
|
||||||
Rate limit headers on applicable responses:
|
Rate limit headers (applicable responses): `X-RateLimit-Limit` (max requests/window), `Retry-After` (seconds until reset). 413 = payload too large; 500 = internal server error.
|
||||||
|
|
||||||
- `X-RateLimit-Limit`: maximum requests per window
|
|
||||||
- `Retry-After`: seconds until window resets
|
|
||||||
- 413: payload too large
|
|
||||||
- 500: internal server error
|
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
# Coolify Deployment
|
||||||
|
|
||||||
|
*Last Updated: 2026-08-28*
|
||||||
|
|
||||||
|
Alternative deployment path for Linear Void using [Coolify](https://github.com/coollabsio/coolify/tree/v4.3.14) (v4.3.14), an open-source self-hostable PaaS that manages servers, apps, and databases over SSH. Drop-in alternative to the flows in [SETUP.md](./SETUP.md): local dev (`docker-compose.yml`) and test-lab/prod (`docker-compose.prod.yml` via Gitea Actions CD). Coolify deploys the same Compose stack through a web UI and its own Traefik reverse proxy for automatic HTTPS, domain routing, and one-click redeploys (no manual `DOCKER_HOST`).
|
||||||
|
|
||||||
|
## Why Coolify
|
||||||
|
|
||||||
|
- **Single control plane**: manage playout, chat, nginx, redis, db (plus exporters/monitoring) from one dashboard.
|
||||||
|
- **Automatic TLS**: Let's Encrypt provisioning/renewal per app/domain, replacing the manual Cloudflare + nginx origin TLS setup for the app hostname.
|
||||||
|
- **No vendor lock-in**: config lives on your server; containers keep running if you stop using Coolify — you only lose automation.
|
||||||
|
- **Simpler cutover**: on Hetzner VPS provisioning, point Coolify at the new server and redeploy instead of editing CD `DOCKER_HOST`.
|
||||||
|
|
||||||
|
> Replaces the *deployment orchestration* layer only. Cloudflare DNS, R2 media bucket, cache rules, and the edge Worker from [SETUP.md](./SETUP.md) are unchanged.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Linux server with **Docker Engine** (any supported distro).
|
||||||
|
- **SSH access** (root or passwordless `sudo`) from the Coolify install machine — Coolify provisions the target over SSH.
|
||||||
|
- **2 GB RAM / 20 GB disk** minimum for Coolify itself (plus resources for the stack).
|
||||||
|
- A domain pointed at the server for HTTPS (e.g. `linearvoid.tv`).
|
||||||
|
- Ports `80` and `443` reachable for Traefik.
|
||||||
|
- Gitea registry credentials (`CI_REGISTRY_USER` / `CI_REGISTRY_TOKEN`) if pulling prebuilt `playout`, `chat`, `nginx` images.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Recommended on a dedicated server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer boots a Docker-based Coolify instance and prints the URL and default credentials. Complete first-run setup in the dashboard. For non-interactive/offline installs, see the [installation docs](https://coolify.io/docs/installation) and [install script source](https://github.com/coollabsio/coolify/blob/v4.3.14/scripts/install.sh).
|
||||||
|
|
||||||
|
## Add the Linear Void server
|
||||||
|
|
||||||
|
1. **Servers → Add**.
|
||||||
|
2. Choose your provider (or **Localhost** if Coolify runs on the same host as the stack).
|
||||||
|
3. Provide SSH details; for a remote host Coolify validates and installs its agent.
|
||||||
|
4. Confirm the server shows **Reachable** before creating resources.
|
||||||
|
|
||||||
|
### Current topology (verified 2026-08-30)
|
||||||
|
|
||||||
|
Active test-lab setup is **two-tier**:
|
||||||
|
|
||||||
|
| Component | Host | Exposes | Docker TCP | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Coolify dashboard** | `192.168.88.18` | `https://coolify.allucanget.biz` via Cloudflare tunnel (`coolify-cloudflared`); dashboard at `8000` | No | Manages target servers over SSH |
|
||||||
|
| **Deployment target** | `192.168.88.40` | `coolify-proxy` = Traefik on 80/443; port `8000` bound by Portainer, not Coolify | Yes | Per-server helpers (`coolify-proxy`, `coolify-sentinel`, `coolify-cloudflared`) + the stack via `docker-compose.prod.yml` (`linearvoid` compose project, 11 containers) |
|
||||||
|
|
||||||
|
The main `coolify` app container runs only on the dashboard host `192.168.88.18`; Compose-resource services land on the target `192.168.88.40`. The dashboard host exposes no Docker TCP socket, so run the checks below against the **target** `192.168.88.40`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check-env.sh # env/deployment health on target (containers, helpers, health)
|
||||||
|
./scripts/check-tokens.sh # Gitea registry, Cloudflare, R2, admin credential access
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy the stack
|
||||||
|
|
||||||
|
Deploy as a **Docker Compose** resource (not individual apps):
|
||||||
|
|
||||||
|
1. **Projects → Add → Docker Compose**.
|
||||||
|
2. Select the target server.
|
||||||
|
3. In **Build Settings**, set the compose file source:
|
||||||
|
- **Git Repository** → point at `https://git.allucanget.biz/zwitschi/linearvoid.git`, compose file `docker-compose.prod.yml` (Coolify pulls on each redeploy).
|
||||||
|
- **Raw Compose** → paste `docker-compose.prod.yml` directly to manage manually.
|
||||||
|
4. For the Git source, add the Gitea registry login as a **Deployment Key / environment secret** so `playout`, `chat`, `nginx` images can be pulled. Alternatively switch those services to `build:` blocks (as in `docker-compose.yml`) to build from source on the server.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
Map Linear Void's `.env` values into Coolify's **Environment Variables** tab:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `DATABASE_URL` | `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable` |
|
||||||
|
| `REDIS_URL` | `redis://redis:6379/0` |
|
||||||
|
| `POSTGRES_PASSWORD` | DB password (used by `db` service) |
|
||||||
|
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | Admin panel credentials |
|
||||||
|
| `ADMIN_SESSION_TTL_HOURS` | Admin session lifetime |
|
||||||
|
| `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` | Cloudflare R2 credentials |
|
||||||
|
| `R2_BUCKET_NAME` / `R2_ENDPOINT` / `R2_REGION` | R2 bucket config (`R2_REGION=auto`) |
|
||||||
|
| `MEDIA_DOMAIN` | `media.linearvoid.tv` |
|
||||||
|
| `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` |
|
||||||
|
| `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` |
|
||||||
|
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` |
|
||||||
|
|
||||||
|
Keep internal service URLs on compose hostnames (`redis`, `db`) exactly as in `docker-compose.prod.yml`.
|
||||||
|
|
||||||
|
### Persistent storage
|
||||||
|
|
||||||
|
Named volumes (`redis-data`, `postgres-data`) and bind mounts under `./data` must be configured under **Persistent Storage** to survive redeploys:
|
||||||
|
|
||||||
|
| Mount | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `data/playlist` | Playout playlist file |
|
||||||
|
| `data/media` | Normalized source videos (read-only to playout) |
|
||||||
|
| `data/hls` | Generated HLS manifests/segments |
|
||||||
|
| `data/chat` | Chat service working data |
|
||||||
|
| `data/slate` | Slate media |
|
||||||
|
| `redis-data` | Redis append-only persistence |
|
||||||
|
| `postgres-data` | PostgreSQL data directory |
|
||||||
|
|
||||||
|
If Coolify doesn't auto-create the dirs, create them before first deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data/playlist data/media data/hls data/chat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domains and proxy
|
||||||
|
|
||||||
|
1. Assign the **nginx** service a public domain (e.g. `linearvoid.tv`); Traefik terminates TLS and forwards to `nginx`.
|
||||||
|
2. Set **Ports Exposes** so Traefik targets the nginx port (`80` inside container, mapped from `${NGINX_PORT:-8082}:80`).
|
||||||
|
3. Do **not** publicly expose playout (`8081`) or chat (`3000`) health ports — keep them internal and route through nginx.
|
||||||
|
4. For `media.linearvoid.tv`, keep serving HLS from the R2 custom domain (per SETUP.md) rather than routing through Coolify, so segments stay on Cloudflare's edge cache.
|
||||||
|
|
||||||
|
## Post-deploy verification
|
||||||
|
|
||||||
|
- `docker compose ps` on the server shows all services healthy.
|
||||||
|
- Playout health: `https://<host>/health` via nginx (internal `:8081/health`).
|
||||||
|
- Chat health: internal `:3000/health`.
|
||||||
|
- HLS written to `data/hls` and served from `media.linearvoid.tv`.
|
||||||
|
- Chat WebSocket connects over `wss://linearvoid.tv/ws`.
|
||||||
|
- Admin login works at `/admin` with configured credentials.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Coolify exposes container logs/basic metrics per resource. The stack ships Prometheus + Grafana (`monitoring/`) plus nginx/redis exporters. To reach them via Coolify, expose Grafana on a restricted subdomain (e.g. `grafana.linearvoid.tv`) and keep Prometheus internal, mirroring SETUP.md access guidance.
|
||||||
|
|
||||||
|
## Cutover from Gitea CD
|
||||||
|
|
||||||
|
1. Deploy the stack as a Coolify compose resource (above).
|
||||||
|
2. Point Cloudflare DNS for `linearvoid.tv` / `www` at the server IP (proxied).
|
||||||
|
3. Disable (or keep as fallback) the `cd` job in the Gitea workflow so the two orchestrators don't fight over the same `DOCKER_HOST`.
|
||||||
|
4. Keep the Gitea **CI** (build + push images) job so Coolify keeps pulling `:latest` from the registry.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Image pull fails**: set Gitea registry credentials in the compose secret store, or switch services to `build:` blocks.
|
||||||
|
- **Healthchecks fail**: confirm `curl` exists in the built images (healthchecks rely on it).
|
||||||
|
- **TLS errors**: check the domain's DNS points at the Coolify server and ports `80`/`443` are open.
|
||||||
|
- **HLS not updating**: verify `data/hls` is writable and playout restarts cleanly; see SETUP.md normalization troubleshooting.
|
||||||
|
- **Ports conflict**: Coolify's Traefik binds `80`/`443`; do not also map those to the host in the compose file.
|
||||||
|
|
||||||
|
## Further reading
|
||||||
|
|
||||||
|
- Coolify repo (v4.3.14): <https://github.com/coollabsio/coolify/tree/v4.3.14>
|
||||||
|
- Coolify docs: <https://coolify.io/docs>
|
||||||
|
- [DEPLOYMENT.md](./DEPLOYMENT.md) · [SETUP.md](./SETUP.md)
|
||||||
+57
-151
@@ -1,63 +1,28 @@
|
|||||||
# Deployment Guide
|
# 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)
|
|
||||||
- [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
|
## Environments
|
||||||
|
|
||||||
Linear Void runs in two separate environments:
|
|
||||||
|
|
||||||
| Environment | Host | Docker Target | Purpose |
|
| Environment | Host | Docker Target | Purpose |
|
||||||
| -------------- | -------------------------- | -------------------------- | ---------------------------------------------- |
|
| -------------- | -------------------------- | -------------------------- | ---------------------------------------------- |
|
||||||
| **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation |
|
| **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation |
|
||||||
| **Production** | Hetzner VPS (TBD) | `tcp://<hetzner-ip>:2375` | Public linearvoid.tv serving real viewers |
|
| **Production** | Hetzner VPS (TBD) | `tcp://<hetzner-ip>:2375` | Public linearvoid.tv |
|
||||||
|
|
||||||
**Current state (2026-08-06):** Test lab is active and receives automated CD deploys. Production Hetzner VPS is **not yet provisioned**. 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)
|
## 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
|
### Planned Infrastructure
|
||||||
|
|
||||||
| Resource | Spec |
|
- Provider: Hetzner Cloud, Region: Ashburn, VA (us-east)
|
||||||
| -------------- | ------------------------------------------------ |
|
- Instance: CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent
|
||||||
| Provider | Hetzner Cloud |
|
- OS: Ubuntu 24.04 LTS or Debian 12
|
||||||
| Region | Ashburn, VA (us-east) |
|
- Bandwidth: 20 TB included traffic/month; Estimated cost: ~€7–10/month base
|
||||||
| 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
|
### 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)
|
2. Add production secrets to Gitea (`CI_PROD_*` prefix)
|
||||||
3. Update `DOCKER_HOST` in CD workflow to Hetzner public IP
|
3. Update `DOCKER_HOST` in CD workflow to Hetzner public IP
|
||||||
4. Trigger deploy → validate health checks
|
4. Trigger deploy → validate health checks
|
||||||
@@ -65,18 +30,14 @@ Linear Void runs in two separate environments:
|
|||||||
6. Enable HTTPS, WAF, cache rules on production zone
|
6. Enable HTTPS, WAF, cache rules on production zone
|
||||||
7. Decommission test lab as public target
|
7. Decommission test lab as public target
|
||||||
|
|
||||||
### Production Preflight
|
### Production Preflight (minimum readiness gate)
|
||||||
|
|
||||||
Before the cutover in the plan above, treat the following as the 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
|
||||||
1. Verify the Hetzner host is reachable over SSH and Docker remote API TLS is working for the Gitea runner.
|
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, `media.linearvoid.tv` DNS targets match intended phase
|
||||||
2. Confirm all required production secrets are present under the `CI_PROD_*` prefix.
|
4. Complete manual Cloudflare/UptimeRobot checklist (see `docs/operations/cloudflare-uptimerobot-checklist.md`)
|
||||||
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv` DNS targets are correct for the intended phase.
|
5. Run production smoke path end-to-end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, playout health
|
||||||
4. Complete the manual Cloudflare/UptimeRobot checklist and attach evidence in [Cloudflare Uptimerobot checklist](../docs/operations/cloudflare-uptimerobot-checklist.md).
|
6. Verify rollback possible before final DNS switch
|
||||||
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.
|
|
||||||
|
|
||||||
### Firewall / Security Group
|
### Firewall / Security Group
|
||||||
|
|
||||||
@@ -91,18 +52,14 @@ Readiness is not complete until the checklist above and the manual evidence trac
|
|||||||
|
|
||||||
### Pipeline Readiness
|
### Pipeline Readiness
|
||||||
|
|
||||||
Three Gitea Actions workflows drive the full pipeline:
|
| Workflow | Trigger | Purpose |
|
||||||
|
| -------------------- | ----------------- | --------------------------------------------------------------------------------- |
|
||||||
|
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push 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 |
|
||||||
|
|
||||||
| Workflow | Trigger | Purpose |
|
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:
|
||||||
| ------------------ | ---------------- | ---------------------------------------------------------------------------------------- |
|
|
||||||
| `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 |
|
|
||||||
| `pages-deploy.yml` | Push to `main` | Deploy frontend to Cloudflare Pages (see `docs/operations/gitea-cloudflare-pages.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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test lab (current)
|
# Test lab (current)
|
||||||
@@ -112,7 +69,7 @@ DOCKER_HOST=tcp://192.168.88.40:2375
|
|||||||
DOCKER_HOST=tcp://<hetzner-public-ip>:2375
|
DOCKER_HOST=tcp://<hetzner-public-ip>: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
|
## Target Topology
|
||||||
|
|
||||||
@@ -126,12 +83,12 @@ No code changes needed in the pipeline. See `DEPLOYMENT.md` Production Deploymen
|
|||||||
|
|
||||||
- Docker Engine + Docker Compose plugin
|
- Docker Engine + Docker Compose plugin
|
||||||
- Network access to git.allucanget.biz
|
- Network access to git.allucanget.biz
|
||||||
- A valid .env file with production values
|
- Valid `.env` file with production values
|
||||||
- DNS configured for linearvoid.tv (and media.linearvoid.tv if split media domain is used)
|
- DNS configured for `linearvoid.tv` (and `media.linearvoid.tv` if split media domain used)
|
||||||
|
|
||||||
## Environment File
|
## Environment File
|
||||||
|
|
||||||
Use `.env` with at least:
|
`.env` requires at minimum:
|
||||||
|
|
||||||
- `DATABASE_URL` — PostgreSQL connection string
|
- `DATABASE_URL` — PostgreSQL connection string
|
||||||
- `REDIS_URL` — Redis connection string
|
- `REDIS_URL` — Redis connection string
|
||||||
@@ -141,12 +98,12 @@ Use `.env` with at least:
|
|||||||
|
|
||||||
**Cloudflare R2 (required for HLS segment sync to edge):**
|
**Cloudflare R2 (required for HLS segment sync to edge):**
|
||||||
|
|
||||||
- `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` — R2 write credentials
|
- `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY`
|
||||||
- `R2_BUCKET_NAME` — bucket name (e.g. `linearvoid-media`)
|
- `R2_BUCKET_NAME` (e.g. `linearvoid-media`)
|
||||||
- `R2_ENDPOINT` — S3-compatible endpoint URL
|
- `R2_ENDPOINT` — S3-compatible endpoint URL
|
||||||
- `R2_REGION` — set to `auto`
|
- `R2_REGION` — set to `auto`
|
||||||
|
|
||||||
**Cloudflare (optional, for Workers/Pages):**
|
**Cloudflare (optional, for Workers):**
|
||||||
|
|
||||||
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`
|
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`
|
||||||
- `MEDIA_DOMAIN` — split media domain (e.g. `media.linearvoid.tv`)
|
- `MEDIA_DOMAIN` — split media domain (e.g. `media.linearvoid.tv`)
|
||||||
@@ -155,149 +112,99 @@ See `.env.example` and `SETUP.md` for full variable reference.
|
|||||||
|
|
||||||
### Gitea Secrets
|
### Gitea Secrets
|
||||||
|
|
||||||
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:
|
`cd.yml` injects sensitive values from Gitea secrets into `.env` at deploy time. Configure in the Gitea repository:
|
||||||
|
|
||||||
| Secret Name | Description |
|
`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`
|
||||||
| ------------------------- | ------------------------------------ |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## Pull-and-Run Deployment
|
## 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).
|
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).
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker login git.allucanget.biz
|
docker login git.allucanget.biz
|
||||||
|
|
||||||
docker compose -f docker-compose.prod.yml pull
|
docker compose -f docker-compose.prod.yml pull
|
||||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||||
```
|
```
|
||||||
|
|
||||||
## First-Time or Recovery Provisioning
|
## First-Time or Recovery Provisioning
|
||||||
|
|
||||||
1. Ensure host paths exist:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/chat data/playlist data/media data/hls
|
mkdir -p data/chat data/playlist data/media data/hls
|
||||||
```
|
|
||||||
|
|
||||||
1. Confirm volumes and networks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker volume ls
|
docker volume ls
|
||||||
docker network ls
|
docker network ls
|
||||||
```
|
|
||||||
|
|
||||||
1. Start stack:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## Health Verification
|
## Health Verification
|
||||||
|
|
||||||
After startup, check service status:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.prod.yml ps
|
docker compose -f docker-compose.prod.yml ps
|
||||||
```
|
```
|
||||||
|
|
||||||
Endpoint checks:
|
Endpoint checks:
|
||||||
|
|
||||||
- App: <http://{host}:8080/>
|
- App: `http://{host}:8080/`
|
||||||
- Nginx health: <http://{host}:8080/health>
|
- Nginx health: `http://{host}:8080/health`
|
||||||
- Chat health: <http://{host}:3000/health>
|
- Chat health: `http://{host}:3000/health`
|
||||||
- Playout health: <http://{host}:8081/health>
|
- Playout health: `http://{host}:8081/health`
|
||||||
- Prometheus: <http://{host}:9090>
|
- Prometheus: `http://{host}:9090`
|
||||||
- Grafana: <http://{host}:3001>
|
- Grafana: `http://{host}:3001`
|
||||||
|
|
||||||
Domain checks:
|
Domain checks:
|
||||||
|
|
||||||
- <https://linearvoid.tv>
|
- `https://linearvoid.tv`
|
||||||
- <https://linearvoid.tv/admin>
|
- `https://linearvoid.tv/admin`
|
||||||
- <https://linearvoid.tv/metrics>
|
- `https://linearvoid.tv/metrics`
|
||||||
|
|
||||||
## Zero/Low-Downtime Update Pattern
|
## Zero/Low-Downtime Update Pattern
|
||||||
|
|
||||||
1. Pull newest images.
|
1. Pull newest images.
|
||||||
1. Recreate changed services only:
|
2. Recreate changed services only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.prod.yml pull
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||||
|
```
|
||||||
|
|
||||||
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
3. Re-run health checks.
|
||||||
```
|
|
||||||
|
|
||||||
1. Re-run health checks.
|
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
If new images are unhealthy:
|
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.
|
2. Pull pinned tags.
|
||||||
3. Restart impacted services.
|
3. Restart impacted services:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.prod.yml pull
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
|
||||||
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||||
```
|
```
|
||||||
|
|
||||||
## Operational Tasks
|
## Operational Tasks
|
||||||
|
|
||||||
### View logs
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# View logs
|
||||||
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
|
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
|
||||||
```
|
|
||||||
|
|
||||||
### Restart one service
|
# Restart one service
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.prod.yml restart chat
|
docker compose -f docker-compose.prod.yml restart chat
|
||||||
```
|
|
||||||
|
|
||||||
### Stop stack
|
# Stop stack (keep data)
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.prod.yml down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Keep volumes (recommended) vs remove volumes (destructive)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# keep data
|
|
||||||
docker compose -f docker-compose.prod.yml down
|
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
|
docker compose -f docker-compose.prod.yml down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
## Nginx and Proxy Considerations
|
## 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).
|
- 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.
|
- For resumable admin uploads, ensure `client_max_body_size` supports chunk requests in both proxy layers.
|
||||||
|
|
||||||
## Security Baseline
|
## Security Baseline
|
||||||
|
|
||||||
- Use strong ADMIN_PASSWORD and rotate regularly.
|
- Use a strong `ADMIN_PASSWORD` and rotate regularly.
|
||||||
- Keep Docker host access restricted.
|
- Keep Docker host access restricted.
|
||||||
- Prefer HTTPS/WSS through Cloudflare.
|
- Prefer HTTPS/WSS through Cloudflare.
|
||||||
- Restrict exposed ports if only proxy access is needed.
|
- Restrict exposed ports if only proxy access is needed.
|
||||||
@@ -321,5 +228,4 @@ docker compose -f docker-compose.prod.yml down -v
|
|||||||
| Prometheus no targets | `curl http://<host>:9090/targets` | Verify container network, scrape config |
|
| Prometheus no targets | `curl http://<host>:9090/targets` | Verify container network, scrape config |
|
||||||
| Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned |
|
| Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned |
|
||||||
|
|
||||||
- Proxy-layer body size limits returning 413 on upload.
|
Additional notes: proxy-layer body size limits can return 413 on upload; health checks may fail before service warmup completes.
|
||||||
- Health checks failing before service warmup is complete.
|
|
||||||
+4
-11
@@ -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
|
- **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
|
- **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:
|
See detailed scripts for querying Prometheus scrape targets:
|
||||||
|
|
||||||
@@ -78,20 +78,13 @@ See detailed scripts for querying Prometheus scrape targets:
|
|||||||
| Prometheus targets down | Check container network: all must be on `linearvoid-net` |
|
| 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 |
|
| 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 <http://prometheus:9090>.
|
**High cardinality / slow queries**: reduce unneeded labels; limit expensive panels; tune retention and query windows.
|
||||||
- Confirm Prometheus is healthy.
|
|
||||||
- Check dashboard query labels match active jobs.
|
|
||||||
|
|
||||||
### High cardinality or query slowness
|
|
||||||
|
|
||||||
- Reduce unneeded labels.
|
|
||||||
- Limit expensive dashboard panels.
|
|
||||||
- Tune retention and query windows.
|
|
||||||
|
|
||||||
## Operational Recommendations
|
## Operational Recommendations
|
||||||
|
|
||||||
- Keep alert thresholds tied to actual user impact.
|
- Keep alert thresholds tied to actual user impact.
|
||||||
- Review dashboards after each release that changes endpoints or metrics names.
|
- Review dashboards after each release that changes endpoints or metrics names.
|
||||||
- Periodically test alert routing and incident response flow.
|
- Periodically test alert routing and incident response flow.
|
||||||
|
- Retention: **30 days** time-based plus **15GB** size cap (`--storage.tsdb.retention.time=30d --storage.tsdb.retention.size=15GB` on the `prometheus` service in `docker-compose.yml` and `docker-compose.prod.yml`). Increase the size cap before extending the time window so the TSDB does not hit the disk limit first.
|
||||||
+1
-1
@@ -9,7 +9,7 @@ This folder contains the documentation for the Linear Void project. The document
|
|||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
- Cloudflare
|
- Cloudflare
|
||||||
- [Cache Rules](./infrastructure/cloudflare/cache-rules.md) - Cache rules for Cloudflare Workers and Pages.
|
- [Cache Rules](./infrastructure/cloudflare/cache-rules.md) - Cache rules for Cloudflare Workers.
|
||||||
- [Account Troubleshooting](./infrastructure/cloudflare/cloudflare-account-troubleshooting.md) - Troubleshooting Cloudflare account issues.
|
- [Account Troubleshooting](./infrastructure/cloudflare/cloudflare-account-troubleshooting.md) - Troubleshooting Cloudflare account issues.
|
||||||
- Monitoring
|
- Monitoring
|
||||||
- [Prometheus CURL](./infrastructure/monitoring/prometheus_curl.md) - Prometheus metrics endpoint testing with curl and jq. Doocumentation of available metrics and their meanings.
|
- [Prometheus CURL](./infrastructure/monitoring/prometheus_curl.md) - Prometheus metrics endpoint testing with curl and jq. Doocumentation of available metrics and their meanings.
|
||||||
|
|||||||
+82
-524
@@ -1,592 +1,150 @@
|
|||||||
# Linear Void Setup Guide
|
# Linear Void Setup Guide
|
||||||
|
|
||||||
This guide covers prerequisites, environment setup, running the application locally and in production, and troubleshooting. It references the [architecture documentation](./docs/architecture/README.md)(`docs/architecture/README.md`).
|
**Last Updated**: 2026-08-03
|
||||||
|
|
||||||
|
Prerequisites, environment setup, and running the app locally and in production. Architecture: [`docs/architecture/README.md`](./architecture/README.md).
|
||||||
|
|
||||||
## Prerequisites
|
## 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).
|
- **Docker + Docker Compose** — runs the full stack (playout, chat, nginx, redis, db, prometheus, grafana).
|
||||||
- **Node.js 22+**: Required for frontend development and production builds (`cd frontend && npm install && npm run build`).
|
- **Node.js 22+ / npm** — frontend dev and 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+** — working on the Go services outside Docker.
|
||||||
- **Go 1.23+**: Recommended when working on the Go services outside Docker (`backend/chat`, `backend/playout`).
|
- **FFmpeg** — playout HLS generation (`apt install ffmpeg`).
|
||||||
- **FFmpeg**: Used by the playout service for HLS generation. Install via `apt install ffmpeg` (Linux), Homebrew (macOS), or Winget (Windows).
|
- **Git**.
|
||||||
- **Git**: For cloning and version control.
|
- **Cloudflare account** — R2 storage + CDN.
|
||||||
- **Cloudflare Account**: For R2 storage and CDN (free tier sufficient for most use cases).
|
- **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`.
|
||||||
- **Gitea Account**: Required if you want to push images to the self-hosted registry or use the repository's CI/CD flow.
|
- **Hetzner Cloud (optional, prod)** — US-East VPS (Ashburn, VA per ADR-001).
|
||||||
- Set up the required Actions secrets in Gitea at: [https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets](https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets)
|
|
||||||
- Add the following secrets:
|
|
||||||
- `CI_REGISTRY_USER`: Your Gitea username (e.g., `zwitschi`)
|
|
||||||
- `CI_REGISTRY_TOKEN`: A Gitea Personal Access Token with `write:packages` scope
|
|
||||||
- `ADMIN_USERNAME`: The administrator username for the production admin panel (e.g., `admin`)
|
|
||||||
- `ADMIN_PASSWORD`: A strong password for the production admin panel
|
|
||||||
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`)
|
|
||||||
- **Hetzner Cloud Account** (optional for production): For the US-East VPS (Ashburn, VA recommended per ADR-001).
|
|
||||||
|
|
||||||
## Network Environments
|
## 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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph Test_Lab["Test Lab (192.168.88.0/24)"]
|
subgraph Test_Lab["Test Lab (192.168.88.0/24)"]
|
||||||
TEST_HOST["192.168.88.40<br/>Docker Engine :2375"]
|
TEST_HOST["192.168.88.40<br/>Docker Engine :2375"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Prod["Production (Hetzner — NOT provisioned)"]
|
subgraph Prod["Production (Hetzner — NOT provisioned)"]
|
||||||
PROD_HOST["Hetzner CX22 Ashburn, VA<br/>Public IP TBD"]
|
PROD_HOST["Hetzner CX22 Ashburn, VA<br/>Public IP TBD"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Gitea["Gitea CI/CD"]
|
subgraph Gitea["Gitea CI/CD"]
|
||||||
CI["CI: build + push images"]
|
CI["CI: build + push images"]
|
||||||
CD["CD: deploy via DOCKER_HOST"]
|
CD["CD: deploy via DOCKER_HOST"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph CF["Cloudflare"]
|
subgraph CF["Cloudflare"]
|
||||||
DNS["linearvoid.tv"]
|
DNS["linearvoid.tv"]
|
||||||
R2["R2 Bucket"]
|
R2["R2 Bucket"]
|
||||||
end
|
end
|
||||||
|
|
||||||
CD -->|"DOCKER_HOST=tcp://192.168.88.40:2375"| TEST_HOST
|
CD -->|"DOCKER_HOST=tcp://192.168.88.40:2375"| TEST_HOST
|
||||||
CD -.->|"DOCKER_HOST=tcp://<hetzner-ip>:2375<br/>(when provisioned)"| PROD_HOST
|
CD -.->|"DOCKER_HOST=tcp://<hetzner-ip>:2375 (when provisioned)"| PROD_HOST
|
||||||
TEST_HOST -->|"test traffic"| DNS
|
TEST_HOST -->|"test traffic"| DNS
|
||||||
PROD_HOST -.->|"production traffic"| DNS
|
PROD_HOST -.->|"production traffic"| DNS
|
||||||
TEST_HOST --> R2
|
TEST_HOST --> R2
|
||||||
PROD_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.
|
- **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).
|
||||||
**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.
|
|
||||||
|
|
||||||
## Environment Setup
|
## Environment Setup
|
||||||
|
|
||||||
1. **Clone the Repository** (if not already done):
|
1. `git clone https://git.allucanget.biz/zwitschi/linearvoid.git && cd linearvoid`
|
||||||
|
2. `cp .env.example .env` then replace placeholders.
|
||||||
```bash
|
3. Compose overrides: `docker-compose.yml` builds images locally and mounts `frontend/dist` into nginx; `docker-compose.prod.yml` pulls prebuilt images from Gitea.
|
||||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
- Internal URLs use compose hostnames: `REDIS_URL=redis://redis:6379/0`, `DATABASE_URL=postgres://linearvoid:<pw>@db:5432/linearvoid?sslmode=disable`.
|
||||||
cd linearvoid
|
- 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).
|
||||||
2. **Copy Environment File**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Configure `.env`** (edit with your values):
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Cloudflare R2 (required for video storage and $0 egress)
|
|
||||||
R2_ACCESS_KEY_ID=your-r2-access-key
|
|
||||||
R2_SECRET_ACCESS_KEY=your-r2-secret-key
|
|
||||||
R2_BUCKET_NAME=linearvoid-media
|
|
||||||
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
|
||||||
R2_REGION=auto
|
|
||||||
|
|
||||||
# Cloudflare (for custom domain and cache rules)
|
|
||||||
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
|
||||||
CLOUDFLARE_ACCOUNT_ID=your-account-id
|
|
||||||
MEDIA_DOMAIN=media.linearvoid.tv
|
|
||||||
|
|
||||||
# Public frontend URLs
|
|
||||||
# Preferred local setup uses the real public domains through Cloudflare and Nginx Proxy Manager.
|
|
||||||
VITE_PUBLIC_APP_URL=https://linearvoid.tv
|
|
||||||
VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
|
|
||||||
VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8
|
|
||||||
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=admin
|
|
||||||
VITE_PUBLIC_ADMIN_DEFAULT_ROOM=main
|
|
||||||
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_PASSWORD=change-me
|
|
||||||
ADMIN_SESSION_TTL_HOURS=12
|
|
||||||
# Optional direct-local fallback when bypassing the public proxy:
|
|
||||||
# VITE_PUBLIC_APP_URL=http://localhost
|
|
||||||
# VITE_PUBLIC_WS_URL=ws://localhost/ws
|
|
||||||
# VITE_PUBLIC_HLS_URL=http://localhost/hls/playlist.m3u8
|
|
||||||
|
|
||||||
# Gitea Container Registry (for self-hosted Docker images)
|
|
||||||
CI_REGISTRY_URL=git.allucanget.biz
|
|
||||||
CI_REGISTRY_USER=your-gitea-username
|
|
||||||
CI_REGISTRY_TOKEN=your-gitea-token
|
|
||||||
CI_REGISTRY_NAMESPACE=zwitschi/linearvoid
|
|
||||||
# Gitea API access (optional, for CI/release automation)
|
|
||||||
CI_SERVER_URL=https://git.allucanget.biz
|
|
||||||
|
|
||||||
# Chat / Database
|
|
||||||
DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
|
|
||||||
# or for SQLite-only local development:
|
|
||||||
# DATABASE_URL=sqlite://./data/chat.db
|
|
||||||
REDIS_URL=redis://redis:6379/0
|
|
||||||
|
|
||||||
# Ports (for local development)
|
|
||||||
PLAYOUT_PORT=8080
|
|
||||||
CHAT_PORT=3000
|
|
||||||
NGINX_PORT=80
|
|
||||||
|
|
||||||
# Playout Configuration
|
|
||||||
PLAYLIST_PATH=/app/playlist.txt
|
|
||||||
HLS_SEGMENT_DURATION=6
|
|
||||||
HLS_WINDOW_SIZE=24
|
|
||||||
|
|
||||||
# Logging / Monitoring
|
|
||||||
LOG_LEVEL=info
|
|
||||||
PROMETHEUS_PORT=9090
|
|
||||||
GRAFANA_PORT=3001
|
|
||||||
|
|
||||||
# Development flags
|
|
||||||
NODE_ENV=development
|
|
||||||
GO_ENV=development
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Check local value overrides for Docker Compose**:
|
|
||||||
- `docker-compose.yml` builds images locally and maps the frontend build output from `frontend/dist` into nginx.
|
|
||||||
- `docker-compose.prod.yml` pulls prebuilt images from the Gitea registry.
|
|
||||||
- Internal container-to-container URLs should use compose hostnames, for example `REDIS_URL=redis://redis:6379/0` and `DATABASE_URL=postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`.
|
|
||||||
- For production public routing, set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
|
|
||||||
|
|
||||||
5. **Create Data Directories**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p data/playlist data/media data/hls data/chat
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Prepare Sample Videos** (optional):
|
|
||||||
- Place normalized MP4 files in `data/media/`.
|
|
||||||
- Create a `data/playlist/playlist.txt` with paths (see ADR-001 for normalization requirements).
|
|
||||||
|
|
||||||
## Gitea Setup
|
## Gitea Setup
|
||||||
|
|
||||||
1. **Create or verify a Gitea user**:
|
1. User with repo + registry access; generate a token (scope `write:packages`) stored as `CI_REGISTRY_TOKEN`.
|
||||||
- The user should have access to the repository and permission to read from the container registry.
|
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`.
|
||||||
2. **Create an access token**:
|
4. Auth: `docker login git.allucanget.biz`.
|
||||||
- Generate a token with the scopes needed for package/registry access and, if applicable, repository automation.
|
5. Local dev builds from source; prod pulls `playout`, `chat`, `nginx` images from the registry namespace. Verify `:latest` tags exist before rollout.
|
||||||
- Store it as `CI_REGISTRY_TOKEN` in `.env`.
|
|
||||||
|
|
||||||
3. **Configure Gitea Actions Secrets**:
|
|
||||||
- Go to the repository settings page: [https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets](https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets)
|
|
||||||
- Add the following secrets to enable automated CI/CD and production deployment:
|
|
||||||
- `CI_REGISTRY_USER`: Your Gitea username (e.g., `zwitschi`).
|
|
||||||
- `CI_REGISTRY_TOKEN`: Your Gitea Personal Access Token with `write:packages` scope.
|
|
||||||
- `ADMIN_USERNAME`: The administrator username for the production admin panel (e.g., `admin`).
|
|
||||||
- `ADMIN_PASSWORD`: A strong password for the production admin panel.
|
|
||||||
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`).
|
|
||||||
|
|
||||||
4. **Configure registry variables**:
|
|
||||||
- Set `CI_REGISTRY_URL` to the Gitea host.
|
|
||||||
- Set `CI_REGISTRY_NAMESPACE` to the image namespace, for example `zwitschi/linearvoid`.
|
|
||||||
- Set `CI_REGISTRY_USER` to the account that will push or pull images.
|
|
||||||
|
|
||||||
5. **Authenticate Docker to the registry**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker login git.allucanget.biz
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Build and publish images when needed**:
|
|
||||||
- Local development uses `docker-compose.yml`, which builds from source.
|
|
||||||
- Production uses `docker-compose.prod.yml`, which expects the `playout`, `chat`, and `nginx` images to exist in the configured Gitea registry namespace.
|
|
||||||
|
|
||||||
7. **Validate image availability**:
|
|
||||||
- Before a production rollout, verify that the expected `:latest` tags can be pulled from the registry on the target server.
|
|
||||||
|
|
||||||
## Cloudflare R2 Setup
|
## Cloudflare R2 Setup
|
||||||
|
|
||||||
1. **Create an R2 bucket**:
|
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`.
|
||||||
- Create a bucket such as `linearvoid-media` in Cloudflare R2.
|
2. S3 endpoint: `https://<account-id>.r2.cloudflarestorage.com` → `R2_ENDPOINT`.
|
||||||
- Keep the bucket name aligned with `R2_BUCKET_NAME` in `.env`.
|
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)**.
|
||||||
2. **Generate R2 API credentials**:
|
5. Apply the `.m4s`/`.m3u8` cache rules from [`cache-rules.md`](./infrastructure/cloudflare/cache-rules.md); enable Smart Tiered Cache; deploy the [`linearvoid-media` worker](../infrastructure/cloudflare/) on `media.linearvoid.tv` (replace the R2 bucket's direct custom-domain binding on that host) to enforce immutable segment caching + HLS CORS.
|
||||||
- Create an access key pair with read/write permissions for the bucket.
|
6. Verify: `aws s3 ls --endpoint-url $R2_ENDPOINT`; check cache hit ratio after serving traffic.
|
||||||
- Set `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_REGION=auto` in `.env`.
|
|
||||||
|
|
||||||
3. **Find the S3 endpoint**:
|
|
||||||
- Use the account-scoped endpoint format `https://<account-id>.r2.cloudflarestorage.com`.
|
|
||||||
- Store the account ID in both `R2_ENDPOINT` and `CLOUDFLARE_ACCOUNT_ID` where applicable.
|
|
||||||
|
|
||||||
4. **Bind a custom media domain**:
|
|
||||||
- Attach a domain such as `media.linearvoid.tv` to the bucket.
|
|
||||||
- Set `MEDIA_DOMAIN` to that hostname.
|
|
||||||
|
|
||||||
5. **Configure app-domain DNS and TLS**:
|
|
||||||
- Point `linearvoid.tv` to the public entrypoint serving nginx.
|
|
||||||
- Point `www.linearvoid.tv` to the same target and redirect it to `https://linearvoid.tv`.
|
|
||||||
- Keep `media.linearvoid.tv` on the R2 custom domain path so HLS delivery stays split from the app origin.
|
|
||||||
- Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`.
|
|
||||||
|
|
||||||
6. **Apply cache behavior**:
|
|
||||||
- Configure the `.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. **Verify access**:
|
|
||||||
- Confirm uploads with an S3-compatible client such as `aws s3 ls --endpoint-url <R2_ENDPOINT>`.
|
|
||||||
- After deployment, check Cloudflare analytics for cache hit ratio and origin request volume.
|
|
||||||
|
|
||||||
## Cloudflare
|
|
||||||
|
|
||||||
Use this section for full edge setup (DNS, Pages, cache, security, and monitoring).
|
|
||||||
|
|
||||||
Execution tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`.
|
|
||||||
|
|
||||||
### 1. DNS Setup
|
|
||||||
|
|
||||||
1. Add apex record for `linearvoid.tv` pointing to Cloudflare Pages target.
|
|
||||||
2. Add `www` CNAME to Cloudflare Pages 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. 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 Pages
|
|
||||||
|
|
||||||
Also see [the automated Gitea Actions deployment guide](./operations/gitea-cloudflare-pages.md).
|
|
||||||
|
|
||||||
Manual setup:
|
|
||||||
|
|
||||||
1. Connect Gitea repository to Cloudflare Pages.
|
|
||||||
2. Build command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Output directory:
|
|
||||||
|
|
||||||
```text
|
|
||||||
frontend/dist
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Configure production domain to `linearvoid.tv`.
|
|
||||||
|
|
||||||
### 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. Pages serves 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.
|
|
||||||
|
|
||||||
## Running the Application
|
## Running the Application
|
||||||
|
|
||||||
### Local Development
|
### Local development
|
||||||
|
|
||||||
1. **Start All Services**:
|
```bash
|
||||||
|
docker compose up --build # start all services
|
||||||
|
docker compose down # stop
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
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`).
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Access the Application**:
|
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`).
|
||||||
- Preferred local access with the documented proxy setup: <https://linearvoid.tv>
|
|
||||||
- Admin Interface: <https://linearvoid.tv/admin> (redirects to `/admin/login/` until authenticated)
|
|
||||||
- Chat WebSocket: `wss://linearvoid.tv/ws`
|
|
||||||
- Direct-local fallback when bypassing Cloudflare/Nginx Proxy Manager: <http://localhost>, <http://localhost/admin>, and `ws://localhost/ws`
|
|
||||||
- Health Checks:
|
|
||||||
- Playout: <http://localhost:8081/health>
|
|
||||||
- Chat: <http://localhost:3000/health>
|
|
||||||
- Monitoring:
|
|
||||||
- Prometheus: <http://localhost:9090>
|
|
||||||
- Grafana: <http://localhost:3001> (default login: admin/admin)
|
|
||||||
|
|
||||||
3. **Frontend Development** (optional, for UI changes):
|
### Test lab (remote Docker host)
|
||||||
|
|
||||||
```bash
|
Deploy to `192.168.88.40` over Docker's TCP socket (port `2375`).
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The dev server runs on <http://localhost:5173>. 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
|
Then `rc-service docker restart`; verify with `netstat -tlnp | grep 2375`; allow port `2375` from `192.168.88.0/24` in the firewall.
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
The local nginx container serves `frontend/dist`, so rebuilding the frontend is required before validating production-like static output.
|
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
|
Health: `http://192.168.88.40:8081/health` (playout), `:3000/health` (chat), nginx entrypoint `:8080`.
|
||||||
docker compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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).
|
1. Provision a Linux VPS (Docker, Compose, Git); open HTTP/HTTPS ports.
|
||||||
- Docker TCP socket enabled and listening on port `2375` on the remote server.
|
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), deploy the [`linearvoid-media` worker](../infrastructure/cloudflare/) on `media.linearvoid.tv` + cache rules, `linearvoid.tv`/`www` at the app entrypoint with `www`→apex redirect, Full (strict) TLS.
|
||||||
|
3. Deploy from the project: `docker compose -f docker-compose.prod.yml up -d` (or pull from Gitea registry); manage under systemd.
|
||||||
|
4. Monitoring: Grafana dashboards from `monitoring/grafana/`.
|
||||||
|
|
||||||
1. Edit `/etc/conf.d/docker` on the Alpine host:
|
See `docs/architecture/README.md` (arc42) and the ADRs for decisions and cost/scaling guidance (ADR-001).
|
||||||
|
|
||||||
```bash
|
|
||||||
vi /etc/conf.d/docker
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Update the `DOCKER_OPTS` line to include both the unix socket and the TCP socket:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Restart the Docker service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rc-service docker restart
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Verify that Docker is listening on port `2375`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
netstat -tlnp | grep 2375
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Ensure any firewall (like `awall` or `iptables`) on the Alpine host allows incoming traffic on port `2375` from your local network subnet (e.g., `192.168.88.0/24`).
|
|
||||||
|
|
||||||
#### Deployment Steps
|
|
||||||
|
|
||||||
1. **Configure Docker CLI to use the Remote Host**:
|
|
||||||
You can point your local Docker CLI to the remote server by setting the `DOCKER_HOST` environment variable:
|
|
||||||
- **PowerShell (Windows)**:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:DOCKER_HOST="tcp://192.168.88.40:2375"
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Bash (Linux/macOS)**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export DOCKER_HOST="tcp://192.168.88.40:2375"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Verify Connection**:
|
|
||||||
Run `docker info` to confirm your local CLI is communicating with the remote server at `192.168.88.40`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker info
|
|
||||||
```
|
|
||||||
|
|
||||||
The output should show the remote server's hostname and system details.
|
|
||||||
|
|
||||||
3. **Deploy the Stack**:
|
|
||||||
With `DOCKER_HOST` set, any `docker` or `docker compose` commands you run locally will execute directly on the remote server.
|
|
||||||
- **Deploy from Registry (Production-like)**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.prod.yml pull
|
|
||||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Build and Deploy from Source**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Access the Test Environment**:
|
|
||||||
- Playout Health: `http://192.168.88.40:8081/health`
|
|
||||||
- Chat Health: `http://192.168.88.40:3000/health`
|
|
||||||
- Nginx Entrypoint: `http://192.168.88.40:8080` (or the port mapped to `NGINX_PORT` in `.env`)
|
|
||||||
|
|
||||||
5. **Reset to Local Docker Host**:
|
|
||||||
Once testing is complete, clear the `DOCKER_HOST` variable to point back to your local machine:
|
|
||||||
- **PowerShell**:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Remove-Item env:DOCKER_HOST
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Bash**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
unset DOCKER_HOST
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Setup Guide
|
|
||||||
|
|
||||||
1. **Prepare the target host**:
|
|
||||||
- Provision a Linux VPS with Docker Engine, Docker Compose, Git, and enough disk for media, HLS output, PostgreSQL, Redis persistence, and monitoring data.
|
|
||||||
- Open the ports required for HTTP/HTTPS and any restricted monitoring access you intend to expose.
|
|
||||||
|
|
||||||
2. **Clone the repository and create the environment file**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
|
||||||
cd linearvoid
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Set production-safe `.env` values**:
|
|
||||||
- Replace placeholder secrets.
|
|
||||||
- Use compose-network hostnames for internal services, for example `redis` and `db`.
|
|
||||||
- Set strong values for `POSTGRES_PASSWORD` and `GRAFANA_PASSWORD`.
|
|
||||||
- Keep `MEDIA_DOMAIN`, R2 credentials, and Gitea registry values consistent with the external infrastructure.
|
|
||||||
- Set `MEDIA_DOMAIN=media.linearvoid.tv`.
|
|
||||||
- Set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
|
|
||||||
- If you intentionally bypass the public proxy for local-only testing, switch those values to `http://localhost`, `ws://localhost/ws`, and `http://localhost/hls/playlist.m3u8`.
|
|
||||||
|
|
||||||
4. **Create runtime directories on the server**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p data/playlist data/media data/hls data/chat
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Publish or verify required images in Gitea**:
|
|
||||||
- Ensure the `playout`, `chat`, and `nginx` images exist under `${CI_REGISTRY_URL}/${CI_REGISTRY_NAMESPACE}`.
|
|
||||||
- Log in to the registry from the VPS before pulling.
|
|
||||||
|
|
||||||
6. **Deploy the production stack**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.prod.yml pull
|
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Run post-deploy verification**:
|
|
||||||
- Check `docker compose -f docker-compose.prod.yml ps`.
|
|
||||||
- Confirm nginx, chat, and playout health endpoints respond.
|
|
||||||
- Verify that HLS output is being written to `data/hls` and that the media domain serves manifests and segments through Cloudflare.
|
|
||||||
|
|
||||||
8. **Harden and operate the deployment**:
|
|
||||||
- Put HTTPS in front of nginx using Cloudflare or another reverse proxy, with `linearvoid.tv` as canonical app host and `www.linearvoid.tv` redirected.
|
|
||||||
- Restrict Grafana and Prometheus exposure to trusted IPs or a private network.
|
|
||||||
- Run the stack under systemd or another service manager if you need auto-start behavior outside Docker restart policies.
|
|
||||||
|
|
||||||
### Production Deployment (Hetzner VPS + Cloudflare)
|
|
||||||
|
|
||||||
1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001).
|
|
||||||
2. **Configure Cloudflare**:
|
|
||||||
- Create R2 bucket with `enam` location hint.
|
|
||||||
- Set up custom media domain (`media.linearvoid.tv`) with the cache rules from [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.
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- **R2 Upload Fails**: Check credentials in `.env` and ensure the bucket exists. Verify with `aws s3 ls --endpoint-url=$R2_ENDPOINT`.
|
- **R2 upload fails** — check `.env` creds/bucket; `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).
|
- **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 Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200.
|
- **Chat won't connect** — check Redis and WS URL; `/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.
|
- **Cache not working** — verify `.m4s` long-TTL / `.m3u8` short-TTL cache rules and hit ratio.
|
||||||
- **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use.
|
- **Docker issues** — `docker compose logs`; check port conflicts.
|
||||||
- **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.
|
- **Admin interface** — `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `ADMIN_SESSION_TTL_HOURS` set; session-based login protects `/admin/` and `/api/admin/*`.
|
||||||
- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist.
|
- **Prod pull fails** — `docker login` to Gitea succeeded and image tags exist.
|
||||||
|
|
||||||
## Further Reading
|
## Further Reading
|
||||||
|
|
||||||
- Architecture: `docs/architecture/README.md` (arc42 template with diagrams).
|
Architecture: `docs/architecture/README.md` · Decisions: `docs/architecture/adr/` (ADR-001…007) · Contributing: `CONTRIBUTING.md` · Overview: root `README.md`.
|
||||||
- Decisions: `docs/architecture/adr/` (ADR-001 to ADR-007).
|
|
||||||
- Contributing: `CONTRIBUTING.md`.
|
|
||||||
- Project Structure: Root `README.md`.
|
|
||||||
|
|
||||||
The system is ready for use. For production, review ADR-001 for cost estimates and scaling guidance.
|
|
||||||
|
|
||||||
**Last Updated**: 2026-08-03
|
|
||||||
+93
-90
@@ -1,6 +1,6 @@
|
|||||||
# Streaming Pipeline
|
# 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
|
## Production flow
|
||||||
|
|
||||||
@@ -20,22 +20,22 @@ The playout service is the timeline authority. It reads the ordered playlist, ru
|
|||||||
https://media.linearvoid.tv/hls/playlist.m3u8
|
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
|
## Source-media contract
|
||||||
|
|
||||||
Only validated files may enter the production playlist.
|
Only validated files may enter the production playlist.
|
||||||
|
|
||||||
| Property | Required profile |
|
| Property | Required profile |
|
||||||
| ---------- | ------------------------------------------------------------------------ |
|
| ---------- | ----------------------------------------------------------------------- |
|
||||||
| Container | MP4 |
|
| Container | MP4 |
|
||||||
| Video | H.264/AVC, `yuv420p` |
|
| 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 |
|
| Frame rate | Constant 30 fps |
|
||||||
| GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` |
|
| GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` |
|
||||||
| Audio | AAC-LC, stereo, 48 kHz, 128–192 kbit/s |
|
| 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:
|
Reference normalization:
|
||||||
|
|
||||||
@@ -51,136 +51,139 @@ ffmpeg -i input.mov \
|
|||||||
|
|
||||||
## Playlist and timeline
|
## Playlist and timeline
|
||||||
|
|
||||||
- `data/playlist/playlist.txt` contains one local media path per line; blank lines and `#` comments are ignored.
|
- `data/playlist/playlist.txt`: one local media path per line; blank lines and `#` comments ignored.
|
||||||
- The service validates the complete playlist before activating a revision.
|
- 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.
|
- 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 do not calculate the schedule independently.
|
- All viewers follow the same live manifest; clients never calculate the schedule independently.
|
||||||
- Schedule metadata must be derived from the same active playlist revision and timeline so “now/next” agrees with the video.
|
- 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 item unless an active schedule override says otherwise.
|
- When the last item ends, playout returns to the first unless an active schedule override says otherwise.
|
||||||
|
|
||||||
## Playout and HLS packaging
|
## 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 |
|
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.
|
||||||
| ---------------- | ------------------------------------------------------------------------------------- |
|
|
||||||
| Protocol | Standard live HLS, not LL-HLS |
|
|
||||||
| Segment format | fMP4 (`.m4s`) with an initialization segment; `.ts` may be supported during migration |
|
|
||||||
| Segment target | 6 seconds (`HLS_SEGMENT_DURATION=6`) |
|
|
||||||
| Live window | 24 segments (`HLS_WINDOW_SIZE=24`), about 144 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 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.
|
||||||
|
|
||||||
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
|
```text
|
||||||
hls/r-20260807T170412Z/init-000001.mp4
|
hls/r-20260807T170412Z/init-000001.mp4
|
||||||
hls/r-20260807T170412Z/seg-000001234.m4s
|
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
|
## 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.
|
1. FFmpeg writes a segment to a temp local name and closes it.
|
||||||
2. The publisher uploads the initialization segment, if new, and media segment to their final immutable R2 keys.
|
2. Publisher uploads the init segment (if new) and media segment to final immutable R2 keys.
|
||||||
3. It verifies successful upload and records object size/checksum.
|
3. Verify upload success; record object size/checksum.
|
||||||
4. It renders the next media manifest referencing only confirmed objects.
|
4. Render the next manifest referencing only confirmed objects.
|
||||||
5. It uploads the manifest to a temporary key, then replaces `hls/playlist.m3u8` as the final commit step.
|
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.
|
**Run-scoped manifest URIs.** Segments and the init file are stored run-scoped under `hls/r-{runID}/…`, so the live `hls/playlist.m3u8` must reference them with that run prefix. Before the final commit step, the publisher rewrites each bare relative media URI (segments plus `#EXT-X-MAP:URI`) to `r-{runID}/<name>`. `publishManifest` in `backend/playout/src/playlist.go` does this via `rewriteManifestToRun`, using the same `runID` under which the segments were uploaded; the rewrite is skipped in slate mode (its manifest is served as-is). The published manifest therefore contains, e.g.:
|
||||||
|
|
||||||
|
```m3u8
|
||||||
|
#EXT-X-MAP:URI="r-20260807T170412Z/init.mp4"
|
||||||
|
#EXTINF:2.000000,
|
||||||
|
r-20260807T170412Z/seg-000001234.m4s
|
||||||
|
```
|
||||||
|
|
||||||
|
The `linearvoid-media` Worker on `media.linearvoid.tv` is a faithful path→R2 mapper and serves these run-scoped paths directly; it does **not** rewrite URIs at serve time.
|
||||||
|
|
||||||
|
`data/hls/` is regenerable staging. Source media, playlist revisions, and schedule state are authoritative; generated HLS is not backed up as application state.
|
||||||
|
|
||||||
## Cache contract
|
## Cache contract
|
||||||
|
|
||||||
| Objects | Edge policy | Browser policy | Required headers/behavior |
|
| 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 |
|
| `.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` |
|
| 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` |
|
| Versioned JS/CSS | Cache eligible; TTL 1 year | TTL 1 year | Content-hashed filenames and `immutable` |
|
||||||
|
|
||||||
Further rules:
|
Further rules:
|
||||||
|
|
||||||
- Do not add query-string cache busters to segment URLs; uniqueness belongs in the path.
|
- No query-string cache busters on segment URLs; uniqueness belongs in the path.
|
||||||
- Cache keys must preserve hostname and full path. Ignore only explicitly approved tracking parameters.
|
- Cache keys preserve hostname and full path; ignore only explicitly approved tracking params.
|
||||||
- HLS responses require `Access-Control-Allow-Origin: https://linearvoid.tv` (or the reviewed origin allowlist), `GET`, `HEAD`, and `OPTIONS` support, and correct MIME types.
|
- 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 reduce R2 reads on regional misses.
|
- Enable Smart Tiered Cache for `media.linearvoid.tv` to cut R2 reads on regional misses.
|
||||||
- Purge manifests when necessary, but never purge immutable segments during normal operation.
|
- Purge manifests when necessary, never 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.
|
- 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
|
```bash
|
||||||
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
|
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
|
||||||
curl -I https://media.linearvoid.tv/hls/<segment>.m4s
|
curl -I https://media.linearvoid.tv/hls/<segment>.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
|
## Player behavior
|
||||||
|
|
||||||
The SPA uses native HLS where supported and hls.js elsewhere.
|
SPA uses native HLS where supported, hls.js elsewhere.
|
||||||
|
|
||||||
|
- Firefox has no native HLS → hls.js/MSE path; fMP4 (`.m4s` + init) is supported. Autoplay needs `muted` + `playsinline`, already set on the `<video>` element.
|
||||||
- Start at the live edge with a conservative buffer; do not seek to playlist start.
|
- 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.
|
- 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 and exponential backoff.
|
- Recover transient network/media errors with bounded retries + 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.
|
- On fatal hls.js network error, reload the manifest; on 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.
|
- 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.
|
- 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.
|
- Schedule/chat timestamps should account for measured player latency, not wall-clock playout.
|
||||||
|
|
||||||
## Failure and recovery
|
## 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;
|
- create a new run/session ID and continue with unique segment paths;
|
||||||
- resume from the schedule-derived current position when reliable state exists, otherwise start the current playlist item;
|
- resume from schedule-derived current position when reliable state exists, else start the current playlist item;
|
||||||
- never overwrite old segment objects;
|
- never overwrite old segment objects;
|
||||||
- keep the last valid manifest available until the replacement stream is playable;
|
- keep the last valid manifest available until the replacement is playable;
|
||||||
- emit a discontinuity when timeline continuity cannot be guaranteed.
|
- 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;
|
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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Required configuration
|
## Required configuration
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| ------------------------------------------ | -------------------------------------------------- |
|
| ------------------------------------------ | ------------------------------------- |
|
||||||
| `PLAYLIST_PATH` | Active playlist file |
|
| `PLAYLIST_PATH` | Active playlist file |
|
||||||
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `6` |
|
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `2` |
|
||||||
| `HLS_WINDOW_SIZE` | Manifest window; baseline `24` |
|
| `HLS_WINDOW_SIZE` | Manifest window; baseline `12` |
|
||||||
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
|
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
|
||||||
| `R2_BUCKET_NAME` | Media bucket |
|
| `R2_BUCKET_NAME` | Media bucket |
|
||||||
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, available only to the publisher |
|
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, publisher only |
|
||||||
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
| `R2_KEEP_RUNS` | Run folders to retain (default `2`) |
|
||||||
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
|
| `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
|
## 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.
|
||||||
+240
-635
File diff suppressed because it is too large.
Load diff
@@ -69,7 +69,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
|||||||
|
|
||||||
### 3. Frontend
|
### 3. Frontend
|
||||||
|
|
||||||
- Static SPA (HTML + custom CSS + vanilla JS) hosted on Cloudflare Pages or same VPS/Nginx.
|
- Static SPA (HTML + custom CSS + vanilla JS) hosted on Cloudflare Workers or same VPS/Nginx.
|
||||||
- Video player: `hls.js` (Chromium/Firefox) or native HLS (Safari). Single live `master.m3u8`.
|
- Video player: `hls.js` (Chromium/Firefox) or native HLS (Safari). Single live `master.m3u8`.
|
||||||
- Embedded chat sidebar with room switcher, private DMs, now-playing overlay.
|
- Embedded chat sidebar with room switcher, private DMs, now-playing overlay.
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
|||||||
|
|
||||||
### 5. Infrastructure Summary & Cost Model
|
### 5. Infrastructure Summary & Cost Model
|
||||||
|
|
||||||
- **Primary**: Hetzner Ashburn VPS (playout + chat + Nginx) + Cloudflare R2 + Pages.
|
- **Primary**: Hetzner Ashburn VPS (playout + chat + Nginx) + Cloudflare R2 + Workers.
|
||||||
- **Monitoring**: MediaMTX/SRS dashboard, UptimeRobot, systemd logs, Cloudflare analytics.
|
- **Monitoring**: MediaMTX/SRS dashboard, UptimeRobot, systemd logs, Cloudflare analytics.
|
||||||
- **Backup**: Weekly R2/S3-compatible snapshots of playlist, DB, config.
|
- **Backup**: Weekly R2/S3-compatible snapshots of playlist, DB, config.
|
||||||
- **Fallback**: Emergency slate/loop playlist, automated restart.
|
- **Fallback**: Emergency slate/loop playlist, automated restart.
|
||||||
|
|||||||
+57
-126
@@ -4,155 +4,101 @@ Triage order for Linear Void incidents. Work top-to-bottom — each tier depends
|
|||||||
|
|
||||||
## Triage Order
|
## Triage Order
|
||||||
|
|
||||||
1. Edge Reachability (Cloudflare)
|
1. **Edge Reachability** (Cloudflare)
|
||||||
2. Origin Health (VPS/Docker)
|
2. **Origin Health** (VPS/Docker)
|
||||||
3. Playout Progression (HLS)
|
3. **Playout Progression** (HLS)
|
||||||
4. Chat Connectivity (WebSocket)
|
4. **Chat Connectivity** (WebSocket)
|
||||||
5. Data-Plane (Redis/DB)
|
5. **Data-Plane** (Redis/DB)
|
||||||
|
|
||||||
## 1. Edge Reachability (Cloudflare)
|
## 1. Edge Reachability (Cloudflare)
|
||||||
|
|
||||||
**Symptoms:** `linearvoid.tv` not resolving, SSL errors, 5xx from Cloudflare.
|
**Symptoms:** `linearvoid.tv` not resolving, SSL errors, 5xx from Cloudflare.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check DNS
|
|
||||||
dig linearvoid.tv
|
dig linearvoid.tv
|
||||||
dig www.linearvoid.tv
|
dig www.linearvoid.tv
|
||||||
|
|
||||||
# Check Cloudflare status
|
|
||||||
curl -I https://linearvoid.tv/health
|
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)
|
**Mitigation:** Verify A record points `linearvoid.tv` → origin IP (proxied, orange cloud). Bypass Cloudflare temporarily:
|
||||||
- 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:**
|
```bash
|
||||||
|
curl -H "Host: linearvoid.tv" http://<origin-ip>/health
|
||||||
- Verify A record: `linearvoid.tv` → origin IP (proxied, orange cloud)
|
```
|
||||||
- Bypass Cloudflare temporarily: `curl -H "Host: linearvoid.tv" http://<origin-ip>/health`
|
|
||||||
|
|
||||||
## 2. Origin Health (VPS/Docker)
|
## 2. Origin Health (VPS/Docker)
|
||||||
|
|
||||||
**Symptoms:** Cloudflare returns 502/504, containers down.
|
**Symptoms:** Cloudflare 502/504, containers down.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Remote Docker (test lab)
|
|
||||||
docker -H tcp://192.168.88.40:2375 ps
|
docker -H tcp://192.168.88.40:2375 ps
|
||||||
docker -H tcp://192.168.88.40:2375 logs linearvoid-nginx --tail 50
|
docker -H tcp://192.168.88.40:2375 logs linearvoid-nginx --tail 50
|
||||||
|
|
||||||
# Direct health check
|
|
||||||
curl http://192.168.88.40/health
|
curl http://192.168.88.40/health
|
||||||
```
|
```
|
||||||
|
|
||||||
**Common causes:**
|
**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`).
|
||||||
|
|
||||||
- Host out of disk space: `df -h`
|
|
||||||
- Docker daemon stopped: `systemctl status docker`
|
|
||||||
- Container restart loop: `docker ps -a --filter "status=exited"`
|
|
||||||
- Resource exhaustion (OOM): `dmesg | grep -i oom`
|
|
||||||
|
|
||||||
**Mitigation:**
|
**Mitigation:**
|
||||||
|
|
||||||
- Restart stack: `docker compose -f docker-compose.prod.yml up -d`
|
```bash
|
||||||
- Free disk: `docker system prune -f`
|
docker compose -f docker-compose.prod.yml up -d # restart stack
|
||||||
- Check logs per service: `docker logs linearvoid-<service> --tail 100`
|
docker system prune -f # free disk
|
||||||
|
docker logs linearvoid-<service> --tail 100 # per-service logs
|
||||||
|
```
|
||||||
|
|
||||||
## 3. Playout Progression (HLS)
|
## 3. Playout Progression (HLS)
|
||||||
|
|
||||||
**Symptoms:** Viewer sees slate or buffering, playlist not advancing.
|
**Symptoms:** Viewer sees slate or buffering, playlist not advancing.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check playout health
|
curl http://192.168.88.40:8081/health # "mode": "live" or "slate"
|
||||||
curl http://192.168.88.40:8081/health
|
|
||||||
# Response includes "mode": "live" or "slate"
|
|
||||||
|
|
||||||
# Check HLS output
|
|
||||||
curl -I http://192.168.88.40/hls/playlist.m3u8
|
curl -I http://192.168.88.40/hls/playlist.m3u8
|
||||||
ls -la data/hls/
|
ls -la data/hls/
|
||||||
|
|
||||||
# Check playout logs
|
|
||||||
docker logs linearvoid-playout --tail 100
|
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`)
|
**Mitigation:** Validate playlist entries and media paths; restart playout (`docker compose restart playout`); regenerate demo playlist if empty (see `backend/playout/start-playout.sh`).
|
||||||
- Playlist file empty/missing: `cat data/playlist/playlist.txt`
|
|
||||||
- Media file missing from playlist: `ls data/media/`
|
|
||||||
- Disk full → HLS segments can't write
|
|
||||||
|
|
||||||
**Mitigation:**
|
|
||||||
|
|
||||||
- Verify playlist has valid entries
|
|
||||||
- Check media files exist at paths in playlist
|
|
||||||
- Restart playout: `docker compose restart playout`
|
|
||||||
- Generate demo playlist if empty: see `backend/playout/start-playout.sh`
|
|
||||||
|
|
||||||
## 4. Chat Connectivity (WebSocket)
|
## 4. Chat Connectivity (WebSocket)
|
||||||
|
|
||||||
**Symptoms:** Chat not loading, messages not sending, presence broken.
|
**Symptoms:** Chat not loading, messages not sending, presence broken.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check chat health
|
|
||||||
curl http://192.168.88.40:3000/health
|
curl http://192.168.88.40:3000/health
|
||||||
|
|
||||||
# Test WebSocket
|
|
||||||
wscat -c ws://192.168.88.40/ws?username=test&room=main
|
wscat -c ws://192.168.88.40/ws?username=test&room=main
|
||||||
|
|
||||||
# Check chat logs
|
|
||||||
docker logs linearvoid-chat --tail 100
|
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)
|
**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.
|
||||||
- Database down → messages not persisting
|
|
||||||
- Port not exposed: `docker port linearvoid-chat`
|
|
||||||
|
|
||||||
**Mitigation:**
|
|
||||||
|
|
||||||
- Restart chat: `docker compose restart chat`
|
|
||||||
- Check Redis: `docker exec linearvoid-redis redis-cli ping`
|
|
||||||
- Check DB: `docker exec linearvoid-db pg_isready -U linearvoid`
|
|
||||||
- Chat works without Redis (graceful degradation) — check logs for "Redis connection warning"
|
|
||||||
|
|
||||||
## 5. Data-Plane (Redis/DB)
|
## 5. Data-Plane (Redis/DB)
|
||||||
|
|
||||||
**Symptoms:** Chat works but no persistence/presence, admin stats empty.
|
**Symptoms:** Chat works but no persistence/presence, admin stats empty.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Redis
|
|
||||||
docker exec linearvoid-redis redis-cli ping
|
docker exec linearvoid-redis redis-cli ping
|
||||||
docker exec linearvoid-redis redis-cli info memory
|
docker exec linearvoid-redis redis-cli info memory
|
||||||
|
|
||||||
# PostgreSQL
|
|
||||||
docker exec linearvoid-db pg_isready -U linearvoid
|
docker exec linearvoid-db pg_isready -U linearvoid
|
||||||
docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
|
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)
|
**Mitigation:** Restart redis (`docker compose restart redis`, AOF persist enabled); restart db (`docker compose restart db`); restore from backup (see `scripts/verify-backups.sh`).
|
||||||
- PostgreSQL connection limit reached
|
|
||||||
- Disk full on volume mount
|
|
||||||
|
|
||||||
**Mitigation:**
|
|
||||||
|
|
||||||
- Redis: `docker compose restart redis` (AOF persist enabled)
|
|
||||||
- PostgreSQL: `docker compose restart db`
|
|
||||||
- Restore from backup: see `scripts/verify-backups.sh` for procedure
|
|
||||||
|
|
||||||
## Escalation
|
## Escalation
|
||||||
|
|
||||||
| Severity | When | Action |
|
| Severity | When | Action |
|
||||||
| -------- | ------------------------------------ | -------------------------------------------------- |
|
| -------- | ------------------------------------ | ------------------------------------------- |
|
||||||
| Critical | Slate active >30min, complete outage | Trigger DR plan, provision fallback host |
|
| Critical | Slate active >30min, complete outage | Trigger DR plan, provision fallback host |
|
||||||
| Warning | Redis down, high CPU >30min | Investigate during business hours, restart service |
|
| Warning | Redis down, high CPU >30min | Investigate business hours, restart service |
|
||||||
| Info | Single service blip | Monitor, no action unless recurring |
|
| Info | Single service blip | Monitor; no action unless recurring |
|
||||||
|
|
||||||
## Post-Incident
|
## Post-Incident
|
||||||
|
|
||||||
@@ -163,67 +109,52 @@ docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
|
|||||||
|
|
||||||
## Quarterly Disaster Recovery Drill
|
## Quarterly Disaster Recovery Drill
|
||||||
|
|
||||||
Goal: validate recovery targets under controlled host-loss simulation.
|
Validate recovery targets under controlled host-loss simulation: **RPO ≤ 24h, RTO ≤ 60min**.
|
||||||
|
|
||||||
**Recovery targets:** RPO <= 24h, RTO <= 60min.
|
|
||||||
|
|
||||||
### Prerequisites
|
### 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
|
- 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)
|
- Simulate complete origin loss (all app containers unavailable)
|
||||||
- Rebuild stack on clean host
|
- Rebuild stack on clean host
|
||||||
- Restore DB and media/playlist mounts
|
- Restore DB and media/playlist mounts
|
||||||
- Verify viewer playback, chat, admin auth, monitoring targets
|
- Verify viewer playback, chat, admin auth, monitoring targets
|
||||||
|
|
||||||
### Drill Steps
|
### Steps
|
||||||
|
|
||||||
1. Record drill start timestamp and on-call participants.
|
1. Record drill start timestamp and on-call participants.
|
||||||
2. Capture pre-drill baseline:
|
2. Capture pre-drill baseline:
|
||||||
- `curl -fsS http://<origin>/health`
|
```bash
|
||||||
- `curl -fsS http://<origin>:3000/health`
|
curl -fsS http://<origin>/health
|
||||||
- `curl -fsS http://<origin>:8081/health`
|
curl -fsS http://<origin>:3000/health
|
||||||
3. Simulate origin outage:
|
curl -fsS http://<origin>:8081/health
|
||||||
- `docker compose -f docker-compose.prod.yml down`
|
```
|
||||||
4. Provision clean recovery host (or clean test-lab VM) and copy deployment bundle.
|
3. Simulate outage: `docker compose -f docker-compose.prod.yml down`
|
||||||
5. Restore required data paths:
|
4. Provision clean recovery host (or clean test-lab VM); copy deployment bundle.
|
||||||
- `data/playlist/`
|
5. Restore data paths: `data/playlist/`, `data/media/`, `data/chat/`, `data/hls/` (optional warm cache).
|
||||||
- `data/media/`
|
|
||||||
- `data/chat/`
|
|
||||||
- `data/hls/` (optional warm cache)
|
|
||||||
6. Restore database from latest backup dump.
|
6. Restore database from latest backup dump.
|
||||||
7. Start stack:
|
7. Start stack: `docker compose -f docker-compose.prod.yml up -d`
|
||||||
- `docker compose -f docker-compose.prod.yml up -d`
|
8. Validate health and runtime:
|
||||||
8. Validate service health and runtime:
|
```bash
|
||||||
- `docker ps --format "table {{.Names}}\t{{.Status}}"`
|
docker ps --format "table {{.Names}}\t{{.Status}}"
|
||||||
- `curl -fsS http://<origin>/health`
|
curl -fsS http://<origin>/health
|
||||||
- `curl -fsS http://<origin>/hls/playlist.m3u8 | head -n 5`
|
curl -fsS http://<origin>/hls/playlist.m3u8 | head -n 5
|
||||||
- `curl -fsS http://<origin>:9090/-/ready`
|
curl -fsS http://<origin>:9090/-/ready
|
||||||
9. Validate app behavior:
|
```
|
||||||
- Viewer SPA loads and HLS plays
|
9. Validate app behavior: viewer SPA loads and HLS plays; WebSocket chat connects with message round-trip; admin login/session endpoint succeeds.
|
||||||
- WebSocket chat connects and message round-trip works
|
10. Run backup integrity check: `scripts/verify-backups.sh`
|
||||||
- Admin login/session endpoint returns success
|
11. Record drill end timestamp; compute measured RTO/RPO.
|
||||||
10. Run backup integrity check:
|
|
||||||
- `scripts/verify-backups.sh`
|
|
||||||
11. Record drill end timestamp and compute measured RTO/RPO.
|
|
||||||
|
|
||||||
### Pass/Fail Criteria
|
### Pass/Fail Criteria
|
||||||
|
|
||||||
- Pass if all core services healthy and functional in <= 60 minutes.
|
- **Pass:** all core services healthy and functional in ≤ 60 min.
|
||||||
- Pass if latest recoverable data point is <= 24 hours old.
|
- **Pass:** latest recoverable data point ≤ 24 h old.
|
||||||
- Fail if any critical path remains degraded (playout, chat, admin auth, monitoring).
|
- **Fail:** any critical path remains degraded (playout, chat, admin auth, monitoring).
|
||||||
|
|
||||||
### Evidence Template
|
### Evidence Template
|
||||||
|
|
||||||
Document each drill in incident tracker with:
|
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.
|
||||||
|
|
||||||
- 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
|
|
||||||
@@ -2,23 +2,23 @@
|
|||||||
|
|
||||||
Reference for Cloudflare account and API token troubleshooting.
|
Reference for Cloudflare account and API token troubleshooting.
|
||||||
|
|
||||||
Use `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects` to verify the account ID and API token are valid.
|
Use `wrangler whoami` to verify the account ID and API token are valid, or query the Workers API at `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts`.
|
||||||
|
|
||||||
## Validate Cloudflare account and API token
|
## Validate Cloudflare account and API token
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
# Validate Cloudflare account and API token in .env
|
# Validate Cloudflare account and API token in .env
|
||||||
Set-Location -Path "E:\Documents\02-Projects\07-Mentoring\Corey\linearvoid"
|
cd /path/to/linearvoid
|
||||||
Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN'
|
grep -E 'CLOUDFLARE_API_TOKEN|CLOUDFLARE_ACCOUNT_ID' .env
|
||||||
Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID'
|
|
||||||
|
|
||||||
# Test API token with curl
|
# Test API token with wrangler
|
||||||
$env:CLOUDFLARE_API_TOKEN = (Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN' | ForEach-Object { $_.Line.Split('=')[1].Trim() })
|
npx wrangler whoami
|
||||||
$env:CLOUDFLARE_ACCOUNT_ID = (Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID' | ForEach-Object { $_.Line.Split('=')[1].Trim() })
|
|
||||||
|
|
||||||
# List all Pages projects for the account
|
# Or verify with the Workers API directly
|
||||||
curl -X GET "https://api.cloudflare.com/client/v4/accounts/$env:CLOUDFLARE_ACCOUNT_ID/pages/projects" -H "Authorization: Bearer $env:CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json"
|
export CLOUDFLARE_API_TOKEN="$(grep '^CLOUDFLARE_API_TOKEN=' .env | cut -d= -f2-)"
|
||||||
|
export CLOUDFLARE_ACCOUNT_ID="$(grep '^CLOUDFLARE_ACCOUNT_ID=' .env | cut -d= -f2-)"
|
||||||
|
|
||||||
# Create a new Pages project (if needed)
|
# List all Workers for the account
|
||||||
# curl -X POST "https://api.cloudflare.com/client/v4/accounts/$env:CLOUDFLARE_ACCOUNT_ID/pages/projects" -H "Authorization: Bearer $env:CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" -d '{"name":"linearvoid","production_branch":"main"}'
|
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"
|
||||||
```
|
```
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,69 +0,0 @@
|
|||||||
# Cloudflare Pages & Workers Setup — Linear Void
|
|
||||||
|
|
||||||
Linear Void uses two Cloudflare products:
|
|
||||||
|
|
||||||
| Product | Purpose | What |
|
|
||||||
| ----------- | ------------------ | ------------------------------------------------------------------------------------ |
|
|
||||||
| **Pages** | Static SPA hosting | Vite-built frontend (`frontend/dist`) deployed via Git integration or Direct Upload |
|
|
||||||
| **Workers** | Edge cache logic | `infrastructure/cloudflare/worker.js` — adds immutable cache headers to HLS segments |
|
|
||||||
|
|
||||||
Both are managed under **Workers & Pages** in the Cloudflare dashboard.
|
|
||||||
|
|
||||||
## Dashboard URLs
|
|
||||||
|
|
||||||
| Resource | URL |
|
|
||||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Workers & Pages overview | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers-and-pages` |
|
|
||||||
| Worker: linearvoid | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid/production/settings` |
|
|
||||||
| Pages project | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/pages/view/linearvoid` |
|
|
||||||
| API tokens | <https://dash.cloudflare.com/profile/api-tokens> |
|
|
||||||
|
|
||||||
## Worker: `linearvoid` (edge cache headers)
|
|
||||||
|
|
||||||
The Worker at `infrastructure/cloudflare/worker.js` runs on the `linearvoid` Workers service. It intercepts requests and adds immutable `Cache-Control` headers to `.m4s` segment files.
|
|
||||||
|
|
||||||
Deploy via Wrangler CLI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx wrangler deploy infrastructure/cloudflare/worker.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Or paste into the Workers dashboard at the URL above (Quick Edit).
|
|
||||||
|
|
||||||
## Pages: Static frontend
|
|
||||||
|
|
||||||
### Direct Upload via Gitea Actions
|
|
||||||
|
|
||||||
Use `.gitea/workflows/pages-deploy.yml` to build in Gitea Actions and upload via the Cloudflare API.
|
|
||||||
|
|
||||||
See [gitea-cloudflare-pages.md](./gitea-cloudflare-pages.md) for the workflow.
|
|
||||||
|
|
||||||
### Variables and secrets
|
|
||||||
|
|
||||||
On Cloudflare pages, set the following environment variables (needed to run the frontend)
|
|
||||||
|
|
||||||
| Type | Name | Value |
|
|
||||||
| --------- | -------------------------------------- | ----------------------------------------------- |
|
|
||||||
| Secret | ADMIN_PASSWORD | `not-a-real-admin-password` |
|
|
||||||
| Plaintext | ADMIN_SESSION_TTL_HOURS | `12` |
|
|
||||||
| Plaintext | ADMIN_USERNAME | `boogers` |
|
|
||||||
| Plaintext | HLS_SEGMENT_DURATION | `6` |
|
|
||||||
| Plaintext | HLS_WINDOW_SIZE | `24` |
|
|
||||||
| Plaintext | PLAYLIST_PATH | `/app/playlist/playlist.txt` |
|
|
||||||
| Plaintext | VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR | `boogers` |
|
|
||||||
| Plaintext | VITE_PUBLIC_ADMIN_DEFAULT_ROOM | `main` |
|
|
||||||
| Plaintext | VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT | `50` |
|
|
||||||
| Plaintext | VITE_PUBLIC_APP_URL | `https://linearvoid.tv` |
|
|
||||||
| Plaintext | VITE_PUBLIC_HLS_URL | `https://media.linearvoid.tv/hls/playlist.m3u8` |
|
|
||||||
| Plaintext | VITE_PUBLIC_WS_URL | `wss://linearvoid.tv/ws` |
|
|
||||||
|
|
||||||
## Custom domains
|
|
||||||
|
|
||||||
- `linearvoid.tv` → Pages project
|
|
||||||
- `www.linearvoid.tv` → redirect to `linearvoid.tv`
|
|
||||||
- `media.linearvoid.tv` → R2 bucket (separate; not Pages)
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Gitea to Cloudflare Pages deployment guide](./gitea-cloudflare-pages.md)
|
|
||||||
- [Cloudflare & UptimeRobot checklist](./cloudflare-uptimerobot-checklist.md)
|
|
||||||
@@ -5,7 +5,7 @@ Purpose: execute and record all manual dashboard tasks that cannot be automated
|
|||||||
Recommended execution order:
|
Recommended execution order:
|
||||||
|
|
||||||
1. Bind `media.linearvoid.tv` and confirm the HLS origin is separate from the app origin.
|
1. Bind `media.linearvoid.tv` and confirm the HLS origin is separate from the app origin.
|
||||||
2. Enable Pages, Tiered Cache, and cache behavior for the media zone.
|
2. Enable Workers, Tiered Cache, and cache behavior for the media zone.
|
||||||
3. Apply WAF, bot, rate limiting, HTTPS, and HSTS settings.
|
3. Apply WAF, bot, rate limiting, HTTPS, and HSTS settings.
|
||||||
4. Verify monitor targets and alert delivery in UptimeRobot.
|
4. Verify monitor targets and alert delivery in UptimeRobot.
|
||||||
5. Capture evidence and fill in the sign-off block.
|
5. Capture evidence and fill in the sign-off block.
|
||||||
@@ -15,18 +15,18 @@ Recommended execution order:
|
|||||||
- [ ] Pending
|
- [ ] Pending
|
||||||
- [x] Done
|
- [x] Done
|
||||||
|
|
||||||
## 1) Cloudflare Pages Link and Build
|
## 1) Cloudflare Workers Deploy and Custom Domain
|
||||||
|
|
||||||
- [ ] Connect repo to Cloudflare Pages project
|
- [ ] Create a Cloudflare Worker for the SPA and bind the build output
|
||||||
- [ ] Build command set to: `cd frontend && npm run build`
|
- [ ] Build command set to: `cd frontend && npm run build`
|
||||||
- [ ] Output directory set to: `frontend/dist`
|
- [ ] Output directory set to: `frontend/dist`
|
||||||
- [ ] Production domain attached to `linearvoid.tv`
|
- [ ] Production domain attached to `linearvoid.tv`
|
||||||
|
|
||||||
Evidence to capture:
|
Evidence to capture:
|
||||||
|
|
||||||
- Screenshot of Pages deployment settings
|
- Screenshot of Worker deployment settings
|
||||||
- Deployment URL and latest deployment timestamp
|
- Deployment URL and latest deployment timestamp
|
||||||
- Confirmed production build output path in the Pages project
|
- Confirmed production build output path in the Worker
|
||||||
|
|
||||||
## 2) Tiered Cache Enablement and Validation
|
## 2) Tiered Cache Enablement and Validation
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ Evidence to capture:
|
|||||||
|
|
||||||
Completion summary:
|
Completion summary:
|
||||||
|
|
||||||
- Pages build URL:
|
- Worker build URL:
|
||||||
- Cache validation result:
|
- Cache validation result:
|
||||||
- WAF/rate-limit status:
|
- WAF/rate-limit status:
|
||||||
- HTTPS/HSTS status:
|
- HTTPS/HSTS status:
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Cloudflare Workers Setup — Linear Void
|
||||||
|
|
||||||
|
Linear Void uses Cloudflare Workers for two purposes:
|
||||||
|
|
||||||
|
| Product | Purpose | What |
|
||||||
|
| ----------- | ------------------ | -------------------------------------------------------------------------------------------- |
|
||||||
|
| **Workers** | Static SPA hosting | `linearvoid-frontend` (`frontend/wrangler.toml`) — Vite SPA (`frontend/dist`) static assets |
|
||||||
|
| **Workers** | Edge HLS delivery | `linearvoid-media` (`infrastructure/cloudflare/`) — R2-bound Worker on `media.linearvoid.tv` |
|
||||||
|
|
||||||
|
Both are managed under **Workers & Pages** in the Cloudflare dashboard.
|
||||||
|
|
||||||
|
## Dashboard URLs
|
||||||
|
|
||||||
|
| Resource | URL |
|
||||||
|
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Workers & Pages overview | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers-and-pages` |
|
||||||
|
| Worker: linearvoid-media | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid-media/production/settings` |
|
||||||
|
| API tokens | <https://dash.cloudflare.com/profile/api-tokens> |
|
||||||
|
|
||||||
|
## Worker: `linearvoid-media` (edge HLS delivery)
|
||||||
|
|
||||||
|
The Worker in [`infrastructure/cloudflare/`](../../infrastructure/cloudflare/) runs on the `linearvoid-media` Workers service. It owns the `media.linearvoid.tv` host, serves HLS objects from the R2 bucket (bound as `MEDIA_BUCKET`), and stamps immutable `Cache-Control` on segments plus a short TTL on live manifests (CORS + Range supported). See its [README](../../infrastructure/cloudflare/README.md).
|
||||||
|
|
||||||
|
**Run-scoped layout.** R2 objects are stored run-scoped (`hls/r-{runID}/seg-*.m4s`, `…/init.mp4`), and the live `hls/playlist.m3u8` references them with the run prefix (the playout rewrites bare URIs before publishing — see [`docs/STREAMING.md`](../STREAMING.md)). The Worker is a faithful path→R2 mapper: it serves whatever key the request path resolves to (e.g. `/hls/r-…/seg-*.m4s`), adds the cache headers by file type, and does not rewrite manifest URIs at serve time.
|
||||||
|
|
||||||
|
Test / deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infrastructure/cloudflare
|
||||||
|
npm install && npm test # unit + Miniflare R2 tests
|
||||||
|
npx wrangler deploy # preview/test target
|
||||||
|
npx wrangler deploy --env production # attach media.linearvoid.tv
|
||||||
|
```
|
||||||
|
|
||||||
|
Attach the production custom domain only after removing `media.linearvoid.tv` from the R2 bucket's direct custom-domain binding. Automated production deploy: [`.gitea/workflows/workers-media-deploy.yml`](../../.gitea/workflows/workers-media-deploy.yml).
|
||||||
|
|
||||||
|
## Worker: static frontend
|
||||||
|
|
||||||
|
Serve the Vite-built SPA (`frontend/dist`) as Worker static assets:
|
||||||
|
|
||||||
|
1. Build the frontend: `cd frontend && npm run build`.
|
||||||
|
2. Bind `frontend/dist` to the Worker as static assets (Workers Static Assets / `assets` in `wrangler.toml`).
|
||||||
|
3. Attach the `linearvoid.tv` custom domain to the Worker.
|
||||||
|
|
||||||
|
See [gitea-cloudflare-workers.md](./gitea-cloudflare-workers.md) for the automated deployment workflow.
|
||||||
|
|
||||||
|
### Build-time variables
|
||||||
|
|
||||||
|
The `VITE_PUBLIC_*` values are baked into the bundle at build time by Vite (`vite.config.ts` uses `envDir: ".."` to read them from the repo root). They are supplied by the Gitea Actions build environment from Gitea secrets — not as Worker runtime variables. The static-assets Worker has no runtime JavaScript, so it reads no `env` at request time.
|
||||||
|
|
||||||
|
| Name | Value |
|
||||||
|
| ---------------------------------------- | ----------------------------------------------- |
|
||||||
|
| `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` |
|
||||||
|
| `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` |
|
||||||
|
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` |
|
||||||
|
| `VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR` | `boogers` |
|
||||||
|
| `VITE_PUBLIC_ADMIN_DEFAULT_ROOM` | `main` |
|
||||||
|
| `VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT` | `50` |
|
||||||
|
|
||||||
|
Backend services (playout, chat, nginx) read their own runtime variables (`ADMIN_*`, `HLS_*`, `PLAYLIST_PATH`, `R2_*`, `DATABASE_URL`, `REDIS_URL`) from the container environment — see [`.env.example`](../../.env.example).
|
||||||
|
|
||||||
|
## Custom domains
|
||||||
|
|
||||||
|
- `linearvoid.tv` → Worker (static SPA, `linearvoid-frontend`)
|
||||||
|
- `www.linearvoid.tv` → redirect to `linearvoid.tv`
|
||||||
|
- `media.linearvoid.tv` → Worker (`linearvoid-media`, R2-bound) serving the `linearvoid-media` bucket
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Gitea to Cloudflare Workers deployment guide](./gitea-cloudflare-workers.md)
|
||||||
|
- [Cloudflare & UptimeRobot checklist](./cloudflare-uptimerobot-checklist.md)
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# Gitea to Cloudflare Pages — Linear Void
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Deploy the Linear Void frontend (Vite + TypeScript SPA) from Gitea to Cloudflare Pages using Gitea Actions.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
> **Important:** The Pages project must exist before the API deploy call works. You'll get error 8000007 ("Project not found") if you skip this step.
|
|
||||||
|
|
||||||
1. **Cloudflare Pages project** created first:
|
|
||||||
- Dashboard → Workers & Pages → Create → Pages → Direct Upload
|
|
||||||
- Name it `linearvoid` (or a different name if `linearvoid` is already taken by a Worker — see note below)
|
|
||||||
- See [Cloudflare Pages Setup](./cloudflare-pages-setup.md) for full details.
|
|
||||||
2. **Project name**: note the exact name (e.g., `linearvoid`).
|
|
||||||
3. **API token** with Pages permissions:
|
|
||||||
- Create at <https://dash.cloudflare.com/profile/api-tokens>
|
|
||||||
- Permission: `Account → Cloudflare Pages → Edit`
|
|
||||||
- Store in Gitea → Settings → Secrets as `CLOUDFLARE_API_TOKEN`
|
|
||||||
4. **Account ID** from Cloudflare dashboard overview → store as `CLOUDFLARE_ACCOUNT_ID`
|
|
||||||
|
|
||||||
> **Naming conflict:** If a Worker already exists with the name `linearvoid`, the Pages project must use a different name (e.g., `linearvoid-site`). Update the `projectName` in the workflow accordingly. Workers and Pages share a namespace under "Workers & Pages".
|
|
||||||
|
|
||||||
## Project-specific values
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
| ------------------ | ---------------------------------------------------------- |
|
|
||||||
| Framework | Vite + TypeScript |
|
|
||||||
| Build command | `cd frontend && npm install && npm run build` |
|
|
||||||
| Output directory | `frontend/dist` |
|
|
||||||
| Node version | 20 |
|
|
||||||
| Multi-page entries | `index.html`, `admin/index.html`, `admin/login/index.html` |
|
|
||||||
|
|
||||||
## Gitea Actions workflow
|
|
||||||
|
|
||||||
Create `.gitea/workflows/pages-deploy.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: Deploy to Cloudflare Pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: cd frontend && npm ci
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: cd frontend && npm run build
|
|
||||||
|
|
||||||
- name: Deploy to Cloudflare Pages
|
|
||||||
uses: cloudflare/pages-action@v1
|
|
||||||
with:
|
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
projectName: linearvoid
|
|
||||||
directory: frontend/dist
|
|
||||||
gitHubToken: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
```
|
|
||||||
|
|
||||||
If the `cloudflare/pages-action@v1` action is unavailable from Gitea, use the direct Cloudflare Pages API (multipart/form-data with manifest):
|
|
||||||
|
|
||||||
Snippet to generate a manifest in format `-F manifest='{"index.html": "abc123", "style.css": "def456"}'`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build manifest form argument from all files in frontend/dist
|
|
||||||
form_args="-F manifest={"
|
|
||||||
for file in $(find frontend/dist -type f | sed 's|^frontend/dist/||'); do
|
|
||||||
hash=$(sha256sum "frontend/dist/$file" | awk '{print $1}')
|
|
||||||
form_args="$form_args\"$file\":\"$hash\","
|
|
||||||
done
|
|
||||||
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
|
||||||
```
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Generate deployment manifest
|
|
||||||
run: |
|
|
||||||
cd frontend/dist
|
|
||||||
form_args="-F manifest={"
|
|
||||||
for file in $(find . -type f ! -name manifest.json | sed 's|^\./||'); do
|
|
||||||
hash=$(sha256sum "$file" | awk '{print $1}')
|
|
||||||
form_args="$form_args\"$file\":\"$hash\","
|
|
||||||
done
|
|
||||||
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
|
||||||
echo "Form arguments: $form_args"
|
|
||||||
echo "FORM_ARGS=$form_args" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Deploy to Cloudflare Pages
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
run: |
|
|
||||||
cd frontend/dist
|
|
||||||
form_args="$FORM_ARGS" # use form arguments generated in previous step
|
|
||||||
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
|
||||||
curl -sS -X POST \
|
|
||||||
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/linearvoid/deployments" \
|
|
||||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
|
||||||
-F "commit_hash=${COMMIT_HASH}" \
|
|
||||||
$form_args
|
|
||||||
```
|
|
||||||
|
|
||||||
This matches the Cloudflare Pages Direct Upload API: `Content-Type: multipart/form-data`, each file as a separate form field, and a `manifest` JSON mapping filenames to SHA-256 hashes.
|
|
||||||
|
|
||||||
## Secrets to add in Gitea
|
|
||||||
|
|
||||||
| Secret | Value |
|
|
||||||
| ----------------------- | ------------------------------------ |
|
|
||||||
| `CLOUDFLARE_API_TOKEN` | API token with Pages:Edit permission |
|
|
||||||
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID from dashboard |
|
|
||||||
|
|
||||||
## Verify deployment
|
|
||||||
|
|
||||||
1. Push to `main` or trigger manually from Gitea Actions.
|
|
||||||
2. Check **Gitea Actions** → job logs for the deploy step.
|
|
||||||
3. Check **Cloudflare Pages** → Deployments for the new deployment.
|
|
||||||
4. Visit the `.pages.dev` preview URL, then promote to `linearvoid.tv` when ready.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The existing `ci.yml` already builds the frontend; the Pages deploy workflow is additive and independent.
|
|
||||||
- Multi-page SPA (`vite.config.ts`) produces three entry points — Cloudflare Pages serves them correctly.
|
|
||||||
- For SPA fallback routing, add a `frontend/public/_redirects` file with `/* /index.html 200` before building.
|
|
||||||
- If deployments fail with 403, verify the API token has `Account → Cloudflare Pages → Edit` permission.
|
|
||||||
- If you get error **8000007** ("Project not found"), the Pages project hasn't been created yet. Create it via the Cloudflare dashboard (Workers & Pages → Create → Pages → Direct Upload) first, then re-run.
|
|
||||||
- If `linearvoid` is already taken by a Worker, use a different project name like `linearvoid-site` and update the `projectName` and `PROJECT_NAME` values in the workflow.
|
|
||||||
|
|
||||||
## Related resources
|
|
||||||
|
|
||||||
- Cloudflare Workers dashboard: `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid/production/settings` — for managing the `infrastructure/cloudflare/worker.js` deployment
|
|
||||||
- Cloudflare Pages project: `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/pages/view/linearvoid` — deployment history and custom domain settings
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Gitea to Cloudflare Workers — Linear Void
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Deploy the Linear Void frontend (Vite + TypeScript SPA) from Gitea to Cloudflare Workers using Gitea Actions and Wrangler.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Cloudflare Worker** for the SPA:
|
||||||
|
- Dashboard → Workers & Pages → Create → Worker (or Workers Static Assets).
|
||||||
|
- Name it `linearvoid-frontend` (the edge-cache Worker already uses the `linearvoid` name).
|
||||||
|
2. **API token** with Workers permissions:
|
||||||
|
- Create at <https://dash.cloudflare.com/profile/api-tokens>
|
||||||
|
- Permission: `Account → Workers Scripts → Edit` and `Account → Workers Routes → Edit`
|
||||||
|
- Store in Gitea → Settings → Secrets as `CLOUDFLARE_API_TOKEN`
|
||||||
|
3. **Account ID** from Cloudflare dashboard overview → store as `CLOUDFLARE_ACCOUNT_ID`
|
||||||
|
4. **Wrangler config** (`frontend/wrangler.toml`) binding the `frontend/dist` output as static assets.
|
||||||
|
|
||||||
|
> **Naming:** Workers share a namespace under "Workers & Pages". The media edge Worker is `linearvoid-media` ([`infrastructure/cloudflare/`](../../infrastructure/cloudflare/wrangler.toml), deployed via `.gitea/workflows/workers-media-deploy.yml`) and the SPA Worker is `linearvoid-frontend` (`frontend/wrangler.toml`, deployed via `.gitea/workflows/workers-deploy.yml`).
|
||||||
|
|
||||||
|
## Project-specific values
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
| ------------------ | ---------------------------------------------------------- |
|
||||||
|
| Framework | Vite + TypeScript |
|
||||||
|
| Build command | `cd frontend && npm install && npm run build` |
|
||||||
|
| Output directory | `frontend/dist` |
|
||||||
|
| Node version | 22 |
|
||||||
|
| Multi-page entries | `index.html`, `admin/index.html`, `admin/login/index.html` |
|
||||||
|
|
||||||
|
## Gitea Actions workflow
|
||||||
|
|
||||||
|
Build the SPA and deploy with Wrangler:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Deploy to Cloudflare Workers
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
VITE_PUBLIC_APP_URL: ${{ secrets.VITE_PUBLIC_APP_URL }}
|
||||||
|
VITE_PUBLIC_WS_URL: ${{ secrets.VITE_PUBLIC_WS_URL }}
|
||||||
|
VITE_PUBLIC_HLS_URL: ${{ secrets.VITE_PUBLIC_HLS_URL }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_ROOM: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }}
|
||||||
|
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT: "50"
|
||||||
|
run: cd frontend && npm run build
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Workers
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npx wrangler deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secrets to add in Gitea
|
||||||
|
|
||||||
|
| Secret | Value |
|
||||||
|
| ------------------------------------- | ------------------------------------ |
|
||||||
|
| `CLOUDFLARE_API_TOKEN` | API token with Workers permissions |
|
||||||
|
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID from dashboard |
|
||||||
|
| `VITE_PUBLIC_APP_URL` | Public viewer URL |
|
||||||
|
| `VITE_PUBLIC_WS_URL` | WebSocket endpoint |
|
||||||
|
| `VITE_PUBLIC_HLS_URL` | HLS playlist URL |
|
||||||
|
| `VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR` | Default moderator username |
|
||||||
|
| `VITE_PUBLIC_ADMIN_DEFAULT_ROOM` | Default chat room |
|
||||||
|
|
||||||
|
## Verify deployment
|
||||||
|
|
||||||
|
1. Push to `main` or trigger manually from Gitea Actions.
|
||||||
|
2. Check **Gitea Actions** → job logs for the deploy step.
|
||||||
|
3. Check **Cloudflare Workers** → `linearvoid-frontend` → Deployments for the new version.
|
||||||
|
4. Visit `https://linearvoid-frontend.zwitschi82.workers.dev`, then attach `linearvoid.tv` when ready.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The existing `ci.yml` already builds the frontend; the Workers deploy workflow is additive and independent.
|
||||||
|
- Multi-page SPA (`vite.config.ts`) produces three entry points — Workers static assets serve them correctly.
|
||||||
|
- For SPA fallback routing, set Workers static assets `not_found_handling` to `single-page-application` (serve `index.html` for unknown paths).
|
||||||
|
- The deploy workflow lives in `.gitea/workflows/workers-deploy.yml` (triggered after CI succeeds).
|
||||||
|
|
||||||
|
## Related resources
|
||||||
|
|
||||||
|
- Cloudflare Workers dashboard: `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid/production/settings` — for managing the `infrastructure/cloudflare/worker.js` deployment and the SPA Worker
|
||||||
@@ -1,345 +1,156 @@
|
|||||||
# Scaling and Performance Playbook
|
# Scaling and Performance Playbook
|
||||||
|
|
||||||
Purpose: define repeatable method to measure performance, capture baseline data, and decide scaling actions.
|
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.
|
||||||
|
|
||||||
Scope: planning and execution guide for TODO items under "Scaling & Performance (planning only)".
|
|
||||||
|
|
||||||
## 1) Manifest TTL Tuning Plan
|
## 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:
|
Inputs: segment duration (`HLS_SEGMENT_DURATION`), current manifest TTL (Cloudflare cache rule), cache outcome distribution (`Cf-Cache-Status`).
|
||||||
|
|
||||||
- Segment duration (`HLS_SEGMENT_DURATION`)
|
|
||||||
- Current manifest TTL (Cloudflare cache rule)
|
|
||||||
- Cache outcome distribution (`Cf-Cache-Status`)
|
|
||||||
|
|
||||||
Procedure:
|
Procedure:
|
||||||
|
|
||||||
1. Start from current manifest TTL (2s).
|
1. Start from current manifest TTL (2s).
|
||||||
2. Capture 24h baseline headers from repeated `playlist.m3u8` requests.
|
2. Capture 24h baseline headers from repeated `playlist.m3u8` requests.
|
||||||
3. Test candidate TTLs: 1s, 2s, 3s.
|
3. Test candidate TTLs: 1s, 2s, 3s.
|
||||||
4. Compare:
|
4. Compare rebuffer events (player logs), origin request increase/decrease, and HIT ratio changes.
|
||||||
- rebuffer events from player logs
|
5. Select the lowest TTL that keeps playback stable and origin load acceptable.
|
||||||
- origin request increase/decrease
|
|
||||||
- HIT ratio changes
|
|
||||||
5. Select lowest TTL that keeps playback stable and origin load acceptable.
|
|
||||||
|
|
||||||
Acceptance:
|
Acceptance: no material increase in rebuffer rate; manifest staleness not observable by users.
|
||||||
|
|
||||||
- No material increase in rebuffer rate.
|
|
||||||
- Manifest staleness not observable by users.
|
|
||||||
|
|
||||||
## 2) TTFB Baseline Plan
|
## 2) TTFB Baseline Plan
|
||||||
|
|
||||||
Goal: measure warm-cache TTFB from 3+ US regions with target `< 4s`.
|
Goal: measure warm-cache TTFB from 3+ US regions with target `< 4s`.
|
||||||
|
|
||||||
Suggested regions:
|
Regions: US East (Virginia), US Central, US West (California/Oregon).
|
||||||
|
|
||||||
- US East (Virginia)
|
|
||||||
- US Central
|
|
||||||
- US West (California/Oregon)
|
|
||||||
|
|
||||||
Method:
|
Method:
|
||||||
|
|
||||||
1. Use WebPageTest with repeat view enabled.
|
1. Use WebPageTest with repeat view enabled.
|
||||||
2. Run at least 5 samples per region.
|
2. Run at least 5 samples per region.
|
||||||
3. Record median and p95 TTFB.
|
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:
|
Acceptance: warm-cache median < 4s in all regions; outliers documented with likely cause.
|
||||||
|
|
||||||
- Warm-cache median < 4s in all tested regions.
|
|
||||||
- Outliers documented with likely cause.
|
|
||||||
|
|
||||||
### TTFB Baseline Worksheet
|
### 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)
|
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.
|
||||||
- Date/time:
|
|
||||||
- Test URL: <https://linearvoid.tv>
|
|
||||||
- WebPageTest result URL:
|
|
||||||
- Median TTFB:
|
|
||||||
- p95 TTFB:
|
|
||||||
- Notes:
|
|
||||||
- US Central
|
|
||||||
- Date/time:
|
|
||||||
- Test URL: <https://linearvoid.tv>
|
|
||||||
- WebPageTest result URL:
|
|
||||||
- Median TTFB:
|
|
||||||
- p95 TTFB:
|
|
||||||
- Notes:
|
|
||||||
- US West (California/Oregon)
|
|
||||||
- Date/time:
|
|
||||||
- Test URL: <https://linearvoid.tv>
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 3) WebSocket Load-Test Plan
|
## 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):
|
Tooling option A (Artillery):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx artillery run scripts/loadtest/ws-chat.yml
|
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
|
Metrics: p50/p95 message latency; error rate (connect/send/receive); dropped connections; CPU/memory for `chat`, `redis`, `nginx`.
|
||||||
- send message every X seconds
|
|
||||||
- measure round-trip latency and failures
|
|
||||||
|
|
||||||
Metrics to capture:
|
Acceptance: p95 chat latency ≤ 1s at target concurrency tier; error rate ≤ 1% during steady state.
|
||||||
|
|
||||||
- p50/p95 message latency
|
|
||||||
- error rate (connect, send, receive)
|
|
||||||
- dropped connections
|
|
||||||
- CPU/memory for `chat`, `redis`, `nginx`
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
|
|
||||||
- p95 chat latency <= 1s at target concurrency tier.
|
|
||||||
- error rate <= 1% during steady state.
|
|
||||||
|
|
||||||
### WebSocket Load-Test Worksheet
|
### WebSocket Load-Test Worksheet
|
||||||
|
Date/time, Environment, Commit SHA/image tag, Test URL, Tool used, Scenario duration, Concurrency tiers reached, p50 message latency, p95 message latency, Error rate, Dropped connections, CPU/memory notes for chat/redis/nginx, Raw results location, Conclusion, Next action.
|
||||||
- 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:
|
|
||||||
|
|
||||||
## 4) Admin API Benchmark Plan
|
## 4) Admin API Benchmark Plan
|
||||||
|
|
||||||
Goal: measure admin API p95 and identify slow endpoints.
|
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
|
```bash
|
||||||
k6 run scripts/loadtest/admin-api.js
|
k6 run scripts/loadtest/admin-api.js
|
||||||
```
|
```
|
||||||
|
|
||||||
Tooling option B (`wrk`):
|
Tooling option B (`wrk`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wrk -t4 -c100 -d60s http://<host>/api/admin/overview
|
wrk -t4 -c100 -d60s http://<host>/api/admin/overview
|
||||||
```
|
```
|
||||||
|
|
||||||
Endpoints:
|
Acceptance: p95 < 1s for primary admin endpoints; slow endpoint list with remediation notes.
|
||||||
|
|
||||||
- `/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.
|
|
||||||
|
|
||||||
### Admin API Benchmark Worksheet
|
### Admin API Benchmark Worksheet
|
||||||
|
Date/time, Environment, Commit SHA/image tag, Test URL, Tool used, Scenario duration, Concurrency/virtual users, `/api/admin/overview|analytics|chat-log|users` p95, Error rate, Slowest endpoint, Cache or DB notes, Raw results location, Conclusion, Next action.
|
||||||
- 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:
|
|
||||||
|
|
||||||
## 5) Cache Hit Ratio Tracking Plan
|
## 5) Cache Hit Ratio Tracking Plan
|
||||||
|
|
||||||
Goal: track Cloudflare cache hit ratio and alert when below threshold.
|
Goal: track Cloudflare cache hit ratio and alert when below threshold.
|
||||||
|
|
||||||
Implementation outline:
|
Implementation:
|
||||||
|
|
||||||
1. Pull Cloudflare GraphQL analytics on schedule.
|
1. Pull Cloudflare GraphQL analytics on schedule.
|
||||||
2. Export to Prometheus-compatible metric path.
|
2. Export to a Prometheus-compatible metric path.
|
||||||
3. Add Grafana panel with 5m/1h/24h windows.
|
3. Add a Grafana panel with 5m/1h/24h windows.
|
||||||
4. Alert when hit ratio < 90% for 15m.
|
4. Alert when hit ratio < 90% for 15m.
|
||||||
|
|
||||||
Reference exporter:
|
Reference exporter:
|
||||||
|
|
||||||
- `scripts/cache-hit-ratio.js`
|
- `scripts/cache-hit-ratio.js`
|
||||||
- `scripts/cache-hit-ratio.ps1` for Windows scheduled tasks / manual runs
|
- `scripts/cache-hit-ratio.sh` for scheduled tasks / manual runs
|
||||||
- Example run: `CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_TAG=... CLOUDFLARE_HOSTNAME=media.linearvoid.tv node scripts/cache-hit-ratio.js`
|
- Example run: `export 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`
|
|
||||||
- Intended output: Prometheus text format on stdout, suitable for cron, a Node wrapper, or a small scrape endpoint.
|
- Intended output: Prometheus text format on stdout, suitable for cron, a Node wrapper, or a small scrape endpoint.
|
||||||
|
|
||||||
Metric target:
|
Metric target: `cache_hit_ratio >= 0.90`.
|
||||||
|
|
||||||
- `cache_hit_ratio >= 0.90`
|
|
||||||
|
|
||||||
### Cache Hit Ratio Worksheet
|
### 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
|
## 6) Capacity Trigger Rules
|
||||||
|
|
||||||
Define operational triggers:
|
Operational triggers:
|
||||||
|
|
||||||
- Cache miss ratio > 10% for 15m
|
- Cache miss ratio > 10% for 15m
|
||||||
- Chat p95 latency > 1s for 5m
|
- Chat p95 latency > 1s for 5m
|
||||||
- Host CPU > 80% for 10m
|
- Host CPU > 80% for 10m
|
||||||
- Redis memory usage ratio (`linearvoid_chat_redis_memory_usage_ratio`) > 0.85 for 10m
|
- Redis memory usage ratio (`linearvoid_chat_redis_memory_usage_ratio`) > 0.85 for 10m
|
||||||
|
|
||||||
Action mapping:
|
Action mapping:
|
||||||
|
|
||||||
- Level 1: investigate + restart non-critical components
|
- Level 1: investigate + restart non-critical components
|
||||||
- Level 2: scale service resources (CPU/RAM)
|
- Level 2: scale service resources (CPU/RAM)
|
||||||
- Level 3: activate standby planning path
|
- Level 3: activate standby planning path
|
||||||
|
|
||||||
### Capacity Trigger Review
|
### 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
|
## 7) Phase 2 Service Separation Plan
|
||||||
|
|
||||||
Objective: separate bottlenecked services while keeping simple ops.
|
Objective: separate bottlenecked services while keeping ops simple.
|
||||||
|
|
||||||
Boundaries:
|
Boundaries:
|
||||||
|
|
||||||
- `playout` isolated first (CPU-heavy)
|
- `playout` isolated first (CPU-heavy)
|
||||||
- `chat` + `redis` grouped for latency
|
- `chat` + `redis` grouped for latency
|
||||||
- `db` isolated for IO stability
|
- `db` isolated for IO stability
|
||||||
- monitoring stack optionally separated after sustained load
|
- monitoring stack optionally separated after sustained load
|
||||||
|
|
||||||
Initial sizing guidance:
|
Initial sizing:
|
||||||
|
|
||||||
- playout host: 4 vCPU / 8 GB
|
- 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
|
- db host: 2 vCPU / 4 GB + fast disk
|
||||||
|
|
||||||
Exit criteria for Phase 2:
|
Exit criteria: repeated trigger events over 7 days; capacity saturation despite tuning.
|
||||||
|
|
||||||
- repeated trigger events over 7 days
|
|
||||||
- capacity saturation despite tuning
|
|
||||||
|
|
||||||
### Phase 2 Boundary Worksheet
|
### Phase 2 Boundary Worksheet
|
||||||
|
Date/time, Environment, Commit SHA/image tag, Trigger history summary, Repeated trigger count over 7 days, Primary bottleneck, Proposed split, `playout`/`chat`/`db` target host size, Monitoring separation needed, Data/state migration notes, Implementation owner, Review decision, Next action.
|
||||||
- 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:
|
|
||||||
|
|
||||||
## 8) Phase 3 Warm Standby Plan
|
## 8) Phase 3 Warm Standby Plan
|
||||||
|
|
||||||
Objective: second-region resilience with DNS failover.
|
Objective: second-region resilience with DNS failover.
|
||||||
|
|
||||||
Candidate layout:
|
Layout:
|
||||||
|
|
||||||
- Primary: Hetzner Ashburn (US-East)
|
- Primary: Hetzner Ashburn (US-East)
|
||||||
- Standby: Hetzner Hillsboro (US-West)
|
- Standby: Hetzner Hillsboro (US-West)
|
||||||
|
|
||||||
Design:
|
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).
|
||||||
|
|
||||||
- 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:
|
|
||||||
|
|
||||||
### Phase 3 Warm Standby Worksheet
|
### 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
|
## 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
|
## 10) Execution Order
|
||||||
|
|
||||||
1. TTFB baseline
|
1. TTFB baseline
|
||||||
2. WebSocket load profile
|
2. WebSocket load profile
|
||||||
3. Admin API benchmark
|
3. Admin API benchmark
|
||||||
@@ -349,14 +160,9 @@ For each benchmark run, capture:
|
|||||||
7. Phase 3 standby design review
|
7. Phase 3 standby design review
|
||||||
|
|
||||||
## 11) Benchmark Run Sheet
|
## 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 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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Run template:
|
Run template:
|
||||||
|
|
||||||
@@ -373,70 +179,47 @@ Run template:
|
|||||||
| Next action | |
|
| Next action | |
|
||||||
|
|
||||||
Suggested command mapping:
|
Suggested command mapping:
|
||||||
|
|
||||||
- TTFB baseline: WebPageTest repeat-view run against `https://linearvoid.tv`.
|
- 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.
|
- WebSocket profile: `npx artillery run scripts/loadtest/ws-chat.yml` or the Go harness referenced above.
|
||||||
- Admin API benchmark: `k6 run scripts/loadtest/admin-api.js` or `wrk -t4 -c100 -d60s http://<host>/api/admin/overview`.
|
- Admin API benchmark: `k6 run scripts/loadtest/admin-api.js` or `wrk -t4 -c100 -d60s http://<host>/api/admin/overview`.
|
||||||
|
|
||||||
## 12) Scaling Decision Gate
|
## 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.
|
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.
|
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.
|
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:
|
Decision note: keep the smallest change that restores the target metric and record the reason in the run sheet.
|
||||||
|
|
||||||
- Keep the smallest change that restores the target metric and record the reason in the run sheet.
|
|
||||||
|
|
||||||
## 13) Artifact-to-Worksheet Field Mapping
|
## 13) Artifact-to-Worksheet Field Mapping
|
||||||
|
Use when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
|
||||||
Use this section when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
|
|
||||||
|
|
||||||
### WebSocket worksheet mapping
|
### WebSocket worksheet mapping
|
||||||
|
Artifact sources: `artifacts/<timestamp>/ws-chat-report.json`, `artifacts/<timestamp>/ws-chat-summary.txt`.
|
||||||
Artifact sources:
|
|
||||||
|
|
||||||
- `artifacts/<timestamp>/ws-chat-report.json`
|
|
||||||
- `artifacts/<timestamp>/ws-chat-summary.txt`
|
|
||||||
|
|
||||||
Field mapping:
|
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`)
|
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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Admin API worksheet mapping
|
### Admin API worksheet mapping
|
||||||
|
Artifact sources: `artifacts/<timestamp>/admin-api-summary.json`, `artifacts/<timestamp>/admin-api-output.txt`, `artifacts/<timestamp>/admin-api-endpoint-summary.txt`.
|
||||||
Artifact sources:
|
|
||||||
|
|
||||||
- `artifacts/<timestamp>/admin-api-summary.json`
|
|
||||||
- `artifacts/<timestamp>/admin-api-output.txt`
|
|
||||||
- `artifacts/<timestamp>/admin-api-endpoint-summary.txt`
|
|
||||||
|
|
||||||
Field mapping:
|
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`)
|
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.
|
||||||
- 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:
|
Summary helper (bash): run `scripts/loadtest/summarize-artillery.sh <report.json>` after an Artillery WS run, and `scripts/loadtest/summarize-k6.sh <summary.json>` after a k6 admin run, to produce the artifact files used in the mappings above.
|
||||||
|
|
||||||
- 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.
|
|
||||||
+55
-122
@@ -1,130 +1,63 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Linear Void Admin</title>
|
|
||||||
<link rel="stylesheet" href="../src/style.css" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="admin-body">
|
<head>
|
||||||
<div class="admin-shell">
|
<meta charset="UTF-8" />
|
||||||
<aside class="admin-sidebar" aria-label="Primary">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<div class="brand-block">
|
<title>Linear Void Admin</title>
|
||||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<div class="brand-text">Admin</div>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
</div>
|
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="../src/style.css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
<button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button>
|
<body class="admin-body">
|
||||||
|
<div class="admin-shell">
|
||||||
|
<aside class="admin-sidebar" aria-label="Primary">
|
||||||
|
<div class="brand-block">
|
||||||
|
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||||
|
<div class="brand-text">Admin</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical">
|
<button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-dashboard"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger is-active"
|
|
||||||
data-tab="dashboard"
|
|
||||||
aria-controls="tab-dashboard"
|
|
||||||
aria-selected="true"
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-upload"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="upload"
|
|
||||||
aria-controls="tab-upload"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Upload Video
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-playlist"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="playlist"
|
|
||||||
aria-controls="tab-playlist"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Playlist
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-schedule"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="schedule"
|
|
||||||
aria-controls="tab-schedule"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Schedule
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-moderation"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="moderation"
|
|
||||||
aria-controls="tab-moderation"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Moderation
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-chat-log"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="chat-log"
|
|
||||||
aria-controls="tab-chat-log"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Chat Log
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-users"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="users"
|
|
||||||
aria-controls="tab-users"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Users
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-analytics"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="analytics"
|
|
||||||
aria-controls="tab-analytics"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Analytics
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-system"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="system"
|
|
||||||
aria-controls="tab-system"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
System
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="admin-main" id="admin-main" tabindex="-1">
|
<nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical">
|
||||||
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
<button type="button" id="tab-trigger-dashboard" role="tab" class="tab-trigger is-active" data-tab="dashboard" aria-controls="tab-dashboard" aria-selected="true">
|
||||||
</main>
|
Dashboard
|
||||||
</div>
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-upload" role="tab" class="tab-trigger" data-tab="upload" aria-controls="tab-upload" aria-selected="false">
|
||||||
|
Upload Video
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">
|
||||||
|
Playlist
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">
|
||||||
|
Schedule
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">
|
||||||
|
Moderation
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-chat-log" role="tab" class="tab-trigger" data-tab="chat-log" aria-controls="tab-chat-log" aria-selected="false">
|
||||||
|
Chat Log
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">
|
||||||
|
Users
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">
|
||||||
|
Analytics
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">
|
||||||
|
System
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="admin-main" id="admin-main" tabindex="-1">
|
||||||
|
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/admin.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
<script type="module" src="/src/admin.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1,65 +1,70 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Linear Void Admin Login</title>
|
|
||||||
<link rel="stylesheet" href="../../src/style.css" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="auth-body">
|
<head>
|
||||||
<main class="auth-shell" aria-label="Admin authentication">
|
<meta charset="UTF-8" />
|
||||||
<section class="auth-card" aria-labelledby="auth-title">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<p class="auth-kicker">Linear Void</p>
|
<title>Linear Void Admin Login</title>
|
||||||
<h1 id="auth-title">Admin Login</h1>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="../../src/style.css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
<form id="login-form" class="form-stack" novalidate>
|
<body class="auth-body">
|
||||||
<label for="username">Username</label>
|
<main class="auth-shell" aria-label="Admin authentication">
|
||||||
<input id="username" type="text" class="text-input" autocomplete="username" required />
|
<section class="auth-card" aria-labelledby="auth-title">
|
||||||
|
<p class="auth-kicker">Linear Void</p>
|
||||||
|
<h1 id="auth-title">Admin Login</h1>
|
||||||
|
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
|
||||||
|
|
||||||
<label for="password">Password</label>
|
<form id="login-form" class="form-stack" novalidate>
|
||||||
<input id="password" type="password" class="text-input" autocomplete="current-password" required />
|
<label for="username">Username</label>
|
||||||
|
<input id="username" type="text" class="text-input" autocomplete="username" required />
|
||||||
|
|
||||||
<button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
|
<label for="password">Password</label>
|
||||||
<p id="status" class="status-line" aria-live="polite"></p>
|
<input id="password" type="password" class="text-input" autocomplete="current-password" required />
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
<button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
|
||||||
const form = document.getElementById("login-form");
|
<p id="status" class="status-line" aria-live="polite"></p>
|
||||||
const username = document.getElementById("username");
|
</form>
|
||||||
const password = document.getElementById("password");
|
</section>
|
||||||
const submit = document.getElementById("submit");
|
</main>
|
||||||
const status = document.getElementById("status");
|
|
||||||
|
|
||||||
form.addEventListener("submit", async (event) => {
|
<script>
|
||||||
event.preventDefault();
|
const form = document.getElementById("login-form");
|
||||||
submit.disabled = true;
|
const username = document.getElementById("username");
|
||||||
status.textContent = "Signing in...";
|
const password = document.getElementById("password");
|
||||||
status.className = "status-line";
|
const submit = document.getElementById("submit");
|
||||||
|
const status = document.getElementById("status");
|
||||||
|
|
||||||
try {
|
form.addEventListener("submit", async (event) => {
|
||||||
const response = await fetch("/api/admin/login", {
|
event.preventDefault();
|
||||||
method: "POST",
|
submit.disabled = true;
|
||||||
headers: { "Content-Type": "application/json" },
|
status.textContent = "Signing in...";
|
||||||
body: JSON.stringify({ username: username.value, password: password.value }),
|
status.className = "status-line";
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
try {
|
||||||
throw new Error(`HTTP ${response.status}`);
|
const response = await fetch("/api/admin/login", {
|
||||||
}
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||||
|
});
|
||||||
|
|
||||||
window.location.href = "/admin/";
|
if (!response.ok) {
|
||||||
} catch (error) {
|
throw new Error(`HTTP ${response.status}`);
|
||||||
console.error("Failed to sign in", error);
|
|
||||||
status.textContent = "Login failed. Check your credentials.";
|
|
||||||
status.className = "status-line status-error";
|
|
||||||
} finally {
|
|
||||||
submit.disabled = false;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
</script>
|
window.location.href = "/admin/";
|
||||||
</body>
|
} catch (error) {
|
||||||
|
console.error("Failed to sign in", error);
|
||||||
|
status.textContent = "Login failed. Check your credentials.";
|
||||||
|
status.className = "status-line status-error";
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
+110
-69
@@ -1,81 +1,122 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
|
||||||
<link rel="stylesheet" href="/src/style.css" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="lv-body">
|
<head>
|
||||||
<main class="viewer-shell" aria-label="Live player and chat">
|
<meta charset="UTF-8" />
|
||||||
<header class="viewer-header">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<div class="viewer-brand-group">
|
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<div>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<h1 class="viewer-title">Linear Void</h1>
|
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="lv-body">
|
||||||
|
<main class="viewer-shell" aria-label="Live player and chat">
|
||||||
|
<header class="viewer-header">
|
||||||
|
<div class="viewer-brand-group">
|
||||||
|
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="viewer-title">Linear Void</h1>
|
||||||
|
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">
|
||||||
|
Hide Chat
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||||
|
<section class="player-stage" aria-label="Video stage">
|
||||||
|
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||||
|
<div id="now-playing" class="now-playing-overlay" aria-live="polite">
|
||||||
|
<span id="np-title">Loading...</span>
|
||||||
|
<span id="np-next" class="np-next"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||||
|
<header class="chat-header">
|
||||||
|
<div class="chat-room-title">
|
||||||
|
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||||
|
<span id="room-name">Main Channel</span>
|
||||||
|
</div>
|
||||||
|
<div class="chat-header-actions">
|
||||||
|
<span id="chat-user-label" class="chat-user-label" hidden></span>
|
||||||
|
<button id="auth-toggle-btn" type="button" class="btn btn-secondary">Sign in</button>
|
||||||
|
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="auth-panel" class="auth-modal" hidden>
|
||||||
|
<div id="auth-backdrop" class="auth-modal-backdrop"></div>
|
||||||
|
<div class="auth-modal-content">
|
||||||
|
<button id="auth-close-btn" type="button" class="btn auth-close" aria-label="Close sign in">×</button>
|
||||||
|
<h2 class="auth-modal-title">Sign in</h2>
|
||||||
|
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
|
||||||
|
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
|
||||||
|
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
|
||||||
|
</div>
|
||||||
|
<p id="auth-status" class="status-line" aria-live="polite"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
id="chat-toggle"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary chat-toggle-btn"
|
|
||||||
onclick="toggleChatPanel()"
|
|
||||||
aria-controls="chat-panel"
|
|
||||||
aria-expanded="true"
|
|
||||||
>
|
|
||||||
Hide Chat
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="viewer-content" aria-label="Live stream and chat layout">
|
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
|
||||||
<section class="player-stage" aria-label="Video stage">
|
|
||||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
||||||
<div id="now-playing" class="now-playing-overlay" aria-live="polite">
|
|
||||||
<span id="np-title">Loading...</span>
|
<section id="presence-panel" class="presence-panel">
|
||||||
<span id="np-next" class="np-next"></span>
|
<div id="presence-list" class="presence-list"></div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
<div class="chat-input-area">
|
||||||
<header class="chat-header">
|
<div class="chat-input-row">
|
||||||
<div class="chat-room-title">
|
<label for="message-input" class="sr-only">Message input</label>
|
||||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if (event.key === 'Enter') sendMessage();" />
|
||||||
<span id="room-name">Main Channel</span>
|
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
|
||||||
|
|
||||||
<section id="presence-panel" class="presence-panel" aria-label="Online users">
|
|
||||||
<div id="presence-list" class="presence-list"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="chat-input-area">
|
|
||||||
<div class="chat-input-row">
|
|
||||||
<label for="message-input" class="sr-only">Message input</label>
|
|
||||||
<input
|
|
||||||
id="message-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Type a message..."
|
|
||||||
class="input"
|
|
||||||
onkeydown="if (event.key === 'Enter') sendMessage();"
|
|
||||||
/>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
|
||||||
</div>
|
|
||||||
<p class="chat-note">Synced with video • Moderation active</p>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
<p class="chat-note">Synced with video • Moderation active</p>
|
||||||
</section>
|
</div>
|
||||||
</main>
|
</aside>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
||||||
<div class="reconnecting-spinner"></div>
|
<div class="reconnecting-spinner"></div>
|
||||||
<span id="reconnecting-text">Reconnecting...</span>
|
<span id="reconnecting-text">Reconnecting...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// Load Matomo analytics when configured via the admin runtime config.
|
||||||
|
fetch("/api/matomo")
|
||||||
|
.then(function (r) {
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
})
|
||||||
|
.then(function (cfg) {
|
||||||
|
if (!cfg || !cfg.matomoUrl || !cfg.matomoSiteId) return;
|
||||||
|
var _paq = (window._paq = window._paq || []);
|
||||||
|
_paq.push(["trackPageView"]);
|
||||||
|
_paq.push(["enableLinkTracking"]);
|
||||||
|
var base = String(cfg.matomoUrl).replace(/\/+$/, "");
|
||||||
|
_paq.push(["setTrackerUrl", base + "/matomo.php"]);
|
||||||
|
_paq.push(["setSiteId", String(cfg.matomoSiteId)]);
|
||||||
|
var d = document;
|
||||||
|
var g = d.createElement("script");
|
||||||
|
var s = d.getElementsByTagName("script")[0];
|
||||||
|
g.async = true;
|
||||||
|
g.src = base + "/matomo.js";
|
||||||
|
s.parentNode.insertBefore(g, s);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
/* analytics is optional */
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.0.11",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.0.11",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hls.js": "^1.6.16"
|
"hls.js": "^1.6.16"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.12",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0"
|
"node": ">=22.0.0"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table">
|
<table class="data-table chat-log-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><input type="checkbox" id="chat-log-select-all" title="Select all" /></th>
|
<th><input type="checkbox" id="chat-log-select-all" title="Select all" /></th>
|
||||||
|
|||||||
@@ -56,8 +56,13 @@
|
|||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<h2>Runtime Configuration</h2>
|
<h2>Runtime Configuration</h2>
|
||||||
<form id="config-form" class="form-stack" style="max-width: 400px">
|
<form id="config-form" class="form-stack" style="max-width: 400px">
|
||||||
|
<label>HLS Segment Duration (seconds) <input id="config-hls-duration" type="number" min="2" max="15" class="text-input" /></label>
|
||||||
|
<label>HLS Window Size (segments) <input id="config-hls-window" type="number" min="3" max="100" class="text-input" /></label>
|
||||||
|
<label>Matomo URL <input id="config-matomo-url" type="url" class="text-input" placeholder="https://..." /></label>
|
||||||
|
<label>Matomo Site ID <input id="config-matomo-site-id" type="text" class="text-input" /></label>
|
||||||
<label>Rate Limit Max <input id="config-rate-max" type="number" class="text-input" /></label>
|
<label>Rate Limit Max <input id="config-rate-max" type="number" class="text-input" /></label>
|
||||||
<label>Rate Limit Window (seconds) <input id="config-rate-window" type="number" class="text-input" /></label>
|
<label>Rate Limit Window (seconds) <input id="config-rate-window" type="number" class="text-input" /></label>
|
||||||
|
<p class="small-muted">HLS changes take effect after restarting the playout service.</p>
|
||||||
<button type="submit" class="btn btn-primary">Save Config</button>
|
<button type="submit" class="btn btn-primary">Save Config</button>
|
||||||
</form>
|
</form>
|
||||||
<p id="config-status" class="status-line" aria-live="polite"></p>
|
<p id="config-status" class="status-line" aria-live="polite"></p>
|
||||||
|
|||||||
@@ -326,3 +326,53 @@ describe("admin responsive layout", () => {
|
|||||||
expect(tabs).toContain("Dashboard");
|
expect(tabs).toContain("Dashboard");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("admin data refresh failures", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="overview-status"></div>
|
||||||
|
<div id="analytics-status"></div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = `<ul id="playlist-sortable"></ul>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders badges for every media status", () => {
|
||||||
|
const statusMap = new Map<string, string>([
|
||||||
|
["/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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+206
-34
@@ -16,6 +16,7 @@ type MediaEntry = {
|
|||||||
runtime?: string;
|
runtime?: string;
|
||||||
modifiedAt?: string;
|
modifiedAt?: string;
|
||||||
tags?: string;
|
tags?: string;
|
||||||
|
path?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const bodyDataset = document.body.dataset;
|
const bodyDataset = document.body.dataset;
|
||||||
@@ -30,6 +31,7 @@ const adminConfig = {
|
|||||||
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
||||||
const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
|
const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
|
||||||
const UPLOAD_RESUME_KEY_PREFIX = "linearvoid-upload-resume:";
|
const UPLOAD_RESUME_KEY_PREFIX = "linearvoid-upload-resume:";
|
||||||
|
const API_URL = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
mediaUploading: false,
|
mediaUploading: false,
|
||||||
@@ -130,7 +132,7 @@ const pageCache: Record<string, string> = {};
|
|||||||
async function loadPageContent(tabName: string): Promise<string> {
|
async function loadPageContent(tabName: string): Promise<string> {
|
||||||
if (pageCache[tabName]) return pageCache[tabName];
|
if (pageCache[tabName]) return pageCache[tabName];
|
||||||
|
|
||||||
const response = await fetch(`/admin/${tabName}.html`);
|
const response = await fetch(`${API_URL}/admin/${tabName}.html`);
|
||||||
if (!response.ok) throw new Error(`Failed to load page: ${tabName}`);
|
if (!response.ok) throw new Error(`Failed to load page: ${tabName}`);
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
pageCache[tabName] = html;
|
pageCache[tabName] = html;
|
||||||
@@ -174,7 +176,7 @@ async function switchTab(tabName: string) {
|
|||||||
|
|
||||||
export async function refreshOverview() {
|
export async function refreshOverview() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/overview");
|
const response = await fetch(`${API_URL}/api/admin/overview`);
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
@@ -201,7 +203,7 @@ export async function refreshOverview() {
|
|||||||
export async function refreshAnalytics() {
|
export async function refreshAnalytics() {
|
||||||
setStatus("analytics-status", "Loading analytics...");
|
setStatus("analytics-status", "Loading analytics...");
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/analytics");
|
const response = await fetch(`${API_URL}/api/admin/analytics`);
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
@@ -239,14 +241,15 @@ export async function refreshAnalytics() {
|
|||||||
async function refreshPlaylist() {
|
async function refreshPlaylist() {
|
||||||
setStatus("playlist-status", "Loading playlist...");
|
setStatus("playlist-status", "Loading playlist...");
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/playlist");
|
const response = await fetch(`${API_URL}/api/admin/playlist`);
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
const content: string = payload.content || "";
|
const content: string = payload.content || "";
|
||||||
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
|
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
|
||||||
if (textarea) textarea.value = content;
|
if (textarea) textarea.value = content;
|
||||||
renderPlaylistSortable(content);
|
const statusMap = await fetchMediaStatusMap();
|
||||||
|
renderPlaylistSortable(content, statusMap);
|
||||||
await showPlaylistWarnings(content);
|
await showPlaylistWarnings(content);
|
||||||
setStatus("playlist-status", "Playlist loaded.");
|
setStatus("playlist-status", "Playlist loaded.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -255,7 +258,7 @@ async function refreshPlaylist() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderPlaylistSortable(content: string) {
|
export function renderPlaylistSortable(content: string, statusMap?: Map<string, string>) {
|
||||||
const list = document.getElementById("playlist-sortable");
|
const list = document.getElementById("playlist-sortable");
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||||
@@ -265,10 +268,12 @@ export function renderPlaylistSortable(content: string) {
|
|||||||
.replace(/^file\s+/, "")
|
.replace(/^file\s+/, "")
|
||||||
.replace(/['"]/g, "")
|
.replace(/['"]/g, "")
|
||||||
.trim();
|
.trim();
|
||||||
|
const badge = statusMap ? statusBadge(statusMap.get(path)) : "";
|
||||||
return `<li class="sortable-item" draggable="true" data-index="${i}">
|
return `<li class="sortable-item" draggable="true" data-index="${i}">
|
||||||
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
|
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
|
||||||
<span class="sortable-index">${i + 1}</span>
|
<span class="sortable-index">${i + 1}</span>
|
||||||
<span class="sortable-path">${path}</span>
|
<span class="sortable-path">${path}</span>
|
||||||
|
${badge}
|
||||||
<button type="button" class="btn btn-secondary btn-sm" data-remove="${i}">✕</button>
|
<button type="button" class="btn btn-secondary btn-sm" data-remove="${i}">✕</button>
|
||||||
</li>`;
|
</li>`;
|
||||||
})
|
})
|
||||||
@@ -352,6 +357,80 @@ async function showPlaylistWarnings(content: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusBadge(status: string | undefined): string {
|
||||||
|
if (!status) return `<span class="badge badge-muted">unknown</span>`;
|
||||||
|
switch (status) {
|
||||||
|
case "ready":
|
||||||
|
return `<span class="badge badge-ok">ready</span>`;
|
||||||
|
case "needs_normalization":
|
||||||
|
return `<span class="badge badge-warn">needs normalization</span>`;
|
||||||
|
case "normalizing":
|
||||||
|
return `<span class="badge badge-info">normalizing</span>`;
|
||||||
|
case "normalization_failed":
|
||||||
|
return `<span class="badge badge-danger">failed</span>`;
|
||||||
|
default:
|
||||||
|
return `<span class="badge badge-muted">${status}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMediaStatusMap(): Promise<Map<string, string>> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/admin/media");
|
||||||
|
if (!resp.ok) return map;
|
||||||
|
const payload = await resp.json();
|
||||||
|
for (const f of payload.files || []) {
|
||||||
|
if (f.path) map.set(f.path, f.status || "unknown");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMediaToPlaylist(file: MediaEntry) {
|
||||||
|
if (file.status !== "ready") {
|
||||||
|
setStatus("media-status", "Only ready media can be added to the playlist.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!file.path) {
|
||||||
|
setStatus("media-status", "Cannot add media without a path.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("media-status", `Adding ${file.name} to playlist...`);
|
||||||
|
try {
|
||||||
|
const getResp = await fetch("/api/admin/playlist");
|
||||||
|
if (!getResp.ok) throw new Error(await readErrorMessage(getResp));
|
||||||
|
const payload = await getResp.json();
|
||||||
|
const content: string = payload.content || "";
|
||||||
|
const lines = content.split("\n").filter((l) => l.trim());
|
||||||
|
const entry = `file '${file.path}'`;
|
||||||
|
const exists = lines.some(
|
||||||
|
(l) =>
|
||||||
|
l
|
||||||
|
.replace(/^file\s+/, "")
|
||||||
|
.replace(/['"]/g, "")
|
||||||
|
.trim() === file.path,
|
||||||
|
);
|
||||||
|
if (exists) {
|
||||||
|
setStatus("media-status", `${file.name} is already in the playlist.`, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lines.push(entry);
|
||||||
|
const putResp = await fetch("/api/admin/playlist", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ content: lines.join("\n") + "\n" }),
|
||||||
|
});
|
||||||
|
if (!putResp.ok) throw new Error(await readErrorMessage(putResp));
|
||||||
|
setStatus("media-status", `${file.name} added to playlist.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to add media to playlist", error);
|
||||||
|
const details = error instanceof Error ? error.message : "Unknown error.";
|
||||||
|
setStatus("media-status", `Failed to add ${file.name} to playlist. (${details})`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function savePlaylist() {
|
export async function savePlaylist() {
|
||||||
if (state.playlistSaving) return;
|
if (state.playlistSaving) return;
|
||||||
state.playlistSaving = true;
|
state.playlistSaving = true;
|
||||||
@@ -444,7 +523,7 @@ async function submitOverride(e: Event) {
|
|||||||
const endTime = (document.getElementById("override-end") as HTMLInputElement).value;
|
const endTime = (document.getElementById("override-end") as HTMLInputElement).value;
|
||||||
const reason = (document.getElementById("override-reason") as HTMLInputElement).value;
|
const reason = (document.getElementById("override-reason") as HTMLInputElement).value;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/admin/schedule/overrides", {
|
const resp = await fetch(`${API_URL}/api/admin/schedule/overrides`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -473,13 +552,48 @@ async function submitOverride(e: Event) {
|
|||||||
|
|
||||||
async function deleteOverride(id: number) {
|
async function deleteOverride(id: number) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" });
|
await fetch(`${API_URL}/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" });
|
||||||
refreshOverrides();
|
refreshOverrides();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderServiceRow(service: any) {
|
||||||
|
const svcDetail = service.detail || "";
|
||||||
|
|
||||||
|
// playout service returns json detail, all others return `local process` or `ping ok`
|
||||||
|
if (service.name === "playout" && svcDetail) {
|
||||||
|
try {
|
||||||
|
// create a list of key-value pairs for each property in the JSON object
|
||||||
|
const detailObj = JSON.parse(svcDetail);
|
||||||
|
// create a table of key-value pairs for each property in the JSON object
|
||||||
|
const detailTable = Object.entries(detailObj)
|
||||||
|
.map(([key, value]) => `<tr><td><strong>${key}</strong></td><td>${value}</td></tr>`)
|
||||||
|
.join("");
|
||||||
|
service.detail = `<p><table class="service-detail-table">${detailTable}</table></p>`;
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parse errors
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
service.detail = `<p>${service.detail}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = document.createElement("article");
|
||||||
|
row.className = "service-row";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<h3>${service.name}</h3>
|
||||||
|
<p class="small-muted">${service.checked}</p>
|
||||||
|
</div>
|
||||||
|
<div class="service-row-right">
|
||||||
|
<p class="service-status service-status-${service.status}">${service.status}</p>
|
||||||
|
${service.detail}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshSystemSummary() {
|
async function refreshSystemSummary() {
|
||||||
setStatus("system-status", "Loading system summary...");
|
setStatus("system-status", "Loading system summary...");
|
||||||
try {
|
try {
|
||||||
@@ -499,19 +613,9 @@ async function refreshSystemSummary() {
|
|||||||
servicesEl.innerHTML = "";
|
servicesEl.innerHTML = "";
|
||||||
const services = (payload.services || []) as ServiceHealth[];
|
const services = (payload.services || []) as ServiceHealth[];
|
||||||
services.forEach((service) => {
|
services.forEach((service) => {
|
||||||
const row = document.createElement("article");
|
renderServiceRow(service).then((row) => {
|
||||||
row.className = "service-row";
|
servicesEl.appendChild(row);
|
||||||
row.innerHTML = `
|
});
|
||||||
<div>
|
|
||||||
<h3>${service.name}</h3>
|
|
||||||
<p class="small-muted">${service.checked}</p>
|
|
||||||
</div>
|
|
||||||
<div class="service-row-right">
|
|
||||||
<p class="service-status service-status-${service.status}">${service.status}</p>
|
|
||||||
<p>${service.detail}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
servicesEl.appendChild(row);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,19 +638,29 @@ function renderMediaRows(files: MediaEntry[]) {
|
|||||||
|
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
|
const canNormalize = file.status === "needs_normalization" || file.status === "normalization_failed";
|
||||||
|
const canAdd = file.status === "ready";
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${file.name}</td>
|
<td>${file.name}</td>
|
||||||
<td>${file.title || "-"}</td>
|
<td>${file.title || "-"}</td>
|
||||||
<td>${file.runtime || "00:00:00"}</td>
|
<td>${file.runtime || "00:00:00"}</td>
|
||||||
<td>${file.status || "unknown"}</td>
|
<td>${statusBadge(file.status)}</td>
|
||||||
<td>${fmtSize(file.size)}</td>
|
<td>${fmtSize(file.size)}</td>
|
||||||
<td>${file.modifiedAt || "-"}</td>
|
<td>${file.modifiedAt || "-"}</td>
|
||||||
<td class="action-cell">
|
<td class="action-cell">
|
||||||
<button type="button" class="btn btn-secondary btn-small" data-action="edit" data-id="${file.id || ""}">Edit</button>
|
<button type="button" class="btn btn-secondary btn-small" data-action="edit" data-id="${file.id || ""}">Edit</button>
|
||||||
|
${canNormalize ? `<button type="button" class="btn btn-secondary btn-small" data-action="normalize" data-id="${file.id || ""}">Normalize</button>` : ""}
|
||||||
|
${canAdd ? `<button type="button" class="btn btn-secondary btn-small" data-action="add" data-id="${file.id || ""}">Add to playlist</button>` : ""}
|
||||||
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
|
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () => editMedia(file));
|
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () => editMedia(file));
|
||||||
|
if (canNormalize) {
|
||||||
|
tr.querySelector('[data-action="normalize"]')?.addEventListener("click", () => normalizeMedia(file));
|
||||||
|
}
|
||||||
|
if (canAdd) {
|
||||||
|
tr.querySelector('[data-action="add"]')?.addEventListener("click", () => addMediaToPlaylist(file));
|
||||||
|
}
|
||||||
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file));
|
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file));
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
@@ -640,9 +754,12 @@ async function uploadVideo(file: File) {
|
|||||||
setStatus("media-status", `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`);
|
setStatus("media-status", `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const completeResponse = await fetch(`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`, {
|
const completeResponse = await fetch(
|
||||||
method: "POST",
|
`${API_URL}/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`,
|
||||||
});
|
{
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!completeResponse.ok) {
|
if (!completeResponse.ok) {
|
||||||
throw new Error(await readErrorMessage(completeResponse));
|
throw new Error(await readErrorMessage(completeResponse));
|
||||||
@@ -682,7 +799,7 @@ async function editMedia(file: MediaEntry) {
|
|||||||
|
|
||||||
setStatus("media-status", `Saving metadata for ${file.name}...`);
|
setStatus("media-status", `Saving metadata for ${file.name}...`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/media/${file.id}`, {
|
const response = await fetch(`${API_URL}/api/admin/media/${file.id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ title, status, tags }),
|
body: JSON.stringify({ title, status, tags }),
|
||||||
@@ -699,6 +816,55 @@ async function editMedia(file: MediaEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function normalizeMedia(file: MediaEntry) {
|
||||||
|
if (!file.id) {
|
||||||
|
setStatus("media-status", "Cannot normalize media item without id.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("media-status", `Normalizing ${file.name}...`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/api/admin/media/${file.id}/normalize`, { method: "POST" });
|
||||||
|
if (!response.ok) throw new Error(await readErrorMessage(response));
|
||||||
|
|
||||||
|
setStatus("media-status", `Normalization started for ${file.name}.`);
|
||||||
|
await refreshMediaLibrary();
|
||||||
|
pollNormalizationStatus(file.id, 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to normalize media", error);
|
||||||
|
const details = error instanceof Error ? error.message : "Unknown error.";
|
||||||
|
setStatus("media-status", `Failed to normalize ${file.name}. (${details})`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollNormalizationStatus(id: number, attempt: number) {
|
||||||
|
if (attempt >= 60) {
|
||||||
|
setStatus("media-status", "Normalization is taking longer than expected. Reload the library to check.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/api/admin/media/${id}`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const file = (await response.json()) as MediaEntry;
|
||||||
|
if (file.status === "normalizing" || file.status === "needs_normalization") {
|
||||||
|
pollNormalizationStatus(id, attempt + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refreshMediaLibrary();
|
||||||
|
await refreshOverview();
|
||||||
|
await refreshSchedule();
|
||||||
|
setStatus(
|
||||||
|
"media-status",
|
||||||
|
file.status === "ready" ? `Normalization complete for ${file.name}.` : `Normalization failed for ${file.name}.`,
|
||||||
|
file.status !== "ready",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Stop polling on error; the user can reload the library manually.
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteMedia(file: MediaEntry) {
|
async function deleteMedia(file: MediaEntry) {
|
||||||
if (!file.id) {
|
if (!file.id) {
|
||||||
setStatus("media-status", "Cannot delete media item without id.", true);
|
setStatus("media-status", "Cannot delete media item without id.", true);
|
||||||
@@ -711,7 +877,7 @@ async function deleteMedia(file: MediaEntry) {
|
|||||||
|
|
||||||
setStatus("media-status", `Deleting ${file.name}...`);
|
setStatus("media-status", `Deleting ${file.name}...`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/media/${file.id}`, {
|
const response = await fetch(`${API_URL}/api/admin/media/${file.id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -729,7 +895,7 @@ async function deleteMedia(file: MediaEntry) {
|
|||||||
async function refreshModerationLogs() {
|
async function refreshModerationLogs() {
|
||||||
setStatus("moderation-status", "Loading moderation logs...");
|
setStatus("moderation-status", "Loading moderation logs...");
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`);
|
const response = await fetch(`${API_URL}/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`);
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
@@ -830,7 +996,7 @@ async function refreshChatLog() {
|
|||||||
if (chatLogState.username) params.set("username", chatLogState.username);
|
if (chatLogState.username) params.set("username", chatLogState.username);
|
||||||
if (chatLogState.text) params.set("text", chatLogState.text);
|
if (chatLogState.text) params.set("text", chatLogState.text);
|
||||||
|
|
||||||
const response = await fetch(`/api/admin/chat-log?${params}`);
|
const response = await fetch(`${API_URL}/api/admin/chat-log?${params}`);
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
@@ -960,7 +1126,7 @@ async function deleteChatEntry(id: number) {
|
|||||||
|
|
||||||
setStatus("chat-log-status", `Deleting message #${id}...`);
|
setStatus("chat-log-status", `Deleting message #${id}...`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/chat-log/${id}`, {
|
const response = await fetch(`${API_URL}/api/admin/chat-log/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -1068,6 +1234,10 @@ async function loadConfig() {
|
|||||||
const cfg = await resp.json();
|
const cfg = await resp.json();
|
||||||
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
|
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
|
||||||
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
|
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
|
||||||
|
(document.getElementById("config-hls-duration") as HTMLInputElement).value = cfg.hlsSegmentDuration;
|
||||||
|
(document.getElementById("config-hls-window") as HTMLInputElement).value = cfg.hlsWindowSize;
|
||||||
|
(document.getElementById("config-matomo-url") as HTMLInputElement).value = cfg.matomoUrl || "";
|
||||||
|
(document.getElementById("config-matomo-site-id") as HTMLInputElement).value = cfg.matomoSiteId || "";
|
||||||
} catch {
|
} catch {
|
||||||
/* non-critical */
|
/* non-critical */
|
||||||
}
|
}
|
||||||
@@ -1082,11 +1252,13 @@ async function saveConfig(e: Event) {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
rateLimitMax: Number((document.getElementById("config-rate-max") as HTMLInputElement).value),
|
hlsSegmentDuration: Number((document.getElementById("config-hls-duration") as HTMLInputElement).value),
|
||||||
rateLimitWindowSec: Number((document.getElementById("config-rate-window") as HTMLInputElement).value),
|
hlsWindowSize: Number((document.getElementById("config-hls-window") as HTMLInputElement).value),
|
||||||
|
matomoUrl: (document.getElementById("config-matomo-url") as HTMLInputElement).value.trim(),
|
||||||
|
matomoSiteId: (document.getElementById("config-matomo-site-id") as HTMLInputElement).value.trim(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(await readErrorMessage(resp));
|
||||||
if (status) {
|
if (status) {
|
||||||
status.textContent = "Config saved.";
|
status.textContent = "Config saved.";
|
||||||
status.className = "status-line";
|
status.className = "status-line";
|
||||||
@@ -1104,7 +1276,7 @@ async function restartService(service: string) {
|
|||||||
const status = document.getElementById("restart-status");
|
const status = document.getElementById("restart-status");
|
||||||
if (status) status.textContent = `Requesting restart of ${service}...`;
|
if (status) status.textContent = `Requesting restart of ${service}...`;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/admin/restart?service=${service}`, { method: "POST" });
|
const resp = await fetch(`${API_URL}/api/admin/restart?service=${service}`, { method: "POST" });
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (status) status.textContent = data.message;
|
if (status) status.textContent = data.message;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+149
-1
@@ -1,6 +1,12 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { resetViewerStateForTests, resolveManifestUrl, resolveWebSocketBaseUrl } from "../src/main";
|
import {
|
||||||
|
resetViewerStateForTests,
|
||||||
|
resolveManifestUrl,
|
||||||
|
resolveWebSocketBaseUrl,
|
||||||
|
showReconnecting,
|
||||||
|
hideReconnecting,
|
||||||
|
} from "../src/main";
|
||||||
|
|
||||||
const hlsInstance = {
|
const hlsInstance = {
|
||||||
loadSource: vi.fn(),
|
loadSource: vi.fn(),
|
||||||
@@ -99,6 +105,7 @@ beforeEach(() => {
|
|||||||
<span id="room-name">Main Channel</span>
|
<span id="room-name">Main Channel</span>
|
||||||
<button type="button" onclick="togglePrivateDM()">New DM</button>
|
<button type="button" onclick="togglePrivateDM()">New DM</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
|
||||||
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
||||||
<section id="presence-panel" class="presence-panel">
|
<section id="presence-panel" class="presence-panel">
|
||||||
<div id="presence-list" class="presence-list"></div>
|
<div id="presence-list" class="presence-list"></div>
|
||||||
@@ -114,6 +121,21 @@ beforeEach(() => {
|
|||||||
<div id="reconnecting-overlay" role="alert" aria-live="assertive" hidden>
|
<div id="reconnecting-overlay" role="alert" aria-live="assertive" hidden>
|
||||||
<span id="reconnecting-text">Reconnecting...</span>
|
<span id="reconnecting-text">Reconnecting...</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="chat-user-label" hidden></div>
|
||||||
|
<button id="auth-toggle-btn" type="button">Sign in</button>
|
||||||
|
<button id="auth-logout-btn" type="button" hidden>Logout</button>
|
||||||
|
<div id="auth-panel" class="auth-modal" hidden>
|
||||||
|
<div id="auth-backdrop" class="auth-modal-backdrop"></div>
|
||||||
|
<div class="auth-modal-content">
|
||||||
|
<button id="auth-close-btn" type="button">×</button>
|
||||||
|
<input id="auth-username" type="text" />
|
||||||
|
<input id="auth-password" type="password" />
|
||||||
|
<span id="auth-status"></span>
|
||||||
|
<button id="auth-login-btn" type="button">Login</button>
|
||||||
|
<button id="auth-register-btn" type="button">Register</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="typing-indicator" hidden></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Stub prompt for DM
|
// Stub prompt for DM
|
||||||
@@ -230,6 +252,25 @@ describe("WebSocket reconnect", () => {
|
|||||||
expect(websocketInstances).toHaveLength(2);
|
expect(websocketInstances).toHaveLength(2);
|
||||||
expect(websocketInstances[1].url).toContain("room=lobby");
|
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", () => {
|
describe("URL resolution", () => {
|
||||||
@@ -359,6 +400,93 @@ describe("message rendering", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("player error recovery", () => {
|
||||||
it("shows reconnecting overlay on fatal network error", async () => {
|
it("shows reconnecting overlay on fatal network error", async () => {
|
||||||
const main = await import("../src/main");
|
const main = await import("../src/main");
|
||||||
@@ -448,3 +576,23 @@ describe("responsive layout", () => {
|
|||||||
expect(np?.getAttribute("aria-live")).toBe("polite");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+224
-11
@@ -15,7 +15,13 @@ let hls: InstanceType<HlsClass> | null = null;
|
|||||||
let ws: WebSocket | null = null;
|
let ws: WebSocket | null = null;
|
||||||
let currentRoom = "main";
|
let currentRoom = "main";
|
||||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||||
|
let authenticated = false;
|
||||||
let chatVisible = true;
|
let chatVisible = true;
|
||||||
|
let typingUser = "";
|
||||||
|
let lastTypingSent = 0;
|
||||||
|
let typingTimeout: number | undefined;
|
||||||
|
let flashTimeout: number | undefined;
|
||||||
|
let flashFadeTimeout: number | undefined;
|
||||||
const onlineUsers = new Set<string>();
|
const onlineUsers = new Set<string>();
|
||||||
|
|
||||||
// Player error recovery state (STREAMING.md contract)
|
// Player error recovery state (STREAMING.md contract)
|
||||||
@@ -28,7 +34,10 @@ export function resetViewerStateForTests() {
|
|||||||
ws = null;
|
ws = null;
|
||||||
currentRoom = "main";
|
currentRoom = "main";
|
||||||
username = "viewer";
|
username = "viewer";
|
||||||
|
authenticated = false;
|
||||||
chatVisible = true;
|
chatVisible = true;
|
||||||
|
typingUser = "";
|
||||||
|
lastTypingSent = 0;
|
||||||
onlineUsers.clear();
|
onlineUsers.clear();
|
||||||
playerErrorCount = 0;
|
playerErrorCount = 0;
|
||||||
}
|
}
|
||||||
@@ -67,44 +76,76 @@ export function resolveManifestUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connectWebSocket(room: string) {
|
function connectWebSocket(room: string) {
|
||||||
if (ws) ws.close();
|
// 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());
|
const socketUrl = new URL(resolveWebSocketBaseUrl());
|
||||||
socketUrl.searchParams.set("username", username);
|
socketUrl.searchParams.set("username", username);
|
||||||
socketUrl.searchParams.set("room", room);
|
socketUrl.searchParams.set("room", room);
|
||||||
ws = new WebSocket(socketUrl);
|
const socket = new WebSocket(socketUrl);
|
||||||
|
ws = socket;
|
||||||
|
|
||||||
ws.onopen = () => {
|
socket.onopen = () => {
|
||||||
console.log(`Connected to chat room: ${room}`);
|
console.log(`Connected to chat room: ${room}`);
|
||||||
addMessage("system", "Connected to " + room);
|
addMessage("system", "Connected to " + room);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
if (msg.type === "typing") {
|
||||||
|
showTyping(msg.username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideTyping(msg.username);
|
||||||
|
|
||||||
// Track presence from system join/leave messages
|
// Presence join/leave announcements become transient flashes, not chat rows.
|
||||||
if (msg.type === "system" && msg.username === "system") {
|
if (msg.type === "system" && msg.username === "system") {
|
||||||
const joined = msg.text?.match(/^(\S+) joined /);
|
const joined = msg.text?.match(/^(\S+) joined /);
|
||||||
const left = msg.text?.match(/^(\S+) left /);
|
const left = msg.text?.match(/^(\S+) left /);
|
||||||
if (joined) {
|
if (joined) {
|
||||||
onlineUsers.add(joined[1]);
|
onlineUsers.add(joined[1]);
|
||||||
renderPresence();
|
renderPresence();
|
||||||
|
showFlash(`${joined[1]} joined`);
|
||||||
|
return;
|
||||||
} else if (left) {
|
} else if (left) {
|
||||||
onlineUsers.delete(left[1]);
|
onlineUsers.delete(left[1]);
|
||||||
renderPresence();
|
renderPresence();
|
||||||
|
showFlash(`${left[1]} left`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
socket.onclose = () => {
|
||||||
|
// Ignore close events from sockets that were intentionally replaced.
|
||||||
|
if (ws !== socket) return;
|
||||||
|
ws = null;
|
||||||
console.log("Chat disconnected. Reconnecting in 3s...");
|
console.log("Chat disconnected. Reconnecting in 3s...");
|
||||||
setTimeout(() => connectWebSocket(room), 3000);
|
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()) {
|
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
|
||||||
const messages = document.getElementById("messages")!;
|
const messages = document.getElementById("messages");
|
||||||
|
if (!messages) return;
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "message-row";
|
div.className = "message-row";
|
||||||
|
|
||||||
@@ -126,6 +167,23 @@ function addMessage(type: string, text: string, sender = "", timestamp = new Dat
|
|||||||
messages.scrollTop = messages.scrollHeight;
|
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() {
|
function renderPresence() {
|
||||||
const list = document.getElementById("presence-list");
|
const list = document.getElementById("presence-list");
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
@@ -137,7 +195,8 @@ function renderPresence() {
|
|||||||
|
|
||||||
async function refreshSchedule() {
|
async function refreshSchedule() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/schedule");
|
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||||
|
const res = await fetch(`${apiUrl}/api/schedule`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const entries = data.entries as Array<{ title: string; status: string }>;
|
const entries = data.entries as Array<{ title: string; status: string }>;
|
||||||
@@ -183,7 +242,9 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
|
|||||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
if (playerErrorCount <= PLAYER_MAX_RETRIES) {
|
if (playerErrorCount <= PLAYER_MAX_RETRIES) {
|
||||||
const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1);
|
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...`);
|
console.warn(
|
||||||
|
`HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`,
|
||||||
|
);
|
||||||
showReconnecting("Reconnecting...");
|
showReconnecting("Reconnecting...");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
hls?.startLoad();
|
hls?.startLoad();
|
||||||
@@ -226,6 +287,9 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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");
|
connectWebSocket("main");
|
||||||
|
|
||||||
// Start schedule polling
|
// Start schedule polling
|
||||||
@@ -284,7 +348,6 @@ function switchRoom(room: string) {
|
|||||||
currentRoom = room;
|
currentRoom = room;
|
||||||
document.getElementById("room-name")!.textContent =
|
document.getElementById("room-name")!.textContent =
|
||||||
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
||||||
addMessage("system", `Switched to ${room} room`);
|
|
||||||
connectWebSocket(room);
|
connectWebSocket(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +364,156 @@ window.sendMessage = sendMessage;
|
|||||||
window.togglePrivateDM = togglePrivateDM;
|
window.togglePrivateDM = togglePrivateDM;
|
||||||
window.toggleChatPanel = toggleChatPanel;
|
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<void> = 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", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
void initPlayerAndChat();
|
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);
|
||||||
});
|
});
|
||||||
+155
-6
@@ -1,7 +1,8 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
--font-body: "Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
|
--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-display: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ h1,
|
|||||||
h2,
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-heading);
|
||||||
color: var(--ink-0);
|
color: var(--ink-0);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
@@ -253,6 +254,135 @@ select {
|
|||||||
line-height: 1.5;
|
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 {
|
.btn {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-1);
|
border-radius: var(--radius-1);
|
||||||
@@ -350,6 +480,10 @@ select {
|
|||||||
color: var(--ink-1);
|
color: var(--ink-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-log-filters input {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -376,6 +510,11 @@ select {
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#chat-log-table-body td {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--ink-0);
|
||||||
|
}
|
||||||
|
|
||||||
.action-cell {
|
.action-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -466,7 +605,7 @@ select {
|
|||||||
|
|
||||||
.brand-text,
|
.brand-text,
|
||||||
.viewer-title {
|
.viewer-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-title {
|
.viewer-title {
|
||||||
@@ -567,6 +706,7 @@ select {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header,
|
.chat-header,
|
||||||
@@ -661,12 +801,12 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-system {
|
.message-system {
|
||||||
font-size: 0.83rem;
|
font-size: 0.7rem;
|
||||||
color: var(--ink-1);
|
color: var(--ink-1);
|
||||||
background: color-mix(in srgb, var(--brand) 22%, var(--panel-2));
|
background: color-mix(in srgb, var(--brand) 22%, var(--panel-2));
|
||||||
border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line));
|
border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line));
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0.24rem 0.68rem;
|
padding: 0.2rem 0.5rem;
|
||||||
animation: messageIn var(--fast);
|
animation: messageIn var(--fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,6 +818,7 @@ select {
|
|||||||
|
|
||||||
.message-text {
|
.message-text {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-time {
|
.message-time {
|
||||||
@@ -903,6 +1044,12 @@ select {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.service-detail-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.service-status {
|
.service-status {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -1085,7 +1232,9 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes lv-spin {
|
@keyframes lv-spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#reconnecting-text {
|
#reconnecting-text {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
name = "linearvoid-frontend"
|
||||||
|
compatibility_date = "2025-08-01"
|
||||||
|
|
||||||
|
[assets]
|
||||||
|
directory = "./dist"
|
||||||
|
not_found_handling = "single-page-application"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.wrangler/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# linearvoid-media Worker
|
||||||
|
|
||||||
|
Cloudflare Worker that owns **`media.linearvoid.tv`** and serves the HLS objects
|
||||||
|
stored in the `linearvoid-media` R2 bucket, applying the edge cache policy in code:
|
||||||
|
|
||||||
|
| Object | Cache-Control |
|
||||||
|
| ------------------------------------------- | ------------------------------------------------ |
|
||||||
|
| `.m4s` / `.ts` / `.mp4` / `.m4a` (segments) | `public, max-age=31536000, immutable` |
|
||||||
|
| `.m3u8` (live manifest) | `public, max-age=1, s-maxage=2, must-revalidate` |
|
||||||
|
|
||||||
|
It also adds HLS CORS (`Access-Control-Allow-Origin: *`), OPTIONS preflight, a
|
||||||
|
`/health` probe, and Range-request support. This worker replaces direct R2
|
||||||
|
custom-domain serving on the media host so cache headers/CORS are controlled in
|
||||||
|
code (cache rules on the same host remain the origin baseline).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
infrastructure/cloudflare/
|
||||||
|
├── wrangler.toml # name/binding + production custom-domain route
|
||||||
|
├── package.json
|
||||||
|
├── src/worker.js # worker module (exports default.fetch + pure helpers)
|
||||||
|
└── test/ # Vitest unit tests + Miniflare (workerd) R2 tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local dev / test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infrastructure/cloudflare
|
||||||
|
npm install
|
||||||
|
npm test # unit + Miniflare R2 integration tests
|
||||||
|
npx wrangler dev # run locally against your account's R2 bucket
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
**Preview / test target** (default environment → `*.workers.dev`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infrastructure/cloudflare
|
||||||
|
npx wrangler deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production** (attaches `media.linearvoid.tv` as a custom domain on this worker):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infrastructure/cloudflare
|
||||||
|
npx wrangler deploy --env production
|
||||||
|
```
|
||||||
|
|
||||||
|
Both need `CLOUDFLARE_API_TOKEN` (Workers Scripts/Routes edit) and
|
||||||
|
`CLOUDFLARE_ACCOUNT_ID` in the environment. The media host must first be removed
|
||||||
|
from the R2 bucket's direct custom-domain binding, otherwise the route collides.
|
||||||
|
|
||||||
|
Automated production deploys run via
|
||||||
|
[`.gitea/workflows/workers-media-deploy.yml`](../../.gitea/workflows/workers-media-deploy.yml)
|
||||||
|
after CI succeeds on `main`.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://media.linearvoid.tv/hls/playlist.m3u8 # short TTL
|
||||||
|
curl -I https://media.linearvoid.tv/hls/<segment>.m4s # immutable + CORS
|
||||||
|
curl https://media.linearvoid.tv/health # {"status":"ok",...}
|
||||||
|
```
|
||||||
+2892
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "linearvoid-media-worker",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Linear Void media edge worker: serves HLS from R2 on media.linearvoid.tv with immutable segment caching.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"start": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"deploy:production": "wrangler deploy --env production",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"miniflare": "^4.0.0",
|
||||||
|
"vitest": "^4.1.10",
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Linear Void media edge worker (linearvoid-media).
|
||||||
|
//
|
||||||
|
// Owns the media.linearvoid.tv host and serves HLS objects from the R2 bucket
|
||||||
|
// bound as MEDIA_BUCKET. Its purpose (arc42 quality goal #1, cost efficiency) is
|
||||||
|
// to guarantee immutable, long-lived caching of media segments at the edge while
|
||||||
|
// keeping live manifests fresh — the caching baseline is also configured as
|
||||||
|
// Cloudflare cache rules on the same host, but this worker enforces it in code.
|
||||||
|
//
|
||||||
|
// Cache policy (matches docs/infrastructure/cloudflare/cache-rules.md + STREAMING.md):
|
||||||
|
// .m4s / .ts / .mp4 / .m4a -> public, max-age=31536000, immutable
|
||||||
|
// .m3u8 (live manifest) -> public, max-age=1, s-maxage=2, must-revalidate
|
||||||
|
|
||||||
|
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
||||||
|
const MANIFEST_CACHE = "public, max-age=1, s-maxage=2, must-revalidate";
|
||||||
|
const DEFAULT_CACHE = "public, max-age=60";
|
||||||
|
|
||||||
|
const IMMUTABLE_SUFFIXES = [".m4s", ".ts", ".mp4", ".m4a"];
|
||||||
|
const MANIFEST_SUFFIXES = [".m3u8"];
|
||||||
|
|
||||||
|
const CONTENT_TYPES = {
|
||||||
|
".m3u8": "application/vnd.apple.mpegurl",
|
||||||
|
".m4s": "video/mp4",
|
||||||
|
".mp4": "video/mp4",
|
||||||
|
".ts": "video/MP2T",
|
||||||
|
".m4a": "audio/mp4",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Map a request path to its edge Cache-Control value. Pure + testable. */
|
||||||
|
export function cacheControlForPath(pathname) {
|
||||||
|
const lower = pathname.toLowerCase();
|
||||||
|
for (const suffix of MANIFEST_SUFFIXES) {
|
||||||
|
if (lower.endsWith(suffix)) return MANIFEST_CACHE;
|
||||||
|
}
|
||||||
|
for (const suffix of IMMUTABLE_SUFFIXES) {
|
||||||
|
if (lower.endsWith(suffix)) return IMMUTABLE_CACHE;
|
||||||
|
}
|
||||||
|
return DEFAULT_CACHE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a request path to a browser-friendly Content-Type. Pure + testable. */
|
||||||
|
export function contentTypeForPath(pathname) {
|
||||||
|
const lower = pathname.toLowerCase();
|
||||||
|
for (const [suffix, type] of Object.entries(CONTENT_TYPES)) {
|
||||||
|
if (lower.endsWith(suffix)) return type;
|
||||||
|
}
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORS_HEADERS = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Range, If-None-Match, If-Modified-Since",
|
||||||
|
"Access-Control-Max-Age": "86400",
|
||||||
|
};
|
||||||
|
|
||||||
|
function json(body, status = 200) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a single, closed byte range ("bytes=start-end") into an R2 range object.
|
||||||
|
// Returns null for open/suffix/multi/invalid ranges — the caller then serves the
|
||||||
|
// full object (HTTP 200), which is valid per RFC 7233 (a server may ignore Range).
|
||||||
|
export function parseByteRange(header) {
|
||||||
|
if (!header) return null;
|
||||||
|
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const start = m[1];
|
||||||
|
const end = m[2];
|
||||||
|
if (start === "" || end === "") return null; // open or suffix range
|
||||||
|
const offset = Number(start);
|
||||||
|
const length = Number(end) - offset + 1;
|
||||||
|
if (!Number.isInteger(offset) || !Number.isInteger(length) || offset < 0 || length < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { offset, length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// CORS preflight for HLS media.
|
||||||
|
if (request.method === "OPTIONS") {
|
||||||
|
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liveness probe used by uptime/monitoring (see cloudflare-uptimerobot-checklist).
|
||||||
|
if (url.pathname === "/health") {
|
||||||
|
return json({ status: "ok", service: "linearvoid-media" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||||
|
return json({ error: "method not allowed" }, 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = decodeURIComponent(url.pathname).replace(/^\/+/, "");
|
||||||
|
if (!key) {
|
||||||
|
return json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.MEDIA_BUCKET) {
|
||||||
|
return json({ error: "media bucket not configured" }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve from R2, honouring single closed Range requests (used by some hls.js
|
||||||
|
// / player paths for byte-range fetches). Unsupported ranges fall back to a
|
||||||
|
// full-body 200, which clients accept.
|
||||||
|
const rangeHeader = request.headers.get("range");
|
||||||
|
const parsedRange = parseByteRange(rangeHeader);
|
||||||
|
const object = parsedRange
|
||||||
|
? await env.MEDIA_BUCKET.get(key, { range: parsedRange })
|
||||||
|
: await env.MEDIA_BUCKET.get(key);
|
||||||
|
|
||||||
|
if (object === null) {
|
||||||
|
return json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
object.writeHttpMetadata(headers);
|
||||||
|
headers.set("Cache-Control", cacheControlForPath(url.pathname));
|
||||||
|
headers.set("Content-Type", contentTypeForPath(url.pathname));
|
||||||
|
headers.set("ETag", object.httpEtag);
|
||||||
|
headers.set("Accept-Ranges", "bytes");
|
||||||
|
headers.set("Access-Control-Allow-Origin", "*");
|
||||||
|
|
||||||
|
let status = 200;
|
||||||
|
if (parsedRange && object.range) {
|
||||||
|
status = 206;
|
||||||
|
const { offset, length } = object.range;
|
||||||
|
headers.set(
|
||||||
|
"Content-Range",
|
||||||
|
`bytes ${offset}-${offset + length - 1}/${object.size}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(request.method === "HEAD" ? null : object.body, {
|
||||||
|
status,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Miniflare } from "miniflare";
|
||||||
|
import { beforeAll, afterAll, describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
const IMMUTABLE = "public, max-age=31536000, immutable";
|
||||||
|
const MANIFEST = "public, max-age=1, s-maxage=2, must-revalidate";
|
||||||
|
|
||||||
|
// Real-workerd integration test: boots the worker with an in-memory R2 bucket and
|
||||||
|
// dispatches requests through the fetch handler.
|
||||||
|
let mf;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mf = new Miniflare({
|
||||||
|
modules: true,
|
||||||
|
scriptPath: "src/worker.js",
|
||||||
|
r2Buckets: { MEDIA_BUCKET: "linearvoid-media" },
|
||||||
|
});
|
||||||
|
const bucket = await mf.getR2Bucket("MEDIA_BUCKET");
|
||||||
|
await bucket.put("hls/playlist.m3u8", "#EXTM3U\n#EXT-X-ENDLIST\n", {
|
||||||
|
httpMetadata: { contentType: "application/vnd.apple.mpegurl" },
|
||||||
|
});
|
||||||
|
await bucket.put("hls/seg-000001.m4s", new Uint8Array([0, 1, 2, 3, 4]));
|
||||||
|
await bucket.put("hls/init.mp4", new Uint8Array([9, 9, 9]));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mf?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = "http://media.linearvoid.tv";
|
||||||
|
|
||||||
|
describe("linearvoid-media worker (R2)", () => {
|
||||||
|
it("serves a media segment with immutable cache headers", async () => {
|
||||||
|
const res = await mf.dispatchFetch(`${base}/hls/seg-000001.m4s`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("cache-control")).toBe(IMMUTABLE);
|
||||||
|
expect(res.headers.get("content-type")).toBe("video/mp4");
|
||||||
|
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
||||||
|
expect(await res.arrayBuffer()).toEqual(new Uint8Array([0, 1, 2, 3, 4]).buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves a live manifest with a short TTL", async () => {
|
||||||
|
const res = await mf.dispatchFetch(`${base}/hls/playlist.m3u8`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("cache-control")).toBe(MANIFEST);
|
||||||
|
expect(res.headers.get("content-type")).toBe("application/vnd.apple.mpegurl");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours Range requests with 206 + Content-Range", async () => {
|
||||||
|
const res = await mf.dispatchFetch(`${base}/hls/seg-000001.m4s`, {
|
||||||
|
headers: { Range: "bytes=0-2" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(206);
|
||||||
|
expect(res.headers.get("content-range")).toBe("bytes 0-2/5");
|
||||||
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||||
|
expect([...bytes]).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 for a missing object", async () => {
|
||||||
|
const res = await mf.dispatchFetch(`${base}/hls/does-not-exist.m4s`);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers OPTIONS preflight with CORS headers", async () => {
|
||||||
|
const res = await mf.dispatchFetch(`${base}/hls/seg-000001.m4s`, {
|
||||||
|
method: "OPTIONS",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-GET/HEAD methods", async () => {
|
||||||
|
const res = await mf.dispatchFetch(`${base}/hls/seg-000001.m4s`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(405);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { cacheControlForPath, contentTypeForPath, parseByteRange } from "../src/worker.js";
|
||||||
|
|
||||||
|
const IMMUTABLE = "public, max-age=31536000, immutable";
|
||||||
|
const MANIFEST = "public, max-age=1, s-maxage=2, must-revalidate";
|
||||||
|
const DEFAULT = "public, max-age=60";
|
||||||
|
|
||||||
|
describe("cacheControlForPath", () => {
|
||||||
|
it("marks media segments as immutable", () => {
|
||||||
|
expect(cacheControlForPath("/hls/seg-000001.m4s")).toBe(IMMUTABLE);
|
||||||
|
expect(cacheControlForPath("/hls/init.mp4")).toBe(IMMUTABLE);
|
||||||
|
expect(cacheControlForPath("/hls/seg-000001.ts")).toBe(IMMUTABLE);
|
||||||
|
expect(cacheControlForPath("/media/audio.m4a")).toBe(IMMUTABLE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps live manifests short-lived", () => {
|
||||||
|
expect(cacheControlForPath("/hls/playlist.m3u8")).toBe(MANIFEST);
|
||||||
|
expect(cacheControlForPath("/hls/master.m3u8")).toBe(MANIFEST);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a sane default for other paths", () => {
|
||||||
|
expect(cacheControlForPath("/hls/unknown.txt")).toBe(DEFAULT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("contentTypeForPath", () => {
|
||||||
|
it("maps known media types", () => {
|
||||||
|
expect(contentTypeForPath("/hls/playlist.m3u8")).toBe("application/vnd.apple.mpegurl");
|
||||||
|
expect(contentTypeForPath("/hls/seg-000001.m4s")).toBe("video/mp4");
|
||||||
|
expect(contentTypeForPath("/hls/init.mp4")).toBe("video/mp4");
|
||||||
|
expect(contentTypeForPath("/hls/seg-000001.ts")).toBe("video/MP2T");
|
||||||
|
expect(contentTypeForPath("/media/audio.m4a")).toBe("audio/mp4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to octet-stream", () => {
|
||||||
|
expect(contentTypeForPath("/hls/unknown.bin")).toBe("application/octet-stream");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseByteRange", () => {
|
||||||
|
it("parses a closed byte range", () => {
|
||||||
|
expect(parseByteRange("bytes=0-2")).toEqual({ offset: 0, length: 3 });
|
||||||
|
expect(parseByteRange("bytes=10-19")).toEqual({ offset: 10, length: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects open, suffix, multi, or malformed ranges", () => {
|
||||||
|
expect(parseByteRange("bytes=0-")).toBeNull();
|
||||||
|
expect(parseByteRange("bytes=-500")).toBeNull();
|
||||||
|
expect(parseByteRange("bytes=0-1,3-4")).toBeNull();
|
||||||
|
expect(parseByteRange("items=0-2")).toBeNull();
|
||||||
|
expect(parseByteRange("")).toBeNull();
|
||||||
|
expect(parseByteRange(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// Cloudflare Worker for advanced cache rules and WAF (ADR-001)
|
|
||||||
addEventListener("fetch", (event) => {
|
|
||||||
event.respondWith(handleRequest(event.request));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleRequest(request) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
// Add immutable cache headers for .m4s segments
|
|
||||||
if (url.pathname.endsWith(".m4s")) {
|
|
||||||
const response = await fetch(request);
|
|
||||||
const headers = new Headers(response.headers);
|
|
||||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
||||||
return new Response(response.body, {
|
|
||||||
status: response.status,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetch(request);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Cloudflare Worker: linearvoid-media (edge HLS delivery)
|
||||||
|
# Serves HLS objects from the `linearvoid-media` R2 bucket on media.linearvoid.tv.
|
||||||
|
# Applies immutable caching to media segments and a short TTL to live manifests.
|
||||||
|
# This worker replaces direct R2 custom-domain serving on the media host so cache
|
||||||
|
# headers/CORS are controlled in code (cache rules remain as the origin baseline).
|
||||||
|
|
||||||
|
name = "linearvoid-media"
|
||||||
|
main = "src/worker.js"
|
||||||
|
compatibility_date = "2025-08-01"
|
||||||
|
|
||||||
|
# R2 bucket holding the HLS objects. The request path is mirrored onto the bucket,
|
||||||
|
# e.g. GET /hls/playlist.m3u8 -> object key `hls/playlist.m3u8`.
|
||||||
|
[[r2_buckets]]
|
||||||
|
binding = "MEDIA_BUCKET"
|
||||||
|
bucket_name = "linearvoid-media"
|
||||||
|
|
||||||
|
# Default environment: deploy to the preview *.workers.dev URL (test target).
|
||||||
|
# npx wrangler deploy
|
||||||
|
#
|
||||||
|
# Production environment: attach the media host as a custom domain on this worker.
|
||||||
|
# Note: bindings are NOT inherited by environments, so r2_buckets is redeclared here.
|
||||||
|
# npx wrangler deploy --env production
|
||||||
|
[env.production]
|
||||||
|
routes = [
|
||||||
|
{ pattern = "media.linearvoid.tv", custom_domain = true }
|
||||||
|
]
|
||||||
|
|
||||||
|
[[env.production.r2_buckets]]
|
||||||
|
binding = "MEDIA_BUCKET"
|
||||||
|
bucket_name = "linearvoid-media"
|
||||||
|
|
||||||
@@ -8,11 +8,21 @@ http {
|
|||||||
|
|
||||||
resolver 127.0.0.11 ipv6=off valid=30s;
|
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||||
|
|
||||||
|
map_hash_bucket_size 128;
|
||||||
|
|
||||||
map $http_upgrade $connection_upgrade {
|
map $http_upgrade $connection_upgrade {
|
||||||
default upgrade;
|
default upgrade;
|
||||||
'' close;
|
'' 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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name www.linearvoid.tv;
|
server_name www.linearvoid.tv;
|
||||||
@@ -85,6 +95,14 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location = /api/admin/login {
|
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;
|
set $chat_upstream http://chat:3000;
|
||||||
proxy_pass $chat_upstream;
|
proxy_pass $chat_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -93,9 +111,19 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Cookie $http_cookie;
|
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 {
|
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;
|
set $chat_upstream http://chat:3000;
|
||||||
proxy_pass $chat_upstream;
|
proxy_pass $chat_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -104,9 +132,19 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Cookie $http_cookie;
|
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 {
|
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;
|
set $chat_upstream http://chat:3000;
|
||||||
proxy_pass $chat_upstream;
|
proxy_pass $chat_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -115,9 +153,19 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Cookie $http_cookie;
|
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/ {
|
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;
|
auth_request /_admin_auth;
|
||||||
error_page 401 = @admin_api_unauthorized;
|
error_page 401 = @admin_api_unauthorized;
|
||||||
set $chat_upstream http://chat:3000;
|
set $chat_upstream http://chat:3000;
|
||||||
@@ -128,9 +176,19 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Cookie $http_cookie;
|
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/ {
|
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;
|
set $chat_upstream http://chat:3000;
|
||||||
proxy_pass $chat_upstream;
|
proxy_pass $chat_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -138,6 +196,8 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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 {
|
location = /metrics {
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "Moderation Actions",
|
|
||||||
"type": "timeseries",
|
|
||||||
"targets": [{ "expr": "linearvoid_chat_moderation_actions_total" }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Redis Reachable",
|
|
||||||
"type": "stat",
|
|
||||||
"targets": [{ "expr": "linearvoid_chat_redis_up" }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Admin Sessions",
|
|
||||||
"type": "timeseries",
|
|
||||||
"targets": [{ "expr": "linearvoid_chat_admin_sessions_active" }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Chat Scrape Health",
|
|
||||||
"type": "stat",
|
|
||||||
"targets": [{ "expr": "up{job=\"chat\"}" }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Cache Hit Ratio",
|
|
||||||
"type": "timeseries",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"expr": "linearvoid_cache_hit_ratio"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Redis Memory Usage",
|
|
||||||
"type": "timeseries",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"expr": "linearvoid_chat_redis_memory_usage_ratio"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Chat Request Latency p95",
|
|
||||||
"type": "timeseries",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"expr": "histogram_quantile(0.95, sum(rate(linearvoid_chat_request_duration_seconds_bucket[5m])) by (le))"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Generated
+1148
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" "$@"
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# check-env.ps1 — Verify the deployment environment for Linear Void.
|
||||||
|
#
|
||||||
|
# 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.ps1
|
||||||
|
# ./scripts/check-env.ps1 -Target 192.168.88.40
|
||||||
|
# ./scripts/check-env.ps1 -Target 192.168.88.40 -DockerPort 2375 -OnlyContainers
|
||||||
|
#
|
||||||
|
# Exit code is 0 when all required checks pass, 1 otherwise.
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Target = '192.168.88.40',
|
||||||
|
[int]$DockerPort = 2375,
|
||||||
|
[switch]$OnlyContainers,
|
||||||
|
[switch]$OnlyEndpoints,
|
||||||
|
[int]$TimeoutSec = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$target = $Target
|
||||||
|
$dockerHost = "tcp://${target}:${DockerPort}"
|
||||||
|
|
||||||
|
$failed = $false
|
||||||
|
function Fail([string]$msg) { Write-Host " [FAIL] $msg" -ForegroundColor Red; $script:failed = $true }
|
||||||
|
function Ok([string]$msg) { Write-Host " [OK] $msg" -ForegroundColor Green }
|
||||||
|
function Warn([string]$msg) { Write-Host " [WARN] $msg" -ForegroundColor Yellow }
|
||||||
|
|
||||||
|
Write-Host "== Linear Void environment check ==" -ForegroundColor Cyan
|
||||||
|
Write-Host "Target Docker host : $dockerHost"
|
||||||
|
Write-Host ("Date : " + (Get-Date -Format s))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Docker Engine reachability
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if (-not $OnlyEndpoints) {
|
||||||
|
Write-Host "`n-- Docker Engine (${target}:${DockerPort}) --" -ForegroundColor Cyan
|
||||||
|
$env:DOCKER_HOST = $dockerHost
|
||||||
|
try {
|
||||||
|
$ver = docker version --format '{{.Server.Version}}' 2>&1
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $ver -match '\d') {
|
||||||
|
Ok "Docker Engine reachable (server version $ver)"
|
||||||
|
} else {
|
||||||
|
Fail "Docker Engine not reachable: $($ver -join ' ')"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Fail "Docker Engine error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 2. Coolify helper stack
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
Write-Host "`n-- Coolify managed-server helpers --" -ForegroundColor Cyan
|
||||||
|
foreach ($c in @('coolify-proxy', 'coolify-sentinel', 'coolify-cloudflared')) {
|
||||||
|
try {
|
||||||
|
$state = docker inspect $c --format '{{.State.Status}} ({{.State.Health.Status}})' 2>&1
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
if ($state -match 'running.*(healthy|^running \(running\))') { Ok "coolify helper: $c -> $state" }
|
||||||
|
else { Warn "coolify helper: $c -> $state" }
|
||||||
|
} else {
|
||||||
|
Warn "coolify helper: $c -> NOT FOUND (is this a Coolify-managed server?)"
|
||||||
|
}
|
||||||
|
} catch { Warn "coolify helper: $c -> error $($_.Exception.Message)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 3. Linear Void containers
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
Write-Host "`n-- Linear Void containers --" -ForegroundColor Cyan
|
||||||
|
$required = @('linearvoid-playout', 'linearvoid-chat', 'linearvoid-nginx',
|
||||||
|
'linearvoid-redis', 'linearvoid-db', 'linearvoid-prometheus', 'linearvoid-grafana')
|
||||||
|
try {
|
||||||
|
$running = @(docker ps --format '{{.Names}}' 2>&1)
|
||||||
|
$all = @(docker ps -a --format '{{.Names}}\t{{.Status}}' 2>&1)
|
||||||
|
foreach ($name in $required) {
|
||||||
|
if ($running -contains $name) {
|
||||||
|
Ok "container: $name is running"
|
||||||
|
} elseif (($all | Where-Object { $_ -match "^$name\s" })) {
|
||||||
|
$status = ($all | Where-Object { $_ -match "^$name\s" } | Select-Object -First 1)
|
||||||
|
Fail "container: $name EXISTS but not running -> $status"
|
||||||
|
} else {
|
||||||
|
Fail "container: $name MISSING"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { Fail "container listing error: $($_.Exception.Message)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Health endpoints (via Docker host)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if (-not $OnlyContainers) {
|
||||||
|
Write-Host "`n-- Health endpoints --" -ForegroundColor Cyan
|
||||||
|
$checks = @(
|
||||||
|
@{ Name = 'chat /health'; Url = "http://${target}:3000/health" },
|
||||||
|
@{ Name = 'nginx /health'; Url = "http://${target}:8081/health" },
|
||||||
|
@{ Name = 'prometheus'; Url = "http://${target}:9090/-/healthy" },
|
||||||
|
@{ Name = 'grafana /api/health'; Url = "http://${target}:3001/api/health" }
|
||||||
|
)
|
||||||
|
foreach ($c in $checks) {
|
||||||
|
try {
|
||||||
|
$r = Invoke-WebRequest -Uri $c.Url -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
|
||||||
|
if ($r.StatusCode -eq 200) { Ok "$($c.Name) -> 200" } else { Warn "$($c.Name) -> $($r.StatusCode)" }
|
||||||
|
} catch {
|
||||||
|
Fail "$($c.Name) -> $($_.Exception.Response.StatusCode.value__)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 5. Public endpoints (routed through the Docker host for reliable TLS)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
Write-Host "`n-- Public endpoints (via Docker host) --" -ForegroundColor Cyan
|
||||||
|
$pub = @(
|
||||||
|
@{ Name = 'linearvoid.tv app'; Url = 'https://linearvoid.tv/' },
|
||||||
|
@{ Name = 'media HLS manifest'; Url = 'https://media.linearvoid.tv/hls/playlist.m3u8' },
|
||||||
|
@{ Name = 'coolify dashboard'; Url = 'https://coolify.allucanget.biz/' }
|
||||||
|
)
|
||||||
|
foreach ($c in $pub) {
|
||||||
|
try {
|
||||||
|
$code = (& docker run --rm curlimages/curl:latest -sS -o /dev/null -w '%{http_code}' --max-time $TimeoutSec -L $c.Url 2>&1 | Out-String).Trim()
|
||||||
|
if ($code -match '^\d{3}$') { Ok "$($c.Name) -> HTTP $code" }
|
||||||
|
else { Warn "$($c.Name) -> $code" }
|
||||||
|
} catch {
|
||||||
|
Warn "$($c.Name) -> error $($_.Exception.Message.Split("`n")[0])"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Host ""
|
||||||
|
if ($failed) { Write-Host "RESULT: FAILED" -ForegroundColor Red; exit 1 }
|
||||||
|
Write-Host "RESULT: OK" -ForegroundColor Green; exit 0
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# check-tokens.ps1 — Verify that the credentials in the environment actually work.
|
||||||
|
#
|
||||||
|
# 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.ps1
|
||||||
|
# ./scripts/check-tokens.ps1 -EnvFile .env
|
||||||
|
# ./scripts/check-tokens.ps1 -Target 192.168.88.40
|
||||||
|
#
|
||||||
|
# Exit code 0 = all credentials valid; 1 = at least one check failed.
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$EnvFile = '',
|
||||||
|
[string]$Target = '192.168.88.40',
|
||||||
|
[int]$DockerPort = 2375,
|
||||||
|
[int]$TimeoutSec = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$target = $Target
|
||||||
|
$dockerHost = "tcp://${target}:${DockerPort}"
|
||||||
|
$env:DOCKER_HOST = $dockerHost
|
||||||
|
|
||||||
|
$failed = $false
|
||||||
|
function Fail([string]$msg) { Write-Host " [FAIL] $msg" -ForegroundColor Red; $script:failed = $true }
|
||||||
|
function Ok([string]$msg) { Write-Host " [OK] $msg" -ForegroundColor Green }
|
||||||
|
function Warn([string]$msg) { Write-Host " [WARN] $msg" -ForegroundColor Yellow }
|
||||||
|
|
||||||
|
# Resolve env file: explicit, or $PWD/.env, or repo root.
|
||||||
|
if (-not $EnvFile) {
|
||||||
|
foreach ($cand in @((Join-Path $PWD '.env'), (Join-Path (Split-Path $PWD) '.env'))) {
|
||||||
|
if (Test-Path $cand) { $EnvFile = $cand; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $EnvFile -or -not (Test-Path $EnvFile)) {
|
||||||
|
Write-Host "No .env file found. Pass one with -EnvFile." -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse KEY=VALUE lines into a hashtable.
|
||||||
|
$envs = @{}
|
||||||
|
Get-Content $EnvFile | ForEach-Object {
|
||||||
|
if ($_ -match '^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$') {
|
||||||
|
$envs[$Matches[1]] = $Matches[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Val([string]$key) {
|
||||||
|
if ($envs.ContainsKey($key)) { return $envs[$key] }
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pull helper images once.
|
||||||
|
Write-Host "Preparing helper images (curl, aws-cli) on $target..." -ForegroundColor Cyan
|
||||||
|
docker pull curlimages/curl:latest 2>&1 | Out-Null
|
||||||
|
docker pull amazon/aws-cli:latest 2>&1 | Out-Null
|
||||||
|
|
||||||
|
function Curl([string]$url, [string]$bearer, [string]$tokenHeader) {
|
||||||
|
$a = @('run', '--rm', 'curlimages/curl:latest', '-sS', '--max-time', "$TimeoutSec")
|
||||||
|
if ($bearer) { $a += @('-H', "Authorization: Bearer $bearer") }
|
||||||
|
if ($tokenHeader) { $a += @('-H', "Authorization: token $tokenHeader") }
|
||||||
|
$a += $url
|
||||||
|
return (& docker @a 2>&1 | Out-String).Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Host "== Linear Void credential / token check ==" -ForegroundColor Cyan
|
||||||
|
Write-Host "Env file : $EnvFile"
|
||||||
|
Write-Host "Docker host: $dockerHost"
|
||||||
|
|
||||||
|
# --- 1. Gitea registry token ---
|
||||||
|
Write-Host "`n-- Gitea container registry --" -ForegroundColor Cyan
|
||||||
|
$regUser = Get-Val 'CI_REGISTRY_USER'
|
||||||
|
$regToken = Get-Val 'CI_REGISTRY_TOKEN'
|
||||||
|
if ($regUser -and $regToken) {
|
||||||
|
# Non-sensitive probe: /api/v1/user returns a scope message for token-based creds.
|
||||||
|
$out = Curl 'https://git.allucanget.biz/api/v1/user' $null $regToken
|
||||||
|
if ($out -match 'token does not have at least one of required scope' -or $out -match '"login"' -or $out -match 'invalid_grant') {
|
||||||
|
if ($out -match 'invalid_grant|Unauthorized|invalid') {
|
||||||
|
Fail "Gitea registry token rejected ($regUser): $out"
|
||||||
|
} else {
|
||||||
|
Ok "Gitea registry token valid for '$regUser' (scope: write:package/write:repository)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Warn "Gitea registry token response unexpected: $out"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Warn "CI_REGISTRY_USER / CI_REGISTRY_TOKEN not set; skipping Gitea registry check"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 2. Cloudflare API token ---
|
||||||
|
Write-Host "`n-- Cloudflare API tokens --" -ForegroundColor Cyan
|
||||||
|
$cfToken = Get-Val 'CLOUDFLARE_API_TOKEN'
|
||||||
|
if ($cfToken) {
|
||||||
|
$out = Curl 'https://api.cloudflare.com/client/v4/user/tokens/verify' $cfToken $null
|
||||||
|
if ($out -match '"success":true') {
|
||||||
|
if ($out -match '"status":"active"') { Ok 'Cloudflare API token valid and ACTIVE' }
|
||||||
|
else { Warn "Cloudflare API token valid but status: $out" }
|
||||||
|
} else {
|
||||||
|
Fail "Cloudflare API token rejected: $out"
|
||||||
|
}
|
||||||
|
} else { Warn "CLOUDFLARE_API_TOKEN not set; skipping Cloudflare check" }
|
||||||
|
|
||||||
|
# --- 3. Cloudflare R2 / S3 bucket access ---
|
||||||
|
Write-Host "`n-- Cloudflare R2 (S3-compatible) --" -ForegroundColor Cyan
|
||||||
|
$r2Key = Get-Val 'R2_ACCESS_KEY_ID'
|
||||||
|
$r2Secret = Get-Val 'R2_SECRET_ACCESS_KEY'
|
||||||
|
$r2Ep = Get-Val 'R2_ENDPOINT'
|
||||||
|
$bucket = Get-Val 'R2_BUCKET_NAME'
|
||||||
|
|
||||||
|
# Use the admin key pair (which has ListBuckets) when present, else the regular key.
|
||||||
|
$listKey = if ((Get-Val 'R2_ADMIN_ACCESS_KEY_ID') -and (Get-Val 'R2_ADMIN_SECRET_ACCESS_KEY')) {
|
||||||
|
@{ k = Get-Val 'R2_ADMIN_ACCESS_KEY_ID'; s = Get-Val 'R2_ADMIN_SECRET_ACCESS_KEY' }
|
||||||
|
} else { @{ k = $r2Key; s = $r2Secret } }
|
||||||
|
|
||||||
|
if ($listKey.k -and $listKey.s -and $r2Ep) {
|
||||||
|
$script = @"
|
||||||
|
export AWS_ACCESS_KEY_ID='$($listKey.k)'
|
||||||
|
export AWS_SECRET_ACCESS_KEY='$($listKey.s)'
|
||||||
|
aws s3 ls --endpoint-url '$r2Ep' --region auto 2>&1
|
||||||
|
"@
|
||||||
|
$out = (& docker run --rm --entrypoint sh amazon/aws-cli:latest -c $script 2>&1 | Out-String).Trim()
|
||||||
|
if ($out -match $bucket) { Ok "R2 bucket listing OK, '$bucket' present" }
|
||||||
|
elseif ($out -match 'AccessDenied') { Fail "R2 listing denied (no ListBuckets permission): $out" }
|
||||||
|
elseif ($out -match '^\S+\s+') { Warn "R2 listing returned (unexpected): $out" }
|
||||||
|
else { Fail "R2 access failed: $out" }
|
||||||
|
} else { Warn "R2 credentials / endpoint not fully set; skipping R2 check" }
|
||||||
|
|
||||||
|
# Verify the specific application bucket is reachable with the primary key (read).
|
||||||
|
if ($r2Key -and $r2Secret -and $r2Ep -and $bucket) {
|
||||||
|
$script = @"
|
||||||
|
export AWS_ACCESS_KEY_ID='$r2Key'
|
||||||
|
export AWS_SECRET_ACCESS_KEY='$r2Secret'
|
||||||
|
aws s3 ls "s3://$bucket/" --endpoint-url '$r2Ep' --region auto --max-keys 5 2>&1 | head -5
|
||||||
|
"@
|
||||||
|
$out = (& docker run --rm --entrypoint sh amazon/aws-cli:latest -c $script 2>&1 | Out-String).Trim()
|
||||||
|
if ($out -match 'AccessDenied') { Fail "Bucket '$bucket' read denied with primary key" }
|
||||||
|
elseif ($out -eq '') { Ok "Bucket '$bucket' reachable (empty listing, expected)" }
|
||||||
|
elseif ($out -match 'NoSuchBucket') { Fail "Bucket '$bucket' NOT FOUND" }
|
||||||
|
else { Ok "Bucket '$bucket' reachable" }
|
||||||
|
} elseif (-not $bucket) { Warn "R2_BUCKET_NAME not set; skipping bucket-level check" }
|
||||||
|
|
||||||
|
# --- 4. Linear Void admin login ---
|
||||||
|
Write-Host "`n-- Linear Void admin login --" -ForegroundColor Cyan
|
||||||
|
$adminUser = Get-Val 'ADMIN_USERNAME'
|
||||||
|
$adminPass = Get-Val 'ADMIN_PASSWORD'
|
||||||
|
if ($adminUser -and $adminPass) {
|
||||||
|
# Hit the chat backend directly (public origin is caught by nginx SPA fallback).
|
||||||
|
$url = "http://${target}:3000/api/admin/login"
|
||||||
|
$body = ('{"username":"' + $adminUser + '","password":"' + $adminPass + '"}')
|
||||||
|
$wrongBody = ('{"username":"' + $adminUser + '","password":"__definitely_wrong__"}')
|
||||||
|
$a = @('run', '--rm', 'curlimages/curl:latest', '-sS', '--max-time', "$TimeoutSec",
|
||||||
|
'-o', '/dev/null', '-w', '%{http_code}', '-X', 'POST',
|
||||||
|
'-H', 'Content-Type: application/json', '-d', $body, $url)
|
||||||
|
$okCode = (& docker @a 2>&1 | Out-String).Trim()
|
||||||
|
$b = @('run', '--rm', 'curlimages/curl:latest', '-sS', '--max-time', "$TimeoutSec",
|
||||||
|
'-o', '/dev/null', '-w', '%{http_code}', '-X', 'POST',
|
||||||
|
'-H', 'Content-Type: application/json', '-d', $wrongBody, $url)
|
||||||
|
$wrongCode = (& docker @b 2>&1 | Out-String).Trim()
|
||||||
|
if ($okCode -eq '200' -and $wrongCode -eq '401') { Ok "Admin credentials valid (correct=200, wrong=401)" }
|
||||||
|
else { Fail "Admin login unexpected (correct=$okCode, wrong=$wrongCode)" }
|
||||||
|
} else { Warn "ADMIN_USERNAME / ADMIN_PASSWORD not set; skipping admin check" }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Host ""
|
||||||
|
if ($failed) { Write-Host "RESULT: FAILED" -ForegroundColor Red; exit 1 }
|
||||||
|
Write-Host "RESULT: OK (no failing checks)" -ForegroundColor Green; exit 0
|
||||||
@@ -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
|
||||||
+20
-49
@@ -2,66 +2,42 @@
|
|||||||
|
|
||||||
These files back the [scaling/performance playbook](../../docs/operations/scaling-performance-playbook.md).
|
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
|
```bash
|
||||||
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml
|
export WS_URL='wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||||
```
|
mkdir -p artifacts
|
||||||
|
|
||||||
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
|
|
||||||
npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
|
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:
|
Summarize key counters, rates, and available percentile stats from a saved Artillery JSON report:
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
pwsh -File scripts/loadtest/summarize-artillery.ps1 -ReportFile artifacts/ws-chat-report.json
|
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
|
```bash
|
||||||
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run scripts/loadtest/admin-api.js
|
export ADMIN_BASE_URL='https://linearvoid.tv'
|
||||||
```
|
export ADMIN_SESSION_COOKIE='session=...'
|
||||||
|
mkdir -p artifacts
|
||||||
Capture machine-readable metrics and a text summary:
|
k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | tee artifacts/admin-api-output.txt
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The script emits global and per-endpoint thresholds (overview, analytics, chat-log, users) so p95 and error-rate regressions are visible per route.
|
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:
|
Summarize endpoint p95 and failure rates from a saved k6 JSON summary:
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
pwsh -File scripts/loadtest/summarize-k6.ps1 -SummaryFile artifacts/admin-api-summary.json
|
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:
|
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.
|
- 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.
|
- Admin runs also emit `admin-api-endpoint-summary.txt` with per-endpoint p95/error values.
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario ws -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
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' \
|
||||||
```powershell
|
-a 'https://linearvoid.tv' -c 'session=...'
|
||||||
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=...'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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 <artillery-report.json>
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
echo "Usage: $0 <artillery-report.json>" >&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
|
||||||
@@ -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 <k6-summary.json>
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
echo "Usage: $0 <k6-summary.json>" >&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
|
||||||
Reference in new issue
Block a user