Compare commits
37
Commits
1.0.11
...
568112b85a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -8,6 +8,7 @@ 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_SEGMENT_RETENTION_HOURS=24
|
||||||
|
|
||||||
# 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
|
||||||
@@ -56,6 +57,11 @@ PLAYLIST_PATH=/app/playlist/playlist.txt
|
|||||||
HLS_SEGMENT_DURATION=6
|
HLS_SEGMENT_DURATION=6
|
||||||
HLS_WINDOW_SIZE=24
|
HLS_WINDOW_SIZE=24
|
||||||
|
|
||||||
|
# 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
|
||||||
PROMETHEUS_PORT=9090
|
PROMETHEUS_PORT=9090
|
||||||
|
|||||||
+31
-2
@@ -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,15 +33,42 @@ 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=6
|
||||||
HLS_WINDOW_SIZE=24
|
HLS_WINDOW_SIZE=24
|
||||||
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
|
||||||
NGINX_PORT=80
|
NGINX_PORT=80
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
@@ -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,57 @@
|
|||||||
|
name: Deploy to Cloudflare Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cd frontend && npm run build
|
||||||
|
|
||||||
|
- name: Generate deployment manifest
|
||||||
|
run: |
|
||||||
|
cd frontend/dist
|
||||||
|
form_args="-F manifest={"
|
||||||
|
for file in $(find . -type f ! -name manifest.json | sed 's|^\./||'); do
|
||||||
|
hash=$(sha256sum "$file" | awk '{print $1}')
|
||||||
|
form_args="$form_args\"$file\":\"$hash\","
|
||||||
|
done
|
||||||
|
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
||||||
|
echo "Form arguments: $form_args"
|
||||||
|
echo "FORM_ARGS=$form_args" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Pages
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: |
|
||||||
|
cd frontend/dist
|
||||||
|
form_args="$FORM_ARGS" # use form arguments generated in previous step
|
||||||
|
# Get short commit hash for deployment metadata
|
||||||
|
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
COMMIT_MSG=$(git log -1 --pretty=%B 2>/dev/null | head -1 || echo "Gitea Actions deploy")
|
||||||
|
|
||||||
|
curl -sS -X POST \
|
||||||
|
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/linearvoid/deployments" \
|
||||||
|
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||||
|
-F "commit_hash=${COMMIT_HASH}" \
|
||||||
|
-F "commit_message=${COMMIT_MSG}" \
|
||||||
|
-F "commit_dirty=false" \
|
||||||
|
$form_args
|
||||||
+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,136 @@
|
|||||||
|
# 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 `pages-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.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# BACKLOG
|
||||||
|
|
||||||
|
> All current work is planned in [TODO](./TODO.md) (2026-08-13). This file lists backlog items; new unplanned work is added here.
|
||||||
|
|
||||||
|
## Cloudflare Configuration
|
||||||
|
|
||||||
|
### DNS & Pages
|
||||||
|
|
||||||
|
- [ ] Cloudflare Pages: link custom domain (manual)
|
||||||
|
- [ ] Create Cloudflare Pages project 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
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## Feature List (Future Enhancements)
|
||||||
|
|
||||||
|
- [ ] Admin backend
|
||||||
|
- [ ] Analytics: implement a settings entry to set matomo url and page id
|
||||||
|
- [ ] User frontend
|
||||||
|
- [ ] Chat: add user registration and login flows
|
||||||
|
- [ ] Chat: private DM history and search
|
||||||
|
- [ ] Analytics: add javascript snippet to send pageview events to matomo
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,57 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
|
||||||
|
|
||||||
|
## Setup phases remaining
|
||||||
|
|
||||||
|
### Phase 5: complete manual Cloudflare and external-service setup
|
||||||
|
|
||||||
|
- [ ] Apply Cloudflare Pages, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
|
||||||
|
- [ ] Cloudflare Pages: 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`
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -2,6 +2,49 @@
|
|||||||
|
|
||||||
<!-- markdownlint-disable MD024 -->
|
<!-- markdownlint-disable MD024 -->
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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,177 @@ 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
|
||||||
|
- [worker.js](infrastructure/cloudflare/worker.js) — adds immutable cache headers to HLS segments
|
||||||
|
- 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.ps1](scripts/loadtest/run-loadtests.ps1) — PowerShell script to run load tests
|
||||||
|
- [summarize-artillery.ps1](scripts/loadtest/summarize-artillery.ps1) — PowerShell script to summarize Artillery load test results
|
||||||
|
- [summarize-k6.ps1](scripts/loadtest/summarize-k6.ps1) — PowerShell script to summarize k6 load test results
|
||||||
|
- [ws-chat.yml](scripts/loadtest/ws-chat.yml) — WebSocket chat load test configuration
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -138,3 +138,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)
|
||||||
|
}
|
||||||
@@ -246,3 +246,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 = 6
|
||||||
|
defaultHLSWindowSize = 24
|
||||||
|
)
|
||||||
|
|
||||||
|
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 != 6 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 6", got)
|
||||||
|
}
|
||||||
|
if got := HLSWindowSize(); got != 24 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 24", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGettersEnvFallback(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
os.Setenv("HLS_SEGMENT_DURATION", "4")
|
||||||
|
os.Setenv("HLS_WINDOW_SIZE", "12")
|
||||||
|
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 != 12 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 12", 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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,7 @@ 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{})
|
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 +113,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 +478,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 +493,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)
|
||||||
|
|
||||||
@@ -945,3 +1233,132 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,17 @@ func (h *Hub) Broadcast(msg db.Message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 == "" {
|
||||||
@@ -224,6 +235,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)
|
||||||
|
|||||||
@@ -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=
|
||||||
+494
-45
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -16,6 +18,8 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
"github.com/aws/aws-sdk-go-v2/config"
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VideoFile represents a normalized video entry in the playlist
|
// VideoFile represents a normalized video entry in the playlist
|
||||||
@@ -31,12 +35,41 @@ var (
|
|||||||
putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
return client.PutObject(context.TODO(), input)
|
return client.PutObject(context.TODO(), input)
|
||||||
}
|
}
|
||||||
|
listObjects = func(client *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||||
|
return client.ListObjectsV2(context.TODO(), input)
|
||||||
|
}
|
||||||
|
deleteObjects = func(client *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||||
|
return client.DeleteObjects(context.TODO(), input)
|
||||||
|
}
|
||||||
bucket = os.Getenv("R2_BUCKET_NAME")
|
bucket = os.Getenv("R2_BUCKET_NAME")
|
||||||
playoutMode = "starting" // "live", "slate", "degraded"
|
playoutMode = "starting" // "live", "slate", "degraded"
|
||||||
restartCount = 0
|
restartCount = 0
|
||||||
restartWindow = time.Now()
|
restartWindow = time.Now()
|
||||||
slateDir = "/app/slate"
|
slateDir = "/app/slate"
|
||||||
hlsDir = "/app/hls"
|
hlsDir = "/app/hls"
|
||||||
|
hlsSegmentDuration = envWithDefault("HLS_SEGMENT_DURATION", "6")
|
||||||
|
hlsWindowSize = envWithDefault("HLS_WINDOW_SIZE", "24")
|
||||||
|
|
||||||
|
// Unique segment naming (STREAMING.md contract: r-{runID}/seg-{seq}.m4s)
|
||||||
|
runID string
|
||||||
|
segSequence uint64
|
||||||
|
|
||||||
|
// Health endpoint detail (STREAMING.md contract)
|
||||||
|
activeItem string
|
||||||
|
playlistRevision string
|
||||||
|
lastSegmentSeq uint64
|
||||||
|
lastR2PubTime time.Time
|
||||||
|
consecutiveFails int
|
||||||
|
lastFFmpegStartTime time.Time
|
||||||
|
lastFFmpegExitError error
|
||||||
|
|
||||||
|
// R2 retention cleanup counter
|
||||||
|
r2CleanupObjectsTotal int64
|
||||||
|
|
||||||
|
// Continuous playout loop metrics
|
||||||
|
loopsTotal int64
|
||||||
|
lastRunDuration time.Duration
|
||||||
|
lastRunClean bool
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -44,11 +77,32 @@ const (
|
|||||||
slateManifestName = "playlist.m3u8"
|
slateManifestName = "playlist.m3u8"
|
||||||
// Cache-Control header values for R2 uploads.
|
// Cache-Control header values for R2 uploads.
|
||||||
segmentCacheControl = "public, max-age=31536000, immutable"
|
segmentCacheControl = "public, max-age=31536000, immutable"
|
||||||
manifestCacheControl = "public, max-age=2"
|
manifestCacheControl = "public, max-age=1, s-maxage=2, must-revalidate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// initR2Client initializes the R2 (S3-compatible) client
|
// envWithDefault returns the value of the environment variable or the default if not set.
|
||||||
|
func envWithDefault(key, defaultVal string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// isR2Configured returns true when the required R2 environment variables are set.
|
||||||
|
func isR2Configured() bool {
|
||||||
|
return os.Getenv("R2_ENDPOINT") != "" &&
|
||||||
|
os.Getenv("R2_ACCESS_KEY_ID") != "" &&
|
||||||
|
os.Getenv("R2_SECRET_ACCESS_KEY") != "" &&
|
||||||
|
os.Getenv("R2_BUCKET_NAME") != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// initR2Client initializes the R2 (S3-compatible) client.
|
||||||
|
// Returns an error if required environment variables are missing.
|
||||||
func initR2Client() error {
|
func initR2Client() error {
|
||||||
|
if !isR2Configured() {
|
||||||
|
return fmt.Errorf("R2 not configured: R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, or R2_BUCKET_NAME is empty")
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.LoadDefaultConfig(context.TODO(),
|
cfg, err := config.LoadDefaultConfig(context.TODO(),
|
||||||
config.WithRegion("auto"),
|
config.WithRegion("auto"),
|
||||||
config.WithCredentialsProvider(aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) {
|
config.WithCredentialsProvider(aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) {
|
||||||
@@ -67,6 +121,84 @@ func initR2Client() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initRunID generates a unique run/session ID for globally unique segment naming (STREAMING.md contract).
|
||||||
|
func initRunID() {
|
||||||
|
runID = "r-" + time.Now().UTC().Format("20060102T150405Z")
|
||||||
|
segSequence = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchAndPublishSegments watches the HLS output directory for new .m4s segments
|
||||||
|
// and uploads them to R2 in real time with runID-based keys (STREAMING.md contract).
|
||||||
|
func watchAndPublishSegments(ctx context.Context, hlsOutputDir string) {
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Warning: fsnotify unavailable, R2 sync falls back to batch:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer watcher.Close()
|
||||||
|
|
||||||
|
if err := watcher.Add(hlsOutputDir); err != nil {
|
||||||
|
fmt.Println("Warning: cannot watch HLS dir:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event, ok := <-watcher.Events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.Op&fsnotify.Create != 0 && strings.HasSuffix(event.Name, ".m4s") {
|
||||||
|
// Wait for FFmpeg to finish writing the segment
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
segSequence++
|
||||||
|
lastSegmentSeq = segSequence
|
||||||
|
key := "hls/" + runID + "/" + filepath.Base(event.Name)
|
||||||
|
if err := SyncToR2(event.Name, key); err != nil {
|
||||||
|
consecutiveFails++
|
||||||
|
fmt.Printf("Failed to sync segment %s: %v\n", event.Name, err)
|
||||||
|
} else {
|
||||||
|
consecutiveFails = 0
|
||||||
|
lastR2PubTime = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case err, ok := <-watcher.Errors:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("fsnotify error:", err)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishManifest uploads the manifest atomically: temp key first, then copy to final key (STREAMING.md contract).
|
||||||
|
func publishManifest(localManifestPath string) error {
|
||||||
|
if r2Client == nil {
|
||||||
|
if err := initR2Client(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Upload to temp key
|
||||||
|
if err := SyncToR2(localManifestPath, "hls/playlist.m3u8.tmp"); err != nil {
|
||||||
|
return fmt.Errorf("temp manifest upload failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Atomic copy to final key
|
||||||
|
_, err := r2Client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
||||||
|
Bucket: aws.String(bucket),
|
||||||
|
CopySource: aws.String(bucket + "/hls/playlist.m3u8.tmp"),
|
||||||
|
Key: aws.String("hls/playlist.m3u8"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("manifest atomic swap failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// LoadPlaylist reads a playlist file and returns a list of VideoFile entries
|
// LoadPlaylist reads a playlist file and returns a list of VideoFile entries
|
||||||
func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
||||||
file, err := os.Open(playlistPath)
|
file, err := os.Open(playlistPath)
|
||||||
@@ -336,16 +468,37 @@ func SyncToR2(localPath, r2Key string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthHandler provides a simple health check endpoint
|
// HealthHandler provides a detailed health check endpoint (STREAMING.md contract)
|
||||||
func HealthHandler(w http.ResponseWriter, r *http.Request) {
|
func HealthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
status := "healthy"
|
status := "healthy"
|
||||||
if playoutMode == "slate" {
|
if playoutMode == "slate" || playoutMode == "degraded" {
|
||||||
status = "degraded"
|
status = "degraded"
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
// Unhealthy if manifest has not advanced for >2 segment durations (12s)
|
||||||
fmt.Fprintf(w, `{"status":"%s","service":"playout","mode":"%s","restarts":%d,"timestamp":"%s"}`,
|
if hlsDir != "" && time.Since(lastR2PubTime) > 12*time.Second && playoutMode != "starting" {
|
||||||
status, playoutMode, restartCount, time.Now().UTC().Format(time.RFC3339))
|
status = "unhealthy"
|
||||||
|
}
|
||||||
|
|
||||||
|
lastExit := ""
|
||||||
|
if lastFFmpegExitError != nil {
|
||||||
|
lastExit = lastFFmpegExitError.Error()
|
||||||
|
}
|
||||||
|
resp := map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"service": "playout",
|
||||||
|
"mode": playoutMode,
|
||||||
|
"crash_count": restartCount,
|
||||||
|
"run_id": runID,
|
||||||
|
"active_item": activeItem,
|
||||||
|
"playlist_revision": playlistRevision,
|
||||||
|
"last_segment_seq": lastSegmentSeq,
|
||||||
|
"last_r2_pub": lastR2PubTime.UTC().Format(time.RFC3339),
|
||||||
|
"consecutive_failures": consecutiveFails,
|
||||||
|
"last_exit_error": lastExit,
|
||||||
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// activateSlate copies pre-rendered slate HLS into the live output directory.
|
// activateSlate copies pre-rendered slate HLS into the live output directory.
|
||||||
@@ -442,33 +595,57 @@ func resetHLSOutput(hlsDir string) error {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
playlistPath := "/app/playlist/playlist.txt"
|
playlistPath := "/app/playlist/playlist.txt"
|
||||||
if len(os.Args) > 1 {
|
validateOnly := false
|
||||||
playlistPath = os.Args[1]
|
for _, arg := range os.Args[1:] {
|
||||||
|
switch arg {
|
||||||
|
case "-validate":
|
||||||
|
validateOnly = true
|
||||||
|
default:
|
||||||
|
if !strings.HasPrefix(arg, "-") {
|
||||||
|
playlistPath = arg
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if validateOnly {
|
||||||
|
runValidateOnly(playlistPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load shared HLS runtime config (written by the chat admin) before starting.
|
||||||
|
loadSharedHLSConfig()
|
||||||
|
|
||||||
fmt.Println("=== Linear Void Playout Service Starting ===")
|
fmt.Println("=== Linear Void Playout Service Starting ===")
|
||||||
fmt.Println("Playlist:", playlistPath)
|
fmt.Println("Playlist:", playlistPath)
|
||||||
fmt.Println("R2 Bucket:", bucket)
|
fmt.Println("R2 Bucket:", bucket)
|
||||||
|
|
||||||
|
// Initialize run ID for globally unique segment naming (STREAMING.md)
|
||||||
|
initRunID()
|
||||||
|
fmt.Println("Run ID:", runID)
|
||||||
|
|
||||||
// Validate playlist before starting (ADR-001 normalization enforcement)
|
// Validate playlist before starting (ADR-001 normalization enforcement)
|
||||||
if _, err := ValidatePlaylist(playlistPath); err != nil {
|
if _, err := ValidatePlaylist(playlistPath); err != nil {
|
||||||
fmt.Println("ERROR: Playlist validation failed:", err)
|
fmt.Println("ERROR: Playlist validation failed:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize R2 client
|
// Initialize R2 client (skip if not configured — local HLS via nginx still works)
|
||||||
if err := initR2Client(); err != nil {
|
if err := initR2Client(); err != nil {
|
||||||
fmt.Println("Warning: R2 client initialization failed:", err)
|
fmt.Println("R2 sync disabled:", err)
|
||||||
// Continue without R2 for local testing
|
}
|
||||||
|
|
||||||
|
// Start R2 segment retention cleanup (hourly + once at startup)
|
||||||
|
if r2Client != nil {
|
||||||
|
go runR2CleanupLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start HTTP health check server
|
// Start HTTP health check server
|
||||||
http.HandleFunc("/health", HealthHandler)
|
http.HandleFunc("/health", HealthHandler)
|
||||||
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
fmt.Fprintln(w, "# HELP linearvoid_playout_restarts_total Total FFmpeg restarts.")
|
fmt.Fprintln(w, "# HELP linearvoid_playout_crashes_total Total FFmpeg crashes (not clean completions).")
|
||||||
fmt.Fprintln(w, "# TYPE linearvoid_playout_restarts_total counter")
|
fmt.Fprintln(w, "# TYPE linearvoid_playout_crashes_total counter")
|
||||||
fmt.Fprintf(w, "linearvoid_playout_restarts_total %d\n", restartCount)
|
fmt.Fprintf(w, "linearvoid_playout_crashes_total %d\n", restartCount)
|
||||||
fmt.Fprintln(w, "# HELP linearvoid_playout_mode Current playout mode (0=live, 1=slate, 2=degraded).")
|
fmt.Fprintln(w, "# HELP linearvoid_playout_mode Current playout mode (0=live, 1=slate, 2=degraded).")
|
||||||
fmt.Fprintln(w, "# TYPE linearvoid_playout_mode gauge")
|
fmt.Fprintln(w, "# TYPE linearvoid_playout_mode gauge")
|
||||||
modeVal := 0
|
modeVal := 0
|
||||||
@@ -478,6 +655,15 @@ func main() {
|
|||||||
modeVal = 2
|
modeVal = 2
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "linearvoid_playout_mode %d\n", modeVal)
|
fmt.Fprintf(w, "linearvoid_playout_mode %d\n", modeVal)
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_playout_r2_cleanup_objects_total Total R2 objects deleted by retention cleanup.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_cleanup_objects_total counter")
|
||||||
|
fmt.Fprintf(w, "linearvoid_playout_r2_cleanup_objects_total %d\n", r2CleanupObjectsTotal)
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_playout_loops_total Total clean playlist completions (loops).")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_playout_loops_total counter")
|
||||||
|
fmt.Fprintf(w, "linearvoid_playout_loops_total %d\n", loopsTotal)
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_playout_run_duration_seconds Duration of the last FFmpeg run in seconds.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_playout_run_duration_seconds gauge")
|
||||||
|
fmt.Fprintf(w, "linearvoid_playout_run_duration_seconds %f\n", lastRunDuration.Seconds())
|
||||||
})
|
})
|
||||||
go func() {
|
go func() {
|
||||||
fmt.Println("Health check server listening on :8081/health")
|
fmt.Println("Health check server listening on :8081/health")
|
||||||
@@ -493,13 +679,51 @@ func main() {
|
|||||||
fmt.Printf("[%s] Slate active — attempting live playout recovery...\n", time.Now().Format(time.RFC3339))
|
fmt.Printf("[%s] Slate active — attempting live playout recovery...\n", time.Now().Format(time.RFC3339))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New run ID on each restart for unique R2 segment keys (STREAMING.md)
|
||||||
|
initRunID()
|
||||||
|
|
||||||
|
// Reset HLS output only when the playlist changed, we are recovering from a
|
||||||
|
// crash/slate, or this is the first run. Otherwise continue the segment
|
||||||
|
// sequence so the loop is seamless for viewers (no gap on clean loops).
|
||||||
|
fingerprint, fpErr := playlistFingerprint(playlistPath)
|
||||||
|
if fpErr != nil {
|
||||||
|
fmt.Println("Warning: cannot fingerprint playlist:", fpErr)
|
||||||
|
fingerprint = ""
|
||||||
|
}
|
||||||
|
playlistChanged := fingerprint != playlistRevision
|
||||||
|
shouldReset := playlistChanged || playoutMode != "live" || !lastRunClean
|
||||||
|
|
||||||
|
windowSize := 24
|
||||||
|
if ws, err := strconv.Atoi(hlsWindowSize); err == nil && ws > 0 {
|
||||||
|
windowSize = ws
|
||||||
|
}
|
||||||
|
|
||||||
|
startNumber := 0
|
||||||
|
if shouldReset {
|
||||||
if err := resetHLSOutput(hlsDir); err != nil {
|
if err := resetHLSOutput(hlsDir); err != nil {
|
||||||
fmt.Println("Failed to reset HLS output:", err)
|
fmt.Println("Failed to reset HLS output:", err)
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
playlistRevision = fingerprint
|
||||||
|
} else {
|
||||||
|
if n, err := hlsNextStartNumber(hlsDir, windowSize); err == nil {
|
||||||
|
startNumber = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("[%s] Starting FFmpeg playout with browser-safe H.264/AAC HLS encoding...\n", time.Now().Format(time.RFC3339))
|
// Start filesystem watcher for real-time R2 segment upload (STREAMING.md).
|
||||||
|
// Only active when R2 is configured; otherwise the watcher is skipped.
|
||||||
|
var watchCtx context.Context
|
||||||
|
var watchCancel context.CancelFunc
|
||||||
|
if r2Client != nil {
|
||||||
|
watchCtx, watchCancel = context.WithCancel(context.Background())
|
||||||
|
go watchAndPublishSegments(watchCtx, hlsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastFFmpegStartTime = time.Now()
|
||||||
|
fmt.Printf("[%s] Starting FFmpeg playout (run %s) with browser-safe H.264/AAC HLS encoding...\n",
|
||||||
|
lastFFmpegStartTime.UTC().Format(time.RFC3339), runID)
|
||||||
|
|
||||||
cmd := exec.Command("ffmpeg", "-re", "-f", "concat", "-safe", "0", "-i", playlistPath,
|
cmd := exec.Command("ffmpeg", "-re", "-f", "concat", "-safe", "0", "-i", playlistPath,
|
||||||
"-map", "0:v:0", "-map", "0:a:0?",
|
"-map", "0:v:0", "-map", "0:a:0?",
|
||||||
@@ -509,57 +733,90 @@ func main() {
|
|||||||
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
||||||
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k",
|
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k",
|
||||||
"-f", "hls",
|
"-f", "hls",
|
||||||
"-hls_time", "6",
|
"-hls_time", hlsSegmentDuration,
|
||||||
"-hls_list_size", "24",
|
"-hls_list_size", hlsWindowSize,
|
||||||
"-hls_segment_type", "fmp4",
|
"-hls_segment_type", "fmp4",
|
||||||
"-hls_fmp4_init_filename", "init.mp4",
|
"-hls_fmp4_init_filename", "init.mp4",
|
||||||
"-strftime", "1",
|
|
||||||
"-hls_flags", "delete_segments+independent_segments",
|
"-hls_flags", "delete_segments+independent_segments",
|
||||||
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.m4s",
|
"-hls_segment_filename", filepath.Join(hlsDir, "seg-%06d.m4s"),
|
||||||
"/app/hls/playlist.m3u8")
|
"-start_number", strconv.Itoa(startNumber),
|
||||||
|
filepath.Join(hlsDir, "playlist.m3u8"))
|
||||||
|
|
||||||
runErr := cmd.Run()
|
runErr := cmd.Run()
|
||||||
|
runDuration := time.Since(lastFFmpegStartTime)
|
||||||
|
lastFFmpegExitError = runErr
|
||||||
|
lastRunDuration = runDuration
|
||||||
|
lastRunClean = runErr == nil
|
||||||
|
|
||||||
// Track restart and check for slate escalation
|
if watchCancel != nil {
|
||||||
|
watchCancel() // Stop the filesystem watcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// After FFmpeg exits, sync remaining segments and publish manifest to R2.
|
||||||
|
// Do this before logging/restart logic so R2 is up to date regardless of exit cause.
|
||||||
|
if r2Client != nil {
|
||||||
|
if err := syncLatestToR2(hlsDir); err != nil {
|
||||||
|
fmt.Println("R2 sync warning:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish manifest atomically (STREAMING.md safe publication)
|
||||||
|
manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
|
||||||
|
if err := publishManifest(manifestPath); err != nil {
|
||||||
|
fmt.Println("Manifest publish warning:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Differentiate clean playlist completion from FFmpeg crash.
|
||||||
|
// A nil error means FFmpeg completed the playlist normally — not a crash.
|
||||||
|
if runErr == nil {
|
||||||
|
loopsTotal++
|
||||||
|
fmt.Printf("[%s] Playlist completed normally after %s. Looping...\n",
|
||||||
|
time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second))
|
||||||
|
// If we were in slate mode, a successful run means recovery
|
||||||
|
if playoutMode == "slate" {
|
||||||
|
deactivateSlate()
|
||||||
|
}
|
||||||
|
// Minimal delay for clean loop — just enough for watcher goroutine cleanup
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// FFmpeg exited with an error — this is a real crash
|
||||||
restartCount++
|
restartCount++
|
||||||
fmt.Printf("[%s] FFmpeg exited (restart #%d in window): %v\n",
|
fmt.Printf("[%s] FFmpeg CRASHED after %s (crash #%d in window): %v\n",
|
||||||
time.Now().Format(time.RFC3339), restartCount, runErr)
|
time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second), restartCount, runErr)
|
||||||
|
|
||||||
if shouldEscalateToSlate() {
|
if shouldEscalateToSlate() {
|
||||||
fmt.Printf("[%s] CRITICAL: %d restarts in 60s — escalating to slate fallback\n",
|
fmt.Printf("[%s] CRITICAL: %d crashes in 60s — escalating to slate fallback\n",
|
||||||
time.Now().Format(time.RFC3339), restartCount)
|
time.Now().UTC().Format(time.RFC3339), restartCount)
|
||||||
if err := activateSlate(); err != nil {
|
if err := activateSlate(); err != nil {
|
||||||
fmt.Printf("[%s] Slate activation failed: %v — will retry\n",
|
fmt.Printf("[%s] Slate activation failed: %v — will retry\n",
|
||||||
time.Now().Format(time.RFC3339), err)
|
time.Now().UTC().Format(time.RFC3339), err)
|
||||||
}
|
}
|
||||||
// Stay in slate mode, retry recovery after delay
|
// Stay in slate mode, retry recovery after delay
|
||||||
time.Sleep(30 * time.Second)
|
time.Sleep(30 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we were in slate but FFmpeg ran successfully, we recovered
|
// If we were in slate but FFmpeg ran long enough to be a recovery, deactivate slate
|
||||||
if playoutMode == "slate" {
|
if playoutMode == "slate" && runDuration > 30*time.Second {
|
||||||
deactivateSlate()
|
deactivateSlate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// After FFmpeg run, sync latest segments and manifest to R2
|
// Backoff delay after crash before retry
|
||||||
if err := syncLatestToR2(hlsDir); err != nil {
|
time.Sleep(2 * time.Second)
|
||||||
fmt.Println("R2 sync warning:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncLatestToR2 uploads new segments and the playlist manifest to R2
|
// syncLatestToR2 uploads new segments to R2 with runID-based keys
|
||||||
func syncLatestToR2(hlsDir string) error {
|
func syncLatestToR2(hlsDir string) error {
|
||||||
files, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.m4s"))
|
files, err := filepath.Glob(filepath.Join(hlsDir, "seg-*.m4s"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
key := "live/" + filepath.Base(f)
|
key := "hls/" + runID + "/" + filepath.Base(f)
|
||||||
if err := SyncToR2(f, key); err != nil {
|
if err := SyncToR2(f, key); err != nil {
|
||||||
fmt.Printf("Failed to sync %s: %v\n", f, err)
|
fmt.Printf("Failed to sync %s: %v\n", f, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -567,16 +824,208 @@ func syncLatestToR2(hlsDir string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync the live playlist manifest (short TTL per ADR-001)
|
// Sync init segment to run-specific R2 key
|
||||||
manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
|
|
||||||
if err := SyncToR2(manifestPath, "live/playlist.m3u8"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
initPath := filepath.Join(hlsDir, "init.mp4")
|
initPath := filepath.Join(hlsDir, "init.mp4")
|
||||||
if err := SyncToR2(initPath, "live/init.mp4"); err != nil {
|
if err := SyncToR2(initPath, "hls/"+runID+"/init.mp4"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println("Synced live playlist.m3u8 to R2")
|
fmt.Println("Synced init.mp4 to R2")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// r2SegmentRetentionHours returns the R2 segment retention window in hours.
|
||||||
|
func r2SegmentRetentionHours() time.Duration {
|
||||||
|
h, err := strconv.Atoi(envWithDefault("R2_SEGMENT_RETENTION_HOURS", "24"))
|
||||||
|
if err != nil || h <= 0 {
|
||||||
|
h = 24
|
||||||
|
}
|
||||||
|
return time.Duration(h) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupOldSegments deletes R2 objects under the hls/ prefix that are older than
|
||||||
|
// the retention window, excluding the live manifest and any non-run keys.
|
||||||
|
// It returns the number of objects deleted.
|
||||||
|
func cleanupOldSegments(ctx context.Context) (int, error) {
|
||||||
|
if r2Client == nil {
|
||||||
|
if err := initR2Client(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().UTC().Add(-r2SegmentRetentionHours())
|
||||||
|
|
||||||
|
var stale []string
|
||||||
|
var token *string
|
||||||
|
for {
|
||||||
|
output, err := listObjects(r2Client, &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(bucket),
|
||||||
|
Prefix: aws.String("hls/"),
|
||||||
|
ContinuationToken: token,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, obj := range output.Contents {
|
||||||
|
key := aws.ToString(obj.Key)
|
||||||
|
if key == "hls/playlist.m3u8" || key == "hls/playlist.m3u8.tmp" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Only consider segment/init objects under a run directory: hls/r-.../...
|
||||||
|
if !strings.HasPrefix(key, "hls/r-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if obj.LastModified != nil && obj.LastModified.Before(cutoff) {
|
||||||
|
stale = append(stale, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if output.IsTruncated == nil || !*output.IsTruncated {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
token = output.NextContinuationToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(stale) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted := 0
|
||||||
|
for i := 0; i < len(stale); i += 1000 {
|
||||||
|
end := i + 1000
|
||||||
|
if end > len(stale) {
|
||||||
|
end = len(stale)
|
||||||
|
}
|
||||||
|
batch := stale[i:end]
|
||||||
|
objects := make([]types.ObjectIdentifier, len(batch))
|
||||||
|
for j, key := range batch {
|
||||||
|
objects[j] = types.ObjectIdentifier{Key: aws.String(key)}
|
||||||
|
}
|
||||||
|
if _, err := deleteObjects(r2Client, &s3.DeleteObjectsInput{
|
||||||
|
Bucket: aws.String(bucket),
|
||||||
|
Delete: &types.Delete{Objects: objects, Quiet: aws.Bool(true)},
|
||||||
|
}); err != nil {
|
||||||
|
return deleted, err
|
||||||
|
}
|
||||||
|
for _, key := range batch {
|
||||||
|
fmt.Printf("R2 cleanup: deleted %s\n", key)
|
||||||
|
}
|
||||||
|
deleted += len(batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
r2CleanupObjectsTotal += int64(deleted)
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runR2CleanupLoop runs retention cleanup once at startup, then hourly.
|
||||||
|
func runR2CleanupLoop() {
|
||||||
|
if _, err := cleanupOldSegments(context.Background()); err != nil {
|
||||||
|
fmt.Println("R2 cleanup error:", err)
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
if _, err := cleanupOldSegments(context.Background()); err != nil {
|
||||||
|
fmt.Println("R2 cleanup error:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// playlistFingerprint returns a short hash of the playlist file content, used to
|
||||||
|
// detect playlist changes between loops.
|
||||||
|
func playlistFingerprint(playlistPath string) (string, error) {
|
||||||
|
data, err := os.ReadFile(playlistPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hlsNextStartNumber returns the next segment sequence number to use, and prunes
|
||||||
|
// stale segments outside the live window so they do not accumulate across loops.
|
||||||
|
func hlsNextStartNumber(hlsDir string, windowSize int) (int, error) {
|
||||||
|
files, err := filepath.Glob(filepath.Join(hlsDir, "seg-*.m4s"))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
seqOf := func(f string) int {
|
||||||
|
base := filepath.Base(f)
|
||||||
|
num := strings.TrimSuffix(strings.TrimPrefix(base, "seg-"), ".m4s")
|
||||||
|
n, err := strconv.Atoi(num)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSeq := -1
|
||||||
|
for _, f := range files {
|
||||||
|
if n := seqOf(f); n > maxSeq {
|
||||||
|
maxSeq = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next := maxSeq + 1
|
||||||
|
|
||||||
|
// Keep the live window; delete older segments no longer referenced.
|
||||||
|
threshold := next - windowSize
|
||||||
|
for _, f := range files {
|
||||||
|
if n := seqOf(f); n >= 0 && n < threshold {
|
||||||
|
if err := os.Remove(f); err != nil && !os.IsNotExist(err) {
|
||||||
|
return next, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runValidateOnly validates the playlist and exits 0 (valid) or 1 (invalid/missing).
|
||||||
|
func runValidateOnly(playlistPath string) {
|
||||||
|
videos, err := ValidatePlaylist(playlistPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("ERROR: Playlist validation failed:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
valid := true
|
||||||
|
for _, v := range videos {
|
||||||
|
if v.Valid {
|
||||||
|
fmt.Printf("OK: %s (%.1fs)\n", v.Path, v.Duration)
|
||||||
|
} else {
|
||||||
|
valid = false
|
||||||
|
fmt.Printf("INVALID: %s — %s\n", v.Path, v.Validation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Playlist valid.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSharedHLSConfig reads HLS settings from the shared runtime config file
|
||||||
|
// (written by the chat admin service) and overrides the env-derived defaults.
|
||||||
|
func loadSharedHLSConfig() {
|
||||||
|
path := envWithDefault("PLAYOUT_CONFIG_PATH", "/app/playlist/config.json")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
fmt.Println("Warning: cannot read shared config:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cfg map[string]string
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
fmt.Println("Warning: cannot parse shared config:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(cfg["hls_segment_duration"]); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
hlsSegmentDuration = strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(cfg["hls_window_size"]); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
hlsWindowSize = strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("Shared HLS config: segment=%ss window=%s\n", hlsSegmentDuration, hlsWindowSize)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -462,15 +498,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 +592,189 @@ func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
|
|||||||
t.Fatalf("ContentType = %q, want empty", gotContentType)
|
t.Fatalf("ContentType = %q, want empty", gotContentType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestR2SegmentRetentionHours(t *testing.T) {
|
||||||
|
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "")
|
||||||
|
if got := r2SegmentRetentionHours(); got != 24*time.Hour {
|
||||||
|
t.Errorf("default = %v, want 24h", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "48")
|
||||||
|
if got := r2SegmentRetentionHours(); got != 48*time.Hour {
|
||||||
|
t.Errorf("override = %v, want 48h", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "invalid")
|
||||||
|
if got := r2SegmentRetentionHours(); got != 24*time.Hour {
|
||||||
|
t.Errorf("invalid = %v, want 24h fallback", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Unsetenv("R2_SEGMENT_RETENTION_HOURS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupOldSegments(t *testing.T) {
|
||||||
|
prevClient := r2Client
|
||||||
|
prevBucket := bucket
|
||||||
|
prevList := listObjects
|
||||||
|
prevDelete := deleteObjects
|
||||||
|
prevTotal := r2CleanupObjectsTotal
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
listObjects = prevList
|
||||||
|
deleteObjects = prevDelete
|
||||||
|
r2CleanupObjectsTotal = prevTotal
|
||||||
|
}()
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-25 * time.Hour)
|
||||||
|
recent := now.Add(-1 * time.Hour)
|
||||||
|
|
||||||
|
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||||
|
return &s3.ListObjectsV2Output{
|
||||||
|
Contents: []types.Object{
|
||||||
|
{Key: aws.String("hls/r-old/seg-1.m4s"), LastModified: &old},
|
||||||
|
{Key: aws.String("hls/r-old/init.mp4"), LastModified: &old},
|
||||||
|
{Key: aws.String("hls/r-new/seg-2.m4s"), LastModified: &recent},
|
||||||
|
{Key: aws.String("hls/playlist.m3u8"), LastModified: &old},
|
||||||
|
{Key: aws.String("hls/playlist.m3u8.tmp"), LastModified: &old},
|
||||||
|
},
|
||||||
|
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 := cleanupOldSegments(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cleanupOldSegments error: %v", err)
|
||||||
|
}
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("deleted = %d, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.Join(deletedKeys, ",")
|
||||||
|
if !strings.Contains(joined, "hls/r-old/seg-1.m4s") || !strings.Contains(joined, "hls/r-old/init.mp4") {
|
||||||
|
t.Fatalf("unexpected deleted keys: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if strings.Contains(joined, "playlist.m3u8") {
|
||||||
|
t.Fatalf("manifest must not be deleted: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if r2CleanupObjectsTotal != 2 {
|
||||||
|
t.Fatalf("r2CleanupObjectsTotal = %d, want 2", r2CleanupObjectsTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,36 +19,13 @@ fi
|
|||||||
|
|
||||||
# Run normalization validation using the Go tool
|
# Run normalization validation using the Go tool
|
||||||
echo "Validating playlist and enforcing normalization (ADR-001/ADR-003)..."
|
echo "Validating playlist and enforcing normalization (ADR-001/ADR-003)..."
|
||||||
if ! /app/playlist-manager "$PLAYLIST_PATH"; then
|
if ! /app/playlist-manager -validate "$PLAYLIST_PATH"; then
|
||||||
echo "ERROR: Playlist validation failed. Check logs."
|
echo "ERROR: Playlist validation failed. Check logs."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Playlist validated successfully. Starting continuous HLS playout..."
|
echo "Playlist validated successfully. Starting continuous HLS playout..."
|
||||||
|
|
||||||
mkdir -p /app/hls
|
# Single source of truth: the Go binary runs the full loop (HLS, R2 sync,
|
||||||
rm -f /app/hls/playlist.m3u8 /app/hls/init.mp4 /app/hls/segment_*.ts /app/hls/segment_*.m4s
|
# slate fallback, health server). The shell no longer duplicates the FFmpeg loop.
|
||||||
|
exec /app/playlist-manager "$PLAYLIST_PATH"
|
||||||
# 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
|
|
||||||
@@ -168,6 +168,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"
|
||||||
|
|||||||
@@ -162,6 +162,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"
|
||||||
|
|||||||
+44
-17
@@ -72,7 +72,7 @@ Before the cutover in the plan above, treat the following as the minimum readine
|
|||||||
1. Verify the Hetzner host is reachable over SSH and Docker remote API TLS is working for the Gitea runner.
|
1. Verify the Hetzner host is reachable over SSH and Docker remote API TLS is working for the Gitea runner.
|
||||||
2. Confirm all required production secrets are present under the `CI_PROD_*` prefix.
|
2. Confirm all required production secrets are present under the `CI_PROD_*` prefix.
|
||||||
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv` DNS targets are correct for the intended phase.
|
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv` DNS targets are correct for the intended phase.
|
||||||
4. Complete the manual Cloudflare/UptimeRobot checklist and attach evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`.
|
4. Complete the manual Cloudflare/UptimeRobot checklist and attach evidence in [Cloudflare Uptimerobot checklist](../docs/operations/cloudflare-uptimerobot-checklist.md).
|
||||||
5. Run the production smoke path end to end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, and playout health.
|
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.
|
6. Verify rollback is still possible before the final DNS switch.
|
||||||
|
|
||||||
@@ -94,10 +94,11 @@ Readiness is not complete until the checklist above and the manual evidence trac
|
|||||||
Three Gitea Actions workflows drive the full pipeline:
|
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 Docker 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 Docker images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
|
||||||
|
| `pages-deploy.yml` | Push to `main` | Deploy frontend to Cloudflare Pages (see `docs/operations/gitea-cloudflare-pages.md`) |
|
||||||
|
|
||||||
CI and CD run simultaneously on every push to `main`. The deploy pulls whatever `:latest` images are available in the registry at that moment.
|
CI and CD run simultaneously on every push to `main`. The deploy pulls whatever `:latest` images are available in the registry at that moment.
|
||||||
|
|
||||||
@@ -130,22 +131,48 @@ No code changes needed in the pipeline. See `DEPLOYMENT.md` Production Deploymen
|
|||||||
|
|
||||||
## Environment File
|
## Environment File
|
||||||
|
|
||||||
Use .env with at least:
|
Use `.env` with at least:
|
||||||
|
|
||||||
- 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 write credentials
|
||||||
|
- `R2_BUCKET_NAME` — bucket name (e.g. `linearvoid-media`)
|
||||||
|
- `R2_ENDPOINT` — S3-compatible endpoint URL
|
||||||
|
- `R2_REGION` — set to `auto`
|
||||||
|
|
||||||
|
**Cloudflare (optional, for Workers/Pages):**
|
||||||
|
|
||||||
|
- `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
|
||||||
|
|
||||||
|
The CD workflow (`cd.yml`) injects sensitive values from Gitea secrets into the `.env` file at deploy time. The following secrets must be configured in the Gitea repository:
|
||||||
|
|
||||||
|
| Secret Name | Description |
|
||||||
|
| ------------------------- | ------------------------------------ |
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection string |
|
||||||
|
| `ADMIN_USERNAME` | Admin dashboard username |
|
||||||
|
| `ADMIN_PASSWORD` | Admin dashboard password |
|
||||||
|
| `R2_ACCESS_KEY_ID` | Cloudflare R2 access key |
|
||||||
|
| `R2_SECRET_ACCESS_KEY` | Cloudflare R2 secret key |
|
||||||
|
| `R2_BUCKET_NAME` | R2 bucket name |
|
||||||
|
| `R2_ENDPOINT` | R2 S3-compatible endpoint URL |
|
||||||
|
| `R2_ADMIN_ACCESS_KEY_ID` | R2 admin access key (optional) |
|
||||||
|
| `R2_ADMIN_SECRET_ACCESS_KEY` | R2 admin secret key (optional) |
|
||||||
|
| `R2_ADMIN_ENDPOINT` | R2 admin endpoint (optional) |
|
||||||
|
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token (optional) |
|
||||||
|
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID (optional) |
|
||||||
|
| `CI_REGISTRY_USER` | Gitea container registry username |
|
||||||
|
| `CI_REGISTRY_TOKEN` | Gitea container registry token |
|
||||||
|
|
||||||
## Pull-and-Run Deployment
|
## Pull-and-Run Deployment
|
||||||
|
|
||||||
@@ -265,7 +292,7 @@ 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 (for example Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx, see [nginx.conf](../infrastructure/nginx/nginx.conf).
|
||||||
- For resumable admin uploads, ensure client_max_body_size supports chunk requests in both proxy layers.
|
- For resumable admin uploads, ensure client_max_body_size supports chunk requests in both proxy layers.
|
||||||
|
|
||||||
## Security Baseline
|
## Security Baseline
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -89,3 +95,4 @@ Config: `monitoring/prometheus/prometheus.yml` (15s scrape interval)
|
|||||||
- 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.
|
||||||
|
- Metrics retention is set to **30 days** time-based plus a **15GB** size cap (`--storage.tsdb.retention.time=30d --storage.tsdb.retention.size=15GB` on the `prometheus` service in `docker-compose.yml` and `docker-compose.prod.yml`). Increase the size cap before extending the time window so the TSDB does not hit the disk limit first.
|
||||||
+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 and Pages.
|
||||||
|
- [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.
|
||||||
+31
-73
@@ -1,13 +1,15 @@
|
|||||||
# Linear Void Setup Guide
|
# Linear Void Setup Guide
|
||||||
|
|
||||||
|
**Last Updated**: 2026-08-03
|
||||||
|
|
||||||
This guide covers prerequisites, environment setup, running the application locally and in production, and troubleshooting. It references the [architecture documentation](./docs/architecture/README.md)(`docs/architecture/README.md`).
|
This guide covers prerequisites, environment setup, running the application locally and in production, and troubleshooting. It references the [architecture documentation](./docs/architecture/README.md)(`docs/architecture/README.md`).
|
||||||
|
|
||||||
## Prerequisites
|
## 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**: Required for running the full stack (playout, chat, nginx, redis, db, prometheus, grafana). Install from [docker.com](https://www.docker.com).
|
||||||
- **Node.js 20+**: Required for frontend development and production builds (`cd frontend && npm install && npm run build`).
|
- **Node.js 22+**: Required for frontend development and production builds (`cd frontend && npm install && npm run build`).
|
||||||
- **npm**: Installed with Node.js and used for the frontend dependency installation and Vite workflow.
|
- **npm**: Installed with Node.js and used for the frontend dependency installation and Vite workflow.
|
||||||
- **Go 1.23+**: Recommended when working on the Go services outside Docker (`backend/chat`, `backend/playout`).
|
- **Go 1.25+**: Recommended when working on the Go services outside Docker (`backend/chat`, `backend/playout`).
|
||||||
- **FFmpeg**: Used by the playout service for HLS generation. Install via `apt install ffmpeg` (Linux), Homebrew (macOS), or Winget (Windows).
|
- **FFmpeg**: Used by the playout service for HLS generation. Install via `apt install ffmpeg` (Linux), Homebrew (macOS), or Winget (Windows).
|
||||||
- **Git**: For cloning and version control.
|
- **Git**: For cloning and version control.
|
||||||
- **Cloudflare Account**: For R2 storage and CDN (free tier sufficient for most use cases).
|
- **Cloudflare Account**: For R2 storage and CDN (free tier sufficient for most use cases).
|
||||||
@@ -81,70 +83,7 @@ flowchart LR
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Configure `.env`** (edit with your values):
|
3. **Configure `.env`** (copy from `.env.example` and replace placeholders)
|
||||||
|
|
||||||
```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**:
|
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.yml` builds images locally and maps the frontend build output from `frontend/dist` into nginx.
|
||||||
@@ -223,11 +162,27 @@ flowchart LR
|
|||||||
- Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`.
|
- 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**:
|
6. **Apply cache behavior**:
|
||||||
- Configure the `.m4s` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
- Configure the `.m4s` and `.m3u8` cache rules from [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
|
||||||
- Enable Smart Tiered Cache for the R2-backed hostname.
|
- 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.
|
- If you need header normalization for immutable segment caching, deploy `infrastructure/cloudflare/worker.js` to the same zone.
|
||||||
|
|
||||||
7. **Verify access**:
|
7. **Configure R2 CORS**:
|
||||||
|
- Add CORS rules to allow the app domain to access the R2 bucket for HLS delivery.
|
||||||
|
- Example CORS configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"AllowedOrigins": ["https://linearvoid.tv"],
|
||||||
|
"AllowedMethods": ["GET"],
|
||||||
|
"AllowedHeaders": ["*"],
|
||||||
|
"ExposeHeaders": ["ETag", "Cache-Control"],
|
||||||
|
"MaxAgeSeconds": 3600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Verify access**:
|
||||||
- Confirm uploads with an S3-compatible client such as `aws s3 ls --endpoint-url <R2_ENDPOINT>`.
|
- 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.
|
- After deployment, check Cloudflare analytics for cache hit ratio and origin request volume.
|
||||||
|
|
||||||
@@ -249,7 +204,8 @@ Execution tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`.
|
|||||||
1. Create bucket `linearvoid-media`.
|
1. Create bucket `linearvoid-media`.
|
||||||
2. Set location hint to `enam` (US-East).
|
2. Set location hint to `enam` (US-East).
|
||||||
3. Attach custom domain `media.linearvoid.tv` to the bucket.
|
3. Attach custom domain `media.linearvoid.tv` to the bucket.
|
||||||
4. Verify object read works from the custom domain.
|
4. Configure CORS rules to allow `https://linearvoid.tv` for HLS delivery.
|
||||||
|
5. Verify object read works from the custom domain.
|
||||||
|
|
||||||
### 3. Cache Rules
|
### 3. Cache Rules
|
||||||
|
|
||||||
@@ -272,10 +228,14 @@ Configure Cloudflare Cache Rules:
|
|||||||
4. Validate behavior using response headers:
|
4. Validate behavior using response headers:
|
||||||
- `Cf-Cache-Status` should move from `MISS` to `HIT` for hot objects
|
- `Cf-Cache-Status` should move from `MISS` to `HIT` for hot objects
|
||||||
|
|
||||||
Reference: `infrastructure/cloudflare/cache-rules.md`.
|
Reference: [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
|
||||||
|
|
||||||
### 4. Cloudflare Pages
|
### 4. Cloudflare Pages
|
||||||
|
|
||||||
|
Also see [the automated Gitea Actions deployment guide](./operations/gitea-cloudflare-pages.md).
|
||||||
|
|
||||||
|
Manual setup:
|
||||||
|
|
||||||
1. Connect Gitea repository to Cloudflare Pages.
|
1. Connect Gitea repository to Cloudflare Pages.
|
||||||
2. Build command:
|
2. Build command:
|
||||||
|
|
||||||
@@ -553,7 +513,7 @@ To allow the Gitea Actions runner (or your local machine) to deploy to the remot
|
|||||||
1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001).
|
1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001).
|
||||||
2. **Configure Cloudflare**:
|
2. **Configure Cloudflare**:
|
||||||
- Create R2 bucket with `enam` location hint.
|
- 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`.
|
- Set up custom media domain (`media.linearvoid.tv`) with the cache rules from [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
|
||||||
- Point `linearvoid.tv` and `www.linearvoid.tv` at the app entrypoint, then redirect `www` to the apex domain.
|
- 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.
|
- Use Full (strict) TLS for both app and media hostnames.
|
||||||
- Apply the Worker from `infrastructure/cloudflare/worker.js` for advanced header management.
|
- Apply the Worker from `infrastructure/cloudflare/worker.js` for advanced header management.
|
||||||
@@ -584,5 +544,3 @@ See `docs/architecture/README.md` for the full arc42 documentation and the ADRs
|
|||||||
- Project Structure: Root `README.md`.
|
- Project Structure: Root `README.md`.
|
||||||
|
|
||||||
The system is ready for use. For production, review ADR-001 for cost estimates and scaling guidance.
|
The system is ready for use. For production, review ADR-001 for cost estimates and scaling guidance.
|
||||||
|
|
||||||
**Last Updated**: 2026-08-03
|
|
||||||
+1
-1
@@ -5,7 +5,7 @@ This document is the operational contract for Linear Void video ingest, playout,
|
|||||||
## 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"]
|
||||||
|
|||||||
+74
-23
@@ -39,7 +39,9 @@
|
|||||||
- [Video Normalization \& Playout](#video-normalization--playout)
|
- [Video Normalization \& Playout](#video-normalization--playout)
|
||||||
- [Required Source Profile (Linear Void v1)](#required-source-profile-linear-void-v1)
|
- [Required Source Profile (Linear Void v1)](#required-source-profile-linear-void-v1)
|
||||||
- [Reference Normalization Command](#reference-normalization-command)
|
- [Reference Normalization Command](#reference-normalization-command)
|
||||||
- [Verification Gate (ffprobe)](#verification-gate-ffprobe)
|
- [Automatic Normalization \& Verification (ffprobe)](#automatic-normalization--verification-ffprobe)
|
||||||
|
- [Runtime Configuration Store](#runtime-configuration-store)
|
||||||
|
- [Analytics (Matomo)](#analytics-matomo)
|
||||||
- [Monitoring \& Resilience](#monitoring--resilience)
|
- [Monitoring \& Resilience](#monitoring--resilience)
|
||||||
- [Prometheus Baseline](#prometheus-baseline)
|
- [Prometheus Baseline](#prometheus-baseline)
|
||||||
- [Grafana Dashboards](#grafana-dashboards)
|
- [Grafana Dashboards](#grafana-dashboards)
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
- [Chat WebSocket API](#chat-websocket-api)
|
- [Chat WebSocket API](#chat-websocket-api)
|
||||||
- [Playout and Delivery REST API](#playout-and-delivery-rest-api)
|
- [Playout and Delivery REST API](#playout-and-delivery-rest-api)
|
||||||
- [Admin REST API](#admin-rest-api)
|
- [Admin REST API](#admin-rest-api)
|
||||||
|
- [Viewer REST API](#viewer-rest-api)
|
||||||
- [Cloudflare Configuration Reference](#cloudflare-configuration-reference)
|
- [Cloudflare Configuration Reference](#cloudflare-configuration-reference)
|
||||||
- [DNS and Zone Routing](#dns-and-zone-routing)
|
- [DNS and Zone Routing](#dns-and-zone-routing)
|
||||||
- [Cache Rules Baseline](#cache-rules-baseline)
|
- [Cache Rules Baseline](#cache-rules-baseline)
|
||||||
@@ -58,6 +61,7 @@
|
|||||||
- [Transport and Edge Protection](#transport-and-edge-protection)
|
- [Transport and Edge Protection](#transport-and-edge-protection)
|
||||||
- [Reverse Proxy Enforcement (nginx)](#reverse-proxy-enforcement-nginx)
|
- [Reverse Proxy Enforcement (nginx)](#reverse-proxy-enforcement-nginx)
|
||||||
- [Admin Authentication and Session Security](#admin-authentication-and-session-security)
|
- [Admin Authentication and Session Security](#admin-authentication-and-session-security)
|
||||||
|
- [Viewer User Accounts](#viewer-user-accounts)
|
||||||
- [Rate Limiting and Abuse Controls](#rate-limiting-and-abuse-controls)
|
- [Rate Limiting and Abuse Controls](#rate-limiting-and-abuse-controls)
|
||||||
- [CORS and Cross-Origin Behavior](#cors-and-cross-origin-behavior)
|
- [CORS and Cross-Origin Behavior](#cors-and-cross-origin-behavior)
|
||||||
- [Moderation and Privacy](#moderation-and-privacy)
|
- [Moderation and Privacy](#moderation-and-privacy)
|
||||||
@@ -87,12 +91,7 @@
|
|||||||
|
|
||||||
## Companion Documentation
|
## Companion Documentation
|
||||||
|
|
||||||
- [API.md](../API.md) - Implemented WebSocket and REST API reference.
|
see [Documentation README](../README.md) for an overview of all documentation, including architecture, ADRs, operational procedures, and setup guides.
|
||||||
- [DEPLOYMENT.md](../DEPLOYMENT.md) - Production deployment and operational runbook.
|
|
||||||
- [MONITORING.md](../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
|
||||||
- [SECURITY.md](../SECURITY.md) - Security model, controls, and hardening baseline.
|
|
||||||
- [SETUP.md](../SETUP.md) - Test lab setup, local development, and CI/CD workflow.
|
|
||||||
- [STREAMING.md](../STREAMING.md) - Streaming architecture, playout, and delivery details.
|
|
||||||
|
|
||||||
## Introduction and Goals
|
## Introduction and Goals
|
||||||
|
|
||||||
@@ -310,7 +309,10 @@ graph TB
|
|||||||
- **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths
|
- **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths
|
||||||
- **Output**: HLS segments (`.m4s` and `.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2
|
- **Output**: HLS segments (`.m4s` and `.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2
|
||||||
- **Resilience**: Restart counter (>5/60s → activate slate), Prometheus metrics on port 8081
|
- **Resilience**: Restart counter (>5/60s → activate slate), Prometheus metrics on port 8081
|
||||||
- **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval)
|
- **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval); `-validate` mode checks the playlist without running the loop
|
||||||
|
- **Seamless looping**: Only resets HLS output when the playlist fingerprint (SHA-256) changes, on crash/slate recovery, or first run; otherwise continues segment numbering via `-start_number`
|
||||||
|
- **R2 retention**: `cleanupOldSegments` deletes `hls/r-*/*` objects older than `R2_SEGMENT_RETENTION_HOURS` (default 24h) hourly + at startup
|
||||||
|
- **Shared config**: Reads the runtime `config.json` (`loadSharedHLSConfig`) as an env fallback for HLS segment duration/window
|
||||||
|
|
||||||
#### Chat Service (`backend/chat/src/main.go`)
|
#### Chat Service (`backend/chat/src/main.go`)
|
||||||
|
|
||||||
@@ -340,7 +342,7 @@ Module-level breakdown per service.
|
|||||||
#### Chat Service Modules
|
#### Chat Service Modules
|
||||||
|
|
||||||
| Module | File/Lines | Responsibility |
|
| Module | File/Lines | Responsibility |
|
||||||
| ---------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
| ---------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
| **Connection Manager** | `main.go` (hub, clients map) | Track active WebSocket connections per room, fan-out messages |
|
| **Connection Manager** | `main.go` (hub, clients map) | Track active WebSocket connections per room, fan-out messages |
|
||||||
| **Message Parser** | `main.go` (readPump, handleMessage) | Parse JSON envelope `{type, room?, ...}`, validate fields, route to handler |
|
| **Message Parser** | `main.go` (readPump, handleMessage) | Parse JSON envelope `{type, room?, ...}`, validate fields, route to handler |
|
||||||
| **Room Registry** | `main.go` (rooms map, room broadcasts) | Map room name → client set, handle join/leave/close |
|
| **Room Registry** | `main.go` (rooms map, room broadcasts) | Map room name → client set, handle join/leave/close |
|
||||||
@@ -348,34 +350,41 @@ Module-level breakdown per service.
|
|||||||
| **Persistence Layer** | `main.go` (GORM models: Message, DirectMessage, ModerationLog) | Store/retrieve chat messages, DMs, moderation events |
|
| **Persistence Layer** | `main.go` (GORM models: Message, DirectMessage, ModerationLog) | Store/retrieve chat messages, DMs, moderation events |
|
||||||
| **Redis Subsystem** | `main.go` (redisClient, rate limiting, pub/sub, presence) | INCR+EXPIRE rate limit, SETEX presence, PSUBSCRIBE room fan-out |
|
| **Redis Subsystem** | `main.go` (redisClient, rate limiting, pub/sub, presence) | INCR+EXPIRE rate limit, SETEX presence, PSUBSCRIBE room fan-out |
|
||||||
| **Auth Handler** | `main.go` (handleAdminLogin, adminAuthMiddleware) | Session cookie creation/validation, login rate limiting |
|
| **Auth Handler** | `main.go` (handleAdminLogin, adminAuthMiddleware) | Session cookie creation/validation, login rate limiting |
|
||||||
|
| **User Auth Handler** | `handlers/handlers.go` (HandleUserRegister/Login/Session/Logout) | Viewer register/login/logout/session (bcrypt, user session cookie) |
|
||||||
|
| **DM History/Search** | `handlers/handlers.go` (HandleUserDMHistory, HandleUserDMSearch) | Paginated DM history + search for authenticated users |
|
||||||
|
| **Runtime Config** | `config/runtime.go` (GetRuntime, SetRuntime, typed setters) | Thread-safe JSON runtime config (HLS duration/window, Matomo) |
|
||||||
| **Admin API Router** | `main.go` (handleAdmin\*) | Upload, playlist CRUD, schedule overrides, moderation actions, analytics, system summary |
|
| **Admin API Router** | `main.go` (handleAdmin\*) | Upload, playlist CRUD, schedule overrides, moderation actions, analytics, system summary |
|
||||||
| **Metrics Exporter** | `main.go` (prometheus.MustRegister) | Expose connected_clients, messages_total, dms_total, moderation_actions, redis_up |
|
| **Metrics Exporter** | `main.go` (prometheus.MustRegister) | Expose connected_clients, messages_total, dms_total, moderation_actions, redis_up |
|
||||||
|
|
||||||
#### Playout Service Modules
|
#### Playout Service Modules
|
||||||
|
|
||||||
| Module | File/Lines | Responsibility |
|
| Module | File/Lines | Responsibility |
|
||||||
| -------------------- | ---------------------------------------------- | ------------------------------------------------------------------ |
|
| -------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
| **Playlist Parser** | `playlist.go` (parsePlaylist) | Read `playlist.txt`, skip comments/blank lines, resolve file paths |
|
| **Playlist Parser** | `playlist.go` (parsePlaylist) | Read `playlist.txt`, skip comments/blank lines, resolve file paths |
|
||||||
| **FFmpeg Manager** | `playlist.go` (runPlayout, restart logic) | Spawn/monitor FFmpeg concat process, restart on crash |
|
| **FFmpeg Manager** | `playlist.go` (runPlayout, restart logic) | Spawn/monitor FFmpeg concat process, restart on crash |
|
||||||
|
| **Loop Controller** | `playlist.go` (playlistFingerprint, hlsNextStartNumber) | Detect playlist changes (SHA-256), continue segment numbering on clean loops |
|
||||||
| **Slate Controller** | `playlist.go` (activateSlate, deactivateSlate) | Switch output to slate loop on excessive failures |
|
| **Slate Controller** | `playlist.go` (activateSlate, deactivateSlate) | Switch output to slate loop on excessive failures |
|
||||||
| **R2 Sync** | `playlist.go` (syncToR2) | Upload HLS segments + manifest to R2 bucket |
|
| **R2 Sync** | `playlist.go` (syncToR2) | Upload HLS segments + manifest to R2 bucket |
|
||||||
|
| **R2 Cleanup** | `playlist.go` (cleanupOldSegments) | Delete stale `hls/r-*/*` segments older than the retention window |
|
||||||
| **Health Endpoint** | `playlist.go` (healthHandler) | `/health` → JSON with mode, segment count, uptime |
|
| **Health Endpoint** | `playlist.go` (healthHandler) | `/health` → JSON with mode, segment count, uptime |
|
||||||
| **Metrics Exporter** | `playlist.go` (prometheus) | Expose restarts_total, mode (live/slate) |
|
| **Metrics Exporter** | `playlist.go` (prometheus) | Expose restarts_total, mode (live/slate), loops_total, run_duration_seconds, r2_cleanup_objects_total |
|
||||||
|
|
||||||
#### Frontend Modules
|
#### Frontend Modules
|
||||||
|
|
||||||
| Module | File | Responsibility |
|
| Module | File | Responsibility |
|
||||||
| ----------------------- | ----------------------------------------------- | ----------------------------------------------------------------- |
|
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
| **HLS Player** | `src/main.ts` | hls.js initialization, attach to video element, error recovery |
|
| **HLS Player** | `src/main.ts` | hls.js initialization, attach to video element, error recovery |
|
||||||
| **WebSocket Client** | `src/main.ts` | Connect/disconnect, parse envelope, dispatch to UI handlers |
|
| **WebSocket Client** | `src/main.ts` | Connect/disconnect, parse envelope, dispatch to UI handlers |
|
||||||
| **Chat UI** | `src/main.ts` | Message list rendering, room tabs, DM panel, presence dots |
|
| **Chat UI** | `src/main.ts` | Message list rendering, room tabs, DM panel, presence dots, typing indicator, user-list toggle |
|
||||||
|
| **DM Search** | `src/main.ts` | Search DM history (`/api/dm/search`) and render results |
|
||||||
|
| **Auth UI** | `src/main.ts` + `index.html` | Viewer login/register/logout, session refresh, user label |
|
||||||
| **Schedule Overlay** | `src/main.ts` | Poll `/api/schedule`, render current/next program |
|
| **Schedule Overlay** | `src/main.ts` | Poll `/api/schedule`, render current/next program |
|
||||||
| **Admin Router** | `src/admin.ts` | Tab-based navigation, dynamic page loading |
|
| **Admin Router** | `src/admin.ts` | Tab-based navigation, dynamic page loading |
|
||||||
| **Upload Manager** | `src/admin.ts` | Multipart upload with chunk progress, ffprobe validation feedback |
|
| **Upload Manager** | `src/admin.ts` | Multipart upload with chunk progress, normalization status badges |
|
||||||
| **Playlist Editor** | `src/admin.ts` | Drag-reorder, add/remove items, save to `/api/admin/playlist` |
|
| **Playlist Editor** | `src/admin.ts` | Drag-reorder, add/remove items (gated to `ready` media), save to `/api/admin/playlist` |
|
||||||
| **Moderation Panel** | `src/admin.ts` + `public/admin/moderation.html` | Inline mute/ban, action log viewer |
|
| **Moderation Panel** | `src/admin.ts` + `public/admin/moderation.html` | Inline mute/ban, action log viewer |
|
||||||
| **Analytics Dashboard** | `src/admin.ts` + `public/admin/analytics.html` | Message counts, top rooms, moderation stats |
|
| **Analytics Dashboard** | `src/admin.ts` + `public/admin/analytics.html` | Message counts, top rooms, moderation stats |
|
||||||
| **System Config** | `src/admin.ts` + `public/admin/system.html` | Service health, config metadata, restart buttons |
|
| **System Config** | `src/admin.ts` + `public/admin/system.html` | Service health, runtime config form (HLS duration/window, Matomo), restart buttons |
|
||||||
|
|
||||||
## Runtime View
|
## Runtime View
|
||||||
|
|
||||||
@@ -485,7 +494,7 @@ Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = ne
|
|||||||
|
|
||||||
### Video Normalization & Playout
|
### Video Normalization & Playout
|
||||||
|
|
||||||
All source assets must be pre-normalized before they are accepted into production playlists. This prevents decode resets, timestamp drift, and audio/video discontinuities during continuous concat playout.
|
All source assets must conform to the Linear Void source profile before they are accepted into production playlists. The chat backend **auto-normalizes** non-conforming uploads on ingest (`media.NormalizeVideo`), so admins no longer need to pre-process files manually. This prevents decode resets, timestamp drift, and audio/video discontinuities during continuous concat playout.
|
||||||
|
|
||||||
#### Required Source Profile (Linear Void v1)
|
#### Required Source Profile (Linear Void v1)
|
||||||
|
|
||||||
@@ -513,12 +522,27 @@ ffmpeg -i input.mov \
|
|||||||
output-normalized.mp4
|
output-normalized.mp4
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Verification Gate (ffprobe)
|
#### Automatic Normalization & Verification (ffprobe)
|
||||||
|
|
||||||
- Validate codec, fps, resolution, and keyframe cadence before upload acceptance.
|
- `media.NormalizeVideo` runs ffmpeg with the Linear Void profile (H.264/AAC, 30fps, 2s keyframes, yuv420p, 48kHz stereo), writes `*.normalized.mp4`, and re-verifies the output with ffprobe.
|
||||||
- Reject non-conforming files from admin ingest with actionable error feedback.
|
- Media records carry a status: `ready`, `needs_normalization`, `normalizing`, or `normalization_failed`; the admin UI shows a "Normalize" action with status polling.
|
||||||
|
- `POST /api/admin/media/{id}/normalize` re-triggers normalization manually for failed/re-queued media.
|
||||||
- Keep verification logic aligned with runtime playout assumptions to preserve `-c copy` efficiency where possible.
|
- Keep verification logic aligned with runtime playout assumptions to preserve `-c copy` efficiency where possible.
|
||||||
|
|
||||||
|
### Runtime Configuration Store
|
||||||
|
|
||||||
|
A thread-safe, JSON-persisted runtime config store lives in `backend/chat/src/config/runtime.go` and is shared with the playout service via the playlist volume (`/app/playlist/config.json`).
|
||||||
|
|
||||||
|
- Editable via `GET/PUT /api/admin/config` from the admin UI.
|
||||||
|
- Fields: `hlsSegmentDuration` (2–15s, default 6), `hlsWindowSize` (3–100, default 24), `matomoUrl`, `matomoSiteId`.
|
||||||
|
- Playout reads the same file (`loadSharedHLSConfig`) as an env fallback for HLS segment duration/window.
|
||||||
|
- Typed setters validate values; invalid input is rejected with `400`.
|
||||||
|
|
||||||
|
### Analytics (Matomo)
|
||||||
|
|
||||||
|
- The viewer sends pageviews to Matomo driven by the public `GET /api/matomo` endpoint, which returns `matomoUrl` + `matomoSiteId` from the runtime config store.
|
||||||
|
- Analytics is optional per deployment: leave `matomoUrl` empty to disable tracking.
|
||||||
|
|
||||||
### Monitoring & Resilience
|
### Monitoring & Resilience
|
||||||
|
|
||||||
Linear Void uses a two-layer monitoring model:
|
Linear Void uses a two-layer monitoring model:
|
||||||
@@ -538,6 +562,7 @@ Linear Void uses a two-layer monitoring model:
|
|||||||
- `postgres` -> `postgres-exporter:9187`
|
- `postgres` -> `postgres-exporter:9187`
|
||||||
- `node` -> `node-exporter:9100`
|
- `node` -> `node-exporter:9100`
|
||||||
- `prometheus` -> `localhost:9090`
|
- `prometheus` -> `localhost:9090`
|
||||||
|
- TSDB retention: `--storage.tsdb.retention.time=30d` (delete metrics older than 30 days) + `--storage.tsdb.retention.size=15GB` (disk cap), set on the `prometheus` service in `docker-compose.yml` and `docker-compose.prod.yml`
|
||||||
|
|
||||||
#### Grafana Dashboards
|
#### Grafana Dashboards
|
||||||
|
|
||||||
@@ -550,7 +575,7 @@ Use the app dashboard for service-level behavior and the Prometheus overview das
|
|||||||
|
|
||||||
Rules are implemented in `monitoring/prometheus/alerts.yml` and loaded via `rule_files` from `prometheus.yml`.
|
Rules are implemented in `monitoring/prometheus/alerts.yml` and loaded via `rule_files` from `prometheus.yml`.
|
||||||
|
|
||||||
Current rule set (8):
|
Current rule set (10):
|
||||||
|
|
||||||
- `ChatDown`
|
- `ChatDown`
|
||||||
- `PlayoutDown`
|
- `PlayoutDown`
|
||||||
@@ -559,6 +584,8 @@ Current rule set (8):
|
|||||||
- `HighChatLatency`
|
- `HighChatLatency`
|
||||||
- `PrometheusWALFailing`
|
- `PrometheusWALFailing`
|
||||||
- `HighCPU`
|
- `HighCPU`
|
||||||
|
- `LowCacheHitRatio`
|
||||||
|
- `HighRedisMemoryUsage`
|
||||||
- `LowDiskSpace`
|
- `LowDiskSpace`
|
||||||
|
|
||||||
Route warning/critical severities to the operations channel and keep runbook links attached to each alert definition.
|
Route warning/critical severities to the operations channel and keep runbook links attached to each alert definition.
|
||||||
@@ -611,6 +638,8 @@ Linear Void currently exposes three operational API surfaces. The canonical deta
|
|||||||
- `/api/admin/session`
|
- `/api/admin/session`
|
||||||
- Operational/admin domains:
|
- Operational/admin domains:
|
||||||
- overview, analytics, users, moderation, schedule, playlist, media
|
- overview, analytics, users, moderation, schedule, playlist, media
|
||||||
|
- runtime config: `GET/PUT /api/admin/config` (HLS segment duration/window, Matomo)
|
||||||
|
- media normalization: `POST /api/admin/media/{id}/normalize`
|
||||||
- Media upload domain:
|
- Media upload domain:
|
||||||
- `/api/admin/media/uploads` (init/resume)
|
- `/api/admin/media/uploads` (init/resume)
|
||||||
- `/api/admin/media/uploads/{uploadId}/chunk`
|
- `/api/admin/media/uploads/{uploadId}/chunk`
|
||||||
@@ -620,6 +649,19 @@ Linear Void currently exposes three operational API surfaces. The canonical deta
|
|||||||
- media metadata + upload orchestration
|
- media metadata + upload orchestration
|
||||||
- moderation/audit and system status management
|
- moderation/audit and system status management
|
||||||
|
|
||||||
|
#### Viewer REST API
|
||||||
|
|
||||||
|
- Auth/session:
|
||||||
|
- `POST /api/auth/register`
|
||||||
|
- `POST /api/auth/login`
|
||||||
|
- `POST /api/auth/logout`
|
||||||
|
- `GET /api/auth/session`
|
||||||
|
- Private messaging:
|
||||||
|
- `GET /api/dm/history` (paginated)
|
||||||
|
- `GET /api/dm/search`
|
||||||
|
- Analytics:
|
||||||
|
- `GET /api/matomo` (public; returns Matomo URL + site ID for the viewer pageview snippet)
|
||||||
|
|
||||||
### Cloudflare Configuration Reference
|
### Cloudflare Configuration Reference
|
||||||
|
|
||||||
Cloudflare is part of the production control plane, not only a CDN edge. The following configuration set is required to maintain cost, latency, and reliability targets.
|
Cloudflare is part of the production control plane, not only a CDN edge. The following configuration set is required to maintain cost, latency, and reliability targets.
|
||||||
@@ -669,7 +711,7 @@ Security is split between edge controls (Cloudflare), reverse proxy controls (ng
|
|||||||
#### Transport and Edge Protection
|
#### Transport and Edge Protection
|
||||||
|
|
||||||
- Public traffic uses HTTPS/WSS at the edge.
|
- Public traffic uses HTTPS/WSS at the edge.
|
||||||
- Cloudflare WAF and rate limiting are part of the baseline (see `infrastructure/cloudflare/cache-rules.md`).
|
- Cloudflare WAF and rate limiting are part of the baseline (see [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md)).
|
||||||
- Cloudflare Worker can enforce immutable cache headers for segment paths (`infrastructure/cloudflare/worker.js`).
|
- Cloudflare Worker can enforce immutable cache headers for segment paths (`infrastructure/cloudflare/worker.js`).
|
||||||
|
|
||||||
#### Reverse Proxy Enforcement (nginx)
|
#### Reverse Proxy Enforcement (nginx)
|
||||||
@@ -685,6 +727,12 @@ Security is split between edge controls (Cloudflare), reverse proxy controls (ng
|
|||||||
- Session cookie controls in backend include `HttpOnly`, `SameSite=Strict`, and `Secure` when request is HTTPS (derived from TLS or `X-Forwarded-Proto`).
|
- Session cookie controls in backend include `HttpOnly`, `SameSite=Strict`, and `Secure` when request is HTTPS (derived from TLS or `X-Forwarded-Proto`).
|
||||||
- Session expiration is enforced server-side.
|
- Session expiration is enforced server-side.
|
||||||
|
|
||||||
|
#### Viewer User Accounts
|
||||||
|
|
||||||
|
- Register/login/logout via `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`; current session via `GET /api/auth/session`.
|
||||||
|
- Passwords are hashed with bcrypt; sessions use a `linearvoid_user_session` cookie with a 30-day default TTL (`USER_SESSION_TTL_HOURS`).
|
||||||
|
- Viewer sign-in UI (username/password) lives in `frontend/index.html`.
|
||||||
|
|
||||||
#### Rate Limiting and Abuse Controls
|
#### Rate Limiting and Abuse Controls
|
||||||
|
|
||||||
- Chat message rate limiting uses Redis `INCR + EXPIRE` windowing.
|
- Chat message rate limiting uses Redis `INCR + EXPIRE` windowing.
|
||||||
@@ -706,6 +754,9 @@ Security is split between edge controls (Cloudflare), reverse proxy controls (ng
|
|||||||
|
|
||||||
WebSockets + Redis (presence, pub/sub, rate limits) + DB (history, audit). Public rooms + on-demand private chats. Scales independently of video.
|
WebSockets + Redis (presence, pub/sub, rate limits) + DB (history, audit). Public rooms + on-demand private chats. Scales independently of video.
|
||||||
|
|
||||||
|
- Private DM history (`GET /api/dm/history`, paginated) and search (`GET /api/dm/search`) are backed by persisted DMs.
|
||||||
|
- Viewer UI includes a typing indicator, user-list toggle, and DM search.
|
||||||
|
|
||||||
Full details, cache rule table, bitrate/storage calculations, cost model, example configs, ToS notes, and implementation phases are in [`adr/ADR-001.md`](adr/ADR-001.md).
|
Full details, cache rule table, bitrate/storage calculations, cost model, example configs, ToS notes, and implementation phases are in [`adr/ADR-001.md`](adr/ADR-001.md).
|
||||||
|
|
||||||
## Architecture Decisions
|
## Architecture Decisions
|
||||||
@@ -727,7 +778,7 @@ These ADRs together form the canonical decision log.
|
|||||||
- Self-managed US-East playout (Hetzner Ashburn) with FFmpeg + MediaMTX/SRS → HLS → Cloudflare R2 + edge caching.
|
- Self-managed US-East playout (Hetzner Ashburn) with FFmpeg + MediaMTX/SRS → HLS → Cloudflare R2 + edge caching.
|
||||||
- Module structure that directly reflects the arc42 building blocks and context diagram.
|
- Module structure that directly reflects the arc42 building blocks and context diagram.
|
||||||
- Go preferred for performance-critical services; TypeScript for frontend; Docker for all deployment.
|
- Go preferred for performance-critical services; TypeScript for frontend; Docker for all deployment.
|
||||||
- Admin interfaces for video management, moderation, analytics, and system configuration using HTMX + Tailwind.
|
- Admin interfaces for video management, moderation, analytics, and system configuration using HTMX + custom CSS.
|
||||||
- SQLite as primary database for v1 with PostgreSQL migration path if needed.
|
- SQLite as primary database for v1 with PostgreSQL migration path if needed.
|
||||||
- Redis for rate limiting, presence, pub/sub, session storage with defined TTL policies and fallback behavior.
|
- Redis for rate limiting, presence, pub/sub, session storage with defined TTL policies and fallback behavior.
|
||||||
|
|
||||||
|
|||||||
@@ -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 Pages 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
File renamed without changes.
@@ -0,0 +1,24 @@
|
|||||||
|
# Cloudflare Account Troubleshooting
|
||||||
|
|
||||||
|
Reference for Cloudflare account and API token troubleshooting.
|
||||||
|
|
||||||
|
Use `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects` to verify the account ID and API token are valid.
|
||||||
|
|
||||||
|
## Validate Cloudflare account and API token
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Validate Cloudflare account and API token in .env
|
||||||
|
Set-Location -Path "E:\Documents\02-Projects\07-Mentoring\Corey\linearvoid"
|
||||||
|
Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN'
|
||||||
|
Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID'
|
||||||
|
|
||||||
|
# Test API token with curl
|
||||||
|
$env:CLOUDFLARE_API_TOKEN = (Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN' | ForEach-Object { $_.Line.Split('=')[1].Trim() })
|
||||||
|
$env:CLOUDFLARE_ACCOUNT_ID = (Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID' | ForEach-Object { $_.Line.Split('=')[1].Trim() })
|
||||||
|
|
||||||
|
# List all Pages projects for the account
|
||||||
|
curl -X GET "https://api.cloudflare.com/client/v4/accounts/$env:CLOUDFLARE_ACCOUNT_ID/pages/projects" -H "Authorization: Bearer $env:CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json"
|
||||||
|
|
||||||
|
# Create a new Pages project (if needed)
|
||||||
|
# curl -X POST "https://api.cloudflare.com/client/v4/accounts/$env:CLOUDFLARE_ACCOUNT_ID/pages/projects" -H "Authorization: Bearer $env:CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" -d '{"name":"linearvoid","production_branch":"main"}'
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,69 @@
|
|||||||
|
# Cloudflare Pages & Workers Setup — Linear Void
|
||||||
|
|
||||||
|
Linear Void uses two Cloudflare products:
|
||||||
|
|
||||||
|
| Product | Purpose | What |
|
||||||
|
| ----------- | ------------------ | ------------------------------------------------------------------------------------ |
|
||||||
|
| **Pages** | Static SPA hosting | Vite-built frontend (`frontend/dist`) deployed via Git integration or Direct Upload |
|
||||||
|
| **Workers** | Edge cache logic | `infrastructure/cloudflare/worker.js` — adds immutable cache headers to HLS segments |
|
||||||
|
|
||||||
|
Both are managed under **Workers & Pages** in the Cloudflare dashboard.
|
||||||
|
|
||||||
|
## Dashboard URLs
|
||||||
|
|
||||||
|
| Resource | URL |
|
||||||
|
| ------------------------ | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Workers & Pages overview | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers-and-pages` |
|
||||||
|
| Worker: linearvoid | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid/production/settings` |
|
||||||
|
| Pages project | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/pages/view/linearvoid` |
|
||||||
|
| API tokens | <https://dash.cloudflare.com/profile/api-tokens> |
|
||||||
|
|
||||||
|
## Worker: `linearvoid` (edge cache headers)
|
||||||
|
|
||||||
|
The Worker at `infrastructure/cloudflare/worker.js` runs on the `linearvoid` Workers service. It intercepts requests and adds immutable `Cache-Control` headers to `.m4s` segment files.
|
||||||
|
|
||||||
|
Deploy via Wrangler CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx wrangler deploy infrastructure/cloudflare/worker.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Or paste into the Workers dashboard at the URL above (Quick Edit).
|
||||||
|
|
||||||
|
## Pages: Static frontend
|
||||||
|
|
||||||
|
### Direct Upload via Gitea Actions
|
||||||
|
|
||||||
|
Use `.gitea/workflows/pages-deploy.yml` to build in Gitea Actions and upload via the Cloudflare API.
|
||||||
|
|
||||||
|
See [gitea-cloudflare-pages.md](./gitea-cloudflare-pages.md) for the workflow.
|
||||||
|
|
||||||
|
### Variables and secrets
|
||||||
|
|
||||||
|
On Cloudflare pages, set the following environment variables (needed to run the frontend)
|
||||||
|
|
||||||
|
| Type | Name | Value |
|
||||||
|
| --------- | -------------------------------------- | ----------------------------------------------- |
|
||||||
|
| Secret | ADMIN_PASSWORD | `not-a-real-admin-password` |
|
||||||
|
| Plaintext | ADMIN_SESSION_TTL_HOURS | `12` |
|
||||||
|
| Plaintext | ADMIN_USERNAME | `boogers` |
|
||||||
|
| Plaintext | HLS_SEGMENT_DURATION | `6` |
|
||||||
|
| Plaintext | HLS_WINDOW_SIZE | `24` |
|
||||||
|
| Plaintext | PLAYLIST_PATH | `/app/playlist/playlist.txt` |
|
||||||
|
| Plaintext | VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR | `boogers` |
|
||||||
|
| Plaintext | VITE_PUBLIC_ADMIN_DEFAULT_ROOM | `main` |
|
||||||
|
| Plaintext | VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT | `50` |
|
||||||
|
| Plaintext | VITE_PUBLIC_APP_URL | `https://linearvoid.tv` |
|
||||||
|
| Plaintext | VITE_PUBLIC_HLS_URL | `https://media.linearvoid.tv/hls/playlist.m3u8` |
|
||||||
|
| Plaintext | VITE_PUBLIC_WS_URL | `wss://linearvoid.tv/ws` |
|
||||||
|
|
||||||
|
## Custom domains
|
||||||
|
|
||||||
|
- `linearvoid.tv` → Pages project
|
||||||
|
- `www.linearvoid.tv` → redirect to `linearvoid.tv`
|
||||||
|
- `media.linearvoid.tv` → R2 bucket (separate; not Pages)
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Gitea to Cloudflare Pages deployment guide](./gitea-cloudflare-pages.md)
|
||||||
|
- [Cloudflare & UptimeRobot checklist](./cloudflare-uptimerobot-checklist.md)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Gitea to Cloudflare Pages — Linear Void
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Deploy the Linear Void frontend (Vite + TypeScript SPA) from Gitea to Cloudflare Pages using Gitea Actions.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
> **Important:** The Pages project must exist before the API deploy call works. You'll get error 8000007 ("Project not found") if you skip this step.
|
||||||
|
|
||||||
|
1. **Cloudflare Pages project** created first:
|
||||||
|
- Dashboard → Workers & Pages → Create → Pages → Direct Upload
|
||||||
|
- Name it `linearvoid` (or a different name if `linearvoid` is already taken by a Worker — see note below)
|
||||||
|
- See [Cloudflare Pages Setup](./cloudflare-pages-setup.md) for full details.
|
||||||
|
2. **Project name**: note the exact name (e.g., `linearvoid`).
|
||||||
|
3. **API token** with Pages permissions:
|
||||||
|
- Create at <https://dash.cloudflare.com/profile/api-tokens>
|
||||||
|
- Permission: `Account → Cloudflare Pages → Edit`
|
||||||
|
- Store in Gitea → Settings → Secrets as `CLOUDFLARE_API_TOKEN`
|
||||||
|
4. **Account ID** from Cloudflare dashboard overview → store as `CLOUDFLARE_ACCOUNT_ID`
|
||||||
|
|
||||||
|
> **Naming conflict:** If a Worker already exists with the name `linearvoid`, the Pages project must use a different name (e.g., `linearvoid-site`). Update the `projectName` in the workflow accordingly. Workers and Pages share a namespace under "Workers & Pages".
|
||||||
|
|
||||||
|
## Project-specific values
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
| ------------------ | ---------------------------------------------------------- |
|
||||||
|
| Framework | Vite + TypeScript |
|
||||||
|
| Build command | `cd frontend && npm install && npm run build` |
|
||||||
|
| Output directory | `frontend/dist` |
|
||||||
|
| Node version | 20 |
|
||||||
|
| Multi-page entries | `index.html`, `admin/index.html`, `admin/login/index.html` |
|
||||||
|
|
||||||
|
## Gitea Actions workflow
|
||||||
|
|
||||||
|
Create `.gitea/workflows/pages-deploy.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Deploy to Cloudflare Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cd frontend && npm run build
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Pages
|
||||||
|
uses: cloudflare/pages-action@v1
|
||||||
|
with:
|
||||||
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
projectName: linearvoid
|
||||||
|
directory: frontend/dist
|
||||||
|
gitHubToken: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the `cloudflare/pages-action@v1` action is unavailable from Gitea, use the direct Cloudflare Pages API (multipart/form-data with manifest):
|
||||||
|
|
||||||
|
Snippet to generate a manifest in format `-F manifest='{"index.html": "abc123", "style.css": "def456"}'`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build manifest form argument from all files in frontend/dist
|
||||||
|
form_args="-F manifest={"
|
||||||
|
for file in $(find frontend/dist -type f | sed 's|^frontend/dist/||'); do
|
||||||
|
hash=$(sha256sum "frontend/dist/$file" | awk '{print $1}')
|
||||||
|
form_args="$form_args\"$file\":\"$hash\","
|
||||||
|
done
|
||||||
|
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Generate deployment manifest
|
||||||
|
run: |
|
||||||
|
cd frontend/dist
|
||||||
|
form_args="-F manifest={"
|
||||||
|
for file in $(find . -type f ! -name manifest.json | sed 's|^\./||'); do
|
||||||
|
hash=$(sha256sum "$file" | awk '{print $1}')
|
||||||
|
form_args="$form_args\"$file\":\"$hash\","
|
||||||
|
done
|
||||||
|
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
||||||
|
echo "Form arguments: $form_args"
|
||||||
|
echo "FORM_ARGS=$form_args" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Pages
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: |
|
||||||
|
cd frontend/dist
|
||||||
|
form_args="$FORM_ARGS" # use form arguments generated in previous step
|
||||||
|
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
curl -sS -X POST \
|
||||||
|
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/linearvoid/deployments" \
|
||||||
|
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||||
|
-F "commit_hash=${COMMIT_HASH}" \
|
||||||
|
$form_args
|
||||||
|
```
|
||||||
|
|
||||||
|
This matches the Cloudflare Pages Direct Upload API: `Content-Type: multipart/form-data`, each file as a separate form field, and a `manifest` JSON mapping filenames to SHA-256 hashes.
|
||||||
|
|
||||||
|
## Secrets to add in Gitea
|
||||||
|
|
||||||
|
| Secret | Value |
|
||||||
|
| ----------------------- | ------------------------------------ |
|
||||||
|
| `CLOUDFLARE_API_TOKEN` | API token with Pages:Edit permission |
|
||||||
|
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID from dashboard |
|
||||||
|
|
||||||
|
## Verify deployment
|
||||||
|
|
||||||
|
1. Push to `main` or trigger manually from Gitea Actions.
|
||||||
|
2. Check **Gitea Actions** → job logs for the deploy step.
|
||||||
|
3. Check **Cloudflare Pages** → Deployments for the new deployment.
|
||||||
|
4. Visit the `.pages.dev` preview URL, then promote to `linearvoid.tv` when ready.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The existing `ci.yml` already builds the frontend; the Pages deploy workflow is additive and independent.
|
||||||
|
- Multi-page SPA (`vite.config.ts`) produces three entry points — Cloudflare Pages serves them correctly.
|
||||||
|
- For SPA fallback routing, add a `frontend/public/_redirects` file with `/* /index.html 200` before building.
|
||||||
|
- If deployments fail with 403, verify the API token has `Account → Cloudflare Pages → Edit` permission.
|
||||||
|
- If you get error **8000007** ("Project not found"), the Pages project hasn't been created yet. Create it via the Cloudflare dashboard (Workers & Pages → Create → Pages → Direct Upload) first, then re-run.
|
||||||
|
- If `linearvoid` is already taken by a Worker, use a different project name like `linearvoid-site` and update the `projectName` and `PROJECT_NAME` values in the workflow.
|
||||||
|
|
||||||
|
## Related resources
|
||||||
|
|
||||||
|
- Cloudflare Workers dashboard: `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid/production/settings` — for managing the `infrastructure/cloudflare/worker.js` deployment
|
||||||
|
- Cloudflare Pages project: `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/pages/view/linearvoid` — deployment history and custom domain settings
|
||||||
+55
-18
@@ -1,5 +1,6 @@
|
|||||||
<!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" />
|
||||||
@@ -17,14 +18,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 +38,35 @@
|
|||||||
<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>
|
||||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
<div class="chat-header-actions">
|
||||||
|
<span id="chat-user-label" class="chat-user-label" hidden></span>
|
||||||
|
<button id="auth-toggle-btn" type="button" class="btn btn-secondary">Sign in</button>
|
||||||
|
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button> <button id="users-toggle-btn" type="button" class="btn btn-secondary" aria-controls="presence-panel" aria-expanded="true">Users</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-panel" hidden>
|
||||||
|
<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 id="dm-search-row" class="dm-search-row" hidden>
|
||||||
|
<input id="dm-search-input" type="text" class="input" placeholder="Search this conversation..." />
|
||||||
|
<button id="dm-search-btn" type="button" class="btn btn-secondary">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section id="presence-panel" class="presence-panel" aria-label="Online users">
|
|
||||||
<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 +75,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.0.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.0.9",
|
"version": "1.0.11",
|
||||||
"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.0.13",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+182
-18
@@ -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;
|
||||||
@@ -246,7 +247,8 @@ async function refreshPlaylist() {
|
|||||||
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 +257,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 +267,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 +356,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;
|
||||||
@@ -480,6 +558,37 @@ async function deleteOverride(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderServiceRow(service: any) {
|
||||||
|
const svcDetail = service.detail || "";
|
||||||
|
|
||||||
|
// playout service returns json detail, all others return ISO formatted time string
|
||||||
|
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);
|
||||||
|
service.detail = Object.entries(detailObj)
|
||||||
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
|
.join("\n");
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<p>${service.detail}</p>
|
||||||
|
</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 +608,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 +633,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);
|
||||||
});
|
});
|
||||||
@@ -699,6 +808,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/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/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);
|
||||||
@@ -1068,6 +1226,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 +1244,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";
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+252
-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>
|
<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" hidden>
|
||||||
|
<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>
|
||||||
|
<button id="users-toggle-btn" type="button" aria-expanded="true">Users</button>
|
||||||
|
<div id="typing-indicator" hidden></div>
|
||||||
|
<div id="dm-search-row" hidden>
|
||||||
|
<input id="dm-search-input" type="text" />
|
||||||
|
<button id="dm-search-btn" type="button">Search</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Stub prompt for DM
|
// Stub prompt for DM
|
||||||
@@ -331,8 +375,209 @@ 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 and user list 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the user list visibility", async () => {
|
||||||
|
await import("../src/main");
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ authenticated: false, entries: [] }),
|
||||||
|
text: async () => "",
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
document.dispatchEvent(new Event("DOMContentLoaded"));
|
||||||
|
|
||||||
|
const panel = document.getElementById("presence-panel")!;
|
||||||
|
const btn = document.getElementById("users-toggle-btn")!;
|
||||||
|
expect(panel.hidden).toBe(false);
|
||||||
|
|
||||||
|
btn.click();
|
||||||
|
expect(panel.hidden).toBe(true);
|
||||||
|
expect(btn.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
|
||||||
|
btn.click();
|
||||||
|
expect(panel.hidden).toBe(false);
|
||||||
|
expect(btn.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+259
-1
@@ -15,16 +15,32 @@ 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 currentDMTarget = "";
|
||||||
let chatVisible = true;
|
let chatVisible = true;
|
||||||
|
let usersVisible = true;
|
||||||
|
let typingUser = "";
|
||||||
|
let lastTypingSent = 0;
|
||||||
|
let typingTimeout: 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;
|
||||||
|
usersVisible = true;
|
||||||
|
typingUser = "";
|
||||||
|
lastTypingSent = 0;
|
||||||
onlineUsers.clear();
|
onlineUsers.clear();
|
||||||
|
playerErrorCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveWebSocketBaseUrl() {
|
export function resolveWebSocketBaseUrl() {
|
||||||
@@ -75,6 +91,11 @@ function connectWebSocket(room: string) {
|
|||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === "typing") {
|
||||||
|
showTyping(msg.username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideTyping(msg.username);
|
||||||
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
||||||
|
|
||||||
// Track presence from system join/leave messages
|
// Track presence from system join/leave messages
|
||||||
@@ -159,10 +180,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);
|
||||||
@@ -179,6 +252,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");
|
||||||
@@ -208,25 +299,192 @@ function sendMessage() {
|
|||||||
|
|
||||||
function switchRoom(room: string) {
|
function switchRoom(room: string) {
|
||||||
currentRoom = room;
|
currentRoom = room;
|
||||||
|
const isDM = room.startsWith("dm:");
|
||||||
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`);
|
addMessage("system", `Switched to ${room} room`);
|
||||||
|
const searchRow = document.getElementById("dm-search-row");
|
||||||
|
if (searchRow) searchRow.hidden = !isDM;
|
||||||
connectWebSocket(room);
|
connectWebSocket(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePrivateDM() {
|
function togglePrivateDM() {
|
||||||
const target = prompt("Enter username to start private chat with:");
|
const target = prompt("Enter username to start private chat with:");
|
||||||
if (target && target !== username) {
|
if (target && target !== username) {
|
||||||
|
currentDMTarget = target;
|
||||||
const dmRoom = `dm:${[username, target].sort().join("-")}`;
|
const dmRoom = `dm:${[username, target].sort().join("-")}`;
|
||||||
switchRoom(dmRoom);
|
switchRoom(dmRoom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchDM() {
|
||||||
|
const input = document.getElementById("dm-search-input") as HTMLInputElement;
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (!q || !currentDMTarget) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/dm/search?with=${encodeURIComponent(currentDMTarget)}&q=${encodeURIComponent(q)}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const results = await res.json();
|
||||||
|
addMessage("system", `Search results (${results.length}) for "${q}":`);
|
||||||
|
for (const m of results) {
|
||||||
|
addMessage(m.type || "private", m.text, m.username, m.timestamp);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.switchRoom = switchRoom;
|
window.switchRoom = switchRoom;
|
||||||
window.sendMessage = sendMessage;
|
window.sendMessage = sendMessage;
|
||||||
window.togglePrivateDM = togglePrivateDM;
|
window.togglePrivateDM = togglePrivateDM;
|
||||||
window.toggleChatPanel = toggleChatPanel;
|
window.toggleChatPanel = toggleChatPanel;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUserSession() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/session");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const wasAnonymous = username.startsWith("viewer");
|
||||||
|
if (data.authenticated && data.username) {
|
||||||
|
authenticated = true;
|
||||||
|
username = data.username;
|
||||||
|
updateAuthUI();
|
||||||
|
if (wasAnonymous) connectWebSocket(currentRoom);
|
||||||
|
} 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 res = await fetch(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 {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
authenticated = false;
|
||||||
|
username = "viewer" + Math.floor(Math.random() * 1000);
|
||||||
|
updateAuthUI();
|
||||||
|
connectWebSocket(currentRoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUserList() {
|
||||||
|
const panel = document.getElementById("presence-panel");
|
||||||
|
const btn = document.getElementById("users-toggle-btn");
|
||||||
|
if (!panel) return;
|
||||||
|
usersVisible = !usersVisible;
|
||||||
|
panel.hidden = !usersVisible;
|
||||||
|
if (btn) btn.setAttribute("aria-expanded", String(usersVisible));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
void refreshUserSession();
|
||||||
|
document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
|
||||||
|
const panel = document.getElementById("auth-panel");
|
||||||
|
if (panel) panel.hidden = !panel.hidden;
|
||||||
|
});
|
||||||
|
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("dm-search-btn")?.addEventListener("click", searchDM);
|
||||||
|
document.getElementById("users-toggle-btn")?.addEventListener("click", toggleUserList);
|
||||||
|
document.getElementById("message-input")?.addEventListener("input", sendTyping);
|
||||||
});
|
});
|
||||||
@@ -253,6 +253,79 @@ 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-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-top: 1px solid var(--bg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-search-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-top: 1px solid var(--bg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
padding: 0.25rem 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-1);
|
border-radius: var(--radius-1);
|
||||||
@@ -1056,3 +1129,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,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
# Load Test Scaffolding
|
# Load Test Scaffolding
|
||||||
|
|
||||||
These files back the scaling/performance playbook.
|
These files back the [scaling/performance playbook](../../docs/operations/scaling-performance-playbook.md).
|
||||||
|
|
||||||
## WebSocket chat
|
## WebSocket chat
|
||||||
|
|
||||||
|
|||||||
Executable
+141
@@ -0,0 +1,141 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Edge cache hit/miss validation (Phase 4 — STREAMING.md cache contract)
|
||||||
|
# Verifies: .m4s segments go MISS→HIT, init.mp4 is cached, .m3u8 refreshes quickly
|
||||||
|
# Usage: ./scripts/validate-edge-cache.sh [base_url]
|
||||||
|
# Default base_url: https://media.linearvoid.tv
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${1:-https://media.linearvoid.tv}"
|
||||||
|
TIMEOUT=10
|
||||||
|
FAILURES=0
|
||||||
|
PASSES=0
|
||||||
|
|
||||||
|
green() { echo -e "\033[32m$1\033[0m"; }
|
||||||
|
red() { echo -e "\033[31m$1\033[0m"; }
|
||||||
|
|
||||||
|
pass() { PASSES=$((PASSES + 1)); green " ✓ PASS: $1"; }
|
||||||
|
fail() { FAILURES=$((FAILURES + 1)); red " ✗ FAIL: $1"; }
|
||||||
|
|
||||||
|
# Fetch a header from a URL
|
||||||
|
fetch_header() {
|
||||||
|
local url="$1" header="$2"
|
||||||
|
curl -sI --connect-timeout "$TIMEOUT" --max-time "$TIMEOUT" "$url" 2>/dev/null | \
|
||||||
|
grep -i "^${header}:" | sed 's/.*: //' | tr -d '\r' || echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Edge Cache Validation ==="
|
||||||
|
echo "Target: $BASE_URL"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Get current playlist manifest and extract a segment name
|
||||||
|
echo "--- Test 1: Manifest freshness ---"
|
||||||
|
MANIFEST_URL="$BASE_URL/hls/playlist.m3u8"
|
||||||
|
MANIFEST=$(curl -s --connect-timeout "$TIMEOUT" --max-time "$TIMEOUT" "$MANIFEST_URL" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [[ -z "$MANIFEST" ]]; then
|
||||||
|
fail "Manifest is empty or unreachable"
|
||||||
|
else
|
||||||
|
if echo "$MANIFEST" | grep -q "#EXTM3U"; then
|
||||||
|
pass "Manifest is valid M3U8"
|
||||||
|
else
|
||||||
|
fail "Manifest missing #EXTM3U header"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
SEGMENT=$(echo "$MANIFEST" | grep "\.m4s" | tail -1 | tr -d '\r')
|
||||||
|
if [[ -z "$SEGMENT" ]]; then
|
||||||
|
fail "No .m4s segment found in manifest"
|
||||||
|
else
|
||||||
|
pass "Found segment: $SEGMENT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Test manifest Cache-Control
|
||||||
|
MANIFEST_CC=$(fetch_header "$MANIFEST_URL" "Cache-Control")
|
||||||
|
echo ""
|
||||||
|
echo "--- Test 2: Manifest Cache-Control ---"
|
||||||
|
if echo "$MANIFEST_CC" | grep -q "must-revalidate"; then
|
||||||
|
pass "Manifest has must-revalidate ($MANIFEST_CC)"
|
||||||
|
else
|
||||||
|
fail "Manifest missing must-revalidate ($MANIFEST_CC)"
|
||||||
|
fi
|
||||||
|
if echo "$MANIFEST_CC" | grep -q "max-age"; then
|
||||||
|
pass "Manifest has max-age ($MANIFEST_CC)"
|
||||||
|
else
|
||||||
|
fail "Manifest missing max-age ($MANIFEST_CC)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Test segment cache (MISS → HIT)
|
||||||
|
if [[ -n "$SEGMENT" ]]; then
|
||||||
|
SEGMENT_URL="$BASE_URL/hls/$SEGMENT"
|
||||||
|
echo ""
|
||||||
|
echo "--- Test 3: Segment cache MISS→HIT ---"
|
||||||
|
|
||||||
|
FIRST_STATUS=$(fetch_header "$SEGMENT_URL" "CF-Cache-Status")
|
||||||
|
echo " First request: CF-Cache-Status=$FIRST_STATUS"
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
SECOND_STATUS=$(fetch_header "$SEGMENT_URL" "CF-Cache-Status")
|
||||||
|
echo " Second request: CF-Cache-Status=$SECOND_STATUS"
|
||||||
|
|
||||||
|
if [[ "$SECOND_STATUS" == "HIT" ]]; then
|
||||||
|
pass "Segment cache HIT on second request (was $FIRST_STATUS)"
|
||||||
|
elif [[ "$FIRST_STATUS" == "HIT" ]]; then
|
||||||
|
pass "Segment already cached (HIT on first request)"
|
||||||
|
else
|
||||||
|
fail "Segment not cached: first=$FIRST_STATUS, second=$SECOND_STATUS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Test segment Cache-Control (immutable)
|
||||||
|
if [[ -n "$SEGMENT" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "--- Test 4: Segment immutable cache ---"
|
||||||
|
SEGMENT_CC=$(fetch_header "$SEGMENT_URL" "Cache-Control")
|
||||||
|
if echo "$SEGMENT_CC" | grep -q "immutable"; then
|
||||||
|
pass "Segment has immutable ($SEGMENT_CC)"
|
||||||
|
else
|
||||||
|
fail "Segment missing immutable ($SEGMENT_CC)"
|
||||||
|
fi
|
||||||
|
if echo "$SEGMENT_CC" | grep -q "max-age=31536000"; then
|
||||||
|
pass "Segment has 1-year max-age ($SEGMENT_CC)"
|
||||||
|
else
|
||||||
|
fail "Segment missing 1-year max-age ($SEGMENT_CC)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Test init.mp4 cache
|
||||||
|
echo ""
|
||||||
|
echo "--- Test 5: Init segment cache ---"
|
||||||
|
INIT_URL="$BASE_URL/hls/init.mp4"
|
||||||
|
INIT_STATUS=$(fetch_header "$INIT_URL" "CF-Cache-Status")
|
||||||
|
INIT_CC=$(fetch_header "$INIT_URL" "Cache-Control")
|
||||||
|
if [[ "$INIT_STATUS" == "HIT" ]]; then
|
||||||
|
pass "Init segment cached (CF-Cache-Status=$INIT_STATUS, CC=$INIT_CC)"
|
||||||
|
else
|
||||||
|
fail "Init segment not cached: $INIT_STATUS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. Test manifest refresh (should NOT be cached long-term)
|
||||||
|
echo ""
|
||||||
|
echo "--- Test 6: Manifest refresh rate ---"
|
||||||
|
MANIFEST_CF=$(fetch_header "$MANIFEST_URL" "CF-Cache-Status")
|
||||||
|
echo " Manifest CF-Cache-Status: $MANIFEST_CF"
|
||||||
|
# DYNAMIC or BYPASS is correct for live manifests. HIT with long TTL is wrong.
|
||||||
|
if [[ "$MANIFEST_CF" == "DYNAMIC" ]] || [[ "$MANIFEST_CF" == "BYPASS" ]] || [[ "$MANIFEST_CF" == "MISS" ]] || [[ "$MANIFEST_CF" == "REVALIDATED" ]]; then
|
||||||
|
pass "Manifest not long-term cached ($MANIFEST_CF)"
|
||||||
|
elif [[ "$MANIFEST_CF" == "HIT" ]]; then
|
||||||
|
fail "Manifest is HIT — may be stale (check TTL)"
|
||||||
|
else
|
||||||
|
pass "Manifest status: $MANIFEST_CF (no HIT = acceptable)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo ""
|
||||||
|
echo "=== Results: $PASSES passed, $FAILURES failed ==="
|
||||||
|
|
||||||
|
if [[ $FAILURES -gt 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
Reference in new issue
Block a user