15 Commits
Author SHA1 Message Date
zwitschi cef002862e feat(chat): add transient presence flash and 5s reconnect grace period
CI / test (push) Successful in 1m33s
CI / docker (push) Successful in 2m10s
2026-08-13 20:45:52 +02:00
zwitschi 4eb88a4594 fix(frontend): reduce system message font size and padding
CI / test (push) Successful in 1m38s
CI / docker (push) Successful in 30s
2026-08-13 18:54:25 +02:00
zwitschi 9cf3956c28 fix(frontend): add chat log table class and compact filter/table styles 2026-08-13 18:52:36 +02:00
zwitschi a15a9f2df0 refactor(frontend): standardize HTML formatting and update font families
CI / test (push) Successful in 1m42s
CI / docker (push) Successful in 32s
2026-08-13 18:40:03 +02:00
zwitschi 76397d52d7 fix(frontend): restore chat DOM structure and guard addMessage against null
CI / test (push) Successful in 1m39s
CI / docker (push) Successful in 49s
2026-08-13 14:57:18 +02:00
zwitschi 70a5159f81 fix(backend): serialize SQLite test connections and await async normalize
CI / test (push) Successful in 1m44s
CI / docker (push) Successful in 2m45s
2026-08-13 13:40:03 +02:00
zwitschi 568112b85a test(frontend): simplify fetch mock signatures in auth tests
CI / test (push) Failing after 2m49s
CI / docker (push) Skipped
2026-08-13 13:24:56 +02:00
zwitschi 02ff53229c test(frontend): expand admin and viewer test coverage for auth, typing, reconnect, and playlist badges 2026-08-13 13:22:08 +02:00
zwitschi 68a4d35334 chore: update todo list 2026-08-13 13:14:39 +02:00
zwitschi 7afab6658b chore: bump version to 1.0.13
CI / test (push) Failing after 13s
CI / docker (push) Skipped
2026-08-13 13:12:35 +02:00
zwitschi 121fdbacdc feat: runtime config, Matomo analytics, chat accounts, DM history/search, and Prometheus retention 2026-08-13 13:12:35 +02:00
zwitschi 06ab5b4655 feat: add automatic media normalization, R2 retention cleanup, and seamless playout loops 2026-08-13 11:59:46 +02:00
zwitschi e15d89edfe fix(playout): differentiate FFmpeg crashes from clean exits and enrich health metrics
CI / test (push) Successful in 1m44s
CI / docker (push) Successful in 56s
2026-08-12 14:26:18 +02:00
zwitschi 044851a43e Add Grafana dashboards for monitoring various services
- Created a new dashboard for NGINX monitoring with panels for status, active connections, request rates, and connection drop ratio.
- Added a Node dashboard to monitor CPU usage, memory usage, disk I/O, and network traffic.
- Introduced a Playout dashboard to track service status, mode, FFmpeg restarts, and scrape duration.
- Implemented a PostgreSQL dashboard featuring active connections, database size, transaction rates, and cache hit ratio.
- Developed a Prometheus dashboard to visualize scrape duration, targets status, and rule evaluation metrics.
- Established a Redis dashboard to monitor connected clients, memory usage, command latency, and keyspace statistics.
2026-08-12 13:55:23 +02:00
zwitschi 926d70dc3c fix: rename R2 object key prefix from live/ to hls/ for consistency
CI / test (push) Successful in 1m37s
CI / docker (push) Successful in 48s
2026-08-11 17:21:53 +02:00
55 changed files with 13747 additions and 928 deletions

No files matched your search

