Compare commits
23
Commits
1.0.12
..
5585a6ecc3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5585a6ecc3 | ||
|
|
53a0c3c825 | ||
|
|
9e56ff7859 | ||
|
|
9ac1a8b79d | ||
|
|
6950bba85d | ||
|
|
06b277e9c1 | ||
|
|
1cc44282bc | ||
|
|
b6705e6dbd | ||
|
|
cef002862e | ||
|
|
4eb88a4594 | ||
|
|
9cf3956c28 | ||
|
|
a15a9f2df0 | ||
|
|
76397d52d7 | ||
|
|
70a5159f81 | ||
|
|
568112b85a | ||
|
|
02ff53229c | ||
|
|
68a4d35334 | ||
|
|
7afab6658b | ||
|
|
121fdbacdc | ||
|
|
06ab5b4655 | ||
|
|
e15d89edfe | ||
|
|
044851a43e | ||
|
|
926d70dc3c |
No files matched your search
+9
-2
@@ -8,6 +8,8 @@ R2_SECRET_ACCESS_KEY=your-r2-secret-key
|
|||||||
R2_BUCKET_NAME=linearvoid-media
|
R2_BUCKET_NAME=linearvoid-media
|
||||||
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
||||||
R2_REGION=auto
|
R2_REGION=auto
|
||||||
|
R2_KEEP_RUNS=2
|
||||||
|
R2_MAX_RETENTION_BYTES=10737418240
|
||||||
|
|
||||||
# Cloudflare (for custom domain, cache rules, Workers if used)
|
# Cloudflare (for custom domain, cache rules, Workers if used)
|
||||||
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
||||||
@@ -53,8 +55,13 @@ NGINX_PORT=80
|
|||||||
|
|
||||||
# Playout configuration
|
# Playout configuration
|
||||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||||
HLS_SEGMENT_DURATION=6
|
HLS_SEGMENT_DURATION=2
|
||||||
HLS_WINDOW_SIZE=24
|
HLS_WINDOW_SIZE=12
|
||||||
|
|
||||||
|
# Shared runtime config (written by chat admin via /api/admin/config, read by playout).
|
||||||
|
# Both resolve to the same host file through the ./data/playlist volume.
|
||||||
|
CONFIG_PATH=/app/playlist/config.json
|
||||||
|
PLAYOUT_CONFIG_PATH=/app/playlist/config.json
|
||||||
|
|
||||||
# Logging / Monitoring
|
# Logging / Monitoring
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ jobs:
|
|||||||
|
|
||||||
# Playout
|
# Playout
|
||||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||||
HLS_SEGMENT_DURATION=6
|
HLS_SEGMENT_DURATION=2
|
||||||
HLS_WINDOW_SIZE=24
|
HLS_WINDOW_SIZE=12
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# Cloudflare R2 (required for HLS segment sync)
|
# Cloudflare R2 (required for HLS segment sync)
|
||||||
@@ -270,8 +270,8 @@ jobs:
|
|||||||
echo "Playout is healthy"
|
echo "Playout is healthy"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Playout not ready yet (attempt $i/6), waiting 5s..."
|
echo "Playout not ready yet (attempt $i/6), waiting 10s..."
|
||||||
sleep 5
|
sleep 10
|
||||||
done
|
done
|
||||||
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
||||||
echo "Playout failed to become healthy after 6 attempts"
|
echo "Playout failed to become healthy after 6 attempts"
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
name: Deploy to Cloudflare Pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: [CI]
|
|
||||||
types: [completed]
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: cd frontend && npm ci
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: cd frontend && npm run build
|
|
||||||
|
|
||||||
- name: Generate deployment manifest
|
|
||||||
run: |
|
|
||||||
cd frontend/dist
|
|
||||||
form_args="-F manifest={"
|
|
||||||
for file in $(find . -type f ! -name manifest.json | sed 's|^\./||'); do
|
|
||||||
hash=$(sha256sum "$file" | awk '{print $1}')
|
|
||||||
form_args="$form_args\"$file\":\"$hash\","
|
|
||||||
done
|
|
||||||
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
|
||||||
echo "Form arguments: $form_args"
|
|
||||||
echo "FORM_ARGS=$form_args" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Deploy to Cloudflare Pages
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
run: |
|
|
||||||
cd frontend/dist
|
|
||||||
form_args="$FORM_ARGS" # use form arguments generated in previous step
|
|
||||||
# Get short commit hash for deployment metadata
|
|
||||||
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
|
||||||
COMMIT_MSG=$(git log -1 --pretty=%B 2>/dev/null | head -1 || echo "Gitea Actions deploy")
|
|
||||||
|
|
||||||
curl -sS -X POST \
|
|
||||||
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/linearvoid/deployments" \
|
|
||||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
|
||||||
-F "commit_hash=${COMMIT_HASH}" \
|
|
||||||
-F "commit_message=${COMMIT_MSG}" \
|
|
||||||
-F "commit_dirty=false" \
|
|
||||||
$form_args
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Deploy to Cloudflare Workers
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
VITE_PUBLIC_APP_URL: ${{ secrets.VITE_PUBLIC_APP_URL }}
|
||||||
|
VITE_PUBLIC_WS_URL: ${{ secrets.VITE_PUBLIC_WS_URL }}
|
||||||
|
VITE_PUBLIC_HLS_URL: ${{ secrets.VITE_PUBLIC_HLS_URL }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_ROOM: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }}
|
||||||
|
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT: "50"
|
||||||
|
run: cd frontend && npm run build
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Workers
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npx wrangler deploy
|
||||||
+19
-1
@@ -109,7 +109,7 @@
|
|||||||
- Total: 45 frontend tests passing (2 test files).
|
- Total: 45 frontend tests passing (2 test files).
|
||||||
- Added coverage reporting: `vitest.config.ts` with v8 provider, thresholds at 20/15/15/20 (statements/branches/functions/lines).
|
- Added coverage reporting: `vitest.config.ts` with v8 provider, thresholds at 20/15/15/20 (statements/branches/functions/lines).
|
||||||
- Added `test:coverage` script to `package.json`.
|
- Added `test:coverage` script to `package.json`.
|
||||||
- Fixed CI: bumped Node from 20 to 22 in `ci.yml` and `pages-deploy.yml` (jsdom@30 requires Node 22+). Added `engines` to `package.json`.
|
- Fixed CI: bumped Node from 20 to 22 in `ci.yml` and `workers-deploy.yml` (jsdom@30 requires Node 22+). Added `engines` to `package.json`.
|
||||||
- Updated AGENTS.md: added Required tracking files table with 13 docs and update cadence.
|
- Updated AGENTS.md: added Required tracking files table with 13 docs and update cadence.
|
||||||
- All Go vet, Go tests, frontend test:coverage passing.
|
- All Go vet, Go tests, frontend test:coverage passing.
|
||||||
- CORS on media.linearvoid.tv remains the only streaming pipeline gap (Cloudflare R2 config, not code).
|
- CORS on media.linearvoid.tv remains the only streaming pipeline gap (Cloudflare R2 config, not code).
|
||||||
@@ -134,3 +134,21 @@
|
|||||||
- Updated `.gitea/workflows/cd.yml` "Prepare environment" step to include all R2* variables sourced from Gitea secrets (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN*\*`), Cloudflare variables (`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`), and frontend public URL variables.
|
- Updated `.gitea/workflows/cd.yml` "Prepare environment" step to include all R2* variables sourced from Gitea secrets (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN*\*`), Cloudflare variables (`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`), and frontend public URL variables.
|
||||||
- Updated `docs/DEPLOYMENT.md` "Environment File" section: replaced vague "Cloudflare/R2 variables" bullet with explicit variable lists and added a new "Gitea Secrets" table documenting all 15 required secrets.
|
- Updated `docs/DEPLOYMENT.md` "Environment File" section: replaced vague "Cloudflare/R2 variables" bullet with explicit variable lists and added a new "Gitea Secrets" table documenting all 15 required secrets.
|
||||||
- All playout tests pass (20 tests), `go vet` clean.
|
- All playout tests pass (20 tests), `go vet` clean.
|
||||||
|
|
||||||
|
## 2026-08-13
|
||||||
|
|
||||||
|
- Completed all remaining enhancement tasks (only manual/infrastructure phases left):
|
||||||
|
- **Playout**: automatic media normalization on upload (`media.NormalizeVideo`, `*.normalized.mp4`, statuses `ready`/`needs_normalization`/`normalizing`/`normalization_failed`, `POST /api/admin/media/{id}/normalize`); R2 segment retention (`cleanupOldRuns`, keep last `R2_KEEP_RUNS` runs, `R2_MAX_RETENTION_BYTES` cap); seamless looping (playlist SHA-256 fingerprint + `-start_number`); single `start-playout.sh` entrypoint + `-validate` mode; loop/cleanup metrics.
|
||||||
|
- **Application configuration**: thread-safe JSON runtime config store (`backend/chat/src/config/runtime.go`) shared with playout via `/app/playlist/config.json`; `GET/PUT /api/admin/config`; admin UI config form.
|
||||||
|
- **Matomo analytics**: public `GET /api/matomo` + viewer pageview snippet in `index.html`.
|
||||||
|
- **Chat user accounts**: register/login/logout/session endpoints, bcrypt hashing, `linearvoid_user_session` cookie, viewer sign-in UI.
|
||||||
|
- **DM history & search**: `GET /api/dm/history` (paginated) + `GET /api/dm/search` + DM search UI.
|
||||||
|
- **Chat UI**: typing indicator, user-list toggle, media status badges, "Add to playlist" gated to `ready`.
|
||||||
|
- **Prometheus TSDB retention**: `--storage.tsdb.retention.time=30d` + `--storage.tsdb.retention.size=15GB` in both compose files; alert rule count corrected 8 → 10.
|
||||||
|
- Updated `docs/architecture/README.md` to reflect all `[Unreleased]` changes (normalization, looping, retention, runtime config, Matomo, user accounts, DM history/search, new API endpoints, module tables).
|
||||||
|
- Released **1.0.13**: promoted changelog, bumped version, tagged and pushed.
|
||||||
|
- Extended frontend test coverage: added `counter.test.ts`, reconnecting/typing/auth/websocket tests, admin failure + status-badge tests → 56 tests; statements 19.89% → 28.58% (CI coverage threshold passing).
|
||||||
|
- Fixed Go test data race in `TestHandleAdminMediaByID_Normalize` (waits for async normalization goroutine) + shared in-memory SQLite via `SetMaxOpenConns(1)`.
|
||||||
|
- Fixed viewer crash: `index.html` was missing the `#messages` chat container (stray `</section>`); restored structure + made `addMessage` defensive.
|
||||||
|
- Set `Doto` as the body font (`--font-body`) and added Google Fonts links to admin + login pages.
|
||||||
|
- Files changed: across `backend/chat`, `backend/playout`, `frontend`, `docs`, `docker-compose*.yml`, `.github/instructions/*`.
|
||||||
+57
-112
@@ -1,33 +1,67 @@
|
|||||||
# BACKLOG
|
# BACKLOG
|
||||||
|
|
||||||
## Remaining from 2026-08-11 (summary)
|
> All current work is planned in [TODO](./TODO.md) (2026-08-13). This file lists backlog items; new unplanned work is added here.
|
||||||
|
|
||||||
- **Test Fixtures**: media + playlist fixtures created; ffmpeg generation commands documented in test headers
|
## Setup phases remaining
|
||||||
- **User Frontend Tests**: video player, chat integration, responsive, accessibility, coverage
|
|
||||||
- **Admin Frontend Tests**: playlist UI, moderation UI, metrics dashboards, coverage
|
### Phase 5: complete manual Cloudflare and external-service setup
|
||||||
- **Cloudflare R2 Integration Test**: upload + cache header validation done (SyncToR2 with CacheControl + ContentType); edge cache hit/miss validation remains manual
|
|
||||||
- **Cloudflare Config**: Cloudflare Pages deploy workflow and setup docs created; manual dashboard setup (Tiered Cache, WAF, rate limiting, HTTPS/HSTS) remains pending; Pages project must be created in dashboard first.
|
- [ ] Apply Cloudflare Workers, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
|
||||||
- **External Services**: UptimeRobot monitors + alerts
|
- [ ] Cloudflare Workers: link custom domain and cut over frontend hosting
|
||||||
- **Production Deployment**: Hetzner provisioning, CD pipeline, DNS cutover, smoke test
|
- [ ] Enable Smart Tiered Cache; validate `Cf-Cache-Status: HIT` from upper tier
|
||||||
- **Scaling & Performance**: TTFB baseline and live benchmark runs still pending; loadtest scaffolding now includes Artillery/k6 helpers with auto summaries plus worksheet field mapping; chat p95, Redis saturation, and cache-hit threshold alerting are implemented
|
- [ ] WAF: enable OWASP Managed Ruleset (Simulate → review 48h → Block), enable Bot Fight Mode
|
||||||
|
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
||||||
|
- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2; enforce at edge and origin
|
||||||
|
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
|
||||||
|
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5min)
|
||||||
|
- [ ] UptimeRobot: alert on 2 consecutive failures via email + Discord/Slack webhook
|
||||||
|
- [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||||
|
|
||||||
|
### Phase 6: rehearse production deployment end to end
|
||||||
|
|
||||||
|
- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04
|
||||||
|
- [ ] Install Docker Engine + Compose plugin; open firewall: 80/443 public, 2375 runner-only
|
||||||
|
- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs)
|
||||||
|
- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories
|
||||||
|
- [ ] Add `CI_PROD_*` secrets to Gitea (DOCKER_HOST, R2 keys, admin password, DB password, Grafana password)
|
||||||
|
- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP
|
||||||
|
- [ ] Verify all 11 containers healthy: `/health`, `/hls/playlist.m3u8`, WebSocket
|
||||||
|
- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict); test HTTPS + WSS
|
||||||
|
- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout
|
||||||
|
- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL
|
||||||
|
- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host
|
||||||
|
- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds
|
||||||
|
|
||||||
|
### Phase 7: baseline scaling and performance
|
||||||
|
|
||||||
|
- [ ] Execute WebSocket load tests — ramp 10→100→500→1000 connections; measure p95 latency + errors
|
||||||
|
- [ ] Execute admin API benchmarks — `wrk`/`k6` against `/api/admin/*`; target p95 <1s
|
||||||
|
- [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions (WebPageTest 3+ regions, <4s warm cache)
|
||||||
|
- [ ] Tune manifest TTL to 0.5× segment duration; validate `Cf-Cache-Status` distribution
|
||||||
|
- [ ] Track cache-hit ratio and R2 ops in production (Cloudflare GraphQL → Grafana panel, alert <90%)
|
||||||
|
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
|
||||||
|
- [ ] Plan Phase 2 service separation (playout CPU, chat latency, DB state, monitoring)
|
||||||
|
- [ ] Plan Phase 3 multi-origin resilience (second Hetzner location, warm standby, DNS failover, DB/Redis replication)
|
||||||
|
|
||||||
|
### Phase 8: close out remaining test and validation gaps
|
||||||
|
|
||||||
|
- [ ] Admin frontend: user moderation tools tests (mute/ban/report, view moderation logs)
|
||||||
|
- [ ] Frontend: coverage for all critical user + admin components and interactions
|
||||||
|
- [ ] Frontend: document test cases and expected outcomes
|
||||||
|
- [ ] Backend R2: test retrieval of segments/manifests via Cloudflare edge
|
||||||
|
- [ ] Backend R2: validate edge cache hit/miss behavior with repeat requests (`scripts/validate-edge-cache.sh`)
|
||||||
|
|
||||||
|
### Playout
|
||||||
|
|
||||||
|
- [ ] Configure an R2 lifecycle rule on the `hls/` prefix as defense-in-depth (manual)
|
||||||
|
- [ ] Full-loop E2E (2-file playlist, assert `Looping...` with no `CRASHED` across the boundary) — manual via `scripts/test-playout-e2e.sh`
|
||||||
|
|
||||||
## Cloudflare Configuration
|
## Cloudflare Configuration
|
||||||
|
|
||||||
### DNS & Pages
|
### DNS & Workers
|
||||||
|
|
||||||
- [x] Configure DNS for linearvoid.tv apex and www redirect (manual)
|
- [ ] Cloudflare Workers: link custom domain (manual)
|
||||||
- [x] Configure media.linearvoid.tv for split-domain HLS delivery (manual)
|
- [ ] Create Cloudflare Worker and cut over frontend hosting (manual)
|
||||||
- [x] Configure Cloudflare Pages deploy workflow (`.gitea/workflows/pages-deploy.yml`) and setup docs (`docs/operations/cloudflare-pages-setup.md`, `docs/operations/gitea-cloudflare-pages.md`)
|
|
||||||
- [x] Create Cloudflare Pages project in dashboard (unavailable, created `linearvoid` via API)
|
|
||||||
- [ ] Cloudflare Pages: link custom domain (manual)
|
|
||||||
- [ ] Create Cloudflare Pages project and cut over frontend hosting (manual)
|
|
||||||
|
|
||||||
### Storage & Cache
|
|
||||||
|
|
||||||
- [x] Create R2 bucket (manual)
|
|
||||||
- [x] Configure Cache Rules: .ts/.m4s + .m3u8 (manual)
|
|
||||||
- [x] Implement Worker for immutable cache headers on segment paths
|
|
||||||
- [ ] CORS headers on HLS responses from media.linearvoid.tv (MEDIUM) — needs Cloudflare R2 CORS config
|
|
||||||
|
|
||||||
### Security & TLS
|
### Security & TLS
|
||||||
|
|
||||||
@@ -40,13 +74,6 @@
|
|||||||
|
|
||||||
## Monitoring & Observability
|
## Monitoring & Observability
|
||||||
|
|
||||||
### Prometheus & Grafana
|
|
||||||
|
|
||||||
- [x] Set up Prometheus with chat/playout/nginx/redis/postgres/node scrape targets
|
|
||||||
- [x] Add nginx-exporter, redis-exporter, postgres-exporter, node-exporter
|
|
||||||
- [x] Import Grafana app + operational dashboards
|
|
||||||
- [x] Define Prometheus alerting rules (8 rules: ChatDown, PlayoutDown, RedisDown, etc.)
|
|
||||||
|
|
||||||
### External Monitoring
|
### External Monitoring
|
||||||
|
|
||||||
- [ ] Set up UptimeRobot external monitoring
|
- [ ] Set up UptimeRobot external monitoring
|
||||||
@@ -54,76 +81,6 @@
|
|||||||
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
|
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
|
||||||
- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
|
- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- [x] Implement admin cookie-session auth (HttpOnly, SameSite=Strict, Secure when TLS)
|
|
||||||
- [x] Implement server-side session expiration
|
|
||||||
- [x] Implement rate limiting for chat messages (Redis windowing)
|
|
||||||
- [x] Implement WebSocket connection rate limiting (5/IP/60s)
|
|
||||||
- [x] Implement admin login rate limiting (5/IP/5min)
|
|
||||||
- [ ] Enforce HTTPS/WSS at edge and origin
|
|
||||||
|
|
||||||
## Test Fixtures
|
|
||||||
|
|
||||||
- [x] Create test media fixtures in `backend/playout/testdata/`: valid H.264/AAC, invalid codec, invalid framerate
|
|
||||||
- [x] Create test playlist fixtures: `valid_playlist.txt`, `empty_playlist.txt`, `comments_only.txt`, `mixed_valid_invalid.txt`
|
|
||||||
- [x] Document ffmpeg fixture-generation commands in test file headers
|
|
||||||
|
|
||||||
## Frontend Tests
|
|
||||||
|
|
||||||
### User Frontend
|
|
||||||
|
|
||||||
- [x] Video player behavior (play, pause, seek, buffering, error handling) — HLS player startup, native/fallback, URL resolution, error recovery with retries/backoff/reconnecting overlay
|
|
||||||
- [x] Chat functionality (send/receive messages, presence, rate limiting, moderation) — send, receive, presence join/leave, DM flow, reconnect covered
|
|
||||||
- [x] State management and data flow
|
|
||||||
- [x] Responsive design and layout across different screen sizes — chat-collapsed toggle, CSS grid, now-playing aria
|
|
||||||
- [x] Accessibility testing (keyboard navigation, screen reader support) — video controls, aria-labels, aria-expanded, input labels, reconnecting overlay aria-live/role
|
|
||||||
- [x] Integration with backend APIs (mocking API responses for testing)
|
|
||||||
- [ ] Test coverage for all critical frontend components and interactions
|
|
||||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
|
||||||
- [ ] Document test cases and expected outcomes
|
|
||||||
|
|
||||||
### Admin Frontend
|
|
||||||
|
|
||||||
- [x] Playlist management UI (add/remove videos, reorder, normalize) — save/error handling, sortable rendering
|
|
||||||
- [ ] User moderation tools (mute/ban/report users, view moderation logs)
|
|
||||||
- [x] Metrics and monitoring dashboards (Prometheus/Grafana integration) — overview + analytics rendering
|
|
||||||
- [x] State management and data flow
|
|
||||||
- [x] Responsive design and layout across different screen sizes — tab content visibility, essential tabs
|
|
||||||
- [x] Accessibility testing (keyboard navigation, screen reader support) — nav aria-label, active tab aria-current, nav button focusability
|
|
||||||
- [x] Integration with backend APIs (mocking API responses for testing)
|
|
||||||
- [ ] Test coverage for all critical admin UI components and interactions
|
|
||||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
|
||||||
- [ ] Document test cases and expected outcomes
|
|
||||||
|
|
||||||
## Backend Integration Tests
|
|
||||||
|
|
||||||
### Cloudflare R2
|
|
||||||
|
|
||||||
- [x] Upload test HLS segments and manifests to R2 bucket (SyncToR2 with mock, verified bucket/key/body)
|
|
||||||
- [x] Verify correct cache headers are applied (immutable segments via segmentCacheControl, short-lived manifests via manifestCacheControl)
|
|
||||||
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
|
|
||||||
- [ ] Validate cache hit/miss behavior and performance metrics
|
|
||||||
- [ ] Validate edge cache hit/miss behavior with repeat requests — script `scripts/validate-edge-cache.sh` created
|
|
||||||
- [x] Ensure integration test can run in CI/CD pipeline with proper mocking/stubbing of R2 API (putObject mock, no network calls)
|
|
||||||
- [x] Document test cases and expected outcomes
|
|
||||||
- [x] Document required stubs or mocks (putObject variable injection pattern)
|
|
||||||
|
|
||||||
## Streaming Pipeline
|
|
||||||
|
|
||||||
- [x] **Continuous R2 sync during playout (HIGH)**: fsnotify watcher uploads segments in real-time (~7s delay)
|
|
||||||
- [x] **Globally unique segment naming (HIGH)**: `r-{ISOTIME}/seg-{seq}.m4s` via run ID + ffmpeg monotonic sequence
|
|
||||||
- [x] **Atomic manifest publication (MEDIUM)**: Upload to `playlist.m3u8.tmp`, then `CopyObject` to final key
|
|
||||||
- [x] **Manifest Cache-Control alignment (LOW)**: `public, max-age=1, s-maxage=2, must-revalidate`
|
|
||||||
- [ ] **CORS headers on HLS responses (MEDIUM)**: Needs Cloudflare R2 bucket CORS config (infrastructure, not code)
|
|
||||||
- [x] **Player error recovery (MEDIUM)**: `hls.on(Hls.Events.ERROR)` handler with bounded retries (5x), exponential backoff, reconnecting overlay
|
|
||||||
- [x] **Health endpoint detail (LOW)**: Added active item, run ID, playlist revision, last segment seq, last R2 pub, consecutive failures
|
|
||||||
- [x] Source media contract + validation (H.264/AAC, 30fps, keyframe check)
|
|
||||||
- [x] fMP4 HLS packaging (6s segments, 24 window)
|
|
||||||
- [x] Slate fallback + recovery (`shouldEscalateToSlate`)
|
|
||||||
- [x] R2 cache headers (immutable segments, short-lived manifest)
|
|
||||||
- [x] Cloudflare Worker immutable `.m4s` headers
|
|
||||||
|
|
||||||
## External Services
|
## External Services
|
||||||
|
|
||||||
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||||
@@ -162,9 +119,6 @@
|
|||||||
|
|
||||||
### Load Testing
|
### Load Testing
|
||||||
|
|
||||||
- [x] Load-test scaffolding: `ws-chat.yml`, `admin-api.js`, `run-loadtests.ps1`, summarizers, README
|
|
||||||
- [x] Cache-hit ratio exporter (`scripts/cache-hit-ratio.js` + `.ps1`)
|
|
||||||
- [x] Saturation alerts: chat p95 latency, Redis memory usage, cache-hit ratio
|
|
||||||
- [ ] Execute WebSocket load tests with real infrastructure — ramp 10→100→500→1000 connections, measure p95 latency + errors
|
- [ ] Execute WebSocket load tests with real infrastructure — ramp 10→100→500→1000 connections, measure p95 latency + errors
|
||||||
- [ ] Execute admin API benchmarks with real infrastructure — `wrk`/`k6` against `/api/admin/*`, target p95 <1s
|
- [ ] Execute admin API benchmarks with real infrastructure — `wrk`/`k6` against `/api/admin/*`, target p95 <1s
|
||||||
|
|
||||||
@@ -180,12 +134,3 @@
|
|||||||
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
|
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
|
||||||
- [ ] Plan Phase 2 service separation — document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources
|
- [ ] Plan Phase 2 service separation — document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources
|
||||||
- [ ] Plan Phase 3 multi-origin resilience — research second Hetzner location, design warm standby + DNS failover + DB/Redis replication
|
- [ ] Plan Phase 3 multi-origin resilience — research second Hetzner location, design warm standby + DNS failover + DB/Redis replication
|
||||||
|
|
||||||
## Feature List (Future Enhancements)
|
|
||||||
|
|
||||||
- [ ] Admin backend
|
|
||||||
- [ ] Analytics: implement a settings entry to set matomo url and page id
|
|
||||||
- [ ] User frontend
|
|
||||||
- [ ] Chat: add user registration and login flows
|
|
||||||
- [ ] Chat: private DM history and search
|
|
||||||
- [ ] Analytics: add javascript snippet to send pageview events to matomo
|
|
||||||
+23
-984
File diff suppressed because it is too large.
Load diff
@@ -1,80 +1,30 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
> Completed tasks moved to [DONE](./DONE.md). Full remaining task list in [BACKLOG](./BACKLOG.md).
|
> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
|
||||||
|
|
||||||
## Plan
|
## Cloudflare Workers
|
||||||
|
|
||||||
### Phase 1: unblock playout test fixtures and lock down the source-media contract
|
- [x] Adjust `frontend/src/main.ts` to use `VITE_PUBLIC_APP_URL` for API requests instead of hardcoded `/api/...` paths.
|
||||||
|
- [x] Adjust `frontend/src/main.ts` to use `VITE_PUBLIC_WS_URL` for WebSocket connections instead of hardcoded `wss://...` paths.
|
||||||
|
|
||||||
- [x] Create small compliant fixture media in `backend/playout/testdata/` for the validator and playout tests
|
## Frontend Tests
|
||||||
- [x] Create invalid fixture media for codec, framerate, and audio rejection paths
|
|
||||||
- [x] Create playlist fixtures for empty, comment-only, mixed-validity, and valid playlists
|
|
||||||
- [x] Document the ffmpeg fixture-generation commands in the relevant test headers
|
|
||||||
|
|
||||||
### Phase 2: finish frontend coverage for the live viewer experience
|
### User Frontend
|
||||||
|
|
||||||
- [x] Add tests for HLS player startup, manifest resolution, and cross-origin/local fallback behavior
|
- [ ] Test coverage for all critical frontend components and interactions
|
||||||
- [x] Add tests for chat send/receive, presence updates, private DM flow, and reconnect behavior
|
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||||
- [x] Add responsive-layout and accessibility checks for the viewer shell and chat sidebar
|
- [ ] Document test cases and expected outcomes
|
||||||
- [x] Add coverage reporting and enforce a minimum threshold in CI
|
|
||||||
|
|
||||||
### Phase 3: finish frontend coverage for the admin experience
|
### Admin Frontend
|
||||||
|
|
||||||
- [x] Add tests for playlist management, moderation flows, and dashboard rendering
|
- [ ] Test coverage for all critical admin UI components and interactions
|
||||||
- [x] Add tests for API integration points with mocked backend responses
|
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||||
- [x] Add responsive-layout and accessibility checks for admin screens
|
- [ ] Document test cases and expected outcomes
|
||||||
- [x] Add coverage reporting and enforce a minimum threshold in CI
|
|
||||||
|
|
||||||
### Phase 4: validate the R2 and edge-delivery path
|
## Backend Integration Tests
|
||||||
|
|
||||||
- [x] Add an integration test for upload and retrieval of HLS objects through the R2 path
|
### Cloudflare R2
|
||||||
- [x] Verify segment and manifest cache headers, including immutable media and live manifest freshness
|
|
||||||
- [x] Validate edge cache hit/miss behavior with repeat requests
|
|
||||||
- [x] Document any required stubs or mocks so the test can run in CI (putObject mock pattern documented in test)
|
|
||||||
|
|
||||||
### Phase 5: complete manual Cloudflare and external-service setup
|
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
|
||||||
|
- [ ] Validate cache hit/miss behavior and performance metrics
|
||||||
- [ ] Apply Cloudflare Pages, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
|
- [ ] Validate edge cache hit/miss behavior with repeat requests — script `scripts/validate-edge-cache.sh` created
|
||||||
- [ ] Create or verify UptimeRobot monitors for health and HLS manifest availability
|
|
||||||
- [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`
|
|
||||||
|
|
||||||
### Phase 6: rehearse production deployment end to end
|
|
||||||
|
|
||||||
- [ ] Provision the Hetzner host and prepare the Docker/runtime baseline
|
|
||||||
- [ ] Configure production secrets, remote deployment, and DNS cutover
|
|
||||||
- [ ] Run health, HLS, chat, and admin smoke tests against production-like infrastructure
|
|
||||||
- [ ] Verify backup creation, restore, and integrity checks on the production host
|
|
||||||
|
|
||||||
### Phase 7: baseline scaling and performance
|
|
||||||
|
|
||||||
- [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions
|
|
||||||
- [x] Prepare WebSocket load-test scaffolding and summary artifacts (`ws-chat.yml`, `run-loadtests.ps1`, `summarize-artillery.ps1`)
|
|
||||||
- [ ] Execute WebSocket throughput and chat-latency benchmarks under growing connection counts
|
|
||||||
- [x] Prepare Admin API benchmark scaffolding and endpoint summary artifacts (`admin-api.js`, `run-loadtests.ps1`, `summarize-k6.ps1`)
|
|
||||||
- [ ] Execute Admin API benchmarks and identify slow paths
|
|
||||||
- [x] Add saturation thresholds to monitoring (chat p95 latency + Redis memory usage)
|
|
||||||
- [x] Add cache-hit threshold alert operationalization to monitoring
|
|
||||||
|
|
||||||
## Status Update (2026-08-10)
|
|
||||||
|
|
||||||
- [x] Phase 1 (test fixtures) complete.
|
|
||||||
- [x] Phase 2 (viewer frontend): player startup, URL resolution, chat send/receive, reconnect, presence, DM flow, message rendering all covered (27 tests).
|
|
||||||
- [x] Phase 3 (admin frontend): dashboard rendering, API integration, playlist management all covered (31 tests total).
|
|
||||||
- [x] Phase 4 (R2 validation) partially complete: SyncToR2 cache headers + integration tests done; edge cache hit/miss remains manual.
|
|
||||||
- [ ] Phase 5 (Cloudflare): Pages deploy workflow (`.gitea/workflows/pages-deploy.yml`) created; Pages/Workers setup docs written; manual dashboard tasks remain.
|
|
||||||
- [ ] Phase 6 (production deployment) pending: Hetzner provisioning, secrets, DNS cutover, smoke tests, backup/restore.
|
|
||||||
- [ ] Phase 7: Loadtest scaffolding complete; `run-loadtests.ps1` auto-generates ws-chat-summary.txt and admin-api-endpoint-summary.txt; live benchmark execution pending.
|
|
||||||
- Version tagged 1.0.11; go vet clean.
|
|
||||||
|
|
||||||
## Streaming Pipeline Gaps (2026-08-10 audit → 2026-08-10 implemented)
|
|
||||||
|
|
||||||
STREAMING.md contract vs playlist.go/main.ts implementation. See Backlog for full details.
|
|
||||||
|
|
||||||
- [x] Continuous R2 sync during playout (HIGH) — implemented: fsnotify watcher uploads segments within ~7s
|
|
||||||
- [x] Globally unique segment naming with run ID (HIGH) — implemented: `r-{ISOTIME}/seg-{seq}.m4s`
|
|
||||||
- [x] Atomic manifest publication with temp key (MEDIUM) — implemented: upload to .tmp, then CopyObject
|
|
||||||
- [x] Manifest Cache-Control: add must-revalidate + s-maxage split (LOW) — implemented: `public, max-age=1, s-maxage=2, must-revalidate`
|
|
||||||
- [ ] CORS headers on HLS responses from media.linearvoid.tv (MEDIUM) — infrastructure, needs Cloudflare R2 CORS config
|
|
||||||
- [x] Player error recovery: retries, backoff, reconnecting state (MEDIUM) — implemented: hls.on(ERROR) handler with exponential backoff
|
|
||||||
- [x] Health endpoint: add active item, revision, run ID, segment info (LOW) — implemented: full STREAMING.md contract fields
|
|
||||||
- [x] Phase 4 edge cache validation — script `scripts/validate-edge-cache.sh` created
|
|
||||||
+54
-1
@@ -4,6 +4,59 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Transient presence flash**: Join/leave announcements now appear as a 3-second flash at the top of the chat panel (`#chat-flash`) with a fade-out, instead of permanent chat rows. Presence indicators still update from the same events.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **System messages no longer persisted**: The chat backend no longer stores `system`-type messages; chat history and the admin chat-log table only contain real chat messages (moderation actions remain in the audit log).
|
||||||
|
- **Reconnect grace period**: Disconnects schedule a delayed "left" announcement (5s); a reconnect within 5s cancels it and skips "joined", eliminating join/leave spam on brief reconnects.
|
||||||
|
- **HLS segment duration 2s**: Default `HLS_SEGMENT_DURATION` changed from 6 to 2 and `HLS_WINDOW_SIZE` from 24 to 12 (2s × 12 = 24s live window), reducing end-to-end latency; the 2s GOP was already in place.
|
||||||
|
- **Chat font sizing**: `.message-text` set to `0.95rem` (slightly smaller than the default, larger than the `0.83rem` system/flash text).
|
||||||
|
- **R2 retention by run count + size**: Playout now keeps the newest `R2_KEEP_RUNS` run folders (default 2) and enforces an `R2_MAX_RETENTION_BYTES` cap (default 10 GiB), replacing the old 24h time-based `R2_SEGMENT_RETENTION_HOURS` cleanup. Cleanup runs after each new run plus an hourly backstop, and never deletes the live manifest. Adds `linearvoid_playout_r2_runs_retained` and `linearvoid_playout_r2_bytes_retained` gauges.
|
||||||
|
- **Cloudflare Workers frontend hosting**: Replaced Cloudflare Pages with a Cloudflare Worker (`linearvoid-frontend`) that serves the static SPA via Workers Static Assets. Added `frontend/wrangler.toml` and renamed the deploy workflow to `.gitea/workflows/workers-deploy.yml` (builds the SPA and runs `npx wrangler deploy`). Architecture, setup/deployment, and ops documentation updated from Pages to Workers.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Login/register cross-origin API URLs**: `submitAuth` in `frontend/src/main.ts` now prefixes `/api/auth/login` and `/api/auth/register` with `VITE_PUBLIC_APP_URL` (falling back to same-origin), matching the other API calls so the Workers-served SPA reaches the app origin instead of a same-origin path.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- WebSocket: `TestHub_Broadcast_SystemNotPersisted`, `TestHub_ReconnectGracePeriod`.
|
||||||
|
- Handlers: `TestHandleAdminChatLog_ExcludesSystem`.
|
||||||
|
- Frontend: `presence flash` test.
|
||||||
|
- Playout: `TestCleanupOldRuns`, `TestCleanupOldRuns_SizeCap`, `TestR2KeepRuns`, `TestR2MaxRetentionBytes`, `TestR2RunFromKey`.
|
||||||
|
|
||||||
|
## [1.0.13] - 2026-08-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Automatic media normalization on upload**: Chat backend auto-transcodes non-conforming uploads. New `media.NormalizeVideo` wraps ffmpeg with the Linear Void profile (H.264/AAC, 30fps, 2s keyframes, yuv420p, 48kHz stereo), re-verifies output with ffprobe, and writes `*.normalized.mp4`. Media records gain `normalizing` / `normalization_failed` statuses; `POST /api/admin/media/{id}/normalize` re-triggers manually. Admin UI shows a "Normalize" action with status polling.
|
||||||
|
- **R2 segment retention**: Playout `cleanupOldSegments` deletes `hls/r-*/*` objects older than `R2_SEGMENT_RETENTION_HOURS` (default 24h), run hourly + at startup, never touching the live manifest. Exposes `linearvoid_playout_r2_cleanup_objects_total` metric. `.env.example` adds `R2_SEGMENT_RETENTION_HOURS`.
|
||||||
|
- **Playout loop metrics**: `linearvoid_playout_loops_total` (clean completions) and `linearvoid_playout_run_duration_seconds` (last run duration).
|
||||||
|
- **Runtime configuration store**: Thread-safe, JSON-persisted runtime config (`HLS_SEGMENT_DURATION`, `HLS_WINDOW_SIZE`, `MATOMO_URL`, `MATOMO_SITE_ID`) with typed getters/setters + validation, editable via `GET/PUT /api/admin/config`. Playout reads the shared `config.json` as env fallback.
|
||||||
|
- **Matomo analytics**: Viewer pageview snippet in `index.html` driven by the public `GET /api/matomo` endpoint.
|
||||||
|
- **Chat user accounts**: Register/login/logout (`POST /api/auth/register`, `/login`, `/logout`, `GET /api/auth/session`), bcrypt password hashing, `linearvoid_user_session` cookie, and a viewer sign-in UI.
|
||||||
|
- **Private DM history & search**: `GET /api/dm/history` (paginated) and `GET /api/dm/search`, with a DM search UI in the viewer.
|
||||||
|
- **Chat UI polish**: Typing indicator, user-list toggle, media status badges, and an "Add to playlist" action gated to `ready` media.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Playout seamless looping**: `main()` now only resets HLS output when the playlist changed (SHA-256 `playlistRevision`), on crash/slate recovery, or first run; otherwise it continues segment numbering via `-start_number` and prunes stale segments. Eliminates the viewer gap on clean playlist loops.
|
||||||
|
- **Single playout entrypoint**: `start-playout.sh` now runs `/app/playlist-manager -validate` then `exec`s the Go binary, removing the duplicate shell ffmpeg loop. Added a `-validate` mode to `main()`.
|
||||||
|
- **Admin media library**: Shows status badges (`ready`/`needs_normalization`/`normalizing`/`failed`) and only offers "Add to playlist" for `ready` media; playlist entries show per-item status badges.
|
||||||
|
- **Prometheus TSDB retention**: The `prometheus` service now sets `--storage.tsdb.retention.time=30d` and `--storage.tsdb.retention.size=15GB` (in both `docker-compose.yml` and `docker-compose.prod.yml`), so metrics older than 30 days are deleted.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Media: `TestNormalizationArgs`, `TestNormalizeVideo_FFmpegError`.
|
||||||
|
- Handlers: `TestNormalizeMediaRecord_Success`, `TestNormalizeMediaRecord_Failure`, `TestHandleAdminMediaByID_Normalize`.
|
||||||
|
- Playout: `TestCleanupOldSegments`, `TestR2SegmentRetentionHours`, `TestPlaylistFingerprint`, `TestHLSNextStartNumber`, `TestLoadSharedHLSConfig`.
|
||||||
|
- Config: `runtime_test.go` (get/set, persist/reload, env fallback, validation).
|
||||||
|
- Auth: `TestUserSessionStore`.
|
||||||
|
- Handlers: config GET/PUT + validation, DM history/search, user register/login/session.
|
||||||
|
|
||||||
## [1.0.12] - 2026-08-11
|
## [1.0.12] - 2026-08-11
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -13,7 +66,7 @@
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Playout R2 endpoint validation**: `initR2Client()` now validates all required R2 environment variables (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`) before creating the S3 client. Previously, an empty `R2_ENDPOINT` caused cryptic `Custom endpoint '' was not a valid URI` errors on every segment upload attempt. Playout now gracefully degrades when R2 is not configured — local HLS via nginx continues to work.
|
- **Playout R2 endpoint validation**: `initR2Client()` now validates all required R2 environment variables (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`) before creating the S3 client. Previously, an empty `R2_ENDPOINT` caused cryptic `Custom endpoint '' was not a valid URI` errors on every segment upload attempt. Playout now gracefully degrades when R2 is not configured — local HLS via nginx continues to work.
|
||||||
- **CD workflow missing R2 secrets**: `.gitea/workflows/cd.yml` "Prepare environment" step now injects all R2_ variables (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, plus admin variants) from Gitea secrets. Previously the generated `.env` was missing R2 entirely, causing the playout container to start with an empty endpoint.
|
- **CD workflow missing R2 secrets**: `.gitea/workflows/cd.yml` "Prepare environment" step now injects all R2\_ variables (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, plus admin variants) from Gitea secrets. Previously the generated `.env` was missing R2 entirely, causing the playout container to start with an empty endpoint.
|
||||||
- **Deployment docs**: `docs/DEPLOYMENT.md` now includes explicit R2 and Cloudflare variable lists plus a "Gitea Secrets" table documenting all 15 required secrets.
|
- **Deployment docs**: `docs/DEPLOYMENT.md` now includes explicit R2 and Cloudflare variable lists plus a "Gitea Secrets" table documenting all 15 required secrets.
|
||||||
|
|
||||||
## [1.0.11] - 2026-08-10
|
## [1.0.11] - 2026-08-10
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||||
[](https://git.allucanget.biz)
|
[](https://git.allucanget.biz)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -46,6 +46,8 @@ flowchart TD
|
|||||||
P -->|sync| R2
|
P -->|sync| R2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A detailed description of the Streaming architecture, playout, and delivery is available in [`docs/STREAMING.md`](docs/STREAMING.md).
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
This structure directly reflects the building blocks, runtime flows, deployment view, and cross-cutting concepts defined in the arc42 documentation.
|
This structure directly reflects the building blocks, runtime flows, deployment view, and cross-cutting concepts defined in the arc42 documentation.
|
||||||
@@ -185,7 +187,7 @@ frontend/
|
|||||||
Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
||||||
|
|
||||||
- Cloudflare
|
- Cloudflare
|
||||||
- [worker.js](infrastructure/cloudflare/worker.js) — adds immutable cache headers to HLS segments
|
- [worker.js](infrastructure/cloudflare/worker.js) — Cloudflare Worker serving the static SPA and adding immutable cache headers to HLS segments
|
||||||
- Nginx
|
- Nginx
|
||||||
- [Dockerfile](infrastructure/nginx/Dockerfile) — builds Nginx with custom config
|
- [Dockerfile](infrastructure/nginx/Dockerfile) — builds Nginx with custom config
|
||||||
- [nginx.conf](infrastructure/nginx/nginx.conf) — reverse proxy, static SPA, HLS delivery
|
- [nginx.conf](infrastructure/nginx/nginx.conf) — reverse proxy, static SPA, HLS delivery
|
||||||
@@ -260,8 +262,8 @@ See the [architecture docs](docs/architecture/README.md) for detailed architectu
|
|||||||
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` | HLS playlist URL |
|
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` | HLS playlist URL |
|
||||||
| `ADMIN_USERNAME` | `admin` | Admin panel username |
|
| `ADMIN_USERNAME` | `admin` | Admin panel username |
|
||||||
| `ADMIN_PASSWORD` | — | Admin panel password |
|
| `ADMIN_PASSWORD` | — | Admin panel password |
|
||||||
| `HLS_SEGMENT_DURATION` | `6` | HLS segment duration in seconds |
|
| `HLS_SEGMENT_DURATION` | `2` | HLS segment duration in seconds |
|
||||||
| `HLS_WINDOW_SIZE` | `24` | HLS playlist window size |
|
| `HLS_WINDOW_SIZE` | `12` | HLS playlist window size |
|
||||||
|
|
||||||
Full reference: [`.env.example`](.env.example).
|
Full reference: [`.env.example`](.env.example).
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/redis/go-redis/v9 v9.7.0
|
github.com/redis/go-redis/v9 v9.7.0
|
||||||
|
golang.org/x/crypto v0.37.0
|
||||||
gorm.io/driver/postgres v1.5.11
|
gorm.io/driver/postgres v1.5.11
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
@@ -24,7 +25,6 @@ require (
|
|||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/crypto v0.37.0 // indirect
|
|
||||||
golang.org/x/sync v0.13.0 // indirect
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/text v0.24.0 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
|
|||||||
@@ -137,4 +137,52 @@ func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc)
|
|||||||
next(w, r)
|
next(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSessionStore maps user session tokens to usernames with expiry.
|
||||||
|
type UserSessionStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]userSession
|
||||||
|
}
|
||||||
|
|
||||||
|
type userSession struct {
|
||||||
|
Username string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserSessionStore creates a new user session store.
|
||||||
|
func NewUserSessionStore() *UserSessionStore {
|
||||||
|
return &UserSessionStore{sessions: make(map[string]userSession)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stores a user session token with username and expiry.
|
||||||
|
func (s *UserSessionStore) Add(token, username string, expiresAt time.Time) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sessions[token] = userSession{Username: username, ExpiresAt: expiresAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username returns the username for a valid, unexpired token.
|
||||||
|
func (s *UserSessionStore) Username(token string) (string, bool) {
|
||||||
|
if token == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
sess, ok := s.sessions[token]
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if time.Now().After(sess.ExpiresAt) {
|
||||||
|
delete(s.sessions, token)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return sess.Username, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deletes a user session token.
|
||||||
|
func (s *UserSessionStore) Remove(token string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.sessions, token)
|
||||||
}
|
}
|
||||||
@@ -245,4 +245,33 @@ func TestRequireAdmin(t *testing.T) {
|
|||||||
if w3.Code != http.StatusUnauthorized {
|
if w3.Code != http.StatusUnauthorized {
|
||||||
t.Errorf("status = %d, want 401", w3.Code)
|
t.Errorf("status = %d, want 401", w3.Code)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserSessionStore(t *testing.T) {
|
||||||
|
store := NewUserSessionStore()
|
||||||
|
|
||||||
|
if _, ok := store.Username(""); ok {
|
||||||
|
t.Error("Username(\"\") = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, _ := GenerateToken()
|
||||||
|
store.Add(token, "alice", time.Now().Add(1*time.Hour))
|
||||||
|
|
||||||
|
username, ok := store.Username(token)
|
||||||
|
if !ok || username != "alice" {
|
||||||
|
t.Errorf("Username() = %q, %v; want alice, true", username, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired session
|
||||||
|
expired, _ := GenerateToken()
|
||||||
|
store.Add(expired, "bob", time.Now().Add(-1*time.Hour))
|
||||||
|
if _, ok := store.Username(expired); ok {
|
||||||
|
t.Error("Username(expired) = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
store.Remove(token)
|
||||||
|
if _, ok := store.Username(token); ok {
|
||||||
|
t.Error("Username after Remove = true, want false")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ const (
|
|||||||
DefaultRateLimitMax = 10
|
DefaultRateLimitMax = 10
|
||||||
DefaultRateLimitWindow = 10 * time.Second
|
DefaultRateLimitWindow = 10 * time.Second
|
||||||
DefaultAdminSessionTTL = 12 * time.Hour
|
DefaultAdminSessionTTL = 12 * time.Hour
|
||||||
|
DefaultUserSessionTTL = 30 * 24 * time.Hour
|
||||||
DefaultChatPort = "3000"
|
DefaultChatPort = "3000"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,6 +69,19 @@ func AdminSessionTTL() time.Duration {
|
|||||||
return time.Duration(hours) * time.Hour
|
return time.Duration(hours) * time.Hour
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserSessionTTL returns the user session TTL from env or default (30 days).
|
||||||
|
func UserSessionTTL() time.Duration {
|
||||||
|
raw := strings.TrimSpace(os.Getenv("USER_SESSION_TTL_HOURS"))
|
||||||
|
if raw == "" {
|
||||||
|
return DefaultUserSessionTTL
|
||||||
|
}
|
||||||
|
hours, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || hours <= 0 {
|
||||||
|
return DefaultUserSessionTTL
|
||||||
|
}
|
||||||
|
return time.Duration(hours) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
// RateLimitMax returns the chat rate limit max messages per window.
|
// RateLimitMax returns the chat rate limit max messages per window.
|
||||||
func RateLimitMax() int {
|
func RateLimitMax() int {
|
||||||
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
|
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Runtime configuration keys stored in the runtime config file.
|
||||||
|
const (
|
||||||
|
keyMatomoURL = "matomo_url"
|
||||||
|
keyMatomoSiteID = "matomo_site_id"
|
||||||
|
keyHLSSegmentDur = "hls_segment_duration"
|
||||||
|
keyHLSWindowSize = "hls_window_size"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default HLS values used when neither env nor persisted config is set.
|
||||||
|
const (
|
||||||
|
defaultHLSSegmentDuration = 2
|
||||||
|
defaultHLSWindowSize = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
runtimeMu sync.RWMutex
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigPath returns the runtime config JSON file path from env or default.
|
||||||
|
// The default lives in the shared playlist volume so the playout service can
|
||||||
|
// read the same file (chat has read-write, playout read-only).
|
||||||
|
func ConfigPath() string {
|
||||||
|
p := strings.TrimSpace(os.Getenv("CONFIG_PATH"))
|
||||||
|
if p == "" {
|
||||||
|
return "/app/playlist/config.json"
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRuntime returns a value from the runtime store and whether it is present.
|
||||||
|
func GetRuntime(key string) (string, bool) {
|
||||||
|
runtimeMu.RLock()
|
||||||
|
defer runtimeMu.RUnlock()
|
||||||
|
v, ok := runtimeStore[key]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRuntime stores a value in the runtime store (in-memory only).
|
||||||
|
func SetRuntime(key, value string) {
|
||||||
|
runtimeMu.Lock()
|
||||||
|
defer runtimeMu.Unlock()
|
||||||
|
runtimeStore[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadRuntimeConfig loads the persisted runtime config from disk. If the file does
|
||||||
|
// not exist, it seeds the store from environment variables.
|
||||||
|
func LoadRuntimeConfig() error {
|
||||||
|
data, err := os.ReadFile(ConfigPath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
runtimeMu.Lock()
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
runtimeMu.Unlock()
|
||||||
|
seedRuntimeDefaults()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read runtime config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := map[string]string{}
|
||||||
|
if err := json.Unmarshal(data, &values); err != nil {
|
||||||
|
return fmt.Errorf("parse runtime config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeMu.Lock()
|
||||||
|
defer runtimeMu.Unlock()
|
||||||
|
runtimeStore = values
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRuntimeConfig persists the runtime store to disk as JSON.
|
||||||
|
func SaveRuntimeConfig() error {
|
||||||
|
runtimeMu.RLock()
|
||||||
|
data, err := json.MarshalIndent(runtimeStore, "", " ")
|
||||||
|
runtimeMu.RUnlock()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal runtime config: %w", err)
|
||||||
|
}
|
||||||
|
if dir := filepath.Dir(ConfigPath()); dir != "" && dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create runtime config dir: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(ConfigPath(), append(data, '\n'), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write runtime config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedRuntimeDefaults populates the store from environment variables.
|
||||||
|
func seedRuntimeDefaults() {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("MATOMO_URL")); v != "" {
|
||||||
|
SetRuntime(keyMatomoURL, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("MATOMO_SITE_ID")); v != "" {
|
||||||
|
SetRuntime(keyMatomoSiteID, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION")); v != "" {
|
||||||
|
SetRuntime(keyHLSSegmentDur, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE")); v != "" {
|
||||||
|
SetRuntime(keyHLSWindowSize, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatomoURL returns the Matomo analytics URL.
|
||||||
|
func MatomoURL() string {
|
||||||
|
if v, ok := GetRuntime(keyMatomoURL); ok && strings.TrimSpace(v) != "" {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(os.Getenv("MATOMO_URL"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatomoSiteID returns the Matomo site ID.
|
||||||
|
func MatomoSiteID() string {
|
||||||
|
if v, ok := GetRuntime(keyMatomoSiteID); ok && strings.TrimSpace(v) != "" {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(os.Getenv("MATOMO_SITE_ID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLSSegmentDuration returns the HLS segment duration in seconds.
|
||||||
|
func HLSSegmentDuration() int {
|
||||||
|
raw := ""
|
||||||
|
if v, ok := GetRuntime(keyHLSSegmentDur); ok {
|
||||||
|
raw = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
raw = strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION"))
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return defaultHLSSegmentDuration
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLSWindowSize returns the HLS playlist window size in segments.
|
||||||
|
func HLSWindowSize() int {
|
||||||
|
raw := ""
|
||||||
|
if v, ok := GetRuntime(keyHLSWindowSize); ok {
|
||||||
|
raw = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
raw = strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE"))
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return defaultHLSWindowSize
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMatomoURL validates and stores the Matomo URL.
|
||||||
|
func SetMatomoURL(raw string) error {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
SetRuntime(keyMatomoURL, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return fmt.Errorf("matomo URL must be an absolute http(s) URL")
|
||||||
|
}
|
||||||
|
SetRuntime(keyMatomoURL, raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMatomoSiteID validates and stores the Matomo site ID.
|
||||||
|
func SetMatomoSiteID(raw string) error {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw != "" {
|
||||||
|
if _, err := strconv.Atoi(raw); err != nil {
|
||||||
|
return fmt.Errorf("matomo site ID must be numeric")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SetRuntime(keyMatomoSiteID, raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHLSSegmentDuration validates (2–15s) and stores the HLS segment duration.
|
||||||
|
func SetHLSSegmentDuration(seconds int) error {
|
||||||
|
if seconds < 2 || seconds > 15 {
|
||||||
|
return fmt.Errorf("HLS segment duration must be between 2 and 15 seconds")
|
||||||
|
}
|
||||||
|
SetRuntime(keyHLSSegmentDur, strconv.Itoa(seconds))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHLSWindowSize validates (3–100) and stores the HLS window size.
|
||||||
|
func SetHLSWindowSize(size int) error {
|
||||||
|
if size < 3 || size > 100 {
|
||||||
|
return fmt.Errorf("HLS window size must be between 3 and 100 segments")
|
||||||
|
}
|
||||||
|
SetRuntime(keyHLSWindowSize, strconv.Itoa(size))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeConfigSnapshot returns the resolved runtime configuration values.
|
||||||
|
func RuntimeConfigSnapshot() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"matomoUrl": MatomoURL(),
|
||||||
|
"matomoSiteId": MatomoSiteID(),
|
||||||
|
"hlsSegmentDuration": HLSSegmentDuration(),
|
||||||
|
"hlsWindowSize": HLSWindowSize(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resetRuntime(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
runtimeMu.Lock()
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
runtimeMu.Unlock()
|
||||||
|
os.Unsetenv("CONFIG_PATH")
|
||||||
|
os.Unsetenv("MATOMO_URL")
|
||||||
|
os.Unsetenv("MATOMO_SITE_ID")
|
||||||
|
os.Unsetenv("HLS_SEGMENT_DURATION")
|
||||||
|
os.Unsetenv("HLS_WINDOW_SIZE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigPath(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
if got := ConfigPath(); got != "/app/playlist/config.json" {
|
||||||
|
t.Errorf("ConfigPath() = %q, want default /app/playlist/config.json", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("CONFIG_PATH", "/tmp/custom.json")
|
||||||
|
defer os.Unsetenv("CONFIG_PATH")
|
||||||
|
if got := ConfigPath(); got != "/tmp/custom.json" {
|
||||||
|
t.Errorf("ConfigPath() = %q, want /tmp/custom.json", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSetRuntime(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
if _, ok := GetRuntime("foo"); ok {
|
||||||
|
t.Errorf("expected missing key")
|
||||||
|
}
|
||||||
|
SetRuntime("foo", "bar")
|
||||||
|
v, ok := GetRuntime("foo")
|
||||||
|
if !ok || v != "bar" {
|
||||||
|
t.Errorf("GetRuntime() = %q, %v; want bar, true", v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGettersDefaults(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
if got := MatomoURL(); got != "" {
|
||||||
|
t.Errorf("MatomoURL() = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := MatomoSiteID(); got != "" {
|
||||||
|
t.Errorf("MatomoSiteID() = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := HLSSegmentDuration(); got != 2 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 2", got)
|
||||||
|
}
|
||||||
|
if got := HLSWindowSize(); got != 12 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 12", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGettersEnvFallback(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
os.Setenv("HLS_SEGMENT_DURATION", "4")
|
||||||
|
os.Setenv("HLS_WINDOW_SIZE", "30")
|
||||||
|
os.Setenv("MATOMO_URL", "https://analytics.example.com")
|
||||||
|
os.Setenv("MATOMO_SITE_ID", "7")
|
||||||
|
|
||||||
|
if got := HLSSegmentDuration(); got != 4 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 4", got)
|
||||||
|
}
|
||||||
|
if got := HLSWindowSize(); got != 30 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 30", got)
|
||||||
|
}
|
||||||
|
if got := MatomoURL(); got != "https://analytics.example.com" {
|
||||||
|
t.Errorf("MatomoURL() = %q", got)
|
||||||
|
}
|
||||||
|
if got := MatomoSiteID(); got != "7" {
|
||||||
|
t.Errorf("MatomoSiteID() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRuntimeConfigSeedsFromEnv(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json"))
|
||||||
|
os.Setenv("HLS_SEGMENT_DURATION", "8")
|
||||||
|
|
||||||
|
if err := LoadRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("LoadRuntimeConfig error: %v", err)
|
||||||
|
}
|
||||||
|
if got := HLSSegmentDuration(); got != 8 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 8 (seeded from env)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAndLoadRuntimeConfig(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json"))
|
||||||
|
|
||||||
|
if err := SetHLSSegmentDuration(5); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SetHLSWindowSize(30); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SetMatomoURL("https://stats.example.com"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SetMatomoSiteID("42"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("SaveRuntimeConfig error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the store and reload from disk.
|
||||||
|
runtimeMu.Lock()
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
runtimeMu.Unlock()
|
||||||
|
|
||||||
|
if err := LoadRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("LoadRuntimeConfig error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := HLSSegmentDuration(); got != 5 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 5", got)
|
||||||
|
}
|
||||||
|
if got := HLSWindowSize(); got != 30 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 30", got)
|
||||||
|
}
|
||||||
|
if got := MatomoURL(); got != "https://stats.example.com" {
|
||||||
|
t.Errorf("MatomoURL() = %q", got)
|
||||||
|
}
|
||||||
|
if got := MatomoSiteID(); got != "42" {
|
||||||
|
t.Errorf("MatomoSiteID() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettersValidation(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
|
||||||
|
if err := SetHLSSegmentDuration(1); err == nil {
|
||||||
|
t.Error("expected error for duration 1")
|
||||||
|
}
|
||||||
|
if err := SetHLSSegmentDuration(20); err == nil {
|
||||||
|
t.Error("expected error for duration 20")
|
||||||
|
}
|
||||||
|
if err := SetHLSSegmentDuration(6); err != nil {
|
||||||
|
t.Errorf("unexpected error for duration 6: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetHLSWindowSize(2); err == nil {
|
||||||
|
t.Error("expected error for window 2")
|
||||||
|
}
|
||||||
|
if err := SetHLSWindowSize(101); err == nil {
|
||||||
|
t.Error("expected error for window 101")
|
||||||
|
}
|
||||||
|
if err := SetHLSWindowSize(24); err != nil {
|
||||||
|
t.Errorf("unexpected error for window 24: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetMatomoURL("not-a-url"); err == nil {
|
||||||
|
t.Error("expected error for invalid URL")
|
||||||
|
}
|
||||||
|
if err := SetMatomoURL("ftp://example.com"); err == nil {
|
||||||
|
t.Error("expected error for non-http URL")
|
||||||
|
}
|
||||||
|
if err := SetMatomoURL("https://stats.example.com"); err != nil {
|
||||||
|
t.Errorf("unexpected error for valid URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetMatomoSiteID("abc"); err == nil {
|
||||||
|
t.Error("expected error for non-numeric site ID")
|
||||||
|
}
|
||||||
|
if err := SetMatomoSiteID("99"); err != nil {
|
||||||
|
t.Errorf("unexpected error for numeric site ID: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ func InitDB(databaseURL string) (*DBClient, string, error) {
|
|||||||
return nil, driver, err
|
return nil, driver, err
|
||||||
}
|
}
|
||||||
|
|
||||||
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
|
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{}, &User{})
|
||||||
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
|
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
|
||||||
|
|
||||||
return &DBClient{DB: gormDB}, driver, nil
|
return &DBClient{DB: gormDB}, driver, nil
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ type Media struct {
|
|||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User represents a registered chat user account (ADR-006).
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Username string `gorm:"uniqueIndex;size:32" json:"username"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
// ScheduleOverride represents a scheduled playlist override (ADR-004)
|
// ScheduleOverride represents a scheduled playlist override (ADR-004)
|
||||||
type ScheduleOverride struct {
|
type ScheduleOverride struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"chat/src/auth"
|
"chat/src/auth"
|
||||||
@@ -197,6 +198,7 @@ type Dependencies struct {
|
|||||||
Redis db.RedisClient
|
Redis db.RedisClient
|
||||||
Hub *ws.Hub
|
Hub *ws.Hub
|
||||||
SessionStore *auth.SessionStore
|
SessionStore *auth.SessionStore
|
||||||
|
UserSessions *auth.UserSessionStore
|
||||||
DBDriver string
|
DBDriver string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
||||||
@@ -1446,7 +1829,7 @@ func HandleAdminChatLog(deps *Dependencies) http.HandlerFunc {
|
|||||||
limit = parsedLimit
|
limit = parsedLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
dbQuery := deps.DB.Model(&db.Message{})
|
dbQuery := deps.DB.Model(&db.Message{}).Where("type <> ?", "system")
|
||||||
if room != "" {
|
if room != "" {
|
||||||
dbQuery = dbQuery.Where("room = ?", room)
|
dbQuery = dbQuery.Where("room = ?", room)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -14,11 +15,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"chat/src/auth"
|
"chat/src/auth"
|
||||||
"chat/src/config"
|
"chat/src/config"
|
||||||
"chat/src/db"
|
"chat/src/db"
|
||||||
|
"chat/src/media"
|
||||||
"chat/src/ws"
|
"chat/src/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,7 +103,14 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to open test DB: %v", err)
|
t.Fatalf("failed to open test DB: %v", err)
|
||||||
}
|
}
|
||||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{})
|
// A single connection keeps the in-memory SQLite database shared across
|
||||||
|
// goroutines (each :memory: connection would otherwise be a separate DB).
|
||||||
|
sqlDB, err := gormDB.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get sql.DB: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{}, &db.User{})
|
||||||
mr := newMockRedis()
|
mr := newMockRedis()
|
||||||
sessionStore := auth.NewSessionStore()
|
sessionStore := auth.NewSessionStore()
|
||||||
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
|
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
|
||||||
@@ -110,6 +120,7 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
|
|||||||
Redis: mr,
|
Redis: mr,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
SessionStore: sessionStore,
|
SessionStore: sessionStore,
|
||||||
|
UserSessions: auth.NewUserSessionStore(),
|
||||||
DBDriver: "sqlite",
|
DBDriver: "sqlite",
|
||||||
}
|
}
|
||||||
return deps, gormDB, sessionStore
|
return deps, gormDB, sessionStore
|
||||||
@@ -474,6 +485,7 @@ func TestHandleAdminConfig_Get(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandleAdminConfig_Put(t *testing.T) {
|
func TestHandleAdminConfig_Put(t *testing.T) {
|
||||||
deps, _, _ := setupTestDeps(t)
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
setupConfigTest(t)
|
||||||
|
|
||||||
handler := HandleAdminConfig(deps)
|
handler := HandleAdminConfig(deps)
|
||||||
body := `{"rateLimitMax":20}`
|
body := `{"rateLimitMax":20}`
|
||||||
@@ -488,6 +500,289 @@ func TestHandleAdminConfig_Put(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupConfigTest points the runtime config at a fresh temp file and resets the store.
|
||||||
|
func setupConfigTest(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
os.Setenv("CONFIG_PATH", filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
os.Unsetenv("MATOMO_URL")
|
||||||
|
os.Unsetenv("MATOMO_SITE_ID")
|
||||||
|
os.Unsetenv("HLS_SEGMENT_DURATION")
|
||||||
|
os.Unsetenv("HLS_WINDOW_SIZE")
|
||||||
|
if err := config.LoadRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("LoadRuntimeConfig: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.Unsetenv("CONFIG_PATH") })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAdminConfig_Get_NewFields(t *testing.T) {
|
||||||
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
setupConfigTest(t)
|
||||||
|
|
||||||
|
handler := HandleAdminConfig(deps)
|
||||||
|
req := httptest.NewRequest("GET", "/api/admin/config", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
for _, k := range []string{"matomoUrl", "matomoSiteId", "hlsSegmentDuration", "hlsWindowSize"} {
|
||||||
|
if _, ok := cfg[k]; !ok {
|
||||||
|
t.Errorf("response missing %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAdminConfig_Put_Persists(t *testing.T) {
|
||||||
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
setupConfigTest(t)
|
||||||
|
|
||||||
|
handler := HandleAdminConfig(deps)
|
||||||
|
body := `{"hlsSegmentDuration":5,"hlsWindowSize":30,"matomoUrl":"https://stats.example.com","matomoSiteId":"42"}`
|
||||||
|
req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if cfg["hlsSegmentDuration"] != float64(5) {
|
||||||
|
t.Errorf("hlsSegmentDuration = %v, want 5", cfg["hlsSegmentDuration"])
|
||||||
|
}
|
||||||
|
if cfg["hlsWindowSize"] != float64(30) {
|
||||||
|
t.Errorf("hlsWindowSize = %v, want 30", cfg["hlsWindowSize"])
|
||||||
|
}
|
||||||
|
if cfg["matomoUrl"] != "https://stats.example.com" {
|
||||||
|
t.Errorf("matomoUrl = %v", cfg["matomoUrl"])
|
||||||
|
}
|
||||||
|
if cfg["matomoSiteId"] != "42" {
|
||||||
|
t.Errorf("matomoSiteId = %v", cfg["matomoSiteId"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAdminConfig_Put_Invalid(t *testing.T) {
|
||||||
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
setupConfigTest(t)
|
||||||
|
|
||||||
|
handler := HandleAdminConfig(deps)
|
||||||
|
body := `{"hlsSegmentDuration":99}`
|
||||||
|
req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePublicMatomo(t *testing.T) {
|
||||||
|
setupConfigTest(t)
|
||||||
|
|
||||||
|
handler := HandlePublicMatomo()
|
||||||
|
req := httptest.NewRequest("GET", "/api/matomo", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
for _, k := range []string{"matomoUrl", "matomoSiteId"} {
|
||||||
|
if _, ok := resp[k]; !ok {
|
||||||
|
t.Errorf("response missing %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserRegister_Success(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
handler := HandleUserRegister(deps)
|
||||||
|
body := `{"username":"alice","password":"secret123"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
gormDB.Model(&db.User{}).Where("username = ?", "alice").Count(&count)
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("user count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
if len(w.Result().Cookies()) == 0 {
|
||||||
|
t.Error("expected session cookie to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserRegister_Duplicate(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
gormDB.Create(&db.User{Username: "alice", PasswordHash: "x"})
|
||||||
|
|
||||||
|
handler := HandleUserRegister(deps)
|
||||||
|
body := `{"username":"alice","password":"secret123"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Errorf("status = %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserLogin_Success(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
|
||||||
|
gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
|
||||||
|
|
||||||
|
handler := HandleUserLogin(deps)
|
||||||
|
body := `{"username":"bob","password":"secret123"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserLogin_WrongPassword(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
|
||||||
|
gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
|
||||||
|
|
||||||
|
handler := HandleUserLogin(deps)
|
||||||
|
body := `{"username":"bob","password":"wrong"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserSession(t *testing.T) {
|
||||||
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
handler := HandleUserSession(deps)
|
||||||
|
|
||||||
|
// Unauthenticated
|
||||||
|
req := httptest.NewRequest("GET", "/api/auth/session", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler(w, req)
|
||||||
|
var resp map[string]any
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
if resp["authenticated"] != false {
|
||||||
|
t.Errorf("authenticated = %v, want false", resp["authenticated"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated
|
||||||
|
token, _ := auth.GenerateToken()
|
||||||
|
deps.UserSessions.Add(token, "carol", time.Now().Add(1*time.Hour))
|
||||||
|
req2 := httptest.NewRequest("GET", "/api/auth/session", nil)
|
||||||
|
req2.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
handler(w2, req2)
|
||||||
|
var resp2 map[string]any
|
||||||
|
json.NewDecoder(w2.Body).Decode(&resp2)
|
||||||
|
if resp2["username"] != "carol" {
|
||||||
|
t.Errorf("username = %v, want carol", resp2["username"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserDMHistory(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
token, _ := auth.GenerateToken()
|
||||||
|
deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "hi bob", Type: "private", Timestamp: now.Add(-2 * time.Hour)})
|
||||||
|
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "hi alice", Type: "private", Timestamp: now.Add(-1 * time.Hour)})
|
||||||
|
|
||||||
|
handler := HandleUserDMHistory(deps)
|
||||||
|
req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var msgs []db.Message
|
||||||
|
json.NewDecoder(w.Body).Decode(&msgs)
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(msgs))
|
||||||
|
}
|
||||||
|
if msgs[0].Text != "hi bob" {
|
||||||
|
t.Errorf("first message = %q, want hi bob (chronological)", msgs[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserDMHistory_Unauthorized(t *testing.T) {
|
||||||
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
handler := HandleUserDMHistory(deps)
|
||||||
|
req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserDMSearch(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
token, _ := auth.GenerateToken()
|
||||||
|
deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
|
||||||
|
|
||||||
|
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "meet at 8", Type: "private"})
|
||||||
|
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "see you", Type: "private"})
|
||||||
|
|
||||||
|
handler := HandleUserDMSearch(deps)
|
||||||
|
req := httptest.NewRequest("GET", "/api/dm/search?with=bob&q=meet", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var msgs []db.Message
|
||||||
|
json.NewDecoder(w.Body).Decode(&msgs)
|
||||||
|
if len(msgs) != 1 || msgs[0].Text != "meet at 8" {
|
||||||
|
t.Fatalf("search results = %v, want [meet at 8]", msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleAdminRestart(t *testing.T) {
|
func TestHandleAdminRestart(t *testing.T) {
|
||||||
deps, _, _ := setupTestDeps(t)
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
|
||||||
@@ -656,6 +951,32 @@ func TestHandleAdminChatLog_FilterByRoom(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleAdminChatLog_ExcludesSystem(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
gormDB.Create(&db.Message{Room: "main", Username: "system", Text: "bob joined main", Timestamp: time.Now(), Type: "system"})
|
||||||
|
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
|
||||||
|
|
||||||
|
handler := HandleAdminChatLog(deps)
|
||||||
|
req := httptest.NewRequest("GET", "/api/admin/chat-log", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp ChatLogListResponse
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
if resp.Total != 1 {
|
||||||
|
t.Errorf("Total = %d, want 1 (system messages must be excluded)", resp.Total)
|
||||||
|
}
|
||||||
|
if len(resp.Entries) != 1 || resp.Entries[0].Text != "hello" {
|
||||||
|
t.Errorf("entries = %v, want only the public chat message", resp.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleAdminChatLogByID_Get(t *testing.T) {
|
func TestHandleAdminChatLogByID_Get(t *testing.T) {
|
||||||
deps, gormDB, _ := setupTestDeps(t)
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
@@ -944,4 +1265,159 @@ func TestHandleAdminMediaUploads_InvalidFile(t *testing.T) {
|
|||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusBadRequest {
|
||||||
t.Errorf("status = %d, want 400", w.Code)
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMediaRecord_Success(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.Setenv("MEDIA_DIR", tmpDir)
|
||||||
|
defer os.Unsetenv("MEDIA_DIR")
|
||||||
|
|
||||||
|
srcPath := filepath.Join(tmpDir, "source.mov")
|
||||||
|
if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB.Create(&db.Media{
|
||||||
|
Filename: "source.mov",
|
||||||
|
OriginalName: "source.mov",
|
||||||
|
Path: srcPath,
|
||||||
|
SizeBytes: 10,
|
||||||
|
DurationSeconds: 0,
|
||||||
|
Status: "needs_normalization",
|
||||||
|
Title: "source",
|
||||||
|
})
|
||||||
|
|
||||||
|
origNormalize := media.NormalizeVideo
|
||||||
|
t.Cleanup(func() { media.NormalizeVideo = origNormalize })
|
||||||
|
media.NormalizeVideo = func(src, dst string) (int, int64, error) {
|
||||||
|
if err := os.WriteFile(dst, []byte("normalized"), 0o644); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return 120, 10, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var record db.Media
|
||||||
|
gormDB.First(&record, 1)
|
||||||
|
normalizeMediaRecord(deps, record)
|
||||||
|
|
||||||
|
var updated db.Media
|
||||||
|
gormDB.First(&updated, 1)
|
||||||
|
if updated.Status != "ready" {
|
||||||
|
t.Errorf("Status = %q, want ready", updated.Status)
|
||||||
|
}
|
||||||
|
if updated.DurationSeconds != 120 {
|
||||||
|
t.Errorf("DurationSeconds = %d, want 120", updated.DurationSeconds)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(updated.Path, ".normalized.mp4") {
|
||||||
|
t.Errorf("Path = %q, want .normalized.mp4 suffix", updated.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMediaRecord_Failure(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.Setenv("MEDIA_DIR", tmpDir)
|
||||||
|
defer os.Unsetenv("MEDIA_DIR")
|
||||||
|
|
||||||
|
srcPath := filepath.Join(tmpDir, "source.mov")
|
||||||
|
if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB.Create(&db.Media{
|
||||||
|
Filename: "source.mov",
|
||||||
|
OriginalName: "source.mov",
|
||||||
|
Path: srcPath,
|
||||||
|
SizeBytes: 10,
|
||||||
|
DurationSeconds: 0,
|
||||||
|
Status: "needs_normalization",
|
||||||
|
Title: "source",
|
||||||
|
})
|
||||||
|
|
||||||
|
origNormalize := media.NormalizeVideo
|
||||||
|
t.Cleanup(func() { media.NormalizeVideo = origNormalize })
|
||||||
|
media.NormalizeVideo = func(src, dst string) (int, int64, error) {
|
||||||
|
return 0, 0, fmt.Errorf("ffmpeg failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
var record db.Media
|
||||||
|
gormDB.First(&record, 1)
|
||||||
|
normalizeMediaRecord(deps, record)
|
||||||
|
|
||||||
|
var updated db.Media
|
||||||
|
gormDB.First(&updated, 1)
|
||||||
|
if updated.Status != "normalization_failed" {
|
||||||
|
t.Errorf("Status = %q, want normalization_failed", updated.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAdminMediaByID_Normalize(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.Setenv("MEDIA_DIR", tmpDir)
|
||||||
|
defer os.Unsetenv("MEDIA_DIR")
|
||||||
|
|
||||||
|
srcPath := filepath.Join(tmpDir, "source.mov")
|
||||||
|
if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB.Create(&db.Media{
|
||||||
|
Filename: "source.mov",
|
||||||
|
OriginalName: "source.mov",
|
||||||
|
Path: srcPath,
|
||||||
|
SizeBytes: 10,
|
||||||
|
DurationSeconds: 0,
|
||||||
|
Status: "needs_normalization",
|
||||||
|
Title: "source",
|
||||||
|
})
|
||||||
|
|
||||||
|
origNormalize := media.NormalizeVideo
|
||||||
|
t.Cleanup(func() { media.NormalizeVideo = origNormalize })
|
||||||
|
media.NormalizeVideo = func(src, dst string) (int, int64, error) {
|
||||||
|
if err := os.WriteFile(dst, []byte("normalized"), 0o644); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return 90, 9, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := HandleAdminMediaByID(deps)
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/media/1/normalize", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusAccepted {
|
||||||
|
t.Errorf("status = %d, want 202", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the asynchronous normalization goroutine to finish before
|
||||||
|
// t.Cleanup restores media.NormalizeVideo, avoiding a data race on the
|
||||||
|
// package-level mock variable.
|
||||||
|
var updated db.Media
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
gormDB.First(&updated, 1)
|
||||||
|
if updated.Status != media.StatusNormalizing {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("normalization did not complete within 5s")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated.Status != media.StatusReady {
|
||||||
|
t.Errorf("Status = %q, want ready", updated.Status)
|
||||||
|
}
|
||||||
|
if updated.DurationSeconds != 90 {
|
||||||
|
t.Errorf("DurationSeconds = %d, want 90", updated.DurationSeconds)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(updated.Path, ".normalized.mp4") {
|
||||||
|
t.Errorf("Path = %q, want .normalized.mp4 suffix", updated.Path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,11 @@ func main() {
|
|||||||
rateLimitWindow := config.RateLimitWindow()
|
rateLimitWindow := config.RateLimitWindow()
|
||||||
log.Printf("Rate limit: %d messages per %v", rateLimitMax, rateLimitWindow)
|
log.Printf("Rate limit: %d messages per %v", rateLimitMax, rateLimitWindow)
|
||||||
|
|
||||||
|
// Load persisted runtime configuration (Matomo + HLS settings)
|
||||||
|
if err := config.LoadRuntimeConfig(); err != nil {
|
||||||
|
log.Printf("Warning: failed to load runtime config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize Redis
|
// Initialize Redis
|
||||||
redisWrapper := db.InitRedis(config.RedisAddress())
|
redisWrapper := db.InitRedis(config.RedisAddress())
|
||||||
|
|
||||||
@@ -39,6 +44,7 @@ func main() {
|
|||||||
|
|
||||||
// Initialize session store
|
// Initialize session store
|
||||||
sessionStore := auth.NewSessionStore()
|
sessionStore := auth.NewSessionStore()
|
||||||
|
userSessions := auth.NewUserSessionStore()
|
||||||
|
|
||||||
// Initialize WebSocket hub
|
// Initialize WebSocket hub
|
||||||
hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername())
|
hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername())
|
||||||
@@ -54,6 +60,7 @@ func main() {
|
|||||||
Redis: redisWrapper,
|
Redis: redisWrapper,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
SessionStore: sessionStore,
|
SessionStore: sessionStore,
|
||||||
|
UserSessions: userSessions,
|
||||||
DBDriver: dbDriver,
|
DBDriver: dbDriver,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +69,12 @@ func main() {
|
|||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
http.HandleFunc("/ws", hub.HandleConnection)
|
http.HandleFunc("/ws", hub.HandleConnection)
|
||||||
|
http.HandleFunc("/api/auth/register", metrics.WrapRequestTiming(handlers.HandleUserRegister(deps)))
|
||||||
|
http.HandleFunc("/api/auth/login", metrics.WrapRequestTiming(handlers.HandleUserLogin(deps)))
|
||||||
|
http.HandleFunc("/api/auth/session", metrics.WrapRequestTiming(handlers.HandleUserSession(deps)))
|
||||||
|
http.HandleFunc("/api/auth/logout", metrics.WrapRequestTiming(handlers.HandleUserLogout(deps)))
|
||||||
|
http.HandleFunc("/api/dm/history", metrics.WrapRequestTiming(handlers.HandleUserDMHistory(deps)))
|
||||||
|
http.HandleFunc("/api/dm/search", metrics.WrapRequestTiming(handlers.HandleUserDMSearch(deps)))
|
||||||
http.HandleFunc("/api/admin/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps)))
|
http.HandleFunc("/api/admin/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps)))
|
||||||
http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps)))
|
http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps)))
|
||||||
http.HandleFunc("/api/admin/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(deps)))
|
http.HandleFunc("/api/admin/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(deps)))
|
||||||
@@ -77,6 +90,7 @@ func main() {
|
|||||||
http.HandleFunc("/api/admin/schedule", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(deps))))
|
http.HandleFunc("/api/admin/schedule", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(deps))))
|
||||||
http.HandleFunc("/api/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(deps))))
|
http.HandleFunc("/api/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(deps))))
|
||||||
http.HandleFunc("/api/schedule", metrics.WrapRequestTiming(handlers.HandlePublicSchedule(deps)))
|
http.HandleFunc("/api/schedule", metrics.WrapRequestTiming(handlers.HandlePublicSchedule(deps)))
|
||||||
|
http.HandleFunc("/api/matomo", metrics.WrapRequestTiming(handlers.HandlePublicMatomo()))
|
||||||
http.HandleFunc("/api/admin/media", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMedia(deps))))
|
http.HandleFunc("/api/admin/media", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMedia(deps))))
|
||||||
http.HandleFunc("/api/admin/media/uploads", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
|
http.HandleFunc("/api/admin/media/uploads", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
|
||||||
http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
|
http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
|
||||||
|
|||||||
@@ -155,6 +155,71 @@ func ProbeDurationSeconds(mediaPath string) (int, error) {
|
|||||||
return seconds, nil
|
return seconds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Media status values (ADR-006 media library states).
|
||||||
|
const (
|
||||||
|
StatusReady = "ready"
|
||||||
|
StatusNeedsNormalization = "needs_normalization"
|
||||||
|
StatusNormalizing = "normalizing"
|
||||||
|
StatusNormalizationFailed = "normalization_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runCommand executes an external command and returns its combined output on failure.
|
||||||
|
// It is a package variable so tests can substitute a stub.
|
||||||
|
var runCommand = func(ctx context.Context, name string, args ...string) error {
|
||||||
|
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s failed: %v (%s)", name, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizationArgs builds the ffmpeg arguments for the Linear Void normalization profile.
|
||||||
|
func normalizationArgs(srcPath, dstPath string) []string {
|
||||||
|
return []string{
|
||||||
|
"-y",
|
||||||
|
"-i", srcPath,
|
||||||
|
"-map", "0:v:0", "-map", "0:a:0?",
|
||||||
|
"-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p",
|
||||||
|
"-c:v", "libx264", "-preset", "medium", "-crf", "21",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
||||||
|
"-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-ac", "2",
|
||||||
|
"-avoid_negative_ts", "make_zero", "-max_muxing_queue_size", "1024",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
dstPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeVideo transcodes srcPath to the Linear Void normalization profile,
|
||||||
|
// re-verifies the output with ffprobe, and returns its duration (seconds) and size (bytes).
|
||||||
|
func normalizeVideo(srcPath, dstPath string) (int, int64, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := runCommand(ctx, "ffmpeg", normalizationArgs(srcPath, dstPath)...); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if res := ValidateNormalization(dstPath); !res.Valid {
|
||||||
|
return 0, 0, fmt.Errorf("normalized output failed validation: %s", strings.Join(res.Errors, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
duration, err := ProbeDurationSeconds(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("probe duration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("stat output failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return duration, info.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeVideo is the injectable normalization entry point.
|
||||||
|
var NormalizeVideo = normalizeVideo
|
||||||
|
|
||||||
// DetectMime reads the first 512 bytes of a file to detect its MIME type.
|
// DetectMime reads the first 512 bytes of a file to detect its MIME type.
|
||||||
func DetectMime(filePath string) (string, error) {
|
func DetectMime(filePath string) (string, error) {
|
||||||
f, err := os.Open(filePath)
|
f, err := os.Open(filePath)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package media
|
package media
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -201,4 +204,43 @@ func TestUniqueTargetPath(t *testing.T) {
|
|||||||
if path != expected {
|
if path != expected {
|
||||||
t.Errorf("UniqueTargetPath() = %q, want %q", path, expected)
|
t.Errorf("UniqueTargetPath() = %q, want %q", path, expected)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizationArgs(t *testing.T) {
|
||||||
|
args := normalizationArgs("/in/movie.mov", "/out/movie.normalized.mp4")
|
||||||
|
joined := strings.Join(args, " ")
|
||||||
|
for _, want := range []string{
|
||||||
|
"-map 0:v:0",
|
||||||
|
"-map 0:a:0?",
|
||||||
|
"-c:v libx264",
|
||||||
|
"-r 30",
|
||||||
|
"-g 60",
|
||||||
|
"-keyint_min 60",
|
||||||
|
"-pix_fmt yuv420p",
|
||||||
|
"-c:a aac",
|
||||||
|
"-ar 48000",
|
||||||
|
"-ac 2",
|
||||||
|
"-avoid_negative_ts make_zero",
|
||||||
|
"/out/movie.normalized.mp4",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Errorf("normalization args missing %q: %v", want, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeVideo_FFmpegError(t *testing.T) {
|
||||||
|
origRun := runCommand
|
||||||
|
t.Cleanup(func() { runCommand = origRun })
|
||||||
|
|
||||||
|
runCommand = func(ctx context.Context, name string, args ...string) error {
|
||||||
|
if name != "ffmpeg" {
|
||||||
|
t.Errorf("runCommand name = %q, want ffmpeg", name)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("simulated ffmpeg failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := normalizeVideo("/in/a.mov", "/out/a.normalized.mp4"); err == nil {
|
||||||
|
t.Fatal("expected error when ffmpeg fails")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+78
-14
@@ -41,19 +41,25 @@ type Hub struct {
|
|||||||
RateLimitWindow time.Duration
|
RateLimitWindow time.Duration
|
||||||
AdminUsername string
|
AdminUsername string
|
||||||
DMsSent int64
|
DMsSent int64
|
||||||
|
|
||||||
|
presenceMu sync.Mutex
|
||||||
|
presenceTimers map[string]*time.Timer
|
||||||
|
PresenceGracePeriod time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHub creates a new WebSocket hub.
|
// NewHub creates a new WebSocket hub.
|
||||||
func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax int, rateWindow time.Duration, adminUser string) *Hub {
|
func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax int, rateWindow time.Duration, adminUser string) *Hub {
|
||||||
return &Hub{
|
return &Hub{
|
||||||
clients: make(map[*websocket.Conn]bool),
|
clients: make(map[*websocket.Conn]bool),
|
||||||
clientMeta: make(map[*websocket.Conn]ClientInfo),
|
clientMeta: make(map[*websocket.Conn]ClientInfo),
|
||||||
DB: gormDB,
|
DB: gormDB,
|
||||||
Redis: redis,
|
Redis: redis,
|
||||||
InstanceID: instanceID,
|
InstanceID: instanceID,
|
||||||
RateLimitMax: rateMax,
|
RateLimitMax: rateMax,
|
||||||
RateLimitWindow: rateWindow,
|
RateLimitWindow: rateWindow,
|
||||||
AdminUsername: adminUser,
|
AdminUsername: adminUser,
|
||||||
|
presenceTimers: make(map[string]*time.Timer),
|
||||||
|
PresenceGracePeriod: 5 * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,12 +71,14 @@ func (h *Hub) ConnectedClients() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast sends a message to all connected clients and persists to DB.
|
// Broadcast sends a message to all connected clients and persists to DB.
|
||||||
|
// System messages (join/leave announcements, moderation notices) fan out but
|
||||||
|
// are not persisted, so the chat-log table only holds real chat messages.
|
||||||
func (h *Hub) Broadcast(msg db.Message) {
|
func (h *Hub) Broadcast(msg db.Message) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
// Persist to configured SQL backend.
|
// Persist to configured SQL backend (chat messages only).
|
||||||
if h.DB != nil {
|
if h.DB != nil && msg.Type != "system" {
|
||||||
h.DB.Create(&msg)
|
h.DB.Create(&msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,13 +101,59 @@ func (h *Hub) Broadcast(msg db.Message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scheduleLeaveAnnouncement broadcasts a "left" message after a short grace
|
||||||
|
// period so quick reconnects don't produce join/leave spam.
|
||||||
|
func (h *Hub) scheduleLeaveAnnouncement(room, username string) {
|
||||||
|
key := room + "\x00" + username
|
||||||
|
h.presenceMu.Lock()
|
||||||
|
if h.presenceTimers == nil {
|
||||||
|
h.presenceTimers = make(map[string]*time.Timer)
|
||||||
|
}
|
||||||
|
grace := h.PresenceGracePeriod
|
||||||
|
if grace <= 0 {
|
||||||
|
grace = 5 * time.Second
|
||||||
|
}
|
||||||
|
h.presenceTimers[key] = time.AfterFunc(grace, func() {
|
||||||
|
h.presenceMu.Lock()
|
||||||
|
delete(h.presenceTimers, key)
|
||||||
|
h.presenceMu.Unlock()
|
||||||
|
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()})
|
||||||
|
})
|
||||||
|
h.presenceMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancelLeaveAnnouncement cancels a pending "left" announcement for a
|
||||||
|
// room+user and reports whether one existed (i.e. a reconnect within grace).
|
||||||
|
func (h *Hub) cancelLeaveAnnouncement(room, username string) bool {
|
||||||
|
key := room + "\x00" + username
|
||||||
|
h.presenceMu.Lock()
|
||||||
|
defer h.presenceMu.Unlock()
|
||||||
|
if timer, ok := h.presenceTimers[key]; ok {
|
||||||
|
timer.Stop()
|
||||||
|
delete(h.presenceTimers, key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastTyping notifies other users in the room that a user is typing.
|
||||||
|
func (h *Hub) BroadcastTyping(username, room string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
for conn, info := range h.clientMeta {
|
||||||
|
if info.Room == room && info.Username != username {
|
||||||
|
_ = conn.WriteJSON(db.Message{Room: room, Username: username, Text: "", Type: "typing", Timestamp: time.Now()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PushChatHistory sends the last N messages for a room via WebSocket.
|
// PushChatHistory sends the last N messages for a room via WebSocket.
|
||||||
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
|
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
|
||||||
if h.DB == nil || room == "" {
|
if h.DB == nil || room == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var messages []db.Message
|
var messages []db.Message
|
||||||
h.DB.Where("room = ?", room).Order("timestamp desc").Limit(limit).Find(&messages)
|
h.DB.Where("room = ? AND type <> ?", room, "system").Order("timestamp desc").Limit(limit).Find(&messages)
|
||||||
// Reverse to chronological order
|
// Reverse to chronological order
|
||||||
for i := len(messages)/2 - 1; i >= 0; i-- {
|
for i := len(messages)/2 - 1; i >= 0; i-- {
|
||||||
opp := len(messages) - 1 - i
|
opp := len(messages) - 1 - i
|
||||||
@@ -147,10 +201,14 @@ func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.clientMeta[conn] = ClientInfo{Username: username, Room: room}
|
h.clientMeta[conn] = ClientInfo{Username: username, Room: room}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
// Announce presence (ADR-007)
|
// Announce presence (ADR-007). A reconnect within the grace window cancels
|
||||||
|
// the pending "left" announcement and skips a redundant "joined".
|
||||||
|
rejoined := h.cancelLeaveAnnouncement(room, username)
|
||||||
if h.Redis != nil && room != "" {
|
if h.Redis != nil && room != "" {
|
||||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||||
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()})
|
if !rejoined {
|
||||||
|
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push chat history on join (last 50 messages)
|
// Push chat history on join (last 50 messages)
|
||||||
@@ -185,7 +243,7 @@ func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
if h.Redis != nil && room != "" {
|
if h.Redis != nil && room != "" {
|
||||||
_ = h.Redis.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
|
_ = h.Redis.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
|
||||||
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()})
|
h.scheduleLeaveAnnouncement(room, username)
|
||||||
}
|
}
|
||||||
conn.Close()
|
conn.Close()
|
||||||
}()
|
}()
|
||||||
@@ -224,6 +282,12 @@ func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle typing indicator (ephemeral — not persisted, not rate-limited)
|
||||||
|
if msg.Type == "typing" {
|
||||||
|
h.BroadcastTyping(msg.Username, msg.Room)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Handle private DMs
|
// Handle private DMs
|
||||||
if strings.HasPrefix(msg.Room, "dm:") {
|
if strings.HasPrefix(msg.Room, "dm:") {
|
||||||
h.handlePrivateDM(msg)
|
h.handlePrivateDM(msg)
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ func setupTestHub(t *testing.T) (*Hub, *gorm.DB, *mockRedis) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to open test DB: %v", err)
|
t.Fatalf("failed to open test DB: %v", err)
|
||||||
}
|
}
|
||||||
|
sqlDB, err := gormDB.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get sql.DB: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
||||||
mr := newMockRedis()
|
mr := newMockRedis()
|
||||||
hub := NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
|
hub := NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
|
||||||
@@ -560,4 +565,47 @@ func TestHub_SubscribeRoomMessages(t *testing.T) {
|
|||||||
})
|
})
|
||||||
mr.Publish(ctx, "room:main:messages", string(ownPayload))
|
mr.Publish(ctx, "room:main:messages", string(ownPayload))
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_Broadcast_SystemNotPersisted(t *testing.T) {
|
||||||
|
hub, _, _ := setupTestHub(t)
|
||||||
|
|
||||||
|
hub.Broadcast(db.Message{Room: "main", Username: "system", Text: "bob joined main", Type: "system", Timestamp: time.Now()})
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
hub.DB.Model(&db.Message{}).Where("type = ?", "system").Count(&count)
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("system message count = %d, want 0 (system messages must not persist)", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.Broadcast(db.Message{Room: "main", Username: "bob", Text: "hi", Type: "public", Timestamp: time.Now()})
|
||||||
|
hub.DB.Model(&db.Message{}).Where("type = ?", "public").Count(&count)
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("public message count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_ReconnectGracePeriod(t *testing.T) {
|
||||||
|
hub, _, _ := setupTestHub(t)
|
||||||
|
hub.PresenceGracePeriod = 50 * time.Millisecond
|
||||||
|
|
||||||
|
// Disconnect schedules a delayed "left"; reconnect cancels it.
|
||||||
|
hub.scheduleLeaveAnnouncement("main", "bob")
|
||||||
|
if !hub.cancelLeaveAnnouncement("main", "bob") {
|
||||||
|
t.Error("expected a pending leave announcement to be cancelled on reconnect")
|
||||||
|
}
|
||||||
|
if hub.cancelLeaveAnnouncement("main", "bob") {
|
||||||
|
t.Error("expected no pending leave announcement after cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh disconnect that is never reconnected fires after the grace period
|
||||||
|
// and cleans up its timer.
|
||||||
|
hub.scheduleLeaveAnnouncement("main", "alice")
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
hub.presenceMu.Lock()
|
||||||
|
_, exists := hub.presenceTimers["main\x00alice"]
|
||||||
|
hub.presenceMu.Unlock()
|
||||||
|
if exists {
|
||||||
|
t.Error("expected the leave announcement timer to clean itself up after firing")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+491
-64
@@ -3,12 +3,15 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,6 +19,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/config"
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,23 +36,45 @@ var (
|
|||||||
putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
return client.PutObject(context.TODO(), input)
|
return client.PutObject(context.TODO(), input)
|
||||||
}
|
}
|
||||||
bucket = os.Getenv("R2_BUCKET_NAME")
|
listObjects = func(client *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||||
playoutMode = "starting" // "live", "slate", "degraded"
|
return client.ListObjectsV2(context.TODO(), input)
|
||||||
restartCount = 0
|
}
|
||||||
restartWindow = time.Now()
|
deleteObjects = func(client *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||||
slateDir = "/app/slate"
|
return client.DeleteObjects(context.TODO(), input)
|
||||||
hlsDir = "/app/hls"
|
}
|
||||||
|
bucket = os.Getenv("R2_BUCKET_NAME")
|
||||||
|
playoutMode = "starting" // "live", "slate", "degraded"
|
||||||
|
restartCount = 0
|
||||||
|
restartWindow = time.Now()
|
||||||
|
slateDir = "/app/slate"
|
||||||
|
hlsDir = "/app/hls"
|
||||||
|
hlsSegmentDuration = envWithDefault("HLS_SEGMENT_DURATION", "2")
|
||||||
|
hlsWindowSize = envWithDefault("HLS_WINDOW_SIZE", "12")
|
||||||
|
|
||||||
// Unique segment naming (STREAMING.md contract: r-{runID}/seg-{seq}.m4s)
|
// Unique segment naming (STREAMING.md contract: r-{runID}/seg-{seq}.m4s)
|
||||||
runID string
|
runID string
|
||||||
segSequence uint64
|
segSequence uint64
|
||||||
|
|
||||||
// Health endpoint detail (STREAMING.md contract)
|
// Health endpoint detail (STREAMING.md contract)
|
||||||
activeItem string
|
activeItem string
|
||||||
playlistRevision string
|
playlistRevision string
|
||||||
lastSegmentSeq uint64
|
lastSegmentSeq uint64
|
||||||
lastR2PubTime time.Time
|
lastR2PubTime time.Time
|
||||||
consecutiveFails int
|
consecutiveFails int
|
||||||
|
lastFFmpegStartTime time.Time
|
||||||
|
lastFFmpegExitError error
|
||||||
|
|
||||||
|
// R2 retention cleanup counter
|
||||||
|
r2CleanupObjectsTotal int64
|
||||||
|
|
||||||
|
// R2 retention gauges
|
||||||
|
r2RunsRetained int64
|
||||||
|
r2BytesRetained int64
|
||||||
|
|
||||||
|
// Continuous playout loop metrics
|
||||||
|
loopsTotal int64
|
||||||
|
lastRunDuration time.Duration
|
||||||
|
lastRunClean bool
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -57,8 +83,20 @@ const (
|
|||||||
// Cache-Control header values for R2 uploads.
|
// Cache-Control header values for R2 uploads.
|
||||||
segmentCacheControl = "public, max-age=31536000, immutable"
|
segmentCacheControl = "public, max-age=31536000, immutable"
|
||||||
manifestCacheControl = "public, max-age=1, s-maxage=2, must-revalidate"
|
manifestCacheControl = "public, max-age=1, s-maxage=2, must-revalidate"
|
||||||
|
|
||||||
|
// R2 retention policy.
|
||||||
|
defaultR2KeepRuns = 2
|
||||||
|
defaultR2MaxRetentionBytes = int64(10 * 1024 * 1024 * 1024) // 10 GiB
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// envWithDefault returns the value of the environment variable or the default if not set.
|
||||||
|
func envWithDefault(key, defaultVal string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
// isR2Configured returns true when the required R2 environment variables are set.
|
// isR2Configured returns true when the required R2 environment variables are set.
|
||||||
func isR2Configured() bool {
|
func isR2Configured() bool {
|
||||||
return os.Getenv("R2_ENDPOINT") != "" &&
|
return os.Getenv("R2_ENDPOINT") != "" &&
|
||||||
@@ -124,7 +162,7 @@ func watchAndPublishSegments(ctx context.Context, hlsOutputDir string) {
|
|||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
segSequence++
|
segSequence++
|
||||||
lastSegmentSeq = segSequence
|
lastSegmentSeq = segSequence
|
||||||
key := "live/" + runID + "/" + filepath.Base(event.Name)
|
key := "hls/" + runID + "/" + filepath.Base(event.Name)
|
||||||
if err := SyncToR2(event.Name, key); err != nil {
|
if err := SyncToR2(event.Name, key); err != nil {
|
||||||
consecutiveFails++
|
consecutiveFails++
|
||||||
fmt.Printf("Failed to sync segment %s: %v\n", event.Name, err)
|
fmt.Printf("Failed to sync segment %s: %v\n", event.Name, err)
|
||||||
@@ -153,15 +191,15 @@ func publishManifest(localManifestPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Upload to temp key
|
// 1. Upload to temp key
|
||||||
if err := SyncToR2(localManifestPath, "live/playlist.m3u8.tmp"); err != nil {
|
if err := SyncToR2(localManifestPath, "hls/playlist.m3u8.tmp"); err != nil {
|
||||||
return fmt.Errorf("temp manifest upload failed: %w", err)
|
return fmt.Errorf("temp manifest upload failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Atomic copy to final key
|
// 2. Atomic copy to final key
|
||||||
_, err := r2Client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
_, err := r2Client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
||||||
Bucket: aws.String(bucket),
|
Bucket: aws.String(bucket),
|
||||||
CopySource: aws.String(bucket + "/live/playlist.m3u8.tmp"),
|
CopySource: aws.String(bucket + "/hls/playlist.m3u8.tmp"),
|
||||||
Key: aws.String("live/playlist.m3u8"),
|
Key: aws.String("hls/playlist.m3u8"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("manifest atomic swap failed: %w", err)
|
return fmt.Errorf("manifest atomic swap failed: %w", err)
|
||||||
@@ -451,18 +489,23 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
status = "unhealthy"
|
status = "unhealthy"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastExit := ""
|
||||||
|
if lastFFmpegExitError != nil {
|
||||||
|
lastExit = lastFFmpegExitError.Error()
|
||||||
|
}
|
||||||
resp := map[string]any{
|
resp := map[string]any{
|
||||||
"status": status,
|
"status": status,
|
||||||
"service": "playout",
|
"service": "playout",
|
||||||
"mode": playoutMode,
|
"mode": playoutMode,
|
||||||
"restarts": restartCount,
|
"crash_count": restartCount,
|
||||||
"run_id": runID,
|
"run_id": runID,
|
||||||
"active_item": activeItem,
|
"active_item": activeItem,
|
||||||
"playlist_revision": playlistRevision,
|
"playlist_revision": playlistRevision,
|
||||||
"last_segment_seq": lastSegmentSeq,
|
"last_segment_seq": lastSegmentSeq,
|
||||||
"last_r2_pub": lastR2PubTime.UTC().Format(time.RFC3339),
|
"last_r2_pub": lastR2PubTime.UTC().Format(time.RFC3339),
|
||||||
"consecutive_failures": consecutiveFails,
|
"consecutive_failures": consecutiveFails,
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
"last_exit_error": lastExit,
|
||||||
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
@@ -561,10 +604,26 @@ func resetHLSOutput(hlsDir string) error {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
playlistPath := "/app/playlist/playlist.txt"
|
playlistPath := "/app/playlist/playlist.txt"
|
||||||
if len(os.Args) > 1 {
|
validateOnly := false
|
||||||
playlistPath = os.Args[1]
|
for _, arg := range os.Args[1:] {
|
||||||
|
switch arg {
|
||||||
|
case "-validate":
|
||||||
|
validateOnly = true
|
||||||
|
default:
|
||||||
|
if !strings.HasPrefix(arg, "-") {
|
||||||
|
playlistPath = arg
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if validateOnly {
|
||||||
|
runValidateOnly(playlistPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load shared HLS runtime config (written by the chat admin) before starting.
|
||||||
|
loadSharedHLSConfig()
|
||||||
|
|
||||||
fmt.Println("=== Linear Void Playout Service Starting ===")
|
fmt.Println("=== Linear Void Playout Service Starting ===")
|
||||||
fmt.Println("Playlist:", playlistPath)
|
fmt.Println("Playlist:", playlistPath)
|
||||||
fmt.Println("R2 Bucket:", bucket)
|
fmt.Println("R2 Bucket:", bucket)
|
||||||
@@ -584,13 +643,18 @@ func main() {
|
|||||||
fmt.Println("R2 sync disabled:", err)
|
fmt.Println("R2 sync disabled:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start R2 segment retention cleanup (hourly + once at startup)
|
||||||
|
if r2Client != nil {
|
||||||
|
go runR2CleanupLoop()
|
||||||
|
}
|
||||||
|
|
||||||
// Start HTTP health check server
|
// Start HTTP health check server
|
||||||
http.HandleFunc("/health", HealthHandler)
|
http.HandleFunc("/health", HealthHandler)
|
||||||
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
||||||
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
|
||||||
@@ -600,6 +664,21 @@ func main() {
|
|||||||
modeVal = 2
|
modeVal = 2
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "linearvoid_playout_mode %d\n", modeVal)
|
fmt.Fprintf(w, "linearvoid_playout_mode %d\n", modeVal)
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_playout_r2_cleanup_objects_total Total R2 objects deleted by retention cleanup.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_cleanup_objects_total counter")
|
||||||
|
fmt.Fprintf(w, "linearvoid_playout_r2_cleanup_objects_total %d\n", r2CleanupObjectsTotal)
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_playout_r2_runs_retained Number of R2 run folders retained by cleanup.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_runs_retained gauge")
|
||||||
|
fmt.Fprintf(w, "linearvoid_playout_r2_runs_retained %d\n", r2RunsRetained)
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_playout_r2_bytes_retained Total bytes retained in R2 run folders.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_bytes_retained gauge")
|
||||||
|
fmt.Fprintf(w, "linearvoid_playout_r2_bytes_retained %d\n", r2BytesRetained)
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_playout_loops_total Total clean playlist completions (loops).")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_playout_loops_total counter")
|
||||||
|
fmt.Fprintf(w, "linearvoid_playout_loops_total %d\n", loopsTotal)
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_playout_run_duration_seconds Duration of the last FFmpeg run in seconds.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_playout_run_duration_seconds gauge")
|
||||||
|
fmt.Fprintf(w, "linearvoid_playout_run_duration_seconds %f\n", lastRunDuration.Seconds())
|
||||||
})
|
})
|
||||||
go func() {
|
go func() {
|
||||||
fmt.Println("Health check server listening on :8081/health")
|
fmt.Println("Health check server listening on :8081/health")
|
||||||
@@ -618,10 +697,43 @@ func main() {
|
|||||||
// New run ID on each restart for unique R2 segment keys (STREAMING.md)
|
// New run ID on each restart for unique R2 segment keys (STREAMING.md)
|
||||||
initRunID()
|
initRunID()
|
||||||
|
|
||||||
if err := resetHLSOutput(hlsDir); err != nil {
|
// Remove run folders older than the retention policy (keep the last N runs,
|
||||||
fmt.Println("Failed to reset HLS output:", err)
|
// cap total bytes) now that the next run has started. This never touches the
|
||||||
time.Sleep(5 * time.Second)
|
// current or previous run folders.
|
||||||
continue
|
if r2Client != nil {
|
||||||
|
if _, err := cleanupOldRuns(context.Background()); err != nil {
|
||||||
|
fmt.Println("R2 cleanup error:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset HLS output only when the playlist changed, we are recovering from a
|
||||||
|
// crash/slate, or this is the first run. Otherwise continue the segment
|
||||||
|
// sequence so the loop is seamless for viewers (no gap on clean loops).
|
||||||
|
fingerprint, fpErr := playlistFingerprint(playlistPath)
|
||||||
|
if fpErr != nil {
|
||||||
|
fmt.Println("Warning: cannot fingerprint playlist:", fpErr)
|
||||||
|
fingerprint = ""
|
||||||
|
}
|
||||||
|
playlistChanged := fingerprint != playlistRevision
|
||||||
|
shouldReset := playlistChanged || playoutMode != "live" || !lastRunClean
|
||||||
|
|
||||||
|
windowSize := 24
|
||||||
|
if ws, err := strconv.Atoi(hlsWindowSize); err == nil && ws > 0 {
|
||||||
|
windowSize = ws
|
||||||
|
}
|
||||||
|
|
||||||
|
startNumber := 0
|
||||||
|
if shouldReset {
|
||||||
|
if err := resetHLSOutput(hlsDir); err != nil {
|
||||||
|
fmt.Println("Failed to reset HLS output:", err)
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
playlistRevision = fingerprint
|
||||||
|
} else {
|
||||||
|
if n, err := hlsNextStartNumber(hlsDir, windowSize); err == nil {
|
||||||
|
startNumber = n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start filesystem watcher for real-time R2 segment upload (STREAMING.md).
|
// Start filesystem watcher for real-time R2 segment upload (STREAMING.md).
|
||||||
@@ -633,7 +745,9 @@ func main() {
|
|||||||
go watchAndPublishSegments(watchCtx, hlsDir)
|
go watchAndPublishSegments(watchCtx, hlsDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[%s] Starting FFmpeg playout with browser-safe H.264/AAC HLS encoding...\n", time.Now().Format(time.RFC3339))
|
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?",
|
||||||
@@ -643,43 +757,27 @@ func main() {
|
|||||||
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
||||||
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k",
|
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k",
|
||||||
"-f", "hls",
|
"-f", "hls",
|
||||||
"-hls_time", "6",
|
"-hls_time", hlsSegmentDuration,
|
||||||
"-hls_list_size", "24",
|
"-hls_list_size", hlsWindowSize,
|
||||||
"-hls_segment_type", "fmp4",
|
"-hls_segment_type", "fmp4",
|
||||||
"-hls_fmp4_init_filename", "init.mp4",
|
"-hls_fmp4_init_filename", "init.mp4",
|
||||||
"-hls_flags", "delete_segments+independent_segments",
|
"-hls_flags", "delete_segments+independent_segments",
|
||||||
"-hls_segment_filename", filepath.Join(hlsDir, "seg-%06d.m4s"),
|
"-hls_segment_filename", filepath.Join(hlsDir, "seg-%06d.m4s"),
|
||||||
|
"-start_number", strconv.Itoa(startNumber),
|
||||||
filepath.Join(hlsDir, "playlist.m3u8"))
|
filepath.Join(hlsDir, "playlist.m3u8"))
|
||||||
|
|
||||||
runErr := cmd.Run()
|
runErr := cmd.Run()
|
||||||
|
runDuration := time.Since(lastFFmpegStartTime)
|
||||||
|
lastFFmpegExitError = runErr
|
||||||
|
lastRunDuration = runDuration
|
||||||
|
lastRunClean = runErr == nil
|
||||||
|
|
||||||
if watchCancel != nil {
|
if watchCancel != nil {
|
||||||
watchCancel() // Stop the filesystem watcher
|
watchCancel() // Stop the filesystem watcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track restart and check for slate escalation
|
// After FFmpeg exits, sync remaining segments and publish manifest to R2.
|
||||||
restartCount++
|
// Do this before logging/restart logic so R2 is up to date regardless of exit cause.
|
||||||
fmt.Printf("[%s] FFmpeg exited (restart #%d in window): %v\n",
|
|
||||||
time.Now().Format(time.RFC3339), restartCount, runErr)
|
|
||||||
|
|
||||||
if shouldEscalateToSlate() {
|
|
||||||
fmt.Printf("[%s] CRITICAL: %d restarts in 60s — escalating to slate fallback\n",
|
|
||||||
time.Now().Format(time.RFC3339), restartCount)
|
|
||||||
if err := activateSlate(); err != nil {
|
|
||||||
fmt.Printf("[%s] Slate activation failed: %v — will retry\n",
|
|
||||||
time.Now().Format(time.RFC3339), err)
|
|
||||||
}
|
|
||||||
// Stay in slate mode, retry recovery after delay
|
|
||||||
time.Sleep(30 * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we were in slate but FFmpeg ran successfully, we recovered
|
|
||||||
if playoutMode == "slate" {
|
|
||||||
deactivateSlate()
|
|
||||||
}
|
|
||||||
|
|
||||||
// After FFmpeg run, publish remaining segments and atomic manifest to R2.
|
|
||||||
// Skipped when R2 is not configured.
|
|
||||||
if r2Client != nil {
|
if r2Client != nil {
|
||||||
if err := syncLatestToR2(hlsDir); err != nil {
|
if err := syncLatestToR2(hlsDir); err != nil {
|
||||||
fmt.Println("R2 sync warning:", err)
|
fmt.Println("R2 sync warning:", err)
|
||||||
@@ -692,7 +790,45 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(5 * time.Second)
|
// Differentiate clean playlist completion from FFmpeg crash.
|
||||||
|
// A nil error means FFmpeg completed the playlist normally — not a crash.
|
||||||
|
if runErr == nil {
|
||||||
|
loopsTotal++
|
||||||
|
fmt.Printf("[%s] Playlist completed normally after %s. Looping...\n",
|
||||||
|
time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second))
|
||||||
|
// If we were in slate mode, a successful run means recovery
|
||||||
|
if playoutMode == "slate" {
|
||||||
|
deactivateSlate()
|
||||||
|
}
|
||||||
|
// Minimal delay for clean loop — just enough for watcher goroutine cleanup
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// FFmpeg exited with an error — this is a real crash
|
||||||
|
restartCount++
|
||||||
|
fmt.Printf("[%s] FFmpeg CRASHED after %s (crash #%d in window): %v\n",
|
||||||
|
time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second), restartCount, runErr)
|
||||||
|
|
||||||
|
if shouldEscalateToSlate() {
|
||||||
|
fmt.Printf("[%s] CRITICAL: %d crashes in 60s — escalating to slate fallback\n",
|
||||||
|
time.Now().UTC().Format(time.RFC3339), restartCount)
|
||||||
|
if err := activateSlate(); err != nil {
|
||||||
|
fmt.Printf("[%s] Slate activation failed: %v — will retry\n",
|
||||||
|
time.Now().UTC().Format(time.RFC3339), err)
|
||||||
|
}
|
||||||
|
// Stay in slate mode, retry recovery after delay
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we were in slate but FFmpeg ran long enough to be a recovery, deactivate slate
|
||||||
|
if playoutMode == "slate" && runDuration > 30*time.Second {
|
||||||
|
deactivateSlate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backoff delay after crash before retry
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,7 +840,7 @@ func syncLatestToR2(hlsDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
key := "live/" + runID + "/" + 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 {
|
||||||
@@ -714,10 +850,301 @@ func syncLatestToR2(hlsDir string) error {
|
|||||||
|
|
||||||
// Sync init segment to run-specific R2 key
|
// Sync init segment to run-specific R2 key
|
||||||
initPath := filepath.Join(hlsDir, "init.mp4")
|
initPath := filepath.Join(hlsDir, "init.mp4")
|
||||||
if err := SyncToR2(initPath, "live/"+runID+"/init.mp4"); err != nil {
|
if err := SyncToR2(initPath, "hls/"+runID+"/init.mp4"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println("Synced init.mp4 to R2")
|
fmt.Println("Synced init.mp4 to R2")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// r2KeepRuns returns how many of the most recent run folders to keep in R2.
|
||||||
|
func r2KeepRuns() int {
|
||||||
|
n, err := strconv.Atoi(envWithDefault("R2_KEEP_RUNS", "2"))
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
return defaultR2KeepRuns
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// r2MaxRetentionBytes returns the maximum total bytes to retain across run folders.
|
||||||
|
func r2MaxRetentionBytes() int64 {
|
||||||
|
n, err := strconv.ParseInt(envWithDefault("R2_MAX_RETENTION_BYTES", "10737418240"), 10, 64)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return defaultR2MaxRetentionBytes
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// r2RunFromKey extracts the run folder name ("r-...") from an R2 object key.
|
||||||
|
func r2RunFromKey(key string) (string, bool) {
|
||||||
|
rest := strings.TrimPrefix(key, "hls/")
|
||||||
|
if rest == key || !strings.HasPrefix(rest, "r-") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if idx := strings.IndexByte(rest, '/'); idx > 0 {
|
||||||
|
return rest[:idx], true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// runObjects groups the R2 object keys and total size for a single run folder.
|
||||||
|
type runObjects struct {
|
||||||
|
keys []string
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteR2Keys deletes the given R2 object keys in batches of up to 1000.
|
||||||
|
func deleteR2Keys(keys []string) (int, error) {
|
||||||
|
deleted := 0
|
||||||
|
for i := 0; i < len(keys); i += 1000 {
|
||||||
|
end := i + 1000
|
||||||
|
if end > len(keys) {
|
||||||
|
end = len(keys)
|
||||||
|
}
|
||||||
|
batch := keys[i:end]
|
||||||
|
objects := make([]types.ObjectIdentifier, len(batch))
|
||||||
|
for j, key := range batch {
|
||||||
|
objects[j] = types.ObjectIdentifier{Key: aws.String(key)}
|
||||||
|
}
|
||||||
|
if _, err := deleteObjects(r2Client, &s3.DeleteObjectsInput{
|
||||||
|
Bucket: aws.String(bucket),
|
||||||
|
Delete: &types.Delete{Objects: objects, Quiet: aws.Bool(true)},
|
||||||
|
}); err != nil {
|
||||||
|
return deleted, err
|
||||||
|
}
|
||||||
|
for _, key := range batch {
|
||||||
|
fmt.Printf("R2 cleanup: deleted %s\n", key)
|
||||||
|
}
|
||||||
|
deleted += len(batch)
|
||||||
|
}
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupOldRuns lists run-scoped objects under hls/, keeps the most recent
|
||||||
|
// R2_KEEP_RUNS runs, and deletes older runs. It also enforces the
|
||||||
|
// R2_MAX_RETENTION_BYTES cap by removing oldest retained runs first, always
|
||||||
|
// keeping at least the newest run. The live manifest keys are never deleted.
|
||||||
|
func cleanupOldRuns(ctx context.Context) (int, error) {
|
||||||
|
if r2Client == nil {
|
||||||
|
if err := initR2Client(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runs := map[string]*runObjects{}
|
||||||
|
var token *string
|
||||||
|
for {
|
||||||
|
output, err := listObjects(r2Client, &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(bucket),
|
||||||
|
Prefix: aws.String("hls/"),
|
||||||
|
ContinuationToken: token,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, obj := range output.Contents {
|
||||||
|
key := aws.ToString(obj.Key)
|
||||||
|
if key == "hls/playlist.m3u8" || key == "hls/playlist.m3u8.tmp" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
run, ok := r2RunFromKey(key)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, exists := runs[run]
|
||||||
|
if !exists {
|
||||||
|
r = &runObjects{}
|
||||||
|
runs[run] = r
|
||||||
|
}
|
||||||
|
r.keys = append(r.keys, key)
|
||||||
|
if obj.Size != nil {
|
||||||
|
r.size += *obj.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if output.IsTruncated == nil || !*output.IsTruncated {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
token = output.NextContinuationToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(runs) == 0 {
|
||||||
|
r2RunsRetained = 0
|
||||||
|
r2BytesRetained = 0
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run IDs are ISO timestamps, so lexicographic order equals chronological order.
|
||||||
|
sortedRuns := make([]string, 0, len(runs))
|
||||||
|
for run := range runs {
|
||||||
|
sortedRuns = append(sortedRuns, run)
|
||||||
|
}
|
||||||
|
sort.Strings(sortedRuns) // ascending: oldest -> newest
|
||||||
|
|
||||||
|
deleteRun := map[string]bool{}
|
||||||
|
keepRuns := r2KeepRuns()
|
||||||
|
|
||||||
|
// Mark all but the newest keepRuns runs for deletion.
|
||||||
|
if len(sortedRuns) > keepRuns {
|
||||||
|
for _, run := range sortedRuns[:len(sortedRuns)-keepRuns] {
|
||||||
|
deleteRun[run] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce the total size cap on retained runs (oldest retained first), always
|
||||||
|
// keeping the newest run even if it alone exceeds the cap.
|
||||||
|
maxBytes := r2MaxRetentionBytes()
|
||||||
|
retained := make([]string, 0, keepRuns)
|
||||||
|
var retainedBytes int64
|
||||||
|
for _, run := range sortedRuns {
|
||||||
|
if !deleteRun[run] {
|
||||||
|
retained = append(retained, run)
|
||||||
|
retainedBytes += runs[run].size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; retainedBytes > maxBytes && i < len(retained)-1; i++ {
|
||||||
|
run := retained[i]
|
||||||
|
deleteRun[run] = true
|
||||||
|
retainedBytes -= runs[run].size
|
||||||
|
}
|
||||||
|
|
||||||
|
var toDelete []string
|
||||||
|
for run, del := range deleteRun {
|
||||||
|
if del {
|
||||||
|
toDelete = append(toDelete, runs[run].keys...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := deleteR2Keys(toDelete)
|
||||||
|
if err != nil {
|
||||||
|
return deleted, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keptRuns := int64(0)
|
||||||
|
keptBytes := int64(0)
|
||||||
|
for run, r := range runs {
|
||||||
|
if !deleteRun[run] {
|
||||||
|
keptRuns++
|
||||||
|
keptBytes += r.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r2RunsRetained = keptRuns
|
||||||
|
r2BytesRetained = keptBytes
|
||||||
|
|
||||||
|
r2CleanupObjectsTotal += int64(deleted)
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runR2CleanupLoop runs retention cleanup once at startup, then hourly.
|
||||||
|
func runR2CleanupLoop() {
|
||||||
|
if _, err := cleanupOldRuns(context.Background()); err != nil {
|
||||||
|
fmt.Println("R2 cleanup error:", err)
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
if _, err := cleanupOldRuns(context.Background()); err != nil {
|
||||||
|
fmt.Println("R2 cleanup error:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// playlistFingerprint returns a short hash of the playlist file content, used to
|
||||||
|
// detect playlist changes between loops.
|
||||||
|
func playlistFingerprint(playlistPath string) (string, error) {
|
||||||
|
data, err := os.ReadFile(playlistPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hlsNextStartNumber returns the next segment sequence number to use, and prunes
|
||||||
|
// stale segments outside the live window so they do not accumulate across loops.
|
||||||
|
func hlsNextStartNumber(hlsDir string, windowSize int) (int, error) {
|
||||||
|
files, err := filepath.Glob(filepath.Join(hlsDir, "seg-*.m4s"))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
seqOf := func(f string) int {
|
||||||
|
base := filepath.Base(f)
|
||||||
|
num := strings.TrimSuffix(strings.TrimPrefix(base, "seg-"), ".m4s")
|
||||||
|
n, err := strconv.Atoi(num)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSeq := -1
|
||||||
|
for _, f := range files {
|
||||||
|
if n := seqOf(f); n > maxSeq {
|
||||||
|
maxSeq = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next := maxSeq + 1
|
||||||
|
|
||||||
|
// Keep the live window; delete older segments no longer referenced.
|
||||||
|
threshold := next - windowSize
|
||||||
|
for _, f := range files {
|
||||||
|
if n := seqOf(f); n >= 0 && n < threshold {
|
||||||
|
if err := os.Remove(f); err != nil && !os.IsNotExist(err) {
|
||||||
|
return next, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runValidateOnly validates the playlist and exits 0 (valid) or 1 (invalid/missing).
|
||||||
|
func runValidateOnly(playlistPath string) {
|
||||||
|
videos, err := ValidatePlaylist(playlistPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("ERROR: Playlist validation failed:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
valid := true
|
||||||
|
for _, v := range videos {
|
||||||
|
if v.Valid {
|
||||||
|
fmt.Printf("OK: %s (%.1fs)\n", v.Path, v.Duration)
|
||||||
|
} else {
|
||||||
|
valid = false
|
||||||
|
fmt.Printf("INVALID: %s — %s\n", v.Path, v.Validation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Playlist valid.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSharedHLSConfig reads HLS settings from the shared runtime config file
|
||||||
|
// (written by the chat admin service) and overrides the env-derived defaults.
|
||||||
|
func loadSharedHLSConfig() {
|
||||||
|
path := envWithDefault("PLAYOUT_CONFIG_PATH", "/app/playlist/config.json")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
fmt.Println("Warning: cannot read shared config:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cfg map[string]string
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
fmt.Println("Warning: cannot parse shared config:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(cfg["hls_segment_duration"]); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
hlsSegmentDuration = strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(cfg["hls_window_size"]); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
hlsWindowSize = strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("Shared HLS config: segment=%ss window=%s\n", hlsSegmentDuration, hlsWindowSize)
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,8 @@ package main
|
|||||||
// See backend/playout/testdata/*.txt.
|
// See backend/playout/testdata/*.txt.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -39,6 +41,7 @@ import (
|
|||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ensureFFProbeInPath(t *testing.T) {
|
func ensureFFProbeInPath(t *testing.T) {
|
||||||
@@ -265,7 +268,7 @@ func TestHealthHandler(t *testing.T) {
|
|||||||
`"status":"healthy"`,
|
`"status":"healthy"`,
|
||||||
`"service":"playout"`,
|
`"service":"playout"`,
|
||||||
`"mode":"live"`,
|
`"mode":"live"`,
|
||||||
`"restarts":3`,
|
`"crash_count":3`,
|
||||||
`"run_id":"r-20260810T120000Z"`,
|
`"run_id":"r-20260810T120000Z"`,
|
||||||
`"last_segment_seq"`,
|
`"last_segment_seq"`,
|
||||||
`"consecutive_failures"`,
|
`"consecutive_failures"`,
|
||||||
@@ -495,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")
|
||||||
@@ -588,4 +591,309 @@ func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
|
|||||||
if gotContentType != "" {
|
if gotContentType != "" {
|
||||||
t.Fatalf("ContentType = %q, want empty", gotContentType)
|
t.Fatalf("ContentType = %q, want empty", gotContentType)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2KeepRuns(t *testing.T) {
|
||||||
|
os.Setenv("R2_KEEP_RUNS", "")
|
||||||
|
if got := r2KeepRuns(); got != 2 {
|
||||||
|
t.Errorf("default = %d, want 2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_KEEP_RUNS", "5")
|
||||||
|
if got := r2KeepRuns(); got != 5 {
|
||||||
|
t.Errorf("override = %d, want 5", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_KEEP_RUNS", "0")
|
||||||
|
if got := r2KeepRuns(); got != 2 {
|
||||||
|
t.Errorf("invalid = %d, want 2 fallback", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Unsetenv("R2_KEEP_RUNS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2MaxRetentionBytes(t *testing.T) {
|
||||||
|
os.Setenv("R2_MAX_RETENTION_BYTES", "")
|
||||||
|
if got := r2MaxRetentionBytes(); got != 10*1024*1024*1024 {
|
||||||
|
t.Errorf("default = %d, want 10GiB", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_MAX_RETENTION_BYTES", "100")
|
||||||
|
if got := r2MaxRetentionBytes(); got != 100 {
|
||||||
|
t.Errorf("override = %d, want 100", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_MAX_RETENTION_BYTES", "invalid")
|
||||||
|
if got := r2MaxRetentionBytes(); got != 10*1024*1024*1024 {
|
||||||
|
t.Errorf("invalid = %d, want 10GiB fallback", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Unsetenv("R2_MAX_RETENTION_BYTES")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2RunFromKey(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
key string
|
||||||
|
run string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"hls/r-20260815T120000Z/seg-1.m4s", "r-20260815T120000Z", true},
|
||||||
|
{"hls/r-20260815T120000Z/init.mp4", "r-20260815T120000Z", true},
|
||||||
|
{"hls/playlist.m3u8", "", false},
|
||||||
|
{"hls/playlist.m3u8.tmp", "", false},
|
||||||
|
{"other/r-x/seg.m4s", "", false},
|
||||||
|
{"hls/r-noslash", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
run, ok := r2RunFromKey(c.key)
|
||||||
|
if run != c.run || ok != c.ok {
|
||||||
|
t.Errorf("r2RunFromKey(%q) = (%q, %v), want (%q, %v)", c.key, run, ok, c.run, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupOldRuns(t *testing.T) {
|
||||||
|
prevClient := r2Client
|
||||||
|
prevBucket := bucket
|
||||||
|
prevList := listObjects
|
||||||
|
prevDelete := deleteObjects
|
||||||
|
prevTotal := r2CleanupObjectsTotal
|
||||||
|
prevRuns := r2RunsRetained
|
||||||
|
prevBytes := r2BytesRetained
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
listObjects = prevList
|
||||||
|
deleteObjects = prevDelete
|
||||||
|
r2CleanupObjectsTotal = prevTotal
|
||||||
|
r2RunsRetained = prevRuns
|
||||||
|
r2BytesRetained = prevBytes
|
||||||
|
}()
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
size := int64(1024)
|
||||||
|
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||||
|
return &s3.ListObjectsV2Output{
|
||||||
|
Contents: []types.Object{
|
||||||
|
{Key: aws.String("hls/r-20260813T000000Z/seg-1.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/r-20260814T000000Z/seg-2.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/r-20260815T000000Z/seg-3.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/playlist.m3u8"), Size: &size},
|
||||||
|
{Key: aws.String("hls/playlist.m3u8.tmp"), Size: &size},
|
||||||
|
},
|
||||||
|
IsTruncated: aws.Bool(false),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedKeys []string
|
||||||
|
deleteObjects = func(_ *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||||
|
for _, obj := range input.Delete.Objects {
|
||||||
|
deletedKeys = append(deletedKeys, aws.ToString(obj.Key))
|
||||||
|
}
|
||||||
|
return &s3.DeleteObjectsOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := cleanupOldRuns(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cleanupOldRuns error: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("deleted = %d, want 1 (oldest run)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.Join(deletedKeys, ",")
|
||||||
|
if !strings.Contains(joined, "hls/r-20260813T000000Z/seg-1.m4s") {
|
||||||
|
t.Fatalf("expected oldest run deleted, got: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if strings.Contains(joined, "playlist.m3u8") {
|
||||||
|
t.Fatalf("manifest must not be deleted: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if r2RunsRetained != 2 {
|
||||||
|
t.Fatalf("r2RunsRetained = %d, want 2", r2RunsRetained)
|
||||||
|
}
|
||||||
|
if r2BytesRetained != 2*size {
|
||||||
|
t.Fatalf("r2BytesRetained = %d, want %d", r2BytesRetained, 2*size)
|
||||||
|
}
|
||||||
|
if r2CleanupObjectsTotal != 1 {
|
||||||
|
t.Fatalf("r2CleanupObjectsTotal = %d, want 1", r2CleanupObjectsTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupOldRuns_SizeCap(t *testing.T) {
|
||||||
|
prevClient := r2Client
|
||||||
|
prevBucket := bucket
|
||||||
|
prevList := listObjects
|
||||||
|
prevDelete := deleteObjects
|
||||||
|
prevTotal := r2CleanupObjectsTotal
|
||||||
|
prevRuns := r2RunsRetained
|
||||||
|
prevBytes := r2BytesRetained
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
listObjects = prevList
|
||||||
|
deleteObjects = prevDelete
|
||||||
|
r2CleanupObjectsTotal = prevTotal
|
||||||
|
r2RunsRetained = prevRuns
|
||||||
|
r2BytesRetained = prevBytes
|
||||||
|
}()
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
// Force a small cap so the size policy trims even the last 2 runs.
|
||||||
|
os.Setenv("R2_KEEP_RUNS", "2")
|
||||||
|
os.Setenv("R2_MAX_RETENTION_BYTES", "1024")
|
||||||
|
defer os.Unsetenv("R2_KEEP_RUNS")
|
||||||
|
defer os.Unsetenv("R2_MAX_RETENTION_BYTES")
|
||||||
|
|
||||||
|
size := int64(1024)
|
||||||
|
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||||
|
return &s3.ListObjectsV2Output{
|
||||||
|
Contents: []types.Object{
|
||||||
|
{Key: aws.String("hls/r-20260813T000000Z/seg-1.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/r-20260814T000000Z/seg-2.m4s"), Size: &size},
|
||||||
|
{Key: aws.String("hls/r-20260815T000000Z/seg-3.m4s"), Size: &size},
|
||||||
|
},
|
||||||
|
IsTruncated: aws.Bool(false),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedKeys []string
|
||||||
|
deleteObjects = func(_ *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||||
|
for _, obj := range input.Delete.Objects {
|
||||||
|
deletedKeys = append(deletedKeys, aws.ToString(obj.Key))
|
||||||
|
}
|
||||||
|
return &s3.DeleteObjectsOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := cleanupOldRuns(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cleanupOldRuns error: %v", err)
|
||||||
|
}
|
||||||
|
// 3 runs -> keep 2 -> oldest deleted by count; remaining 2 bytes (2048) exceed
|
||||||
|
// the 1024 cap -> the older of the kept two is deleted, leaving only the newest.
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("deleted = %d, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.Join(deletedKeys, ",")
|
||||||
|
if !strings.Contains(joined, "hls/r-20260813T000000Z/seg-1.m4s") {
|
||||||
|
t.Fatalf("expected oldest run deleted, got: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, "hls/r-20260814T000000Z/seg-2.m4s") {
|
||||||
|
t.Fatalf("expected second-oldest run deleted by size cap, got: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if strings.Contains(joined, "hls/r-20260815T000000Z/seg-3.m4s") {
|
||||||
|
t.Fatalf("newest run must be kept, got: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if r2RunsRetained != 1 {
|
||||||
|
t.Fatalf("r2RunsRetained = %d, want 1", r2RunsRetained)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaylistFingerprint(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
p := filepath.Join(tmpDir, "playlist.txt")
|
||||||
|
if err := os.WriteFile(p, []byte("file '/app/media/a.mp4'\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fp1, err := playlistFingerprint(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playlistFingerprint error: %v", err)
|
||||||
|
}
|
||||||
|
fp2, err := playlistFingerprint(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playlistFingerprint error: %v", err)
|
||||||
|
}
|
||||||
|
if fp1 != fp2 {
|
||||||
|
t.Errorf("fingerprint not stable: %q vs %q", fp1, fp2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(p, []byte("file '/app/media/b.mp4'\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fp3, err := playlistFingerprint(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playlistFingerprint error: %v", err)
|
||||||
|
}
|
||||||
|
if fp1 == fp3 {
|
||||||
|
t.Errorf("fingerprint should change when playlist content changes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSNextStartNumber(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
// Create segments 0..29 with a live window of 10.
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i)), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next, err := hlsNextStartNumber(tmpDir, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hlsNextStartNumber error: %v", err)
|
||||||
|
}
|
||||||
|
if next != 30 {
|
||||||
|
t.Errorf("next = %d, want 30", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segments < 20 should be pruned; 20..29 kept (the live window).
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("seg-%06d should have been pruned", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 20; i < 30; i++ {
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); err != nil {
|
||||||
|
t.Errorf("seg-%06d should be kept: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSharedHLSConfig(t *testing.T) {
|
||||||
|
prevDur := hlsSegmentDuration
|
||||||
|
prevWin := hlsWindowSize
|
||||||
|
defer func() {
|
||||||
|
hlsSegmentDuration = prevDur
|
||||||
|
hlsWindowSize = prevWin
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(`{"hls_segment_duration":"4","hls_window_size":"12"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.Setenv("PLAYOUT_CONFIG_PATH", cfgPath)
|
||||||
|
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
|
||||||
|
|
||||||
|
loadSharedHLSConfig()
|
||||||
|
|
||||||
|
if hlsSegmentDuration != "4" {
|
||||||
|
t.Errorf("hlsSegmentDuration = %q, want 4", hlsSegmentDuration)
|
||||||
|
}
|
||||||
|
if hlsWindowSize != "12" {
|
||||||
|
t.Errorf("hlsWindowSize = %q, want 12", hlsWindowSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSharedHLSConfig_MissingFile(t *testing.T) {
|
||||||
|
prevDur := hlsSegmentDuration
|
||||||
|
prevWin := hlsWindowSize
|
||||||
|
defer func() {
|
||||||
|
hlsSegmentDuration = prevDur
|
||||||
|
hlsWindowSize = prevWin
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("PLAYOUT_CONFIG_PATH", filepath.Join(t.TempDir(), "nope.json"))
|
||||||
|
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
|
||||||
|
|
||||||
|
loadSharedHLSConfig() // should be a no-op without erroring
|
||||||
|
|
||||||
|
if hlsSegmentDuration != prevDur {
|
||||||
|
t.Errorf("hlsSegmentDuration changed to %q, want unchanged %q", hlsSegmentDuration, prevDur)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@ set -e
|
|||||||
|
|
||||||
echo "=== Linear Void Playout Service Starting ==="
|
echo "=== Linear Void Playout Service Starting ==="
|
||||||
echo "Playlist path: ${PLAYLIST_PATH:=/app/playlist/playlist.txt}"
|
echo "Playlist path: ${PLAYLIST_PATH:=/app/playlist/playlist.txt}"
|
||||||
echo "HLS segment duration: ${HLS_SEGMENT_DURATION:-6}s"
|
echo "HLS segment duration: ${HLS_SEGMENT_DURATION:-2}s"
|
||||||
echo "HLS window size: ${HLS_WINDOW_SIZE:-24}"
|
echo "HLS window size: ${HLS_WINDOW_SIZE:-12}"
|
||||||
|
|
||||||
# Ensure playlist directory and file exist
|
# Ensure playlist directory and file exist
|
||||||
mkdir -p "$(dirname "$PLAYLIST_PATH")"
|
mkdir -p "$(dirname "$PLAYLIST_PATH")"
|
||||||
@@ -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"
|
||||||
|
|||||||
+23
-22
@@ -14,6 +14,7 @@
|
|||||||
- [Target Topology](#target-topology)
|
- [Target Topology](#target-topology)
|
||||||
- [Prerequisites](#prerequisites)
|
- [Prerequisites](#prerequisites)
|
||||||
- [Environment File](#environment-file)
|
- [Environment File](#environment-file)
|
||||||
|
- [Gitea Secrets](#gitea-secrets)
|
||||||
- [Pull-and-Run Deployment](#pull-and-run-deployment)
|
- [Pull-and-Run Deployment](#pull-and-run-deployment)
|
||||||
- [First-Time or Recovery Provisioning](#first-time-or-recovery-provisioning)
|
- [First-Time or Recovery Provisioning](#first-time-or-recovery-provisioning)
|
||||||
- [Health Verification](#health-verification)
|
- [Health Verification](#health-verification)
|
||||||
@@ -93,12 +94,12 @@ 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`) |
|
| `workers-deploy.yml` | After CI succeeds | Deploy frontend to Cloudflare Workers (see `docs/operations/gitea-cloudflare-workers.md`) |
|
||||||
|
|
||||||
CI and CD run simultaneously on every push to `main`. The deploy pulls whatever `:latest` images are available in the registry at that moment.
|
CI and CD run simultaneously on every push to `main`. The deploy pulls whatever `:latest` images are available in the registry at that moment.
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ Use `.env` with at least:
|
|||||||
- `R2_ENDPOINT` — S3-compatible endpoint URL
|
- `R2_ENDPOINT` — S3-compatible endpoint URL
|
||||||
- `R2_REGION` — set to `auto`
|
- `R2_REGION` — set to `auto`
|
||||||
|
|
||||||
**Cloudflare (optional, for Workers/Pages):**
|
**Cloudflare (optional, for Workers):**
|
||||||
|
|
||||||
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`
|
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`
|
||||||
- `MEDIA_DOMAIN` — split media domain (e.g. `media.linearvoid.tv`)
|
- `MEDIA_DOMAIN` — split media domain (e.g. `media.linearvoid.tv`)
|
||||||
@@ -157,22 +158,22 @@ See `.env.example` and `SETUP.md` for full variable reference.
|
|||||||
|
|
||||||
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:
|
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 |
|
| Secret Name | Description |
|
||||||
| ------------------------- | ------------------------------------ |
|
| ---------------------------- | --------------------------------- |
|
||||||
| `DATABASE_URL` | PostgreSQL connection string |
|
| `DATABASE_URL` | PostgreSQL connection string |
|
||||||
| `ADMIN_USERNAME` | Admin dashboard username |
|
| `ADMIN_USERNAME` | Admin dashboard username |
|
||||||
| `ADMIN_PASSWORD` | Admin dashboard password |
|
| `ADMIN_PASSWORD` | Admin dashboard password |
|
||||||
| `R2_ACCESS_KEY_ID` | Cloudflare R2 access key |
|
| `R2_ACCESS_KEY_ID` | Cloudflare R2 access key |
|
||||||
| `R2_SECRET_ACCESS_KEY` | Cloudflare R2 secret key |
|
| `R2_SECRET_ACCESS_KEY` | Cloudflare R2 secret key |
|
||||||
| `R2_BUCKET_NAME` | R2 bucket name |
|
| `R2_BUCKET_NAME` | R2 bucket name |
|
||||||
| `R2_ENDPOINT` | R2 S3-compatible endpoint URL |
|
| `R2_ENDPOINT` | R2 S3-compatible endpoint URL |
|
||||||
| `R2_ADMIN_ACCESS_KEY_ID` | R2 admin access key (optional) |
|
| `R2_ADMIN_ACCESS_KEY_ID` | R2 admin access key (optional) |
|
||||||
| `R2_ADMIN_SECRET_ACCESS_KEY` | R2 admin secret key (optional) |
|
| `R2_ADMIN_SECRET_ACCESS_KEY` | R2 admin secret key (optional) |
|
||||||
| `R2_ADMIN_ENDPOINT` | R2 admin endpoint (optional) |
|
| `R2_ADMIN_ENDPOINT` | R2 admin endpoint (optional) |
|
||||||
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token (optional) |
|
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token (optional) |
|
||||||
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID (optional) |
|
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID (optional) |
|
||||||
| `CI_REGISTRY_USER` | Gitea container registry username |
|
| `CI_REGISTRY_USER` | Gitea container registry username |
|
||||||
| `CI_REGISTRY_TOKEN` | Gitea container registry token |
|
| `CI_REGISTRY_TOKEN` | Gitea container registry token |
|
||||||
|
|
||||||
## Pull-and-Run Deployment
|
## Pull-and-Run Deployment
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,6 @@ Config: `monitoring/prometheus/prometheus.yml` (15s scrape interval)
|
|||||||
See detailed scripts for querying Prometheus scrape targets:
|
See detailed scripts for querying Prometheus scrape targets:
|
||||||
|
|
||||||
- [Linux/macOS](./infrastructure/monitoring/prometheus_curl.md)
|
- [Linux/macOS](./infrastructure/monitoring/prometheus_curl.md)
|
||||||
- [Windows](./infrastructure/monitoring/prometheus_curl_win.md)
|
|
||||||
|
|
||||||
## Key Metrics
|
## Key Metrics
|
||||||
|
|
||||||
@@ -96,3 +95,4 @@ See detailed scripts for querying Prometheus scrape targets:
|
|||||||
- 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.
|
||||||
+2
-3
@@ -9,11 +9,10 @@ This folder contains the documentation for the Linear Void project. The document
|
|||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
- Cloudflare
|
- Cloudflare
|
||||||
- [Cache Rules](./infrastructure/cloudflare/cache-rules.md) - Cache rules for Cloudflare Workers and Pages.
|
- [Cache Rules](./infrastructure/cloudflare/cache-rules.md) - Cache rules for Cloudflare Workers.
|
||||||
- [Account Troubleshooting](./infrastructure/cloudflare/cloudflare-account-troubleshooting.md) - Troubleshooting Cloudflare account issues.
|
- [Account Troubleshooting](./infrastructure/cloudflare/cloudflare-account-troubleshooting.md) - Troubleshooting Cloudflare account issues.
|
||||||
- Monitoring
|
- Monitoring
|
||||||
- [Prometheus CURL](./infrastructure/monitoring/prometheus_curl.md) - Prometheus metrics endpoint testing with curl.
|
- [Prometheus CURL](./infrastructure/monitoring/prometheus_curl.md) - Prometheus metrics endpoint testing with curl and jq. Doocumentation of available metrics and their meanings.
|
||||||
- [Prometheus CURL Windows](./infrastructure/monitoring/prometheus_curl_win.md) - Prometheus metrics endpoint testing with curl on Windows.
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
+33
-84
@@ -1,5 +1,7 @@
|
|||||||
# 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
|
||||||
@@ -7,7 +9,7 @@ This guide covers prerequisites, environment setup, running the application loca
|
|||||||
- **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 22+**: 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.
|
||||||
@@ -227,20 +166,36 @@ flowchart LR
|
|||||||
- 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.
|
||||||
|
|
||||||
## Cloudflare
|
## Cloudflare
|
||||||
|
|
||||||
Use this section for full edge setup (DNS, Pages, cache, security, and monitoring).
|
Use this section for full edge setup (DNS, Workers, cache, security, and monitoring).
|
||||||
|
|
||||||
Execution tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`.
|
Execution tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`.
|
||||||
|
|
||||||
### 1. DNS Setup
|
### 1. DNS Setup
|
||||||
|
|
||||||
1. Add apex record for `linearvoid.tv` pointing to Cloudflare Pages target.
|
1. Add apex record for `linearvoid.tv` pointing to the Cloudflare Worker.
|
||||||
2. Add `www` CNAME to Cloudflare Pages and configure redirect to `https://linearvoid.tv`.
|
2. Add `www` CNAME to the Cloudflare Worker and configure redirect to `https://linearvoid.tv`.
|
||||||
3. Add `media.linearvoid.tv` for split-domain HLS delivery.
|
3. Add `media.linearvoid.tv` for split-domain HLS delivery.
|
||||||
4. Keep all records proxied (orange cloud) unless troubleshooting requires DNS-only mode.
|
4. Keep all records proxied (orange cloud) unless troubleshooting requires DNS-only mode.
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -274,26 +230,21 @@ Configure Cloudflare Cache Rules:
|
|||||||
|
|
||||||
Reference: [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
|
Reference: [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
|
||||||
|
|
||||||
### 4. Cloudflare Pages
|
### 4. Cloudflare Workers
|
||||||
|
|
||||||
Also see [the automated Gitea Actions deployment guide](./operations/gitea-cloudflare-pages.md).
|
Also see [the automated Gitea Actions deployment guide](./operations/gitea-cloudflare-workers.md).
|
||||||
|
|
||||||
Manual setup:
|
Manual setup:
|
||||||
|
|
||||||
1. Connect Gitea repository to Cloudflare Pages.
|
1. Create a Cloudflare Worker for the static SPA (Workers & Pages → Create → Worker).
|
||||||
2. Build command:
|
2. Build the frontend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Output directory:
|
3. Bind the build output (`frontend/dist`) as Worker static assets.
|
||||||
|
4. Configure production domain `linearvoid.tv` on the Worker.
|
||||||
```text
|
|
||||||
frontend/dist
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Configure production domain to `linearvoid.tv`.
|
|
||||||
|
|
||||||
### 5. Tiered Cache
|
### 5. Tiered Cache
|
||||||
|
|
||||||
@@ -350,7 +301,7 @@ Create external monitors:
|
|||||||
### 10. Verification Checklist
|
### 10. Verification Checklist
|
||||||
|
|
||||||
1. DNS resolves for `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv`.
|
1. DNS resolves for `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv`.
|
||||||
2. Pages serves frontend for apex and `www` redirects correctly.
|
2. Workers serve frontend for apex and `www` redirects correctly.
|
||||||
3. HLS segments are cacheable long-term, manifests refresh every ~2s.
|
3. HLS segments are cacheable long-term, manifests refresh every ~2s.
|
||||||
4. Admin paths are rate-limited and WAF events are visible.
|
4. Admin paths are rate-limited and WAF events are visible.
|
||||||
5. HTTPS and WSS both succeed with valid certificates.
|
5. HTTPS and WSS both succeed with valid certificates.
|
||||||
@@ -588,5 +539,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
|
|
||||||
+10
-4
@@ -68,13 +68,15 @@ Baseline packaging:
|
|||||||
| ---------------- | ------------------------------------------------------------------------------------- |
|
| ---------------- | ------------------------------------------------------------------------------------- |
|
||||||
| Protocol | Standard live HLS, not LL-HLS |
|
| Protocol | Standard live HLS, not LL-HLS |
|
||||||
| Segment format | fMP4 (`.m4s`) with an initialization segment; `.ts` may be supported during migration |
|
| Segment format | fMP4 (`.m4s`) with an initialization segment; `.ts` may be supported during migration |
|
||||||
| Segment target | 6 seconds (`HLS_SEGMENT_DURATION=6`) |
|
| Segment target | 2 seconds (`HLS_SEGMENT_DURATION=2`) |
|
||||||
| Live window | 24 segments (`HLS_WINDOW_SIZE=24`), about 144 seconds |
|
| Live window | 12 segments (`HLS_WINDOW_SIZE=12`), about 24 seconds |
|
||||||
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
|
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
|
||||||
| Expected latency | 15–35 seconds |
|
| Expected latency | 15–35 seconds |
|
||||||
|
|
||||||
Segment boundaries must align with keyframes. Timestamps must remain monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new initialization segment.
|
Segment boundaries must align with keyframes. Timestamps must remain monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new initialization segment.
|
||||||
|
|
||||||
|
**Segment duration rationale**: 2s segments were chosen to lower end-to-end latency and keep the live window small (12 × 2s = 24s) while staying well above the manifest TTL (1–2s). The 2s GOP (30fps, `-g 60 -keyint_min 60`) already matches the target, so segments cut cleanly on keyframes. Longer durations (4–6s) increase latency and window depth; shorter than 2s inflates segment/request overhead and R2 Class B operations with little latency gain.
|
||||||
|
|
||||||
Every segment name is globally unique and never reused, including after a restart or playlist loop. Use a run/session ID plus a monotonically increasing media sequence, for example:
|
Every segment name is globally unique and never reused, including after a restart or playlist loop. Use a run/session ID plus a monotonically increasing media sequence, for example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -128,6 +130,8 @@ The manifest must refresh within a few seconds. Repeated segment requests should
|
|||||||
|
|
||||||
The SPA uses native HLS where supported and hls.js elsewhere.
|
The SPA uses native HLS where supported and hls.js elsewhere.
|
||||||
|
|
||||||
|
- Firefox has no native HLS, so it uses the hls.js/MSE path; fMP4 (`.m4s` + init segment) is supported. Autoplay requires `muted` + `playsinline`, which are already set on the `<video>` element.
|
||||||
|
|
||||||
- Start at the live edge with a conservative buffer; do not seek to playlist start.
|
- Start at the live edge with a conservative buffer; do not seek to playlist start.
|
||||||
- Treat the manifest as the synchronization source and target roughly three segments behind the newest segment unless testing supports a smaller margin.
|
- Treat the manifest as the synchronization source and target roughly three segments behind the newest segment unless testing supports a smaller margin.
|
||||||
- Recover transient network/media errors with bounded retries and exponential backoff.
|
- Recover transient network/media errors with bounded retries and exponential backoff.
|
||||||
@@ -171,11 +175,13 @@ A release is streaming-ready only when one full playlist boundary has played wit
|
|||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| ------------------------------------------ | -------------------------------------------------- |
|
| ------------------------------------------ | -------------------------------------------------- |
|
||||||
| `PLAYLIST_PATH` | Active playlist file |
|
| `PLAYLIST_PATH` | Active playlist file |
|
||||||
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `6` |
|
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `2` |
|
||||||
| `HLS_WINDOW_SIZE` | Manifest window; baseline `24` |
|
| `HLS_WINDOW_SIZE` | Manifest window; baseline `12` |
|
||||||
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
|
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
|
||||||
| `R2_BUCKET_NAME` | Media bucket |
|
| `R2_BUCKET_NAME` | Media bucket |
|
||||||
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, available only to the publisher |
|
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, available only to the publisher |
|
||||||
|
| `R2_KEEP_RUNS` | Number of run folders to retain (default `2`) |
|
||||||
|
| `R2_MAX_RETENTION_BYTES` | Max bytes retained across run folders (10 GiB) |
|
||||||
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
||||||
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
|
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
|
||||||
|
|
||||||
|
|||||||
+105
-49
@@ -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)
|
||||||
@@ -160,7 +164,7 @@ flowchart TD
|
|||||||
|
|
||||||
subgraph "Cloudflare Global Edge"
|
subgraph "Cloudflare Global Edge"
|
||||||
CDN[Cloudflare CDN<br/>Cache Rules + WAF + TLS]
|
CDN[Cloudflare CDN<br/>Cache Rules + WAF + TLS]
|
||||||
Pages[Cloudflare Pages<br/>Static SPA Hosting]
|
Workers[Cloudflare Workers<br/>Static SPA Hosting]
|
||||||
R2[Cloudflare R2<br/>HLS Segments + Source MP4s<br/>enam location hint]
|
R2[Cloudflare R2<br/>HLS Segments + Source MP4s<br/>enam location hint]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -173,17 +177,17 @@ flowchart TD
|
|||||||
Admins -->|HTTPS - Schedule/Playlist| Playout
|
Admins -->|HTTPS - Schedule/Playlist| Playout
|
||||||
Playout -->|S3-compatible Upload| R2
|
Playout -->|S3-compatible Upload| R2
|
||||||
R2 -->|Cached HLS| CDN
|
R2 -->|Cached HLS| CDN
|
||||||
Viewers -->|HTTPS| Pages
|
Viewers -->|HTTPS| Workers
|
||||||
Viewers -->|HLS Requests| CDN
|
Viewers -->|HLS Requests| CDN
|
||||||
Viewers -->|WSS Chat| Chat
|
Viewers -->|WSS Chat| Chat
|
||||||
Chat --> VPS
|
Chat --> VPS
|
||||||
VPS -->|Proxy / Static| Pages
|
VPS -->|Proxy / Static| Workers
|
||||||
CDN -->|Origin Pull on Miss| R2
|
CDN -->|Origin Pull on Miss| R2
|
||||||
```
|
```
|
||||||
|
|
||||||
**External Interfaces**:
|
**External Interfaces**:
|
||||||
|
|
||||||
- **Cloudflare**: CDN (cache rules for `.m4s` and `.ts` vs `.m3u8`), R2 (object storage, $0 egress), Pages (static hosting), WAF/TLS.
|
- **Cloudflare**: CDN (cache rules for `.m4s` and `.ts` vs `.m3u8`), R2 (object storage, $0 egress), Workers (static hosting), WAF/TLS.
|
||||||
- **Hetzner VPS**: Compute for playout and chat (low-cost US-East with good peering).
|
- **Hetzner VPS**: Compute for playout and chat (low-cost US-East with good peering).
|
||||||
- **Browsers**: Native HLS (`<video>` + hls.js) and WebSocket APIs.
|
- **Browsers**: Native HLS (`<video>` + hls.js) and WebSocket APIs.
|
||||||
- **Source**: Pre-normalized MP4 files uploaded to R2.
|
- **Source**: Pre-normalized MP4 files uploaded to R2.
|
||||||
@@ -229,7 +233,7 @@ graph TD
|
|||||||
subgraph Cloudflare
|
subgraph Cloudflare
|
||||||
CDN[CDN + Cache Rules<br/>.m4s and .ts immutable long TTL<br/>.m3u8 short TTL]
|
CDN[CDN + Cache Rules<br/>.m4s and .ts immutable long TTL<br/>.m3u8 short TTL]
|
||||||
R2[R2 - HLS + Source]
|
R2[R2 - HLS + Source]
|
||||||
Pages[Pages - Static SPA]
|
Workers[Workers - Static SPA]
|
||||||
end
|
end
|
||||||
VPS[VPS Hetzner Ashburn]
|
VPS[VPS Hetzner Ashburn]
|
||||||
Playout[FFmpeg + MediaMTX/SRS<br/>Playlist → HLS]
|
Playout[FFmpeg + MediaMTX/SRS<br/>Playlist → HLS]
|
||||||
@@ -240,7 +244,7 @@ graph TD
|
|||||||
Admin --> Playout
|
Admin --> Playout
|
||||||
Playout --> R2
|
Playout --> R2
|
||||||
R2 --> CDN
|
R2 --> CDN
|
||||||
Viewer --> Pages
|
Viewer --> Workers
|
||||||
Viewer --> CDN
|
Viewer --> CDN
|
||||||
Viewer --> Chat
|
Viewer --> Chat
|
||||||
Chat --> VPS
|
Chat --> VPS
|
||||||
@@ -270,7 +274,7 @@ graph TB
|
|||||||
subgraph "Cloudflare Edge"
|
subgraph "Cloudflare Edge"
|
||||||
CF[CDN + WAF<br/>Cache Rules]
|
CF[CDN + WAF<br/>Cache Rules]
|
||||||
R2[R2 Bucket<br/>media + HLS]
|
R2[R2 Bucket<br/>media + HLS]
|
||||||
PAGES[Pages<br/>SPA hosting]
|
WORKERS[Workers<br/>SPA hosting]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Docker Host (Hetzner CX22)"
|
subgraph "Docker Host (Hetzner CX22)"
|
||||||
@@ -305,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**: `cleanupOldRuns` keeps the newest `R2_KEEP_RUNS` run folders (default 2) and caps retained data at `R2_MAX_RETENTION_BYTES` (default 10 GiB), triggered after each run plus an hourly backstop
|
||||||
|
- **Shared config**: Reads the runtime `config.json` (`loadSharedHLSConfig`) as an env fallback for HLS segment duration/window
|
||||||
|
|
||||||
#### Chat Service (`backend/chat/src/main.go`)
|
#### Chat Service (`backend/chat/src/main.go`)
|
||||||
|
|
||||||
@@ -334,50 +341,57 @@ 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 |
|
||||||
| **DM Router** | `main.go` (handleDirectMessage) | Resolve target user in clients map, deliver private message |
|
| **DM Router** | `main.go` (handleDirectMessage) | Resolve target user in clients map, deliver private message |
|
||||||
| **Persistence Layer** | `main.go` (GORM models: Message, DirectMessage, ModerationLog) | Store/retrieve chat messages, DMs, moderation events |
|
| **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 |
|
||||||
| **Admin API Router** | `main.go` (handleAdmin\*) | Upload, playlist CRUD, schedule overrides, moderation actions, analytics, system summary |
|
| **User Auth Handler** | `handlers/handlers.go` (HandleUserRegister/Login/Session/Logout) | Viewer register/login/logout/session (bcrypt, user session cookie) |
|
||||||
| **Metrics Exporter** | `main.go` (prometheus.MustRegister) | Expose connected_clients, messages_total, dms_total, moderation_actions, redis_up |
|
| **DM History/Search** | `handlers/handlers.go` (HandleUserDMHistory, HandleUserDMSearch) | Paginated DM history + search for authenticated users |
|
||||||
|
| **Runtime Config** | `config/runtime.go` (GetRuntime, SetRuntime, typed setters) | Thread-safe JSON runtime config (HLS duration/window, Matomo) |
|
||||||
|
| **Admin API Router** | `main.go` (handleAdmin\*) | Upload, playlist CRUD, schedule overrides, moderation actions, analytics, system summary |
|
||||||
|
| **Metrics Exporter** | `main.go` (prometheus.MustRegister) | Expose connected_clients, messages_total, dms_total, moderation_actions, redis_up |
|
||||||
|
|
||||||
#### 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 |
|
||||||
| **Slate Controller** | `playlist.go` (activateSlate, deactivateSlate) | Switch output to slate loop on excessive failures |
|
| **Loop Controller** | `playlist.go` (playlistFingerprint, hlsNextStartNumber) | Detect playlist changes (SHA-256), continue segment numbering on clean loops |
|
||||||
| **R2 Sync** | `playlist.go` (syncToR2) | Upload HLS segments + manifest to R2 bucket |
|
| **Slate Controller** | `playlist.go` (activateSlate, deactivateSlate) | Switch output to slate loop on excessive failures |
|
||||||
| **Health Endpoint** | `playlist.go` (healthHandler) | `/health` → JSON with mode, segment count, uptime |
|
| **R2 Sync** | `playlist.go` (syncToR2) | Upload HLS segments + manifest to R2 bucket |
|
||||||
| **Metrics Exporter** | `playlist.go` (prometheus) | Expose restarts_total, mode (live/slate) |
|
| **R2 Cleanup** | `playlist.go` (cleanupOldRuns) | Keep the last N run folders under the size cap; delete older `hls/r-*/*` objects |
|
||||||
|
| **Health Endpoint** | `playlist.go` (healthHandler) | `/health` → JSON with mode, segment count, uptime |
|
||||||
|
| **Metrics Exporter** | `playlist.go` (prometheus) | Expose restarts_total, mode (live/slate), loops_total, run_duration_seconds, r2_cleanup_objects_total |
|
||||||
|
|
||||||
#### Frontend Modules
|
#### 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 |
|
||||||
| **Schedule Overlay** | `src/main.ts` | Poll `/api/schedule`, render current/next program |
|
| **DM Search** | `src/main.ts` | Search DM history (`/api/dm/search`) and render results |
|
||||||
| **Admin Router** | `src/admin.ts` | Tab-based navigation, dynamic page loading |
|
| **Auth UI** | `src/main.ts` + `index.html` | Viewer login/register/logout, session refresh, user label |
|
||||||
| **Upload Manager** | `src/admin.ts` | Multipart upload with chunk progress, ffprobe validation feedback |
|
| **Schedule Overlay** | `src/main.ts` | Poll `/api/schedule`, render current/next program |
|
||||||
| **Playlist Editor** | `src/admin.ts` | Drag-reorder, add/remove items, save to `/api/admin/playlist` |
|
| **Admin Router** | `src/admin.ts` | Tab-based navigation, dynamic page loading |
|
||||||
| **Moderation Panel** | `src/admin.ts` + `public/admin/moderation.html` | Inline mute/ban, action log viewer |
|
| **Upload Manager** | `src/admin.ts` | Multipart upload with chunk progress, normalization status badges |
|
||||||
| **Analytics Dashboard** | `src/admin.ts` + `public/admin/analytics.html` | Message counts, top rooms, moderation stats |
|
| **Playlist Editor** | `src/admin.ts` | Drag-reorder, add/remove items (gated to `ready` media), save to `/api/admin/playlist` |
|
||||||
| **System Config** | `src/admin.ts` + `public/admin/system.html` | Service health, config metadata, restart buttons |
|
| **Moderation Panel** | `src/admin.ts` + `public/admin/moderation.html` | Inline mute/ban, action log viewer |
|
||||||
|
| **Analytics Dashboard** | `src/admin.ts` + `public/admin/analytics.html` | Message counts, top rooms, moderation stats |
|
||||||
|
| **System Config** | `src/admin.ts` + `public/admin/system.html` | Service health, runtime config form (HLS duration/window, Matomo), restart buttons |
|
||||||
|
|
||||||
## Runtime View
|
## Runtime View
|
||||||
|
|
||||||
### Playout & Delivery Scenario
|
### Playout & Delivery Scenario
|
||||||
|
|
||||||
1. Admin adds normalized MP4 to R2 + updates playlist.
|
1. Admin adds normalized MP4 to R2 + updates playlist.
|
||||||
2. Playout worker (FFmpeg loop with concat demuxer) reads next file, packages as HLS segments (4-6s).
|
2. Playout worker (FFmpeg loop with concat demuxer) reads next file, packages as HLS segments (2s).
|
||||||
3. Segments uploaded to R2 with unique immutable names; live `.m3u8` updated.
|
3. Segments uploaded to R2 with unique immutable names; live `.m3u8` updated.
|
||||||
4. Viewer loads SPA → requests master/live manifest → hls.js player follows current edge.
|
4. Viewer loads SPA → requests master/live manifest → hls.js player follows current edge.
|
||||||
5. Cloudflare serves `.m4s` and `.ts` from nearest edge cache (origin/R2 hit only once per POP/region).
|
5. Cloudflare serves `.m4s` and `.ts` from nearest edge cache (origin/R2 hit only once per POP/region).
|
||||||
@@ -447,7 +461,7 @@ flowchart LR
|
|||||||
|
|
||||||
### Infrastructure Level 1
|
### Infrastructure Level 1
|
||||||
|
|
||||||
**Overview Diagram**: See Mermaid in Building Block section (VPS + Cloudflare R2/Pages/CDN).
|
**Overview Diagram**: See Mermaid in Building Block section (VPS + Cloudflare R2/Workers/CDN).
|
||||||
|
|
||||||
**Motivation**: Low-cost US-East origin with global edge caching. Hetzner provides excellent price/performance and included traffic; Cloudflare eliminates egress fees.
|
**Motivation**: Low-cost US-East origin with global edge caching. Hetzner provides excellent price/performance and included traffic; Cloudflare eliminates egress fees.
|
||||||
|
|
||||||
@@ -456,7 +470,7 @@ flowchart LR
|
|||||||
**Mapping of Building Blocks to Infrastructure**:
|
**Mapping of Building Blocks to Infrastructure**:
|
||||||
|
|
||||||
- Playout + Chat + Nginx → Hetzner Cloud CX22 (Ashburn, VA).
|
- Playout + Chat + Nginx → Hetzner Cloud CX22 (Ashburn, VA).
|
||||||
- HLS storage, caching, static SPA → Cloudflare R2 + CDN + Pages (custom domains, `enam` location hint).
|
- HLS storage, caching, static SPA → Cloudflare R2 + CDN + Workers (custom domains, `enam` location hint).
|
||||||
- All public traffic routed through Cloudflare.
|
- All public traffic routed through Cloudflare.
|
||||||
|
|
||||||
### Infrastructure Level 2
|
### Infrastructure Level 2
|
||||||
@@ -468,7 +482,7 @@ flowchart LR
|
|||||||
- R2 bucket for media.
|
- R2 bucket for media.
|
||||||
- Cache Rules (`.m4s` and `.ts`: Cache Everything, immutable, long TTL; `.m3u8`: short TTL/must-revalidate).
|
- Cache Rules (`.m4s` and `.ts`: Cache Everything, immutable, long TTL; `.m3u8`: short TTL/must-revalidate).
|
||||||
- WAF, rate limiting, Tiered Cache.
|
- WAF, rate limiting, Tiered Cache.
|
||||||
- Pages for SPA hosting.
|
- Workers for SPA hosting.
|
||||||
|
|
||||||
Future evolution: Docker Compose, managed DB, secondary regions (detailed in subsequent ADRs).
|
Future evolution: Docker Compose, managed DB, secondary regions (detailed in subsequent ADRs).
|
||||||
|
|
||||||
@@ -480,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)
|
||||||
|
|
||||||
@@ -508,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 2), `hlsWindowSize` (3–100, default 12), `matomoUrl`, `matomoSiteId`.
|
||||||
|
- Playout reads the same file (`loadSharedHLSConfig`) as an env fallback for HLS segment duration/window.
|
||||||
|
- Typed setters validate values; invalid input is rejected with `400`.
|
||||||
|
|
||||||
|
### 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:
|
||||||
@@ -533,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
|
||||||
|
|
||||||
@@ -545,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`
|
||||||
@@ -554,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.
|
||||||
@@ -606,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`
|
||||||
@@ -615,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.
|
||||||
@@ -680,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.
|
||||||
@@ -701,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
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
|||||||
|
|
||||||
### 3. Frontend
|
### 3. Frontend
|
||||||
|
|
||||||
- Static SPA (HTML + custom CSS + vanilla JS) hosted on Cloudflare Pages or same VPS/Nginx.
|
- Static SPA (HTML + custom CSS + vanilla JS) hosted on Cloudflare Workers or same VPS/Nginx.
|
||||||
- Video player: `hls.js` (Chromium/Firefox) or native HLS (Safari). Single live `master.m3u8`.
|
- Video player: `hls.js` (Chromium/Firefox) or native HLS (Safari). Single live `master.m3u8`.
|
||||||
- Embedded chat sidebar with room switcher, private DMs, now-playing overlay.
|
- Embedded chat sidebar with room switcher, private DMs, now-playing overlay.
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
|||||||
|
|
||||||
### 5. Infrastructure Summary & Cost Model
|
### 5. Infrastructure Summary & Cost Model
|
||||||
|
|
||||||
- **Primary**: Hetzner Ashburn VPS (playout + chat + Nginx) + Cloudflare R2 + Pages.
|
- **Primary**: Hetzner Ashburn VPS (playout + chat + Nginx) + Cloudflare R2 + Workers.
|
||||||
- **Monitoring**: MediaMTX/SRS dashboard, UptimeRobot, systemd logs, Cloudflare analytics.
|
- **Monitoring**: MediaMTX/SRS dashboard, UptimeRobot, systemd logs, Cloudflare analytics.
|
||||||
- **Backup**: Weekly R2/S3-compatible snapshots of playlist, DB, config.
|
- **Backup**: Weekly R2/S3-compatible snapshots of playlist, DB, config.
|
||||||
- **Fallback**: Emergency slate/loop playlist, automated restart.
|
- **Fallback**: Emergency slate/loop playlist, automated restart.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Reference for Cloudflare account and API token troubleshooting.
|
Reference for Cloudflare account and API token troubleshooting.
|
||||||
|
|
||||||
Use `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects` to verify the account ID and API token are valid.
|
Use `wrangler whoami` to verify the account ID and API token are valid, or query the Workers API at `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts`.
|
||||||
|
|
||||||
## Validate Cloudflare account and API token
|
## Validate Cloudflare account and API token
|
||||||
|
|
||||||
@@ -12,13 +12,13 @@ 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_API_TOKEN'
|
||||||
Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID'
|
Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID'
|
||||||
|
|
||||||
# Test API token with curl
|
# Test API token with wrangler
|
||||||
|
npx wrangler whoami
|
||||||
|
|
||||||
|
# Or verify with the Workers API directly
|
||||||
$env:CLOUDFLARE_API_TOKEN = (Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN' | ForEach-Object { $_.Line.Split('=')[1].Trim() })
|
$env:CLOUDFLARE_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() })
|
$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
|
# List all Workers 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"
|
curl -X GET "https://api.cloudflare.com/client/v4/accounts/$env:CLOUDFLARE_ACCOUNT_ID/workers/scripts" -H "Authorization: Bearer $env:CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json"
|
||||||
|
|
||||||
# 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"}'
|
|
||||||
```
|
```
|
||||||
@@ -3,69 +3,60 @@
|
|||||||
Here are ready‑to‑run **cURL** commands you can use to query each Prometheus scrape target and list the metric names they expose.
|
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.
|
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`)
|
## 1. Chat (`http://chat:3000/metrics`)
|
||||||
|
|
||||||
|
[Browse `chat`](http://192.168.88.40:9090/api/v1/label/__name__/values?match[]={job=%22chat%22})
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="chat",instance="chat:3000"}' | jq .
|
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`)
|
## 2. NGINX exporter (`http://nginx-exporter:9113/metrics`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="nginx",instance="nginx-exporter:9113"}' | jq .
|
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`)
|
## 3. Node exporter (`http://node-exporter:9100/metrics`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="node",instance="node-exporter:9100"}' | jq .
|
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`)
|
## 4. Playout (`http://playout:8081/metrics`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="playout",instance="playout:8081"}' | jq .
|
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`)
|
## 5. Postgres exporter (`http://postgres-exporter:9187/metrics`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="postgres",instance="postgres-exporter:9187"}' | jq .
|
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`)
|
## 6. Prometheus itself (`http://prometheus:9090/metrics`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="prometheus",instance="prometheus:9090"}' | jq .
|
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`)
|
## 7. Redis exporter (`http://redis-exporter:9121/metrics`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="redis",instance="redis-exporter:9121"}' | jq .
|
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.
|
||||||
|
|
||||||
### How it works
|
|
||||||
|
|
||||||
- `http://localhost:9090` – address of your Prometheus server.
|
|
||||||
- `/api/v1/label/__name__/values` – API call that lists _all_ metric names (`__name__` label) known to Prometheus.
|
|
||||||
- `match[]` – a selector that restricts the result to the specific `job` and `instance` you care about.
|
|
||||||
- `-s` – silent mode (no progress meter).
|
|
||||||
- `| jq .` – pretty‑prints the JSON response (optional but handy).
|
|
||||||
|
|
||||||
Running any of these commands will output a JSON array like:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
"up",
|
|
||||||
"process_cpu_seconds_total",
|
|
||||||
"http_requests_total",
|
|
||||||
...
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
which is the full set of metrics currently being scraped from that target. Adjust the Prometheus host/port if it differs from `http://localhost:9090`.
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# Prometheus scrape target metric names (Windows)
|
|
||||||
|
|
||||||
Below are **cURL** one‑liners you can run from a Windows command prompt (or PowerShell) that query the Prometheus HTTP API and print the raw JSON list of metric names for each target.
|
|
||||||
Since **jq** isn’t installed, the commands simply echo the JSON response; you can pipe it to `findstr` or PowerShell’s `ConvertFrom‑Json` if you want a prettier view.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Chat (`http://chat:3000/metrics`)
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"chat\",instance=\"chat:3000\"}"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. NGINX exporter (`http://nginx-exporter:9113/metrics`)
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"nginx\",instance=\"nginx-exporter:9113\"}"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Node exporter (`http://node-exporter:9100/metrics`)
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"node\",instance=\"node-exporter:9100\"}"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Playout (`http://playout:8081/metrics`)
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"playout\",instance=\"playout:8081\"}"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Postgres exporter (`http://postgres-exporter:9187/metrics`)
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"postgres\",instance=\"postgres-exporter:9187\"}"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Prometheus itself (`http://prometheus:9090/metrics`)
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"prometheus\",instance=\"prometheus:9090\"}"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Redis exporter (`http://redis-exporter:9121/metrics`)
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"redis\",instance=\"redis-exporter:9121\"}"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Making the output a bit more readable (optional)
|
|
||||||
|
|
||||||
If you have **PowerShell** (available on Windows 10/11), you can format the JSON nicely:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=`"chat`",instance=`"chat:3000`"}" |
|
|
||||||
ConvertFrom-Json |
|
|
||||||
Select-Object -ExpandProperty data |
|
|
||||||
Sort-Object
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace the URL part with the one for the target you’re interested in. The pipeline:
|
|
||||||
|
|
||||||
1. Calls `curl` (the built‑in alias for `Invoke-WebRequest` in PowerShell).
|
|
||||||
2. `ConvertFrom-Json` parses the JSON.
|
|
||||||
3. `Select-Object -ExpandProperty data` extracts the array of metric names.
|
|
||||||
4. `Sort-Object` alphabetizes them.
|
|
||||||
|
|
||||||
You can similarly pipe the output to `findstr /i "http_requests_total"` to search for a particular metric name without any extra tools.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Quick checklist
|
|
||||||
|
|
||||||
| Item | What to adjust |
|
|
||||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **Prometheus address** | If your Prometheus container isn’t bound to `localhost:9090`, replace `localhost:9090` with the appropriate host/port (e.g., `127.0.0.1:9090`). |
|
|
||||||
| **Target job/instance** | The `job` and `instance` values must match the labels shown in your UI (they’re case‑sensitive). |
|
|
||||||
| **PowerShell vs CMD** | The raw `curl` commands work in both CMD and PowerShell; the PowerShell‑only pretty‑print uses `ConvertFrom‑Json`. |
|
|
||||||
|
|
||||||
Run the command for any target and you’ll receive a JSON payload like:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"data": [
|
|
||||||
"up",
|
|
||||||
"process_cpu_seconds_total",
|
|
||||||
"http_requests_total",
|
|
||||||
"... more metrics ..."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
That array (`data`) is the complete list of metric names currently scraped from the specified endpoint.
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,69 +0,0 @@
|
|||||||
# Cloudflare Pages & Workers Setup — Linear Void
|
|
||||||
|
|
||||||
Linear Void uses two Cloudflare products:
|
|
||||||
|
|
||||||
| Product | Purpose | What |
|
|
||||||
| ----------- | ------------------ | ------------------------------------------------------------------------------------ |
|
|
||||||
| **Pages** | Static SPA hosting | Vite-built frontend (`frontend/dist`) deployed via Git integration or Direct Upload |
|
|
||||||
| **Workers** | Edge cache logic | `infrastructure/cloudflare/worker.js` — adds immutable cache headers to HLS segments |
|
|
||||||
|
|
||||||
Both are managed under **Workers & Pages** in the Cloudflare dashboard.
|
|
||||||
|
|
||||||
## Dashboard URLs
|
|
||||||
|
|
||||||
| Resource | URL |
|
|
||||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Workers & Pages overview | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers-and-pages` |
|
|
||||||
| Worker: linearvoid | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid/production/settings` |
|
|
||||||
| Pages project | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/pages/view/linearvoid` |
|
|
||||||
| API tokens | <https://dash.cloudflare.com/profile/api-tokens> |
|
|
||||||
|
|
||||||
## Worker: `linearvoid` (edge cache headers)
|
|
||||||
|
|
||||||
The Worker at `infrastructure/cloudflare/worker.js` runs on the `linearvoid` Workers service. It intercepts requests and adds immutable `Cache-Control` headers to `.m4s` segment files.
|
|
||||||
|
|
||||||
Deploy via Wrangler CLI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx wrangler deploy infrastructure/cloudflare/worker.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Or paste into the Workers dashboard at the URL above (Quick Edit).
|
|
||||||
|
|
||||||
## Pages: Static frontend
|
|
||||||
|
|
||||||
### Direct Upload via Gitea Actions
|
|
||||||
|
|
||||||
Use `.gitea/workflows/pages-deploy.yml` to build in Gitea Actions and upload via the Cloudflare API.
|
|
||||||
|
|
||||||
See [gitea-cloudflare-pages.md](./gitea-cloudflare-pages.md) for the workflow.
|
|
||||||
|
|
||||||
### Variables and secrets
|
|
||||||
|
|
||||||
On Cloudflare pages, set the following environment variables (needed to run the frontend)
|
|
||||||
|
|
||||||
| Type | Name | Value |
|
|
||||||
| --------- | -------------------------------------- | ----------------------------------------------- |
|
|
||||||
| Secret | ADMIN_PASSWORD | `not-a-real-admin-password` |
|
|
||||||
| Plaintext | ADMIN_SESSION_TTL_HOURS | `12` |
|
|
||||||
| Plaintext | ADMIN_USERNAME | `boogers` |
|
|
||||||
| Plaintext | HLS_SEGMENT_DURATION | `6` |
|
|
||||||
| Plaintext | HLS_WINDOW_SIZE | `24` |
|
|
||||||
| Plaintext | PLAYLIST_PATH | `/app/playlist/playlist.txt` |
|
|
||||||
| Plaintext | VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR | `boogers` |
|
|
||||||
| Plaintext | VITE_PUBLIC_ADMIN_DEFAULT_ROOM | `main` |
|
|
||||||
| Plaintext | VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT | `50` |
|
|
||||||
| Plaintext | VITE_PUBLIC_APP_URL | `https://linearvoid.tv` |
|
|
||||||
| Plaintext | VITE_PUBLIC_HLS_URL | `https://media.linearvoid.tv/hls/playlist.m3u8` |
|
|
||||||
| Plaintext | VITE_PUBLIC_WS_URL | `wss://linearvoid.tv/ws` |
|
|
||||||
|
|
||||||
## Custom domains
|
|
||||||
|
|
||||||
- `linearvoid.tv` → Pages project
|
|
||||||
- `www.linearvoid.tv` → redirect to `linearvoid.tv`
|
|
||||||
- `media.linearvoid.tv` → R2 bucket (separate; not Pages)
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Gitea to Cloudflare Pages deployment guide](./gitea-cloudflare-pages.md)
|
|
||||||
- [Cloudflare & UptimeRobot checklist](./cloudflare-uptimerobot-checklist.md)
|
|
||||||
@@ -5,7 +5,7 @@ Purpose: execute and record all manual dashboard tasks that cannot be automated
|
|||||||
Recommended execution order:
|
Recommended execution order:
|
||||||
|
|
||||||
1. Bind `media.linearvoid.tv` and confirm the HLS origin is separate from the app origin.
|
1. Bind `media.linearvoid.tv` and confirm the HLS origin is separate from the app origin.
|
||||||
2. Enable Pages, Tiered Cache, and cache behavior for the media zone.
|
2. Enable Workers, Tiered Cache, and cache behavior for the media zone.
|
||||||
3. Apply WAF, bot, rate limiting, HTTPS, and HSTS settings.
|
3. Apply WAF, bot, rate limiting, HTTPS, and HSTS settings.
|
||||||
4. Verify monitor targets and alert delivery in UptimeRobot.
|
4. Verify monitor targets and alert delivery in UptimeRobot.
|
||||||
5. Capture evidence and fill in the sign-off block.
|
5. Capture evidence and fill in the sign-off block.
|
||||||
@@ -15,18 +15,18 @@ Recommended execution order:
|
|||||||
- [ ] Pending
|
- [ ] Pending
|
||||||
- [x] Done
|
- [x] Done
|
||||||
|
|
||||||
## 1) Cloudflare Pages Link and Build
|
## 1) Cloudflare Workers Deploy and Custom Domain
|
||||||
|
|
||||||
- [ ] Connect repo to Cloudflare Pages project
|
- [ ] Create a Cloudflare Worker for the SPA and bind the build output
|
||||||
- [ ] Build command set to: `cd frontend && npm run build`
|
- [ ] Build command set to: `cd frontend && npm run build`
|
||||||
- [ ] Output directory set to: `frontend/dist`
|
- [ ] Output directory set to: `frontend/dist`
|
||||||
- [ ] Production domain attached to `linearvoid.tv`
|
- [ ] Production domain attached to `linearvoid.tv`
|
||||||
|
|
||||||
Evidence to capture:
|
Evidence to capture:
|
||||||
|
|
||||||
- Screenshot of Pages deployment settings
|
- Screenshot of Worker deployment settings
|
||||||
- Deployment URL and latest deployment timestamp
|
- Deployment URL and latest deployment timestamp
|
||||||
- Confirmed production build output path in the Pages project
|
- Confirmed production build output path in the Worker
|
||||||
|
|
||||||
## 2) Tiered Cache Enablement and Validation
|
## 2) Tiered Cache Enablement and Validation
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ Evidence to capture:
|
|||||||
|
|
||||||
Completion summary:
|
Completion summary:
|
||||||
|
|
||||||
- Pages build URL:
|
- Worker build URL:
|
||||||
- Cache validation result:
|
- Cache validation result:
|
||||||
- WAF/rate-limit status:
|
- WAF/rate-limit status:
|
||||||
- HTTPS/HSTS status:
|
- HTTPS/HSTS status:
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Cloudflare Workers Setup — Linear Void
|
||||||
|
|
||||||
|
Linear Void uses Cloudflare Workers for two purposes:
|
||||||
|
|
||||||
|
| Product | Purpose | What |
|
||||||
|
| ----------- | ------------------ | ------------------------------------------------------------------------------------ |
|
||||||
|
| **Workers** | Static SPA hosting | Vite-built frontend (`frontend/dist`) served as Worker static assets |
|
||||||
|
| **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` |
|
||||||
|
| 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).
|
||||||
|
|
||||||
|
## Worker: static frontend
|
||||||
|
|
||||||
|
Serve the Vite-built SPA (`frontend/dist`) as Worker static assets:
|
||||||
|
|
||||||
|
1. Build the frontend: `cd frontend && npm run build`.
|
||||||
|
2. Bind `frontend/dist` to the Worker as static assets (Workers Static Assets / `assets` in `wrangler.toml`).
|
||||||
|
3. Attach the `linearvoid.tv` custom domain to the Worker.
|
||||||
|
|
||||||
|
See [gitea-cloudflare-workers.md](./gitea-cloudflare-workers.md) for the automated deployment workflow.
|
||||||
|
|
||||||
|
### Build-time variables
|
||||||
|
|
||||||
|
The `VITE_PUBLIC_*` values are baked into the bundle at build time by Vite (`vite.config.ts` uses `envDir: ".."` to read them from the repo root). They are supplied by the Gitea Actions build environment from Gitea secrets — not as Worker runtime variables. The static-assets Worker has no runtime JavaScript, so it reads no `env` at request time.
|
||||||
|
|
||||||
|
| Name | Value |
|
||||||
|
| ---------------------------------------- | ----------------------------------------------- |
|
||||||
|
| `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` |
|
||||||
|
| `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` |
|
||||||
|
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` |
|
||||||
|
| `VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR` | `boogers` |
|
||||||
|
| `VITE_PUBLIC_ADMIN_DEFAULT_ROOM` | `main` |
|
||||||
|
| `VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT` | `50` |
|
||||||
|
|
||||||
|
Backend services (playout, chat, nginx) read their own runtime variables (`ADMIN_*`, `HLS_*`, `PLAYLIST_PATH`, `R2_*`, `DATABASE_URL`, `REDIS_URL`) from the container environment — see [`.env.example`](../../.env.example).
|
||||||
|
|
||||||
|
## Custom domains
|
||||||
|
|
||||||
|
- `linearvoid.tv` → Worker (static SPA)
|
||||||
|
- `www.linearvoid.tv` → redirect to `linearvoid.tv`
|
||||||
|
- `media.linearvoid.tv` → R2 bucket (separate; not a Worker)
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Gitea to Cloudflare Workers deployment guide](./gitea-cloudflare-workers.md)
|
||||||
|
- [Cloudflare & UptimeRobot checklist](./cloudflare-uptimerobot-checklist.md)
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# Gitea to Cloudflare Pages — Linear Void
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Deploy the Linear Void frontend (Vite + TypeScript SPA) from Gitea to Cloudflare Pages using Gitea Actions.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
> **Important:** The Pages project must exist before the API deploy call works. You'll get error 8000007 ("Project not found") if you skip this step.
|
|
||||||
|
|
||||||
1. **Cloudflare Pages project** created first:
|
|
||||||
- Dashboard → Workers & Pages → Create → Pages → Direct Upload
|
|
||||||
- Name it `linearvoid` (or a different name if `linearvoid` is already taken by a Worker — see note below)
|
|
||||||
- See [Cloudflare Pages Setup](./cloudflare-pages-setup.md) for full details.
|
|
||||||
2. **Project name**: note the exact name (e.g., `linearvoid`).
|
|
||||||
3. **API token** with Pages permissions:
|
|
||||||
- Create at <https://dash.cloudflare.com/profile/api-tokens>
|
|
||||||
- Permission: `Account → Cloudflare Pages → Edit`
|
|
||||||
- Store in Gitea → Settings → Secrets as `CLOUDFLARE_API_TOKEN`
|
|
||||||
4. **Account ID** from Cloudflare dashboard overview → store as `CLOUDFLARE_ACCOUNT_ID`
|
|
||||||
|
|
||||||
> **Naming conflict:** If a Worker already exists with the name `linearvoid`, the Pages project must use a different name (e.g., `linearvoid-site`). Update the `projectName` in the workflow accordingly. Workers and Pages share a namespace under "Workers & Pages".
|
|
||||||
|
|
||||||
## Project-specific values
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
| ------------------ | ---------------------------------------------------------- |
|
|
||||||
| Framework | Vite + TypeScript |
|
|
||||||
| Build command | `cd frontend && npm install && npm run build` |
|
|
||||||
| Output directory | `frontend/dist` |
|
|
||||||
| Node version | 20 |
|
|
||||||
| Multi-page entries | `index.html`, `admin/index.html`, `admin/login/index.html` |
|
|
||||||
|
|
||||||
## Gitea Actions workflow
|
|
||||||
|
|
||||||
Create `.gitea/workflows/pages-deploy.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: Deploy to Cloudflare Pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: cd frontend && npm ci
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: cd frontend && npm run build
|
|
||||||
|
|
||||||
- name: Deploy to Cloudflare Pages
|
|
||||||
uses: cloudflare/pages-action@v1
|
|
||||||
with:
|
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
projectName: linearvoid
|
|
||||||
directory: frontend/dist
|
|
||||||
gitHubToken: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
```
|
|
||||||
|
|
||||||
If the `cloudflare/pages-action@v1` action is unavailable from Gitea, use the direct Cloudflare Pages API (multipart/form-data with manifest):
|
|
||||||
|
|
||||||
Snippet to generate a manifest in format `-F manifest='{"index.html": "abc123", "style.css": "def456"}'`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build manifest form argument from all files in frontend/dist
|
|
||||||
form_args="-F manifest={"
|
|
||||||
for file in $(find frontend/dist -type f | sed 's|^frontend/dist/||'); do
|
|
||||||
hash=$(sha256sum "frontend/dist/$file" | awk '{print $1}')
|
|
||||||
form_args="$form_args\"$file\":\"$hash\","
|
|
||||||
done
|
|
||||||
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
|
||||||
```
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Generate deployment manifest
|
|
||||||
run: |
|
|
||||||
cd frontend/dist
|
|
||||||
form_args="-F manifest={"
|
|
||||||
for file in $(find . -type f ! -name manifest.json | sed 's|^\./||'); do
|
|
||||||
hash=$(sha256sum "$file" | awk '{print $1}')
|
|
||||||
form_args="$form_args\"$file\":\"$hash\","
|
|
||||||
done
|
|
||||||
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
|
||||||
echo "Form arguments: $form_args"
|
|
||||||
echo "FORM_ARGS=$form_args" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Deploy to Cloudflare Pages
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
run: |
|
|
||||||
cd frontend/dist
|
|
||||||
form_args="$FORM_ARGS" # use form arguments generated in previous step
|
|
||||||
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
|
||||||
curl -sS -X POST \
|
|
||||||
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/linearvoid/deployments" \
|
|
||||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
|
||||||
-F "commit_hash=${COMMIT_HASH}" \
|
|
||||||
$form_args
|
|
||||||
```
|
|
||||||
|
|
||||||
This matches the Cloudflare Pages Direct Upload API: `Content-Type: multipart/form-data`, each file as a separate form field, and a `manifest` JSON mapping filenames to SHA-256 hashes.
|
|
||||||
|
|
||||||
## Secrets to add in Gitea
|
|
||||||
|
|
||||||
| Secret | Value |
|
|
||||||
| ----------------------- | ------------------------------------ |
|
|
||||||
| `CLOUDFLARE_API_TOKEN` | API token with Pages:Edit permission |
|
|
||||||
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID from dashboard |
|
|
||||||
|
|
||||||
## Verify deployment
|
|
||||||
|
|
||||||
1. Push to `main` or trigger manually from Gitea Actions.
|
|
||||||
2. Check **Gitea Actions** → job logs for the deploy step.
|
|
||||||
3. Check **Cloudflare Pages** → Deployments for the new deployment.
|
|
||||||
4. Visit the `.pages.dev` preview URL, then promote to `linearvoid.tv` when ready.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The existing `ci.yml` already builds the frontend; the Pages deploy workflow is additive and independent.
|
|
||||||
- Multi-page SPA (`vite.config.ts`) produces three entry points — Cloudflare Pages serves them correctly.
|
|
||||||
- For SPA fallback routing, add a `frontend/public/_redirects` file with `/* /index.html 200` before building.
|
|
||||||
- If deployments fail with 403, verify the API token has `Account → Cloudflare Pages → Edit` permission.
|
|
||||||
- If you get error **8000007** ("Project not found"), the Pages project hasn't been created yet. Create it via the Cloudflare dashboard (Workers & Pages → Create → Pages → Direct Upload) first, then re-run.
|
|
||||||
- If `linearvoid` is already taken by a Worker, use a different project name like `linearvoid-site` and update the `projectName` and `PROJECT_NAME` values in the workflow.
|
|
||||||
|
|
||||||
## Related resources
|
|
||||||
|
|
||||||
- Cloudflare Workers dashboard: `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid/production/settings` — for managing the `infrastructure/cloudflare/worker.js` deployment
|
|
||||||
- Cloudflare Pages project: `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/pages/view/linearvoid` — deployment history and custom domain settings
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Gitea to Cloudflare Workers — Linear Void
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Deploy the Linear Void frontend (Vite + TypeScript SPA) from Gitea to Cloudflare Workers using Gitea Actions and Wrangler.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Cloudflare Worker** for the SPA:
|
||||||
|
- Dashboard → Workers & Pages → Create → Worker (or Workers Static Assets).
|
||||||
|
- Name it `linearvoid-frontend` (the edge-cache Worker already uses the `linearvoid` name).
|
||||||
|
2. **API token** with Workers permissions:
|
||||||
|
- Create at <https://dash.cloudflare.com/profile/api-tokens>
|
||||||
|
- Permission: `Account → Workers Scripts → Edit` and `Account → Workers Routes → Edit`
|
||||||
|
- Store in Gitea → Settings → Secrets as `CLOUDFLARE_API_TOKEN`
|
||||||
|
3. **Account ID** from Cloudflare dashboard overview → store as `CLOUDFLARE_ACCOUNT_ID`
|
||||||
|
4. **Wrangler config** (`frontend/wrangler.toml`) binding the `frontend/dist` output as static assets.
|
||||||
|
|
||||||
|
> **Naming conflict:** Workers share a namespace under "Workers & Pages". The edge-cache Worker (`infrastructure/cloudflare/worker.js`) already uses the `linearvoid` name, so the SPA Worker is named `linearvoid-frontend` (see `frontend/wrangler.toml`).
|
||||||
|
|
||||||
|
## Project-specific values
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
| ------------------ | ---------------------------------------------------------- |
|
||||||
|
| Framework | Vite + TypeScript |
|
||||||
|
| Build command | `cd frontend && npm install && npm run build` |
|
||||||
|
| Output directory | `frontend/dist` |
|
||||||
|
| Node version | 22 |
|
||||||
|
| Multi-page entries | `index.html`, `admin/index.html`, `admin/login/index.html` |
|
||||||
|
|
||||||
|
## Gitea Actions workflow
|
||||||
|
|
||||||
|
Build the SPA and deploy with Wrangler:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Deploy to Cloudflare Workers
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
VITE_PUBLIC_APP_URL: ${{ secrets.VITE_PUBLIC_APP_URL }}
|
||||||
|
VITE_PUBLIC_WS_URL: ${{ secrets.VITE_PUBLIC_WS_URL }}
|
||||||
|
VITE_PUBLIC_HLS_URL: ${{ secrets.VITE_PUBLIC_HLS_URL }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }}
|
||||||
|
VITE_PUBLIC_ADMIN_DEFAULT_ROOM: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }}
|
||||||
|
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT: "50"
|
||||||
|
run: cd frontend && npm run build
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Workers
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npx wrangler deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secrets to add in Gitea
|
||||||
|
|
||||||
|
| Secret | Value |
|
||||||
|
| ------------------------------------- | ------------------------------------ |
|
||||||
|
| `CLOUDFLARE_API_TOKEN` | API token with Workers permissions |
|
||||||
|
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID from dashboard |
|
||||||
|
| `VITE_PUBLIC_APP_URL` | Public viewer URL |
|
||||||
|
| `VITE_PUBLIC_WS_URL` | WebSocket endpoint |
|
||||||
|
| `VITE_PUBLIC_HLS_URL` | HLS playlist URL |
|
||||||
|
| `VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR` | Default moderator username |
|
||||||
|
| `VITE_PUBLIC_ADMIN_DEFAULT_ROOM` | Default chat room |
|
||||||
|
|
||||||
|
## Verify deployment
|
||||||
|
|
||||||
|
1. Push to `main` or trigger manually from Gitea Actions.
|
||||||
|
2. Check **Gitea Actions** → job logs for the deploy step.
|
||||||
|
3. Check **Cloudflare Workers** → `linearvoid-frontend` → Deployments for the new version.
|
||||||
|
4. Visit `https://linearvoid-frontend.zwitschi82.workers.dev`, then attach `linearvoid.tv` when ready.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The existing `ci.yml` already builds the frontend; the Workers deploy workflow is additive and independent.
|
||||||
|
- Multi-page SPA (`vite.config.ts`) produces three entry points — Workers static assets serve them correctly.
|
||||||
|
- For SPA fallback routing, set Workers static assets `not_found_handling` to `single-page-application` (serve `index.html` for unknown paths).
|
||||||
|
- The deploy workflow lives in `.gitea/workflows/workers-deploy.yml` (triggered after CI succeeds).
|
||||||
|
|
||||||
|
## Related resources
|
||||||
|
|
||||||
|
- Cloudflare Workers dashboard: `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid/production/settings` — for managing the `infrastructure/cloudflare/worker.js` deployment and the SPA Worker
|
||||||
+56
-123
@@ -1,130 +1,63 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Linear Void Admin</title>
|
|
||||||
<link rel="stylesheet" href="../src/style.css" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="admin-body">
|
<head>
|
||||||
<div class="admin-shell">
|
<meta charset="UTF-8" />
|
||||||
<aside class="admin-sidebar" aria-label="Primary">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<div class="brand-block">
|
<title>Linear Void Admin</title>
|
||||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<div class="brand-text">Admin</div>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
</div>
|
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="../src/style.css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
<button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button>
|
<body class="admin-body">
|
||||||
|
<div class="admin-shell">
|
||||||
|
<aside class="admin-sidebar" aria-label="Primary">
|
||||||
|
<div class="brand-block">
|
||||||
|
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||||
|
<div class="brand-text">Admin</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical">
|
<button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-dashboard"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger is-active"
|
|
||||||
data-tab="dashboard"
|
|
||||||
aria-controls="tab-dashboard"
|
|
||||||
aria-selected="true"
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-upload"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="upload"
|
|
||||||
aria-controls="tab-upload"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Upload Video
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-playlist"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="playlist"
|
|
||||||
aria-controls="tab-playlist"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Playlist
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-schedule"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="schedule"
|
|
||||||
aria-controls="tab-schedule"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Schedule
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-moderation"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="moderation"
|
|
||||||
aria-controls="tab-moderation"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Moderation
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-chat-log"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="chat-log"
|
|
||||||
aria-controls="tab-chat-log"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Chat Log
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-users"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="users"
|
|
||||||
aria-controls="tab-users"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Users
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-analytics"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="analytics"
|
|
||||||
aria-controls="tab-analytics"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Analytics
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="tab-trigger-system"
|
|
||||||
role="tab"
|
|
||||||
class="tab-trigger"
|
|
||||||
data-tab="system"
|
|
||||||
aria-controls="tab-system"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
System
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="admin-main" id="admin-main" tabindex="-1">
|
<nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical">
|
||||||
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
<button type="button" id="tab-trigger-dashboard" role="tab" class="tab-trigger is-active" data-tab="dashboard" aria-controls="tab-dashboard" aria-selected="true">
|
||||||
</main>
|
Dashboard
|
||||||
</div>
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-upload" role="tab" class="tab-trigger" data-tab="upload" aria-controls="tab-upload" aria-selected="false">
|
||||||
|
Upload Video
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">
|
||||||
|
Playlist
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">
|
||||||
|
Schedule
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">
|
||||||
|
Moderation
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-chat-log" role="tab" class="tab-trigger" data-tab="chat-log" aria-controls="tab-chat-log" aria-selected="false">
|
||||||
|
Chat Log
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">
|
||||||
|
Users
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">
|
||||||
|
Analytics
|
||||||
|
</button>
|
||||||
|
<button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">
|
||||||
|
System
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<script type="module" src="/src/admin.ts"></script>
|
<main class="admin-main" id="admin-main" tabindex="-1">
|
||||||
</body>
|
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
||||||
</html>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/admin.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,65 +1,70 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Linear Void Admin Login</title>
|
|
||||||
<link rel="stylesheet" href="../../src/style.css" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="auth-body">
|
<head>
|
||||||
<main class="auth-shell" aria-label="Admin authentication">
|
<meta charset="UTF-8" />
|
||||||
<section class="auth-card" aria-labelledby="auth-title">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<p class="auth-kicker">Linear Void</p>
|
<title>Linear Void Admin Login</title>
|
||||||
<h1 id="auth-title">Admin Login</h1>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="../../src/style.css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
<form id="login-form" class="form-stack" novalidate>
|
<body class="auth-body">
|
||||||
<label for="username">Username</label>
|
<main class="auth-shell" aria-label="Admin authentication">
|
||||||
<input id="username" type="text" class="text-input" autocomplete="username" required />
|
<section class="auth-card" aria-labelledby="auth-title">
|
||||||
|
<p class="auth-kicker">Linear Void</p>
|
||||||
|
<h1 id="auth-title">Admin Login</h1>
|
||||||
|
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
|
||||||
|
|
||||||
<label for="password">Password</label>
|
<form id="login-form" class="form-stack" novalidate>
|
||||||
<input id="password" type="password" class="text-input" autocomplete="current-password" required />
|
<label for="username">Username</label>
|
||||||
|
<input id="username" type="text" class="text-input" autocomplete="username" required />
|
||||||
|
|
||||||
<button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
|
<label for="password">Password</label>
|
||||||
<p id="status" class="status-line" aria-live="polite"></p>
|
<input id="password" type="password" class="text-input" autocomplete="current-password" required />
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
<button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
|
||||||
const form = document.getElementById("login-form");
|
<p id="status" class="status-line" aria-live="polite"></p>
|
||||||
const username = document.getElementById("username");
|
</form>
|
||||||
const password = document.getElementById("password");
|
</section>
|
||||||
const submit = document.getElementById("submit");
|
</main>
|
||||||
const status = document.getElementById("status");
|
|
||||||
|
|
||||||
form.addEventListener("submit", async (event) => {
|
<script>
|
||||||
event.preventDefault();
|
const form = document.getElementById("login-form");
|
||||||
submit.disabled = true;
|
const username = document.getElementById("username");
|
||||||
status.textContent = "Signing in...";
|
const password = document.getElementById("password");
|
||||||
status.className = "status-line";
|
const submit = document.getElementById("submit");
|
||||||
|
const status = document.getElementById("status");
|
||||||
|
|
||||||
try {
|
form.addEventListener("submit", async (event) => {
|
||||||
const response = await fetch("/api/admin/login", {
|
event.preventDefault();
|
||||||
method: "POST",
|
submit.disabled = true;
|
||||||
headers: { "Content-Type": "application/json" },
|
status.textContent = "Signing in...";
|
||||||
body: JSON.stringify({ username: username.value, password: password.value }),
|
status.className = "status-line";
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
try {
|
||||||
throw new Error(`HTTP ${response.status}`);
|
const response = await fetch("/api/admin/login", {
|
||||||
}
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||||
|
});
|
||||||
|
|
||||||
window.location.href = "/admin/";
|
if (!response.ok) {
|
||||||
} catch (error) {
|
throw new Error(`HTTP ${response.status}`);
|
||||||
console.error("Failed to sign in", error);
|
|
||||||
status.textContent = "Login failed. Check your credentials.";
|
|
||||||
status.className = "status-line status-error";
|
|
||||||
} finally {
|
|
||||||
submit.disabled = false;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
</script>
|
window.location.href = "/admin/";
|
||||||
</body>
|
} catch (error) {
|
||||||
</html>
|
console.error("Failed to sign in", error);
|
||||||
|
status.textContent = "Login failed. Check your credentials.";
|
||||||
|
status.className = "status-line status-error";
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
+110
-70
@@ -1,81 +1,121 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
|
||||||
<link rel="stylesheet" href="/src/style.css" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="lv-body">
|
<head>
|
||||||
<main class="viewer-shell" aria-label="Live player and chat">
|
<meta charset="UTF-8" />
|
||||||
<header class="viewer-header">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<div class="viewer-brand-group">
|
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<div>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<h1 class="viewer-title">Linear Void</h1>
|
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
</div>
|
</head>
|
||||||
|
|
||||||
|
<body class="lv-body">
|
||||||
|
<main class="viewer-shell" aria-label="Live player and chat">
|
||||||
|
<header class="viewer-header">
|
||||||
|
<div class="viewer-brand-group">
|
||||||
|
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="viewer-title">Linear Void</h1>
|
||||||
|
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</div>
|
||||||
id="chat-toggle"
|
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">
|
||||||
type="button"
|
Hide Chat
|
||||||
class="btn btn-secondary chat-toggle-btn"
|
</button>
|
||||||
onclick="toggleChatPanel()"
|
</header>
|
||||||
aria-controls="chat-panel"
|
|
||||||
aria-expanded="true"
|
|
||||||
>
|
|
||||||
Hide Chat
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="viewer-content" aria-label="Live stream and chat layout">
|
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||||
<section class="player-stage" aria-label="Video stage">
|
<section class="player-stage" aria-label="Video 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" class="now-playing-overlay" aria-live="polite">
|
<div id="now-playing" class="now-playing-overlay" 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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||||
|
<header class="chat-header">
|
||||||
|
<div class="chat-room-title">
|
||||||
|
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||||
|
<span id="room-name">Main Channel</span>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
|
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div id="presence-list" class="presence-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
<div class="chat-input-area">
|
||||||
<header class="chat-header">
|
<div class="chat-input-row">
|
||||||
<div class="chat-room-title">
|
<label for="message-input" class="sr-only">Message input</label>
|
||||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if (event.key === 'Enter') sendMessage();" />
|
||||||
<span id="room-name">Main Channel</span>
|
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
|
||||||
|
|
||||||
<section id="presence-panel" class="presence-panel" aria-label="Online users">
|
|
||||||
<div id="presence-list" class="presence-list"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="chat-input-area">
|
|
||||||
<div class="chat-input-row">
|
|
||||||
<label for="message-input" class="sr-only">Message input</label>
|
|
||||||
<input
|
|
||||||
id="message-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Type a message..."
|
|
||||||
class="input"
|
|
||||||
onkeydown="if (event.key === 'Enter') sendMessage();"
|
|
||||||
/>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
|
||||||
</div>
|
|
||||||
<p class="chat-note">Synced with video • Moderation active</p>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
<p class="chat-note">Synced with video • Moderation active</p>
|
||||||
</section>
|
</div>
|
||||||
</main>
|
</aside>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
||||||
<div class="reconnecting-spinner"></div>
|
<div class="reconnecting-spinner"></div>
|
||||||
<span id="reconnecting-text">Reconnecting...</span>
|
<span id="reconnecting-text">Reconnecting...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script>
|
||||||
</body>
|
(function () {
|
||||||
</html>
|
// Load Matomo analytics when configured via the admin runtime config.
|
||||||
|
fetch("/api/matomo")
|
||||||
|
.then(function (r) {
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
})
|
||||||
|
.then(function (cfg) {
|
||||||
|
if (!cfg || !cfg.matomoUrl || !cfg.matomoSiteId) return;
|
||||||
|
var _paq = (window._paq = window._paq || []);
|
||||||
|
_paq.push(["trackPageView"]);
|
||||||
|
_paq.push(["enableLinkTracking"]);
|
||||||
|
var base = String(cfg.matomoUrl).replace(/\/+$/, "");
|
||||||
|
_paq.push(["setTrackerUrl", base + "/matomo.php"]);
|
||||||
|
_paq.push(["setSiteId", String(cfg.matomoSiteId)]);
|
||||||
|
var d = document;
|
||||||
|
var g = d.createElement("script");
|
||||||
|
var s = d.getElementsByTagName("script")[0];
|
||||||
|
g.async = true;
|
||||||
|
g.src = base + "/matomo.js";
|
||||||
|
s.parentNode.insertBefore(g, s);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
/* analytics is optional */
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.12",
|
"version": "1.0.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0"
|
"node": ">=22.0.0"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table">
|
<table class="data-table chat-log-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><input type="checkbox" id="chat-log-select-all" title="Select all" /></th>
|
<th><input type="checkbox" id="chat-log-select-all" title="Select all" /></th>
|
||||||
@@ -43,4 +43,4 @@
|
|||||||
<button id="chat-log-next-btn" type="button" class="btn btn-secondary" disabled>Next</button>
|
<button id="chat-log-next-btn" type="button" class="btn btn-secondary" disabled>Next</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="chat-log-status" class="status-line" aria-live="polite">Loading chat log...</p>
|
<p id="chat-log-status" class="status-line" aria-live="polite">Loading chat log...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,8 +56,13 @@
|
|||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<h2>Runtime Configuration</h2>
|
<h2>Runtime Configuration</h2>
|
||||||
<form id="config-form" class="form-stack" style="max-width: 400px">
|
<form id="config-form" class="form-stack" style="max-width: 400px">
|
||||||
|
<label>HLS Segment Duration (seconds) <input id="config-hls-duration" type="number" min="2" max="15" class="text-input" /></label>
|
||||||
|
<label>HLS Window Size (segments) <input id="config-hls-window" type="number" min="3" max="100" class="text-input" /></label>
|
||||||
|
<label>Matomo URL <input id="config-matomo-url" type="url" class="text-input" placeholder="https://..." /></label>
|
||||||
|
<label>Matomo Site ID <input id="config-matomo-site-id" type="text" class="text-input" /></label>
|
||||||
<label>Rate Limit Max <input id="config-rate-max" type="number" class="text-input" /></label>
|
<label>Rate Limit Max <input id="config-rate-max" type="number" class="text-input" /></label>
|
||||||
<label>Rate Limit Window (seconds) <input id="config-rate-window" type="number" class="text-input" /></label>
|
<label>Rate Limit Window (seconds) <input id="config-rate-window" type="number" class="text-input" /></label>
|
||||||
|
<p class="small-muted">HLS changes take effect after restarting the playout service.</p>
|
||||||
<button type="submit" class="btn btn-primary">Save Config</button>
|
<button type="submit" class="btn btn-primary">Save Config</button>
|
||||||
</form>
|
</form>
|
||||||
<p id="config-status" class="status-line" aria-live="polite"></p>
|
<p id="config-status" class="status-line" aria-live="polite"></p>
|
||||||
|
|||||||
@@ -326,3 +326,53 @@ describe("admin responsive layout", () => {
|
|||||||
expect(tabs).toContain("Dashboard");
|
expect(tabs).toContain("Dashboard");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("admin data refresh failures", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="overview-status"></div>
|
||||||
|
<div id="analytics-status"></div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets error status when overview fails", async () => {
|
||||||
|
mockFetchResponse = { ok: false, status: 500, payload: {} };
|
||||||
|
await refreshOverview();
|
||||||
|
expect(document.getElementById("overview-status")?.textContent).toContain("Failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets error status when analytics fails", async () => {
|
||||||
|
mockFetchResponse = { ok: false, status: 500, payload: {} };
|
||||||
|
await refreshAnalytics();
|
||||||
|
expect(document.getElementById("analytics-status")?.textContent).toContain("Failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("playlist status badges", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `<ul id="playlist-sortable"></ul>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders badges for every media status", () => {
|
||||||
|
const statusMap = new Map<string, string>([
|
||||||
|
["/app/media/ready.mp4", "ready"],
|
||||||
|
["/app/media/needs.mp4", "needs_normalization"],
|
||||||
|
["/app/media/normalizing.mp4", "normalizing"],
|
||||||
|
["/app/media/failed.mp4", "normalization_failed"],
|
||||||
|
["/app/media/other.mp4", "weird"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderPlaylistSortable(
|
||||||
|
"file '/app/media/ready.mp4'\nfile '/app/media/needs.mp4'\nfile '/app/media/normalizing.mp4'\nfile '/app/media/failed.mp4'\nfile '/app/media/other.mp4'\nfile '/app/media/nopath.mp4'\n",
|
||||||
|
statusMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = document.getElementById("playlist-sortable")!;
|
||||||
|
expect(list.innerHTML).toContain("badge-ok");
|
||||||
|
expect(list.innerHTML).toContain("badge-warn");
|
||||||
|
expect(list.innerHTML).toContain("badge-info");
|
||||||
|
expect(list.innerHTML).toContain("badge-danger");
|
||||||
|
expect(list.innerHTML).toContain("badge-muted"); // default + unknown fall back to muted
|
||||||
|
expect(list.children).toHaveLength(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
+183
-19
@@ -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,19 +608,9 @@ async function refreshSystemSummary() {
|
|||||||
servicesEl.innerHTML = "";
|
servicesEl.innerHTML = "";
|
||||||
const services = (payload.services || []) as ServiceHealth[];
|
const services = (payload.services || []) as ServiceHealth[];
|
||||||
services.forEach((service) => {
|
services.forEach((service) => {
|
||||||
const row = document.createElement("article");
|
renderServiceRow(service).then((row) => {
|
||||||
row.className = "service-row";
|
servicesEl.appendChild(row);
|
||||||
row.innerHTML = `
|
});
|
||||||
<div>
|
|
||||||
<h3>${service.name}</h3>
|
|
||||||
<p class="small-muted">${service.checked}</p>
|
|
||||||
</div>
|
|
||||||
<div class="service-row-right">
|
|
||||||
<p class="service-status service-status-${service.status}">${service.status}</p>
|
|
||||||
<p>${service.detail}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
servicesEl.appendChild(row);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,19 +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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+174
-1
@@ -1,6 +1,12 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { resetViewerStateForTests, resolveManifestUrl, resolveWebSocketBaseUrl } from "../src/main";
|
import {
|
||||||
|
resetViewerStateForTests,
|
||||||
|
resolveManifestUrl,
|
||||||
|
resolveWebSocketBaseUrl,
|
||||||
|
showReconnecting,
|
||||||
|
hideReconnecting,
|
||||||
|
} from "../src/main";
|
||||||
|
|
||||||
const hlsInstance = {
|
const hlsInstance = {
|
||||||
loadSource: vi.fn(),
|
loadSource: vi.fn(),
|
||||||
@@ -99,6 +105,7 @@ beforeEach(() => {
|
|||||||
<span id="room-name">Main Channel</span>
|
<span id="room-name">Main Channel</span>
|
||||||
<button type="button" onclick="togglePrivateDM()">New DM</button>
|
<button type="button" onclick="togglePrivateDM()">New DM</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
|
||||||
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
||||||
<section id="presence-panel" class="presence-panel">
|
<section id="presence-panel" class="presence-panel">
|
||||||
<div id="presence-list" class="presence-list"></div>
|
<div id="presence-list" class="presence-list"></div>
|
||||||
@@ -114,6 +121,22 @@ beforeEach(() => {
|
|||||||
<div id="reconnecting-overlay" role="alert" aria-live="assertive" hidden>
|
<div id="reconnecting-overlay" role="alert" aria-live="assertive" hidden>
|
||||||
<span id="reconnecting-text">Reconnecting...</span>
|
<span id="reconnecting-text">Reconnecting...</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="chat-user-label" hidden></div>
|
||||||
|
<button id="auth-toggle-btn" type="button">Sign in</button>
|
||||||
|
<button id="auth-logout-btn" type="button" hidden>Logout</button>
|
||||||
|
<div id="auth-panel" 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
|
||||||
@@ -230,6 +253,25 @@ describe("WebSocket reconnect", () => {
|
|||||||
expect(websocketInstances).toHaveLength(2);
|
expect(websocketInstances).toHaveLength(2);
|
||||||
expect(websocketInstances[1].url).toContain("room=lobby");
|
expect(websocketInstances[1].url).toContain("room=lobby");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not reconnect from a socket that was intentionally replaced", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
window.switchRoom("lobby");
|
||||||
|
expect(websocketInstances).toHaveLength(1);
|
||||||
|
const replaced = websocketInstances[0];
|
||||||
|
|
||||||
|
// A room switch intentionally replaces the socket; the old socket must not
|
||||||
|
// schedule its own reconnect, otherwise history is re-pulled on a loop.
|
||||||
|
window.switchRoom("main");
|
||||||
|
expect(websocketInstances).toHaveLength(2);
|
||||||
|
expect(replaced.onclose).toBeNull();
|
||||||
|
|
||||||
|
replaced.onclose?.();
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
|
||||||
|
expect(websocketInstances).toHaveLength(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("URL resolution", () => {
|
describe("URL resolution", () => {
|
||||||
@@ -359,6 +401,117 @@ describe("message rendering", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reconnecting overlay", () => {
|
||||||
|
it("shows and hides the overlay with a custom message", () => {
|
||||||
|
showReconnecting("Custom message");
|
||||||
|
const overlay = document.getElementById("reconnecting-overlay")!;
|
||||||
|
expect(overlay.classList.contains("hidden")).toBe(false);
|
||||||
|
expect(document.getElementById("reconnecting-text")!.textContent).toBe("Custom message");
|
||||||
|
|
||||||
|
hideReconnecting();
|
||||||
|
expect(overlay.classList.contains("hidden")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("typing indicator", () => {
|
||||||
|
it("shows typing on a typing event and clears on the next message", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "alice" }) });
|
||||||
|
|
||||||
|
const indicator = document.getElementById("typing-indicator")!;
|
||||||
|
expect(indicator.textContent).toBe("alice is typing...");
|
||||||
|
expect(indicator.hidden).toBe(false);
|
||||||
|
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "public", text: "hi", username: "alice", timestamp: Date.now() }) });
|
||||||
|
expect(indicator.hidden).toBe(true);
|
||||||
|
expect(indicator.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores typing events from the current user", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "viewer" }) });
|
||||||
|
|
||||||
|
const indicator = document.getElementById("typing-indicator")!;
|
||||||
|
expect(indicator.textContent).toBe("");
|
||||||
|
expect(indicator.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendMessage over websocket", () => {
|
||||||
|
it("sends a JSON message and clears the input", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||||
|
input.value = "hello world";
|
||||||
|
window.sendMessage();
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ws.send).toHaveBeenCalledWith(expect.stringContaining("hello world"));
|
||||||
|
expect(input.value).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth 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", () => {
|
describe("player error recovery", () => {
|
||||||
it("shows reconnecting overlay on fatal network error", async () => {
|
it("shows reconnecting overlay on fatal network error", async () => {
|
||||||
const main = await import("../src/main");
|
const main = await import("../src/main");
|
||||||
@@ -448,3 +601,23 @@ describe("responsive layout", () => {
|
|||||||
expect(np?.getAttribute("aria-live")).toBe("polite");
|
expect(np?.getAttribute("aria-live")).toBe("polite");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("presence flash", () => {
|
||||||
|
it("flashes join/leave messages instead of appending chat rows", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||||
|
|
||||||
|
const flash = document.getElementById("chat-flash")!;
|
||||||
|
expect(flash.textContent).toBe("alice joined");
|
||||||
|
expect(flash.hidden).toBe(false);
|
||||||
|
|
||||||
|
// Join/leave must not be rendered as chat rows.
|
||||||
|
const messages = document.getElementById("messages")!;
|
||||||
|
expect(messages.textContent).not.toContain("joined");
|
||||||
|
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) });
|
||||||
|
expect(flash.textContent).toBe("alice left");
|
||||||
|
});
|
||||||
|
});
|
||||||
+239
-10
@@ -15,7 +15,15 @@ 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;
|
||||||
|
let flashTimeout: number | undefined;
|
||||||
|
let flashFadeTimeout: number | undefined;
|
||||||
const onlineUsers = new Set<string>();
|
const onlineUsers = new Set<string>();
|
||||||
|
|
||||||
// Player error recovery state (STREAMING.md contract)
|
// Player error recovery state (STREAMING.md contract)
|
||||||
@@ -28,7 +36,11 @@ export function resetViewerStateForTests() {
|
|||||||
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;
|
playerErrorCount = 0;
|
||||||
}
|
}
|
||||||
@@ -67,44 +79,67 @@ export function resolveManifestUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connectWebSocket(room: string) {
|
function connectWebSocket(room: string) {
|
||||||
if (ws) ws.close();
|
// Detach the old socket's close handler before closing it. Otherwise an
|
||||||
|
// intentional reconnect (login/logout/room switch/session refresh) would
|
||||||
|
// have the replaced socket schedule yet another reconnect, and each new
|
||||||
|
// connection re-pulls the room's history — filling the panel with old
|
||||||
|
// messages on a loop.
|
||||||
|
if (ws) {
|
||||||
|
ws.onclose = null;
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
const socketUrl = new URL(resolveWebSocketBaseUrl());
|
const socketUrl = new URL(resolveWebSocketBaseUrl());
|
||||||
socketUrl.searchParams.set("username", username);
|
socketUrl.searchParams.set("username", username);
|
||||||
socketUrl.searchParams.set("room", room);
|
socketUrl.searchParams.set("room", room);
|
||||||
ws = new WebSocket(socketUrl);
|
const socket = new WebSocket(socketUrl);
|
||||||
|
ws = socket;
|
||||||
|
|
||||||
ws.onopen = () => {
|
socket.onopen = () => {
|
||||||
console.log(`Connected to chat room: ${room}`);
|
console.log(`Connected to chat room: ${room}`);
|
||||||
addMessage("system", "Connected to " + room);
|
addMessage("system", "Connected to " + room);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
if (msg.type === "typing") {
|
||||||
|
showTyping(msg.username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideTyping(msg.username);
|
||||||
|
|
||||||
// Track presence from system join/leave messages
|
// Presence join/leave announcements become transient flashes, not chat rows.
|
||||||
if (msg.type === "system" && msg.username === "system") {
|
if (msg.type === "system" && msg.username === "system") {
|
||||||
const joined = msg.text?.match(/^(\S+) joined /);
|
const joined = msg.text?.match(/^(\S+) joined /);
|
||||||
const left = msg.text?.match(/^(\S+) left /);
|
const left = msg.text?.match(/^(\S+) left /);
|
||||||
if (joined) {
|
if (joined) {
|
||||||
onlineUsers.add(joined[1]);
|
onlineUsers.add(joined[1]);
|
||||||
renderPresence();
|
renderPresence();
|
||||||
|
showFlash(`${joined[1]} joined`);
|
||||||
|
return;
|
||||||
} else if (left) {
|
} else if (left) {
|
||||||
onlineUsers.delete(left[1]);
|
onlineUsers.delete(left[1]);
|
||||||
renderPresence();
|
renderPresence();
|
||||||
|
showFlash(`${left[1]} left`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
socket.onclose = () => {
|
||||||
|
// Ignore close events from sockets that were intentionally replaced.
|
||||||
|
if (ws !== socket) return;
|
||||||
|
ws = null;
|
||||||
console.log("Chat disconnected. Reconnecting in 3s...");
|
console.log("Chat disconnected. Reconnecting in 3s...");
|
||||||
setTimeout(() => connectWebSocket(room), 3000);
|
setTimeout(() => connectWebSocket(room), 3000);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
|
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
|
||||||
const messages = document.getElementById("messages")!;
|
const messages = document.getElementById("messages");
|
||||||
|
if (!messages) return;
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "message-row";
|
div.className = "message-row";
|
||||||
|
|
||||||
@@ -126,6 +161,23 @@ function addMessage(type: string, text: string, sender = "", timestamp = new Dat
|
|||||||
messages.scrollTop = messages.scrollHeight;
|
messages.scrollTop = messages.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showFlash(text: string) {
|
||||||
|
const el = document.getElementById("chat-flash");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text;
|
||||||
|
el.hidden = false;
|
||||||
|
el.classList.remove("fade-out");
|
||||||
|
window.clearTimeout(flashTimeout);
|
||||||
|
window.clearTimeout(flashFadeTimeout);
|
||||||
|
flashTimeout = window.setTimeout(() => {
|
||||||
|
el.classList.add("fade-out");
|
||||||
|
flashFadeTimeout = window.setTimeout(() => {
|
||||||
|
el.hidden = true;
|
||||||
|
el.classList.remove("fade-out");
|
||||||
|
}, 500);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
function renderPresence() {
|
function renderPresence() {
|
||||||
const list = document.getElementById("presence-list");
|
const list = document.getElementById("presence-list");
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
@@ -137,7 +189,8 @@ function renderPresence() {
|
|||||||
|
|
||||||
async function refreshSchedule() {
|
async function refreshSchedule() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/schedule");
|
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||||
|
const res = await fetch(`${apiUrl}/api/schedule`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const entries = data.entries as Array<{ title: string; status: string }>;
|
const entries = data.entries as Array<{ title: string; status: string }>;
|
||||||
@@ -183,7 +236,9 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
|
|||||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
if (playerErrorCount <= PLAYER_MAX_RETRIES) {
|
if (playerErrorCount <= PLAYER_MAX_RETRIES) {
|
||||||
const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1);
|
const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1);
|
||||||
console.warn(`HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`);
|
console.warn(
|
||||||
|
`HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`,
|
||||||
|
);
|
||||||
showReconnecting("Reconnecting...");
|
showReconnecting("Reconnecting...");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
hls?.startLoad();
|
hls?.startLoad();
|
||||||
@@ -282,25 +337,199 @@ 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 apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||||
|
const input = document.getElementById("dm-search-input") as HTMLInputElement;
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (!q || !currentDMTarget) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${apiUrl}/api/dm/search?with=${encodeURIComponent(currentDMTarget)}&q=${encodeURIComponent(q)}`,
|
||||||
|
{ credentials: "include" },
|
||||||
|
);
|
||||||
|
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 apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||||
|
const res = await fetch(`${apiUrl}/api/auth/session`, { credentials: "include" });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
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 apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||||
|
const res = await fetch(`${apiUrl}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: u, password: p }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = (await res.text()).trim();
|
||||||
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
username = data.username || u;
|
||||||
|
authenticated = true;
|
||||||
|
updateAuthUI();
|
||||||
|
if (status) {
|
||||||
|
status.textContent = `Signed in as ${username}.`;
|
||||||
|
status.className = "status-line";
|
||||||
|
}
|
||||||
|
connectWebSocket(currentRoom);
|
||||||
|
} catch (err) {
|
||||||
|
if (status) {
|
||||||
|
status.textContent = `Failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||||
|
status.className = "status-line status-error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
void submitAuth("/api/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
function register() {
|
||||||
|
void submitAuth("/api/auth/register");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||||
|
await fetch(`${apiUrl}/api/auth/logout`, { method: "POST", credentials: "include" });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
authenticated = false;
|
||||||
|
username = "viewer" + Math.floor(Math.random() * 1000);
|
||||||
|
updateAuthUI();
|
||||||
|
connectWebSocket(currentRoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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);
|
||||||
});
|
});
|
||||||
+115
-6
@@ -1,7 +1,8 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
--font-body: "Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
|
--font-body: "Tektur", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||||
|
--font-heading: "Doto", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||||
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
|
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ h1,
|
|||||||
h2,
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-heading);
|
||||||
color: var(--ink-0);
|
color: var(--ink-0);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
@@ -253,6 +254,101 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-flash {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
z-index: 5;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-0);
|
||||||
|
background: color-mix(in srgb, var(--brand) 30%, var(--panel-2));
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-1);
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-flash.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-1);
|
border-radius: var(--radius-1);
|
||||||
@@ -350,6 +446,10 @@ select {
|
|||||||
color: var(--ink-1);
|
color: var(--ink-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-log-filters input {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -376,6 +476,11 @@ select {
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#chat-log-table-body td {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--ink-0);
|
||||||
|
}
|
||||||
|
|
||||||
.action-cell {
|
.action-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -466,7 +571,7 @@ select {
|
|||||||
|
|
||||||
.brand-text,
|
.brand-text,
|
||||||
.viewer-title {
|
.viewer-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-title {
|
.viewer-title {
|
||||||
@@ -567,6 +672,7 @@ select {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header,
|
.chat-header,
|
||||||
@@ -661,12 +767,12 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-system {
|
.message-system {
|
||||||
font-size: 0.83rem;
|
font-size: 0.7rem;
|
||||||
color: var(--ink-1);
|
color: var(--ink-1);
|
||||||
background: color-mix(in srgb, var(--brand) 22%, var(--panel-2));
|
background: color-mix(in srgb, var(--brand) 22%, var(--panel-2));
|
||||||
border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line));
|
border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line));
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0.24rem 0.68rem;
|
padding: 0.2rem 0.5rem;
|
||||||
animation: messageIn var(--fast);
|
animation: messageIn var(--fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,6 +784,7 @@ select {
|
|||||||
|
|
||||||
.message-text {
|
.message-text {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-time {
|
.message-time {
|
||||||
@@ -1085,7 +1192,9 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes lv-spin {
|
@keyframes lv-spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#reconnecting-text {
|
#reconnecting-text {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
name = "linearvoid-frontend"
|
||||||
|
compatibility_date = "2025-08-01"
|
||||||
|
|
||||||
|
[assets]
|
||||||
|
directory = "./dist"
|
||||||
|
not_found_handling = "single-page-application"
|
||||||
@@ -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
Reference in new issue
Block a user