Compare commits
3
Commits
e15d89edfe
...
7afab6658b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7afab6658b | ||
|
|
121fdbacdc | ||
|
|
06ab5b4655 |
No files matched your search
@@ -8,6 +8,7 @@ R2_SECRET_ACCESS_KEY=your-r2-secret-key
|
|||||||
R2_BUCKET_NAME=linearvoid-media
|
R2_BUCKET_NAME=linearvoid-media
|
||||||
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
||||||
R2_REGION=auto
|
R2_REGION=auto
|
||||||
|
R2_SEGMENT_RETENTION_HOURS=24
|
||||||
|
|
||||||
# Cloudflare (for custom domain, cache rules, Workers if used)
|
# Cloudflare (for custom domain, cache rules, Workers if used)
|
||||||
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
||||||
@@ -56,6 +57,11 @@ PLAYLIST_PATH=/app/playlist/playlist.txt
|
|||||||
HLS_SEGMENT_DURATION=6
|
HLS_SEGMENT_DURATION=6
|
||||||
HLS_WINDOW_SIZE=24
|
HLS_WINDOW_SIZE=24
|
||||||
|
|
||||||
|
# Shared runtime config (written by chat admin via /api/admin/config, read by playout).
|
||||||
|
# Both resolve to the same host file through the ./data/playlist volume.
|
||||||
|
CONFIG_PATH=/app/playlist/config.json
|
||||||
|
PLAYOUT_CONFIG_PATH=/app/playlist/config.json
|
||||||
|
|
||||||
# Logging / Monitoring
|
# Logging / Monitoring
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
PROMETHEUS_PORT=9090
|
PROMETHEUS_PORT=9090
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+231
-81
@@ -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,161 @@ 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
|
||||||
@@ -1,80 +1,63 @@
|
|||||||
# 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
|
## Enhancements
|
||||||
|
|
||||||
### Phase 1: unblock playout test fixtures and lock down the source-media contract
|
### Release new version 1.0.13
|
||||||
|
|
||||||
- [x] Create small compliant fixture media in `backend/playout/testdata/` for the validator and playout tests
|
- [ ] Follow the [release procedure](/CONTRIBUTING.md#full-release-procedure) to release a new version of the software, including updating `CHANGELOG.md`, tagging the release in Git, and creating a Gitea release with notes.
|
||||||
- [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
|
## Setup phases remaining
|
||||||
|
|
||||||
- [x] Add tests for HLS player startup, manifest resolution, and cross-origin/local fallback behavior
|
|
||||||
- [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
|
|
||||||
+30
-1
@@ -4,6 +4,35 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.13] - 2026-08-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Automatic media normalization on upload**: Chat backend auto-transcodes non-conforming uploads. New `media.NormalizeVideo` wraps ffmpeg with the Linear Void profile (H.264/AAC, 30fps, 2s keyframes, yuv420p, 48kHz stereo), re-verifies output with ffprobe, and writes `*.normalized.mp4`. Media records gain `normalizing` / `normalization_failed` statuses; `POST /api/admin/media/{id}/normalize` re-triggers manually. Admin UI shows a "Normalize" action with status polling.
|
||||||
|
- **R2 segment retention**: Playout `cleanupOldSegments` deletes `hls/r-*/*` objects older than `R2_SEGMENT_RETENTION_HOURS` (default 24h), run hourly + at startup, never touching the live manifest. Exposes `linearvoid_playout_r2_cleanup_objects_total` metric. `.env.example` adds `R2_SEGMENT_RETENTION_HOURS`.
|
||||||
|
- **Playout loop metrics**: `linearvoid_playout_loops_total` (clean completions) and `linearvoid_playout_run_duration_seconds` (last run duration).
|
||||||
|
- **Runtime configuration store**: Thread-safe, JSON-persisted runtime config (`HLS_SEGMENT_DURATION`, `HLS_WINDOW_SIZE`, `MATOMO_URL`, `MATOMO_SITE_ID`) with typed getters/setters + validation, editable via `GET/PUT /api/admin/config`. Playout reads the shared `config.json` as env fallback.
|
||||||
|
- **Matomo analytics**: Viewer pageview snippet in `index.html` driven by the public `GET /api/matomo` endpoint.
|
||||||
|
- **Chat user accounts**: Register/login/logout (`POST /api/auth/register`, `/login`, `/logout`, `GET /api/auth/session`), bcrypt password hashing, `linearvoid_user_session` cookie, and a viewer sign-in UI.
|
||||||
|
- **Private DM history & search**: `GET /api/dm/history` (paginated) and `GET /api/dm/search`, with a DM search UI in the viewer.
|
||||||
|
- **Chat UI polish**: Typing indicator, user-list toggle, media status badges, and an "Add to playlist" action gated to `ready` media.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Playout seamless looping**: `main()` now only resets HLS output when the playlist changed (SHA-256 `playlistRevision`), on crash/slate recovery, or first run; otherwise it continues segment numbering via `-start_number` and prunes stale segments. Eliminates the viewer gap on clean playlist loops.
|
||||||
|
- **Single playout entrypoint**: `start-playout.sh` now runs `/app/playlist-manager -validate` then `exec`s the Go binary, removing the duplicate shell ffmpeg loop. Added a `-validate` mode to `main()`.
|
||||||
|
- **Admin media library**: Shows status badges (`ready`/`needs_normalization`/`normalizing`/`failed`) and only offers "Add to playlist" for `ready` media; playlist entries show per-item status badges.
|
||||||
|
- **Prometheus TSDB retention**: The `prometheus` service now sets `--storage.tsdb.retention.time=30d` and `--storage.tsdb.retention.size=15GB` (in both `docker-compose.yml` and `docker-compose.prod.yml`), so metrics older than 30 days are deleted.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Media: `TestNormalizationArgs`, `TestNormalizeVideo_FFmpegError`.
|
||||||
|
- Handlers: `TestNormalizeMediaRecord_Success`, `TestNormalizeMediaRecord_Failure`, `TestHandleAdminMediaByID_Normalize`.
|
||||||
|
- Playout: `TestCleanupOldSegments`, `TestR2SegmentRetentionHours`, `TestPlaylistFingerprint`, `TestHLSNextStartNumber`, `TestLoadSharedHLSConfig`.
|
||||||
|
- Config: `runtime_test.go` (get/set, persist/reload, env fallback, validation).
|
||||||
|
- Auth: `TestUserSessionStore`.
|
||||||
|
- Handlers: config GET/PUT + validation, DM history/search, user register/login/session.
|
||||||
|
|
||||||
## [1.0.12] - 2026-08-11
|
## [1.0.12] - 2026-08-11
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -13,7 +42,7 @@
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Playout R2 endpoint validation**: `initR2Client()` now validates all required R2 environment variables (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`) before creating the S3 client. Previously, an empty `R2_ENDPOINT` caused cryptic `Custom endpoint '' was not a valid URI` errors on every segment upload attempt. Playout now gracefully degrades when R2 is not configured — local HLS via nginx continues to work.
|
- **Playout R2 endpoint validation**: `initR2Client()` now validates all required R2 environment variables (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`) before creating the S3 client. Previously, an empty `R2_ENDPOINT` caused cryptic `Custom endpoint '' was not a valid URI` errors on every segment upload attempt. Playout now gracefully degrades when R2 is not configured — local HLS via nginx continues to work.
|
||||||
- **CD workflow missing R2 secrets**: `.gitea/workflows/cd.yml` "Prepare environment" step now injects all R2_ variables (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, plus admin variants) from Gitea secrets. Previously the generated `.env` was missing R2 entirely, causing the playout container to start with an empty endpoint.
|
- **CD workflow missing R2 secrets**: `.gitea/workflows/cd.yml` "Prepare environment" step now injects all R2\_ variables (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, plus admin variants) from Gitea secrets. Previously the generated `.env` was missing R2 entirely, causing the playout container to start with an empty endpoint.
|
||||||
- **Deployment docs**: `docs/DEPLOYMENT.md` now includes explicit R2 and Cloudflare variable lists plus a "Gitea Secrets" table documenting all 15 required secrets.
|
- **Deployment docs**: `docs/DEPLOYMENT.md` now includes explicit R2 and Cloudflare variable lists plus a "Gitea Secrets" table documenting all 15 required secrets.
|
||||||
|
|
||||||
## [1.0.11] - 2026-08-10
|
## [1.0.11] - 2026-08-10
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||||
[](https://git.allucanget.biz)
|
[](https://git.allucanget.biz)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -46,6 +46,8 @@ flowchart TD
|
|||||||
P -->|sync| R2
|
P -->|sync| R2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A detailed description of the Streaming architecture, playout, and delivery is available in [`docs/STREAMING.md`](docs/STREAMING.md).
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
This structure directly reflects the building blocks, runtime flows, deployment view, and cross-cutting concepts defined in the arc42 documentation.
|
This structure directly reflects the building blocks, runtime flows, deployment view, and cross-cutting concepts defined in the arc42 documentation.
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/redis/go-redis/v9 v9.7.0
|
github.com/redis/go-redis/v9 v9.7.0
|
||||||
|
golang.org/x/crypto v0.37.0
|
||||||
gorm.io/driver/postgres v1.5.11
|
gorm.io/driver/postgres v1.5.11
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
@@ -24,7 +25,6 @@ require (
|
|||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/crypto v0.37.0 // indirect
|
|
||||||
golang.org/x/sync v0.13.0 // indirect
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/text v0.24.0 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
|
|||||||
@@ -137,4 +137,52 @@ func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc)
|
|||||||
next(w, r)
|
next(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSessionStore maps user session tokens to usernames with expiry.
|
||||||
|
type UserSessionStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]userSession
|
||||||
|
}
|
||||||
|
|
||||||
|
type userSession struct {
|
||||||
|
Username string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserSessionStore creates a new user session store.
|
||||||
|
func NewUserSessionStore() *UserSessionStore {
|
||||||
|
return &UserSessionStore{sessions: make(map[string]userSession)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stores a user session token with username and expiry.
|
||||||
|
func (s *UserSessionStore) Add(token, username string, expiresAt time.Time) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sessions[token] = userSession{Username: username, ExpiresAt: expiresAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username returns the username for a valid, unexpired token.
|
||||||
|
func (s *UserSessionStore) Username(token string) (string, bool) {
|
||||||
|
if token == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
sess, ok := s.sessions[token]
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if time.Now().After(sess.ExpiresAt) {
|
||||||
|
delete(s.sessions, token)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return sess.Username, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deletes a user session token.
|
||||||
|
func (s *UserSessionStore) Remove(token string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.sessions, token)
|
||||||
}
|
}
|
||||||
@@ -245,4 +245,33 @@ func TestRequireAdmin(t *testing.T) {
|
|||||||
if w3.Code != http.StatusUnauthorized {
|
if w3.Code != http.StatusUnauthorized {
|
||||||
t.Errorf("status = %d, want 401", w3.Code)
|
t.Errorf("status = %d, want 401", w3.Code)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserSessionStore(t *testing.T) {
|
||||||
|
store := NewUserSessionStore()
|
||||||
|
|
||||||
|
if _, ok := store.Username(""); ok {
|
||||||
|
t.Error("Username(\"\") = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, _ := GenerateToken()
|
||||||
|
store.Add(token, "alice", time.Now().Add(1*time.Hour))
|
||||||
|
|
||||||
|
username, ok := store.Username(token)
|
||||||
|
if !ok || username != "alice" {
|
||||||
|
t.Errorf("Username() = %q, %v; want alice, true", username, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired session
|
||||||
|
expired, _ := GenerateToken()
|
||||||
|
store.Add(expired, "bob", time.Now().Add(-1*time.Hour))
|
||||||
|
if _, ok := store.Username(expired); ok {
|
||||||
|
t.Error("Username(expired) = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
store.Remove(token)
|
||||||
|
if _, ok := store.Username(token); ok {
|
||||||
|
t.Error("Username after Remove = true, want false")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ const (
|
|||||||
DefaultRateLimitMax = 10
|
DefaultRateLimitMax = 10
|
||||||
DefaultRateLimitWindow = 10 * time.Second
|
DefaultRateLimitWindow = 10 * time.Second
|
||||||
DefaultAdminSessionTTL = 12 * time.Hour
|
DefaultAdminSessionTTL = 12 * time.Hour
|
||||||
|
DefaultUserSessionTTL = 30 * 24 * time.Hour
|
||||||
DefaultChatPort = "3000"
|
DefaultChatPort = "3000"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,6 +69,19 @@ func AdminSessionTTL() time.Duration {
|
|||||||
return time.Duration(hours) * time.Hour
|
return time.Duration(hours) * time.Hour
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserSessionTTL returns the user session TTL from env or default (30 days).
|
||||||
|
func UserSessionTTL() time.Duration {
|
||||||
|
raw := strings.TrimSpace(os.Getenv("USER_SESSION_TTL_HOURS"))
|
||||||
|
if raw == "" {
|
||||||
|
return DefaultUserSessionTTL
|
||||||
|
}
|
||||||
|
hours, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || hours <= 0 {
|
||||||
|
return DefaultUserSessionTTL
|
||||||
|
}
|
||||||
|
return time.Duration(hours) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
// RateLimitMax returns the chat rate limit max messages per window.
|
// RateLimitMax returns the chat rate limit max messages per window.
|
||||||
func RateLimitMax() int {
|
func RateLimitMax() int {
|
||||||
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
|
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Runtime configuration keys stored in the runtime config file.
|
||||||
|
const (
|
||||||
|
keyMatomoURL = "matomo_url"
|
||||||
|
keyMatomoSiteID = "matomo_site_id"
|
||||||
|
keyHLSSegmentDur = "hls_segment_duration"
|
||||||
|
keyHLSWindowSize = "hls_window_size"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default HLS values used when neither env nor persisted config is set.
|
||||||
|
const (
|
||||||
|
defaultHLSSegmentDuration = 6
|
||||||
|
defaultHLSWindowSize = 24
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
runtimeMu sync.RWMutex
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigPath returns the runtime config JSON file path from env or default.
|
||||||
|
// The default lives in the shared playlist volume so the playout service can
|
||||||
|
// read the same file (chat has read-write, playout read-only).
|
||||||
|
func ConfigPath() string {
|
||||||
|
p := strings.TrimSpace(os.Getenv("CONFIG_PATH"))
|
||||||
|
if p == "" {
|
||||||
|
return "/app/playlist/config.json"
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRuntime returns a value from the runtime store and whether it is present.
|
||||||
|
func GetRuntime(key string) (string, bool) {
|
||||||
|
runtimeMu.RLock()
|
||||||
|
defer runtimeMu.RUnlock()
|
||||||
|
v, ok := runtimeStore[key]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRuntime stores a value in the runtime store (in-memory only).
|
||||||
|
func SetRuntime(key, value string) {
|
||||||
|
runtimeMu.Lock()
|
||||||
|
defer runtimeMu.Unlock()
|
||||||
|
runtimeStore[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadRuntimeConfig loads the persisted runtime config from disk. If the file does
|
||||||
|
// not exist, it seeds the store from environment variables.
|
||||||
|
func LoadRuntimeConfig() error {
|
||||||
|
data, err := os.ReadFile(ConfigPath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
runtimeMu.Lock()
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
runtimeMu.Unlock()
|
||||||
|
seedRuntimeDefaults()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read runtime config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := map[string]string{}
|
||||||
|
if err := json.Unmarshal(data, &values); err != nil {
|
||||||
|
return fmt.Errorf("parse runtime config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeMu.Lock()
|
||||||
|
defer runtimeMu.Unlock()
|
||||||
|
runtimeStore = values
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRuntimeConfig persists the runtime store to disk as JSON.
|
||||||
|
func SaveRuntimeConfig() error {
|
||||||
|
runtimeMu.RLock()
|
||||||
|
data, err := json.MarshalIndent(runtimeStore, "", " ")
|
||||||
|
runtimeMu.RUnlock()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal runtime config: %w", err)
|
||||||
|
}
|
||||||
|
if dir := filepath.Dir(ConfigPath()); dir != "" && dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create runtime config dir: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(ConfigPath(), append(data, '\n'), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write runtime config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedRuntimeDefaults populates the store from environment variables.
|
||||||
|
func seedRuntimeDefaults() {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("MATOMO_URL")); v != "" {
|
||||||
|
SetRuntime(keyMatomoURL, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("MATOMO_SITE_ID")); v != "" {
|
||||||
|
SetRuntime(keyMatomoSiteID, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION")); v != "" {
|
||||||
|
SetRuntime(keyHLSSegmentDur, v)
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE")); v != "" {
|
||||||
|
SetRuntime(keyHLSWindowSize, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatomoURL returns the Matomo analytics URL.
|
||||||
|
func MatomoURL() string {
|
||||||
|
if v, ok := GetRuntime(keyMatomoURL); ok && strings.TrimSpace(v) != "" {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(os.Getenv("MATOMO_URL"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatomoSiteID returns the Matomo site ID.
|
||||||
|
func MatomoSiteID() string {
|
||||||
|
if v, ok := GetRuntime(keyMatomoSiteID); ok && strings.TrimSpace(v) != "" {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(os.Getenv("MATOMO_SITE_ID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLSSegmentDuration returns the HLS segment duration in seconds.
|
||||||
|
func HLSSegmentDuration() int {
|
||||||
|
raw := ""
|
||||||
|
if v, ok := GetRuntime(keyHLSSegmentDur); ok {
|
||||||
|
raw = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
raw = strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION"))
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return defaultHLSSegmentDuration
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLSWindowSize returns the HLS playlist window size in segments.
|
||||||
|
func HLSWindowSize() int {
|
||||||
|
raw := ""
|
||||||
|
if v, ok := GetRuntime(keyHLSWindowSize); ok {
|
||||||
|
raw = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
raw = strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE"))
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return defaultHLSWindowSize
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMatomoURL validates and stores the Matomo URL.
|
||||||
|
func SetMatomoURL(raw string) error {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
SetRuntime(keyMatomoURL, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return fmt.Errorf("matomo URL must be an absolute http(s) URL")
|
||||||
|
}
|
||||||
|
SetRuntime(keyMatomoURL, raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMatomoSiteID validates and stores the Matomo site ID.
|
||||||
|
func SetMatomoSiteID(raw string) error {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw != "" {
|
||||||
|
if _, err := strconv.Atoi(raw); err != nil {
|
||||||
|
return fmt.Errorf("matomo site ID must be numeric")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SetRuntime(keyMatomoSiteID, raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHLSSegmentDuration validates (2–15s) and stores the HLS segment duration.
|
||||||
|
func SetHLSSegmentDuration(seconds int) error {
|
||||||
|
if seconds < 2 || seconds > 15 {
|
||||||
|
return fmt.Errorf("HLS segment duration must be between 2 and 15 seconds")
|
||||||
|
}
|
||||||
|
SetRuntime(keyHLSSegmentDur, strconv.Itoa(seconds))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHLSWindowSize validates (3–100) and stores the HLS window size.
|
||||||
|
func SetHLSWindowSize(size int) error {
|
||||||
|
if size < 3 || size > 100 {
|
||||||
|
return fmt.Errorf("HLS window size must be between 3 and 100 segments")
|
||||||
|
}
|
||||||
|
SetRuntime(keyHLSWindowSize, strconv.Itoa(size))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeConfigSnapshot returns the resolved runtime configuration values.
|
||||||
|
func RuntimeConfigSnapshot() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"matomoUrl": MatomoURL(),
|
||||||
|
"matomoSiteId": MatomoSiteID(),
|
||||||
|
"hlsSegmentDuration": HLSSegmentDuration(),
|
||||||
|
"hlsWindowSize": HLSWindowSize(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resetRuntime(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
runtimeMu.Lock()
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
runtimeMu.Unlock()
|
||||||
|
os.Unsetenv("CONFIG_PATH")
|
||||||
|
os.Unsetenv("MATOMO_URL")
|
||||||
|
os.Unsetenv("MATOMO_SITE_ID")
|
||||||
|
os.Unsetenv("HLS_SEGMENT_DURATION")
|
||||||
|
os.Unsetenv("HLS_WINDOW_SIZE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigPath(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
if got := ConfigPath(); got != "/app/playlist/config.json" {
|
||||||
|
t.Errorf("ConfigPath() = %q, want default /app/playlist/config.json", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("CONFIG_PATH", "/tmp/custom.json")
|
||||||
|
defer os.Unsetenv("CONFIG_PATH")
|
||||||
|
if got := ConfigPath(); got != "/tmp/custom.json" {
|
||||||
|
t.Errorf("ConfigPath() = %q, want /tmp/custom.json", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSetRuntime(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
if _, ok := GetRuntime("foo"); ok {
|
||||||
|
t.Errorf("expected missing key")
|
||||||
|
}
|
||||||
|
SetRuntime("foo", "bar")
|
||||||
|
v, ok := GetRuntime("foo")
|
||||||
|
if !ok || v != "bar" {
|
||||||
|
t.Errorf("GetRuntime() = %q, %v; want bar, true", v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGettersDefaults(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
if got := MatomoURL(); got != "" {
|
||||||
|
t.Errorf("MatomoURL() = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := MatomoSiteID(); got != "" {
|
||||||
|
t.Errorf("MatomoSiteID() = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := HLSSegmentDuration(); got != 6 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 6", got)
|
||||||
|
}
|
||||||
|
if got := HLSWindowSize(); got != 24 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 24", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGettersEnvFallback(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
os.Setenv("HLS_SEGMENT_DURATION", "4")
|
||||||
|
os.Setenv("HLS_WINDOW_SIZE", "12")
|
||||||
|
os.Setenv("MATOMO_URL", "https://analytics.example.com")
|
||||||
|
os.Setenv("MATOMO_SITE_ID", "7")
|
||||||
|
|
||||||
|
if got := HLSSegmentDuration(); got != 4 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 4", got)
|
||||||
|
}
|
||||||
|
if got := HLSWindowSize(); got != 12 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 12", got)
|
||||||
|
}
|
||||||
|
if got := MatomoURL(); got != "https://analytics.example.com" {
|
||||||
|
t.Errorf("MatomoURL() = %q", got)
|
||||||
|
}
|
||||||
|
if got := MatomoSiteID(); got != "7" {
|
||||||
|
t.Errorf("MatomoSiteID() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRuntimeConfigSeedsFromEnv(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json"))
|
||||||
|
os.Setenv("HLS_SEGMENT_DURATION", "8")
|
||||||
|
|
||||||
|
if err := LoadRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("LoadRuntimeConfig error: %v", err)
|
||||||
|
}
|
||||||
|
if got := HLSSegmentDuration(); got != 8 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 8 (seeded from env)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAndLoadRuntimeConfig(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json"))
|
||||||
|
|
||||||
|
if err := SetHLSSegmentDuration(5); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SetHLSWindowSize(30); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SetMatomoURL("https://stats.example.com"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SetMatomoSiteID("42"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("SaveRuntimeConfig error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the store and reload from disk.
|
||||||
|
runtimeMu.Lock()
|
||||||
|
runtimeStore = map[string]string{}
|
||||||
|
runtimeMu.Unlock()
|
||||||
|
|
||||||
|
if err := LoadRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("LoadRuntimeConfig error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := HLSSegmentDuration(); got != 5 {
|
||||||
|
t.Errorf("HLSSegmentDuration() = %d, want 5", got)
|
||||||
|
}
|
||||||
|
if got := HLSWindowSize(); got != 30 {
|
||||||
|
t.Errorf("HLSWindowSize() = %d, want 30", got)
|
||||||
|
}
|
||||||
|
if got := MatomoURL(); got != "https://stats.example.com" {
|
||||||
|
t.Errorf("MatomoURL() = %q", got)
|
||||||
|
}
|
||||||
|
if got := MatomoSiteID(); got != "42" {
|
||||||
|
t.Errorf("MatomoSiteID() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettersValidation(t *testing.T) {
|
||||||
|
resetRuntime(t)
|
||||||
|
|
||||||
|
if err := SetHLSSegmentDuration(1); err == nil {
|
||||||
|
t.Error("expected error for duration 1")
|
||||||
|
}
|
||||||
|
if err := SetHLSSegmentDuration(20); err == nil {
|
||||||
|
t.Error("expected error for duration 20")
|
||||||
|
}
|
||||||
|
if err := SetHLSSegmentDuration(6); err != nil {
|
||||||
|
t.Errorf("unexpected error for duration 6: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetHLSWindowSize(2); err == nil {
|
||||||
|
t.Error("expected error for window 2")
|
||||||
|
}
|
||||||
|
if err := SetHLSWindowSize(101); err == nil {
|
||||||
|
t.Error("expected error for window 101")
|
||||||
|
}
|
||||||
|
if err := SetHLSWindowSize(24); err != nil {
|
||||||
|
t.Errorf("unexpected error for window 24: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetMatomoURL("not-a-url"); err == nil {
|
||||||
|
t.Error("expected error for invalid URL")
|
||||||
|
}
|
||||||
|
if err := SetMatomoURL("ftp://example.com"); err == nil {
|
||||||
|
t.Error("expected error for non-http URL")
|
||||||
|
}
|
||||||
|
if err := SetMatomoURL("https://stats.example.com"); err != nil {
|
||||||
|
t.Errorf("unexpected error for valid URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetMatomoSiteID("abc"); err == nil {
|
||||||
|
t.Error("expected error for non-numeric site ID")
|
||||||
|
}
|
||||||
|
if err := SetMatomoSiteID("99"); err != nil {
|
||||||
|
t.Errorf("unexpected error for numeric site ID: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ func InitDB(databaseURL string) (*DBClient, string, error) {
|
|||||||
return nil, driver, err
|
return nil, driver, err
|
||||||
}
|
}
|
||||||
|
|
||||||
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
|
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{}, &User{})
|
||||||
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
|
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
|
||||||
|
|
||||||
return &DBClient{DB: gormDB}, driver, nil
|
return &DBClient{DB: gormDB}, driver, nil
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ type Media struct {
|
|||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User represents a registered chat user account (ADR-006).
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Username string `gorm:"uniqueIndex;size:32" json:"username"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
// ScheduleOverride represents a scheduled playlist override (ADR-004)
|
// ScheduleOverride represents a scheduled playlist override (ADR-004)
|
||||||
type ScheduleOverride struct {
|
type ScheduleOverride struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"chat/src/auth"
|
"chat/src/auth"
|
||||||
@@ -197,6 +198,7 @@ type Dependencies struct {
|
|||||||
Redis db.RedisClient
|
Redis db.RedisClient
|
||||||
Hub *ws.Hub
|
Hub *ws.Hub
|
||||||
SessionStore *auth.SessionStore
|
SessionStore *auth.SessionStore
|
||||||
|
UserSessions *auth.UserSessionStore
|
||||||
DBDriver string
|
DBDriver string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +335,251 @@ func HandleAdminSession(deps *Dependencies) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userSessionCookieName is the cookie for registered chat users.
|
||||||
|
const userSessionCookieName = "linearvoid_user_session"
|
||||||
|
|
||||||
|
// UserCredentialsPayload is used by registration and login.
|
||||||
|
type UserCredentialsPayload struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateUsername checks the username length constraints.
|
||||||
|
func validateUsername(username string) error {
|
||||||
|
if len(username) < 3 || len(username) > 32 {
|
||||||
|
return fmt.Errorf("username must be 3-32 characters")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePassword checks the password length constraints.
|
||||||
|
func validatePassword(password string) error {
|
||||||
|
if len(password) < 6 {
|
||||||
|
return fmt.Errorf("password must be at least 6 characters")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startUserSession issues a user session cookie for the given username.
|
||||||
|
func startUserSession(deps *Dependencies, w http.ResponseWriter, r *http.Request, username string) {
|
||||||
|
token, err := auth.GenerateToken()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(config.UserSessionTTL())
|
||||||
|
deps.UserSessions.Add(token, username, expiresAt)
|
||||||
|
auth.SetCookie(w, r, userSessionCookieName, token, expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUserRegister creates a new chat user and signs them in.
|
||||||
|
func HandleUserRegister(deps *Dependencies) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload UserCredentialsPayload
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(payload.Username)
|
||||||
|
if err := validateUsername(username); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validatePassword(payload.Password); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
deps.DB.Model(&db.User{}).Where("username = ?", username).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
http.Error(w, "username already taken", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(payload.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to hash password", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := db.User{Username: username, PasswordHash: string(hash)}
|
||||||
|
if err := deps.DB.Create(&user).Error; err != nil {
|
||||||
|
http.Error(w, "failed to create user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startUserSession(deps, w, r, user.Username)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"username": user.Username})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUserLogin verifies credentials and signs the user in.
|
||||||
|
func HandleUserLogin(deps *Dependencies) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload UserCredentialsPayload
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(payload.Username)
|
||||||
|
|
||||||
|
var user db.User
|
||||||
|
if err := deps.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||||
|
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(payload.Password)); err != nil {
|
||||||
|
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startUserSession(deps, w, r, user.Username)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"username": user.Username})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUserSession returns the current user session state.
|
||||||
|
func HandleUserSession(deps *Dependencies) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, _ := auth.GetTokenFromCookie(r, userSessionCookieName)
|
||||||
|
username, ok := deps.UserSessions.Username(token)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"authenticated": ok,
|
||||||
|
"username": username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUserLogout clears the user session.
|
||||||
|
func HandleUserLogout(deps *Dependencies) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if token, ok := auth.GetTokenFromCookie(r, userSessionCookieName); ok {
|
||||||
|
deps.UserSessions.Remove(token)
|
||||||
|
}
|
||||||
|
auth.ClearCookie(w, r, userSessionCookieName)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentUsername returns the authenticated username from the user session cookie.
|
||||||
|
func currentUsername(deps *Dependencies, r *http.Request) (string, bool) {
|
||||||
|
token, ok := auth.GetTokenFromCookie(r, userSessionCookieName)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return deps.UserSessions.Username(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmRoomFor returns the canonical DM room for two users (sorted, prefixed).
|
||||||
|
func dmRoomFor(a, b string) string {
|
||||||
|
if a > b {
|
||||||
|
a, b = b, a
|
||||||
|
}
|
||||||
|
return "dm:" + a + "-" + b
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUserDMHistory returns paginated private message history with a target user.
|
||||||
|
func HandleUserDMHistory(deps *Dependencies) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, ok := currentUsername(deps, r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(r.URL.Query().Get("with"))
|
||||||
|
if target == "" {
|
||||||
|
http.Error(w, "missing target user", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 50
|
||||||
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||||
|
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
room := dmRoomFor(username, target)
|
||||||
|
|
||||||
|
query := deps.DB.Where("room = ?", room)
|
||||||
|
if beforeRaw := r.URL.Query().Get("before"); beforeRaw != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, beforeRaw); err == nil {
|
||||||
|
query = query.Where("timestamp < ?", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var messages []db.Message
|
||||||
|
if err := query.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
|
||||||
|
http.Error(w, "failed to load history", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
messages[i], messages[j] = messages[j], messages[i]
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUserDMSearch searches private messages with a target user.
|
||||||
|
func HandleUserDMSearch(deps *Dependencies) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, ok := currentUsername(deps, r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(r.URL.Query().Get("with"))
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
if target == "" || q == "" {
|
||||||
|
http.Error(w, "missing target or query", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 50
|
||||||
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||||
|
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
room := dmRoomFor(username, target)
|
||||||
|
var messages []db.Message
|
||||||
|
if err := deps.DB.Where("room = ? AND LOWER(text) LIKE ?", room, "%"+strings.ToLower(q)+"%").
|
||||||
|
Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
|
||||||
|
http.Error(w, "failed to search", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
messages[i], messages[j] = messages[j], messages[i]
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleAdminOverview returns the admin dashboard overview.
|
// HandleAdminOverview returns the admin dashboard overview.
|
||||||
func HandleAdminOverview(deps *Dependencies) http.HandlerFunc {
|
func HandleAdminOverview(deps *Dependencies) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -757,29 +1004,83 @@ func HandleAdminSystemSummary(deps *Dependencies) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandlePublicMatomo exposes the Matomo analytics settings for the viewer frontend.
|
||||||
|
func HandlePublicMatomo() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"matomoUrl": config.MatomoURL(),
|
||||||
|
"matomoSiteId": config.MatomoSiteID(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminConfigResponse builds the admin configuration payload from runtime config
|
||||||
|
// and read-only runtime facts.
|
||||||
|
func adminConfigResponse(deps *Dependencies) map[string]any {
|
||||||
|
resp := config.RuntimeConfigSnapshot()
|
||||||
|
resp["rateLimitMax"] = config.RateLimitMax()
|
||||||
|
resp["rateLimitWindowSec"] = int(config.RateLimitWindow().Seconds())
|
||||||
|
resp["databaseDriver"] = deps.DBDriver
|
||||||
|
resp["redisEnabled"] = deps.Redis != nil
|
||||||
|
resp["sessionTTLHours"] = int(config.AdminSessionTTL().Hours())
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
// HandleAdminConfig returns/updates runtime configuration.
|
// HandleAdminConfig returns/updates runtime configuration.
|
||||||
func HandleAdminConfig(deps *Dependencies) http.HandlerFunc {
|
func HandleAdminConfig(deps *Dependencies) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(adminConfigResponse(deps))
|
||||||
"rateLimitMax": config.RateLimitMax(),
|
|
||||||
"rateLimitWindowSec": int(config.RateLimitWindow().Seconds()),
|
|
||||||
"databaseDriver": deps.DBDriver,
|
|
||||||
"redisEnabled": deps.Redis != nil,
|
|
||||||
"sessionTTLHours": int(config.AdminSessionTTL().Hours()),
|
|
||||||
})
|
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
var payload map[string]any
|
// Editable runtime fields (Matomo + HLS). Rate limit fields remain
|
||||||
|
// env-driven and are intentionally read-only here.
|
||||||
|
var payload struct {
|
||||||
|
MatomoURL *string `json:"matomoUrl"`
|
||||||
|
MatomoSiteID *string `json:"matomoSiteId"`
|
||||||
|
HLSSegmentDuration *int `json:"hlsSegmentDuration"`
|
||||||
|
HLSWindowSize *int `json:"hlsWindowSize"`
|
||||||
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Note: rate limit config is read-only at runtime in this refactored version.
|
if payload.MatomoURL != nil {
|
||||||
// To change, restart with env vars.
|
if err := config.SetMatomoURL(*payload.MatomoURL); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if payload.MatomoSiteID != nil {
|
||||||
|
if err := config.SetMatomoSiteID(*payload.MatomoSiteID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if payload.HLSSegmentDuration != nil {
|
||||||
|
if err := config.SetHLSSegmentDuration(*payload.HLSSegmentDuration); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if payload.HLSWindowSize != nil {
|
||||||
|
if err := config.SetHLSWindowSize(*payload.HLSWindowSize); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := config.SaveRuntimeConfig(); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
json.NewEncoder(w).Encode(adminConfigResponse(deps))
|
||||||
default:
|
default:
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
@@ -976,9 +1277,9 @@ func HandleAdminMedia(deps *Dependencies) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
normResult := media.ValidateNormalization(targetPath)
|
normResult := media.ValidateNormalization(targetPath)
|
||||||
status := "ready"
|
status := media.StatusReady
|
||||||
if !normResult.Valid {
|
if !normResult.Valid {
|
||||||
status = "needs_normalization"
|
status = media.StatusNeedsNormalization
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := filepath.Base(targetPath)
|
filename := filepath.Base(targetPath)
|
||||||
@@ -998,6 +1299,10 @@ func HandleAdminMedia(deps *Dependencies) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !normResult.Valid {
|
||||||
|
go normalizeMediaRecord(deps, mediaRecord)
|
||||||
|
}
|
||||||
|
|
||||||
files, err := media.ListFiles(deps.DB)
|
files, err := media.ListFiles(deps.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
|
||||||
@@ -1307,9 +1612,9 @@ func HandleAdminMediaUploads(deps *Dependencies) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
normResult := media.ValidateNormalization(targetPath)
|
normResult := media.ValidateNormalization(targetPath)
|
||||||
status := "ready"
|
status := media.StatusReady
|
||||||
if !normResult.Valid {
|
if !normResult.Valid {
|
||||||
status = "needs_normalization"
|
status = media.StatusNeedsNormalization
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := filepath.Base(targetPath)
|
filename := filepath.Base(targetPath)
|
||||||
@@ -1329,6 +1634,10 @@ func HandleAdminMediaUploads(deps *Dependencies) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !normResult.Valid {
|
||||||
|
go normalizeMediaRecord(deps, mediaRecord)
|
||||||
|
}
|
||||||
|
|
||||||
_ = os.RemoveAll(media.UploadSessionDir(mediaDir, uploadID))
|
_ = os.RemoveAll(media.UploadSessionDir(mediaDir, uploadID))
|
||||||
|
|
||||||
files, err := media.ListFiles(deps.DB)
|
files, err := media.ListFiles(deps.DB)
|
||||||
@@ -1348,6 +1657,11 @@ func HandleAdminMediaUploads(deps *Dependencies) http.HandlerFunc {
|
|||||||
func HandleAdminMediaByID(deps *Dependencies) http.HandlerFunc {
|
func HandleAdminMediaByID(deps *Dependencies) http.HandlerFunc {
|
||||||
mediaDir := config.MediaDir()
|
mediaDir := config.MediaDir()
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/normalize") {
|
||||||
|
handleNormalizeMedia(deps, w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
mediaID, err := media.GetMediaIDFromPath(r.URL.Path, "/api/admin/media/")
|
mediaID, err := media.GetMediaIDFromPath(r.URL.Path, "/api/admin/media/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -1424,6 +1738,75 @@ func HandleAdminMediaByID(deps *Dependencies) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeMediaRecord runs ffmpeg normalization for a media record and updates
|
||||||
|
// its status, path, duration, and size. Safe to run asynchronously.
|
||||||
|
func normalizeMediaRecord(deps *Dependencies, record db.Media) {
|
||||||
|
src := record.Path
|
||||||
|
dst := strings.TrimSuffix(src, filepath.Ext(src)) + ".normalized.mp4"
|
||||||
|
|
||||||
|
if err := deps.DB.Model(&record).Update("status", media.StatusNormalizing).Error; err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
duration, size, err := media.NormalizeVideo(src, dst)
|
||||||
|
if err != nil {
|
||||||
|
_ = deps.DB.Model(&record).Updates(map[string]any{
|
||||||
|
"status": media.StatusNormalizationFailed,
|
||||||
|
"tags": `"normalization":false`,
|
||||||
|
}).Error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = deps.DB.Model(&record).Updates(map[string]any{
|
||||||
|
"path": dst,
|
||||||
|
"filename": filepath.Base(dst),
|
||||||
|
"size_bytes": size,
|
||||||
|
"duration_seconds": duration,
|
||||||
|
"status": media.StatusReady,
|
||||||
|
"tags": `"normalization":true`,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNormalizeMedia handles POST /api/admin/media/{id}/normalize.
|
||||||
|
func handleNormalizeMedia(deps *Dependencies, w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idPart := strings.TrimSuffix(r.URL.Path, "/normalize")
|
||||||
|
idPart = strings.TrimPrefix(idPart, "/api/admin/media/")
|
||||||
|
idPart = strings.Trim(idPart, "/")
|
||||||
|
mediaID, err := strconv.ParseUint(idPart, 10, 64)
|
||||||
|
if err != nil || mediaID == 0 {
|
||||||
|
http.Error(w, "invalid media id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var record db.Media
|
||||||
|
if err := deps.DB.First(&record, mediaID).Error; err != nil {
|
||||||
|
http.Error(w, "media item not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Status == media.StatusNormalizing {
|
||||||
|
http.Error(w, "normalization already in progress", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Status = media.StatusNormalizing
|
||||||
|
if err := deps.DB.Model(&record).Update("status", media.StatusNormalizing).Error; err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to update media status: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go normalizeMediaRecord(deps, record)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
json.NewEncoder(w).Encode(mediaEntryFromRecord(record))
|
||||||
|
}
|
||||||
|
|
||||||
// HandleAdminChatLog lists chat log entries.
|
// HandleAdminChatLog lists chat log entries.
|
||||||
func HandleAdminChatLog(deps *Dependencies) http.HandlerFunc {
|
func HandleAdminChatLog(deps *Dependencies) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -14,11 +15,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"chat/src/auth"
|
"chat/src/auth"
|
||||||
"chat/src/config"
|
"chat/src/config"
|
||||||
"chat/src/db"
|
"chat/src/db"
|
||||||
|
"chat/src/media"
|
||||||
"chat/src/ws"
|
"chat/src/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,7 +103,7 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to open test DB: %v", err)
|
t.Fatalf("failed to open test DB: %v", err)
|
||||||
}
|
}
|
||||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{})
|
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{}, &db.User{})
|
||||||
mr := newMockRedis()
|
mr := newMockRedis()
|
||||||
sessionStore := auth.NewSessionStore()
|
sessionStore := auth.NewSessionStore()
|
||||||
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
|
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
|
||||||
@@ -110,6 +113,7 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
|
|||||||
Redis: mr,
|
Redis: mr,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
SessionStore: sessionStore,
|
SessionStore: sessionStore,
|
||||||
|
UserSessions: auth.NewUserSessionStore(),
|
||||||
DBDriver: "sqlite",
|
DBDriver: "sqlite",
|
||||||
}
|
}
|
||||||
return deps, gormDB, sessionStore
|
return deps, gormDB, sessionStore
|
||||||
@@ -474,6 +478,7 @@ func TestHandleAdminConfig_Get(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandleAdminConfig_Put(t *testing.T) {
|
func TestHandleAdminConfig_Put(t *testing.T) {
|
||||||
deps, _, _ := setupTestDeps(t)
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
setupConfigTest(t)
|
||||||
|
|
||||||
handler := HandleAdminConfig(deps)
|
handler := HandleAdminConfig(deps)
|
||||||
body := `{"rateLimitMax":20}`
|
body := `{"rateLimitMax":20}`
|
||||||
@@ -488,6 +493,289 @@ func TestHandleAdminConfig_Put(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupConfigTest points the runtime config at a fresh temp file and resets the store.
|
||||||
|
func setupConfigTest(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
os.Setenv("CONFIG_PATH", filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
os.Unsetenv("MATOMO_URL")
|
||||||
|
os.Unsetenv("MATOMO_SITE_ID")
|
||||||
|
os.Unsetenv("HLS_SEGMENT_DURATION")
|
||||||
|
os.Unsetenv("HLS_WINDOW_SIZE")
|
||||||
|
if err := config.LoadRuntimeConfig(); err != nil {
|
||||||
|
t.Fatalf("LoadRuntimeConfig: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.Unsetenv("CONFIG_PATH") })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAdminConfig_Get_NewFields(t *testing.T) {
|
||||||
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
setupConfigTest(t)
|
||||||
|
|
||||||
|
handler := HandleAdminConfig(deps)
|
||||||
|
req := httptest.NewRequest("GET", "/api/admin/config", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
for _, k := range []string{"matomoUrl", "matomoSiteId", "hlsSegmentDuration", "hlsWindowSize"} {
|
||||||
|
if _, ok := cfg[k]; !ok {
|
||||||
|
t.Errorf("response missing %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAdminConfig_Put_Persists(t *testing.T) {
|
||||||
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
setupConfigTest(t)
|
||||||
|
|
||||||
|
handler := HandleAdminConfig(deps)
|
||||||
|
body := `{"hlsSegmentDuration":5,"hlsWindowSize":30,"matomoUrl":"https://stats.example.com","matomoSiteId":"42"}`
|
||||||
|
req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if cfg["hlsSegmentDuration"] != float64(5) {
|
||||||
|
t.Errorf("hlsSegmentDuration = %v, want 5", cfg["hlsSegmentDuration"])
|
||||||
|
}
|
||||||
|
if cfg["hlsWindowSize"] != float64(30) {
|
||||||
|
t.Errorf("hlsWindowSize = %v, want 30", cfg["hlsWindowSize"])
|
||||||
|
}
|
||||||
|
if cfg["matomoUrl"] != "https://stats.example.com" {
|
||||||
|
t.Errorf("matomoUrl = %v", cfg["matomoUrl"])
|
||||||
|
}
|
||||||
|
if cfg["matomoSiteId"] != "42" {
|
||||||
|
t.Errorf("matomoSiteId = %v", cfg["matomoSiteId"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAdminConfig_Put_Invalid(t *testing.T) {
|
||||||
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
setupConfigTest(t)
|
||||||
|
|
||||||
|
handler := HandleAdminConfig(deps)
|
||||||
|
body := `{"hlsSegmentDuration":99}`
|
||||||
|
req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePublicMatomo(t *testing.T) {
|
||||||
|
setupConfigTest(t)
|
||||||
|
|
||||||
|
handler := HandlePublicMatomo()
|
||||||
|
req := httptest.NewRequest("GET", "/api/matomo", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
for _, k := range []string{"matomoUrl", "matomoSiteId"} {
|
||||||
|
if _, ok := resp[k]; !ok {
|
||||||
|
t.Errorf("response missing %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserRegister_Success(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
handler := HandleUserRegister(deps)
|
||||||
|
body := `{"username":"alice","password":"secret123"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
gormDB.Model(&db.User{}).Where("username = ?", "alice").Count(&count)
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("user count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
if len(w.Result().Cookies()) == 0 {
|
||||||
|
t.Error("expected session cookie to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserRegister_Duplicate(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
gormDB.Create(&db.User{Username: "alice", PasswordHash: "x"})
|
||||||
|
|
||||||
|
handler := HandleUserRegister(deps)
|
||||||
|
body := `{"username":"alice","password":"secret123"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Errorf("status = %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserLogin_Success(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
|
||||||
|
gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
|
||||||
|
|
||||||
|
handler := HandleUserLogin(deps)
|
||||||
|
body := `{"username":"bob","password":"secret123"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserLogin_WrongPassword(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
|
||||||
|
gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
|
||||||
|
|
||||||
|
handler := HandleUserLogin(deps)
|
||||||
|
body := `{"username":"bob","password":"wrong"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserSession(t *testing.T) {
|
||||||
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
handler := HandleUserSession(deps)
|
||||||
|
|
||||||
|
// Unauthenticated
|
||||||
|
req := httptest.NewRequest("GET", "/api/auth/session", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler(w, req)
|
||||||
|
var resp map[string]any
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
if resp["authenticated"] != false {
|
||||||
|
t.Errorf("authenticated = %v, want false", resp["authenticated"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated
|
||||||
|
token, _ := auth.GenerateToken()
|
||||||
|
deps.UserSessions.Add(token, "carol", time.Now().Add(1*time.Hour))
|
||||||
|
req2 := httptest.NewRequest("GET", "/api/auth/session", nil)
|
||||||
|
req2.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
handler(w2, req2)
|
||||||
|
var resp2 map[string]any
|
||||||
|
json.NewDecoder(w2.Body).Decode(&resp2)
|
||||||
|
if resp2["username"] != "carol" {
|
||||||
|
t.Errorf("username = %v, want carol", resp2["username"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserDMHistory(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
token, _ := auth.GenerateToken()
|
||||||
|
deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "hi bob", Type: "private", Timestamp: now.Add(-2 * time.Hour)})
|
||||||
|
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "hi alice", Type: "private", Timestamp: now.Add(-1 * time.Hour)})
|
||||||
|
|
||||||
|
handler := HandleUserDMHistory(deps)
|
||||||
|
req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var msgs []db.Message
|
||||||
|
json.NewDecoder(w.Body).Decode(&msgs)
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(msgs))
|
||||||
|
}
|
||||||
|
if msgs[0].Text != "hi bob" {
|
||||||
|
t.Errorf("first message = %q, want hi bob (chronological)", msgs[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserDMHistory_Unauthorized(t *testing.T) {
|
||||||
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
handler := HandleUserDMHistory(deps)
|
||||||
|
req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUserDMSearch(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
token, _ := auth.GenerateToken()
|
||||||
|
deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
|
||||||
|
|
||||||
|
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "meet at 8", Type: "private"})
|
||||||
|
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "see you", Type: "private"})
|
||||||
|
|
||||||
|
handler := HandleUserDMSearch(deps)
|
||||||
|
req := httptest.NewRequest("GET", "/api/dm/search?with=bob&q=meet", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var msgs []db.Message
|
||||||
|
json.NewDecoder(w.Body).Decode(&msgs)
|
||||||
|
if len(msgs) != 1 || msgs[0].Text != "meet at 8" {
|
||||||
|
t.Fatalf("search results = %v, want [meet at 8]", msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleAdminRestart(t *testing.T) {
|
func TestHandleAdminRestart(t *testing.T) {
|
||||||
deps, _, _ := setupTestDeps(t)
|
deps, _, _ := setupTestDeps(t)
|
||||||
|
|
||||||
@@ -944,4 +1232,133 @@ func TestHandleAdminMediaUploads_InvalidFile(t *testing.T) {
|
|||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusBadRequest {
|
||||||
t.Errorf("status = %d, want 400", w.Code)
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMediaRecord_Success(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.Setenv("MEDIA_DIR", tmpDir)
|
||||||
|
defer os.Unsetenv("MEDIA_DIR")
|
||||||
|
|
||||||
|
srcPath := filepath.Join(tmpDir, "source.mov")
|
||||||
|
if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB.Create(&db.Media{
|
||||||
|
Filename: "source.mov",
|
||||||
|
OriginalName: "source.mov",
|
||||||
|
Path: srcPath,
|
||||||
|
SizeBytes: 10,
|
||||||
|
DurationSeconds: 0,
|
||||||
|
Status: "needs_normalization",
|
||||||
|
Title: "source",
|
||||||
|
})
|
||||||
|
|
||||||
|
origNormalize := media.NormalizeVideo
|
||||||
|
t.Cleanup(func() { media.NormalizeVideo = origNormalize })
|
||||||
|
media.NormalizeVideo = func(src, dst string) (int, int64, error) {
|
||||||
|
if err := os.WriteFile(dst, []byte("normalized"), 0o644); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return 120, 10, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var record db.Media
|
||||||
|
gormDB.First(&record, 1)
|
||||||
|
normalizeMediaRecord(deps, record)
|
||||||
|
|
||||||
|
var updated db.Media
|
||||||
|
gormDB.First(&updated, 1)
|
||||||
|
if updated.Status != "ready" {
|
||||||
|
t.Errorf("Status = %q, want ready", updated.Status)
|
||||||
|
}
|
||||||
|
if updated.DurationSeconds != 120 {
|
||||||
|
t.Errorf("DurationSeconds = %d, want 120", updated.DurationSeconds)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(updated.Path, ".normalized.mp4") {
|
||||||
|
t.Errorf("Path = %q, want .normalized.mp4 suffix", updated.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMediaRecord_Failure(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.Setenv("MEDIA_DIR", tmpDir)
|
||||||
|
defer os.Unsetenv("MEDIA_DIR")
|
||||||
|
|
||||||
|
srcPath := filepath.Join(tmpDir, "source.mov")
|
||||||
|
if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB.Create(&db.Media{
|
||||||
|
Filename: "source.mov",
|
||||||
|
OriginalName: "source.mov",
|
||||||
|
Path: srcPath,
|
||||||
|
SizeBytes: 10,
|
||||||
|
DurationSeconds: 0,
|
||||||
|
Status: "needs_normalization",
|
||||||
|
Title: "source",
|
||||||
|
})
|
||||||
|
|
||||||
|
origNormalize := media.NormalizeVideo
|
||||||
|
t.Cleanup(func() { media.NormalizeVideo = origNormalize })
|
||||||
|
media.NormalizeVideo = func(src, dst string) (int, int64, error) {
|
||||||
|
return 0, 0, fmt.Errorf("ffmpeg failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
var record db.Media
|
||||||
|
gormDB.First(&record, 1)
|
||||||
|
normalizeMediaRecord(deps, record)
|
||||||
|
|
||||||
|
var updated db.Media
|
||||||
|
gormDB.First(&updated, 1)
|
||||||
|
if updated.Status != "normalization_failed" {
|
||||||
|
t.Errorf("Status = %q, want normalization_failed", updated.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAdminMediaByID_Normalize(t *testing.T) {
|
||||||
|
deps, gormDB, _ := setupTestDeps(t)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.Setenv("MEDIA_DIR", tmpDir)
|
||||||
|
defer os.Unsetenv("MEDIA_DIR")
|
||||||
|
|
||||||
|
srcPath := filepath.Join(tmpDir, "source.mov")
|
||||||
|
if err := os.WriteFile(srcPath, []byte("fake video"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB.Create(&db.Media{
|
||||||
|
Filename: "source.mov",
|
||||||
|
OriginalName: "source.mov",
|
||||||
|
Path: srcPath,
|
||||||
|
SizeBytes: 10,
|
||||||
|
DurationSeconds: 0,
|
||||||
|
Status: "needs_normalization",
|
||||||
|
Title: "source",
|
||||||
|
})
|
||||||
|
|
||||||
|
origNormalize := media.NormalizeVideo
|
||||||
|
t.Cleanup(func() { media.NormalizeVideo = origNormalize })
|
||||||
|
media.NormalizeVideo = func(src, dst string) (int, int64, error) {
|
||||||
|
if err := os.WriteFile(dst, []byte("normalized"), 0o644); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return 90, 9, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := HandleAdminMediaByID(deps)
|
||||||
|
req := httptest.NewRequest("POST", "/api/admin/media/1/normalize", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusAccepted {
|
||||||
|
t.Errorf("status = %d, want 202", w.Code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,11 @@ func main() {
|
|||||||
rateLimitWindow := config.RateLimitWindow()
|
rateLimitWindow := config.RateLimitWindow()
|
||||||
log.Printf("Rate limit: %d messages per %v", rateLimitMax, rateLimitWindow)
|
log.Printf("Rate limit: %d messages per %v", rateLimitMax, rateLimitWindow)
|
||||||
|
|
||||||
|
// Load persisted runtime configuration (Matomo + HLS settings)
|
||||||
|
if err := config.LoadRuntimeConfig(); err != nil {
|
||||||
|
log.Printf("Warning: failed to load runtime config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize Redis
|
// Initialize Redis
|
||||||
redisWrapper := db.InitRedis(config.RedisAddress())
|
redisWrapper := db.InitRedis(config.RedisAddress())
|
||||||
|
|
||||||
@@ -39,6 +44,7 @@ func main() {
|
|||||||
|
|
||||||
// Initialize session store
|
// Initialize session store
|
||||||
sessionStore := auth.NewSessionStore()
|
sessionStore := auth.NewSessionStore()
|
||||||
|
userSessions := auth.NewUserSessionStore()
|
||||||
|
|
||||||
// Initialize WebSocket hub
|
// Initialize WebSocket hub
|
||||||
hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername())
|
hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername())
|
||||||
@@ -54,6 +60,7 @@ func main() {
|
|||||||
Redis: redisWrapper,
|
Redis: redisWrapper,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
SessionStore: sessionStore,
|
SessionStore: sessionStore,
|
||||||
|
UserSessions: userSessions,
|
||||||
DBDriver: dbDriver,
|
DBDriver: dbDriver,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +69,12 @@ func main() {
|
|||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
http.HandleFunc("/ws", hub.HandleConnection)
|
http.HandleFunc("/ws", hub.HandleConnection)
|
||||||
|
http.HandleFunc("/api/auth/register", metrics.WrapRequestTiming(handlers.HandleUserRegister(deps)))
|
||||||
|
http.HandleFunc("/api/auth/login", metrics.WrapRequestTiming(handlers.HandleUserLogin(deps)))
|
||||||
|
http.HandleFunc("/api/auth/session", metrics.WrapRequestTiming(handlers.HandleUserSession(deps)))
|
||||||
|
http.HandleFunc("/api/auth/logout", metrics.WrapRequestTiming(handlers.HandleUserLogout(deps)))
|
||||||
|
http.HandleFunc("/api/dm/history", metrics.WrapRequestTiming(handlers.HandleUserDMHistory(deps)))
|
||||||
|
http.HandleFunc("/api/dm/search", metrics.WrapRequestTiming(handlers.HandleUserDMSearch(deps)))
|
||||||
http.HandleFunc("/api/admin/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps)))
|
http.HandleFunc("/api/admin/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps)))
|
||||||
http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps)))
|
http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps)))
|
||||||
http.HandleFunc("/api/admin/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(deps)))
|
http.HandleFunc("/api/admin/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(deps)))
|
||||||
@@ -77,6 +90,7 @@ func main() {
|
|||||||
http.HandleFunc("/api/admin/schedule", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(deps))))
|
http.HandleFunc("/api/admin/schedule", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(deps))))
|
||||||
http.HandleFunc("/api/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(deps))))
|
http.HandleFunc("/api/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(deps))))
|
||||||
http.HandleFunc("/api/schedule", metrics.WrapRequestTiming(handlers.HandlePublicSchedule(deps)))
|
http.HandleFunc("/api/schedule", metrics.WrapRequestTiming(handlers.HandlePublicSchedule(deps)))
|
||||||
|
http.HandleFunc("/api/matomo", metrics.WrapRequestTiming(handlers.HandlePublicMatomo()))
|
||||||
http.HandleFunc("/api/admin/media", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMedia(deps))))
|
http.HandleFunc("/api/admin/media", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMedia(deps))))
|
||||||
http.HandleFunc("/api/admin/media/uploads", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
|
http.HandleFunc("/api/admin/media/uploads", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
|
||||||
http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
|
http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
|
||||||
|
|||||||
@@ -155,6 +155,71 @@ func ProbeDurationSeconds(mediaPath string) (int, error) {
|
|||||||
return seconds, nil
|
return seconds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Media status values (ADR-006 media library states).
|
||||||
|
const (
|
||||||
|
StatusReady = "ready"
|
||||||
|
StatusNeedsNormalization = "needs_normalization"
|
||||||
|
StatusNormalizing = "normalizing"
|
||||||
|
StatusNormalizationFailed = "normalization_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runCommand executes an external command and returns its combined output on failure.
|
||||||
|
// It is a package variable so tests can substitute a stub.
|
||||||
|
var runCommand = func(ctx context.Context, name string, args ...string) error {
|
||||||
|
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s failed: %v (%s)", name, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizationArgs builds the ffmpeg arguments for the Linear Void normalization profile.
|
||||||
|
func normalizationArgs(srcPath, dstPath string) []string {
|
||||||
|
return []string{
|
||||||
|
"-y",
|
||||||
|
"-i", srcPath,
|
||||||
|
"-map", "0:v:0", "-map", "0:a:0?",
|
||||||
|
"-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p",
|
||||||
|
"-c:v", "libx264", "-preset", "medium", "-crf", "21",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
||||||
|
"-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-ac", "2",
|
||||||
|
"-avoid_negative_ts", "make_zero", "-max_muxing_queue_size", "1024",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
dstPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeVideo transcodes srcPath to the Linear Void normalization profile,
|
||||||
|
// re-verifies the output with ffprobe, and returns its duration (seconds) and size (bytes).
|
||||||
|
func normalizeVideo(srcPath, dstPath string) (int, int64, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := runCommand(ctx, "ffmpeg", normalizationArgs(srcPath, dstPath)...); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if res := ValidateNormalization(dstPath); !res.Valid {
|
||||||
|
return 0, 0, fmt.Errorf("normalized output failed validation: %s", strings.Join(res.Errors, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
duration, err := ProbeDurationSeconds(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("probe duration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("stat output failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return duration, info.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeVideo is the injectable normalization entry point.
|
||||||
|
var NormalizeVideo = normalizeVideo
|
||||||
|
|
||||||
// DetectMime reads the first 512 bytes of a file to detect its MIME type.
|
// DetectMime reads the first 512 bytes of a file to detect its MIME type.
|
||||||
func DetectMime(filePath string) (string, error) {
|
func DetectMime(filePath string) (string, error) {
|
||||||
f, err := os.Open(filePath)
|
f, err := os.Open(filePath)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package media
|
package media
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -201,4 +204,43 @@ func TestUniqueTargetPath(t *testing.T) {
|
|||||||
if path != expected {
|
if path != expected {
|
||||||
t.Errorf("UniqueTargetPath() = %q, want %q", path, expected)
|
t.Errorf("UniqueTargetPath() = %q, want %q", path, expected)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizationArgs(t *testing.T) {
|
||||||
|
args := normalizationArgs("/in/movie.mov", "/out/movie.normalized.mp4")
|
||||||
|
joined := strings.Join(args, " ")
|
||||||
|
for _, want := range []string{
|
||||||
|
"-map 0:v:0",
|
||||||
|
"-map 0:a:0?",
|
||||||
|
"-c:v libx264",
|
||||||
|
"-r 30",
|
||||||
|
"-g 60",
|
||||||
|
"-keyint_min 60",
|
||||||
|
"-pix_fmt yuv420p",
|
||||||
|
"-c:a aac",
|
||||||
|
"-ar 48000",
|
||||||
|
"-ac 2",
|
||||||
|
"-avoid_negative_ts make_zero",
|
||||||
|
"/out/movie.normalized.mp4",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Errorf("normalization args missing %q: %v", want, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeVideo_FFmpegError(t *testing.T) {
|
||||||
|
origRun := runCommand
|
||||||
|
t.Cleanup(func() { runCommand = origRun })
|
||||||
|
|
||||||
|
runCommand = func(ctx context.Context, name string, args ...string) error {
|
||||||
|
if name != "ffmpeg" {
|
||||||
|
t.Errorf("runCommand name = %q, want ffmpeg", name)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("simulated ffmpeg failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := normalizeVideo("/in/a.mov", "/out/a.normalized.mp4"); err == nil {
|
||||||
|
t.Fatal("expected error when ffmpeg fails")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,17 @@ func (h *Hub) Broadcast(msg db.Message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BroadcastTyping notifies other users in the room that a user is typing.
|
||||||
|
func (h *Hub) BroadcastTyping(username, room string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
for conn, info := range h.clientMeta {
|
||||||
|
if info.Room == room && info.Username != username {
|
||||||
|
_ = conn.WriteJSON(db.Message{Room: room, Username: username, Text: "", Type: "typing", Timestamp: time.Now()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PushChatHistory sends the last N messages for a room via WebSocket.
|
// PushChatHistory sends the last N messages for a room via WebSocket.
|
||||||
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
|
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
|
||||||
if h.DB == nil || room == "" {
|
if h.DB == nil || room == "" {
|
||||||
@@ -224,6 +235,12 @@ func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle typing indicator (ephemeral — not persisted, not rate-limited)
|
||||||
|
if msg.Type == "typing" {
|
||||||
|
h.BroadcastTyping(msg.Username, msg.Room)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Handle private DMs
|
// Handle private DMs
|
||||||
if strings.HasPrefix(msg.Room, "dm:") {
|
if strings.HasPrefix(msg.Room, "dm:") {
|
||||||
h.handlePrivateDM(msg)
|
h.handlePrivateDM(msg)
|
||||||
|
|||||||
+295
-14
@@ -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,12 +35,20 @@ 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", "6")
|
||||||
|
hlsWindowSize = envWithDefault("HLS_WINDOW_SIZE", "24")
|
||||||
|
|
||||||
// Unique segment naming (STREAMING.md contract: r-{runID}/seg-{seq}.m4s)
|
// Unique segment naming (STREAMING.md contract: r-{runID}/seg-{seq}.m4s)
|
||||||
runID string
|
runID string
|
||||||
@@ -51,6 +62,14 @@ var (
|
|||||||
consecutiveFails int
|
consecutiveFails int
|
||||||
lastFFmpegStartTime time.Time
|
lastFFmpegStartTime time.Time
|
||||||
lastFFmpegExitError error
|
lastFFmpegExitError error
|
||||||
|
|
||||||
|
// R2 retention cleanup counter
|
||||||
|
r2CleanupObjectsTotal int64
|
||||||
|
|
||||||
|
// Continuous playout loop metrics
|
||||||
|
loopsTotal int64
|
||||||
|
lastRunDuration time.Duration
|
||||||
|
lastRunClean bool
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -61,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") != "" &&
|
||||||
@@ -568,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)
|
||||||
@@ -591,6 +634,11 @@ 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) {
|
||||||
@@ -607,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")
|
||||||
@@ -625,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).
|
||||||
@@ -652,17 +733,20 @@ func main() {
|
|||||||
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
||||||
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k",
|
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k",
|
||||||
"-f", "hls",
|
"-f", "hls",
|
||||||
"-hls_time", "6",
|
"-hls_time", hlsSegmentDuration,
|
||||||
"-hls_list_size", "24",
|
"-hls_list_size", hlsWindowSize,
|
||||||
"-hls_segment_type", "fmp4",
|
"-hls_segment_type", "fmp4",
|
||||||
"-hls_fmp4_init_filename", "init.mp4",
|
"-hls_fmp4_init_filename", "init.mp4",
|
||||||
"-hls_flags", "delete_segments+independent_segments",
|
"-hls_flags", "delete_segments+independent_segments",
|
||||||
"-hls_segment_filename", filepath.Join(hlsDir, "seg-%06d.m4s"),
|
"-hls_segment_filename", filepath.Join(hlsDir, "seg-%06d.m4s"),
|
||||||
|
"-start_number", strconv.Itoa(startNumber),
|
||||||
filepath.Join(hlsDir, "playlist.m3u8"))
|
filepath.Join(hlsDir, "playlist.m3u8"))
|
||||||
|
|
||||||
runErr := cmd.Run()
|
runErr := cmd.Run()
|
||||||
runDuration := time.Since(lastFFmpegStartTime)
|
runDuration := time.Since(lastFFmpegStartTime)
|
||||||
lastFFmpegExitError = runErr
|
lastFFmpegExitError = runErr
|
||||||
|
lastRunDuration = runDuration
|
||||||
|
lastRunClean = runErr == nil
|
||||||
|
|
||||||
if watchCancel != nil {
|
if watchCancel != nil {
|
||||||
watchCancel() // Stop the filesystem watcher
|
watchCancel() // Stop the filesystem watcher
|
||||||
@@ -685,6 +769,7 @@ func main() {
|
|||||||
// Differentiate clean playlist completion from FFmpeg crash.
|
// Differentiate clean playlist completion from FFmpeg crash.
|
||||||
// A nil error means FFmpeg completed the playlist normally — not a crash.
|
// A nil error means FFmpeg completed the playlist normally — not a crash.
|
||||||
if runErr == nil {
|
if runErr == nil {
|
||||||
|
loopsTotal++
|
||||||
fmt.Printf("[%s] Playlist completed normally after %s. Looping...\n",
|
fmt.Printf("[%s] Playlist completed normally after %s. Looping...\n",
|
||||||
time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second))
|
time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second))
|
||||||
// If we were in slate mode, a successful run means recovery
|
// If we were in slate mode, a successful run means recovery
|
||||||
@@ -747,4 +832,200 @@ func syncLatestToR2(hlsDir string) error {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
@@ -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"`,
|
||||||
@@ -588,4 +591,190 @@ func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
|
|||||||
if gotContentType != "" {
|
if gotContentType != "" {
|
||||||
t.Fatalf("ContentType = %q, want empty", gotContentType)
|
t.Fatalf("ContentType = %q, want empty", gotContentType)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR2SegmentRetentionHours(t *testing.T) {
|
||||||
|
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "")
|
||||||
|
if got := r2SegmentRetentionHours(); got != 24*time.Hour {
|
||||||
|
t.Errorf("default = %v, want 24h", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "48")
|
||||||
|
if got := r2SegmentRetentionHours(); got != 48*time.Hour {
|
||||||
|
t.Errorf("override = %v, want 48h", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "invalid")
|
||||||
|
if got := r2SegmentRetentionHours(); got != 24*time.Hour {
|
||||||
|
t.Errorf("invalid = %v, want 24h fallback", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Unsetenv("R2_SEGMENT_RETENTION_HOURS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupOldSegments(t *testing.T) {
|
||||||
|
prevClient := r2Client
|
||||||
|
prevBucket := bucket
|
||||||
|
prevList := listObjects
|
||||||
|
prevDelete := deleteObjects
|
||||||
|
prevTotal := r2CleanupObjectsTotal
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
listObjects = prevList
|
||||||
|
deleteObjects = prevDelete
|
||||||
|
r2CleanupObjectsTotal = prevTotal
|
||||||
|
}()
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := now.Add(-25 * time.Hour)
|
||||||
|
recent := now.Add(-1 * time.Hour)
|
||||||
|
|
||||||
|
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||||
|
return &s3.ListObjectsV2Output{
|
||||||
|
Contents: []types.Object{
|
||||||
|
{Key: aws.String("hls/r-old/seg-1.m4s"), LastModified: &old},
|
||||||
|
{Key: aws.String("hls/r-old/init.mp4"), LastModified: &old},
|
||||||
|
{Key: aws.String("hls/r-new/seg-2.m4s"), LastModified: &recent},
|
||||||
|
{Key: aws.String("hls/playlist.m3u8"), LastModified: &old},
|
||||||
|
{Key: aws.String("hls/playlist.m3u8.tmp"), LastModified: &old},
|
||||||
|
},
|
||||||
|
IsTruncated: aws.Bool(false),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedKeys []string
|
||||||
|
deleteObjects = func(_ *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||||
|
for _, obj := range input.Delete.Objects {
|
||||||
|
deletedKeys = append(deletedKeys, aws.ToString(obj.Key))
|
||||||
|
}
|
||||||
|
return &s3.DeleteObjectsOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := cleanupOldSegments(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cleanupOldSegments error: %v", err)
|
||||||
|
}
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("deleted = %d, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.Join(deletedKeys, ",")
|
||||||
|
if !strings.Contains(joined, "hls/r-old/seg-1.m4s") || !strings.Contains(joined, "hls/r-old/init.mp4") {
|
||||||
|
t.Fatalf("unexpected deleted keys: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if strings.Contains(joined, "playlist.m3u8") {
|
||||||
|
t.Fatalf("manifest must not be deleted: %v", deletedKeys)
|
||||||
|
}
|
||||||
|
if r2CleanupObjectsTotal != 2 {
|
||||||
|
t.Fatalf("r2CleanupObjectsTotal = %d, want 2", r2CleanupObjectsTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaylistFingerprint(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
p := filepath.Join(tmpDir, "playlist.txt")
|
||||||
|
if err := os.WriteFile(p, []byte("file '/app/media/a.mp4'\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fp1, err := playlistFingerprint(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playlistFingerprint error: %v", err)
|
||||||
|
}
|
||||||
|
fp2, err := playlistFingerprint(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playlistFingerprint error: %v", err)
|
||||||
|
}
|
||||||
|
if fp1 != fp2 {
|
||||||
|
t.Errorf("fingerprint not stable: %q vs %q", fp1, fp2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(p, []byte("file '/app/media/b.mp4'\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fp3, err := playlistFingerprint(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playlistFingerprint error: %v", err)
|
||||||
|
}
|
||||||
|
if fp1 == fp3 {
|
||||||
|
t.Errorf("fingerprint should change when playlist content changes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSNextStartNumber(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
// Create segments 0..29 with a live window of 10.
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i)), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next, err := hlsNextStartNumber(tmpDir, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hlsNextStartNumber error: %v", err)
|
||||||
|
}
|
||||||
|
if next != 30 {
|
||||||
|
t.Errorf("next = %d, want 30", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segments < 20 should be pruned; 20..29 kept (the live window).
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("seg-%06d should have been pruned", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 20; i < 30; i++ {
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); err != nil {
|
||||||
|
t.Errorf("seg-%06d should be kept: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSharedHLSConfig(t *testing.T) {
|
||||||
|
prevDur := hlsSegmentDuration
|
||||||
|
prevWin := hlsWindowSize
|
||||||
|
defer func() {
|
||||||
|
hlsSegmentDuration = prevDur
|
||||||
|
hlsWindowSize = prevWin
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(`{"hls_segment_duration":"4","hls_window_size":"12"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.Setenv("PLAYOUT_CONFIG_PATH", cfgPath)
|
||||||
|
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
|
||||||
|
|
||||||
|
loadSharedHLSConfig()
|
||||||
|
|
||||||
|
if hlsSegmentDuration != "4" {
|
||||||
|
t.Errorf("hlsSegmentDuration = %q, want 4", hlsSegmentDuration)
|
||||||
|
}
|
||||||
|
if hlsWindowSize != "12" {
|
||||||
|
t.Errorf("hlsWindowSize = %q, want 12", hlsWindowSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSharedHLSConfig_MissingFile(t *testing.T) {
|
||||||
|
prevDur := hlsSegmentDuration
|
||||||
|
prevWin := hlsWindowSize
|
||||||
|
defer func() {
|
||||||
|
hlsSegmentDuration = prevDur
|
||||||
|
hlsWindowSize = prevWin
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("PLAYOUT_CONFIG_PATH", filepath.Join(t.TempDir(), "nope.json"))
|
||||||
|
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
|
||||||
|
|
||||||
|
loadSharedHLSConfig() // should be a no-op without erroring
|
||||||
|
|
||||||
|
if hlsSegmentDuration != prevDur {
|
||||||
|
t.Errorf("hlsSegmentDuration changed to %q, want unchanged %q", hlsSegmentDuration, prevDur)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -19,36 +19,13 @@ fi
|
|||||||
|
|
||||||
# Run normalization validation using the Go tool
|
# Run normalization validation using the Go tool
|
||||||
echo "Validating playlist and enforcing normalization (ADR-001/ADR-003)..."
|
echo "Validating playlist and enforcing normalization (ADR-001/ADR-003)..."
|
||||||
if ! /app/playlist-manager "$PLAYLIST_PATH"; then
|
if ! /app/playlist-manager -validate "$PLAYLIST_PATH"; then
|
||||||
echo "ERROR: Playlist validation failed. Check logs."
|
echo "ERROR: Playlist validation failed. Check logs."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Playlist validated successfully. Starting continuous HLS playout..."
|
echo "Playlist validated successfully. Starting continuous HLS playout..."
|
||||||
|
|
||||||
mkdir -p /app/hls
|
# Single source of truth: the Go binary runs the full loop (HLS, R2 sync,
|
||||||
rm -f /app/hls/playlist.m3u8 /app/hls/init.mp4 /app/hls/segment_*.ts /app/hls/segment_*.m4s
|
# slate fallback, health server). The shell no longer duplicates the FFmpeg loop.
|
||||||
|
exec /app/playlist-manager "$PLAYLIST_PATH"
|
||||||
# Run FFmpeg in a loop for continuous playback
|
|
||||||
while true; do
|
|
||||||
echo "[$(date)] Starting FFmpeg playout with browser-safe HLS encoding..."
|
|
||||||
ffmpeg -re -f concat -safe 0 -i "$PLAYLIST_PATH" \
|
|
||||||
-map 0:v:0 -map 0:a:0? \
|
|
||||||
-vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p" \
|
|
||||||
-c:v libx264 -preset veryfast -pix_fmt yuv420p \
|
|
||||||
-profile:v high -level 4.0 \
|
|
||||||
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
|
||||||
-c:a aac -ar 48000 -ac 2 -b:a 128k \
|
|
||||||
-f hls \
|
|
||||||
-hls_time "${HLS_SEGMENT_DURATION}" \
|
|
||||||
-hls_list_size "${HLS_WINDOW_SIZE}" \
|
|
||||||
-hls_segment_type fmp4 \
|
|
||||||
-hls_fmp4_init_filename init.mp4 \
|
|
||||||
-strftime 1 \
|
|
||||||
-hls_flags delete_segments+independent_segments \
|
|
||||||
-hls_segment_filename "/app/hls/segment_%Y%m%d_%H%M%S.m4s" \
|
|
||||||
"/app/hls/playlist.m3u8" || echo "FFmpeg exited with error (will restart in 5s per ADR-001 resilience requirements)..."
|
|
||||||
|
|
||||||
echo "Playout loop crashed or ended. Restarting in 5 seconds..."
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
@@ -168,6 +168,8 @@ services:
|
|||||||
command:
|
command:
|
||||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||||
- "--storage.tsdb.path=/prometheus"
|
- "--storage.tsdb.path=/prometheus"
|
||||||
|
- "--storage.tsdb.retention.time=30d"
|
||||||
|
- "--storage.tsdb.retention.size=15GB"
|
||||||
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
||||||
- "--web.console.templates=/etc/prometheus/consoles"
|
- "--web.console.templates=/etc/prometheus/consoles"
|
||||||
- "--web.enable-lifecycle"
|
- "--web.enable-lifecycle"
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ services:
|
|||||||
command:
|
command:
|
||||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||||
- "--storage.tsdb.path=/prometheus"
|
- "--storage.tsdb.path=/prometheus"
|
||||||
|
- "--storage.tsdb.retention.time=30d"
|
||||||
|
- "--storage.tsdb.retention.size=15GB"
|
||||||
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
- "--web.console.libraries=/etc/prometheus/console_libraries"
|
||||||
- "--web.console.templates=/etc/prometheus/consoles"
|
- "--web.console.templates=/etc/prometheus/consoles"
|
||||||
- "--web.enable-lifecycle"
|
- "--web.enable-lifecycle"
|
||||||
|
|||||||
@@ -95,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.
|
||||||
+23
-69
@@ -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
|
|
||||||
+94
-38
@@ -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,43 +341,50 @@ 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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
+102
-70
@@ -1,81 +1,113 @@
|
|||||||
<!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="stylesheet" href="/src/style.css" />
|
||||||
<div>
|
</head>
|
||||||
<h1 class="viewer-title">Linear Void</h1>
|
|
||||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
<body class="lv-body">
|
||||||
</div>
|
<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="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>
|
||||||
|
|
||||||
|
<div id="presence-list" class="presence-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
<div class="chat-input-area">
|
||||||
<header class="chat-header">
|
<div class="chat-input-row">
|
||||||
<div class="chat-room-title">
|
<label for="message-input" class="sr-only">Message input</label>
|
||||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if (event.key === 'Enter') sendMessage();" />
|
||||||
<span id="room-name">Main Channel</span>
|
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
|
||||||
|
|
||||||
<section id="presence-panel" class="presence-panel" aria-label="Online users">
|
|
||||||
<div id="presence-list" class="presence-list"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="chat-input-area">
|
|
||||||
<div class="chat-input-row">
|
|
||||||
<label for="message-input" class="sr-only">Message input</label>
|
|
||||||
<input
|
|
||||||
id="message-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Type a message..."
|
|
||||||
class="input"
|
|
||||||
onkeydown="if (event.key === 'Enter') sendMessage();"
|
|
||||||
/>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
|
||||||
</div>
|
|
||||||
<p class="chat-note">Synced with video • Moderation active</p>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
<p class="chat-note">Synced with video • Moderation active</p>
|
||||||
</section>
|
</div>
|
||||||
</main>
|
</aside>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
||||||
<div class="reconnecting-spinner"></div>
|
<div class="reconnecting-spinner"></div>
|
||||||
<span id="reconnecting-text">Reconnecting...</span>
|
<span id="reconnecting-text">Reconnecting...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script>
|
||||||
</body>
|
(function () {
|
||||||
</html>
|
// Load Matomo analytics when configured via the admin runtime config.
|
||||||
|
fetch("/api/matomo")
|
||||||
|
.then(function (r) {
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
})
|
||||||
|
.then(function (cfg) {
|
||||||
|
if (!cfg || !cfg.matomoUrl || !cfg.matomoSiteId) return;
|
||||||
|
var _paq = (window._paq = window._paq || []);
|
||||||
|
_paq.push(["trackPageView"]);
|
||||||
|
_paq.push(["enableLinkTracking"]);
|
||||||
|
var base = String(cfg.matomoUrl).replace(/\/+$/, "");
|
||||||
|
_paq.push(["setTrackerUrl", base + "/matomo.php"]);
|
||||||
|
_paq.push(["setSiteId", String(cfg.matomoSiteId)]);
|
||||||
|
var d = document;
|
||||||
|
var g = d.createElement("script");
|
||||||
|
var s = d.getElementsByTagName("script")[0];
|
||||||
|
g.async = true;
|
||||||
|
g.src = base + "/matomo.js";
|
||||||
|
s.parentNode.insertBefore(g, s);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
/* analytics is optional */
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.12",
|
"version": "1.0.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0"
|
"node": ">=22.0.0"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+183
-19
@@ -16,6 +16,7 @@ type MediaEntry = {
|
|||||||
runtime?: string;
|
runtime?: string;
|
||||||
modifiedAt?: string;
|
modifiedAt?: string;
|
||||||
tags?: string;
|
tags?: string;
|
||||||
|
path?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const bodyDataset = document.body.dataset;
|
const bodyDataset = document.body.dataset;
|
||||||
@@ -246,7 +247,8 @@ async function refreshPlaylist() {
|
|||||||
const content: string = payload.content || "";
|
const content: string = payload.content || "";
|
||||||
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
|
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
|
||||||
if (textarea) textarea.value = content;
|
if (textarea) textarea.value = content;
|
||||||
renderPlaylistSortable(content);
|
const statusMap = await fetchMediaStatusMap();
|
||||||
|
renderPlaylistSortable(content, statusMap);
|
||||||
await showPlaylistWarnings(content);
|
await showPlaylistWarnings(content);
|
||||||
setStatus("playlist-status", "Playlist loaded.");
|
setStatus("playlist-status", "Playlist loaded.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -255,7 +257,7 @@ async function refreshPlaylist() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderPlaylistSortable(content: string) {
|
export function renderPlaylistSortable(content: string, statusMap?: Map<string, string>) {
|
||||||
const list = document.getElementById("playlist-sortable");
|
const list = document.getElementById("playlist-sortable");
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||||
@@ -265,10 +267,12 @@ export function renderPlaylistSortable(content: string) {
|
|||||||
.replace(/^file\s+/, "")
|
.replace(/^file\s+/, "")
|
||||||
.replace(/['"]/g, "")
|
.replace(/['"]/g, "")
|
||||||
.trim();
|
.trim();
|
||||||
|
const badge = statusMap ? statusBadge(statusMap.get(path)) : "";
|
||||||
return `<li class="sortable-item" draggable="true" data-index="${i}">
|
return `<li class="sortable-item" draggable="true" data-index="${i}">
|
||||||
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
|
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
|
||||||
<span class="sortable-index">${i + 1}</span>
|
<span class="sortable-index">${i + 1}</span>
|
||||||
<span class="sortable-path">${path}</span>
|
<span class="sortable-path">${path}</span>
|
||||||
|
${badge}
|
||||||
<button type="button" class="btn btn-secondary btn-sm" data-remove="${i}">✕</button>
|
<button type="button" class="btn btn-secondary btn-sm" data-remove="${i}">✕</button>
|
||||||
</li>`;
|
</li>`;
|
||||||
})
|
})
|
||||||
@@ -352,6 +356,80 @@ async function showPlaylistWarnings(content: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusBadge(status: string | undefined): string {
|
||||||
|
if (!status) return `<span class="badge badge-muted">unknown</span>`;
|
||||||
|
switch (status) {
|
||||||
|
case "ready":
|
||||||
|
return `<span class="badge badge-ok">ready</span>`;
|
||||||
|
case "needs_normalization":
|
||||||
|
return `<span class="badge badge-warn">needs normalization</span>`;
|
||||||
|
case "normalizing":
|
||||||
|
return `<span class="badge badge-info">normalizing</span>`;
|
||||||
|
case "normalization_failed":
|
||||||
|
return `<span class="badge badge-danger">failed</span>`;
|
||||||
|
default:
|
||||||
|
return `<span class="badge badge-muted">${status}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMediaStatusMap(): Promise<Map<string, string>> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/admin/media");
|
||||||
|
if (!resp.ok) return map;
|
||||||
|
const payload = await resp.json();
|
||||||
|
for (const f of payload.files || []) {
|
||||||
|
if (f.path) map.set(f.path, f.status || "unknown");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMediaToPlaylist(file: MediaEntry) {
|
||||||
|
if (file.status !== "ready") {
|
||||||
|
setStatus("media-status", "Only ready media can be added to the playlist.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!file.path) {
|
||||||
|
setStatus("media-status", "Cannot add media without a path.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("media-status", `Adding ${file.name} to playlist...`);
|
||||||
|
try {
|
||||||
|
const getResp = await fetch("/api/admin/playlist");
|
||||||
|
if (!getResp.ok) throw new Error(await readErrorMessage(getResp));
|
||||||
|
const payload = await getResp.json();
|
||||||
|
const content: string = payload.content || "";
|
||||||
|
const lines = content.split("\n").filter((l) => l.trim());
|
||||||
|
const entry = `file '${file.path}'`;
|
||||||
|
const exists = lines.some(
|
||||||
|
(l) =>
|
||||||
|
l
|
||||||
|
.replace(/^file\s+/, "")
|
||||||
|
.replace(/['"]/g, "")
|
||||||
|
.trim() === file.path,
|
||||||
|
);
|
||||||
|
if (exists) {
|
||||||
|
setStatus("media-status", `${file.name} is already in the playlist.`, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lines.push(entry);
|
||||||
|
const putResp = await fetch("/api/admin/playlist", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ content: lines.join("\n") + "\n" }),
|
||||||
|
});
|
||||||
|
if (!putResp.ok) throw new Error(await readErrorMessage(putResp));
|
||||||
|
setStatus("media-status", `${file.name} added to playlist.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to add media to playlist", error);
|
||||||
|
const details = error instanceof Error ? error.message : "Unknown error.";
|
||||||
|
setStatus("media-status", `Failed to add ${file.name} to playlist. (${details})`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function savePlaylist() {
|
export async function savePlaylist() {
|
||||||
if (state.playlistSaving) return;
|
if (state.playlistSaving) return;
|
||||||
state.playlistSaving = true;
|
state.playlistSaving = true;
|
||||||
@@ -480,6 +558,37 @@ async function deleteOverride(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderServiceRow(service: any) {
|
||||||
|
const svcDetail = service.detail || "";
|
||||||
|
|
||||||
|
// playout service returns json detail, all others return ISO formatted time string
|
||||||
|
if (service.name === "playout" && svcDetail) {
|
||||||
|
try {
|
||||||
|
// create a list of key-value pairs for each property in the JSON object
|
||||||
|
const detailObj = JSON.parse(svcDetail);
|
||||||
|
service.detail = Object.entries(detailObj)
|
||||||
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
|
.join("\n");
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = document.createElement("article");
|
||||||
|
row.className = "service-row";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<h3>${service.name}</h3>
|
||||||
|
<p class="small-muted">${service.checked}</p>
|
||||||
|
</div>
|
||||||
|
<div class="service-row-right">
|
||||||
|
<p class="service-status service-status-${service.status}">${service.status}</p>
|
||||||
|
<p>${service.detail}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshSystemSummary() {
|
async function refreshSystemSummary() {
|
||||||
setStatus("system-status", "Loading system summary...");
|
setStatus("system-status", "Loading system summary...");
|
||||||
try {
|
try {
|
||||||
@@ -499,19 +608,9 @@ async function refreshSystemSummary() {
|
|||||||
servicesEl.innerHTML = "";
|
servicesEl.innerHTML = "";
|
||||||
const services = (payload.services || []) as ServiceHealth[];
|
const services = (payload.services || []) as ServiceHealth[];
|
||||||
services.forEach((service) => {
|
services.forEach((service) => {
|
||||||
const row = document.createElement("article");
|
renderServiceRow(service).then((row) => {
|
||||||
row.className = "service-row";
|
servicesEl.appendChild(row);
|
||||||
row.innerHTML = `
|
});
|
||||||
<div>
|
|
||||||
<h3>${service.name}</h3>
|
|
||||||
<p class="small-muted">${service.checked}</p>
|
|
||||||
</div>
|
|
||||||
<div class="service-row-right">
|
|
||||||
<p class="service-status service-status-${service.status}">${service.status}</p>
|
|
||||||
<p>${service.detail}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
servicesEl.appendChild(row);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,19 +633,29 @@ function renderMediaRows(files: MediaEntry[]) {
|
|||||||
|
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
|
const canNormalize = file.status === "needs_normalization" || file.status === "normalization_failed";
|
||||||
|
const canAdd = file.status === "ready";
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${file.name}</td>
|
<td>${file.name}</td>
|
||||||
<td>${file.title || "-"}</td>
|
<td>${file.title || "-"}</td>
|
||||||
<td>${file.runtime || "00:00:00"}</td>
|
<td>${file.runtime || "00:00:00"}</td>
|
||||||
<td>${file.status || "unknown"}</td>
|
<td>${statusBadge(file.status)}</td>
|
||||||
<td>${fmtSize(file.size)}</td>
|
<td>${fmtSize(file.size)}</td>
|
||||||
<td>${file.modifiedAt || "-"}</td>
|
<td>${file.modifiedAt || "-"}</td>
|
||||||
<td class="action-cell">
|
<td class="action-cell">
|
||||||
<button type="button" class="btn btn-secondary btn-small" data-action="edit" data-id="${file.id || ""}">Edit</button>
|
<button type="button" class="btn btn-secondary btn-small" data-action="edit" data-id="${file.id || ""}">Edit</button>
|
||||||
|
${canNormalize ? `<button type="button" class="btn btn-secondary btn-small" data-action="normalize" data-id="${file.id || ""}">Normalize</button>` : ""}
|
||||||
|
${canAdd ? `<button type="button" class="btn btn-secondary btn-small" data-action="add" data-id="${file.id || ""}">Add to playlist</button>` : ""}
|
||||||
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
|
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () => editMedia(file));
|
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () => editMedia(file));
|
||||||
|
if (canNormalize) {
|
||||||
|
tr.querySelector('[data-action="normalize"]')?.addEventListener("click", () => normalizeMedia(file));
|
||||||
|
}
|
||||||
|
if (canAdd) {
|
||||||
|
tr.querySelector('[data-action="add"]')?.addEventListener("click", () => addMediaToPlaylist(file));
|
||||||
|
}
|
||||||
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file));
|
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file));
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
@@ -699,6 +808,55 @@ async function editMedia(file: MediaEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function normalizeMedia(file: MediaEntry) {
|
||||||
|
if (!file.id) {
|
||||||
|
setStatus("media-status", "Cannot normalize media item without id.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("media-status", `Normalizing ${file.name}...`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/media/${file.id}/normalize`, { method: "POST" });
|
||||||
|
if (!response.ok) throw new Error(await readErrorMessage(response));
|
||||||
|
|
||||||
|
setStatus("media-status", `Normalization started for ${file.name}.`);
|
||||||
|
await refreshMediaLibrary();
|
||||||
|
pollNormalizationStatus(file.id, 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to normalize media", error);
|
||||||
|
const details = error instanceof Error ? error.message : "Unknown error.";
|
||||||
|
setStatus("media-status", `Failed to normalize ${file.name}. (${details})`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollNormalizationStatus(id: number, attempt: number) {
|
||||||
|
if (attempt >= 60) {
|
||||||
|
setStatus("media-status", "Normalization is taking longer than expected. Reload the library to check.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/media/${id}`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const file = (await response.json()) as MediaEntry;
|
||||||
|
if (file.status === "normalizing" || file.status === "needs_normalization") {
|
||||||
|
pollNormalizationStatus(id, attempt + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refreshMediaLibrary();
|
||||||
|
await refreshOverview();
|
||||||
|
await refreshSchedule();
|
||||||
|
setStatus(
|
||||||
|
"media-status",
|
||||||
|
file.status === "ready" ? `Normalization complete for ${file.name}.` : `Normalization failed for ${file.name}.`,
|
||||||
|
file.status !== "ready",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Stop polling on error; the user can reload the library manually.
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteMedia(file: MediaEntry) {
|
async function deleteMedia(file: MediaEntry) {
|
||||||
if (!file.id) {
|
if (!file.id) {
|
||||||
setStatus("media-status", "Cannot delete media item without id.", true);
|
setStatus("media-status", "Cannot delete media item without id.", true);
|
||||||
@@ -1068,6 +1226,10 @@ async function loadConfig() {
|
|||||||
const cfg = await resp.json();
|
const cfg = await resp.json();
|
||||||
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
|
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
|
||||||
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
|
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
|
||||||
|
(document.getElementById("config-hls-duration") as HTMLInputElement).value = cfg.hlsSegmentDuration;
|
||||||
|
(document.getElementById("config-hls-window") as HTMLInputElement).value = cfg.hlsWindowSize;
|
||||||
|
(document.getElementById("config-matomo-url") as HTMLInputElement).value = cfg.matomoUrl || "";
|
||||||
|
(document.getElementById("config-matomo-site-id") as HTMLInputElement).value = cfg.matomoSiteId || "";
|
||||||
} catch {
|
} catch {
|
||||||
/* non-critical */
|
/* non-critical */
|
||||||
}
|
}
|
||||||
@@ -1082,11 +1244,13 @@ async function saveConfig(e: Event) {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
rateLimitMax: Number((document.getElementById("config-rate-max") as HTMLInputElement).value),
|
hlsSegmentDuration: Number((document.getElementById("config-hls-duration") as HTMLInputElement).value),
|
||||||
rateLimitWindowSec: Number((document.getElementById("config-rate-window") as HTMLInputElement).value),
|
hlsWindowSize: Number((document.getElementById("config-hls-window") as HTMLInputElement).value),
|
||||||
|
matomoUrl: (document.getElementById("config-matomo-url") as HTMLInputElement).value.trim(),
|
||||||
|
matomoSiteId: (document.getElementById("config-matomo-site-id") as HTMLInputElement).value.trim(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(await readErrorMessage(resp));
|
||||||
if (status) {
|
if (status) {
|
||||||
status.textContent = "Config saved.";
|
status.textContent = "Config saved.";
|
||||||
status.className = "status-line";
|
status.className = "status-line";
|
||||||
|
|||||||
+185
-1
@@ -15,7 +15,13 @@ let hls: InstanceType<HlsClass> | null = null;
|
|||||||
let ws: WebSocket | null = null;
|
let ws: WebSocket | null = null;
|
||||||
let currentRoom = "main";
|
let currentRoom = "main";
|
||||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||||
|
let authenticated = false;
|
||||||
|
let currentDMTarget = "";
|
||||||
let chatVisible = true;
|
let chatVisible = true;
|
||||||
|
let usersVisible = true;
|
||||||
|
let typingUser = "";
|
||||||
|
let lastTypingSent = 0;
|
||||||
|
let typingTimeout: number | undefined;
|
||||||
const onlineUsers = new Set<string>();
|
const onlineUsers = new Set<string>();
|
||||||
|
|
||||||
// Player error recovery state (STREAMING.md contract)
|
// Player error recovery state (STREAMING.md contract)
|
||||||
@@ -28,7 +34,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,6 +91,11 @@ function connectWebSocket(room: string) {
|
|||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === "typing") {
|
||||||
|
showTyping(msg.username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideTyping(msg.username);
|
||||||
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
||||||
|
|
||||||
// Track presence from system join/leave messages
|
// Track presence from system join/leave messages
|
||||||
@@ -183,7 +198,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 +299,192 @@ function sendMessage() {
|
|||||||
|
|
||||||
function switchRoom(room: string) {
|
function switchRoom(room: string) {
|
||||||
currentRoom = room;
|
currentRoom = room;
|
||||||
|
const isDM = room.startsWith("dm:");
|
||||||
document.getElementById("room-name")!.textContent =
|
document.getElementById("room-name")!.textContent =
|
||||||
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
||||||
addMessage("system", `Switched to ${room} room`);
|
addMessage("system", `Switched to ${room} room`);
|
||||||
|
const searchRow = document.getElementById("dm-search-row");
|
||||||
|
if (searchRow) searchRow.hidden = !isDM;
|
||||||
connectWebSocket(room);
|
connectWebSocket(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePrivateDM() {
|
function togglePrivateDM() {
|
||||||
const target = prompt("Enter username to start private chat with:");
|
const target = prompt("Enter username to start private chat with:");
|
||||||
if (target && target !== username) {
|
if (target && target !== username) {
|
||||||
|
currentDMTarget = target;
|
||||||
const dmRoom = `dm:${[username, target].sort().join("-")}`;
|
const dmRoom = `dm:${[username, target].sort().join("-")}`;
|
||||||
switchRoom(dmRoom);
|
switchRoom(dmRoom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchDM() {
|
||||||
|
const input = document.getElementById("dm-search-input") as HTMLInputElement;
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (!q || !currentDMTarget) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/dm/search?with=${encodeURIComponent(currentDMTarget)}&q=${encodeURIComponent(q)}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const results = await res.json();
|
||||||
|
addMessage("system", `Search results (${results.length}) for "${q}":`);
|
||||||
|
for (const m of results) {
|
||||||
|
addMessage(m.type || "private", m.text, m.username, m.timestamp);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.switchRoom = switchRoom;
|
window.switchRoom = switchRoom;
|
||||||
window.sendMessage = sendMessage;
|
window.sendMessage = sendMessage;
|
||||||
window.togglePrivateDM = togglePrivateDM;
|
window.togglePrivateDM = togglePrivateDM;
|
||||||
window.toggleChatPanel = toggleChatPanel;
|
window.toggleChatPanel = toggleChatPanel;
|
||||||
|
|
||||||
|
function updateAuthUI() {
|
||||||
|
const label = document.getElementById("chat-user-label");
|
||||||
|
const toggle = document.getElementById("auth-toggle-btn");
|
||||||
|
const logoutBtn = document.getElementById("auth-logout-btn");
|
||||||
|
const panel = document.getElementById("auth-panel");
|
||||||
|
if (label) {
|
||||||
|
label.textContent = authenticated ? username : "";
|
||||||
|
label.hidden = !authenticated;
|
||||||
|
}
|
||||||
|
if (toggle) toggle.hidden = authenticated;
|
||||||
|
if (logoutBtn) logoutBtn.hidden = !authenticated;
|
||||||
|
if (panel) panel.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUserSession() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/session");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const wasAnonymous = username.startsWith("viewer");
|
||||||
|
if (data.authenticated && data.username) {
|
||||||
|
authenticated = true;
|
||||||
|
username = data.username;
|
||||||
|
updateAuthUI();
|
||||||
|
if (wasAnonymous) connectWebSocket(currentRoom);
|
||||||
|
} else {
|
||||||
|
authenticated = false;
|
||||||
|
updateAuthUI();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* non-critical */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAuth(path: string) {
|
||||||
|
const u = (document.getElementById("auth-username") as HTMLInputElement).value.trim();
|
||||||
|
const p = (document.getElementById("auth-password") as HTMLInputElement).value;
|
||||||
|
const status = document.getElementById("auth-status");
|
||||||
|
if (!u || !p) {
|
||||||
|
if (status) {
|
||||||
|
status.textContent = "Username and password required.";
|
||||||
|
status.className = "status-line status-error";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: u, password: p }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = (await res.text()).trim();
|
||||||
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
username = data.username || u;
|
||||||
|
authenticated = true;
|
||||||
|
updateAuthUI();
|
||||||
|
if (status) {
|
||||||
|
status.textContent = `Signed in as ${username}.`;
|
||||||
|
status.className = "status-line";
|
||||||
|
}
|
||||||
|
connectWebSocket(currentRoom);
|
||||||
|
} catch (err) {
|
||||||
|
if (status) {
|
||||||
|
status.textContent = `Failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||||
|
status.className = "status-line status-error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
void submitAuth("/api/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
function register() {
|
||||||
|
void submitAuth("/api/auth/register");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
authenticated = false;
|
||||||
|
username = "viewer" + Math.floor(Math.random() * 1000);
|
||||||
|
updateAuthUI();
|
||||||
|
connectWebSocket(currentRoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUserList() {
|
||||||
|
const panel = document.getElementById("presence-panel");
|
||||||
|
const btn = document.getElementById("users-toggle-btn");
|
||||||
|
if (!panel) return;
|
||||||
|
usersVisible = !usersVisible;
|
||||||
|
panel.hidden = !usersVisible;
|
||||||
|
if (btn) btn.setAttribute("aria-expanded", String(usersVisible));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTyping(name: string) {
|
||||||
|
if (name === username) return;
|
||||||
|
const el = document.getElementById("typing-indicator");
|
||||||
|
if (!el) return;
|
||||||
|
typingUser = name;
|
||||||
|
el.textContent = `${name} is typing...`;
|
||||||
|
el.hidden = false;
|
||||||
|
window.clearTimeout(typingTimeout);
|
||||||
|
typingTimeout = window.setTimeout(() => {
|
||||||
|
typingUser = "";
|
||||||
|
el.hidden = true;
|
||||||
|
el.textContent = "";
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTyping(name: string) {
|
||||||
|
if (name !== typingUser) return;
|
||||||
|
const el = document.getElementById("typing-indicator");
|
||||||
|
typingUser = "";
|
||||||
|
window.clearTimeout(typingTimeout);
|
||||||
|
if (el) {
|
||||||
|
el.hidden = true;
|
||||||
|
el.textContent = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendTyping() {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTypingSent < 2000) return;
|
||||||
|
lastTypingSent = now;
|
||||||
|
ws.send(JSON.stringify({ room: currentRoom, username, text: "", type: "typing" }));
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
void initPlayerAndChat();
|
void initPlayerAndChat();
|
||||||
|
void refreshUserSession();
|
||||||
|
document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
|
||||||
|
const panel = document.getElementById("auth-panel");
|
||||||
|
if (panel) panel.hidden = !panel.hidden;
|
||||||
|
});
|
||||||
|
document.getElementById("auth-login-btn")?.addEventListener("click", login);
|
||||||
|
document.getElementById("auth-register-btn")?.addEventListener("click", register);
|
||||||
|
document.getElementById("auth-logout-btn")?.addEventListener("click", logout);
|
||||||
|
document.getElementById("dm-search-btn")?.addEventListener("click", searchDM);
|
||||||
|
document.getElementById("users-toggle-btn")?.addEventListener("click", toggleUserList);
|
||||||
|
document.getElementById("message-input")?.addEventListener("input", sendTyping);
|
||||||
});
|
});
|
||||||
+76
-1
@@ -253,6 +253,79 @@ select {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-ok {
|
||||||
|
color: var(--ok);
|
||||||
|
background: rgba(4, 101, 13, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warn {
|
||||||
|
color: var(--warn);
|
||||||
|
background: rgba(212, 150, 36, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
color: var(--brand);
|
||||||
|
background: rgba(88, 119, 146, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
background: rgba(196, 74, 94, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-muted {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-user-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
max-width: 10rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-top: 1px solid var(--bg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-search-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-top: 1px solid var(--bg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
padding: 0.25rem 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-1);
|
border-radius: var(--radius-1);
|
||||||
@@ -1085,7 +1158,9 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes lv-spin {
|
@keyframes lv-spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#reconnecting-text {
|
#reconnecting-text {
|
||||||
|
|||||||
@@ -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))"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user