+8 -2
View File
@@ -8,6 +8,7 @@ R2_SECRET_ACCESS_KEY=your-r2-secret-key
R2_BUCKET_NAME=linearvoid-media R2_BUCKET_NAME=linearvoid-media
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
R2_REGION=auto R2_REGION=auto
R2_SEGMENT_RETENTION_HOURS=24
# Cloudflare (for custom domain, cache rules, Workers if used) # Cloudflare (for custom domain, cache rules, Workers if used)
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
@@ -53,8 +54,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
+18
View File
@@ -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 (`cleanupOldSegments`, `R2_SEGMENT_RETENTION_HOURS`); 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/*`.
+1 -79
View File
@@ -1,34 +1,14 @@
# 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
- **User Frontend Tests**: video player, chat integration, responsive, accessibility, coverage
- **Admin Frontend Tests**: playlist UI, moderation UI, metrics dashboards, coverage
- **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.
- **External Services**: UptimeRobot monitors + alerts
- **Production Deployment**: Hetzner provisioning, CD pipeline, DNS cutover, smoke test
- **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
## Cloudflare Configuration ## Cloudflare Configuration
### DNS & Pages ### DNS & Pages
- [x] Configure DNS for linearvoid.tv apex and www redirect (manual)
- [x] Configure media.linearvoid.tv for split-domain HLS delivery (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) - [ ] Cloudflare Pages: link custom domain (manual)
- [ ] Create Cloudflare Pages project and cut over frontend hosting (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
- [ ] Enable Tiered Cache, WAF, rate limiting (manual) - [ ] Enable Tiered Cache, WAF, rate limiting (manual)
@@ -40,13 +20,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,44 +27,16 @@
- [ ] 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 ## Frontend Tests
### User Frontend ### 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 - [ ] Test coverage for all critical frontend components and interactions
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline - [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
- [ ] Document test cases and expected outcomes - [ ] Document test cases and expected outcomes
### Admin Frontend ### 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 - [ ] Test coverage for all critical admin UI components and interactions
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline - [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
- [ ] Document test cases and expected outcomes - [ ] Document test cases and expected outcomes
@@ -100,29 +45,9 @@
### Cloudflare R2 ### 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 - [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
- [ ] Validate cache hit/miss behavior and performance metrics - [ ] Validate cache hit/miss behavior and performance metrics
- [ ] Validate edge cache hit/miss behavior with repeat requests — script `scripts/validate-edge-cache.sh` created - [ ] 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
@@ -162,9 +87,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
+265 -81
View File
@@ -1,77 +1,77 @@
# DONE # DONE
Completed tasks from `.github/instructions/TODO.md`. Historical record for CHANGELOG.md, README.md, architecture docs. > Completed tasks from [TODO](./TODO.md). Historical record for [CHANGELOG](./CHANGELOG.md), [README](./README.md), [architecture docs](./architecture/README.md).
## Session 2026-08-07 — Unit Tests ## Session 2026-08-07 — Unit Tests
- [x] Chat service refactor: `main.go` split into 8 packages (config, auth, db, handlers, ws, media, ratelimit, metrics) - Chat service refactor: `main.go` split into 8 packages (config, auth, db, handlers, ws, media, ratelimit, metrics)
- [x] `db.RedisClient` + `db.PubSub` interfaces defined for mocking - `db.RedisClient` + `db.PubSub` interfaces defined for mocking
- [x] Config tests: 10 env-var tests, table-driven, 100% coverage - Config tests: 10 env-var tests, table-driven, 100% coverage
- [x] Auth tests: token generation, session CRUD, cookie security, middleware, 97.8% coverage - Auth tests: token generation, session CRUD, cookie security, middleware, 97.8% coverage
- [x] DB tests: SQLite in-memory CRUD for all 4 models, 14.3% coverage - DB tests: SQLite in-memory CRUD for all 4 models, 14.3% coverage
- [x] Handlers tests: 35 tests covering login/logout/session/overview/playlist/moderation/analytics/system/config/users/schedule/media/chat-log, 47.2% coverage - Handlers tests: 35 tests covering login/logout/session/overview/playlist/moderation/analytics/system/config/users/schedule/media/chat-log, 47.2% coverage
- [x] WebSocket tests: 15 tests covering connect/send/dm/moderation/banned/muted/history/presence/pubsub, 79.6% coverage - WebSocket tests: 15 tests covering connect/send/dm/moderation/banned/muted/history/presence/pubsub, 79.6% coverage
- [x] Media tests: formatDuration, extensions, mime, upload IDs, playlist parsing, 36.8% coverage - Media tests: formatDuration, extensions, mime, upload IDs, playlist parsing, 36.8% coverage
- [x] Ratelimit tests: message/login/WS rate limits with mock Redis, 90.3% coverage - Ratelimit tests: message/login/WS rate limits with mock Redis, 90.3% coverage
- [x] Metrics tests: Prometheus output validation, all 9 metrics present, 100% coverage - Metrics tests: Prometheus output validation, all 9 metrics present, 100% coverage
- [x] Playout tests: 12 tests (LoadPlaylist, keyframeInterval, HealthHandler, slate escalation, reset/activate/deactivate) - Playout tests: 12 tests (LoadPlaylist, keyframeInterval, HealthHandler, slate escalation, reset/activate/deactivate)
- [x] Frontend test framework: vitest + jsdom installed - Frontend test framework: vitest + jsdom installed
- [x] User frontend smoke tests: 6 tests (chat toggle, sendMessage, switchRoom, DM toggle) - User frontend smoke tests: 6 tests (chat toggle, sendMessage, switchRoom, DM toggle)
- [x] Admin frontend smoke tests: 5 tests (DOM structure, upload progress, fingerprint, constants, import stability) - Admin frontend smoke tests: 5 tests (DOM structure, upload progress, fingerprint, constants, import stability)
- [x] CI pipeline updated: `go test -race -cover` with 50% threshold - CI pipeline updated: `go test -race -cover` with 50% threshold
- [x] SQLite driver switched to pure-Go `github.com/glebarez/sqlite` (CGO-free) - SQLite driver switched to pure-Go `github.com/glebarez/sqlite` (CGO-free)
- [x] Total: 78 tests across Go + frontend, all passing - Total: 78 tests across Go + frontend, all passing
## Session 2026-08-07 — Runtime Validation ## Session 2026-08-07 — Runtime Validation
- [x] Check `monitoring/prometheus/prometheus.yml` for completeness - Check `monitoring/prometheus/prometheus.yml` for completeness
- [x] Make sure all 7 scrape targets are included (chat, playout, nginx, redis, postgres, node, prometheus) - Make sure all 7 scrape targets are included (chat, playout, nginx, redis, postgres, node, prometheus)
- [x] Check `monitoring/prometheus/alerts.yml` for completeness - Check `monitoring/prometheus/alerts.yml` for completeness
- [x] Make sure configuration for prometheus is mounted into container via named volume - Make sure configuration for prometheus is mounted into container via named volume
- [x] Use folder `monitoring/grafana/` to create ini file for Grafana, set `GF_PATHS_CONFIG` environment variable to point to a different location than `/etc/grafana/grafana.ini` if needed - Use folder `monitoring/grafana/` to create ini file for Grafana, set `GF_PATHS_CONFIG` environment variable to point to a different location than `/etc/grafana/grafana.ini` if needed
- [x] Set default password from `GRAFANA_PASSWORD` env var, disable user signups - Set default password from `GRAFANA_PASSWORD` env var, disable user signups
- [x] Configure Prometheus data source in Grafana ini file - Configure Prometheus data source in Grafana ini file
- [x] Include dashboards from `monitoring/grafana/dashboards/` directory to be imported into Grafana on startup - Include dashboards from `monitoring/grafana/dashboards/` directory to be imported into Grafana on startup
- [x] **CD trigger parallel**: Change `cd.yml` from `workflow_run: [CI]` to `push: branches: [main]` so deploy runs in parallel with CI, not after - **CD trigger parallel**: Change `cd.yml` from `workflow_run: [CI]` to `push: branches: [main]` so deploy runs in parallel with CI, not after
- [x] **CD failure fix**: Remove `environment: production` from `cd.yml` deploy job (no Gitea environment configured, causes silent job abort after Buildx) - **CD failure fix**: Remove `environment: production` from `cd.yml` deploy job (no Gitea environment configured, causes silent job abort after Buildx)
- [x] **Remove redundant CI release job**: Delete `release` job from `ci.yml` — fully handled by `release.yml` - **Remove redundant CI release job**: Delete `release` job from `ci.yml` — fully handled by `release.yml`
- [x] **Tag format**: Use `*.*.*` tags (e.g. `1.0.9`) — `release.yml` pattern is `*.*.*`, not `v*.*.*`. Fix existing `v1.0.x` tags to `1.0.x` - **Tag format**: Use `*.*.*` tags (e.g. `1.0.9`) — `release.yml` pattern is `*.*.*`, not `v*.*.*`. Fix existing `v1.0.x` tags to `1.0.x`
- [x] **Update DEPLOYMENT.md**: Document new parallel pipeline flow (push → CI + CD run simultaneously; tag push → Release builds versioned images) - **Update DEPLOYMENT.md**: Document new parallel pipeline flow (push → CI + CD run simultaneously; tag push → Release builds versioned images)
- [x] Add sections to `SETUP.md` under `## Cloudflare` for detailed instructions: - Add sections to `SETUP.md` under `## Cloudflare` for detailed instructions:
- [x] DNS: `linearvoid.tv` apex + `www` CNAME → Cloudflare Pages - DNS: `linearvoid.tv` apex + `www` CNAME → Cloudflare Pages
- [x] DNS: `media.linearvoid.tv` for split-domain HLS delivery - DNS: `media.linearvoid.tv` for split-domain HLS delivery
- [x] R2 bucket `linearvoid-media` with `enam` location hint - R2 bucket `linearvoid-media` with `enam` location hint
- [x] Cache Rules: `.ts/.m4s` immutable long TTL (31536000), `.m3u8` short TTL (2s, revalidate) - Cache Rules: `.ts/.m4s` immutable long TTL (31536000), `.m3u8` short TTL (2s, revalidate)
- [x] Cloudflare Pages: link Gitea repo, build `cd frontend && npm run build`, output `frontend/dist` - Cloudflare Pages: link Gitea repo, build `cd frontend && npm run build`, output `frontend/dist`
- [x] Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier - Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier
- [x] WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode - WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode
- [x] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action - Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
- [x] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2 - HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2
- [x] HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS - HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS
- [x] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval) - UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
- [x] 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)
- [x] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook - Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
- [x] Check all known URLs for metrics and their availability: - Check all known URLs for metrics and their availability:
- [x] Prometheus: `http://<host>:9090` (targets, graph, alerts) - Prometheus: `http://<host>:9090` (targets, graph, alerts)
- [x] Grafana: `http://<host>:3001` (default: admin/admin, change via `GRAFANA_PASSWORD`) - Grafana: `http://<host>:3001` (default: admin/admin, change via `GRAFANA_PASSWORD`)
- [x] Chat metrics: `http://chat:3000/metrics` from Prometheus container - Chat metrics: `http://chat:3000/metrics` from Prometheus container
- [x] Playout metrics: `http://playout:8081/metrics` from Prometheus container - Playout metrics: `http://playout:8081/metrics` from Prometheus container
- [x] Nginx metrics: `http://nginx-exporter:9113/metrics` from Prometheus container - Nginx metrics: `http://nginx-exporter:9113/metrics` from Prometheus container
- [x] Redis metrics: `http://redis-exporter:9121/metrics` from Prometheus container - Redis metrics: `http://redis-exporter:9121/metrics` from Prometheus container
- [x] Postgres metrics: `http://postgres-exporter:9187/metrics` from Prometheus container - Postgres metrics: `http://postgres-exporter:9187/metrics` from Prometheus container
- [x] Node metrics: `http://node-exporter:9100/metrics` from Prometheus container - Node metrics: `http://node-exporter:9100/metrics` from Prometheus container
- [x] Make sure Prometheus config is mounted into container via named volume `prometheus-config` (not bind mount) - Make sure Prometheus config is mounted into container via named volume `prometheus-config` (not bind mount)
- [x] Make sure Prometheus alerting rules are configured and working (8 rules: ChatDown, PlayoutDown, RedisDown, etc.) - Make sure Prometheus alerting rules are configured and working (8 rules: ChatDown, PlayoutDown, RedisDown, etc.)
- [x] Verify that all Prometheus targets are up and reporting metrics - Verify that all Prometheus targets are up and reporting metrics
- [x] Verify that Grafana dashboards are imported and displaying metrics correctly - Verify that Grafana dashboards are imported and displaying metrics correctly
- [x] Video normalization: test with MOV, MKV, variable framerate, different audio layouts - Video normalization: test with MOV, MKV, variable framerate, different audio layouts
- Evidence: all normalized outputs probed as `h264`, `yuv420p`, `30/1`, `aac`, `48000`, `2ch`. - Evidence: all normalized outputs probed as `h264`, `yuv420p`, `30/1`, `aac`, `48000`, `2ch`.
- [x] Video normalization: verify output plays back-to-back in continuous concat - Video normalization: verify output plays back-to-back in continuous concat
- Evidence: concat remux reported `CONCAT_OK`; output duration `25.344667s`. - Evidence: concat remux reported `CONCAT_OK`; output duration `25.344667s`.
- [x] Viewer SPA: test responsive layout on iOS Safari + Android Chrome - Viewer SPA: test responsive layout on iOS Safari + Android Chrome
- Evidence: viewport emulation (`390x844`, `412x915`) showed video/chat/toggle present; fixed 8px horizontal overflow in `frontend/src/style.css` (`100vw` -> `100%`). - Evidence: viewport emulation (`390x844`, `412x915`) showed video/chat/toggle present; fixed 8px horizontal overflow in `frontend/src/style.css` (`100vw` -> `100%`).
- Limitation: no physical-device Safari/Chrome test in this run. - Limitation: no physical-device Safari/Chrome test in this run.
- [x] Redis: test restart, verify sessions + presence data survive - Redis: test restart, verify sessions + presence data survive
- Evidence: admin session HTTP `200` before/after Redis restart; seeded presence key visible before/after restart; Redis health returned to `healthy`. - Evidence: admin session HTTP `200` before/after Redis restart; seeded presence key visible before/after restart; Redis health returned to `healthy`.
### Runtime Tests (all 4) ### Runtime Tests (all 4)
@@ -84,15 +84,15 @@ Completed tasks from `.github/instructions/TODO.md`. Historical record for CHANG
### Documentation Polish ### Documentation Polish
- [x] API.md: add error codes table (full status code matrix) + rate limit headers - API.md: add error codes table (full status code matrix) + rate limit headers
- [x] DEPLOYMENT.md: prereqs checklist, first-deploy walkthrough, restart/view-logs/rollback, troubleshooting - DEPLOYMENT.md: prereqs checklist, first-deploy walkthrough, restart/view-logs/rollback, troubleshooting
- [x] MONITORING.md: architecture diagram, Grafana URL + default creds, dashboard tour, alert descriptions, scrape targets - MONITORING.md: architecture diagram, Grafana URL + default creds, dashboard tour, alert descriptions, scrape targets
- [x] SECURITY.md: threat model diagram, TLS/HSTS config, auth flow, network isolation, secrets management - SECURITY.md: threat model diagram, TLS/HSTS config, auth flow, network isolation, secrets management
- [x] Level 2 building blocks: detail Playout/Storage/Frontend/Chat/Proxy with Mermaid + code locations + persistence - Level 2 building blocks: detail Playout/Storage/Frontend/Chat/Proxy with Mermaid + code locations + persistence
- [x] Level 3 building blocks: module breakdowns (parser, manager, uploader, handler, registry, router, etc.) - Level 3 building blocks: module breakdowns (parser, manager, uploader, handler, registry, router, etc.)
- [x] Recovery runbook: document quarterly disaster recovery drill - Recovery runbook: document quarterly disaster recovery drill
- Evidence: expanded `docs/incident-runbook.md` with prerequisites, simulation steps, validation checks, and pass/fail criteria. - Evidence: expanded `docs/incident-runbook.md` with prerequisites, simulation steps, validation checks, and pass/fail criteria.
- [x] Recovery runbook: rehearse quarterly disaster recovery drill - Recovery runbook: rehearse quarterly disaster recovery drill
- Manual step: execute full drill on target environment and capture RTO/RPO evidence. - Manual step: execute full drill on target environment and capture RTO/RPO evidence.
## Session 2026-08-06 — Documentation Adjustments, Playout, Normalization, Chat, Viewer SPA, Admin, Docker, CI/CD ## Session 2026-08-06 — Documentation Adjustments, Playout, Normalization, Chat, Viewer SPA, Admin, Docker, CI/CD
@@ -975,12 +975,12 @@ Notes:
Completed 6 of 7 streaming pipeline gaps from STREAMING.md contract audit: Completed 6 of 7 streaming pipeline gaps from STREAMING.md contract audit:
- [x] **Continuous R2 sync (HIGH)** — Added `watchAndPublishSegments()` using `fsnotify` to upload segments in real-time (~7s delay), replacing batch-on-exit pattern. - **Continuous R2 sync (HIGH)** — Added `watchAndPublishSegments()` using `fsnotify` to upload segments in real-time (~7s delay), replacing batch-on-exit pattern.
- [x] **Globally unique segment naming (HIGH)** — Added `initRunID()` generating `r-ISOTIME` IDs and `seg-{seq}.m4s` monotonic naming. Replaced `strftime` collision-prone pattern. - **Globally unique segment naming (HIGH)** — Added `initRunID()` generating `r-ISOTIME` IDs and `seg-{seq}.m4s` monotonic naming. Replaced `strftime` collision-prone pattern.
- [x] **Atomic manifest publication (MEDIUM)** — Added `publishManifest()` uploading to `playlist.m3u8.tmp` then `CopyObject` to final key. - **Atomic manifest publication (MEDIUM)** — Added `publishManifest()` uploading to `playlist.m3u8.tmp` then `CopyObject` to final key.
- [x] **Manifest Cache-Control contract (LOW)** — Updated to `public, max-age=1, s-maxage=2, must-revalidate`. - **Manifest Cache-Control contract (LOW)** — Updated to `public, max-age=1, s-maxage=2, must-revalidate`.
- [x] **Player error recovery (MEDIUM)** — Added `hls.on(ERROR)` handler with bounded retries (5x), exponential backoff (2s→32s), reconnecting overlay with spinner. - **Player error recovery (MEDIUM)** — Added `hls.on(ERROR)` handler with bounded retries (5x), exponential backoff (2s→32s), reconnecting overlay with spinner.
- [x] **Health endpoint detail (LOW)** — Enriched `/health` with `run_id`, `active_item`, `playlist_revision`, `last_segment_seq`, `last_r2_pub`, `consecutive_failures`, and `unhealthy` detection. - **Health endpoint detail (LOW)** — Enriched `/health` with `run_id`, `active_item`, `playlist_revision`, `last_segment_seq`, `last_r2_pub`, `consecutive_failures`, and `unhealthy` detection.
- [ ] CORS on `media.linearvoid.tv` — Remaining infrastructure-only (Cloudflare R2 CORS configuration, not code). - [ ] CORS on `media.linearvoid.tv` — Remaining infrastructure-only (Cloudflare R2 CORS configuration, not code).
**Files changed:** `backend/playout/src/playlist.go` (+171/-35), `playlist_test.go` (+51/-7), `go.mod` (+2), `frontend/src/main.ts` (+76/-2), `main.test.ts` (+43/-1), `style.css` (+37), `index.html` (+5). **Files changed:** `backend/playout/src/playlist.go` (+171/-35), `playlist_test.go` (+51/-7), `go.mod` (+2), `frontend/src/main.ts` (+76/-2), `main.test.ts` (+43/-1), `style.css` (+37), `index.html` (+5).
@@ -990,11 +990,195 @@ Completed 6 of 7 streaming pipeline gaps from STREAMING.md contract audit:
Completed Phase 2 (viewer) and Phase 3 (admin) responsive/accessibility tests + coverage: Completed Phase 2 (viewer) and Phase 3 (admin) responsive/accessibility tests + coverage:
- [x] **Viewer accessibility (5 tests):** video controls/autoplay/playsinline attributes, chat panel aria-labels (live region + panel label), chat toggle aria-expanded state, message input label association, reconnecting overlay aria-live=assertive + role=alert. - **Viewer accessibility (5 tests):** video controls/autoplay/playsinline attributes, chat panel aria-labels (live region + panel label), chat toggle aria-expanded state, message input label association, reconnecting overlay aria-live=assertive + role=alert.
- [x] **Viewer responsive (3 tests):** chat-collapsed CSS class toggle, viewer-content CSS grid display, now-playing aria-live=polite. - **Viewer responsive (3 tests):** chat-collapsed CSS class toggle, viewer-content CSS grid display, now-playing aria-live=polite.
- [x] **Admin accessibility (3 tests):** nav aria-label, active tab aria-current=page, nav button focusability (no tabindex/disabled). - **Admin accessibility (3 tests):** nav aria-label, active tab aria-current=page, nav button focusability (no tabindex/disabled).
- [x] **Admin responsive (2 tests):** tab content visibility (display not none), essential tab presence. - **Admin responsive (2 tests):** tab content visibility (display not none), essential tab presence.
- [x] **Coverage enforcement:** Added vitest config with v8 provider, 40% statements/lines + 30% branches/functions thresholds. Added `test:coverage` script and CI step. - **Coverage enforcement:** Added vitest config with v8 provider, 40% statements/lines + 30% branches/functions thresholds. Added `test:coverage` script and CI step.
**Files:** `frontend/src/main.test.ts` (+65), `frontend/src/admin.test.ts` (+55), `frontend/vitest.config.ts` (+12), `frontend/package.json` (+1), `.gitea/workflows/ci.yml` (+3). **Files:** `frontend/src/main.test.ts` (+65), `frontend/src/admin.test.ts` (+55), `frontend/vitest.config.ts` (+12), `frontend/package.json` (+1), `.gitea/workflows/ci.yml` (+3).
**Total:** 44 frontend tests (31 existing + 13 new). **Total:** 44 frontend tests (31 existing + 13 new).
## Session 2026-08-13 — Plan consolidation (TODO → DONE)
Completed tasks moved out of `.github/instructions/TODO.md`. Remaining work replanned in TODO from `BACKLOG.md`.
### Phase 1 — playout test fixtures + source-media contract
- Create small compliant fixture media in `backend/playout/testdata/` for the validator and playout tests
- Create invalid fixture media for codec, framerate, and audio rejection paths
- Create playlist fixtures for empty, comment-only, mixed-validity, and valid playlists
- Document the ffmpeg fixture-generation commands in the relevant test headers
### Phase 2 — viewer frontend coverage
- HLS player startup, manifest resolution, and cross-origin/local fallback tests
- Chat send/receive, presence updates, private DM flow, and reconnect tests
- Responsive-layout and accessibility checks for the viewer shell and chat sidebar
- Coverage reporting added and minimum threshold enforced in CI
### Phase 3 — admin frontend coverage
- Playlist management, moderation flows, and dashboard rendering tests
- API integration points with mocked backend responses
- Responsive-layout and accessibility checks for admin screens
- Coverage reporting added and minimum threshold enforced in CI
### Phase 4 — R2 and edge-delivery validation
- Integration test for upload and retrieval of HLS objects through the R2 path
- Segment and manifest cache headers verified (immutable media, live manifest freshness)
- Edge cache hit/miss validation scripted (`scripts/validate-edge-cache.sh`)
- Stubs/mocks documented (putObject variable-injection pattern)
### Phase 7 (partial) — load-test scaffolding + saturation alerts
- WebSocket load-test scaffolding and summary artifacts (`ws-chat.yml`, `run-loadtests.ps1`, `summarize-artillery.ps1`)
- Admin API benchmark scaffolding and endpoint summary artifacts (`admin-api.js`, `run-loadtests.ps1`, `summarize-k6.ps1`)
- Saturation thresholds added to monitoring (chat p95 latency + Redis memory usage)
- Cache-hit threshold alert operationalization added to monitoring
### Streaming pipeline gaps (audit → implemented)
- Continuous R2 sync during playout (fsnotify watcher, ~7s delay)
- Globally unique segment naming with run ID (`r-{ISOTIME}/seg-{seq}.m4s`)
- Atomic manifest publication (upload to `.tmp`, then CopyObject)
- Manifest Cache-Control: `public, max-age=1, s-maxage=2, must-revalidate`
- Player error recovery (bounded retries, exponential backoff, reconnecting overlay)
- Health endpoint detail (run ID, active item, playlist revision, segment seq, R2 pub, consecutive failures)
- CORS headers — documented as Cloudflare R2 infrastructure config
- Phase 4 edge cache validation — `scripts/validate-edge-cache.sh` created
## Session 2026-08-13 — Playout Enhancements
Completed the three Playout enhancement workstreams from `TODO.md`. Remaining polish items kept in TODO.
### 1. Automatic normalization of source media on upload (H.264/AAC, 30fps, keyframe check)
- Implement `media.NormalizeVideo` in `backend/chat/src/media/media.go` (ffmpeg exec, Linear Void profile, bounded 2h timeout, stderr capture)
- Write normalized output to `*.normalized.mp4` and re-verify with ffprobe (`ValidateNormalization` + `ProbeDurationSeconds`)
- Extend media status states: `normalizing`, `normalization_failed` (alongside `ready` / `needs_normalization`)
- Integrate into single-upload path — `HandleAdminMedia`: persist `needs_normalization` then `go normalizeMediaRecord(...)`
- Integrate into chunked-upload completion — `HandleAdminMediaUploads` `"complete"`: same async flow after assembly + validation
- Add `POST /api/admin/media/{id}/normalize` (behind `requireAdminAuth`, returns 202 + `normalizing` record)
- Admin UI (`admin.ts`): "Normalize" action + status polling until `ready`/`normalization_failed`
- Tests: `NormalizeVideo` args + ffmpeg-error path; handler `normalizeMediaRecord` success/failure; `/normalize` endpoint 202
- Retire hardcoded `backend/playout/normalize-video.sh` (superseded by in-Go normalization) - file deleted
### 2. R2 data retention: delete old segments after 24h
- Add `cleanupOldSegments(ctx)` to `backend/playout/src/playlist.go`
- Use `s3.ListObjectsV2` on prefix `hls/` to enumerate run directories
- Delete `hls/r-*/*` older than `now - R2_SEGMENT_RETENTION_HOURS` (default 24)
- Never delete `hls/playlist.m3u8` or `hls/playlist.m3u8.tmp`
- Run cleanup as a ticker goroutine (hourly) plus once at startup
- Add `R2_SEGMENT_RETENTION_HOURS` (default `24`) to `.env.example`
- Expose `linearvoid_playout_r2_cleanup_objects_total` metric and log deleted keys
- Tests: mock `ListObjectsV2`/`DeleteObjects` via injectable vars (mirror the existing `putObject` pattern)
### 3. Continuous playout: confirm ffmpeg loops without interruption
- Resolve the dual-loop ambiguity between `main()` and `start-playout.sh`
- Add a `-validate` mode to `main()`: run `ValidatePlaylist` and exit 0/1 without the ffmpeg loop
- Update `start-playout.sh` to invoke `-validate` and `exec` the Go binary (single source of truth)
- Minimize the restart gap: skip full `resetHLSOutput` when the playlist is unchanged
- Track `playlistRevision` (SHA-256 fingerprint); reset only when the playlist changed, on crash/slate recovery, or first run
- Continue segment numbering via `-start_number` + prune stale segments (`hlsNextStartNumber`)
- Verify `init.mp4` + manifest are correctly published on clean completion (existing `publishManifest` path runs after every run)
- Add metrics `linearvoid_playout_loops_total` (clean completions) and `linearvoid_playout_run_duration_seconds`
- Tests: `TestPlaylistFingerprint`, `TestHLSNextStartNumber`
## Session 2026-08-13 — Application Configuration, Matomo, Chat Accounts
### Application configuration (runtime config store)
- Runtime config store in `backend/chat/src/config/runtime.go` (thread-safe `Get`/`Set`, JSON persist, typed getters/setters with validation)
- `ConfigPath()` default `/app/playlist/config.json` (shared with playout via the playlist volume)
- Typed getters: `MatomoURL()`, `MatomoSiteID()`, `HLSSegmentDuration()`, `HLSWindowSize()`
- `GET/PUT /api/admin/config` now reads/persists `matomoUrl`, `matomoSiteId`, `hlsSegmentDuration`, `hlsWindowSize`
- Admin UI config form extended (`frontend/public/admin/system.html` + `frontend/src/admin.ts`)
- Playout reads the shared `config.json` (`loadSharedHLSConfig` + `PLAYOUT_CONFIG_PATH`)
- Tests: `runtime_test.go`, config handler tests, `TestLoadSharedHLSConfig`
### Matomo analytics
- Public `GET /api/matomo` endpoint
- Viewer pageview snippet in `frontend/index.html`
- Test: `TestHandlePublicMatomo`
### Admin frontend (media status + playlist gating)
- Media library: status badges (`ready`/`needs_normalization`/`normalizing`/`failed`) + "Add to playlist" gated to `ready` media
- Playlist entries: per-item status badges
- `statusBadge`, `fetchMediaStatusMap`, `addMediaToPlaylist` in `admin.ts`
### Chat user accounts
- `db.User` model (bcrypt password hash, unique username)
- `auth.UserSessionStore` (token → username with expiry)
- Endpoints: `POST /api/auth/register`, `POST /api/auth/login`, `GET /api/auth/session`, `POST /api/auth/logout`
- Viewer sign-in UI (`frontend/index.html` + `frontend/src/main.ts`)
- Tests: `TestUserSessionStore`, register/login/session handler tests
### Private DM history & search
- `GET /api/dm/history` (paginated) + `GET /api/dm/search` (case-insensitive)
- DM search UI in viewer
- Tests: `TestHandleUserDMHistory`, `TestHandleUserDMHistory_Unauthorized`, `TestHandleUserDMSearch`
### Chat UI (typing indicator + user list)
- `Hub.BroadcastTyping` (ephemeral typing events, not persisted)
- Viewer typing indicator (`showTyping`/`hideTyping`/`sendTyping`) + user-list toggle
## Session 2026-08-13 — Prometheus TSDB Retention
- Added `--storage.tsdb.retention.time=30d` (delete metrics older than 30d) + `--storage.tsdb.retention.size=15GB` (disk cap) to the `prometheus` service `command` in `docker-compose.yml` and `docker-compose.prod.yml`
- Validated: `docker compose config --quiet` (both files), `promtool check config` (SUCCESS, 10 rules), Prometheus v3.13.2 accepts both retention flags
- Documented the retention policy in `docs/MONITORING.md` → Operational Recommendations
## Session 2026-08-13 — Architecture Documentation Update
- Updated `docs/architecture/README.md` to reflect the `[Unreleased]` changes:
- Automatic media normalization on upload (`media.NormalizeVideo`, statuses, `POST /api/admin/media/{id}/normalize`)
- Playout: seamless looping, R2 segment retention, `-validate` mode, shared runtime config, new loop/cleanup metrics
- Prometheus: TSDB retention (`30d` + `15GB`), alert rule count fixed 8 → 10
- New Cross-cutting Concepts sections: Runtime Configuration Store, Analytics (Matomo)
- Viewer user accounts (register/login/logout/session, bcrypt, `linearvoid_user_session`)
- Chat: DM history/search, typing indicator, user-list toggle
- API reference: `GET/PUT /api/admin/config`, `POST /api/admin/media/{id}/normalize`, new Viewer REST API section
- Frontend + Chat Service module tables updated
## Session 2026-08-13 — Release 1.0.13
- Promoted `[Unreleased]` → `## [1.0.13] - 2026-08-13` in `CHANGELOG.md`
- Bumped `frontend/package.json` to `1.0.13` and README version badge to `1.0.13`
- Committed remaining work + `chore: bump version to 1.0.13`, tagged `1.0.13` (annotated)
- Pushed `main` + `1.0.13` to origin → CI/CD + `release.yml` builds semver images and creates the Gitea release
## Session 2026-08-13 — Frontend Error Fix
- Fixed `Uncaught TypeError: Cannot read properties of null (reading 'appendChild')` in the viewer bundle: `index.html` was missing the `#messages` chat container (and had a stray `</section>`), so `addMessage` crashed on WebSocket open/message.
- Restored chat panel structure: added `<section id="messages" class="chat-messages">` and wrapped `#presence-list` in `<section id="presence-panel" class="presence-panel">`.
- Made `addMessage` defensive (returns early when `#messages` is absent), matching the null-guards in `renderPresence`/`showTyping`/`hideTyping`.
## Session 2026-08-13 — Chat improvements
### Transient presence flash + system-message cleanup
- Added `#chat-flash` container (`index.html`) + `.chat-flash` CSS (top of chat panel, `0.83rem`, 3s auto-hide with 0.5s fade-out).
- `main.ts` `ws.onmessage` routes join/leave system messages to `showFlash()` + presence list instead of `addMessage()`.
- Backend: `Broadcast` no longer persists `type: "system"` messages; `PushChatHistory` excludes system rows; `HandleAdminChatLog` filters `type <> 'system'`.
- 5s reconnect grace: disconnect schedules a delayed "left" via `time.AfterFunc` (`Hub.PresenceGracePeriod`); a reconnect within grace cancels it and skips "joined".
- Font sizing: `.message-text` set to `0.95rem` (between default and system `0.83rem`); message alignment already correct.
- Tests: `TestHub_Broadcast_SystemNotPersisted`, `TestHub_ReconnectGracePeriod`, `TestHandleAdminChatLog_ExcludesSystem`, frontend `presence flash`; ws test DB fixed to `SetMaxOpenConns(1)`.
## Session 2026-08-13 — Playout improvements
### HLS segment duration 2s + window 12
- `backend/playout/src/playlist.go`: `HLS_SEGMENT_DURATION` default `"6"→"2"`, `HLS_WINDOW_SIZE` `"24"→"12"`.
- `backend/chat/src/config/runtime.go`: `defaultHLSSegmentDuration 6→2`, `defaultHLSWindowSize 24→12`.
- ffmpeg GOP already 2s (`-g 60 -keyint_min 60` @30fps), so segments cut cleanly on keyframes — no encoder change needed.
- Updated `runtime_test.go` defaults + env-fallback tests, `.env.example`, `docs/STREAMING.md` (2s rationale + Firefox notes), `docs/operations/cloudflare-pages-setup.md`, `docs/architecture/README.md`.
- Verified: chat + playout `go test ./...` and `go vet ./...` all pass.
+45 -58
View File
@@ -1,80 +1,67 @@
# 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 ## Chat improvements (remaining)
### Phase 1: unblock playout test fixtures and lock down the source-media contract - [ ] Manual smoke: viewer join/leave shows a 3s flash (not a chat row); reconnect <5s shows no duplicate; admin chat-log table has no system rows
- [x] Create small compliant fixture media in `backend/playout/testdata/` for the validator and playout tests ## Playout improvements (remaining)
- [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 - [ ] Test in Firefox: live playback, rebuffer/recovery, chat sync; record quirks in `docs/STREAMING.md`
- [ ] Rebuild + run playout; confirm `playlist.m3u8` lists ~12 segments at 2s each
- [ ] Manual: verify viewer latency and cache-hit behavior after the change
- [x] Add tests for HLS player startup, manifest resolution, and cross-origin/local fallback behavior ## Setup phases remaining
- [x] Add tests for chat send/receive, presence updates, private DM flow, and reconnect behavior
- [x] Add responsive-layout and accessibility checks for the viewer shell and chat sidebar
- [x] Add coverage reporting and enforce a minimum threshold in CI
### Phase 3: finish frontend coverage for the admin experience
- [x] Add tests for playlist management, moderation flows, and dashboard rendering
- [x] Add tests for API integration points with mocked backend responses
- [x] Add responsive-layout and accessibility checks for admin screens
- [x] Add coverage reporting and enforce a minimum threshold in CI
### Phase 4: validate the R2 and edge-delivery path
- [x] Add an integration test for upload and retrieval of HLS objects through the R2 path
- [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 ### Phase 5: complete manual Cloudflare and external-service setup
- [ ] Apply Cloudflare Pages, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard - [ ] Apply Cloudflare Pages, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
- [ ] Create or verify UptimeRobot monitors for health and HLS manifest availability - [ ] Cloudflare Pages: link custom domain and cut over frontend hosting
- [ ] Enable Smart Tiered Cache; validate `Cf-Cache-Status: HIT` from upper tier
- [ ] WAF: enable OWASP Managed Ruleset (Simulate → review 48h → Block), enable Bot Fight Mode
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2; enforce at edge and origin
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5min)
- [ ] UptimeRobot: alert on 2 consecutive failures via email + Discord/Slack webhook
- [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md` - [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`
### Phase 6: rehearse production deployment end to end ### Phase 6: rehearse production deployment end to end
- [ ] Provision the Hetzner host and prepare the Docker/runtime baseline - [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04
- [ ] Configure production secrets, remote deployment, and DNS cutover - [ ] Install Docker Engine + Compose plugin; open firewall: 80/443 public, 2375 runner-only
- [ ] Run health, HLS, chat, and admin smoke tests against production-like infrastructure - [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs)
- [ ] Verify backup creation, restore, and integrity checks on the production host - [ ] 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 ### Phase 7: baseline scaling and performance
- [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions - [ ] Execute WebSocket load tests — ramp 10→100→500→1000 connections; measure p95 latency + errors
- [x] Prepare WebSocket load-test scaffolding and summary artifacts (`ws-chat.yml`, `run-loadtests.ps1`, `summarize-artillery.ps1`) - [ ] Execute admin API benchmarks — `wrk`/`k6` against `/api/admin/*`; target p95 <1s
- [ ] Execute WebSocket throughput and chat-latency benchmarks under growing connection counts - [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions (WebPageTest 3+ regions, <4s warm cache)
- [x] Prepare Admin API benchmark scaffolding and endpoint summary artifacts (`admin-api.js`, `run-loadtests.ps1`, `summarize-k6.ps1`) - [ ] Tune manifest TTL to 0.5× segment duration; validate `Cf-Cache-Status` distribution
- [ ] Execute Admin API benchmarks and identify slow paths - [ ] Track cache-hit ratio and R2 ops in production (Cloudflare GraphQL → Grafana panel, alert <90%)
- [x] Add saturation thresholds to monitoring (chat p95 latency + Redis memory usage) - [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
- [x] Add cache-hit threshold alert operationalization to monitoring - [ ] 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)
## Status Update (2026-08-10) ### Phase 8: close out remaining test and validation gaps
- [x] Phase 1 (test fixtures) complete. - [ ] Admin frontend: user moderation tools tests (mute/ban/report, view moderation logs)
- [x] Phase 2 (viewer frontend): player startup, URL resolution, chat send/receive, reconnect, presence, DM flow, message rendering all covered (27 tests). - [ ] Frontend: coverage for all critical user + admin components and interactions
- [x] Phase 3 (admin frontend): dashboard rendering, API integration, playlist management all covered (31 tests total). - [ ] Frontend: document test cases and expected outcomes
- [x] Phase 4 (R2 validation) partially complete: SyncToR2 cache headers + integration tests done; edge cache hit/miss remains manual. - [ ] Backend R2: test retrieval of segments/manifests via Cloudflare edge
- [ ] Phase 5 (Cloudflare): Pages deploy workflow (`.gitea/workflows/pages-deploy.yml`) created; Pages/Workers setup docs written; manual dashboard tasks remain. - [ ] Backend R2: validate edge cache hit/miss behavior with repeat requests (`scripts/validate-edge-cache.sh`)
- [ ] 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) ### Playout
STREAMING.md contract vs playlist.go/main.ts implementation. See Backlog for full details. - [ ] 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`
- [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
+47 -1
View File
@@ -4,6 +4,52 @@
## [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).
### Tests
- WebSocket: `TestHub_Broadcast_SystemNotPersisted`, `TestHub_ReconnectGracePeriod`.
- Handlers: `TestHandleAdminChatLog_ExcludesSystem`.
- Frontend: `presence flash` test.
## [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 +59,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
+3 -1
View File
@@ -7,7 +7,7 @@
[![Build Status](https://git.allucanget.biz/zwitschi/linearvoid/actions/workflows/ci.yml/badge.svg)](https://git.allucanget.biz/zwitschi/linearvoid/actions) [![Build Status](https://git.allucanget.biz/zwitschi/linearvoid/actions/workflows/ci.yml/badge.svg)](https://git.allucanget.biz/zwitschi/linearvoid/actions)
[![Gitea](https://img.shields.io/badge/Gitea-1.27.1-blue)](https://git.allucanget.biz) [![Gitea](https://img.shields.io/badge/Gitea-1.27.1-blue)](https://git.allucanget.biz)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.0.12-blue)](https://git.allucanget.biz/zwitschi/linearvoid/releases) [![Version](https://img.shields.io/badge/version-1.0.13-blue)](https://git.allucanget.biz/zwitschi/linearvoid/releases)
[![Release Notes](https://img.shields.io/badge/release-notes-green)](CHANGELOG.md) [![Release Notes](https://img.shields.io/badge/release-notes-green)](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.
+1 -1
View File
@@ -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
+48
View File
@@ -138,3 +138,51 @@ func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc)
} }
} }
} }
// UserSessionStore maps user session tokens to usernames with expiry.
type UserSessionStore struct {
mu sync.Mutex
sessions map[string]userSession
}
type userSession struct {
Username string
ExpiresAt time.Time
}
// NewUserSessionStore creates a new user session store.
func NewUserSessionStore() *UserSessionStore {
return &UserSessionStore{sessions: make(map[string]userSession)}
}
// Add stores a user session token with username and expiry.
func (s *UserSessionStore) Add(token, username string, expiresAt time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[token] = userSession{Username: username, ExpiresAt: expiresAt}
}
// Username returns the username for a valid, unexpired token.
func (s *UserSessionStore) Username(token string) (string, bool) {
if token == "" {
return "", false
}
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[token]
if !ok {
return "", false
}
if time.Now().After(sess.ExpiresAt) {
delete(s.sessions, token)
return "", false
}
return sess.Username, true
}
// Remove deletes a user session token.
func (s *UserSessionStore) Remove(token string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, token)
}
+29
View File
@@ -246,3 +246,32 @@ func TestRequireAdmin(t *testing.T) {
t.Errorf("status = %d, want 401", w3.Code) t.Errorf("status = %d, want 401", w3.Code)
} }
} }
func TestUserSessionStore(t *testing.T) {
store := NewUserSessionStore()
if _, ok := store.Username(""); ok {
t.Error("Username(\"\") = true, want false")
}
token, _ := GenerateToken()
store.Add(token, "alice", time.Now().Add(1*time.Hour))
username, ok := store.Username(token)
if !ok || username != "alice" {
t.Errorf("Username() = %q, %v; want alice, true", username, ok)
}
// Expired session
expired, _ := GenerateToken()
store.Add(expired, "bob", time.Now().Add(-1*time.Hour))
if _, ok := store.Username(expired); ok {
t.Error("Username(expired) = true, want false")
}
// Remove
store.Remove(token)
if _, ok := store.Username(token); ok {
t.Error("Username after Remove = true, want false")
}
}
+14
View File
@@ -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"))
+221
View File
@@ -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(),
}
}
+181
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+8
View File
@@ -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"`
+399 -16
View File
@@ -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)
} }
+477 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -14,11 +15,13 @@ import (
"time" "time"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
"chat/src/auth" "chat/src/auth"
"chat/src/config" "chat/src/config"
"chat/src/db" "chat/src/db"
"chat/src/media"
"chat/src/ws" "chat/src/ws"
) )
@@ -100,7 +103,14 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
if err != nil { if err != nil {
t.Fatalf("failed to open test DB: %v", err) t.Fatalf("failed to open test DB: %v", err)
} }
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{}) // A single connection keeps the in-memory SQLite database shared across
// goroutines (each :memory: connection would otherwise be a separate DB).
sqlDB, err := gormDB.DB()
if err != nil {
t.Fatalf("failed to get sql.DB: %v", err)
}
sqlDB.SetMaxOpenConns(1)
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{}, &db.User{})
mr := newMockRedis() mr := newMockRedis()
sessionStore := auth.NewSessionStore() sessionStore := auth.NewSessionStore()
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername()) hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
@@ -110,6 +120,7 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
Redis: mr, Redis: mr,
Hub: hub, Hub: hub,
SessionStore: sessionStore, SessionStore: sessionStore,
UserSessions: auth.NewUserSessionStore(),
DBDriver: "sqlite", DBDriver: "sqlite",
} }
return deps, gormDB, sessionStore return deps, gormDB, sessionStore
@@ -474,6 +485,7 @@ func TestHandleAdminConfig_Get(t *testing.T) {
func TestHandleAdminConfig_Put(t *testing.T) { func TestHandleAdminConfig_Put(t *testing.T) {
deps, _, _ := setupTestDeps(t) deps, _, _ := setupTestDeps(t)
setupConfigTest(t)
handler := HandleAdminConfig(deps) handler := HandleAdminConfig(deps)
body := `{"rateLimitMax":20}` body := `{"rateLimitMax":20}`
@@ -488,6 +500,289 @@ func TestHandleAdminConfig_Put(t *testing.T) {
} }
} }
// setupConfigTest points the runtime config at a fresh temp file and resets the store.
func setupConfigTest(t *testing.T) {
t.Helper()
os.Setenv("CONFIG_PATH", filepath.Join(t.TempDir(), "config.json"))
os.Unsetenv("MATOMO_URL")
os.Unsetenv("MATOMO_SITE_ID")
os.Unsetenv("HLS_SEGMENT_DURATION")
os.Unsetenv("HLS_WINDOW_SIZE")
if err := config.LoadRuntimeConfig(); err != nil {
t.Fatalf("LoadRuntimeConfig: %v", err)
}
t.Cleanup(func() { os.Unsetenv("CONFIG_PATH") })
}
func TestHandleAdminConfig_Get_NewFields(t *testing.T) {
deps, _, _ := setupTestDeps(t)
setupConfigTest(t)
handler := HandleAdminConfig(deps)
req := httptest.NewRequest("GET", "/api/admin/config", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var cfg map[string]any
if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
t.Fatalf("decode: %v", err)
}
for _, k := range []string{"matomoUrl", "matomoSiteId", "hlsSegmentDuration", "hlsWindowSize"} {
if _, ok := cfg[k]; !ok {
t.Errorf("response missing %q", k)
}
}
}
func TestHandleAdminConfig_Put_Persists(t *testing.T) {
deps, _, _ := setupTestDeps(t)
setupConfigTest(t)
handler := HandleAdminConfig(deps)
body := `{"hlsSegmentDuration":5,"hlsWindowSize":30,"matomoUrl":"https://stats.example.com","matomoSiteId":"42"}`
req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var cfg map[string]any
if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
t.Fatalf("decode: %v", err)
}
if cfg["hlsSegmentDuration"] != float64(5) {
t.Errorf("hlsSegmentDuration = %v, want 5", cfg["hlsSegmentDuration"])
}
if cfg["hlsWindowSize"] != float64(30) {
t.Errorf("hlsWindowSize = %v, want 30", cfg["hlsWindowSize"])
}
if cfg["matomoUrl"] != "https://stats.example.com" {
t.Errorf("matomoUrl = %v", cfg["matomoUrl"])
}
if cfg["matomoSiteId"] != "42" {
t.Errorf("matomoSiteId = %v", cfg["matomoSiteId"])
}
}
func TestHandleAdminConfig_Put_Invalid(t *testing.T) {
deps, _, _ := setupTestDeps(t)
setupConfigTest(t)
handler := HandleAdminConfig(deps)
body := `{"hlsSegmentDuration":99}`
req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestHandlePublicMatomo(t *testing.T) {
setupConfigTest(t)
handler := HandlePublicMatomo()
req := httptest.NewRequest("GET", "/api/matomo", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
for _, k := range []string{"matomoUrl", "matomoSiteId"} {
if _, ok := resp[k]; !ok {
t.Errorf("response missing %q", k)
}
}
}
func TestHandleUserRegister_Success(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
handler := HandleUserRegister(deps)
body := `{"username":"alice","password":"secret123"}`
req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
}
var count int64
gormDB.Model(&db.User{}).Where("username = ?", "alice").Count(&count)
if count != 1 {
t.Errorf("user count = %d, want 1", count)
}
if len(w.Result().Cookies()) == 0 {
t.Error("expected session cookie to be set")
}
}
func TestHandleUserRegister_Duplicate(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
gormDB.Create(&db.User{Username: "alice", PasswordHash: "x"})
handler := HandleUserRegister(deps)
body := `{"username":"alice","password":"secret123"}`
req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusConflict {
t.Errorf("status = %d, want 409", w.Code)
}
}
func TestHandleUserLogin_Success(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
handler := HandleUserLogin(deps)
body := `{"username":"bob","password":"secret123"}`
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
}
}
func TestHandleUserLogin_WrongPassword(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
handler := HandleUserLogin(deps)
body := `{"username":"bob","password":"wrong"}`
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestHandleUserSession(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleUserSession(deps)
// Unauthenticated
req := httptest.NewRequest("GET", "/api/auth/session", nil)
w := httptest.NewRecorder()
handler(w, req)
var resp map[string]any
json.NewDecoder(w.Body).Decode(&resp)
if resp["authenticated"] != false {
t.Errorf("authenticated = %v, want false", resp["authenticated"])
}
// Authenticated
token, _ := auth.GenerateToken()
deps.UserSessions.Add(token, "carol", time.Now().Add(1*time.Hour))
req2 := httptest.NewRequest("GET", "/api/auth/session", nil)
req2.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
w2 := httptest.NewRecorder()
handler(w2, req2)
var resp2 map[string]any
json.NewDecoder(w2.Body).Decode(&resp2)
if resp2["username"] != "carol" {
t.Errorf("username = %v, want carol", resp2["username"])
}
}
func TestHandleUserDMHistory(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
token, _ := auth.GenerateToken()
deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
now := time.Now()
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "hi bob", Type: "private", Timestamp: now.Add(-2 * time.Hour)})
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "hi alice", Type: "private", Timestamp: now.Add(-1 * time.Hour)})
handler := HandleUserDMHistory(deps)
req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var msgs []db.Message
json.NewDecoder(w.Body).Decode(&msgs)
if len(msgs) != 2 {
t.Fatalf("len = %d, want 2", len(msgs))
}
if msgs[0].Text != "hi bob" {
t.Errorf("first message = %q, want hi bob (chronological)", msgs[0].Text)
}
}
func TestHandleUserDMHistory_Unauthorized(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleUserDMHistory(deps)
req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestHandleUserDMSearch(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
token, _ := auth.GenerateToken()
deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "meet at 8", Type: "private"})
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "see you", Type: "private"})
handler := HandleUserDMSearch(deps)
req := httptest.NewRequest("GET", "/api/dm/search?with=bob&q=meet", nil)
req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var msgs []db.Message
json.NewDecoder(w.Body).Decode(&msgs)
if len(msgs) != 1 || msgs[0].Text != "meet at 8" {
t.Fatalf("search results = %v, want [meet at 8]", msgs)
}
}
func TestHandleAdminRestart(t *testing.T) { func TestHandleAdminRestart(t *testing.T) {
deps, _, _ := setupTestDeps(t) deps, _, _ := setupTestDeps(t)
@@ -656,6 +951,32 @@ func TestHandleAdminChatLog_FilterByRoom(t *testing.T) {
} }
} }
func TestHandleAdminChatLog_ExcludesSystem(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
gormDB.Create(&db.Message{Room: "main", Username: "system", Text: "bob joined main", Timestamp: time.Now(), Type: "system"})
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
handler := HandleAdminChatLog(deps)
req := httptest.NewRequest("GET", "/api/admin/chat-log", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp ChatLogListResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Total != 1 {
t.Errorf("Total = %d, want 1 (system messages must be excluded)", resp.Total)
}
if len(resp.Entries) != 1 || resp.Entries[0].Text != "hello" {
t.Errorf("entries = %v, want only the public chat message", resp.Entries)
}
}
func TestHandleAdminChatLogByID_Get(t *testing.T) { func TestHandleAdminChatLogByID_Get(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t) deps, gormDB, _ := setupTestDeps(t)
@@ -945,3 +1266,158 @@ func TestHandleAdminMediaUploads_InvalidFile(t *testing.T) {
t.Errorf("status = %d, want 400", w.Code) t.Errorf("status = %d, want 400", w.Code)
} }
} }
func TestNormalizeMediaRecord_Success(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
tmpDir := t.TempDir()
os.Setenv("MEDIA_DIR", tmpDir)
defer os.Unsetenv("MEDIA_DIR")
srcPath := filepath.Join(tmpDir, "source.mov")
if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
t.Fatal(err)
}
gormDB.Create(&db.Media{
Filename: "source.mov",
OriginalName: "source.mov",
Path: srcPath,
SizeBytes: 10,
DurationSeconds: 0,
Status: "needs_normalization",
Title: "source",
})
origNormalize := media.NormalizeVideo
t.Cleanup(func() { media.NormalizeVideo = origNormalize })
media.NormalizeVideo = func(src, dst string) (int, int64, error) {
if err := os.WriteFile(dst, []byte("normalized"), 0o644); err != nil {
return 0, 0, err
}
return 120, 10, nil
}
var record db.Media
gormDB.First(&record, 1)
normalizeMediaRecord(deps, record)
var updated db.Media
gormDB.First(&updated, 1)
if updated.Status != "ready" {
t.Errorf("Status = %q, want ready", updated.Status)
}
if updated.DurationSeconds != 120 {
t.Errorf("DurationSeconds = %d, want 120", updated.DurationSeconds)
}
if !strings.HasSuffix(updated.Path, ".normalized.mp4") {
t.Errorf("Path = %q, want .normalized.mp4 suffix", updated.Path)
}
}
func TestNormalizeMediaRecord_Failure(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
tmpDir := t.TempDir()
os.Setenv("MEDIA_DIR", tmpDir)
defer os.Unsetenv("MEDIA_DIR")
srcPath := filepath.Join(tmpDir, "source.mov")
if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
t.Fatal(err)
}
gormDB.Create(&db.Media{
Filename: "source.mov",
OriginalName: "source.mov",
Path: srcPath,
SizeBytes: 10,
DurationSeconds: 0,
Status: "needs_normalization",
Title: "source",
})
origNormalize := media.NormalizeVideo
t.Cleanup(func() { media.NormalizeVideo = origNormalize })
media.NormalizeVideo = func(src, dst string) (int, int64, error) {
return 0, 0, fmt.Errorf("ffmpeg failed")
}
var record db.Media
gormDB.First(&record, 1)
normalizeMediaRecord(deps, record)
var updated db.Media
gormDB.First(&updated, 1)
if updated.Status != "normalization_failed" {
t.Errorf("Status = %q, want normalization_failed", updated.Status)
}
}
func TestHandleAdminMediaByID_Normalize(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
tmpDir := t.TempDir()
os.Setenv("MEDIA_DIR", tmpDir)
defer os.Unsetenv("MEDIA_DIR")
srcPath := filepath.Join(tmpDir, "source.mov")
if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
t.Fatal(err)
}
gormDB.Create(&db.Media{
Filename: "source.mov",
OriginalName: "source.mov",
Path: srcPath,
SizeBytes: 10,
DurationSeconds: 0,
Status: "needs_normalization",
Title: "source",
})
origNormalize := media.NormalizeVideo
t.Cleanup(func() { media.NormalizeVideo = origNormalize })
media.NormalizeVideo = func(src, dst string) (int, int64, error) {
if err := os.WriteFile(dst, []byte("normalized"), 0o644); err != nil {
return 0, 0, err
}
return 90, 9, nil
}
handler := HandleAdminMediaByID(deps)
req := httptest.NewRequest("POST", "/api/admin/media/1/normalize", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusAccepted {
t.Errorf("status = %d, want 202", w.Code)
}
// Wait for the asynchronous normalization goroutine to finish before
// t.Cleanup restores media.NormalizeVideo, avoiding a data race on the
// package-level mock variable.
var updated db.Media
deadline := time.Now().Add(5 * time.Second)
for {
gormDB.First(&updated, 1)
if updated.Status != media.StatusNormalizing {
break
}
if time.Now().After(deadline) {
t.Fatal("normalization did not complete within 5s")
}
time.Sleep(10 * time.Millisecond)
}
if updated.Status != media.StatusReady {
t.Errorf("Status = %q, want ready", updated.Status)
}
if updated.DurationSeconds != 90 {
t.Errorf("DurationSeconds = %d, want 90", updated.DurationSeconds)
}
if !strings.HasSuffix(updated.Path, ".normalized.mp4") {
t.Errorf("Path = %q, want .normalized.mp4 suffix", updated.Path)
}
}
+14
View File
@@ -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))))
+65
View File
@@ -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)
+42
View File
@@ -1,7 +1,10 @@
package media package media
import ( import (
"context"
"fmt"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -202,3 +205,42 @@ func TestUniqueTargetPath(t *testing.T) {
t.Errorf("UniqueTargetPath() = %q, want %q", path, expected) t.Errorf("UniqueTargetPath() = %q, want %q", path, expected)
} }
} }
func TestNormalizationArgs(t *testing.T) {
args := normalizationArgs("/in/movie.mov", "/out/movie.normalized.mp4")
joined := strings.Join(args, " ")
for _, want := range []string{
"-map 0:v:0",
"-map 0:a:0?",
"-c:v libx264",
"-r 30",
"-g 60",
"-keyint_min 60",
"-pix_fmt yuv420p",
"-c:a aac",
"-ar 48000",
"-ac 2",
"-avoid_negative_ts make_zero",
"/out/movie.normalized.mp4",
} {
if !strings.Contains(joined, want) {
t.Errorf("normalization args missing %q: %v", want, args)
}
}
}
func TestNormalizeVideo_FFmpegError(t *testing.T) {
origRun := runCommand
t.Cleanup(func() { runCommand = origRun })
runCommand = func(ctx context.Context, name string, args ...string) error {
if name != "ffmpeg" {
t.Errorf("runCommand name = %q, want ffmpeg", name)
}
return fmt.Errorf("simulated ffmpeg failure")
}
if _, _, err := normalizeVideo("/in/a.mov", "/out/a.normalized.mp4"); err == nil {
t.Fatal("expected error when ffmpeg fails")
}
}
+78 -14
View File
@@ -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)
+48
View File
@@ -113,6 +113,11 @@ func setupTestHub(t *testing.T) (*Hub, *gorm.DB, *mockRedis) {
if err != nil { if err != nil {
t.Fatalf("failed to open test DB: %v", err) t.Fatalf("failed to open test DB: %v", err)
} }
sqlDB, err := gormDB.DB()
if err != nil {
t.Fatalf("failed to get sql.DB: %v", err)
}
sqlDB.SetMaxOpenConns(1)
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}) gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
mr := newMockRedis() mr := newMockRedis()
hub := NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin") hub := NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
@@ -561,3 +566,46 @@ func TestHub_SubscribeRoomMessages(t *testing.T) {
mr.Publish(ctx, "room:main:messages", string(ownPayload)) mr.Publish(ctx, "room:main:messages", string(ownPayload))
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
} }
func TestHub_Broadcast_SystemNotPersisted(t *testing.T) {
hub, _, _ := setupTestHub(t)
hub.Broadcast(db.Message{Room: "main", Username: "system", Text: "bob joined main", Type: "system", Timestamp: time.Now()})
var count int64
hub.DB.Model(&db.Message{}).Where("type = ?", "system").Count(&count)
if count != 0 {
t.Errorf("system message count = %d, want 0 (system messages must not persist)", count)
}
hub.Broadcast(db.Message{Room: "main", Username: "bob", Text: "hi", Type: "public", Timestamp: time.Now()})
hub.DB.Model(&db.Message{}).Where("type = ?", "public").Count(&count)
if count != 1 {
t.Errorf("public message count = %d, want 1", count)
}
}
func TestHub_ReconnectGracePeriod(t *testing.T) {
hub, _, _ := setupTestHub(t)
hub.PresenceGracePeriod = 50 * time.Millisecond
// Disconnect schedules a delayed "left"; reconnect cancels it.
hub.scheduleLeaveAnnouncement("main", "bob")
if !hub.cancelLeaveAnnouncement("main", "bob") {
t.Error("expected a pending leave announcement to be cancelled on reconnect")
}
if hub.cancelLeaveAnnouncement("main", "bob") {
t.Error("expected no pending leave announcement after cancellation")
}
// A fresh disconnect that is never reconnected fires after the grace period
// and cleans up its timer.
hub.scheduleLeaveAnnouncement("main", "alice")
time.Sleep(120 * time.Millisecond)
hub.presenceMu.Lock()
_, exists := hub.presenceTimers["main\x00alice"]
hub.presenceMu.Unlock()
if exists {
t.Error("expected the leave announcement timer to clean itself up after firing")
}
}
+372 -64
View File
@@ -3,6 +3,8 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -16,6 +18,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 +35,41 @@ var (
putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) { putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
return client.PutObject(context.TODO(), input) return client.PutObject(context.TODO(), input)
} }
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
// Continuous playout loop metrics
loopsTotal int64
lastRunDuration time.Duration
lastRunClean bool
) )
const ( const (
@@ -59,6 +80,14 @@ const (
manifestCacheControl = "public, max-age=1, s-maxage=2, must-revalidate" manifestCacheControl = "public, max-age=1, s-maxage=2, must-revalidate"
) )
// 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 +153,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 +182,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 +480,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 +595,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 +634,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 +655,15 @@ func main() {
modeVal = 2 modeVal = 2
} }
fmt.Fprintf(w, "linearvoid_playout_mode %d\n", modeVal) fmt.Fprintf(w, "linearvoid_playout_mode %d\n", modeVal)
fmt.Fprintln(w, "# HELP linearvoid_playout_r2_cleanup_objects_total Total R2 objects deleted by retention cleanup.")
fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_cleanup_objects_total counter")
fmt.Fprintf(w, "linearvoid_playout_r2_cleanup_objects_total %d\n", r2CleanupObjectsTotal)
fmt.Fprintln(w, "# HELP linearvoid_playout_loops_total Total clean playlist completions (loops).")
fmt.Fprintln(w, "# TYPE linearvoid_playout_loops_total counter")
fmt.Fprintf(w, "linearvoid_playout_loops_total %d\n", loopsTotal)
fmt.Fprintln(w, "# HELP linearvoid_playout_run_duration_seconds Duration of the last FFmpeg run in seconds.")
fmt.Fprintln(w, "# TYPE linearvoid_playout_run_duration_seconds gauge")
fmt.Fprintf(w, "linearvoid_playout_run_duration_seconds %f\n", lastRunDuration.Seconds())
}) })
go func() { go func() {
fmt.Println("Health check server listening on :8081/health") fmt.Println("Health check server listening on :8081/health")
@@ -618,10 +682,34 @@ 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 { // Reset HLS output only when the playlist changed, we are recovering from a
fmt.Println("Failed to reset HLS output:", err) // crash/slate, or this is the first run. Otherwise continue the segment
time.Sleep(5 * time.Second) // sequence so the loop is seamless for viewers (no gap on clean loops).
continue 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 +721,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 +733,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 +766,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 +816,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 +826,206 @@ 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
} }
// r2SegmentRetentionHours returns the R2 segment retention window in hours.
func r2SegmentRetentionHours() time.Duration {
h, err := strconv.Atoi(envWithDefault("R2_SEGMENT_RETENTION_HOURS", "24"))
if err != nil || h <= 0 {
h = 24
}
return time.Duration(h) * time.Hour
}
// cleanupOldSegments deletes R2 objects under the hls/ prefix that are older than
// the retention window, excluding the live manifest and any non-run keys.
// It returns the number of objects deleted.
func cleanupOldSegments(ctx context.Context) (int, error) {
if r2Client == nil {
if err := initR2Client(); err != nil {
return 0, err
}
}
cutoff := time.Now().UTC().Add(-r2SegmentRetentionHours())
var stale []string
var token *string
for {
output, err := listObjects(r2Client, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String("hls/"),
ContinuationToken: token,
})
if err != nil {
return 0, err
}
for _, obj := range output.Contents {
key := aws.ToString(obj.Key)
if key == "hls/playlist.m3u8" || key == "hls/playlist.m3u8.tmp" {
continue
}
// Only consider segment/init objects under a run directory: hls/r-.../...
if !strings.HasPrefix(key, "hls/r-") {
continue
}
if obj.LastModified != nil && obj.LastModified.Before(cutoff) {
stale = append(stale, key)
}
}
if output.IsTruncated == nil || !*output.IsTruncated {
break
}
token = output.NextContinuationToken
}
if len(stale) == 0 {
return 0, nil
}
deleted := 0
for i := 0; i < len(stale); i += 1000 {
end := i + 1000
if end > len(stale) {
end = len(stale)
}
batch := stale[i:end]
objects := make([]types.ObjectIdentifier, len(batch))
for j, key := range batch {
objects[j] = types.ObjectIdentifier{Key: aws.String(key)}
}
if _, err := deleteObjects(r2Client, &s3.DeleteObjectsInput{
Bucket: aws.String(bucket),
Delete: &types.Delete{Objects: objects, Quiet: aws.Bool(true)},
}); err != nil {
return deleted, err
}
for _, key := range batch {
fmt.Printf("R2 cleanup: deleted %s\n", key)
}
deleted += len(batch)
}
r2CleanupObjectsTotal += int64(deleted)
return deleted, nil
}
// runR2CleanupLoop runs retention cleanup once at startup, then hourly.
func runR2CleanupLoop() {
if _, err := cleanupOldSegments(context.Background()); err != nil {
fmt.Println("R2 cleanup error:", err)
}
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
if _, err := cleanupOldSegments(context.Background()); err != nil {
fmt.Println("R2 cleanup error:", err)
}
}
}
// playlistFingerprint returns a short hash of the playlist file content, used to
// detect playlist changes between loops.
func playlistFingerprint(playlistPath string) (string, error) {
data, err := os.ReadFile(playlistPath)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), nil
}
// hlsNextStartNumber returns the next segment sequence number to use, and prunes
// stale segments outside the live window so they do not accumulate across loops.
func hlsNextStartNumber(hlsDir string, windowSize int) (int, error) {
files, err := filepath.Glob(filepath.Join(hlsDir, "seg-*.m4s"))
if err != nil {
return 0, err
}
seqOf := func(f string) int {
base := filepath.Base(f)
num := strings.TrimSuffix(strings.TrimPrefix(base, "seg-"), ".m4s")
n, err := strconv.Atoi(num)
if err != nil {
return -1
}
return n
}
maxSeq := -1
for _, f := range files {
if n := seqOf(f); n > maxSeq {
maxSeq = n
}
}
next := maxSeq + 1
// Keep the live window; delete older segments no longer referenced.
threshold := next - windowSize
for _, f := range files {
if n := seqOf(f); n >= 0 && n < threshold {
if err := os.Remove(f); err != nil && !os.IsNotExist(err) {
return next, err
}
}
}
return next, nil
}
// runValidateOnly validates the playlist and exits 0 (valid) or 1 (invalid/missing).
func runValidateOnly(playlistPath string) {
videos, err := ValidatePlaylist(playlistPath)
if err != nil {
fmt.Println("ERROR: Playlist validation failed:", err)
os.Exit(1)
}
valid := true
for _, v := range videos {
if v.Valid {
fmt.Printf("OK: %s (%.1fs)\n", v.Path, v.Duration)
} else {
valid = false
fmt.Printf("INVALID: %s — %s\n", v.Path, v.Validation)
}
}
if !valid {
os.Exit(1)
}
fmt.Println("Playlist valid.")
}
// loadSharedHLSConfig reads HLS settings from the shared runtime config file
// (written by the chat admin service) and overrides the env-derived defaults.
func loadSharedHLSConfig() {
path := envWithDefault("PLAYOUT_CONFIG_PATH", "/app/playlist/config.json")
data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
fmt.Println("Warning: cannot read shared config:", err)
}
return
}
var cfg map[string]string
if err := json.Unmarshal(data, &cfg); err != nil {
fmt.Println("Warning: cannot parse shared config:", err)
return
}
if v := strings.TrimSpace(cfg["hls_segment_duration"]); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
hlsSegmentDuration = strconv.Itoa(n)
}
}
if v := strings.TrimSpace(cfg["hls_window_size"]); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
hlsWindowSize = strconv.Itoa(n)
}
}
fmt.Printf("Shared HLS config: segment=%ss window=%s\n", hlsSegmentDuration, hlsWindowSize)
}
+193 -4
View File
@@ -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")
@@ -589,3 +592,189 @@ func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
t.Fatalf("ContentType = %q, want empty", gotContentType) t.Fatalf("ContentType = %q, want empty", gotContentType)
} }
} }
func TestR2SegmentRetentionHours(t *testing.T) {
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "")
if got := r2SegmentRetentionHours(); got != 24*time.Hour {
t.Errorf("default = %v, want 24h", got)
}
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "48")
if got := r2SegmentRetentionHours(); got != 48*time.Hour {
t.Errorf("override = %v, want 48h", got)
}
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "invalid")
if got := r2SegmentRetentionHours(); got != 24*time.Hour {
t.Errorf("invalid = %v, want 24h fallback", got)
}
os.Unsetenv("R2_SEGMENT_RETENTION_HOURS")
}
func TestCleanupOldSegments(t *testing.T) {
prevClient := r2Client
prevBucket := bucket
prevList := listObjects
prevDelete := deleteObjects
prevTotal := r2CleanupObjectsTotal
defer func() {
r2Client = prevClient
bucket = prevBucket
listObjects = prevList
deleteObjects = prevDelete
r2CleanupObjectsTotal = prevTotal
}()
bucket = "linearvoid-media"
r2Client = &s3.Client{}
now := time.Now().UTC()
old := now.Add(-25 * time.Hour)
recent := now.Add(-1 * time.Hour)
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
return &s3.ListObjectsV2Output{
Contents: []types.Object{
{Key: aws.String("hls/r-old/seg-1.m4s"), LastModified: &old},
{Key: aws.String("hls/r-old/init.mp4"), LastModified: &old},
{Key: aws.String("hls/r-new/seg-2.m4s"), LastModified: &recent},
{Key: aws.String("hls/playlist.m3u8"), LastModified: &old},
{Key: aws.String("hls/playlist.m3u8.tmp"), LastModified: &old},
},
IsTruncated: aws.Bool(false),
}, nil
}
var deletedKeys []string
deleteObjects = func(_ *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
for _, obj := range input.Delete.Objects {
deletedKeys = append(deletedKeys, aws.ToString(obj.Key))
}
return &s3.DeleteObjectsOutput{}, nil
}
n, err := cleanupOldSegments(context.Background())
if err != nil {
t.Fatalf("cleanupOldSegments error: %v", err)
}
if n != 2 {
t.Fatalf("deleted = %d, want 2", n)
}
joined := strings.Join(deletedKeys, ",")
if !strings.Contains(joined, "hls/r-old/seg-1.m4s") || !strings.Contains(joined, "hls/r-old/init.mp4") {
t.Fatalf("unexpected deleted keys: %v", deletedKeys)
}
if strings.Contains(joined, "playlist.m3u8") {
t.Fatalf("manifest must not be deleted: %v", deletedKeys)
}
if r2CleanupObjectsTotal != 2 {
t.Fatalf("r2CleanupObjectsTotal = %d, want 2", r2CleanupObjectsTotal)
}
}
func TestPlaylistFingerprint(t *testing.T) {
tmpDir := t.TempDir()
p := filepath.Join(tmpDir, "playlist.txt")
if err := os.WriteFile(p, []byte("file '/app/media/a.mp4'\n"), 0o644); err != nil {
t.Fatal(err)
}
fp1, err := playlistFingerprint(p)
if err != nil {
t.Fatalf("playlistFingerprint error: %v", err)
}
fp2, err := playlistFingerprint(p)
if err != nil {
t.Fatalf("playlistFingerprint error: %v", err)
}
if fp1 != fp2 {
t.Errorf("fingerprint not stable: %q vs %q", fp1, fp2)
}
if err := os.WriteFile(p, []byte("file '/app/media/b.mp4'\n"), 0o644); err != nil {
t.Fatal(err)
}
fp3, err := playlistFingerprint(p)
if err != nil {
t.Fatalf("playlistFingerprint error: %v", err)
}
if fp1 == fp3 {
t.Errorf("fingerprint should change when playlist content changes")
}
}
func TestHLSNextStartNumber(t *testing.T) {
tmpDir := t.TempDir()
// Create segments 0..29 with a live window of 10.
for i := 0; i < 30; i++ {
if err := os.WriteFile(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i)), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
next, err := hlsNextStartNumber(tmpDir, 10)
if err != nil {
t.Fatalf("hlsNextStartNumber error: %v", err)
}
if next != 30 {
t.Errorf("next = %d, want 30", next)
}
// Segments < 20 should be pruned; 20..29 kept (the live window).
for i := 0; i < 20; i++ {
if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); !os.IsNotExist(err) {
t.Errorf("seg-%06d should have been pruned", i)
}
}
for i := 20; i < 30; i++ {
if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); err != nil {
t.Errorf("seg-%06d should be kept: %v", i, err)
}
}
}
func TestLoadSharedHLSConfig(t *testing.T) {
prevDur := hlsSegmentDuration
prevWin := hlsWindowSize
defer func() {
hlsSegmentDuration = prevDur
hlsWindowSize = prevWin
}()
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, "config.json")
if err := os.WriteFile(cfgPath, []byte(`{"hls_segment_duration":"4","hls_window_size":"12"}`), 0o644); err != nil {
t.Fatal(err)
}
os.Setenv("PLAYOUT_CONFIG_PATH", cfgPath)
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
loadSharedHLSConfig()
if hlsSegmentDuration != "4" {
t.Errorf("hlsSegmentDuration = %q, want 4", hlsSegmentDuration)
}
if hlsWindowSize != "12" {
t.Errorf("hlsWindowSize = %q, want 12", hlsWindowSize)
}
}
func TestLoadSharedHLSConfig_MissingFile(t *testing.T) {
prevDur := hlsSegmentDuration
prevWin := hlsWindowSize
defer func() {
hlsSegmentDuration = prevDur
hlsWindowSize = prevWin
}()
os.Setenv("PLAYOUT_CONFIG_PATH", filepath.Join(t.TempDir(), "nope.json"))
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
loadSharedHLSConfig() // should be a no-op without erroring
if hlsSegmentDuration != prevDur {
t.Errorf("hlsSegmentDuration changed to %q, want unchanged %q", hlsSegmentDuration, prevDur)
}
}
+4 -27
View File
@@ -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
+2
View File
@@ -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"
+2
View File
@@ -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"
+1 -1
View File
@@ -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.
+1 -2
View File
@@ -12,8 +12,7 @@ This folder contains the documentation for the Linear Void project. The document
- [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 and Pages.
- [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
+23 -69
View File
@@ -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,7 +166,23 @@ 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.
@@ -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
@@ -588,5 +544,3 @@ See `docs/architecture/README.md` for the full arc42 documentation and the ADRs
- Project Structure: Root `README.md`. - Project Structure: Root `README.md`.
The system is ready for use. For production, review ADR-001 for cost estimates and scaling guidance. The system is ready for use. For production, review ADR-001 for cost estimates and scaling guidance.
**Last Updated**: 2026-08-03
+8 -4
View File
@@ -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,8 +175,8 @@ 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 |
+95 -39
View File
@@ -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)
@@ -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**: `cleanupOldSegments` deletes `hls/r-*/*` objects older than `R2_SEGMENT_RETENTION_HOURS` (default 24h) hourly + at startup
- **Shared config**: Reads the runtime `config.json` (`loadSharedHLSConfig`) as an env fallback for HLS segment duration/window
#### Chat Service (`backend/chat/src/main.go`) #### Chat Service (`backend/chat/src/main.go`)
@@ -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` (cleanupOldSegments) | Delete stale `hls/r-*/*` segments older than the retention window |
| **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).
@@ -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 6), `hlsWindowSize` (3–100, default 24), `matomoUrl`, `matomoSiteId`.
- Playout reads the same file (`loadSharedHLSConfig`) as an env fallback for HLS segment duration/window.
- Typed setters validate values; invalid input is rejected with `400`.
### Analytics (Matomo)
- The viewer sends pageviews to Matomo driven by the public `GET /api/matomo` endpoint, which returns `matomoUrl` + `matomoSiteId` from the runtime config store.
- Analytics is optional per deployment: leave `matomoUrl` empty to disable tracking.
### Monitoring & Resilience ### Monitoring & Resilience
Linear Void uses a two-layer monitoring model: Linear Void uses a two-layer monitoring model:
@@ -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
@@ -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
+2 -2
View File
@@ -47,8 +47,8 @@ On Cloudflare pages, set the following environment variables (needed to run the
| Secret | ADMIN_PASSWORD | `not-a-real-admin-password` | | Secret | ADMIN_PASSWORD | `not-a-real-admin-password` |
| Plaintext | ADMIN_SESSION_TTL_HOURS | `12` | | Plaintext | ADMIN_SESSION_TTL_HOURS | `12` |
| Plaintext | ADMIN_USERNAME | `boogers` | | Plaintext | ADMIN_USERNAME | `boogers` |
| Plaintext | HLS_SEGMENT_DURATION | `6` | | Plaintext | HLS_SEGMENT_DURATION | `2` |
| Plaintext | HLS_WINDOW_SIZE | `24` | | Plaintext | HLS_WINDOW_SIZE | `12` |
| Plaintext | PLAYLIST_PATH | `/app/playlist/playlist.txt` | | Plaintext | PLAYLIST_PATH | `/app/playlist/playlist.txt` |
| Plaintext | VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR | `boogers` | | Plaintext | VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR | `boogers` |
| Plaintext | VITE_PUBLIC_ADMIN_DEFAULT_ROOM | `main` | | Plaintext | VITE_PUBLIC_ADMIN_DEFAULT_ROOM | `main` |
+55 -122
View File
@@ -1,130 +1,63 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linear Void Admin</title>
<link rel="stylesheet" href="../src/style.css" />
</head>
<body class="admin-body"> <head>
<div class="admin-shell"> <meta charset="UTF-8" />
<aside class="admin-sidebar" aria-label="Primary"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<div class="brand-block"> <title>Linear Void Admin</title>
<div class="brand-badge" aria-hidden="true">LV</div> <link rel="preconnect" href="https://fonts.googleapis.com">
<div class="brand-text">Admin</div> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</div> <link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../src/style.css" />
</head>
<button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button> <body class="admin-body">
<div class="admin-shell">
<aside class="admin-sidebar" aria-label="Primary">
<div class="brand-block">
<div class="brand-badge" aria-hidden="true">LV</div>
<div class="brand-text">Admin</div>
</div>
<nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical"> <button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button>
<button
type="button"
id="tab-trigger-dashboard"
role="tab"
class="tab-trigger is-active"
data-tab="dashboard"
aria-controls="tab-dashboard"
aria-selected="true"
>
Dashboard
</button>
<button
type="button"
id="tab-trigger-upload"
role="tab"
class="tab-trigger"
data-tab="upload"
aria-controls="tab-upload"
aria-selected="false"
>
Upload Video
</button>
<button
type="button"
id="tab-trigger-playlist"
role="tab"
class="tab-trigger"
data-tab="playlist"
aria-controls="tab-playlist"
aria-selected="false"
>
Playlist
</button>
<button
type="button"
id="tab-trigger-schedule"
role="tab"
class="tab-trigger"
data-tab="schedule"
aria-controls="tab-schedule"
aria-selected="false"
>
Schedule
</button>
<button
type="button"
id="tab-trigger-moderation"
role="tab"
class="tab-trigger"
data-tab="moderation"
aria-controls="tab-moderation"
aria-selected="false"
>
Moderation
</button>
<button
type="button"
id="tab-trigger-chat-log"
role="tab"
class="tab-trigger"
data-tab="chat-log"
aria-controls="tab-chat-log"
aria-selected="false"
>
Chat Log
</button>
<button
type="button"
id="tab-trigger-users"
role="tab"
class="tab-trigger"
data-tab="users"
aria-controls="tab-users"
aria-selected="false"
>
Users
</button>
<button
type="button"
id="tab-trigger-analytics"
role="tab"
class="tab-trigger"
data-tab="analytics"
aria-controls="tab-analytics"
aria-selected="false"
>
Analytics
</button>
<button
type="button"
id="tab-trigger-system"
role="tab"
class="tab-trigger"
data-tab="system"
aria-controls="tab-system"
aria-selected="false"
>
System
</button>
</nav>
</aside>
<main class="admin-main" id="admin-main" tabindex="-1"> <nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical">
<div id="tab-content-container" aria-live="polite">Loading...</div> <button type="button" id="tab-trigger-dashboard" role="tab" class="tab-trigger is-active" data-tab="dashboard" aria-controls="tab-dashboard" aria-selected="true">
</main> Dashboard
</div> </button>
<button type="button" id="tab-trigger-upload" role="tab" class="tab-trigger" data-tab="upload" aria-controls="tab-upload" aria-selected="false">
Upload Video
</button>
<button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">
Playlist
</button>
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">
Schedule
</button>
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">
Moderation
</button>
<button type="button" id="tab-trigger-chat-log" role="tab" class="tab-trigger" data-tab="chat-log" aria-controls="tab-chat-log" aria-selected="false">
Chat Log
</button>
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">
Users
</button>
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">
Analytics
</button>
<button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">
System
</button>
</nav>
</aside>
<main class="admin-main" id="admin-main" tabindex="-1">
<div id="tab-content-container" aria-live="polite">Loading...</div>
</main>
</div>
<script type="module" src="/src/admin.ts"></script>
</body>
<script type="module" src="/src/admin.ts"></script>
</body>
</html> </html>
+57 -52
View File
@@ -1,65 +1,70 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linear Void Admin Login</title>
<link rel="stylesheet" href="../../src/style.css" />
</head>
<body class="auth-body"> <head>
<main class="auth-shell" aria-label="Admin authentication"> <meta charset="UTF-8" />
<section class="auth-card" aria-labelledby="auth-title"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<p class="auth-kicker">Linear Void</p> <title>Linear Void Admin Login</title>
<h1 id="auth-title">Admin Login</h1> <link rel="preconnect" href="https://fonts.googleapis.com">
<p class="auth-subtitle">Authenticate to access protected admin controls.</p> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../../src/style.css" />
</head>
<form id="login-form" class="form-stack" novalidate> <body class="auth-body">
<label for="username">Username</label> <main class="auth-shell" aria-label="Admin authentication">
<input id="username" type="text" class="text-input" autocomplete="username" required /> <section class="auth-card" aria-labelledby="auth-title">
<p class="auth-kicker">Linear Void</p>
<h1 id="auth-title">Admin Login</h1>
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
<label for="password">Password</label> <form id="login-form" class="form-stack" novalidate>
<input id="password" type="password" class="text-input" autocomplete="current-password" required /> <label for="username">Username</label>
<input id="username" type="text" class="text-input" autocomplete="username" required />
<button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button> <label for="password">Password</label>
<p id="status" class="status-line" aria-live="polite"></p> <input id="password" type="password" class="text-input" autocomplete="current-password" required />
</form>
</section>
</main>
<script> <button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
const form = document.getElementById("login-form"); <p id="status" class="status-line" aria-live="polite"></p>
const username = document.getElementById("username"); </form>
const password = document.getElementById("password"); </section>
const submit = document.getElementById("submit"); </main>
const status = document.getElementById("status");
form.addEventListener("submit", async (event) => { <script>
event.preventDefault(); const form = document.getElementById("login-form");
submit.disabled = true; const username = document.getElementById("username");
status.textContent = "Signing in..."; const password = document.getElementById("password");
status.className = "status-line"; const submit = document.getElementById("submit");
const status = document.getElementById("status");
try { form.addEventListener("submit", async (event) => {
const response = await fetch("/api/admin/login", { event.preventDefault();
method: "POST", submit.disabled = true;
headers: { "Content-Type": "application/json" }, status.textContent = "Signing in...";
body: JSON.stringify({ username: username.value, password: password.value }), status.className = "status-line";
});
if (!response.ok) { try {
throw new Error(`HTTP ${response.status}`); const response = await fetch("/api/admin/login", {
} method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.value, password: password.value }),
});
window.location.href = "/admin/"; if (!response.ok) {
} catch (error) { throw new Error(`HTTP ${response.status}`);
console.error("Failed to sign in", error);
status.textContent = "Login failed. Check your credentials.";
status.className = "status-line status-error";
} finally {
submit.disabled = false;
} }
});
</script> window.location.href = "/admin/";
</body> } catch (error) {
console.error("Failed to sign in", error);
status.textContent = "Login failed. Check your credentials.";
status.className = "status-line status-error";
} finally {
submit.disabled = false;
}
});
</script>
</body>
</html> </html>
+109 -69
View File
@@ -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>
(function () {
// Load Matomo analytics when configured via the admin runtime config.
fetch("/api/matomo")
.then(function (r) {
return r.ok ? r.json() : null;
})
.then(function (cfg) {
if (!cfg || !cfg.matomoUrl || !cfg.matomoSiteId) return;
var _paq = (window._paq = window._paq || []);
_paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]);
var base = String(cfg.matomoUrl).replace(/\/+$/, "");
_paq.push(["setTrackerUrl", base + "/matomo.php"]);
_paq.push(["setSiteId", String(cfg.matomoSiteId)]);
var d = document;
var g = d.createElement("script");
var s = d.getElementsByTagName("script")[0];
g.async = true;
g.src = base + "/matomo.js";
s.parentNode.insertBefore(g, s);
})
.catch(function () {
/* analytics is optional */
});
})();
</script>
<script type="module" src="/src/main.ts"></script>
</body>
<script type="module" src="/src/main.ts"></script>
</body>
</html> </html>
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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>
+5
View File
@@ -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>
+50
View File
@@ -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
View File
@@ -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";
+23
View File
@@ -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");
});
});
+155 -1
View File
@@ -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
@@ -359,6 +382,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 +582,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");
});
});
+213 -4
View File
@@ -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;
} }
@@ -81,20 +93,30 @@ function connectWebSocket(room: string) {
ws.onmessage = (event) => { ws.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 = () => { ws.onclose = () => {
@@ -104,7 +126,8 @@ function connectWebSocket(room: string) {
} }
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 +149,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;
@@ -183,7 +223,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 +324,192 @@ function sendMessage() {
function switchRoom(room: string) { function switchRoom(room: string) {
currentRoom = room; currentRoom = room;
const isDM = room.startsWith("dm:");
document.getElementById("room-name")!.textContent = document.getElementById("room-name")!.textContent =
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room"; room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
addMessage("system", `Switched to ${room} room`); addMessage("system", `Switched to ${room} room`);
const searchRow = document.getElementById("dm-search-row");
if (searchRow) searchRow.hidden = !isDM;
connectWebSocket(room); connectWebSocket(room);
} }
function togglePrivateDM() { function togglePrivateDM() {
const target = prompt("Enter username to start private chat with:"); const target = prompt("Enter username to start private chat with:");
if (target && target !== username) { if (target && target !== username) {
currentDMTarget = target;
const dmRoom = `dm:${[username, target].sort().join("-")}`; const dmRoom = `dm:${[username, target].sort().join("-")}`;
switchRoom(dmRoom); switchRoom(dmRoom);
} }
} }
async function searchDM() {
const input = document.getElementById("dm-search-input") as HTMLInputElement;
const q = input.value.trim();
if (!q || !currentDMTarget) return;
try {
const res = await fetch(`/api/dm/search?with=${encodeURIComponent(currentDMTarget)}&q=${encodeURIComponent(q)}`);
if (!res.ok) return;
const results = await res.json();
addMessage("system", `Search results (${results.length}) for "${q}":`);
for (const m of results) {
addMessage(m.type || "private", m.text, m.username, m.timestamp);
}
} catch {
/* ignore */
}
}
window.switchRoom = switchRoom; window.switchRoom = switchRoom;
window.sendMessage = sendMessage; window.sendMessage = sendMessage;
window.togglePrivateDM = togglePrivateDM; window.togglePrivateDM = togglePrivateDM;
window.toggleChatPanel = toggleChatPanel; window.toggleChatPanel = toggleChatPanel;
function updateAuthUI() {
const label = document.getElementById("chat-user-label");
const toggle = document.getElementById("auth-toggle-btn");
const logoutBtn = document.getElementById("auth-logout-btn");
const panel = document.getElementById("auth-panel");
if (label) {
label.textContent = authenticated ? username : "";
label.hidden = !authenticated;
}
if (toggle) toggle.hidden = authenticated;
if (logoutBtn) logoutBtn.hidden = !authenticated;
if (panel) panel.hidden = true;
}
async function refreshUserSession() {
try {
const res = await fetch("/api/auth/session");
if (!res.ok) return;
const data = await res.json();
const wasAnonymous = username.startsWith("viewer");
if (data.authenticated && data.username) {
authenticated = true;
username = data.username;
updateAuthUI();
if (wasAnonymous) connectWebSocket(currentRoom);
} else {
authenticated = false;
updateAuthUI();
}
} catch {
/* non-critical */
}
}
async function submitAuth(path: string) {
const u = (document.getElementById("auth-username") as HTMLInputElement).value.trim();
const p = (document.getElementById("auth-password") as HTMLInputElement).value;
const status = document.getElementById("auth-status");
if (!u || !p) {
if (status) {
status.textContent = "Username and password required.";
status.className = "status-line status-error";
}
return;
}
try {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: u, password: p }),
});
if (!res.ok) {
const text = (await res.text()).trim();
throw new Error(text || `HTTP ${res.status}`);
}
const data = await res.json();
username = data.username || u;
authenticated = true;
updateAuthUI();
if (status) {
status.textContent = `Signed in as ${username}.`;
status.className = "status-line";
}
connectWebSocket(currentRoom);
} catch (err) {
if (status) {
status.textContent = `Failed: ${err instanceof Error ? err.message : String(err)}`;
status.className = "status-line status-error";
}
}
}
function login() {
void submitAuth("/api/auth/login");
}
function register() {
void submitAuth("/api/auth/register");
}
async function logout() {
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch {
/* ignore */
}
authenticated = false;
username = "viewer" + Math.floor(Math.random() * 1000);
updateAuthUI();
connectWebSocket(currentRoom);
}
function toggleUserList() {
const panel = document.getElementById("presence-panel");
const btn = document.getElementById("users-toggle-btn");
if (!panel) return;
usersVisible = !usersVisible;
panel.hidden = !usersVisible;
if (btn) btn.setAttribute("aria-expanded", String(usersVisible));
}
function showTyping(name: string) {
if (name === username) return;
const el = document.getElementById("typing-indicator");
if (!el) return;
typingUser = name;
el.textContent = `${name} is typing...`;
el.hidden = false;
window.clearTimeout(typingTimeout);
typingTimeout = window.setTimeout(() => {
typingUser = "";
el.hidden = true;
el.textContent = "";
}, 3000);
}
function hideTyping(name: string) {
if (name !== typingUser) return;
const el = document.getElementById("typing-indicator");
typingUser = "";
window.clearTimeout(typingTimeout);
if (el) {
el.hidden = true;
el.textContent = "";
}
}
function sendTyping() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const now = Date.now();
if (now - lastTypingSent < 2000) return;
lastTypingSent = now;
ws.send(JSON.stringify({ room: currentRoom, username, text: "", type: "typing" }));
}
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
void initPlayerAndChat(); void initPlayerAndChat();
void refreshUserSession();
document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
const panel = document.getElementById("auth-panel");
if (panel) panel.hidden = !panel.hidden;
});
document.getElementById("auth-login-btn")?.addEventListener("click", login);
document.getElementById("auth-register-btn")?.addEventListener("click", register);
document.getElementById("auth-logout-btn")?.addEventListener("click", logout);
document.getElementById("dm-search-btn")?.addEventListener("click", searchDM);
document.getElementById("users-toggle-btn")?.addEventListener("click", toggleUserList);
document.getElementById("message-input")?.addEventListener("input", sendTyping);
}); });
+115 -6
View File
@@ -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 {
-66
View File
@@ -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))"
}
]
}
]
}
+984
View File
@@ -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
+923
View File
@@ -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