Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8cf853842 | ||
|
|
8c5e70959b | ||
|
|
5f1f796a20 | ||
|
|
11d48c8e0d | ||
|
|
242fc8da67 | ||
|
|
33b6296c5e | ||
|
|
9f60087a4c | ||
|
|
474da7418e | ||
|
|
d267173e30 | ||
|
|
e7bff8b9c0 | ||
|
|
9a0511b36b | ||
|
|
c2f9f9ed62 | ||
|
|
bde3dc3a46 | ||
|
|
21a70dcf0f | ||
|
|
4fe09a0a3d | ||
|
|
85454610f4 | ||
|
|
5585a6ecc3 | ||
|
|
53a0c3c825 | ||
|
|
9e56ff7859 | ||
|
|
9ac1a8b79d | ||
|
|
6950bba85d | ||
|
|
06b277e9c1 | ||
|
|
1cc44282bc | ||
|
|
b6705e6dbd | ||
|
|
cef002862e | ||
|
|
4eb88a4594 | ||
|
|
9cf3956c28 | ||
|
|
a15a9f2df0 | ||
|
|
76397d52d7 | ||
|
|
70a5159f81 | ||
|
|
568112b85a | ||
|
|
02ff53229c | ||
|
|
68a4d35334 | ||
|
|
7afab6658b | ||
|
|
121fdbacdc | ||
|
|
06ab5b4655 | ||
|
|
e15d89edfe | ||
|
|
044851a43e | ||
|
|
926d70dc3c | ||
|
|
cca8d77325 | ||
|
|
528a7fffc0 | ||
|
|
3742859489 | ||
|
|
27c74f1663 | ||
|
|
2a718e4652 | ||
|
|
1019972e25 | ||
|
|
7be66a703c | ||
|
|
4d627e13b9 | ||
|
|
c0356a26f6 | ||
|
|
4aac5f9009 | ||
|
|
af2e94aa46 | ||
|
|
187d288e2e | ||
|
|
377dc69859 | ||
|
|
5eb5edcac4 | ||
|
|
55a128dc3a | ||
|
|
1f11baa949 | ||
|
|
89ddfb3528 | ||
|
|
99f75eb9ed | ||
|
|
d823763dc3 | ||
|
|
9178b7b295 | ||
|
|
18459a9811 | ||
|
|
f6ca89747e | ||
|
|
98fafcd93c | ||
|
|
8b8f2335e1 | ||
|
|
80f4d9f198 | ||
|
|
87e9eff1bd | ||
|
|
24273b5513 | ||
|
|
31882f6b73 |
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
|
||||||
|
|||||||
+38
-8
@@ -6,12 +6,14 @@
|
|||||||
name: CD
|
name: CD
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
branches: [main]
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
runs-on: [ubuntu-latest]
|
runs-on: [ubuntu-latest]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -31,16 +33,44 @@ jobs:
|
|||||||
- name: Prepare environment
|
- name: Prepare environment
|
||||||
run: |
|
run: |
|
||||||
cat > .env << 'EOF'
|
cat > .env << 'EOF'
|
||||||
|
# Database & Redis
|
||||||
DATABASE_URL="${{ secrets.DATABASE_URL }}"
|
DATABASE_URL="${{ secrets.DATABASE_URL }}"
|
||||||
REDIS_URL=redis://redis:6379/0
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# Playout
|
||||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||||
HLS_SEGMENT_DURATION=6
|
HLS_SEGMENT_DURATION=2
|
||||||
HLS_WINDOW_SIZE=24
|
HLS_WINDOW_SIZE=12
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Cloudflare R2 (required for HLS segment sync)
|
||||||
|
R2_ACCESS_KEY_ID="${{ secrets.R2_ACCESS_KEY_ID }}"
|
||||||
|
R2_SECRET_ACCESS_KEY="${{ secrets.R2_SECRET_ACCESS_KEY }}"
|
||||||
|
R2_BUCKET_NAME="${{ secrets.R2_BUCKET_NAME }}"
|
||||||
|
R2_ENDPOINT="${{ secrets.R2_ENDPOINT }}"
|
||||||
|
R2_REGION=auto
|
||||||
|
|
||||||
|
# Cloudflare
|
||||||
|
CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}"
|
||||||
|
CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"
|
||||||
|
MEDIA_DOMAIN=${{ secrets.MEDIA_DOMAIN }}
|
||||||
|
|
||||||
|
# Frontend public URLs
|
||||||
|
VITE_PUBLIC_APP_URL=${{ secrets.VITE_PUBLIC_APP_URL }}
|
||||||
|
VITE_PUBLIC_WS_URL=${{ secrets.VITE_PUBLIC_WS_URL }}
|
||||||
|
VITE_PUBLIC_HLS_URL=${{ secrets.VITE_PUBLIC_HLS_URL }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_ROOM=${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }}
|
||||||
|
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50
|
||||||
|
|
||||||
|
# Admin auth
|
||||||
ADMIN_USERNAME="${{ secrets.ADMIN_USERNAME }}"
|
ADMIN_USERNAME="${{ secrets.ADMIN_USERNAME }}"
|
||||||
ADMIN_PASSWORD="${{ secrets.ADMIN_PASSWORD }}"
|
ADMIN_PASSWORD="${{ secrets.ADMIN_PASSWORD }}"
|
||||||
ADMIN_SESSION_TTL_HOURS=12
|
ADMIN_SESSION_TTL_HOURS=12
|
||||||
NGINX_PORT=80
|
|
||||||
|
# Nginx
|
||||||
|
# Host port 80/443 (Coolify Traefik) and 8080 are taken; nginx publishes on 8082.
|
||||||
|
NGINX_PORT=8082
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Seed Prometheus config on remote host
|
- name: Seed Prometheus config on remote host
|
||||||
@@ -216,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..."
|
||||||
@@ -241,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"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Install Frontend Dependencies
|
- name: Install Frontend Dependencies
|
||||||
run: cd frontend && npm ci
|
run: cd frontend && npm ci
|
||||||
@@ -26,6 +26,9 @@ jobs:
|
|||||||
- name: Lint Frontend
|
- name: Lint Frontend
|
||||||
run: cd frontend && npm run lint
|
run: cd frontend && npm run lint
|
||||||
|
|
||||||
|
- name: Test Frontend with Coverage
|
||||||
|
run: cd frontend && npm run test:coverage
|
||||||
|
|
||||||
- name: Build Frontend
|
- name: Build Frontend
|
||||||
run: cd frontend && npm run build
|
run: cd frontend && npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+20
-5
@@ -2,10 +2,6 @@
|
|||||||
|
|
||||||
Agents instructions for the linearvoid project.
|
Agents instructions for the linearvoid project.
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
- use /caveman skill, latest version from <https://github.com/JuliusBrussee/caveman> is <https://github.com/JuliusBrussee/caveman/tree/main/skills/caveman>
|
|
||||||
|
|
||||||
## General Guidelines
|
## General Guidelines
|
||||||
|
|
||||||
- Use the architecture documentation (arc42 template and ADRs in `docs`) as the source of truth for design decisions.
|
- Use the architecture documentation (arc42 template and ADRs in `docs`) as the source of truth for design decisions.
|
||||||
@@ -14,9 +10,28 @@ Agents instructions for the linearvoid project.
|
|||||||
|
|
||||||
## Task Management
|
## Task Management
|
||||||
|
|
||||||
- Plan any task in the `TODO.md` file with clear steps and mark them as completed when done. Move completed sections to DONE.md. Write entry in root `CHANGELOG.md` for any significant changes. Avoid using in-memory todo lists or update them frequently to sync from the `TODO.md` file.
|
- Plan any task in the `.github/instructions/TODO.md` file with clear steps and mark them as completed when done. Move completed sections to `.github/instructions/DONE.md`. Write entry in root `CHANGELOG.md` for any significant changes. Avoid using in-memory todo lists or update them frequently to sync from the `TODO.md` file.
|
||||||
- Before taking any action except for planning and writing `TODO.md`, always ask user for confirmation, accept `go`, `ok`, `yes`, `next`, or `continue` to proceed. If the user says `no`, `stop`, or `cancel`, abort the action and return to planning.
|
- Before taking any action except for planning and writing `TODO.md`, always ask user for confirmation, accept `go`, `ok`, `yes`, `next`, or `continue` to proceed. If the user says `no`, `stop`, or `cancel`, abort the action and return to planning.
|
||||||
|
|
||||||
|
### Required tracking files — update at every development step
|
||||||
|
|
||||||
|
These files must be kept current with every change. Do not let them drift.
|
||||||
|
|
||||||
|
| File | Purpose | When to update |
|
||||||
|
| --------------------------------- | --------------------------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| `.github/instructions/TODO.md` | Active implementation plan with checkboxes | Mark items done as they complete; add new items when scope expands |
|
||||||
|
| `.github/instructions/BACKLOG.md` | Deferred, manual, and operational tasks | Sync whenever TODO items complete or new gaps are identified |
|
||||||
|
| `.github/instructions/DONE.md` | Archive of completed TODO sections | Move completed sections here from TODO.md |
|
||||||
|
| `.github/MEMORY.md` | Session memory — what was done, when, and why | Append dated entry after each meaningful batch of changes |
|
||||||
|
| `CHANGELOG.md` | User-facing release notes | Add entry when a phase or significant feature completes |
|
||||||
|
| `docs/STREAMING.md` | Streaming pipeline contract | Update when playout, HLS, or caching behavior changes |
|
||||||
|
| `docs/DEPLOYMENT.md` | Deploy and infrastructure procedures | Update when pipeline, topology, or provisioning changes |
|
||||||
|
| `docs/SETUP.md` | Environment and onboarding guide | Update when prerequisites, `.env`, or setup steps change |
|
||||||
|
| `docs/MONITORING.md` | Observability reference | Update when metrics, alerts, or dashboards change |
|
||||||
|
| `docs/API.md` | API contract | Update when endpoints, status codes, or rate limits change |
|
||||||
|
| `docs/SECURITY.md` | Security posture | Update when auth, TLS, or rate limiting changes |
|
||||||
|
| `docs/operations/*.md` | Runbooks, checklists, how-to guides | Keep aligned with actual procedures and dashboard URLs |
|
||||||
|
|
||||||
## Security & Secrets Protection
|
## Security & Secrets Protection
|
||||||
|
|
||||||
- **Never commit raw secrets or credentials** to the repository.
|
- **Never commit raw secrets or credentials** to the repository.
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# MEMORY
|
||||||
|
|
||||||
|
## 2026-08-07
|
||||||
|
|
||||||
|
- Completed runtime validation section from TODO.
|
||||||
|
- Runtime video normalization tests executed in running `linearvoid-playout` container using ffmpeg/ffprobe.
|
||||||
|
- Verified normalized outputs for MOV, MKV, VFR, and 5.1 inputs: `h264`, `yuv420p`, `30/1`, `aac`, `48000`, `2 channels`.
|
||||||
|
- Concat continuity check passed with `CONCAT_OK`; output duration `25.344667` seconds.
|
||||||
|
- Responsive viewport test on live viewer (`390x844`, `412x915`) detected 8px horizontal overflow from `100vw` usage in viewer container styles.
|
||||||
|
- Fixed overflow in source by changing `.lv-body` and `.viewer-shell` width from `100vw` to `100%` in `frontend/src/style.css`.
|
||||||
|
- Redis restart test:
|
||||||
|
- Admin session endpoint returned 200 before and after Redis restart.
|
||||||
|
- Presence key survived Redis restart in test-lab configuration.
|
||||||
|
- Redis health returned to `healthy` post-restart.
|
||||||
|
- Files changed:
|
||||||
|
- `.github/instructions/TODO.md`
|
||||||
|
- `.github/instructions/DONE.md`
|
||||||
|
- `frontend/src/style.css`
|
||||||
|
- `CHANGELOG.md`
|
||||||
|
|
||||||
|
## 2026-08-07 (continued)
|
||||||
|
|
||||||
|
- Completed documentation half of recovery runbook TODO item.
|
||||||
|
- Expanded `docs/incident-runbook.md` with quarterly DR drill: prerequisites, outage simulation, restore validation, pass/fail criteria, and evidence template.
|
||||||
|
- Split TODO item into:
|
||||||
|
- documented drill (`[x]`)
|
||||||
|
- manual rehearsal execution (`[ ]`)
|
||||||
|
- Updated `CHANGELOG.md` with incident runbook drill documentation entry.
|
||||||
|
|
||||||
|
## 2026-08-07 (continued 2)
|
||||||
|
|
||||||
|
- Added manual operations checklist `docs/operations/cloudflare-uptimerobot-checklist.md` for Cloudflare + UptimeRobot task execution and evidence capture.
|
||||||
|
- Linked checklist from `SETUP.md` Cloudflare section and TODO manual sections.
|
||||||
|
- Did not mark manual dashboard items complete; checklist is preparation only.
|
||||||
|
|
||||||
|
## 2026-08-07 (continued 3)
|
||||||
|
|
||||||
|
- Added `docs/operations/scaling-performance-playbook.md` covering manifest TTL tuning, TTFB baseline method, WebSocket/API benchmarking approach, cache ratio tracking, capacity triggers, and Phase 2/3 plans.
|
||||||
|
- Updated TODO and BACKLOG to mark playbook creation done while keeping execution/measurement tasks pending.
|
||||||
|
- Linked scaling playbook in `README.md` resources.
|
||||||
|
|
||||||
|
## 2026-08-09
|
||||||
|
|
||||||
|
- Added WebSocket benchmark summary helper `scripts/loadtest/summarize-artillery.ps1`.
|
||||||
|
- Updated `scripts/loadtest/run-loadtests.ps1` to auto-generate `ws-chat-summary.txt` for `ws` and `all` scenarios.
|
||||||
|
- Updated `scripts/loadtest/summarize-k6.ps1` to produce pipeline-friendly output and integrated automatic `admin-api-endpoint-summary.txt` generation.
|
||||||
|
- Expanded `scripts/loadtest/README.md` with PowerShell-first artifact capture and helper usage for both ws and admin scenarios.
|
||||||
|
- Updated `.gitignore` to ignore loadtest artifacts (`artifacts/` and `scripts/loadtest/artifacts/`).
|
||||||
|
- Added artifact-to-worksheet field mapping section in `docs/operations/scaling-performance-playbook.md` so ws/admin worksheet entries map directly to generated summary files.
|
||||||
|
- Updated `docs/DEVELOPMENT.md` and `.github/instructions/TODO.md` to mark ws/admin benchmark scaffolding complete while live benchmark execution remains pending.
|
||||||
|
- Added `CHANGELOG.md` entry `1.0.10` summarizing loadtest helper automation and playbook mapping updates.
|
||||||
|
|
||||||
|
## 2026-08-09 (continued)
|
||||||
|
|
||||||
|
- Completed Phase 1 (test fixtures): documented ffmpeg fixture-generation commands in `playlist_test.go` header comments (valid_720p30, invalid_h265, invalid_24fps).
|
||||||
|
- Completed Phase 4 (R2 integration):
|
||||||
|
- Enhanced `SyncToR2` in `playlist.go` to set CacheControl + ContentType based on file extension:
|
||||||
|
- `.m4s`, `.ts`, `.mp4`, `.m4a` -> `public, max-age=31536000, immutable` + `video/MP2T`
|
||||||
|
- `.m3u8` -> `public, max-age=2` + `application/vnd.apple.mpegurl`
|
||||||
|
- unknown extension -> no cache headers
|
||||||
|
- Added three tests: `TestSyncToR2` (segment upload with cache verification), `TestSyncToR2_ManifestCacheHeaders` (manifest upload), `TestSyncToR2_NoCacheHeadersForUnknownExt` (unknown extension).
|
||||||
|
- All 17 playout tests pass (16 pass, 1 pre-existing skip).
|
||||||
|
- Updated TODO.md: Phase 1 marked complete, Phase 4 partially complete (edge cache hit/miss remains manual).
|
||||||
|
- Updated DEVELOPMENT.md: Test Fixtures marked complete, R2 Integration Test marked partially complete.
|
||||||
|
|
||||||
|
## 2026-08-09 (continued 2)
|
||||||
|
|
||||||
|
- Completed Phase 2 chat tests in `frontend/src/main.test.ts`:
|
||||||
|
- Presence updates: join/leave system messages add and remove users from presence list, sorted rendering.
|
||||||
|
- Private DM flow: togglePrivateDM creates correct dm: room name from sorted usernames.
|
||||||
|
- Message rendering: system messages, self messages (message-self), and other-user messages (message-other) all render correctly.
|
||||||
|
- Total frontend tests: 27 passing (2 test files).
|
||||||
|
- Marked Phase 4 "Document stubs/mocks" done (putObject injection pattern).
|
||||||
|
- Updated TODO.md: Phase 2 chat tests checked, Phase 4 stubs checked.
|
||||||
|
- Updated DEVELOPMENT.md: User Frontend Tests marked partially complete, R2 stubs checked.
|
||||||
|
- Updated CHANGELOG.md with Phase 2 presence/DM/message tests.
|
||||||
|
|
||||||
|
## 2026-08-09 (continued 3)
|
||||||
|
|
||||||
|
- Completed Phase 3 admin frontend tests in `frontend/src/admin.test.ts`:
|
||||||
|
- Exported `savePlaylist` and `renderPlaylistSortable` from `admin.ts`.
|
||||||
|
- Added playlist save test (verifies PUT /api/admin/playlist with JSON body).
|
||||||
|
- Added playlist save error handling test (verifies failure status).
|
||||||
|
- Added sortable rendering test (verifies index, paths, remove buttons, comment/blank line skip).
|
||||||
|
- Total frontend tests: 31 passing (2 test files).
|
||||||
|
- Updated TODO.md: Phase 3 playlist/dashboard/API integration all checked.
|
||||||
|
- Updated DEVELOPMENT.md: Admin Frontend Tests marked partially complete.
|
||||||
|
|
||||||
|
## 2026-08-10 (continued 2)
|
||||||
|
|
||||||
|
- Implemented STREAMING.md production-readiness gaps from plan `.hermes/plans/2026-08-10_164500-production-readiness.md`:
|
||||||
|
- **Task 1-2**: Added `runID` (ISO timestamp format) and monotonic `segSequence` for globally unique segment naming (`r-{ISOTIME}/seg-{seq}.m4s`). Replaced `strftime` pattern with run ID-based path.
|
||||||
|
- **Task 2**: Added `watchAndPublishSegments` using `fsnotify` for real-time per-segment R2 upload (~7s delay). Previously `syncLatestToR2()` only ran after FFmpeg exit.
|
||||||
|
- **Task 3**: Added `publishManifest` with atomic `CopyObject` (temp key → final key) to prevent manifest referencing unconfirmed segments.
|
||||||
|
- **Task 4**: Updated `manifestCacheControl` from `"public, max-age=2"` to `"public, max-age=1, s-maxage=2, must-revalidate"` per STREAMING.md.
|
||||||
|
- **Task 5**: Added `hls.on(Hls.Events.ERROR)` handler in `main.ts` with bounded retries (5x), exponential backoff (2s→32s), reconnecting overlay (spinner + text). Added `showReconnecting`/`hideReconnecting` functions.
|
||||||
|
- **Task 6**: Enriched `HealthHandler` with `run_id`, `active_item`, `playlist_revision`, `last_segment_seq`, `last_r2_pub`, `consecutive_failures`. Added "unhealthy" status when R2 publish is stale (>12s).
|
||||||
|
- Files changed: `backend/playout/src/playlist.go`, `playlist_test.go`, `go.mod`, `frontend/src/main.ts`, `main.test.ts`, `style.css`, `index.html`.
|
||||||
|
- Updated `.github/instructions/TODO.md`, `BACKLOG.md`, `MEMORY.md`, `DONE.md`.
|
||||||
|
- Remaining: CORS on media.linearvoid.tv (Cloudflare R2 config, infrastructure).
|
||||||
|
|
||||||
|
## 2026-08-10 (continued 3)
|
||||||
|
|
||||||
|
- Completed Phase 2 (viewer) + Phase 3 (admin) responsive/accessibility tests:
|
||||||
|
- 5 viewer accessibility tests: video controls/attributes, chat panel aria-labels, chat toggle aria-expanded, message input label, reconnecting overlay aria-live/role.
|
||||||
|
- 3 viewer responsive tests: chat-collapsed toggle, CSS grid layout detection, now-playing aria-live.
|
||||||
|
- 3 admin accessibility tests: nav aria-label, active tab aria-current, nav button focusability.
|
||||||
|
- 2 admin responsive tests: tab content visibility, essential tab presence.
|
||||||
|
- Total: 45 frontend tests passing (2 test files).
|
||||||
|
- Added coverage reporting: `vitest.config.ts` with v8 provider, thresholds at 20/15/15/20 (statements/branches/functions/lines).
|
||||||
|
- Added `test:coverage` script to `package.json`.
|
||||||
|
- Fixed CI: bumped Node from 20 to 22 in `ci.yml` and `workers-deploy.yml` (jsdom@30 requires Node 22+). Added `engines` to `package.json`.
|
||||||
|
- Updated AGENTS.md: added Required tracking files table with 13 docs and update cadence.
|
||||||
|
- All Go vet, Go tests, frontend test:coverage passing.
|
||||||
|
- CORS on media.linearvoid.tv remains the only streaming pipeline gap (Cloudflare R2 config, not code).
|
||||||
|
|
||||||
|
## 2026-08-10 (continued 4)
|
||||||
|
|
||||||
|
- Verified all test suites pass after streaming pipeline implementation:
|
||||||
|
- Playout: `go mod tidy` (added fsnotify v1.8.0) → all tests pass
|
||||||
|
- Chat: 7 packages pass (go vet + go test)
|
||||||
|
- Frontend: 45 tests pass (2 files), test:coverage meets thresholds (22.92% / 18.44% / 19.01% / 25.42%)
|
||||||
|
- Edge cache validation script `scripts/validate-edge-cache.sh` exists.
|
||||||
|
- Remaining: CORS on media.linearvoid.tv (Cloudflare R2 bucket CORS config, not a code change). Phases 5-7 are manual/infrastructure only (Cloudflare dashboard, Hetzner provisioning, live benchmarks).
|
||||||
|
|
||||||
|
## 2026-08-11
|
||||||
|
|
||||||
|
- Consolidated all development tasks from `DEVELOPMENT.md` and `.github/instructions/TODO.md` into `.github/instructions/BACKLOG.md` as the single source of truth. `DEVELOPMENT.md` now obsolete and removed. `TODO.md` references `BACKLOG.md` and `DONE.md`.
|
||||||
|
- Fixed R2 endpoint validation in playout service (`backend/playout/src/playlist.go`):
|
||||||
|
- Added `isR2Configured()` checking all four required env vars (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`).
|
||||||
|
- `initR2Client()` now returns a clear error if R2 is not configured instead of creating an S3 client with an empty endpoint.
|
||||||
|
- Segment watcher (`watchAndPublishSegments`), `syncLatestToR2()`, and `publishManifest()` are now guarded by `r2Client != nil` — no error spam when R2 is unavailable.
|
||||||
|
- Playout gracefully degrades: local HLS via nginx works without R2.
|
||||||
|
- Updated `.gitea/workflows/cd.yml` "Prepare environment" step to include all R2* variables sourced from Gitea secrets (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN*\*`), Cloudflare variables (`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`), and frontend public URL variables.
|
||||||
|
- Updated `docs/DEPLOYMENT.md` "Environment File" section: replaced vague "Cloudflare/R2 variables" bullet with explicit variable lists and added a new "Gitea Secrets" table documenting all 15 required secrets.
|
||||||
|
- All playout tests pass (20 tests), `go vet` clean.
|
||||||
|
|
||||||
|
## 2026-08-13
|
||||||
|
|
||||||
|
- Completed all remaining enhancement tasks (only manual/infrastructure phases left):
|
||||||
|
- **Playout**: automatic media normalization on upload (`media.NormalizeVideo`, `*.normalized.mp4`, statuses `ready`/`needs_normalization`/`normalizing`/`normalization_failed`, `POST /api/admin/media/{id}/normalize`); R2 segment retention (`cleanupOldRuns`, keep last `R2_KEEP_RUNS` runs, `R2_MAX_RETENTION_BYTES` cap); seamless looping (playlist SHA-256 fingerprint + `-start_number`); single `start-playout.sh` entrypoint + `-validate` mode; loop/cleanup metrics.
|
||||||
|
- **Application configuration**: thread-safe JSON runtime config store (`backend/chat/src/config/runtime.go`) shared with playout via `/app/playlist/config.json`; `GET/PUT /api/admin/config`; admin UI config form.
|
||||||
|
- **Matomo analytics**: public `GET /api/matomo` + viewer pageview snippet in `index.html`.
|
||||||
|
- **Chat user accounts**: register/login/logout/session endpoints, bcrypt hashing, `linearvoid_user_session` cookie, viewer sign-in UI.
|
||||||
|
- **DM history & search**: `GET /api/dm/history` (paginated) + `GET /api/dm/search` + DM search UI.
|
||||||
|
- **Chat UI**: typing indicator, user-list toggle, media status badges, "Add to playlist" gated to `ready`.
|
||||||
|
- **Prometheus TSDB retention**: `--storage.tsdb.retention.time=30d` + `--storage.tsdb.retention.size=15GB` in both compose files; alert rule count corrected 8 → 10.
|
||||||
|
- Updated `docs/architecture/README.md` to reflect all `[Unreleased]` changes (normalization, looping, retention, runtime config, Matomo, user accounts, DM history/search, new API endpoints, module tables).
|
||||||
|
- Released **1.0.13**: promoted changelog, bumped version, tagged and pushed.
|
||||||
|
- Extended frontend test coverage: added `counter.test.ts`, reconnecting/typing/auth/websocket tests, admin failure + status-badge tests → 56 tests; statements 19.89% → 28.58% (CI coverage threshold passing).
|
||||||
|
- Fixed Go test data race in `TestHandleAdminMediaByID_Normalize` (waits for async normalization goroutine) + shared in-memory SQLite via `SetMaxOpenConns(1)`.
|
||||||
|
- Fixed viewer crash: `index.html` was missing the `#messages` chat container (stray `</section>`); restored structure + made `addMessage` defensive.
|
||||||
|
- Set `Doto` as the body font (`--font-body`) and added Google Fonts links to admin + login pages.
|
||||||
|
- Files changed: across `backend/chat`, `backend/playout`, `frontend`, `docs`, `docker-compose*.yml`, `.github/instructions/*`.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# BACKLOG
|
||||||
|
|
||||||
|
> All current work is planned in [TODO](./TODO.md) (2026-08-13). This file lists backlog items; new unplanned work is added here.
|
||||||
|
|
||||||
|
## Setup phases remaining
|
||||||
|
|
||||||
|
### Phase 5: complete manual Cloudflare and external-service setup
|
||||||
|
|
||||||
|
- [ ] Apply Cloudflare Workers, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
|
||||||
|
- [ ] Cloudflare Workers: link custom domain and cut over frontend hosting
|
||||||
|
- [ ] Enable Smart Tiered Cache; validate `Cf-Cache-Status: HIT` from upper tier
|
||||||
|
- [ ] WAF: enable OWASP Managed Ruleset (Simulate → review 48h → Block), enable Bot Fight Mode
|
||||||
|
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
||||||
|
- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2; enforce at edge and origin
|
||||||
|
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
|
||||||
|
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5min)
|
||||||
|
- [ ] UptimeRobot: alert on 2 consecutive failures via email + Discord/Slack webhook
|
||||||
|
- [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||||
|
|
||||||
|
### Phase 6: rehearse production deployment end to end
|
||||||
|
|
||||||
|
- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04
|
||||||
|
- [ ] Install Docker Engine + Compose plugin; open firewall: 80/443 public, 2375 runner-only
|
||||||
|
- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs)
|
||||||
|
- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories
|
||||||
|
- [ ] Add `CI_PROD_*` secrets to Gitea (DOCKER_HOST, R2 keys, admin password, DB password, Grafana password)
|
||||||
|
- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP
|
||||||
|
- [ ] Verify all 11 containers healthy: `/health`, `/hls/playlist.m3u8`, WebSocket
|
||||||
|
- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict); test HTTPS + WSS
|
||||||
|
- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout
|
||||||
|
- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL
|
||||||
|
- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host
|
||||||
|
- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds
|
||||||
|
|
||||||
|
### Phase 7: baseline scaling and performance
|
||||||
|
|
||||||
|
- [ ] Execute WebSocket load tests — ramp 10→100→500→1000 connections; measure p95 latency + errors
|
||||||
|
- [ ] Execute admin API benchmarks — `wrk`/`k6` against `/api/admin/*`; target p95 <1s
|
||||||
|
- [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions (WebPageTest 3+ regions, <4s warm cache)
|
||||||
|
- [ ] Tune manifest TTL to 0.5× segment duration; validate `Cf-Cache-Status` distribution
|
||||||
|
- [ ] Track cache-hit ratio and R2 ops in production (Cloudflare GraphQL → Grafana panel, alert <90%)
|
||||||
|
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
|
||||||
|
- [ ] Plan Phase 2 service separation (playout CPU, chat latency, DB state, monitoring)
|
||||||
|
- [ ] Plan Phase 3 multi-origin resilience (second Hetzner location, warm standby, DNS failover, DB/Redis replication)
|
||||||
|
|
||||||
|
### Phase 8: close out remaining test and validation gaps
|
||||||
|
|
||||||
|
- [ ] Admin frontend: user moderation tools tests (mute/ban/report, view moderation logs)
|
||||||
|
- [ ] Frontend: coverage for all critical user + admin components and interactions
|
||||||
|
- [ ] Frontend: document test cases and expected outcomes
|
||||||
|
- [ ] Backend R2: test retrieval of segments/manifests via Cloudflare edge
|
||||||
|
- [ ] Backend R2: validate edge cache hit/miss behavior with repeat requests (`scripts/validate-edge-cache.sh`)
|
||||||
|
|
||||||
|
### Playout
|
||||||
|
|
||||||
|
- [ ] Configure an R2 lifecycle rule on the `hls/` prefix as defense-in-depth (manual)
|
||||||
|
- [ ] Full-loop E2E (2-file playlist, assert `Looping...` with no `CRASHED` across the boundary) — manual via `scripts/test-playout-e2e.sh`
|
||||||
|
|
||||||
|
## Cloudflare Configuration
|
||||||
|
|
||||||
|
### DNS & Workers
|
||||||
|
|
||||||
|
- [ ] Cloudflare Workers: link custom domain (manual)
|
||||||
|
- [ ] Create Cloudflare Worker and cut over frontend hosting (manual)
|
||||||
|
|
||||||
|
### Security & TLS
|
||||||
|
|
||||||
|
- [ ] Enable Tiered Cache, WAF, rate limiting (manual)
|
||||||
|
- [ ] Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier
|
||||||
|
- [ ] WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode
|
||||||
|
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
||||||
|
- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2
|
||||||
|
- [ ] HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS
|
||||||
|
|
||||||
|
## Monitoring & Observability
|
||||||
|
|
||||||
|
### External Monitoring
|
||||||
|
|
||||||
|
- [ ] Set up UptimeRobot external monitoring
|
||||||
|
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
|
||||||
|
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
|
||||||
|
- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
|
||||||
|
|
||||||
|
## External Services
|
||||||
|
|
||||||
|
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||||
|
|
||||||
|
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
|
||||||
|
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
|
||||||
|
- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Host Provisioning (Hetzner — not provisioned)
|
||||||
|
|
||||||
|
- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04
|
||||||
|
- [ ] Install Docker Engine + Compose plugin, open firewall: 80/443 (public), 2375 (runner IP only)
|
||||||
|
- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs)
|
||||||
|
- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories
|
||||||
|
|
||||||
|
### CI/CD & Secrets
|
||||||
|
|
||||||
|
- [ ] Add `CI_PROD_*` secrets to Gitea: DOCKER_HOST, R2 keys, admin password, DB password, Grafana password
|
||||||
|
- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP
|
||||||
|
|
||||||
|
### Smoke Test & Cutover
|
||||||
|
|
||||||
|
- [ ] Verify all 11 containers start healthy, check `/health`, `/hls/playlist.m3u8`, WebSocket
|
||||||
|
- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict), test HTTPS + WSS
|
||||||
|
- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout
|
||||||
|
|
||||||
|
### Production Operations
|
||||||
|
|
||||||
|
- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL
|
||||||
|
- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host
|
||||||
|
- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds
|
||||||
|
|
||||||
|
## Scaling & Performance
|
||||||
|
|
||||||
|
### Load Testing
|
||||||
|
|
||||||
|
- [ ] Execute WebSocket load tests with real infrastructure — ramp 10→100→500→1000 connections, measure p95 latency + errors
|
||||||
|
- [ ] Execute admin API benchmarks with real infrastructure — `wrk`/`k6` against `/api/admin/*`, target p95 <1s
|
||||||
|
|
||||||
|
### Cache & TTFB Optimization
|
||||||
|
|
||||||
|
- [ ] Optimize segment immutability and manifest TTL for cache efficiency
|
||||||
|
- [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration
|
||||||
|
- [ ] Measure playback TTFB from multiple US regions — WebPageTest from 3+ regions, target <4s warm cache
|
||||||
|
- [ ] Track cache hit ratio and R2 ops in production — Cloudflare GraphQL → Grafana panel, alert <90%
|
||||||
|
|
||||||
|
### Capacity & Future Planning
|
||||||
|
|
||||||
|
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
|
||||||
|
- [ ] Plan Phase 2 service separation — document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources
|
||||||
|
- [ ] Plan Phase 3 multi-origin resilience — research second Hetzner location, design warm standby + DNS failover + DB/Redis replication
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# DONE
|
||||||
|
|
||||||
|
> Completed tasks from [TODO](./TODO.md). Product, feature, and release history lives in [CHANGELOG](./CHANGELOG.md); this file keeps only engineering and ops notes not captured there.
|
||||||
|
|
||||||
|
## Manifest run-scoped rewrite — candidate (a) (2026-09-03)
|
||||||
|
|
||||||
|
- Playout now publishes the live R2 manifest with run-scoped media URIs. `rewriteManifestToRun` in `backend/playout/src/playlist.go` prefixes each bare relative URI (`seg-*.m4s` / `init.mp4`, plus `#EXT-X-MAP:URI`) with `r-{runID}/` before `publishManifest`'s atomic `.tmp` → `CopyObject` commit; skipped in slate mode. Added a stubbable `copyObject` hook for tests (consistent with `putObject`/`listObjects`/`deleteObjects`).
|
||||||
|
- Tests: `playlist_rewrite_test.go` — `TestRewriteManifestToRun`, `TestRewriteManifestToRun_AlreadyPrefixedUnchanged`, `TestRewriteManifestToRun_HandlesTSAndComments`, `TestRewriteManifestToRun_EmptyRunID`, `TestIsMediaURI`, `TestPublishManifest_RewritesToRunScopedURIs`, `TestPublishManifest_SlateNotRewritten`.
|
||||||
|
- Docs updated: `STREAMING.md`, `operations/cloudflare-workers-setup.md`, `architecture/README.md` (incl. Level-2 diagram showing the R2-bound `linearvoid-media` Worker and run-scoped layout).
|
||||||
|
- **Still pending (in TODO): live Validation** — deploy the updated playout to the test lab and confirm manifest/segment URIs resolve via `media.linearvoid.tv`.
|
||||||
|
|
||||||
|
## Test infrastructure & refactors (2026-08-07)
|
||||||
|
|
||||||
|
- Chat service `main.go` split into 8 packages (config, auth, db, handlers, ws, media, ratelimit, metrics); `db.RedisClient` + `db.PubSub` interfaces added for mocking.
|
||||||
|
- 78 tests total (Go + frontend), all passing. Coverage: auth 97.8%, config 100%, metrics 100%, ratelimit 90.3%, ws 79.6%, handlers 47.2%, media 36.8%, db 14.3%.
|
||||||
|
- Playout: 12 tests. Frontend: vitest + jsdom, 6 user + 5 admin smoke tests.
|
||||||
|
- CI: `go test -race -cover` with a 50% coverage threshold; SQLite switched to pure-Go `github.com/glebarez/sqlite` (CGO-free, works on Windows).
|
||||||
|
- Frontend coverage enforcement: vitest v8 provider, 40% statements/lines + 30% branches/functions thresholds, `test:coverage` script + CI step.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
- `scripts/backup-db.sh` (daily pg_dump → R2, 14-day retention), `scripts/backup-weekly.sh` (weekly full dump + integrity check, 8-week retention), `scripts/verify-backups.sh` (download → temp Postgres → restore + row counts).
|
||||||
|
|
||||||
|
## Gitea self-hosting migration
|
||||||
|
|
||||||
|
- Migrated repo + CI/CD from GitHub to Gitea Actions (`git.allucanget.biz/zwitschi/linearvoid`): self-hosted runner, Container Registry convention `git.allucanget.biz/zwitschi/linearvoid/<service>:<tag>`, secrets renamed `GITEA_*` → `CI_*`.
|
||||||
|
|
||||||
|
## Streaming pipeline gaps (2026-08-10)
|
||||||
|
|
||||||
|
- Continuous R2 sync via `fsnotify` (`watchAndPublishSegments`, ~7s delay); run-ID + monotonic segment naming (`r-{ISOTIME}/seg-{seq}.m4s`); atomic manifest publication (upload `.tmp` → `CopyObject`); enriched `/health` (run_id, active_item, playlist_revision, last_segment_seq, last_r2_pub, consecutive_failures); player error recovery (5 retries, 2s→32s exponential backoff, reconnecting overlay).
|
||||||
|
- CORS on `media.linearvoid.tv` remains infrastructure-only (Cloudflare R2 config, not code).
|
||||||
|
|
||||||
|
## Bug fixes not in CHANGELOG
|
||||||
|
|
||||||
|
- **2026-08-13 viewer crash**: `index.html` was missing the `#messages` container, so `addMessage` threw `Cannot read properties of null (reading 'appendChild')` on WebSocket open/message. Restored the chat panel structure and made `addMessage` defensive.
|
||||||
|
- **2026-08-15 chat reconnect loop**: after a registered user logged in, the chat panel repeatedly filled with old messages. `connectWebSocket` left the replaced socket's `onclose` handler attached, so each intentional reconnect scheduled another reconnect and every connection re-pulled the room's 50-message history on a 3s loop. Fixed by detaching the old socket's `onclose` before closing and guarding the handler against stale sockets. Regression test added; `npm --prefix frontend test` → 58 tests passing.
|
||||||
|
|
||||||
|
## Chat improvements
|
||||||
|
|
||||||
|
- Manual smoke: viewer join/leave shows a 3s flash (not a chat row); reconnect <5s shows no duplicate; admin chat-log table has no system rows
|
||||||
|
|
||||||
|
## Playout improvements
|
||||||
|
|
||||||
|
- Test in Firefox: live playback, rebuffer/recovery, chat sync; record quirks in `docs/STREAMING.md`
|
||||||
|
- Rebuild + run playout; confirm `playlist.m3u8` lists ~12 segments at 2s each
|
||||||
|
- Manual: verify viewer latency and cache-hit behavior after the change
|
||||||
|
|
||||||
|
## Chat sidebar (2026-08-17)
|
||||||
|
|
||||||
|
- [x] remove search bar from chat sidebar (not needed for single-room)
|
||||||
|
- [x] remove "user" toggle button from chat sidebar (hide for now, add back when user interface is stable)
|
||||||
|
- [x] make login/registration form a modal and only toggle it when user is not logged in and clicks "Login" or "Register"
|
||||||
|
- [x] when user is logged in, do not show login/registration form
|
||||||
|
- [x] make sure each message is only rendered once in the chat log (no duplicates on reconnect or initial load)
|
||||||
|
|
||||||
|
### Plan
|
||||||
|
|
||||||
|
Files: `frontend/index.html`, `frontend/src/main.ts`, `frontend/src/style.css`.
|
||||||
|
|
||||||
|
1. **Remove search bar** (single-room)
|
||||||
|
- `index.html`: delete the `<div id="dm-search-row">` block.
|
||||||
|
- `main.ts`: remove `searchDM()` and the `dm-search-btn` listener; drop the `#dm-search-row` show/hide logic in `switchRoom()` (the `searchRow.hidden = !isDM` lines). Keep `currentDMTarget` only if the "New DM" flow stays.
|
||||||
|
|
||||||
|
2. **Remove "Users" toggle button**
|
||||||
|
- `index.html`: delete the `<button id="users-toggle-btn">` button.
|
||||||
|
- `main.ts`: remove `toggleUserList()` and the `users-toggle-btn` listener; drop the now-unused `usersVisible` variable.
|
||||||
|
|
||||||
|
3. **Login/registration form as a modal**
|
||||||
|
- `index.html`: wrap the `#auth-panel` form in a modal structure (`.auth-modal` + backdrop) with a close button.
|
||||||
|
- `style.css`: add `.auth-modal` / `.auth-modal-backdrop` styles.
|
||||||
|
- `main.ts`: add `openAuthModal()` / `closeAuthModal()`; change the `auth-toggle-btn` handler to open the modal (only when not authenticated) instead of toggling `#auth-panel` inline; wire a close/backdrop-click to `closeAuthModal()`.
|
||||||
|
|
||||||
|
4. **Hide form when logged in**
|
||||||
|
- `main.ts` `updateAuthUI()`: when `authenticated` → hide the "Sign in" button and call `closeAuthModal()`; show the user label + "Sign out" button. When not authenticated → show "Sign in" and hide "Sign out".
|
||||||
|
|
||||||
|
5. **No duplicate messages**
|
||||||
|
- `main.ts`: add `clearMessages()` (empties `#messages`) and call it at the top of `connectWebSocket()` so each (re)connect renders history fresh instead of appending.
|
||||||
|
- Fix the initial double-connect: make the initial `connectWebSocket("main")` wait for `refreshUserSession()` (or connect once with the resolved username) so history isn't pushed twice on load.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
|
||||||
|
|
||||||
|
## Playout / R2 manifest–segment layout mismatch
|
||||||
|
|
||||||
|
**Chosen approach: candidate (a)** — rewrite the live manifest so its media URIs point into the current run-scoped folder before the atomic R2 publish. Go change in `backend/playout/src/playlist.go`. The `linearvoid-media` Worker stays a faithful path→R2 mapper (no serve-time rewrite).
|
||||||
|
|
||||||
|
### Remaining — Validation
|
||||||
|
|
||||||
|
- [ ] Run playout (local or test lab), then:
|
||||||
|
- `curl https://media.linearvoid.tv/hls/playlist.m3u8` → 200, short TTL.
|
||||||
|
- Every URI in the manifest resolves `200` with `Cache-Control: public, max-age=31536000, immutable` (via the `linearvoid-media` Worker).
|
||||||
|
- Confirm manifest refresh + `CF-Cache-Status` progression and no 404 on segment fetch across a run loop.
|
||||||
|
- [ ] Verify slate fallback still serves correctly after the rewrite change.
|
||||||
|
|
||||||
|
> Implementation (candidate a), Go unit tests, and documentation are complete — see [DONE.md](./DONE.md).
|
||||||
|
|
||||||
|
## Frontend Tests
|
||||||
|
|
||||||
|
### User Frontend
|
||||||
|
|
||||||
|
- [ ] Test coverage for all critical frontend components and interactions
|
||||||
|
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||||
|
- [ ] Document test cases and expected outcomes
|
||||||
|
|
||||||
|
### Admin Frontend
|
||||||
|
|
||||||
|
- [ ] Test coverage for all critical admin UI components and interactions
|
||||||
|
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||||
|
- [ ] Document test cases and expected outcomes
|
||||||
|
|
||||||
|
## Backend Integration Tests
|
||||||
|
|
||||||
|
### Cloudflare R2
|
||||||
|
|
||||||
|
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
|
||||||
|
- [ ] Validate cache hit/miss behavior and performance metrics
|
||||||
|
- [ ] Validate edge cache hit/miss behavior with repeat requests — script `scripts/validate-edge-cache.sh` created
|
||||||
@@ -87,10 +87,6 @@ Thumbs.db
|
|||||||
*.tfstate.backup
|
*.tfstate.backup
|
||||||
.terraform/
|
.terraform/
|
||||||
|
|
||||||
# Agent / Tool files
|
|
||||||
.github/agents/
|
|
||||||
.github/instructions/
|
|
||||||
.github/MEMORY.md
|
|
||||||
|
|
||||||
# Temporary architecture artifacts
|
# Temporary architecture artifacts
|
||||||
**/*.tmp
|
**/*.tmp
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"proseWrap": "preserve"
|
||||||
|
}
|
||||||
@@ -2,6 +2,77 @@
|
|||||||
|
|
||||||
<!-- markdownlint-disable MD024 -->
|
<!-- markdownlint-disable MD024 -->
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-09-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Cloudflare media edge worker (`linearvoid-media`)**: new self-contained project under `infrastructure/cloudflare/` — an R2-bound Worker owning `media.linearvoid.tv` that enforces immutable segment caching (`public, max-age=31536000, immutable`) and short-TTL live `.m3u8`, with HLS CORS/OPTIONS, byte-Range, and a `/health` probe. Includes Vitest unit tests + Miniflare/workerd R2 integration tests and production deploy via `.gitea/workflows/workers-media-deploy.yml`. Replaces the ad-hoc `worker.js`.
|
||||||
|
- **Bash script ports**: Linux/macOS equivalents of the Windows ops/load-test scripts (`cache-hit-ratio`, `check-env`, `check-tokens`, loadtest run/summarize); `.ps1` files retained.
|
||||||
|
- **Transient presence flash**: join/leave events show as a 3-second `#chat-flash` instead of permanent chat rows.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Cloudflare Workers hosting**: frontend moved from Cloudflare Pages to the `linearvoid-frontend` static-assets Worker; docs now describe `linearvoid-frontend` (SPA) and `linearvoid-media` (edge HLS), correcting stale `worker.js` and R2 custom-domain wording.
|
||||||
|
- **Docs de-Windows'd & condensed**: bash-first guides for `SETUP`, `DEPLOYMENT`, `API`, `MONITORING`, `STREAMING`, `COOLIFY`, incident runbook, scaling playbook, architecture README, and Prometheus metrics inventory.
|
||||||
|
- **Compose port de-collision**: playout on host port `8081`, nginx moved to `8082` (`NGINX_PORT` default updated) to avoid Coolify-reserved ports.
|
||||||
|
- **Playout**: R2 retention now by run count + byte cap (`R2_KEEP_RUNS` default 2, `R2_MAX_RETENTION_BYTES` default 10 GiB, new gauges) replacing the 24h time-based cleanup; default `HLS_SEGMENT_DURATION` 2s / `HLS_WINDOW_SIZE` 12; deduplicated, concurrent startup validation.
|
||||||
|
- **Chat/UX**: 5s reconnect grace window (no join/leave spam); `system` messages no longer persisted; smaller `.message-text`; sidebar cleanup (removed DM search + Users toggle, login/register now a modal).
|
||||||
|
- **Auth**: viewer session cookie uses `SameSite=None; Secure` over HTTPS (cross-site SPA) and `Lax` over plain HTTP.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Run-scoped R2 manifest URIs (playout)**: `publishManifest` now rewrites bare relative media URIs to the current run folder (`r-{runID}/…`) via `rewriteManifestToRun` before the atomic `.tmp` → `CopyObject` publish, so the live `hls/playlist.m3u8` references resolve against the run-scoped segment keys on `media.linearvoid.tv`. Skipped in slate mode. (Live validation pending — see `TODO.md`.)
|
||||||
|
- **Cross-origin auth**: login/register API calls now prefix `VITE_PUBLIC_APP_URL` so the Workers-served SPA reaches the app origin.
|
||||||
|
- **Duplicate chat history**: `connectWebSocket` clears messages on each (re)connect and the initial connect waits for session resolution.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- WebSocket (`TestHub_Broadcast_SystemNotPersisted`, `TestHub_ReconnectGracePeriod`), handlers (`TestHandleAdminChatLog_ExcludesSystem`), frontend presence flash.
|
||||||
|
- Playout: `TestCleanupOldRuns(_SizeCap)`, `TestR2KeepRuns`, `TestR2MaxRetentionBytes`, `TestR2RunFromKey`, `TestValidateWorkersConfig`, `TestValidatePlaylist_Dedup`.
|
||||||
|
- Playout (run-scoped manifest rewrite): `TestRewriteManifestToRun`, `TestRewriteManifestToRun_AlreadyPrefixedUnchanged`, `TestRewriteManifestToRun_HandlesTSAndComments`, `TestRewriteManifestToRun_EmptyRunID`, `TestIsMediaURI`, `TestPublishManifest_RewritesToRunScopedURIs`, `TestPublishManifest_SlateNotRewritten`.
|
||||||
|
- Cloudflare media worker: Vitest unit (`cacheControlForPath`, `contentTypeForPath`, `parseByteRange`) + Miniflare R2 integration (immutable segment, short-TTL manifest, Range `206`, `404`, OPTIONS, method guard).
|
||||||
|
|
||||||
|
## [1.0.13] - 2026-08-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Automatic media normalization on upload**: Chat backend auto-transcodes non-conforming uploads. New `media.NormalizeVideo` wraps ffmpeg with the Linear Void profile (H.264/AAC, 30fps, 2s keyframes, yuv420p, 48kHz stereo), re-verifies output with ffprobe, and writes `*.normalized.mp4`. Media records gain `normalizing` / `normalization_failed` statuses; `POST /api/admin/media/{id}/normalize` re-triggers manually. Admin UI shows a "Normalize" action with status polling.
|
||||||
|
- **R2 segment retention**: Playout `cleanupOldSegments` deletes `hls/r-*/*` objects older than `R2_SEGMENT_RETENTION_HOURS` (default 24h), run hourly + at startup, never touching the live manifest. Exposes `linearvoid_playout_r2_cleanup_objects_total` metric. `.env.example` adds `R2_SEGMENT_RETENTION_HOURS`.
|
||||||
|
- **Playout loop metrics**: `linearvoid_playout_loops_total` (clean completions) and `linearvoid_playout_run_duration_seconds` (last run duration).
|
||||||
|
- **Runtime configuration store**: Thread-safe, JSON-persisted runtime config (`HLS_SEGMENT_DURATION`, `HLS_WINDOW_SIZE`, `MATOMO_URL`, `MATOMO_SITE_ID`) with typed getters/setters + validation, editable via `GET/PUT /api/admin/config`. Playout reads the shared `config.json` as env fallback.
|
||||||
|
- **Matomo analytics**: Viewer pageview snippet in `index.html` driven by the public `GET /api/matomo` endpoint.
|
||||||
|
- **Chat user accounts**: Register/login/logout (`POST /api/auth/register`, `/login`, `/logout`, `GET /api/auth/session`), bcrypt password hashing, `linearvoid_user_session` cookie, and a viewer sign-in UI.
|
||||||
|
- **Private DM history & search**: `GET /api/dm/history` (paginated) and `GET /api/dm/search`, with a DM search UI in the viewer.
|
||||||
|
- **Chat UI polish**: Typing indicator, user-list toggle, media status badges, and an "Add to playlist" action gated to `ready` media.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Playout seamless looping**: `main()` now only resets HLS output when the playlist changed (SHA-256 `playlistRevision`), on crash/slate recovery, or first run; otherwise it continues segment numbering via `-start_number` and prunes stale segments. Eliminates the viewer gap on clean playlist loops.
|
||||||
|
- **Single playout entrypoint**: `start-playout.sh` now runs `/app/playlist-manager -validate` then `exec`s the Go binary, removing the duplicate shell ffmpeg loop. Added a `-validate` mode to `main()`.
|
||||||
|
- **Admin media library**: Shows status badges (`ready`/`needs_normalization`/`normalizing`/`failed`) and only offers "Add to playlist" for `ready` media; playlist entries show per-item status badges.
|
||||||
|
- **Prometheus TSDB retention**: The `prometheus` service now sets `--storage.tsdb.retention.time=30d` and `--storage.tsdb.retention.size=15GB` (in both `docker-compose.yml` and `docker-compose.prod.yml`), so metrics older than 30 days are deleted.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Media: `TestNormalizationArgs`, `TestNormalizeVideo_FFmpegError`.
|
||||||
|
- Handlers: `TestNormalizeMediaRecord_Success`, `TestNormalizeMediaRecord_Failure`, `TestHandleAdminMediaByID_Normalize`.
|
||||||
|
- Playout: `TestCleanupOldSegments`, `TestR2SegmentRetentionHours`, `TestPlaylistFingerprint`, `TestHLSNextStartNumber`, `TestLoadSharedHLSConfig`.
|
||||||
|
- Config: `runtime_test.go` (get/set, persist/reload, env fallback, validation).
|
||||||
|
- Auth: `TestUserSessionStore`.
|
||||||
|
- Handlers: config GET/PUT + validation, DM history/search, user register/login/session.
|
||||||
|
|
||||||
|
## [1.0.12] - 2026-08-11
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Task tracking consolidated**: Removed `DEVELOPMENT.md` and moved all development tasks to `.github/instructions/BACKLOG.md` as the single source of truth. `TODO.md` updated to reference `BACKLOG.md` and `DONE.md`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Playout R2 endpoint validation**: `initR2Client()` now validates all required R2 environment variables (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`) before creating the S3 client. Previously, an empty `R2_ENDPOINT` caused cryptic `Custom endpoint '' was not a valid URI` errors on every segment upload attempt. Playout now gracefully degrades when R2 is not configured — local HLS via nginx continues to work.
|
||||||
|
- **CD workflow missing R2 secrets**: `.gitea/workflows/cd.yml` "Prepare environment" step now injects all R2\_ variables (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, plus admin variants) from Gitea secrets. Previously the generated `.env` was missing R2 entirely, causing the playout container to start with an empty endpoint.
|
||||||
|
- **Deployment docs**: `docs/DEPLOYMENT.md` now includes explicit R2 and Cloudflare variable lists plus a "Gitea Secrets" table documenting all 15 required secrets.
|
||||||
|
|
||||||
## [1.0.11] - 2026-08-10
|
## [1.0.11] - 2026-08-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Copyright © 2026 linearvoid.tv (Corey Pellizzi, Georg Sinn)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
**Repository**: [git.allucanget.biz/zwitschi/linearvoid](https://git.allucanget.biz/zwitschi/linearvoid)
|
**Repository**: [git.allucanget.biz/zwitschi/linearvoid](https://git.allucanget.biz/zwitschi/linearvoid)
|
||||||
|
|
||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||||
[](LICENSE)
|
|
||||||
[](https://git.allucanget.biz)
|
[](https://git.allucanget.biz)
|
||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
[](LICENSE)
|
||||||
|
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -46,69 +46,180 @@ 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.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
linearvoid/
|
linearvoid/
|
||||||
├── frontend/ # Static Single-Page Application (HTML/CSS/JS)
|
├── docs/ # see Documentation section below
|
||||||
│ ├── src/
|
├── backend/ # see Backend section below
|
||||||
│ │ ├── components/ # Video player, chat sidebar, now-playing overlay
|
├── frontend/ # see Frontend section below
|
||||||
│ │ ├── js/ # hls.js integration, WebSocket client, UI logic
|
├── infrastructure/ # see Infrastructure section below
|
||||||
│ │ └── styles/ # Tailwind / custom CSS
|
├── monitoring/ # see Monitoring section below
|
||||||
│ ├── index.html
|
├── scripts/ # see Scripts section below
|
||||||
│ └── package.json # Build tools (Vite recommended)
|
├── .editorconfig # EditorConfig for consistent coding styles
|
||||||
├── backend/
|
├── .env.example # Example environment variables for local development
|
||||||
│ ├── playout/ # Continuous HLS generation from playlist
|
├── .gitignore # Ignore node_modules, build artifacts, etc.
|
||||||
│ │ ├── src/ # FFmpeg orchestration, playlist manager
|
├── .prettierignore # Ignore files for Prettier formatting
|
||||||
│ │ ├── config/
|
├── CHANGELOG.md # Release notes and version history
|
||||||
│ │ └── Dockerfile
|
├── CONTRIBUTING.md # Contribution guidelines
|
||||||
│ ├── chat/ # Real-time WebSocket service
|
├── docker-compose.prod.yml # Production deployment (Hetzner + Cloudflare)
|
||||||
│ │ ├── src/ # Rooms, DMs, moderation, presence (Node/Go)
|
|
||||||
│ │ ├── config/
|
|
||||||
│ │ └── Dockerfile
|
|
||||||
│ └── shared/ # Common utilities, types, normalization helpers
|
|
||||||
├── infrastructure/ # Deployment, IaC, Cloudflare configs
|
|
||||||
│ ├── docker-compose.yml # Local/dev orchestration
|
|
||||||
│ ├── nginx/ # Proxy, static serving, HLS delivery
|
|
||||||
│ ├── cloudflare/ # Cache rule examples, Workers
|
|
||||||
│ └── terraform/ # (optional) provisioning
|
|
||||||
├── scripts/ # Automation scripts
|
|
||||||
│ ├── normalize-video.sh # Pre-process MP4s (codec, keyframes, CRF)
|
|
||||||
│ ├── sync-to-r2.sh # Upload segments/manifests to R2
|
|
||||||
│ └── backup.sh
|
|
||||||
├── monitoring/ # Dashboards, alerts, logging
|
|
||||||
│ ├── grafana/
|
|
||||||
│ └── prometheus/
|
|
||||||
├── docs/
|
|
||||||
│ └── architecture/
|
|
||||||
│ ├── README.md # arc42 template (fully populated)
|
|
||||||
│ └── adr/
|
|
||||||
│ ├── ADR-001.md # Consolidated decision record
|
|
||||||
│ ├── ADR-002-module-structure.md
|
|
||||||
│ ├── ADR-003-tech-stack.md
|
|
||||||
│ ├── ADR-004-admin-video.md
|
|
||||||
│ ├── ADR-005-admin-moderation-analytics.md
|
|
||||||
│ ├── ADR-006-data-storage.md
|
|
||||||
│ └── ADR-007-redis-usage.md
|
|
||||||
├── .env.example
|
|
||||||
├── .gitignore
|
|
||||||
├── docker-compose.yml # Top-level for dev (playout + chat + nginx)
|
├── docker-compose.yml # Top-level for dev (playout + chat + nginx)
|
||||||
├── README.md
|
└── README.md # This file
|
||||||
└── LICENSE
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Folder Explanations
|
### Documentation
|
||||||
|
|
||||||
- **`frontend/`**: The viewer experience — minimal, modern SPA with Tailwind + Alpine.js (or vanilla). Uses `hls.js` for playback and a WebSocket client for chat.
|
- `docs/`: Folder containing all documentation for the project, including architecture, ADRs, operational procedures, and setup guides.
|
||||||
- **`backend/playout/`**: Core linear engine. FFmpeg consumes a normalized playlist and outputs continuous HLS. Includes normalization validation.
|
- `docs/architecture/`: Architecture documentation, including high-level system architecture, component interactions, and decision records (ADRs).
|
||||||
- **`backend/chat/`**: Independent WebSocket service (Node.js/Socket.io or Go). Handles public rooms, private DMs, presence (Redis), persistence (PostgreSQL/SQLite), rate limiting, and moderation.
|
|
||||||
- **`backend/shared/`**: Reusable code (types, utils, video validation).
|
|
||||||
- **`infrastructure/`**: Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
|
||||||
- **`scripts/`**: One-off and CI-friendly scripts for video preparation, R2 sync, backups, and playlist management.
|
|
||||||
- **`monitoring/`**: Grafana dashboards, Prometheus rules, logging configuration.
|
|
||||||
- **`docs/architecture/`**: Complete arc42 documentation + ADRs 001-007 (the single source of truth for all architectural decisions).
|
|
||||||
|
|
||||||
This structure directly reflects the building blocks, runtime flows, deployment view, and cross-cutting concepts defined in the arc42 documentation.
|
see [README.md](docs/README.md) for full architecture, ADRs, and operational documentation.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- `backend/playout/`: Core linear engine. FFmpeg consumes a normalized playlist and outputs continuous HLS. Includes normalization validation.
|
||||||
|
- `backend/chat/`: Independent WebSocket service (Node.js/Socket.io or Go). Handles public rooms, private DMs, presence (Redis), persistence (PostgreSQL/SQLite), rate limiting, and moderation.
|
||||||
|
- `backend/shared/`: Reusable code (types, utils, video validation).
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
├── chat/ # WebSocket chat service (Node.js or Go)
|
||||||
|
│ ├── src/ # Chat service source code (rooms, DMs, moderation)
|
||||||
|
| | ├── auth/ # Admin authentication and session management
|
||||||
|
| | | ├── auth_test.go # Unit tests for authentication
|
||||||
|
| | | └── auth.go # Authentication logic (login, session validation)
|
||||||
|
| | ├── config/ # Configuration files for chat service
|
||||||
|
| | | ├── config_test.go # Unit tests for configuration
|
||||||
|
| | | └── config.go # Configuration loading and validation
|
||||||
|
| | ├── db/ # Database access and models
|
||||||
|
| | | ├── db_test.go # Unit tests for database access
|
||||||
|
| | | ├── db.go # Database connection and query logic
|
||||||
|
| | | ├── models.go # Database models (users, messages, rooms)
|
||||||
|
| | | └── redis.go # Redis connection and presence management
|
||||||
|
| | ├── handlers/ # WebSocket message handlers
|
||||||
|
| | | ├── handlers_test.go # Unit tests for WebSocket message handlers
|
||||||
|
| | | └── handlers.go # WebSocket message handling logic
|
||||||
|
| | ├── media/ # Media-related utilities (e.g., video validation)
|
||||||
|
| | | ├── media_test.go # Unit tests for media-related utilities
|
||||||
|
| | | └── media.go # Media-related utility functions
|
||||||
|
| | ├── metrics/ # Prometheus metrics endpoints
|
||||||
|
| | | ├── metrics_test.go # Unit tests for Prometheus metrics
|
||||||
|
| | | └── metrics.go # Prometheus metrics collection and exposition
|
||||||
|
| | ├── ratelimit/ # Rate limiting logic (Redis-based)
|
||||||
|
| | | ├── ratelimit_test.go # Unit tests for rate limiting
|
||||||
|
| | | └── ratelimit.go # Rate limiting logic using Redis
|
||||||
|
| | ├── ws/ # WebSocket handlers
|
||||||
|
| | | ├── ws_test.go # Unit tests for WebSocket handlers
|
||||||
|
| | | └── ws.go # WebSocket connection and message handling
|
||||||
|
| | └── main.go # Entry point for chat service
|
||||||
|
| ├── Dockerfile # Dockerfile for chat service
|
||||||
|
| ├── go.mod # Go module file for chat service
|
||||||
|
| └── go.sum # Go checksum file for chat service
|
||||||
|
└── playout/ # Continuous HLS generation from playlist (Go)
|
||||||
|
├── demodata/ # Sample media and playlist files for testing
|
||||||
|
| ├── media/ # Sample media files for testing
|
||||||
|
| └── playlist/ # Sample playlist files for testing
|
||||||
|
├── src/ # Playout service source code (FFmpeg orchestration)
|
||||||
|
| ├── go.mod # Go module file for playout service
|
||||||
|
| ├── go.sum # Go checksum file for playout service
|
||||||
|
| ├── playlist_test.go # Unit tests for playlist management
|
||||||
|
| └── playlist.go # Playlist management logic
|
||||||
|
└── testdata/ # Test data for playout service (sample playlists, media)
|
||||||
|
| ├── comments_only.txt # Sample playlist with comments only
|
||||||
|
| ├── empty_playlist.txt # Sample empty playlist
|
||||||
|
| ├── invalid_24fps.mp4 # Sample invalid 24fps video for testing
|
||||||
|
| ├── invalid_h265.mp4 # Sample invalid H.265 video for testing
|
||||||
|
| ├── mixed_valid_invalid.txt # Sample playlist with mixed valid and invalid entries
|
||||||
|
| ├── valid_720p30.mp4 # Sample valid 720p30 video for testing
|
||||||
|
| └── valid_playlist.txt # Sample valid playlist for testing
|
||||||
|
├── Dockerfile # Dockerfile for the playout service
|
||||||
|
└── start-playout.sh # Script to start the playout service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
The viewer experience — minimal, modern SPA with custom CSS + vanilla JS. Uses `hls.js` for playback and a WebSocket client for chat.
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/
|
||||||
|
├── admin/ # Admin SPA (playlist management, moderation, analytics)
|
||||||
|
│ ├── login/ # Login page for admin authentication
|
||||||
|
| | └── index.html # Admin login page
|
||||||
|
| └── index.html # Admin SPA
|
||||||
|
├── public/ # Static assets (favicon, images, etc.)
|
||||||
|
│ ├── admin/ # Admin static assets
|
||||||
|
| | ├── analytics.html # Analytics dashboard page
|
||||||
|
| | ├── chat-log.html # Chat log viewer page
|
||||||
|
| | ├── dashboard.html # Admin dashboard page
|
||||||
|
| | ├── moderation.html # User moderation page
|
||||||
|
| | ├── playlist.html # Playlist management page
|
||||||
|
| | ├── schedule.html # Schedule overrides page
|
||||||
|
| | ├── system.html # System configuration page
|
||||||
|
| | ├── upload.html # Video upload page
|
||||||
|
| | └── users.html # User management page
|
||||||
|
│ ├── favicon.ico # Favicon for the SPA
|
||||||
|
| └── icons.svg # SVG icons for the SPA
|
||||||
|
├── src/ # Source code for the SPA
|
||||||
|
│ ├── assets/ # Images, fonts, and other static assets
|
||||||
|
| | ├── hero.png # Hero image for the SPA
|
||||||
|
| | ├── typescript.svg # TypeScript logo for the SPA
|
||||||
|
| | └── vite.svg # Vite logo for the SPA
|
||||||
|
│ ├── admin.test.ts # Admin frontend tests (Vitest)
|
||||||
|
│ ├── admin.ts # Admin SPA logic (playlist, moderation, analytics)
|
||||||
|
│ ├── counter.ts # Counter component for testing
|
||||||
|
│ ├── main.test.ts # Main frontend tests (Vitest)
|
||||||
|
│ ├── main.ts # Main SPA logic (hls.js player, WebSocket chat)
|
||||||
|
│ └── style.css # Custom CSS for the SPA
|
||||||
|
├── .gitignore # Ignore node_modules, build artifacts, etc.
|
||||||
|
├── index.html # Main SPA entry point
|
||||||
|
├── package-lock.json # Lockfile for npm dependencies
|
||||||
|
├── package.json # Build tools (Vite recommended)
|
||||||
|
├── tsconfig.json # TypeScript configuration
|
||||||
|
├── vite.config.ts # Vite configuration
|
||||||
|
└── vitest.config.ts # Vitest configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
||||||
|
|
||||||
|
- Cloudflare
|
||||||
|
- [linearvoid-media worker](infrastructure/cloudflare/) — R2-bound edge Worker serving HLS on `media.linearvoid.tv` (immutable segment caching, CORS, Range)
|
||||||
|
- [linearvoid-frontend worker](frontend/wrangler.toml) — serves the static SPA on `linearvoid.tv`
|
||||||
|
- Nginx
|
||||||
|
- [Dockerfile](infrastructure/nginx/Dockerfile) — builds Nginx with custom config
|
||||||
|
- [nginx.conf](infrastructure/nginx/nginx.conf) — reverse proxy, static SPA, HLS delivery
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
Grafana dashboards, Prometheus rules, logging configuration.
|
||||||
|
|
||||||
|
- Grafana
|
||||||
|
- dashboards
|
||||||
|
- [dashboard.json](monitoring//grafana/dashboards/dashboard.json) — operational dashboards for playout, chat, and Redis
|
||||||
|
- [dashboard.yml](monitoring//grafana/dashboards/dashboard.yml) — operational dashboards for playout, chat, and Redis
|
||||||
|
- datasources
|
||||||
|
- [datasource.yml](monitoring/grafana/datasources/datasource.yml) — Prometheus datasource configuration
|
||||||
|
- [dashboard.json](monitoring/grafana/dashboard.json) — operational dashboards for playout, chat, and Redis
|
||||||
|
- Prometheus
|
||||||
|
- [alerts.yml](monitoring/prometheus/alerts.yml) — alerting rules for playout, chat, Redis, and system health
|
||||||
|
- [prometheus.yml](monitoring/prometheus/prometheus.yml) — scrape targets for playout, chat, Redis, and system metrics
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
One-off and CI-friendly scripts for video preparation, R2 sync, backups, and playlist management.
|
||||||
|
|
||||||
|
- loadtest/
|
||||||
|
- [admin-api.js](scripts/loadtest/admin-api.js) — admin API load test script
|
||||||
|
- [run-loadtests.sh](scripts/loadtest/run-loadtests.sh) — bash script to run ws and/or admin load tests
|
||||||
|
- [summarize-artillery.sh](scripts/loadtest/summarize-artillery.sh) — summarize Artillery JSON results
|
||||||
|
- [summarize-k6.sh](scripts/loadtest/summarize-k6.sh) — summarize k6 JSON results
|
||||||
|
- [ws-chat.yml](scripts/loadtest/ws-chat.yml) — WebSocket chat load test configuration
|
||||||
|
|
||||||
|
PowerShell equivalents (`*.ps1`) of the three load-test scripts are kept alongside them.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -154,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,10 @@ 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.
|
||||||
@@ -54,6 +58,8 @@ func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax in
|
|||||||
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,11 +201,15 @@ 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)
|
||||||
|
if !rejoined {
|
||||||
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()})
|
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)
|
||||||
h.PushChatHistory(conn, room, 50)
|
h.PushChatHistory(conn, room, 50)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ COPY src/go.mod src/go.sum ./
|
|||||||
|
|
||||||
# Download dependencies and build
|
# Download dependencies and build
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /playlist-manager ./src/playlist.go
|
RUN CGO_ENABLED=0 GOOS=linux GOFLAGS=-mod=mod go build -o /playlist-manager ./src/playlist.go
|
||||||
|
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2 v1.43.4
|
github.com/aws/aws-sdk-go-v2 v1.43.4
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.35
|
github.com/aws/aws-sdk-go-v2/config v1.32.35
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -24,4 +25,5 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect
|
||||||
github.com/aws/smithy-go v1.27.6 // indirect
|
github.com/aws/smithy-go v1.27.6 // indirect
|
||||||
|
golang.org/x/sys v0.13.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -34,3 +34,7 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTw
|
|||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc=
|
||||||
github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA=
|
github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA=
|
||||||
github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||||
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
+775
-62
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleManifest = `#EXTM3U
|
||||||
|
#EXT-X-VERSION:7
|
||||||
|
#EXT-X-TARGETDURATION:2
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:0
|
||||||
|
#EXT-X-INDEPENDENT-SEGMENTS
|
||||||
|
#EXT-X-MAP:URI="init.mp4"
|
||||||
|
#EXTINF:2.000000,
|
||||||
|
seg-000000.m4s
|
||||||
|
#EXTINF:2.000000,
|
||||||
|
seg-000001.m4s
|
||||||
|
#EXT-X-ENDLIST
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun(t *testing.T) {
|
||||||
|
runID := "r-20260903T120000Z"
|
||||||
|
out := string(rewriteManifestToRun(runID, []byte(sampleManifest)))
|
||||||
|
|
||||||
|
wants := []string{
|
||||||
|
`#EXT-X-MAP:URI="r-20260903T120000Z/init.mp4"`,
|
||||||
|
"r-20260903T120000Z/seg-000000.m4s",
|
||||||
|
"r-20260903T120000Z/seg-000001.m4s",
|
||||||
|
"#EXTM3U",
|
||||||
|
"#EXT-X-VERSION:7",
|
||||||
|
"#EXT-X-TARGETDURATION:2",
|
||||||
|
"#EXT-X-MEDIA-SEQUENCE:0",
|
||||||
|
"#EXT-X-INDEPENDENT-SEGMENTS",
|
||||||
|
"#EXT-X-ENDLIST",
|
||||||
|
"#EXTINF:2.000000,",
|
||||||
|
}
|
||||||
|
for _, want := range wants {
|
||||||
|
if !contains(out, want) {
|
||||||
|
t.Errorf("output missing %q\noutput:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media URIs must be prefixed (no bare segments left).
|
||||||
|
for _, bare := range []string{"\nseg-000000.m4s\n", "\nseg-000001.m4s\n", `URI="init.mp4"`} {
|
||||||
|
if contains(out, bare) {
|
||||||
|
t.Errorf("output still contains bare %q\noutput:\n%s", bare, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun_AlreadyPrefixedUnchanged(t *testing.T) {
|
||||||
|
manifest := "#EXTM3U\n#EXT-X-MAP:URI=\"r-old/init.mp4\"\nseg-000000.m4s\nr-old/seg-000001.m4s\n#EXT-X-ENDLIST\n"
|
||||||
|
out := string(rewriteManifestToRun("r-new", []byte(manifest)))
|
||||||
|
|
||||||
|
if !contains(out, `#EXT-X-MAP:URI="r-old/init.mp4"`) {
|
||||||
|
t.Errorf("EXT-X-MAP already-prefixed URI was rewritten: %s", out)
|
||||||
|
}
|
||||||
|
// A URI line that already contains a path is left alone.
|
||||||
|
if !contains(out, "r-old/seg-000001.m4s") {
|
||||||
|
t.Errorf("already-prefixed segment URI was changed:\n%s", out)
|
||||||
|
}
|
||||||
|
// Bare URI still gets the run prefix.
|
||||||
|
if !contains(out, "r-new/seg-000000.m4s") {
|
||||||
|
t.Errorf("bare segment not prefixed:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun_HandlesTSAndComments(t *testing.T) {
|
||||||
|
manifest := "#EXTM3U\n# a comment\nvideo-1.ts\ninit.mp4\n#EXT-X-ENDLIST\n"
|
||||||
|
out := string(rewriteManifestToRun("r-x", []byte(manifest)))
|
||||||
|
|
||||||
|
for _, want := range []string{"r-x/video-1.ts", "r-x/init.mp4", "# a comment", "#EXTM3U", "#EXT-X-ENDLIST"} {
|
||||||
|
if !contains(out, want) {
|
||||||
|
t.Errorf("output missing %q\noutput:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteManifestToRun_EmptyRunID(t *testing.T) {
|
||||||
|
out := rewriteManifestToRun("", []byte(sampleManifest))
|
||||||
|
if string(out) != sampleManifest {
|
||||||
|
t.Errorf("empty runID should return input unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsMediaURI(t *testing.T) {
|
||||||
|
for _, ok := range []string{"seg-000001.m4s", "init.mp4", "a.ts", "x.m4a"} {
|
||||||
|
if !isMediaURI(ok) {
|
||||||
|
t.Errorf("isMediaURI(%q) = false, want true", ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, notOK := range []string{"playlist.m3u8", "readme.txt", "seg-000001.m4s?x=1"} {
|
||||||
|
// Note: query strings are stripped; '?x=1' on m4s should still be media.
|
||||||
|
if notOK == "seg-000001.m4s?x=1" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isMediaURI(notOK) {
|
||||||
|
t.Errorf("isMediaURI(%q) = true, want false", notOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isMediaURI("seg-000001.m4s?token=abc") {
|
||||||
|
t.Errorf("isMediaURI with query should strip query and be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishManifest_RewritesToRunScopedURIs(t *testing.T) {
|
||||||
|
prevClient, prevBucket := r2Client, bucket
|
||||||
|
prevRunID, prevMode := runID, playoutMode
|
||||||
|
prevPut, prevCopy := putObject, copyObject
|
||||||
|
defer func() {
|
||||||
|
r2Client, bucket = prevClient, prevBucket
|
||||||
|
runID, playoutMode = prevRunID, prevMode
|
||||||
|
putObject, copyObject = prevPut, prevCopy
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mp := filepath.Join(tmpDir, "playlist.m3u8")
|
||||||
|
if err := os.WriteFile(mp, []byte(sampleManifest), 0o644); err != nil {
|
||||||
|
t.Fatalf("write manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
runID = "r-20260903T120000Z"
|
||||||
|
playoutMode = "live"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
var gotKey, gotCopySource, gotCopyKey string
|
||||||
|
var gotBody []byte
|
||||||
|
putObject = func(_ *s3.Client, in *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
gotKey = aws.ToString(in.Key)
|
||||||
|
b, err := io.ReadAll(in.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gotBody = b
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
copyObject = func(_ *s3.Client, in *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||||
|
gotCopySource = aws.ToString(in.CopySource)
|
||||||
|
gotCopyKey = aws.ToString(in.Key)
|
||||||
|
return &s3.CopyObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := publishManifest(mp); err != nil {
|
||||||
|
t.Fatalf("publishManifest error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotKey != "hls/playlist.m3u8.tmp" {
|
||||||
|
t.Errorf("temp upload key = %q, want hls/playlist.m3u8.tmp", gotKey)
|
||||||
|
}
|
||||||
|
if gotCopySource != "linearvoid-media/hls/playlist.m3u8.tmp" {
|
||||||
|
t.Errorf("CopySource = %q, want linearvoid-media/hls/playlist.m3u8.tmp", gotCopySource)
|
||||||
|
}
|
||||||
|
if gotCopyKey != "hls/playlist.m3u8" {
|
||||||
|
t.Errorf("final key = %q, want hls/playlist.m3u8", gotCopyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := string(gotBody)
|
||||||
|
for _, want := range []string{
|
||||||
|
"r-20260903T120000Z/seg-000000.m4s",
|
||||||
|
"r-20260903T120000Z/seg-000001.m4s",
|
||||||
|
`#EXT-X-MAP:URI="r-20260903T120000Z/init.mp4"`,
|
||||||
|
} {
|
||||||
|
if !contains(body, want) {
|
||||||
|
t.Errorf("published manifest missing %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bare := range []string{"\nseg-000000.m4s\n", "\nseg-000001.m4s\n"} {
|
||||||
|
if contains(body, bare) {
|
||||||
|
t.Errorf("published manifest still contains bare %q:\n%s", bare, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishManifest_SlateNotRewritten(t *testing.T) {
|
||||||
|
prevClient, prevBucket := r2Client, bucket
|
||||||
|
prevRunID, prevMode := runID, playoutMode
|
||||||
|
prevPut, prevCopy := putObject, copyObject
|
||||||
|
defer func() {
|
||||||
|
r2Client, bucket = prevClient, prevBucket
|
||||||
|
runID, playoutMode = prevRunID, prevMode
|
||||||
|
putObject, copyObject = prevPut, prevCopy
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mp := filepath.Join(tmpDir, "playlist.m3u8")
|
||||||
|
if err := os.WriteFile(mp, []byte(sampleManifest), 0o644); err != nil {
|
||||||
|
t.Fatalf("write manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
runID = "r-20260903T120000Z"
|
||||||
|
playoutMode = "slate"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
var gotBody []byte
|
||||||
|
putObject = func(_ *s3.Client, in *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
b, _ := io.ReadAll(in.Body)
|
||||||
|
gotBody = b
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
copyObject = func(_ *s3.Client, _ *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||||
|
return &s3.CopyObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := publishManifest(mp); err != nil {
|
||||||
|
t.Fatalf("publishManifest error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(gotBody) != sampleManifest {
|
||||||
|
t.Errorf("slate manifest should be published unmodified; got:\n%s", string(gotBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
@@ -241,11 +244,15 @@ func TestHealthHandler(t *testing.T) {
|
|||||||
// Save and restore globals
|
// Save and restore globals
|
||||||
prevMode := playoutMode
|
prevMode := playoutMode
|
||||||
prevCount := restartCount
|
prevCount := restartCount
|
||||||
|
prevRunID := runID
|
||||||
playoutMode = "live"
|
playoutMode = "live"
|
||||||
restartCount = 3
|
restartCount = 3
|
||||||
|
runID = "r-20260810T120000Z"
|
||||||
|
lastR2PubTime = time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
playoutMode = prevMode
|
playoutMode = prevMode
|
||||||
restartCount = prevCount
|
restartCount = prevCount
|
||||||
|
runID = prevRunID
|
||||||
}()
|
}()
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/health", nil)
|
req := httptest.NewRequest("GET", "/health", nil)
|
||||||
@@ -257,21 +264,30 @@ func TestHealthHandler(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
if !strings.Contains(body, `"healthy"`) {
|
for _, want := range []string{
|
||||||
t.Errorf("body missing healthy: %s", body)
|
`"status":"healthy"`,
|
||||||
|
`"service":"playout"`,
|
||||||
|
`"mode":"live"`,
|
||||||
|
`"crash_count":3`,
|
||||||
|
`"run_id":"r-20260810T120000Z"`,
|
||||||
|
`"last_segment_seq"`,
|
||||||
|
`"consecutive_failures"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q: %s", want, body)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, `"playout"`) {
|
|
||||||
t.Errorf("body missing service: %s", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, `"restarts":3`) {
|
|
||||||
t.Errorf("body missing restarts: %s", body)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthHandler_Degraded(t *testing.T) {
|
func TestHealthHandler_Degraded(t *testing.T) {
|
||||||
prevMode := playoutMode
|
prevMode := playoutMode
|
||||||
|
prevR2Pub := lastR2PubTime
|
||||||
playoutMode = "slate"
|
playoutMode = "slate"
|
||||||
defer func() { playoutMode = prevMode }()
|
lastR2PubTime = time.Now() // fresh R2 pub = not unhealthy
|
||||||
|
defer func() {
|
||||||
|
playoutMode = prevMode
|
||||||
|
lastR2PubTime = prevR2Pub
|
||||||
|
}()
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/health", nil)
|
req := httptest.NewRequest("GET", "/health", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -286,6 +302,26 @@ func TestHealthHandler_Degraded(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHealthHandler_Unhealthy(t *testing.T) {
|
||||||
|
prevMode := playoutMode
|
||||||
|
prevR2Pub := lastR2PubTime
|
||||||
|
playoutMode = "live"
|
||||||
|
lastR2PubTime = time.Now().Add(-30 * time.Second) // stale R2 = unhealthy
|
||||||
|
defer func() {
|
||||||
|
playoutMode = prevMode
|
||||||
|
lastR2PubTime = prevR2Pub
|
||||||
|
}()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/health", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
HealthHandler(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, `"unhealthy"`) {
|
||||||
|
t.Errorf("body missing unhealthy (stale R2 pub should trigger unhealthy): %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestShouldEscalateToSlate(t *testing.T) {
|
func TestShouldEscalateToSlate(t *testing.T) {
|
||||||
// Save globals
|
// Save globals
|
||||||
prevCount := restartCount
|
prevCount := restartCount
|
||||||
@@ -429,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
|
||||||
@@ -462,15 +546,15 @@ func TestSyncToR2(t *testing.T) {
|
|||||||
return &s3.PutObjectOutput{}, nil
|
return &s3.PutObjectOutput{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := SyncToR2(localPath, "live/segment.m4s"); err != nil {
|
if err := SyncToR2(localPath, "hls/segment.m4s"); err != nil {
|
||||||
t.Fatalf("SyncToR2 error: %v", err)
|
t.Fatalf("SyncToR2 error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if gotBucket != "linearvoid-media" {
|
if gotBucket != "linearvoid-media" {
|
||||||
t.Fatalf("bucket = %q, want %q", gotBucket, "linearvoid-media")
|
t.Fatalf("bucket = %q, want %q", gotBucket, "linearvoid-media")
|
||||||
}
|
}
|
||||||
if gotKey != "live/segment.m4s" {
|
if gotKey != "hls/segment.m4s" {
|
||||||
t.Fatalf("key = %q, want %q", gotKey, "live/segment.m4s")
|
t.Fatalf("key = %q, want %q", gotKey, "hls/segment.m4s")
|
||||||
}
|
}
|
||||||
if string(gotBody) != "segment-bytes" {
|
if string(gotBody) != "segment-bytes" {
|
||||||
t.Fatalf("body = %q, want %q", string(gotBody), "segment-bytes")
|
t.Fatalf("body = %q, want %q", string(gotBody), "segment-bytes")
|
||||||
@@ -556,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)
|
||||||
+70
-137
@@ -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 `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,17 +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 |
|
| Workflow | Trigger | Purpose |
|
||||||
| ------------- | ---------------- | ---------------------------------------------------------------------------------------- |
|
| -------------------- | ----------------- | --------------------------------------------------------------------------------- |
|
||||||
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push Docker images (`:latest`, `:main`) |
|
| `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 |
|
| `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 |
|
| `release.yml` | Tag push `*.*.*` | Build & push versioned images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
|
||||||
|
| `workers-deploy.yml` | After CI succeeds | Deploy frontend to Cloudflare Workers |
|
||||||
|
|
||||||
CI and CD run simultaneously on every push to `main`. The deploy pulls whatever `:latest` images are available in the registry at that moment.
|
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:
|
||||||
|
|
||||||
The CD workflow (`cd.yml`) uses `DOCKER_HOST` to target the deployment host. To switch targets:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test lab (current)
|
# Test lab (current)
|
||||||
@@ -111,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
|
||||||
|
|
||||||
@@ -125,152 +83,128 @@ 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:
|
||||||
|
|
||||||
- CI_REGISTRY_URL
|
- `DATABASE_URL` — PostgreSQL connection string
|
||||||
- CI_REGISTRY_NAMESPACE
|
- `REDIS_URL` — Redis connection string
|
||||||
- CHAT_PORT
|
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` — admin dashboard credentials
|
||||||
- NGINX_PORT
|
- `ADMIN_SESSION_TTL_HOURS` — session expiry (default 12)
|
||||||
- DATABASE_URL (if overridden)
|
- `VITE_PUBLIC_APP_URL` / `VITE_PUBLIC_WS_URL` / `VITE_PUBLIC_HLS_URL` — frontend URLs
|
||||||
- REDIS_URL (if overridden)
|
|
||||||
- ADMIN_USERNAME
|
|
||||||
- ADMIN_PASSWORD
|
|
||||||
- VITE_PUBLIC_APP_URL
|
|
||||||
- VITE_PUBLIC_WS_URL
|
|
||||||
- VITE_PUBLIC_HLS_URL
|
|
||||||
- Cloudflare/R2 variables when media sync is enabled
|
|
||||||
|
|
||||||
See .env.example and SETUP.md for full variable reference.
|
**Cloudflare R2 (required for HLS segment sync to edge):**
|
||||||
|
|
||||||
|
- `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY`
|
||||||
|
- `R2_BUCKET_NAME` (e.g. `linearvoid-media`)
|
||||||
|
- `R2_ENDPOINT` — S3-compatible endpoint URL
|
||||||
|
- `R2_REGION` — set to `auto`
|
||||||
|
|
||||||
|
**Cloudflare (optional, for Workers):**
|
||||||
|
|
||||||
|
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`
|
||||||
|
- `MEDIA_DOMAIN` — split media domain (e.g. `media.linearvoid.tv`)
|
||||||
|
|
||||||
|
See `.env.example` and `SETUP.md` for full variable reference.
|
||||||
|
|
||||||
|
### Gitea Secrets
|
||||||
|
|
||||||
|
`cd.yml` injects sensitive values from Gitea secrets into `.env` at deploy time. Configure in the Gitea repository:
|
||||||
|
|
||||||
|
`DATABASE_URL`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN_ACCESS_KEY_ID` (optional), `R2_ADMIN_SECRET_ACCESS_KEY` (optional), `R2_ADMIN_ENDPOINT` (optional), `CLOUDFLARE_API_TOKEN` (optional), `CLOUDFLARE_ACCOUNT_ID` (optional), `CI_REGISTRY_USER`, `CI_REGISTRY_TOKEN`
|
||||||
|
|
||||||
## Pull-and-Run Deployment
|
## 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
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Re-run health checks.
|
3. 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.
|
- 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.
|
||||||
@@ -294,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.
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# DEVELOPMENT.md — Linear Void Remaining Tasks
|
|
||||||
|
|
||||||
## Test Fixtures (COMPLETE)
|
|
||||||
|
|
||||||
- [x] Create test media fixtures in `backend/playout/testdata/`:
|
|
||||||
- `valid_720p30.mp4` — small (1-2s) H.264/AAC compliant file
|
|
||||||
- `invalid_h265.mp4` — wrong codec
|
|
||||||
- `invalid_24fps.mp4` — wrong framerate
|
|
||||||
- Generate via `ffmpeg` commands documented in test file headers
|
|
||||||
- Windows dev path: `C:\Users\zwits\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0-full_build\bin\ffmpeg.exe`
|
|
||||||
|
|
||||||
- [x] Create test playlist fixtures in `backend/playout/testdata/`:
|
|
||||||
- `valid_playlist.txt`, `empty_playlist.txt`, `comments_only.txt`, `mixed_valid_invalid.txt`
|
|
||||||
|
|
||||||
- [x] Document ffmpeg fixture-generation commands in `playlist_test.go` header
|
|
||||||
|
|
||||||
## User Frontend Tests (PARTIALLY COMPLETE)
|
|
||||||
|
|
||||||
- [x] Video player behavior (play, pause, seek, buffering, error handling) - HLS player startup, native/fallback, URL resolution covered
|
|
||||||
- [x] Chat functionality (send/receive messages, presence, rate limiting, moderation) - send, receive, presence join/leave, DM flow, reconnect covered
|
|
||||||
- [ ] State management and data flow
|
|
||||||
- [ ] Responsive design and layout across different screen sizes
|
|
||||||
- [ ] Accessibility testing (keyboard navigation, screen reader support)
|
|
||||||
- [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 Tests (PARTIALLY COMPLETE)
|
|
||||||
|
|
||||||
- [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
|
|
||||||
- [ ] State management and data flow
|
|
||||||
- [ ] Responsive design and layout across different screen sizes
|
|
||||||
- [ ] Accessibility testing (keyboard navigation, screen reader support)
|
|
||||||
- [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
|
|
||||||
|
|
||||||
## Cloudflare R2 Integration Test (PARTIALLY COMPLETE)
|
|
||||||
|
|
||||||
- [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
|
|
||||||
- [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 (TestSyncToR2 + TestSyncToR2_ManifestCacheHeaders + TestSyncToR2_NoCacheHeadersForUnknownExt)
|
|
||||||
- [x] Document required stubs or mocks (putObject variable injection pattern)
|
|
||||||
|
|
||||||
## Cloudflare Configuration (manual — Cloudflare dashboard)
|
|
||||||
|
|
||||||
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
|
||||||
|
|
||||||
- [ ] Cloudflare Pages: link Gitea repo, build `cd frontend && npm run build`, output `frontend/dist`
|
|
||||||
- [ ] Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier
|
|
||||||
- [ ] WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode
|
|
||||||
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
|
||||||
- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2
|
|
||||||
- [ ] HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS
|
|
||||||
|
|
||||||
## External Services
|
|
||||||
|
|
||||||
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
|
||||||
|
|
||||||
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
|
|
||||||
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
|
|
||||||
- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
|
|
||||||
|
|
||||||
## Production Deployment (Hetzner — not provisioned)
|
|
||||||
|
|
||||||
- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04
|
|
||||||
- [ ] Install Docker Engine + Compose plugin, open firewall: 80/443 (public), 2375 (runner IP only)
|
|
||||||
- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs)
|
|
||||||
- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories
|
|
||||||
- [ ] 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 start healthy, check `/health`, `/hls/playlist.m3u8`, WebSocket
|
|
||||||
- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict), test HTTPS + WSS
|
|
||||||
- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout
|
|
||||||
- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL
|
|
||||||
- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host
|
|
||||||
- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds
|
|
||||||
|
|
||||||
## Scaling & Performance (planning only)
|
|
||||||
|
|
||||||
- [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration
|
|
||||||
- [ ] Measure TTFB: WebPageTest from 3+ US regions, target <4s warm cache, document baseline
|
|
||||||
- [ ] Load-test WebSocket: `artillery` or Go script, ramp 10→100→500→1000 connections, measure p95 latency + errors (scaffolding complete; benchmark execution pending)
|
|
||||||
- [ ] Benchmark admin API: `wrk`/`k6` against `/api/admin/*`, target p95 <1s, identify slow queries (scaffolding complete; benchmark execution pending)
|
|
||||||
- [ ] Track cache hit ratio: Cloudflare GraphQL → Grafana panel, alert <90%
|
|
||||||
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, chat p95 >1s/5min, CPU >80%/10min, Redis memory >85%/10min
|
|
||||||
- [ ] Phase 2 plan: document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources
|
|
||||||
- [ ] Phase 3 plan: research second Hetzner location, design warm standby + DNS failover + DB/Redis replication
|
|
||||||
+9
-10
@@ -52,6 +52,12 @@ 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
|
||||||
|
|
||||||
|
See detailed scripts for querying Prometheus scrape targets:
|
||||||
|
|
||||||
|
- [Linux/macOS](./infrastructure/monitoring/prometheus_curl.md)
|
||||||
|
|
||||||
## Key Metrics
|
## Key Metrics
|
||||||
|
|
||||||
- `linearvoid_chat_connected_clients` — active WebSocket connections
|
- `linearvoid_chat_connected_clients` — active WebSocket connections
|
||||||
@@ -72,20 +78,13 @@ Config: `monitoring/prometheus/prometheus.yml` (15s scrape interval)
|
|||||||
| 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.
|
||||||
+18
-4
@@ -6,6 +6,14 @@ This folder contains the documentation for the Linear Void project. The document
|
|||||||
|
|
||||||
- [Architecture](architecture/README.md) - High-level system architecture and component interactions.
|
- [Architecture](architecture/README.md) - High-level system architecture and component interactions.
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- Cloudflare
|
||||||
|
- [Cache Rules](./infrastructure/cloudflare/cache-rules.md) - Cache rules for Cloudflare Workers.
|
||||||
|
- [Account Troubleshooting](./infrastructure/cloudflare/cloudflare-account-troubleshooting.md) - Troubleshooting Cloudflare account issues.
|
||||||
|
- Monitoring
|
||||||
|
- [Prometheus CURL](./infrastructure/monitoring/prometheus_curl.md) - Prometheus metrics endpoint testing with curl and jq. Doocumentation of available metrics and their meanings.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
- [API](API.md) - Implemented WebSocket and REST API reference.
|
- [API](API.md) - Implemented WebSocket and REST API reference.
|
||||||
@@ -30,12 +38,18 @@ This folder contains the documentation for the Linear Void project. The document
|
|||||||
|
|
||||||
- [Streaming](STREAMING.md) - Streaming architecture, playout, and delivery details.
|
- [Streaming](STREAMING.md) - Streaming architecture, playout, and delivery details.
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
- [Development](DEVELOPMENT.md) - Remaining tasks and test fixtures for development.
|
|
||||||
|
|
||||||
## Additional Resources
|
## Additional Resources
|
||||||
|
|
||||||
- [Marketing](marketing/README.md) - Marketing materials and resources.
|
- [Marketing](marketing/README.md) - Marketing materials and resources.
|
||||||
- [Operations](operations/README.md) - Operational procedures and checklists.
|
- [Operations](operations/README.md) - Operational procedures and checklists.
|
||||||
- [Incident Runbook](incident-runbook.md) - Incident response and disaster recovery procedures.
|
- [Incident Runbook](incident-runbook.md) - Incident response and disaster recovery procedures.
|
||||||
|
|
||||||
|
## Other Documents
|
||||||
|
|
||||||
|
- [CHANGELOG](../CHANGELOG.md) - Release notes and version history.
|
||||||
|
- [CONTRIBUTING](../CONTRIBUTING.md) - Contribution guidelines and code of conduct.
|
||||||
|
- [TODO](../.github/instructions/TODO.md) - Active implementation plan with checkboxes.
|
||||||
|
- [Backlog](../.github/instructions/BACKLOG.md) - Consolidated list of all development tasks.
|
||||||
|
- [Agents](../.github/AGENTS.md) - Instructions for agents and task management.
|
||||||
|
- [Memory](../.github/MEMORY.md) - Session memory and development progress tracking.
|
||||||
|
- [README](../README.md) - Project overview and high-level information.
|
||||||
+73
-511
@@ -1,588 +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 20+**: 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 `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: `infrastructure/cloudflare/cache-rules.md`.
|
|
||||||
|
|
||||||
### 4. Cloudflare Pages
|
|
||||||
|
|
||||||
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
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build # start all services
|
||||||
|
docker compose down # stop
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Access the Application**:
|
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`).
|
||||||
- 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):
|
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`).
|
||||||
|
|
||||||
```bash
|
### Test lab (remote Docker host)
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The dev server runs on <http://localhost:5173>. It proxies API calls to the backend services.
|
Deploy to `192.168.88.40` over Docker's TCP socket (port `2375`).
|
||||||
|
|
||||||
4. **Build frontend assets for nginx-backed local runs**:
|
Enable TCP on Alpine host — edit `/etc/conf.d/docker`:
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
The local nginx container serves `frontend/dist`, so rebuilding the frontend is required before validating production-like static output.
|
|
||||||
|
|
||||||
5. **Stop Services**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Local Test Environment Deployment (Remote Docker Host)
|
|
||||||
|
|
||||||
For testing deployments on a local network server without setting up a full production VPS, you can deploy directly to a remote Docker host (e.g., a home lab server or test machine) using Docker's TCP socket.
|
|
||||||
|
|
||||||
#### Test Environment Prerequisites
|
|
||||||
|
|
||||||
- A remote server running Docker at IP `192.168.88.40` (running Alpine Linux).
|
|
||||||
- Docker TCP socket enabled and listening on port `2375` on the remote server.
|
|
||||||
|
|
||||||
##### Enabling Docker TCP Socket on Alpine Linux
|
|
||||||
|
|
||||||
To allow the Gitea Actions runner (or your local machine) to deploy to the remote Alpine host, you must configure the Docker daemon to listen on the TCP socket:
|
|
||||||
|
|
||||||
1. Edit `/etc/conf.d/docker` on the Alpine host:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
vi /etc/conf.d/docker
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Update the `DOCKER_OPTS` line to include both the unix socket and the TCP socket:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"
|
DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Restart the Docker service:
|
Then `rc-service docker restart`; verify with `netstat -tlnp | grep 2375`; allow port `2375` from `192.168.88.0/24` in the firewall.
|
||||||
|
|
||||||
```bash
|
Deploy from a machine with the Docker CLI:
|
||||||
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
|
```bash
|
||||||
export DOCKER_HOST="tcp://192.168.88.40:2375"
|
export DOCKER_HOST="tcp://192.168.88.40:2375"
|
||||||
```
|
docker info # confirm remote host
|
||||||
|
|
||||||
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 pull
|
||||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Build and Deploy from Source**:
|
Health: `http://192.168.88.40:8081/health` (playout), `:3000/health` (chat), nginx entrypoint `:8080`.
|
||||||
|
|
||||||
```bash
|
### Coolify deployment
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Access the Test Environment**:
|
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).
|
||||||
- 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**:
|
### Production setup
|
||||||
Once testing is complete, clear the `DOCKER_HOST` variable to point back to your local machine:
|
|
||||||
- **PowerShell**:
|
|
||||||
|
|
||||||
```powershell
|
1. Provision a Linux VPS (Docker, Compose, Git); open HTTP/HTTPS ports.
|
||||||
Remove-Item env:DOCKER_HOST
|
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.
|
||||||
|
|
||||||
- **Bash**:
|
### Production (Hetzner VPS + Cloudflare)
|
||||||
|
|
||||||
```bash
|
1. Provision CX22 in Ashburn, VA (ADR-001).
|
||||||
unset DOCKER_HOST
|
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/`.
|
||||||
|
|
||||||
### Production Setup Guide
|
See `docs/architecture/README.md` (arc42) and the ADRs for decisions and cost/scaling guidance (ADR-001).
|
||||||
|
|
||||||
1. **Prepare the target host**:
|
|
||||||
- Provision a Linux VPS with Docker Engine, Docker Compose, Git, and enough disk for media, HLS output, PostgreSQL, Redis persistence, and monitoring data.
|
|
||||||
- Open the ports required for HTTP/HTTPS and any restricted monitoring access you intend to expose.
|
|
||||||
|
|
||||||
2. **Clone the repository and create the environment file**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
|
||||||
cd linearvoid
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Set production-safe `.env` values**:
|
|
||||||
- Replace placeholder secrets.
|
|
||||||
- Use compose-network hostnames for internal services, for example `redis` and `db`.
|
|
||||||
- Set strong values for `POSTGRES_PASSWORD` and `GRAFANA_PASSWORD`.
|
|
||||||
- Keep `MEDIA_DOMAIN`, R2 credentials, and Gitea registry values consistent with the external infrastructure.
|
|
||||||
- Set `MEDIA_DOMAIN=media.linearvoid.tv`.
|
|
||||||
- Set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
|
|
||||||
- If you intentionally bypass the public proxy for local-only testing, switch those values to `http://localhost`, `ws://localhost/ws`, and `http://localhost/hls/playlist.m3u8`.
|
|
||||||
|
|
||||||
4. **Create runtime directories on the server**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p data/playlist data/media data/hls data/chat
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Publish or verify required images in Gitea**:
|
|
||||||
- Ensure the `playout`, `chat`, and `nginx` images exist under `${CI_REGISTRY_URL}/${CI_REGISTRY_NAMESPACE}`.
|
|
||||||
- Log in to the registry from the VPS before pulling.
|
|
||||||
|
|
||||||
6. **Deploy the production stack**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.prod.yml pull
|
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Run post-deploy verification**:
|
|
||||||
- Check `docker compose -f docker-compose.prod.yml ps`.
|
|
||||||
- Confirm nginx, chat, and playout health endpoints respond.
|
|
||||||
- Verify that HLS output is being written to `data/hls` and that the media domain serves manifests and segments through Cloudflare.
|
|
||||||
|
|
||||||
8. **Harden and operate the deployment**:
|
|
||||||
- Put HTTPS in front of nginx using Cloudflare or another reverse proxy, with `linearvoid.tv` as canonical app host and `www.linearvoid.tv` redirected.
|
|
||||||
- Restrict Grafana and Prometheus exposure to trusted IPs or a private network.
|
|
||||||
- Run the stack under systemd or another service manager if you need auto-start behavior outside Docker restart policies.
|
|
||||||
|
|
||||||
### Production Deployment (Hetzner VPS + Cloudflare)
|
|
||||||
|
|
||||||
1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001).
|
|
||||||
2. **Configure Cloudflare**:
|
|
||||||
- Create R2 bucket with `enam` location hint.
|
|
||||||
- Set up custom media domain (`media.linearvoid.tv`) with the cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
|
||||||
- Point `linearvoid.tv` and `www.linearvoid.tv` at the app entrypoint, then redirect `www` to the apex domain.
|
|
||||||
- Use Full (strict) TLS for both app and media hostnames.
|
|
||||||
- Apply the Worker from `infrastructure/cloudflare/worker.js` for advanced header management.
|
|
||||||
3. **Deploy**:
|
|
||||||
- Copy the project to the VPS.
|
|
||||||
- Use production compose: `docker compose -f docker-compose.prod.yml up -d`
|
|
||||||
- Or pull from Gitea registry: `docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d`
|
|
||||||
- Configure systemd to manage the Docker Compose stack.
|
|
||||||
4. **Monitoring**: Access Grafana at the exposed port for dashboards (included in `monitoring/grafana/dashboard.json`).
|
|
||||||
|
|
||||||
See `docs/architecture/README.md` for the full arc42 documentation and the ADRs for architectural decisions.
|
|
||||||
|
|
||||||
## Troubleshooting
|
## 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
|
|
||||||
+76
-73
@@ -1,11 +1,11 @@
|
|||||||
# 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
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart TD
|
||||||
A["Normalized MP4 assets"] --> B["Playlist and schedule"]
|
A["Normalized MP4 assets"] --> B["Playlist and schedule"]
|
||||||
B --> C["Playout service and FFmpeg"]
|
B --> C["Playout service and FFmpeg"]
|
||||||
C --> D["Local HLS staging"]
|
C --> D["Local HLS staging"]
|
||||||
@@ -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 |
|
| Setting | Value |
|
||||||
| ---------------- | ------------------------------------------------------------------------------------- |
|
| ---------------- | ------------------------------------------------------------------------ |
|
||||||
| Protocol | Standard live HLS, not LL-HLS |
|
| Protocol | Standard live HLS, not LL-HLS |
|
||||||
| Segment format | fMP4 (`.m4s`) with an initialization segment; `.ts` may be supported during migration |
|
| Segment format | fMP4 (`.m4s`) with init segment; `.ts` may be supported during migration |
|
||||||
| Segment target | 6 seconds (`HLS_SEGMENT_DURATION=6`) |
|
| Segment target | 2 seconds (`HLS_SEGMENT_DURATION=2`) |
|
||||||
| Live window | 24 segments (`HLS_WINDOW_SIZE=24`), about 144 seconds |
|
| Live window | 12 segments (`HLS_WINDOW_SIZE=12`), ~24 seconds |
|
||||||
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
|
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
|
||||||
| Expected latency | 15–35 seconds |
|
| 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 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.
|
||||||
|
|
||||||
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:
|
**Segment duration rationale**: 2s lowers latency and keeps the window small (12 × 2s = 24s) while staying above the manifest TTL (1–2s). The 2s GOP (30fps, `-g 60`) already matches the target, so segments cut cleanly on keyframes. Longer (4–6s) raises latency and window depth; shorter than 2s inflates segment/request overhead and R2 Class B ops with little gain.
|
||||||
|
|
||||||
|
Every segment name is globally unique and never reused, including after restart or playlist loop — use a run/session ID plus a monotonically increasing media sequence:
|
||||||
|
|
||||||
```text
|
```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 |
|
||||||
|
| `R2_KEEP_RUNS` | Run folders to retain (default `2`) |
|
||||||
|
| `R2_MAX_RETENTION_BYTES` | Max bytes across run folders (10 GiB) |
|
||||||
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
||||||
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
|
| `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.
|
||||||
+232
-632
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/CSS/JS + Tailwind + Alpine.js or vanilla) hosted on Cloudflare Pages or same VPS/Nginx.
|
- Static SPA (HTML + custom CSS + vanilla JS) hosted on Cloudflare Workers or same VPS/Nginx.
|
||||||
- Video player: `hls.js` (Chromium/Firefox) or native HLS (Safari). Single live `master.m3u8`.
|
- Video player: `hls.js` (Chromium/Firefox) or native HLS (Safari). Single live `master.m3u8`.
|
||||||
- Embedded chat sidebar with room switcher, private DMs, now-playing overlay.
|
- Embedded chat sidebar with room switcher, private DMs, now-playing overlay.
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
|||||||
|
|
||||||
### 5. Infrastructure Summary & Cost Model
|
### 5. Infrastructure Summary & Cost Model
|
||||||
|
|
||||||
- **Primary**: Hetzner Ashburn VPS (playout + chat + Nginx) + Cloudflare R2 + Pages.
|
- **Primary**: Hetzner Ashburn VPS (playout + chat + Nginx) + Cloudflare R2 + Workers.
|
||||||
- **Monitoring**: MediaMTX/SRS dashboard, UptimeRobot, systemd logs, Cloudflare analytics.
|
- **Monitoring**: MediaMTX/SRS dashboard, UptimeRobot, systemd logs, Cloudflare analytics.
|
||||||
- **Backup**: Weekly R2/S3-compatible snapshots of playlist, DB, config.
|
- **Backup**: Weekly R2/S3-compatible snapshots of playlist, DB, config.
|
||||||
- **Fallback**: Emergency slate/loop playlist, automated restart.
|
- **Fallback**: Emergency slate/loop playlist, automated restart.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ We must explicitly document language/runtime choices per module and the assumpti
|
|||||||
|
|
||||||
| Layer | Technology | Language | Rationale |
|
| Layer | Technology | Language | Rationale |
|
||||||
| ------------------- | ------------------------------------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ------------------------------------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **Frontend SPA** | HTML + Tailwind + Alpine.js + hls.js | TypeScript / JavaScript | Minimal bundle, excellent HLS support, fast iteration. Vite for build. |
|
| **Frontend SPA** | HTML + custom CSS + vanilla JS + hls.js | TypeScript / JavaScript | Minimal bundle, excellent HLS support, fast iteration. Vite for build. |
|
||||||
| **Playout Service** | FFmpeg + MediaMTX (or SRS) | Bash + Go (wrapper) | FFmpeg is the proven tool for continuous playlist HLS. Go wrapper for reliability and monitoring. |
|
| **Playout Service** | FFmpeg + MediaMTX (or SRS) | Bash + Go (wrapper) | FFmpeg is the proven tool for continuous playlist HLS. Go wrapper for reliability and monitoring. |
|
||||||
| **Chat Backend** | Go (preferred) or Node.js + Socket.io | Go | High-performance WebSockets, low memory, excellent Redis/PostgreSQL clients. Node.js acceptable for rapid prototyping. |
|
| **Chat Backend** | Go (preferred) or Node.js + Socket.io | Go | High-performance WebSockets, low memory, excellent Redis/PostgreSQL clients. Node.js acceptable for rapid prototyping. |
|
||||||
| **Shared Code** | TypeScript definitions + Go structs | TS + Go | Clear contracts between frontend and backend. |
|
| **Shared Code** | TypeScript definitions + Go structs | TS + Go | Clear contracts between frontend and backend. |
|
||||||
@@ -38,7 +38,7 @@ We must explicitly document language/runtime choices per module and the assumpti
|
|||||||
|
|
||||||
1. All video assets **must** be normalized before being added to any playlist (H.264, AAC, 30 fps constant, 2s keyframes, CRF encoding).
|
1. All video assets **must** be normalized before being added to any playlist (H.264, AAC, 30 fps constant, 2s keyframes, CRF encoding).
|
||||||
2. Deployment is **Docker-first**; services will be containerized.
|
2. Deployment is **Docker-first**; services will be containerized.
|
||||||
3. Cloudflare is the primary CDN and will be configured with specific [Cache Rules](../../../infrastructure/cloudflare/cache-rules.md).
|
3. Cloudflare is the primary CDN and will be configured with specific [Cache Rules](../../infrastructure/cloudflare/cache-rules.md) for HLS segments and manifests.
|
||||||
4. Chat does **not** require end-to-end encryption in v1 (separate ADR if needed).
|
4. Chat does **not** require end-to-end encryption in v1 (separate ADR if needed).
|
||||||
5. Single VPS origin initially; multi-region is a later evolution.
|
5. Single VPS origin initially; multi-region is a later evolution.
|
||||||
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
|
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ This interface must be lightweight, secure, and integrated with the playout serv
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
1. **Lightweight & Maintainable** – Prefer server-rendered or minimal JS (HTMX + Tailwind) over heavy SPA frameworks to align with ADR-003's "lightweight" principle.
|
1. **Lightweight & Maintainable** – Prefer server-rendered or minimal JS (HTMX + custom CSS) over heavy SPA frameworks to align with ADR-003's "lightweight" principle.
|
||||||
2. **Security** – Strict authentication, file type/size validation, and R2 upload permissions.
|
2. **Security** – Strict authentication, file type/size validation, and R2 upload permissions.
|
||||||
3. **Usability** – Drag-and-drop playlist editor, progress indicators for normalization and upload, real-time playout status.
|
3. **Usability** – Drag-and-drop playlist editor, progress indicators for normalization and upload, real-time playout status.
|
||||||
4. **Integration** – Must call playout service APIs for validation and playlist updates; upload directly to R2 with metadata.
|
4. **Integration** – Must call playout service APIs for validation and playlist updates; upload directly to R2 with metadata.
|
||||||
@@ -31,7 +31,7 @@ Create a dedicated **admin web interface** in a new `frontend/admin/` directory
|
|||||||
|
|
||||||
**Tech Stack (per ADR-003)**:
|
**Tech Stack (per ADR-003)**:
|
||||||
|
|
||||||
- **Frontend**: HTMX + Tailwind CSS + Alpine.js (minimal JS, server-rendered HTML). Alternative: React if complex interactions are needed later.
|
- **Frontend**: HTMX + custom CSS + minimal JS (server-rendered HTML). Alternative: React if complex interactions are needed later.
|
||||||
- **Backend**: Go (or Node.js) API endpoints exposed by the playout service or a dedicated `backend/admin/` service.
|
- **Backend**: Go (or Node.js) API endpoints exposed by the playout service or a dedicated `backend/admin/` service.
|
||||||
- **Authentication**: JWT or session-based (admin roles only). Use Cloudflare Access or simple OAuth for production.
|
- **Authentication**: JWT or session-based (admin roles only). Use Cloudflare Access or simple OAuth for production.
|
||||||
- **File Handling**: Upload → validate with ffprobe (codec, fps, keyframes, duration) → normalize if needed → store in R2 with metadata.
|
- **File Handling**: Upload → validate with ffprobe (codec, fps, keyframes, duration) → normalize if needed → store in R2 with metadata.
|
||||||
@@ -48,7 +48,7 @@ Create a dedicated **admin web interface** in a new `frontend/admin/` directory
|
|||||||
|
|
||||||
**Folder Placement** (per ADR-002):
|
**Folder Placement** (per ADR-002):
|
||||||
|
|
||||||
- `frontend/admin/` – Admin-specific HTML/JS components (HTMX + Tailwind).
|
- `frontend/admin/` – Admin-specific HTML/JS components (HTMX + custom CSS).
|
||||||
- `backend/admin/` – Dedicated API handlers for validation, playlist updates, and configuration (or extend playout/chat services).
|
- `backend/admin/` – Dedicated API handlers for validation, playlist updates, and configuration (or extend playout/chat services).
|
||||||
- Protected route in nginx with authentication (JWT or Cloudflare Access).
|
- Protected route in nginx with authentication (JWT or Cloudflare Access).
|
||||||
- Documentation and API specs added to `docs/architecture/`.
|
- Documentation and API specs added to `docs/architecture/`.
|
||||||
@@ -95,7 +95,7 @@ Create a dedicated **admin web interface** in a new `frontend/admin/` directory
|
|||||||
|
|
||||||
- ADR-001: Normalization requirements, HLS playout, R2 usage.
|
- ADR-001: Normalization requirements, HLS playout, R2 usage.
|
||||||
- ADR-002: Folder structure (admin UI belongs under frontend or a protected route).
|
- ADR-002: Folder structure (admin UI belongs under frontend or a protected route).
|
||||||
- ADR-003: Lightweight frontend preference (HTMX/Tailwind over heavy frameworks).
|
- ADR-003: Lightweight frontend preference (HTMX/custom CSS over heavy frameworks).
|
||||||
- arc42 documentation: Building Block View (admin as part of the overall system).
|
- arc42 documentation: Building Block View (admin as part of the overall system).
|
||||||
|
|
||||||
This ADR ensures all admin interface decisions are documented before code is written.
|
This ADR ensures all admin interface decisions are documented before code is written.
|
||||||
@@ -17,7 +17,7 @@ These interfaces must be secure, auditable, and integrated with the chat backend
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
1. **Consistency** – Use the same lightweight tech stack decided in ADR-004 (HTMX + Tailwind + Alpine.js) to avoid introducing new frameworks.
|
1. **Consistency** – Use the same lightweight tech stack decided in ADR-004 (HTMX + custom CSS + vanilla JS) to avoid introducing new frameworks.
|
||||||
2. **Security & Auditability** – All actions must require admin authentication and be logged immutably.
|
2. **Security & Auditability** – All actions must require admin authentication and be logged immutably.
|
||||||
3. **Real-time Needs** – Moderation dashboard should show live chat activity (WebSocket integration).
|
3. **Real-time Needs** – Moderation dashboard should show live chat activity (WebSocket integration).
|
||||||
4. **Simplicity** – Analytics should use existing Prometheus/Grafana data where possible rather than building a duplicate system.
|
4. **Simplicity** – Analytics should use existing Prometheus/Grafana data where possible rather than building a duplicate system.
|
||||||
@@ -29,7 +29,7 @@ Extend the admin interface started in ADR-004 with additional protected sections
|
|||||||
|
|
||||||
**Tech Stack (consistent with ADR-003 and ADR-004)**:
|
**Tech Stack (consistent with ADR-003 and ADR-004)**:
|
||||||
|
|
||||||
- **Frontend**: HTMX + Tailwind + Alpine.js for all admin pages. Shared layout with sidebar navigation.
|
- **Frontend**: HTMX + custom CSS + vanilla JS for all admin pages. Shared layout with sidebar navigation.
|
||||||
- **Backend**: Go API endpoints (protected by JWT/session middleware) in `backend/admin/` or extended from the chat/playout services.
|
- **Backend**: Go API endpoints (protected by JWT/session middleware) in `backend/admin/` or extended from the chat/playout services.
|
||||||
- **Authentication**: Role-based (admin, moderator, viewer). Use Cloudflare Access or JWT with short expiry for production.
|
- **Authentication**: Role-based (admin, moderator, viewer). Use Cloudflare Access or JWT with short expiry for production.
|
||||||
- **Data Sources**:
|
- **Data Sources**:
|
||||||
@@ -54,7 +54,7 @@ Extend the admin interface started in ADR-004 with additional protected sections
|
|||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
- Reusing HTMX/Tailwind from ADR-004 keeps the admin interface lightweight and consistent.
|
- Reusing HTMX/custom CSS from ADR-004 keeps the admin interface lightweight and consistent.
|
||||||
- Centralizing admin functionality under `/admin/` reduces maintenance overhead.
|
- Centralizing admin functionality under `/admin/` reduces maintenance overhead.
|
||||||
- Leveraging Prometheus/Grafana for analytics avoids duplicating metrics collection.
|
- Leveraging Prometheus/Grafana for analytics avoids duplicating metrics collection.
|
||||||
- Immutable audit logging supports future compliance and moderation review (key requirement for a public chat system).
|
- Immutable audit logging supports future compliance and moderation review (key requirement for a public chat system).
|
||||||
@@ -94,7 +94,7 @@ Extend the admin interface started in ADR-004 with additional protected sections
|
|||||||
|
|
||||||
- ADR-001: Chat features, moderation requirements, monitoring needs.
|
- ADR-001: Chat features, moderation requirements, monitoring needs.
|
||||||
- ADR-002: Folder structure – admin UI belongs under `frontend/admin/`.
|
- ADR-002: Folder structure – admin UI belongs under `frontend/admin/`.
|
||||||
- ADR-003: Lightweight frontend (HTMX/Tailwind) and Go backend preference.
|
- ADR-003: Lightweight frontend (HTMX/custom CSS) and Go backend preference.
|
||||||
- ADR-004: Video admin interface (shared layout and authentication approach).
|
- ADR-004: Video admin interface (shared layout and authentication approach).
|
||||||
- arc42 documentation: Cross-cutting Concepts (security, monitoring) and Building Block View (admin as extension of the system).
|
- arc42 documentation: Cross-cutting Concepts (security, monitoring) and Building Block View (admin as extension of the system).
|
||||||
|
|
||||||
|
|||||||
+55
-124
@@ -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
|
|
||||||
File renamed without changes.
@@ -0,0 +1,24 @@
|
|||||||
|
# Cloudflare Account Troubleshooting
|
||||||
|
|
||||||
|
Reference for Cloudflare account and API token troubleshooting.
|
||||||
|
|
||||||
|
Use `wrangler whoami` to verify the account ID and API token are valid, or query the Workers API at `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts`.
|
||||||
|
|
||||||
|
## Validate Cloudflare account and API token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Validate Cloudflare account and API token in .env
|
||||||
|
cd /path/to/linearvoid
|
||||||
|
grep -E 'CLOUDFLARE_API_TOKEN|CLOUDFLARE_ACCOUNT_ID' .env
|
||||||
|
|
||||||
|
# Test API token with wrangler
|
||||||
|
npx wrangler whoami
|
||||||
|
|
||||||
|
# Or verify with the Workers API directly
|
||||||
|
export CLOUDFLARE_API_TOKEN="$(grep '^CLOUDFLARE_API_TOKEN=' .env | cut -d= -f2-)"
|
||||||
|
export CLOUDFLARE_ACCOUNT_ID="$(grep '^CLOUDFLARE_ACCOUNT_ID=' .env | cut -d= -f2-)"
|
||||||
|
|
||||||
|
# List all Workers for the account
|
||||||
|
curl -X GET "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts" \
|
||||||
|
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json"
|
||||||
|
```
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Prometheus scrape target metric names
|
||||||
|
|
||||||
|
Here are ready‑to‑run **cURL** commands you can use to query each Prometheus scrape target and list the metric names they expose.
|
||||||
|
The Prometheus HTTP API endpoint `http://localhost:9090/api/v1/label/__name__/values` returns all metric names currently known for the target’s job/instance.
|
||||||
|
|
||||||
|
## 1. Chat (`http://chat:3000/metrics`)
|
||||||
|
|
||||||
|
[Browse `chat`](http://192.168.88.40:9090/api/v1/label/__name__/values?match[]={job=%22chat%22})
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="chat",instance="chat:3000"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#chat-metrics) for full output of all 20+ metric names exposed by the Chat service.
|
||||||
|
|
||||||
|
## 2. NGINX exporter (`http://nginx-exporter:9113/metrics`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="nginx",instance="nginx-exporter:9113"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#nginx-metrics) for full output of all 40+ metric names exposed by the NGINX exporter.
|
||||||
|
|
||||||
|
## 3. Node exporter (`http://node-exporter:9100/metrics`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="node",instance="node-exporter:9100"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#node-metrics) for full output of all 200+ metric names exposed by the Node exporter.
|
||||||
|
|
||||||
|
## 4. Playout (`http://playout:8081/metrics`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="playout",instance="playout:8081"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#playout-metrics) for full output of all 10+ metric names exposed by the Playout service.
|
||||||
|
|
||||||
|
## 5. Postgres exporter (`http://postgres-exporter:9187/metrics`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="postgres",instance="postgres-exporter:9187"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#postgres-metrics) for full output of all 100+ metric names exposed by the Postgres exporter.
|
||||||
|
|
||||||
|
## 6. Prometheus itself (`http://prometheus:9090/metrics`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="prometheus",instance="prometheus:9090"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#prometheus-metrics) for full output of all 100+ metric names exposed by Prometheus itself.
|
||||||
|
|
||||||
|
## 7. Redis exporter (`http://redis-exporter:9121/metrics`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="redis",instance="redis-exporter:9121"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
see [Prometheus Results](./prometheus_results.md#redis-metrics) for full output of all 100+ metric names exposed by the Redis exporter.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Prometheus Metrics — Linear Void
|
||||||
|
|
||||||
|
Raw scrape-target metric inventories captured for the monitoring setup. This file
|
||||||
|
lists which metric names are exposed by each target. No threshold values, alert
|
||||||
|
rules, or query results are present beyond the metric inventory itself.
|
||||||
|
|
||||||
|
Standard boilerplate metrics are exposed by every Go-based target and are omitted
|
||||||
|
below where noted: `go_*` (gc/memstats/sched/goroutines), `process_*` (cpu, fds,
|
||||||
|
memory, network), `promhttp_metric_handler_*`, and Prometheus scrape internals
|
||||||
|
(`scrape_duration_seconds`, `scrape_samples_scraped`, `scrape_samples_post_metric_relabeling`,
|
||||||
|
`scrape_series_added`, `up`).
|
||||||
|
|
||||||
|
## Chat Target (`linearvoid_chat_*`)
|
||||||
|
Application metrics for the chat service:
|
||||||
|
- `linearvoid_chat_admin_sessions_active`
|
||||||
|
- `linearvoid_chat_build_info`
|
||||||
|
- `linearvoid_chat_connected_clients`
|
||||||
|
- `linearvoid_chat_dms_total`
|
||||||
|
- `linearvoid_chat_messages_total`
|
||||||
|
- `linearvoid_chat_moderation_actions_total`
|
||||||
|
- `linearvoid_chat_redis_connected_clients`
|
||||||
|
- `linearvoid_chat_redis_enabled`
|
||||||
|
- `linearvoid_chat_redis_memory_max_bytes`
|
||||||
|
- `linearvoid_chat_redis_memory_usage_ratio`
|
||||||
|
- `linearvoid_chat_redis_memory_used_bytes`
|
||||||
|
- `linearvoid_chat_redis_up`
|
||||||
|
- `linearvoid_chat_request_duration_seconds_bucket/_count/_sum` (histogram)
|
||||||
|
- Plus `ALERTS`, `ALERTS_FOR_STATE` (alert state series).
|
||||||
|
|
||||||
|
## Playout Target (`linearvoid_playout_*`)
|
||||||
|
- `linearvoid_playout_mode`
|
||||||
|
- `linearvoid_playout_restarts_total`
|
||||||
|
- Plus `ALERTS`, `ALERTS_FOR_STATE`.
|
||||||
|
|
||||||
|
## Nginx Target (`nginx_*`)
|
||||||
|
Nginx (Go-based nginx exporter) metrics. Key operational series:
|
||||||
|
- `nginx_connections_accepted`, `nginx_connections_active`, `nginx_connections_handled`
|
||||||
|
- `nginx_connections_reading`, `nginx_connections_waiting`, `nginx_connections_writing`
|
||||||
|
- `nginx_http_requests_total`
|
||||||
|
- `nginx_exporter_build_info`, `nginx_up`
|
||||||
|
- Standard Go/process/promhttp/scrape boilerplate.
|
||||||
|
|
||||||
|
## Node Target (node_exporter, ZFS host)
|
||||||
|
Exposes the full standard node_exporter metric set plus ZFS (`node_zfs_*`) and
|
||||||
|
hwmon (`node_hwmon_*`) collectors. Operationally meaningful series include:
|
||||||
|
- **CPU/load:** `node_load1/5/15`, `node_cpu_seconds_total`, `node_cpu_scaling_frequency_hertz`,
|
||||||
|
`node_procs_running`, `node_procs_blocked`, `node_context_switches_total`
|
||||||
|
- **Memory:** `node_memory_MemTotal_bytes`, `node_memory_MemAvailable_bytes`,
|
||||||
|
`node_memory_MemFree_bytes`, `node_memory_SwapTotal_bytes`, `node_memory_SwapFree_bytes`,
|
||||||
|
`node_vmstat_oom_kill`
|
||||||
|
- **Filesystem/disk:** `node_filesystem_avail_bytes`, `node_filesystem_free_bytes`,
|
||||||
|
`node_filesystem_size_bytes`, `node_filesystem_readonly`, `node_disk_read_bytes_total`,
|
||||||
|
`node_disk_written_bytes_total`, `node_disk_io_time_seconds_total`
|
||||||
|
- **Network:** `node_network_receive_bytes_total`, `node_network_transmit_bytes_total`,
|
||||||
|
`node_network_up`, plus full `node_netstat_*` TCP/UDP/ICMP series
|
||||||
|
- **Files/fds:** `node_filefd_allocated`, `node_filefd_maximum`
|
||||||
|
- **Time:** `node_time_seconds`, `node_time_zone_offset_seconds`, `node_timex_sync_status`
|
||||||
|
- **ZFS ARC/L2ARC:** extensive `node_zfs_arc_*`/`node_zfs_l2_*` hit/miss/size series,
|
||||||
|
`node_zfs_zil_*`, `node_zfs_dbuf_*`, `node_zfs_dnode_*`, `node_zfs_zfetch_*`
|
||||||
|
- **Thermals:** `node_hwmon_temp_celsius`, `node_hwmon_temp_crit_celsius`
|
||||||
|
- `node_exporter_build_info`, `node_uname_info`, `node_os_info`, `node_boot_time_seconds`.
|
||||||
|
|
||||||
|
## Postgres Target (`pg_*` / postgres_exporter)
|
||||||
|
Go-based postgres exporter. Key operational series:
|
||||||
|
- **Health/replication:** `pg_up`, `pg_database_size_bytes`, `pg_database_connection_limit`,
|
||||||
|
`pg_replication_is_replica`, `pg_replication_lag_seconds`, `pg_replication_last_replay_seconds`,
|
||||||
|
`pg_locks_count`, `pg_wal_segments`, `pg_wal_size_bytes`
|
||||||
|
- **Settings (config-as-metric):** `pg_settings_*` (autovacuum, bgwriter, checkpoints,
|
||||||
|
max_connections, shared_buffers, work_mem, wal_*, etc.)
|
||||||
|
- **Activity/statistics:** `pg_stat_activity_count`, `pg_stat_activity_max_tx_duration`,
|
||||||
|
`pg_stat_bgwriter_*` (checkpoints, buffers, fsync), `pg_stat_database_*` (commits,
|
||||||
|
rollbacks, deadlocks, temp files, cache hit/read, tuple ops), `pg_stat_archiver_*`
|
||||||
|
- **Tables:** `pg_stat_user_tables_*` (live/dead tuples, seq/index scans, vacuum/analyze
|
||||||
|
counts), `pg_statio_user_tables_*` (heap/index block hits & reads, incl. TOAST)
|
||||||
|
- `pg_exporter_*` scrape health series, `postgres_exporter_build_info`, and Go/process boilerplate.
|
||||||
|
|
||||||
|
## Prometheus Target (self-scrape)
|
||||||
|
Prometheus' own metrics. Operationally meaningful series include:
|
||||||
|
- **Health/status:** `prometheus_build_info`, `prometheus_ready`,
|
||||||
|
`prometheus_config_last_reload_successful` (+ timestamp), `prometheus_tsdb_clean_start`
|
||||||
|
- **TSDB:** `prometheus_tsdb_head_series`, `prometheus_tsdb_head_samples_appended_total`,
|
||||||
|
`prometheus_tsdb_blocks_loaded`, `prometheus_tsdb_retention_limit_seconds`,
|
||||||
|
`prometheus_tsdb_storage_blocks_bytes`, `prometheus_tsdb_wal_storage_size_bytes`,
|
||||||
|
`prometheus_tsdb_lowest_timestamp`/`_seconds`, `prometheus_tsdb_head_max_time`
|
||||||
|
- **Scraping:** `prometheus_sd_discovered_targets`, `prometheus_sd_failed_configs`,
|
||||||
|
`prometheus_sd_last_update_timestamp_seconds`, `prometheus_target_scrape_pool_targets`,
|
||||||
|
`prometheus_target_scrape_pool_target_limit`, `prometheus_target_interval_length_seconds`
|
||||||
|
- **Rules/alerting:** `prometheus_rule_group_*` (evaluation duration/failures/interval/
|
||||||
|
rules), `prometheus_rule_evaluation_failures_total`, `prometheus_evaluations_total`,
|
||||||
|
`prometheus_notifications_alertmanagers_discovered`, `prometheus_notifications_queue_capacity`
|
||||||
|
- **Engine/HTTP:** `prometheus_engine_queries`, `prometheus_http_requests_total`,
|
||||||
|
`prometheus_http_request_duration_seconds_*`, `prometheus_remote_storage_*` (if remote write enabled)
|
||||||
|
- Standard Go/process/promhttp/scrape boilerplate.
|
||||||
|
|
||||||
|
## Redis Target (redis_exporter)
|
||||||
|
Go-based redis exporter. Key operational series:
|
||||||
|
- **Health:** `redis_up`, `redis_instance_info`, `redis_uptime_in_seconds`,
|
||||||
|
`redis_start_time_seconds`, `redis_exporter_last_scrape_error`
|
||||||
|
- **Memory:** `redis_memory_used_bytes`, `redis_memory_used_peak_bytes`,
|
||||||
|
`redis_memory_max_bytes`, `redis_memory_used_rss_bytes`, `redis_mem_fragmentation_ratio`,
|
||||||
|
`redis_mem_fragmentation_bytes`
|
||||||
|
- **Clients/connections:** `redis_connected_clients`, `redis_max_clients`,
|
||||||
|
`redis_connections_received_total`, `redis_rejected_connections_total`,
|
||||||
|
`redis_blocked_clients`
|
||||||
|
- **Commands/latency:** `redis_commands_processed_total`, `redis_commands_failed_calls_total`,
|
||||||
|
`redis_commands_duration_seconds_total`, `redis_commands_latencies_usec_*`,
|
||||||
|
`redis_latency_percentiles_usec`, `redis_errors_total`
|
||||||
|
- **Keys/eviction/expiry:** `redis_db_keys`, `redis_db_keys_expiring`, `redis_db_avg_ttl_seconds`,
|
||||||
|
`redis_keyspace_hits_total`, `redis_keyspace_misses_total`, `redis_evicted_keys_total`,
|
||||||
|
`redis_expired_keys_total`
|
||||||
|
- **Persistence:** `redis_rdb_last_bgsave_status`, `redis_rdb_last_bgsave_duration_sec`,
|
||||||
|
`redis_aof_enabled`, `redis_aof_last_write_status`, `redis_aof_current_size_bytes`
|
||||||
|
- **Replication/pubsub:** `redis_connected_slaves`, `redis_master_repl_offset`,
|
||||||
|
`redis_replication_backlog_bytes`, `redis_pubsub_channels`, `redis_pubsub_clients`
|
||||||
|
- **Config:** `redis_config_maxmemory`, `redis_config_maxclients`
|
||||||
|
- Standard process/promhttp/scrape boilerplate.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The only alert-related series present are Prometheus' standard `ALERTS` and
|
||||||
|
`ALERTS_FOR_STATE` on the Chat and Playout targets; no concrete rule thresholds
|
||||||
|
are recorded in this file.
|
||||||
|
- No dashboard definitions, alert thresholds, curl outputs, or numeric sample
|
||||||
|
values are captured here — this document is purely the exposed-metric inventory
|
||||||
|
per target.
|
||||||
@@ -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)
|
||||||
@@ -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.
|
|
||||||
+14
-81
@@ -1,9 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Linear Void Admin</title>
|
<title>Linear Void Admin</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="../src/style.css" />
|
<link rel="stylesheet" href="../src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -18,103 +22,31 @@
|
|||||||
<button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button>
|
<button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button>
|
||||||
|
|
||||||
<nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical">
|
<nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical">
|
||||||
<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">
|
||||||
type="button"
|
|
||||||
id="tab-trigger-dashboard"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger is-active"
|
|
||||||
data-tab="dashboard"
|
|
||||||
aria-controls="tab-dashboard"
|
|
||||||
aria-selected="true"
|
|
||||||
>
|
|
||||||
Dashboard
|
Dashboard
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" id="tab-trigger-upload" role="tab" class="tab-trigger" data-tab="upload" aria-controls="tab-upload" aria-selected="false">
|
||||||
type="button"
|
|
||||||
id="tab-trigger-upload"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="upload"
|
|
||||||
aria-controls="tab-upload"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Upload Video
|
Upload Video
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">
|
||||||
type="button"
|
|
||||||
id="tab-trigger-playlist"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="playlist"
|
|
||||||
aria-controls="tab-playlist"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Playlist
|
Playlist
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">
|
||||||
type="button"
|
|
||||||
id="tab-trigger-schedule"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="schedule"
|
|
||||||
aria-controls="tab-schedule"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Schedule
|
Schedule
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">
|
||||||
type="button"
|
|
||||||
id="tab-trigger-moderation"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="moderation"
|
|
||||||
aria-controls="tab-moderation"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Moderation
|
Moderation
|
||||||
</button>
|
</button>
|
||||||
<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">
|
||||||
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
|
Chat Log
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">
|
||||||
type="button"
|
|
||||||
id="tab-trigger-users"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="users"
|
|
||||||
aria-controls="tab-users"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Users
|
Users
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">
|
||||||
type="button"
|
|
||||||
id="tab-trigger-analytics"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="analytics"
|
|
||||||
aria-controls="tab-analytics"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Analytics
|
Analytics
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">
|
||||||
type="button"
|
|
||||||
id="tab-trigger-system"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="system"
|
|
||||||
aria-controls="tab-system"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
System
|
System
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -127,4 +59,5 @@
|
|||||||
|
|
||||||
<script type="module" src="/src/admin.ts"></script>
|
<script type="module" src="/src/admin.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Linear Void Admin Login</title>
|
<title>Linear Void Admin Login</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="../../src/style.css" />
|
<link rel="stylesheet" href="../../src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -62,4 +66,5 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
+63
-17
@@ -1,9 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/src/style.css" />
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -17,14 +21,7 @@
|
|||||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">
|
||||||
id="chat-toggle"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary chat-toggle-btn"
|
|
||||||
onclick="toggleChatPanel()"
|
|
||||||
aria-controls="chat-panel"
|
|
||||||
aria-expanded="true"
|
|
||||||
>
|
|
||||||
Hide Chat
|
Hide Chat
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -44,25 +41,41 @@
|
|||||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||||
<span id="room-name">Main Channel</span>
|
<span id="room-name">Main Channel</span>
|
||||||
</div>
|
</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>
|
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
<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>
|
||||||
|
|
||||||
<section id="presence-panel" class="presence-panel" aria-label="Online users">
|
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
|
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
||||||
|
|
||||||
|
<section id="presence-panel" class="presence-panel">
|
||||||
<div id="presence-list" class="presence-list"></div>
|
<div id="presence-list" class="presence-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="chat-input-area">
|
<div class="chat-input-area">
|
||||||
<div class="chat-input-row">
|
<div class="chat-input-row">
|
||||||
<label for="message-input" class="sr-only">Message input</label>
|
<label for="message-input" class="sr-only">Message input</label>
|
||||||
<input
|
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if (event.key === 'Enter') sendMessage();" />
|
||||||
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>
|
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="chat-note">Synced with video • Moderation active</p>
|
<p class="chat-note">Synced with video • Moderation active</p>
|
||||||
@@ -71,6 +84,39 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
||||||
|
<div class="reconnecting-spinner"></div>
|
||||||
|
<span id="reconnecting-text">Reconnecting...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// Load Matomo analytics when configured via the admin runtime config.
|
||||||
|
fetch("/api/matomo")
|
||||||
|
.then(function (r) {
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
})
|
||||||
|
.then(function (cfg) {
|
||||||
|
if (!cfg || !cfg.matomoUrl || !cfg.matomoSiteId) return;
|
||||||
|
var _paq = (window._paq = window._paq || []);
|
||||||
|
_paq.push(["trackPageView"]);
|
||||||
|
_paq.push(["enableLinkTracking"]);
|
||||||
|
var base = String(cfg.matomoUrl).replace(/\/+$/, "");
|
||||||
|
_paq.push(["setTrackerUrl", base + "/matomo.php"]);
|
||||||
|
_paq.push(["setSiteId", String(cfg.matomoSiteId)]);
|
||||||
|
var d = document;
|
||||||
|
var g = d.createElement("script");
|
||||||
|
var s = d.getElementsByTagName("script")[0];
|
||||||
|
g.async = true;
|
||||||
|
g.src = base + "/matomo.js";
|
||||||
|
s.parentNode.insertBefore(g, s);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
/* analytics is optional */
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Generated
+247
-35
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.0.9",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.0.9",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hls.js": "^1.6.16"
|
"hls.js": "^1.6.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"@vitest/ui": "^4.1.10",
|
"@vitest/ui": "^4.1.10",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
@@ -51,6 +52,66 @@
|
|||||||
"node": "^22.13.0 || >=24.0.0"
|
"node": "^22.13.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/helper-string-parser": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/parser": {
|
||||||
|
"version": "7.29.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
||||||
|
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.29.8"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"parser": "bin/babel-parser.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/types": {
|
||||||
|
"version": "7.29.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||||
|
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bcoe/v8-coverage": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@bramus/specificity": {
|
"node_modules/@bramus/specificity": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
@@ -256,6 +317,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
@@ -263,6 +334,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
|
||||||
@@ -395,9 +477,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -415,9 +494,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -435,9 +511,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -455,9 +528,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -475,9 +545,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -495,9 +562,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -624,6 +688,37 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/coverage-v8": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bcoe/v8-coverage": "^1.0.2",
|
||||||
|
"@vitest/utils": "4.1.10",
|
||||||
|
"ast-v8-to-istanbul": "^1.0.0",
|
||||||
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
|
"istanbul-lib-report": "^3.0.1",
|
||||||
|
"istanbul-reports": "^3.2.0",
|
||||||
|
"magicast": "^0.5.2",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"std-env": "^4.0.0-rc.1",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vitest/browser": "4.1.10",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vitest/browser": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.1.10",
|
"version": "4.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||||
@@ -769,6 +864,18 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ast-v8-to-istanbul": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.31",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"js-tokens": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bidi-js": {
|
"node_modules/bidi-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
@@ -943,6 +1050,16 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hls.js": {
|
"node_modules/hls.js": {
|
||||||
"version": "1.6.16",
|
"version": "1.6.16",
|
||||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||||
@@ -962,6 +1079,13 @@
|
|||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-escaper": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-potential-custom-element-name": {
|
"node_modules/is-potential-custom-element-name": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
@@ -969,6 +1093,52 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/istanbul-lib-coverage": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-report": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"istanbul-lib-coverage": "^3.0.0",
|
||||||
|
"make-dir": "^4.0.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-reports": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"html-escaper": "^2.0.0",
|
||||||
|
"istanbul-lib-report": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/js-tokens": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "30.0.1",
|
"version": "30.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
|
||||||
@@ -1153,9 +1323,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1177,9 +1344,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1201,9 +1365,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1225,9 +1386,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1303,6 +1461,34 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/magicast": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.29.7",
|
||||||
|
"@babel/types": "^7.29.7",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mdn-data": {
|
"node_modules/mdn-data": {
|
||||||
"version": "2.27.1",
|
"version": "2.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||||
@@ -1321,9 +1507,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1489,6 +1675,19 @@
|
|||||||
"node": ">=v12.22.7"
|
"node": ">=v12.22.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
@@ -1535,6 +1734,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/symbol-tree": {
|
"node_modules/symbol-tree": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.11",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"@vitest/ui": "^4.1.10",
|
"@vitest/ui": "^4.1.10",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -271,3 +271,108 @@ file '/app/media/demo2.mp4'
|
|||||||
expect(list.innerHTML).toContain("demo1.mp4");
|
expect(list.innerHTML).toContain("demo1.mp4");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("admin accessibility", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<nav class="admin-nav" aria-label="Admin navigation">
|
||||||
|
<button data-tab="dashboard" aria-current="page">Dashboard</button>
|
||||||
|
<button data-tab="playlist">Playlist</button>
|
||||||
|
<button data-tab="users">Users</button>
|
||||||
|
<button data-tab="moderation">Moderation</button>
|
||||||
|
</nav>
|
||||||
|
<div id="tab-content"></div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nav has aria-label for screen readers", () => {
|
||||||
|
const nav = document.querySelector(".admin-nav");
|
||||||
|
expect(nav?.getAttribute("aria-label")).toBe("Admin navigation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active tab has aria-current attribute", () => {
|
||||||
|
const active = document.querySelector("[aria-current]");
|
||||||
|
expect(active).toBeTruthy();
|
||||||
|
expect(active?.getAttribute("aria-current")).toBe("page");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all nav buttons are focusable", () => {
|
||||||
|
const buttons = document.querySelectorAll(".admin-nav button");
|
||||||
|
buttons.forEach((btn) => {
|
||||||
|
expect(btn.getAttribute("tabindex")).toBeNull();
|
||||||
|
expect(btn.getAttribute("disabled")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("admin responsive layout", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<nav class="admin-nav"><button data-tab="dashboard">Dashboard</button></nav>
|
||||||
|
<div id="tab-content"><div id="overview-content">Overview data</div></div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tab content is present and visible", () => {
|
||||||
|
const tabContent = document.getElementById("tab-content");
|
||||||
|
expect(tabContent).toBeTruthy();
|
||||||
|
const style = window.getComputedStyle(tabContent!);
|
||||||
|
expect(style.display).not.toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin nav contains essential tabs", () => {
|
||||||
|
const buttons = document.querySelectorAll(".admin-nav button");
|
||||||
|
const tabs = Array.from(buttons).map((b) => b.textContent?.trim());
|
||||||
|
expect(tabs).toContain("Dashboard");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("admin data refresh failures", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+204
-32
@@ -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,20 +613,10 @@ 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";
|
|
||||||
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);
|
servicesEl.appendChild(row);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("system-status", "System summary loaded.");
|
setStatus("system-status", "System summary loaded.");
|
||||||
@@ -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(
|
||||||
|
`${API_URL}/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`,
|
||||||
|
{
|
||||||
method: "POST",
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+267
-7
@@ -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(),
|
||||||
@@ -13,7 +19,17 @@ const HlsMock = Object.assign(
|
|||||||
vi.fn().mockImplementation(() => hlsInstance),
|
vi.fn().mockImplementation(() => hlsInstance),
|
||||||
{
|
{
|
||||||
isSupported: vi.fn(() => true),
|
isSupported: vi.fn(() => true),
|
||||||
Events: { MANIFEST_PARSED: "hlsManifestParsed" },
|
Events: {
|
||||||
|
MANIFEST_PARSED: "hlsManifestParsed",
|
||||||
|
ERROR: "hlsError",
|
||||||
|
LEVEL_UPDATED: "hlsLevelUpdated",
|
||||||
|
},
|
||||||
|
ErrorTypes: {
|
||||||
|
NETWORK_ERROR: "networkError",
|
||||||
|
MEDIA_ERROR: "mediaError",
|
||||||
|
MUX_ERROR: "muxError",
|
||||||
|
OTHER_ERROR: "otherError",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -63,35 +79,63 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set up DOM from index.html
|
// Set up DOM from index.html
|
||||||
|
// Inject minimal CSS for jsdom layout tests (external style.css not loaded)
|
||||||
|
document.head.innerHTML = `
|
||||||
|
<style>
|
||||||
|
.viewer-content { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 420px); }
|
||||||
|
.viewer-shell.chat-collapsed .chat-sidebar { display: none; }
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<main class="viewer-shell">
|
<main class="viewer-shell">
|
||||||
<header class="viewer-header">
|
<header class="viewer-header">
|
||||||
<button id="chat-toggle" type="button">Hide Chat</button>
|
<button id="chat-toggle" type="button" aria-expanded="true">Hide Chat</button>
|
||||||
</header>
|
</header>
|
||||||
<section class="viewer-content">
|
<section class="viewer-content">
|
||||||
<section class="player-stage">
|
<section class="player-stage">
|
||||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||||
<div id="now-playing">
|
<div id="now-playing" aria-live="polite">
|
||||||
<span id="np-title">Loading...</span>
|
<span id="np-title">Loading...</span>
|
||||||
<span id="np-next" class="np-next"></span>
|
<span id="np-next" class="np-next"></span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<aside id="chat-panel" class="chat-sidebar">
|
<aside id="chat-panel" class="chat-sidebar" role="complementary" aria-label="Chat panel">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<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>
|
||||||
<section id="messages" class="chat-messages"></section>
|
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
|
||||||
|
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
||||||
<section id="presence-panel" class="presence-panel">
|
<section id="presence-panel" class="presence-panel">
|
||||||
<div id="presence-list" class="presence-list"></div>
|
<div id="presence-list" class="presence-list"></div>
|
||||||
</section>
|
</section>
|
||||||
<div class="chat-input-area">
|
<div class="chat-input-area">
|
||||||
|
<label for="message-input">Message input</label>
|
||||||
<input id="message-input" type="text" placeholder="Type a message..." />
|
<input id="message-input" type="text" placeholder="Type a message..." />
|
||||||
<button type="button" onclick="sendMessage()">Send</button>
|
<button type="button" onclick="sendMessage()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<div id="reconnecting-overlay" role="alert" aria-live="assertive" hidden>
|
||||||
|
<span id="reconnecting-text">Reconnecting...</span>
|
||||||
|
</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
|
||||||
@@ -208,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", () => {
|
||||||
@@ -331,8 +394,205 @@ describe("message rendering", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const messages = document.getElementById("messages")!;
|
const messages = document.getElementById("messages")!;
|
||||||
expect(messages.innerHTML).toContain("alice");
|
|
||||||
expect(messages.innerHTML).toContain("hey");
|
expect(messages.innerHTML).toContain("hey");
|
||||||
|
expect(messages.innerHTML).toContain("alice");
|
||||||
expect(messages.querySelector(".message-other")).toBeTruthy();
|
expect(messages.querySelector(".message-other")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reconnecting overlay", () => {
|
||||||
|
it("shows and hides the overlay with a custom message", () => {
|
||||||
|
showReconnecting("Custom message");
|
||||||
|
const overlay = document.getElementById("reconnecting-overlay")!;
|
||||||
|
expect(overlay.classList.contains("hidden")).toBe(false);
|
||||||
|
expect(document.getElementById("reconnecting-text")!.textContent).toBe("Custom message");
|
||||||
|
|
||||||
|
hideReconnecting();
|
||||||
|
expect(overlay.classList.contains("hidden")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("typing indicator", () => {
|
||||||
|
it("shows typing on a typing event and clears on the next message", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "alice" }) });
|
||||||
|
|
||||||
|
const indicator = document.getElementById("typing-indicator")!;
|
||||||
|
expect(indicator.textContent).toBe("alice is typing...");
|
||||||
|
expect(indicator.hidden).toBe(false);
|
||||||
|
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "public", text: "hi", username: "alice", timestamp: Date.now() }) });
|
||||||
|
expect(indicator.hidden).toBe(true);
|
||||||
|
expect(indicator.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores typing events from the current user", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "viewer" }) });
|
||||||
|
|
||||||
|
const indicator = document.getElementById("typing-indicator")!;
|
||||||
|
expect(indicator.textContent).toBe("");
|
||||||
|
expect(indicator.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendMessage over websocket", () => {
|
||||||
|
it("sends a JSON message and clears the input", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||||
|
input.value = "hello world";
|
||||||
|
window.sendMessage();
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ws.send).toHaveBeenCalledWith(expect.stringContaining("hello world"));
|
||||||
|
expect(input.value).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth wiring", () => {
|
||||||
|
it("logs in and updates the auth UI", async () => {
|
||||||
|
await import("../src/main");
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn(async (url: any) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.includes("/api/auth/login")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ username: "alice", authenticated: true }),
|
||||||
|
text: async () => "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, status: 200, json: async () => ({ authenticated: false }), text: async () => "" };
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
document.dispatchEvent(new Event("DOMContentLoaded"));
|
||||||
|
|
||||||
|
const usernameInput = document.getElementById("auth-username") as HTMLInputElement;
|
||||||
|
const passwordInput = document.getElementById("auth-password") as HTMLInputElement;
|
||||||
|
usernameInput.value = "alice";
|
||||||
|
passwordInput.value = "secret";
|
||||||
|
|
||||||
|
document.getElementById("auth-login-btn")!.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(document.getElementById("auth-status")!.textContent).toContain("Signed in as alice");
|
||||||
|
expect(document.getElementById("chat-user-label")!.textContent).toBe("alice");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("player error recovery", () => {
|
||||||
|
it("shows reconnecting overlay on fatal network error", async () => {
|
||||||
|
const main = await import("../src/main");
|
||||||
|
await main.initPlayerAndChat(async () => ({ default: HlsMock }) as any);
|
||||||
|
|
||||||
|
// Find the error handler registered by initPlayerAndChat
|
||||||
|
const errorHandler = (hlsInstance.on as any).mock.calls.find((c: any) => c[0] === "hlsError") as
|
||||||
|
| [string, Function]
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (errorHandler) {
|
||||||
|
const handler = errorHandler[1];
|
||||||
|
handler("hlsError", {
|
||||||
|
type: "networkError",
|
||||||
|
details: "manifestLoadError",
|
||||||
|
fatal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const overlay = document.getElementById("reconnecting-overlay")!;
|
||||||
|
expect(overlay.classList.contains("hidden")).toBe(false);
|
||||||
|
expect(document.getElementById("reconnecting-text")?.textContent).toBe("Reconnecting...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("accessibility", () => {
|
||||||
|
it("player video has controls and aria attributes", () => {
|
||||||
|
const video = document.getElementById("player") as HTMLVideoElement;
|
||||||
|
expect(video.controls).toBe(true);
|
||||||
|
expect(video.hasAttribute("autoplay")).toBe(true);
|
||||||
|
expect(video.hasAttribute("playsinline")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chat panel has aria-labels for live region", () => {
|
||||||
|
const messages = document.getElementById("messages");
|
||||||
|
expect(messages?.getAttribute("aria-live")).toBe("polite");
|
||||||
|
|
||||||
|
const chatPanel = document.getElementById("chat-panel");
|
||||||
|
expect(chatPanel?.getAttribute("aria-label")).toBe("Chat panel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chat toggle exposes aria-expanded state", () => {
|
||||||
|
const toggle = document.getElementById("chat-toggle")!;
|
||||||
|
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
|
||||||
|
window.toggleChatPanel();
|
||||||
|
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("message input has associated label", () => {
|
||||||
|
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||||
|
expect(input.getAttribute("type")).toBe("text");
|
||||||
|
expect(input.placeholder).toBe("Type a message...");
|
||||||
|
const label = document.querySelector("label[for='message-input']");
|
||||||
|
expect(label).toBeTruthy();
|
||||||
|
expect(label?.textContent).toBe("Message input");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconnecting overlay is an aria-live assertive region", () => {
|
||||||
|
const overlay = document.getElementById("reconnecting-overlay");
|
||||||
|
expect(overlay?.getAttribute("role")).toBe("alert");
|
||||||
|
expect(overlay?.getAttribute("aria-live")).toBe("assertive");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("responsive layout", () => {
|
||||||
|
it("chat-collapsed class hides sidebar at narrow viewport", () => {
|
||||||
|
const shell = document.querySelector(".viewer-shell")!;
|
||||||
|
expect(shell.classList.contains("chat-collapsed")).toBe(false);
|
||||||
|
|
||||||
|
window.toggleChatPanel();
|
||||||
|
expect(shell.classList.contains("chat-collapsed")).toBe(true);
|
||||||
|
|
||||||
|
window.toggleChatPanel();
|
||||||
|
expect(shell.classList.contains("chat-collapsed")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("viewer-shell uses CSS grid for content layout", () => {
|
||||||
|
const content = document.querySelector(".viewer-content");
|
||||||
|
expect(content).toBeTruthy();
|
||||||
|
const style = window.getComputedStyle(content!);
|
||||||
|
expect(style.display).toBe("grid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("now-playing overlay is accessible at small sizes", () => {
|
||||||
|
const np = document.getElementById("now-playing");
|
||||||
|
expect(np?.getAttribute("aria-live")).toBe("polite");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("presence flash", () => {
|
||||||
|
it("flashes join/leave messages instead of appending chat rows", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||||
|
|
||||||
|
const flash = document.getElementById("chat-flash")!;
|
||||||
|
expect(flash.textContent).toBe("alice joined");
|
||||||
|
expect(flash.hidden).toBe(false);
|
||||||
|
|
||||||
|
// Join/leave must not be rendered as chat rows.
|
||||||
|
const messages = document.getElementById("messages")!;
|
||||||
|
expect(messages.textContent).not.toContain("joined");
|
||||||
|
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) });
|
||||||
|
expect(flash.textContent).toBe("alice left");
|
||||||
|
});
|
||||||
|
});
|
||||||
+298
-11
@@ -15,16 +15,31 @@ 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)
|
||||||
|
let playerErrorCount = 0;
|
||||||
|
const PLAYER_MAX_RETRIES = 5;
|
||||||
|
const PLAYER_BASE_DELAY_MS = 2000;
|
||||||
|
|
||||||
export function resetViewerStateForTests() {
|
export function resetViewerStateForTests() {
|
||||||
hls = null;
|
hls = null;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveWebSocketBaseUrl() {
|
export function resolveWebSocketBaseUrl() {
|
||||||
@@ -61,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";
|
||||||
|
|
||||||
@@ -120,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;
|
||||||
@@ -131,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 }>;
|
||||||
@@ -159,10 +224,62 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
|
|||||||
try {
|
try {
|
||||||
const { default: Hls } = await loadHlsModule();
|
const { default: Hls } = await loadHlsModule();
|
||||||
if (Hls.isSupported()) {
|
if (Hls.isSupported()) {
|
||||||
hls = new Hls();
|
hls = new Hls({
|
||||||
|
liveDurationInfinity: true,
|
||||||
|
liveSyncDurationCount: 3,
|
||||||
|
});
|
||||||
hls.loadSource(manifestUrl);
|
hls.loadSource(manifestUrl);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
|
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
|
||||||
|
|
||||||
|
// Player error recovery (STREAMING.md contract)
|
||||||
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
|
if (!data.fatal) return;
|
||||||
|
|
||||||
|
playerErrorCount++;
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
|
if (playerErrorCount <= PLAYER_MAX_RETRIES) {
|
||||||
|
const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1);
|
||||||
|
console.warn(
|
||||||
|
`HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`,
|
||||||
|
);
|
||||||
|
showReconnecting("Reconnecting...");
|
||||||
|
setTimeout(() => {
|
||||||
|
hls?.startLoad();
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
console.error("HLS network error: max retries exhausted");
|
||||||
|
showReconnecting("Stream temporarily unavailable");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
|
if (playerErrorCount <= 1) {
|
||||||
|
console.warn("HLS media error, attempting recovery...");
|
||||||
|
showReconnecting("Recovering...");
|
||||||
|
hls!.recoverMediaError();
|
||||||
|
} else {
|
||||||
|
console.error("HLS media error: recovery failed");
|
||||||
|
hls!.destroy();
|
||||||
|
showReconnecting("Stream temporarily unavailable");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error("HLS fatal error:", data.type, data.details);
|
||||||
|
hls!.destroy();
|
||||||
|
showReconnecting("Stream temporarily unavailable");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset error count on successful manifest parse
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
playerErrorCount = 0;
|
||||||
|
hideReconnecting();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load HLS player", error);
|
console.error("Failed to load HLS player", error);
|
||||||
@@ -170,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
|
||||||
@@ -179,6 +299,24 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
|
|||||||
console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready");
|
console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function showReconnecting(message = "Reconnecting...") {
|
||||||
|
const overlay = document.getElementById("reconnecting-overlay");
|
||||||
|
const text = document.getElementById("reconnecting-text");
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
text.textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideReconnecting() {
|
||||||
|
const overlay = document.getElementById("reconnecting-overlay");
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleChatPanel() {
|
function toggleChatPanel() {
|
||||||
const shell = document.querySelector(".viewer-shell");
|
const shell = document.querySelector(".viewer-shell");
|
||||||
const toggle = document.getElementById("chat-toggle");
|
const toggle = document.getElementById("chat-toggle");
|
||||||
@@ -210,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,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);
|
||||||
});
|
});
|
||||||
+191
-5
@@ -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;
|
||||||
@@ -1056,3 +1203,42 @@ select {
|
|||||||
scroll-behavior: auto !important;
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reconnecting overlay (player error recovery) */
|
||||||
|
.reconnecting-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-4, 1rem);
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnecting-overlay.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnecting-spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 3px solid var(--line, #2a2a2a);
|
||||||
|
border-top-color: var(--brand, #587792);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: lv-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lv-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#reconnecting-text {
|
||||||
|
color: var(--ink-0, #ede8e6);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
@@ -4,5 +4,17 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
include: ["src/**/*.test.ts"],
|
include: ["src/**/*.test.ts"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "text-summary"],
|
||||||
|
include: ["src/**/*.ts"],
|
||||||
|
exclude: ["src/**/*.test.ts"],
|
||||||
|
thresholds: {
|
||||||
|
statements: 20,
|
||||||
|
branches: 15,
|
||||||
|
functions: 15,
|
||||||
|
lines: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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))"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,984 @@
|
|||||||
|
{
|
||||||
|
"apiVersion": "dashboard.grafana.app/v2",
|
||||||
|
"kind": "Dashboard",
|
||||||
|
"metadata": {
|
||||||
|
"name": "linearvoid-nginx",
|
||||||
|
"namespace": "default",
|
||||||
|
"uid": "d92799b5-4b53-41bb-8fbc-d5cc7aadd858",
|
||||||
|
"resourceVersion": "1786469269844989",
|
||||||
|
"generation": 2,
|
||||||
|
"creationTimestamp": "2026-08-11T17:21:15Z",
|
||||||
|
"labels": {
|
||||||
|
"grafana.app/deprecatedInternalID": "2061233167626240"
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"grafana.app/createdBy": "user:efu3jht65vz0gf",
|
||||||
|
"grafana.app/saved-from-ui": "Grafana v13.1.3 (45a27d64b6)",
|
||||||
|
"grafana.app/updatedBy": "user:efu3jht65vz0gf",
|
||||||
|
"grafana.app/updatedTimestamp": "2026-08-11T17:27:49Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"annotations": [
|
||||||
|
{
|
||||||
|
"kind": "AnnotationQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "grafana",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "-- Grafana --"
|
||||||
|
},
|
||||||
|
"spec": {}
|
||||||
|
},
|
||||||
|
"enable": true,
|
||||||
|
"hide": true,
|
||||||
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
|
"name": "Annotations & Alerts",
|
||||||
|
"builtIn": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cursorSync": "Off",
|
||||||
|
"editable": true,
|
||||||
|
"elements": {
|
||||||
|
"panel-1": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 1,
|
||||||
|
"title": "NGINX Up",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "nginx_up"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "stat",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"percentChangeColorMode": "standard",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"showPercentChange": false,
|
||||||
|
"textMode": "auto",
|
||||||
|
"wideLayout": true
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "red"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 1,
|
||||||
|
"color": "green"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel-2": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 2,
|
||||||
|
"title": "Active Connections",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "nginx_connections_active"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "timeseries",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"annotations": {
|
||||||
|
"clustering": -1,
|
||||||
|
"multiLane": false
|
||||||
|
},
|
||||||
|
"legend": {
|
||||||
|
"calcs": [],
|
||||||
|
"displayMode": "list",
|
||||||
|
"enableFacetedFilter": false,
|
||||||
|
"overflow": "ellipsis",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "single",
|
||||||
|
"sort": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 80,
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 0,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "auto",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel-3": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 3,
|
||||||
|
"title": "Connections Reading / Writing / Waiting",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "nginx_connections_reading",
|
||||||
|
"legendFormat": "reading"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "nginx_connections_writing",
|
||||||
|
"legendFormat": "writing"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "B",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "nginx_connections_waiting",
|
||||||
|
"legendFormat": "waiting"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "C",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "timeseries",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"annotations": {
|
||||||
|
"clustering": -1,
|
||||||
|
"multiLane": false
|
||||||
|
},
|
||||||
|
"legend": {
|
||||||
|
"calcs": [],
|
||||||
|
"displayMode": "list",
|
||||||
|
"enableFacetedFilter": false,
|
||||||
|
"overflow": "ellipsis",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "single",
|
||||||
|
"sort": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 80,
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 0,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "auto",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel-4": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 4,
|
||||||
|
"title": "Accepted / Handled Connections",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "nginx_connections_accepted",
|
||||||
|
"legendFormat": "accepted"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "nginx_connections_handled",
|
||||||
|
"legendFormat": "handled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "B",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "timeseries",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"annotations": {
|
||||||
|
"clustering": -1,
|
||||||
|
"multiLane": false
|
||||||
|
},
|
||||||
|
"legend": {
|
||||||
|
"calcs": [],
|
||||||
|
"displayMode": "list",
|
||||||
|
"enableFacetedFilter": false,
|
||||||
|
"overflow": "ellipsis",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "single",
|
||||||
|
"sort": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 80,
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 0,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "auto",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel-5": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 5,
|
||||||
|
"title": "HTTP Request Rate",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "rate(nginx_http_requests_total[5m])"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "timeseries",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"annotations": {
|
||||||
|
"clustering": -1,
|
||||||
|
"multiLane": false
|
||||||
|
},
|
||||||
|
"legend": {
|
||||||
|
"calcs": [],
|
||||||
|
"displayMode": "list",
|
||||||
|
"enableFacetedFilter": false,
|
||||||
|
"overflow": "ellipsis",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "single",
|
||||||
|
"sort": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "reqps",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 80,
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 0,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "auto",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel-6": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 6,
|
||||||
|
"title": "HTTP Requests (total)",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "nginx_http_requests_total"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "stat",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"percentChangeColorMode": "standard",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"showPercentChange": false,
|
||||||
|
"textMode": "auto",
|
||||||
|
"wideLayout": true
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 80,
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel-7": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 7,
|
||||||
|
"title": "Connection Drop Ratio",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "(nginx_connections_accepted - nginx_connections_handled) / nginx_connections_accepted"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "timeseries",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"annotations": {
|
||||||
|
"clustering": -1,
|
||||||
|
"multiLane": false
|
||||||
|
},
|
||||||
|
"legend": {
|
||||||
|
"calcs": [],
|
||||||
|
"displayMode": "list",
|
||||||
|
"enableFacetedFilter": false,
|
||||||
|
"overflow": "ellipsis",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "single",
|
||||||
|
"sort": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 80,
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 0,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "auto",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"kind": "GridLayout",
|
||||||
|
"spec": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 12,
|
||||||
|
"height": 5,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 0,
|
||||||
|
"width": 10,
|
||||||
|
"height": 5,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 22,
|
||||||
|
"y": 0,
|
||||||
|
"width": 2,
|
||||||
|
"height": 5,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 5,
|
||||||
|
"width": 12,
|
||||||
|
"height": 5,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 5,
|
||||||
|
"width": 12,
|
||||||
|
"height": 5,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 10,
|
||||||
|
"width": 12,
|
||||||
|
"height": 5,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 10,
|
||||||
|
"width": 12,
|
||||||
|
"height": 5,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"links": [],
|
||||||
|
"liveNow": false,
|
||||||
|
"preload": false,
|
||||||
|
"tags": [],
|
||||||
|
"timeSettings": {
|
||||||
|
"timezone": "browser",
|
||||||
|
"from": "now-6h",
|
||||||
|
"to": "now",
|
||||||
|
"autoRefresh": "",
|
||||||
|
"autoRefreshIntervals": [
|
||||||
|
"5s",
|
||||||
|
"10s",
|
||||||
|
"30s",
|
||||||
|
"1m",
|
||||||
|
"5m",
|
||||||
|
"15m",
|
||||||
|
"30m",
|
||||||
|
"1h",
|
||||||
|
"2h",
|
||||||
|
"1d"
|
||||||
|
],
|
||||||
|
"hideTimepicker": false,
|
||||||
|
"fiscalYearStartMonth": 0
|
||||||
|
},
|
||||||
|
"title": "Linearvoid NGINX",
|
||||||
|
"variables": []
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,923 @@
|
|||||||
|
{
|
||||||
|
"apiVersion": "dashboard.grafana.app/v2",
|
||||||
|
"kind": "Dashboard",
|
||||||
|
"metadata": {
|
||||||
|
"name": "linearvoid-playout",
|
||||||
|
"namespace": "default",
|
||||||
|
"uid": "cd256b89-2a57-4202-9c26-d9b6047ca55b",
|
||||||
|
"resourceVersion": "1786469434829007",
|
||||||
|
"generation": 2,
|
||||||
|
"creationTimestamp": "2026-08-11T17:17:57Z",
|
||||||
|
"labels": {
|
||||||
|
"grafana.app/deprecatedInternalID": "2060404050194432"
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"grafana.app/createdBy": "user:efu3jht65vz0gf",
|
||||||
|
"grafana.app/saved-from-ui": "Grafana v13.1.3 (45a27d64b6)",
|
||||||
|
"grafana.app/updatedBy": "user:efu3jht65vz0gf",
|
||||||
|
"grafana.app/updatedTimestamp": "2026-08-11T17:30:34Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"annotations": [
|
||||||
|
{
|
||||||
|
"kind": "AnnotationQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "grafana",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "-- Grafana --"
|
||||||
|
},
|
||||||
|
"spec": {}
|
||||||
|
},
|
||||||
|
"enable": true,
|
||||||
|
"hide": true,
|
||||||
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
|
"name": "Annotations & Alerts",
|
||||||
|
"builtIn": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cursorSync": "Off",
|
||||||
|
"editable": true,
|
||||||
|
"elements": {
|
||||||
|
"panel-1": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 1,
|
||||||
|
"title": "Playout Service Up",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "up{job=\"playout\"}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "stat",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"percentChangeColorMode": "standard",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"showPercentChange": false,
|
||||||
|
"textMode": "auto",
|
||||||
|
"wideLayout": true
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "red"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 1,
|
||||||
|
"color": "green"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel-2": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 2,
|
||||||
|
"title": "Playout Mode",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "linearvoid_playout_mode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "stat",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"percentChangeColorMode": "standard",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"showPercentChange": false,
|
||||||
|
"textMode": "auto",
|
||||||
|
"wideLayout": true
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 1,
|
||||||
|
"color": "orange"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 2,
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel-3": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 3,
|
||||||
|
"title": "FFmpeg Restarts (total)",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "linearvoid_playout_restarts_total"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "stat",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"percentChangeColorMode": "standard",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"showPercentChange": false,
|
||||||
|
"textMode": "auto",
|
||||||
|
"wideLayout": true
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 80,
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel-4": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 4,
|
||||||
|
"title": "FFmpeg Restart Rate",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "rate(linearvoid_playout_restarts_total[5m])"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "timeseries",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"annotations": {
|
||||||
|
"clustering": -1,
|
||||||
|
"multiLane": false
|
||||||
|
},
|
||||||
|
"legend": {
|
||||||
|
"calcs": [],
|
||||||
|
"displayMode": "list",
|
||||||
|
"enableFacetedFilter": false,
|
||||||
|
"overflow": "ellipsis",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "single",
|
||||||
|
"sort": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "ops/s",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 80,
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 0,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "auto",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel-5": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 5,
|
||||||
|
"title": "Playout Mode Over Time",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "linearvoid_playout_mode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "timeseries",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"annotations": {
|
||||||
|
"clustering": -1,
|
||||||
|
"multiLane": false
|
||||||
|
},
|
||||||
|
"legend": {
|
||||||
|
"calcs": [],
|
||||||
|
"displayMode": "list",
|
||||||
|
"enableFacetedFilter": false,
|
||||||
|
"overflow": "ellipsis",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "single",
|
||||||
|
"sort": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 1,
|
||||||
|
"color": "orange"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 2,
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 0,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "auto",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel-6": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 6,
|
||||||
|
"title": "FFmpeg Restarts (cumulative)",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "linearvoid_playout_restarts_total"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "timeseries",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"annotations": {
|
||||||
|
"clustering": -1,
|
||||||
|
"multiLane": false
|
||||||
|
},
|
||||||
|
"legend": {
|
||||||
|
"calcs": [],
|
||||||
|
"displayMode": "list",
|
||||||
|
"enableFacetedFilter": false,
|
||||||
|
"overflow": "ellipsis",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "single",
|
||||||
|
"sort": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 80,
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 0,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "auto",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel-7": {
|
||||||
|
"kind": "Panel",
|
||||||
|
"spec": {
|
||||||
|
"id": 7,
|
||||||
|
"title": "Scrape Duration (playout)",
|
||||||
|
"description": "",
|
||||||
|
"links": [],
|
||||||
|
"data": {
|
||||||
|
"kind": "QueryGroup",
|
||||||
|
"spec": {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"kind": "PanelQuery",
|
||||||
|
"spec": {
|
||||||
|
"query": {
|
||||||
|
"kind": "DataQuery",
|
||||||
|
"group": "prometheus",
|
||||||
|
"version": "v0",
|
||||||
|
"datasource": {
|
||||||
|
"name": "PBFA97CFB590B2093"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"expr": "scrape_duration_seconds{job=\"playout\"}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [],
|
||||||
|
"queryOptions": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vizConfig": {
|
||||||
|
"kind": "VizConfig",
|
||||||
|
"group": "timeseries",
|
||||||
|
"version": "13.1.3",
|
||||||
|
"spec": {
|
||||||
|
"options": {
|
||||||
|
"annotations": {
|
||||||
|
"clustering": -1,
|
||||||
|
"multiLane": false
|
||||||
|
},
|
||||||
|
"legend": {
|
||||||
|
"calcs": [],
|
||||||
|
"displayMode": "list",
|
||||||
|
"enableFacetedFilter": false,
|
||||||
|
"overflow": "ellipsis",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "single",
|
||||||
|
"sort": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "s",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"color": "green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 80,
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 0,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "auto",
|
||||||
|
"showValues": false,
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"kind": "GridLayout",
|
||||||
|
"spec": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"width": 4,
|
||||||
|
"height": 5,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 4,
|
||||||
|
"y": 0,
|
||||||
|
"width": 4,
|
||||||
|
"height": 5,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 8,
|
||||||
|
"y": 0,
|
||||||
|
"width": 4,
|
||||||
|
"height": 5,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 0,
|
||||||
|
"width": 12,
|
||||||
|
"height": 5,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 5,
|
||||||
|
"width": 12,
|
||||||
|
"height": 5,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 5,
|
||||||
|
"width": 12,
|
||||||
|
"height": 5,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "GridLayoutItem",
|
||||||
|
"spec": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 10,
|
||||||
|
"width": 24,
|
||||||
|
"height": 4,
|
||||||
|
"element": {
|
||||||
|
"kind": "ElementReference",
|
||||||
|
"name": "panel-7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"links": [],
|
||||||
|
"liveNow": false,
|
||||||
|
"preload": false,
|
||||||
|
"tags": [],
|
||||||
|
"timeSettings": {
|
||||||
|
"timezone": "browser",
|
||||||
|
"from": "now-6h",
|
||||||
|
"to": "now",
|
||||||
|
"autoRefresh": "",
|
||||||
|
"autoRefreshIntervals": [
|
||||||
|
"5s",
|
||||||
|
"10s",
|
||||||
|
"30s",
|
||||||
|
"1m",
|
||||||
|
"5m",
|
||||||
|
"15m",
|
||||||
|
"30m",
|
||||||
|
"1h",
|
||||||
|
"2h",
|
||||||
|
"1d"
|
||||||
|
],
|
||||||
|
"hideTimepicker": false,
|
||||||
|
"fiscalYearStartMonth": 0
|
||||||
|
},
|
||||||
|
"title": "Linearvoid Playout",
|
||||||
|
"variables": []
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
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
|
||||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user