Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5710548b1 | ||
|
|
d2e5b64ece | ||
|
|
5b5cdc4cd8 | ||
|
|
17ac04856b | ||
|
|
69df0e51b8 | ||
|
|
9a06dc389d | ||
|
|
51f3e98816 | ||
|
|
68ba3a25b9 | ||
|
|
7e9308c48a |
No files matched your search
@@ -1,3 +1,8 @@
|
||||
# CD workflow — TEST LAB DEPLOYMENT
|
||||
# Deploys to test lab host 192.168.88.40:2375.
|
||||
# Production Hetzner target TBD — switch DOCKER_HOST when provisioned.
|
||||
# See TODO.md Production Deployment section for cutover plan.
|
||||
|
||||
name: CD
|
||||
|
||||
on:
|
||||
@@ -76,9 +81,9 @@ jobs:
|
||||
# Wait for services to stabilize
|
||||
sleep 20
|
||||
|
||||
# Check chat health
|
||||
# Check chat health (via docker exec — IP-agnostic, works on any DOCKER_HOST)
|
||||
echo "Checking chat health..."
|
||||
if ! curl -f http://192.168.88.40:3000/health; then
|
||||
if ! docker exec linearvoid-chat curl -sf http://localhost:3000/health; then
|
||||
echo "Chat health check failed"
|
||||
docker logs linearvoid-chat --tail 50
|
||||
exit 1
|
||||
@@ -86,7 +91,7 @@ jobs:
|
||||
|
||||
# Check nginx health
|
||||
echo "Checking nginx health..."
|
||||
if ! curl -f http://192.168.88.40:8080/; then
|
||||
if ! docker exec linearvoid-nginx curl -sf http://localhost/health; then
|
||||
echo "Nginx health check failed"
|
||||
docker logs linearvoid-nginx --tail 50
|
||||
exit 1
|
||||
|
||||
+21
-29
@@ -19,14 +19,12 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Lint Frontend
|
||||
run: cd frontend && npm run lint || echo "Linting not configured yet"
|
||||
run: cd frontend && npm run lint
|
||||
|
||||
- name: Build Frontend
|
||||
run: cd frontend && npm run build
|
||||
@@ -34,17 +32,15 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22"
|
||||
cache: true
|
||||
cache-dependency-path: |
|
||||
backend/playout/go.sum
|
||||
backend/chat/go.sum
|
||||
go-version: "1.25"
|
||||
env:
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
- name: Validate Playlist Normalization
|
||||
run: cd backend/playout && go run src/playlist.go playlist.txt || echo "Validation script not yet complete"
|
||||
- name: Download Go modules
|
||||
run: cd backend/chat && GOTOOLCHAIN=local go mod download
|
||||
|
||||
- name: Build Docker Images
|
||||
run: docker compose build --no-cache
|
||||
- name: Validate Go
|
||||
run: cd backend/chat && GOTOOLCHAIN=local go vet ./...
|
||||
|
||||
- name: Run Tests
|
||||
run: echo "Tests will be added in future CI steps (per TODO.md)"
|
||||
@@ -89,6 +85,8 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta-playout.outputs.tags }}
|
||||
labels: ${{ steps.meta-playout.outputs.labels }}
|
||||
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout
|
||||
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout,mode=max
|
||||
|
||||
- name: Extract metadata for Chat
|
||||
id: meta-chat
|
||||
@@ -109,6 +107,8 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta-chat.outputs.tags }}
|
||||
labels: ${{ steps.meta-chat.outputs.labels }}
|
||||
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat
|
||||
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat,mode=max
|
||||
|
||||
- name: Extract metadata for Nginx
|
||||
id: meta-nginx
|
||||
@@ -123,23 +123,15 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Nginx image
|
||||
run: |
|
||||
TAGS="${{ steps.meta-nginx.outputs.tags }}"
|
||||
LABELS="${{ steps.meta-nginx.outputs.labels }}"
|
||||
TAG_ARGS=""
|
||||
for tag in $TAGS; do
|
||||
TAG_ARGS="$TAG_ARGS --tag $tag"
|
||||
done
|
||||
LABEL_ARGS=""
|
||||
while IFS= read -r label; do
|
||||
[ -n "$label" ] && LABEL_ARGS="$LABEL_ARGS --label $label"
|
||||
done <<< "$LABELS"
|
||||
docker buildx build \
|
||||
--push \
|
||||
--file infrastructure/nginx/Dockerfile \
|
||||
$TAG_ARGS \
|
||||
$LABEL_ARGS \
|
||||
.
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: infrastructure/nginx/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta-nginx.outputs.tags }}
|
||||
labels: ${{ steps.meta-nginx.outputs.labels }}
|
||||
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx
|
||||
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max
|
||||
|
||||
release:
|
||||
needs: docker
|
||||
|
||||
@@ -30,13 +30,15 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22"
|
||||
go-version: "1.25"
|
||||
cache: true
|
||||
env:
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
- name: Update Go Dependencies
|
||||
run: |
|
||||
cd backend/playout && go get -u ./... && go mod tidy
|
||||
cd ../chat && go get -u ./... && go mod tidy
|
||||
cd backend/playout && GOTOOLCHAIN=local go get -u ./... && go mod tidy
|
||||
cd ../chat && GOTOOLCHAIN=local go get -u ./... && go mod tidy
|
||||
|
||||
- name: Commit and push changes
|
||||
run: |
|
||||
|
||||
@@ -3,11 +3,11 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "*.*.*" # Trigger on any semantic version tag (e.g., 1.0.0, 2.1.3)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to release (e.g., v1.0.0)"
|
||||
description: "Version to release (e.g., 1.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -71,6 +71,8 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta-playout.outputs.tags }}
|
||||
labels: ${{ steps.meta-playout.outputs.labels }}
|
||||
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout
|
||||
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout,mode=max
|
||||
|
||||
- name: Extract metadata for Chat
|
||||
id: meta-chat
|
||||
@@ -89,6 +91,8 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta-chat.outputs.tags }}
|
||||
labels: ${{ steps.meta-chat.outputs.labels }}
|
||||
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat
|
||||
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat,mode=max
|
||||
|
||||
- name: Extract metadata for Nginx
|
||||
id: meta-nginx
|
||||
@@ -108,6 +112,8 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta-nginx.outputs.tags }}
|
||||
labels: ${{ steps.meta-nginx.outputs.labels }}
|
||||
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx
|
||||
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max
|
||||
|
||||
- name: Create Release on Gitea
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# MEMORY.md
|
||||
|
||||
## 2026-08-05: Chat Message Admin + CI/CD + Release 1.0.5
|
||||
|
||||
**Completed**: Chat Message Admin, CI/CD, Release 1.0.5 committed + tagged + pushed.
|
||||
|
||||
**Bug — nested admin page**: Fixed. Root cause: page HTML files were in `frontend/admin/` (Vite source root). Vite multi-page mode injected scripts/styles into every `.html` entry. `fetch("/admin/dashboard.html")` returned a full self-contained page, injected into `#tab-content-container`, nesting duplicate admin shell. Fix: moved all 9 page files to `frontend/public/admin/` — Vite serves `public/` contents verbatim with no processing. Fetch now returns raw HTML fragment.
|
||||
|
||||
**Key lesson**: Page fragments loaded via `fetch()` must live under `public/` in Vite projects. Any `.html` in the project source root gets processed as a multi-page entry.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
description: "Caveman-style agent for linearvoid project. Terse output, plan-first workflow, confirmation gating. Use for: implementing TODO.md tasks, editing project files, docs updates, release prep."
|
||||
name: "Caveman"
|
||||
tools:
|
||||
[
|
||||
vscode/memory,
|
||||
vscode/runCommand,
|
||||
execute,
|
||||
read,
|
||||
agent,
|
||||
edit,
|
||||
search,
|
||||
web,
|
||||
playwright/browser_click,
|
||||
playwright/browser_evaluate,
|
||||
playwright/browser_fill_form,
|
||||
playwright/browser_handle_dialog,
|
||||
playwright/browser_install,
|
||||
playwright/browser_navigate,
|
||||
playwright/browser_press_key,
|
||||
playwright/browser_type,
|
||||
browser/openBrowserPage,
|
||||
browser/navigatePage,
|
||||
browser/runPlaywrightCode,
|
||||
vscodeBrowser/openBrowserPage,
|
||||
vscodeBrowser/navigatePage,
|
||||
vscodeBrowser/runPlaywrightCode,
|
||||
todo,
|
||||
]
|
||||
handoffs:
|
||||
- name: "Default"
|
||||
description: "When task complete or user exits caveman mode. Also handoff for security warnings or irreversible actions."
|
||||
scope: "return to default agent for normal interaction"
|
||||
---
|
||||
|
||||
# Caveman Agent — Linear Void
|
||||
|
||||
Caveman mode always active. Terse, no fluff, no filler, no tool-call narration. Technical substance preserved exactly.
|
||||
|
||||
## Scope
|
||||
|
||||
This agent handles implementation tasks within linearvoid project boundaries:
|
||||
|
||||
- Backend (`backend/chat/src/`, `backend/playout/src/`)
|
||||
- Frontend (`frontend/src/`, `frontend/admin/`, `frontend/public/`)
|
||||
- Infrastructure (`infrastructure/`, `.gitea/workflows/`)
|
||||
- Documentation (`docs/`, `*.md` at project root)
|
||||
- Config (`docker-compose*.yml`, `.env.example`, `package.json`, `go.mod`)
|
||||
|
||||
Outside scope: secrets management (use Gitea UI), production deployment approval, external service configuration.
|
||||
|
||||
## Output Rules
|
||||
|
||||
Drop articles (a/an/the), filler words, pleasantries, hedging. Fragments OK. Short synonyms. Standard acronyms OK (API, DB, CI). Never invent abbreviations. Code blocks, error strings, technical terms verbatim. Never drop negations (not/never/no/only/except). Numbers and units exact.
|
||||
|
||||
## Project Workflow
|
||||
|
||||
**Documentation first. Always.**
|
||||
|
||||
1. **Read docs before code** — Check `docs/architecture/README.md` and relevant ADRs (`docs/architecture/adr/`) for design decisions before touching files.
|
||||
2. **Propose ADR if architecture changes** — Any new endpoint pattern, data model, service dependency, or cross-cutting change needs an ADR proposal before implementation. Write to `docs/architecture/adr/ADR-XXX.md` following existing template.
|
||||
3. **Plan** — Write tasks to `.github/instructions/TODO.md` with checkboxes.
|
||||
4. **Confirm before acting** — Ask user. Accept `go`, `ok`, `yes`, `next`, `continue`. Abort on `no`, `stop`, `cancel`.
|
||||
5. **Do the work** — Edit files, run commands.
|
||||
6. **Track completion** — Mark `[x]` in TODO.md. When section done, move to `.github/instructions/DONE.md`.
|
||||
7. **Log changes** — Write entry to `CHANGELOG.md` for significant work.
|
||||
8. **Sync memory** — Update `.github/MEMORY.md` between major steps and at end of session. Record: current state, decisions made, lessons learned, file locations changed. This is the agent's persistent working memory across sessions.
|
||||
|
||||
## Architecture
|
||||
|
||||
Source of truth: `docs/architecture/README.md` and ADRs (`docs/architecture/adr/`). Follow project structure in root `README.md`.
|
||||
|
||||
## Security
|
||||
|
||||
Never commit raw secrets. Never print or log secrets. Use `CI_` prefix for Gitea secrets (never `GITEA_`). `.env` always gitignored.
|
||||
|
||||
## Task Tracking
|
||||
|
||||
Sync with `manage_todo_list` tool. Mirror state to `.github/instructions/TODO.md` checkboxes.
|
||||
@@ -0,0 +1,121 @@
|
||||
# BACKLOG
|
||||
|
||||
## Playout & Delivery
|
||||
|
||||
- [x] Implement FFmpeg + MediaMTX/SRS continuous HLS playout from playlist
|
||||
- [x] Implement immutable HLS segment naming for cache-friendly delivery
|
||||
- [x] Upload HLS segments to Cloudflare R2 via S3-compatible API
|
||||
- [x] Implement emergency slate fallback on playout failure
|
||||
- [x] Auto-restart loop for FFmpeg/MediaMTX on failure
|
||||
|
||||
## Video Normalization
|
||||
|
||||
- [x] Implement video normalization pipeline (H.264/AAC, 30 fps, 2 s keyframes)
|
||||
- [x] Implement ffprobe verification gate rejecting non-conforming files
|
||||
- [x] Enforce normalization check before upload acceptance in admin UI
|
||||
|
||||
## Chat Service
|
||||
|
||||
- [x] Implement WebSocket chat backend (Go) with room fan-out
|
||||
- [x] Implement private DM channel routing
|
||||
- [x] Implement Redis presence tracking for chat
|
||||
- [ ] Implement Redis pub/sub for multi-instance chat
|
||||
- [x] Implement Redis rate limiting (INCR + EXPIRE windowing, >10/10s throttle)
|
||||
- [x] Implement chat message persistence (SQLite v1, PostgreSQL migration path)
|
||||
- [x] Implement moderation commands (/mod, /mute, /ban) in backend
|
||||
- [x] Implement moderation audit log persistence
|
||||
- [x] Implement graceful degradation when Redis unavailable (core chat remains up)
|
||||
|
||||
## Frontend — Viewer SPA
|
||||
|
||||
- [x] Build static SPA with hls.js player integration
|
||||
- [x] Build chat sidebar UI (public rooms + private DMs)
|
||||
- [x] Implement schedule metadata display synced with playlist timeline
|
||||
- [x] Implement responsive/mobile-friendly viewer layout
|
||||
|
||||
## Frontend — Admin Interfaces
|
||||
|
||||
- [x] Build admin login page with session authentication
|
||||
- [x] Build admin dashboard overview page
|
||||
- [x] Build video upload interface with chunked upload support
|
||||
- [x] Build playlist management interface (drag-to-reorder, validation warnings)
|
||||
- [x] Build schedule override/management interface (CRUD + conflict detection)
|
||||
- [x] Build user management interface (mute/unmute/ban/unban actions)
|
||||
- [x] Build moderation dashboard (chat log, inline mod actions, CSV export)
|
||||
- [x] Build analytics dashboard (time range selector)
|
||||
- [x] Build system configuration/status page (config editor, restart buttons)
|
||||
|
||||
## Infrastructure — nginx
|
||||
|
||||
- [x] Configure nginx reverse proxy with auth_request for admin paths
|
||||
- [x] Configure nginx HLS path with CORS headers + OPTIONS preflight
|
||||
- [x] Configure nginx WebSocket upgrade forwarding for /ws
|
||||
- [x] Configure nginx health endpoints (/health JSON, stub_status)
|
||||
|
||||
## Infrastructure — Docker
|
||||
|
||||
- [x] Create Dockerfiles for playout, chat, nginx services
|
||||
- [x] Create docker-compose.yml for all services
|
||||
- [x] Create docker-compose.prod.yml override for production
|
||||
|
||||
## Infrastructure — CI/CD (Gitea Actions)
|
||||
|
||||
- [x] Implement CI workflow (lint, build, test, push images)
|
||||
- [x] Implement CD workflow (pull, deploy, health checks — IP-agnostic)
|
||||
- [x] Generate .env from Gitea secrets in CD workflow
|
||||
- [x] Seed Prometheus config into named volume during deploy
|
||||
- [x] Implement health check validation with retry in CD pipeline
|
||||
|
||||
## Cloudflare Configuration
|
||||
|
||||
- [ ] Configure DNS for linearvoid.tv apex and www redirect (manual)
|
||||
- [ ] Configure media.linearvoid.tv for split-domain HLS delivery (manual)
|
||||
- [ ] Create R2 bucket (manual)
|
||||
- [ ] Configure Cache Rules: .ts/.m4s + .m3u8 (manual)
|
||||
- [ ] Configure Cloudflare Pages (manual)
|
||||
- [ ] Enable Tiered Cache, WAF, rate limiting (manual)
|
||||
- [x] Implement Worker for immutable cache headers on segment paths
|
||||
- [ ] Enforce HTTPS/WSS, redirect HTTP → HTTPS (manual)
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
- [x] Set up Prometheus with chat scrape target
|
||||
- [ ] Add nginx/redis/postgres/node exporters
|
||||
- [x] Import Grafana app + operational dashboards
|
||||
- [ ] Define Prometheus alerting rules
|
||||
- [ ] Set up UptimeRobot external monitoring
|
||||
|
||||
## Security
|
||||
|
||||
- [x] Implement admin cookie-session auth (HttpOnly, SameSite=Strict, Secure when TLS)
|
||||
- [x] Implement server-side session expiration
|
||||
- [ ] Enforce HTTPS/WSS at edge and origin
|
||||
- [x] Implement rate limiting for chat messages with Redis windowing
|
||||
|
||||
## Data & Storage
|
||||
|
||||
- [x] Set up PostgreSQL/SQLite for chat history and moderation data
|
||||
- [x] Set up Redis with append-only persistence + maxmemory policy
|
||||
- [ ] Implement daily/weekly/monthly Postgres backups
|
||||
- [ ] Implement backup integrity verification
|
||||
- [ ] Document and rehearse recovery runbook
|
||||
|
||||
## Scaling & Performance
|
||||
|
||||
- [ ] Optimize segment immutability and manifest TTL for cache efficiency
|
||||
- [ ] Measure playback TTFB from multiple US regions
|
||||
- [ ] Load-test WebSocket connections
|
||||
- [ ] Benchmark admin API p95 latency
|
||||
- [ ] Track cache hit ratio and R2 ops in production
|
||||
- [ ] Capacity trigger monitoring
|
||||
- [ ] Plan Phase 2 service separation
|
||||
- [ ] Plan Phase 3 multi-origin resilience
|
||||
|
||||
## Documentation
|
||||
|
||||
- [x] Document API contracts in API.md
|
||||
- [ ] Document deployment runbook in DEPLOYMENT.md
|
||||
- [ ] Document monitoring setup in MONITORING.md
|
||||
- [ ] Document security model in SECURITY.md
|
||||
- [ ] Create incident runbook
|
||||
- [ ] Write Level 2/3 building block views
|
||||
+52
-2
@@ -2,7 +2,52 @@
|
||||
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
## [1.0.5] - 2026-08-05
|
||||
## [1.0.7] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Emergency slate fallback**: `scripts/generate-slate.sh` creates "We'll Be Right Back" slate video + pre-rendered HLS. Playout detects FFmpeg failures (>5 restarts/60s) and auto-switches to slate manifest. Health endpoint reports `degraded` mode. Auto-recovers when FFmpeg restarts successfully.
|
||||
- **Prometheus metrics**: `linearvoid_playout_restarts_total`, `linearvoid_playout_mode` (0=live, 1=slate), `linearvoid_chat_dms_total` exposed on playout + chat `/metrics`.
|
||||
- **Video normalization pipeline**: `scripts/normalize.sh` with env-configurable CRF/preset/audio-bitrate wrapping reference FFmpeg command.
|
||||
- **Strict ffprobe verification gate**: `ValidateVideo()` in playout parses ffprobe JSON, checks codec (h264/aac), framerate (30/1), resolution (1280x720 or 1920x1080), pix_fmt (yuv420p), sample_rate (48000), keyframe interval (≤2s). `ValidateVideoStrict()` returns `{valid, errors[]}`.
|
||||
- **Upload normalization enforcement**: `validateMediaNormalization()` runs on every admin upload (single + chunked). Non-conforming files get `needs_normalization` status. Visible in media library + playlist validation warnings.
|
||||
- **Chat history on room join**: `pushChatHistory()` sends last 50 messages via WebSocket on connect.
|
||||
- **DM offline delivery**: Private messages persisted to DB, appear in history when recipient reconnects.
|
||||
- **Online presence indicators**: Green dot per user in chat sidebar, updated via join/leave system messages.
|
||||
- **Schedule metadata overlay**: "Now Playing" + "Up Next" below viewer player, polling `GET /api/schedule` every 30s. Public `/api/schedule` endpoint added.
|
||||
- **Playlist drag-to-reorder**: Sortable list with drag-and-drop, per-entry remove buttons, validation warnings (missing files, needs_normalization).
|
||||
- **Schedule overrides**: `ScheduleOverride` model + `GET/POST/DELETE /api/admin/schedule/overrides` with conflict detection (409 on overlap). Admin UI with form + list.
|
||||
- **User management actions**: `POST /api/admin/users` with mute/unmute/ban/unban actions. Per-user action buttons in admin users table.
|
||||
- **Inline moderation**: Mute/Ban buttons on each chat log entry row in admin.
|
||||
- **CSV export**: `GET /api/admin/chat-log/export` returns CSV download.
|
||||
- **Analytics time range selector**: 1h, 6h, 24h, 7d dropdown on admin analytics page.
|
||||
- **Runtime config editor**: `GET/PUT /api/admin/config` for rate limit max + window. Admin system page with config form + restart buttons.
|
||||
- **Production target documentation**: `DEPLOYMENT.md` updated with two-environment model (test lab 192.168.88.0/24 vs Hetzner production TBD), CX22 specs, firewall requirements, DNS cutover plan.
|
||||
- **Network diagram**: Mermaid diagram in `SETUP.md` distinguishing test lab from planned production.
|
||||
- **E2E test script**: `scripts/test-playout-e2e.sh` for runtime validation in test lab.
|
||||
- **Redis pub/sub stub**: Architecture ready for multi-instance chat (pending implementation).
|
||||
|
||||
### Changed
|
||||
|
||||
- `docker-compose.yml` + `.prod.yml`: added slate volume mount (`./data/slate:/app/slate:ro`), Redis `--maxmemory 256mb --maxmemory-policy allkeys-lru`.
|
||||
- `backend/playout/Dockerfile`: added `/app/slate` directory.
|
||||
- `handleConnection`: added 30s presence heartbeat + WebSocket ping, banned/muted user checks, env-configurable rate limits (`CHAT_RATE_LIMIT_MAX`, `CHAT_RATE_LIMIT_WINDOW_S`).
|
||||
- `handleModerationCommand`: added moderator role check, mute duration parsing, Redis key enforcement.
|
||||
- `handlePrivateDM`: targeted delivery via `clientMeta` map instead of broadcast.
|
||||
- `initRedis`: added `redisReconnectLoop` with exponential backoff (2s→60s).
|
||||
- `handleAdminMedia` + chunked upload: normalization validation runs on every upload, sets `needs_normalization` status.
|
||||
- nginx: OPTIONS preflight → 204 on all 4 HLS locations. `/health` returns JSON. `/nginx-status` with stub_status (internal-network restricted).
|
||||
- CD workflow: health checks now IP-agnostic (`docker exec` instead of hardcoded IP curls).
|
||||
- `docker-compose.yml` + `.prod.yml`, `.gitea/workflows/cd.yml`, `DEPLOYMENT.md`, `SETUP.md`, `docs/architecture/README.md`, `README.md`: all annotated with test-lab vs production distinction, `192.168.88.0/24` flagged as internal test network.
|
||||
- `docs/architecture/README.md`: CI/CD deployment view updated to note test lab target, production pending.
|
||||
|
||||
### Fixed
|
||||
|
||||
- CD health check no longer hardcodes `192.168.88.40` in curl targets — uses `docker exec` for portability.
|
||||
- Playlist page no longer single-textarea — now shows sortable list with validation warnings.
|
||||
- Schedule page shows overrides alongside playlist timeline.
|
||||
|
||||
## [1.0.6] - 2026-08-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -23,13 +68,18 @@
|
||||
- Rebuilt viewer, admin, and admin-login pages around custom semantic class structures with accessibility-focused landmarks, tab semantics, and keyboard-focus behavior.
|
||||
- Replaced legacy and Tailwind-based stylesheet rules with a custom design-token CSS system covering responsive layouts, forms, tables, and reduced-motion behavior.
|
||||
- Switched frontend theme to dark-mode-first token defaults with light-scheme override.
|
||||
- Applied new color palette (`#32292f` dark, `#b4c4ae` light, `#587792` brand, `#2ebfa5` focus, `#04650d` ok) across all 22 CSS custom properties in dark and light themes.
|
||||
- Replaced hardcoded admin sidebar and tab-trigger colors with CSS custom properties.
|
||||
- Optimized CI pipeline: removed duplicate `docker compose build --no-cache` from test job, removed broken playlist validation step, fixed Go module cache path, added Docker Buildx registry cache to docker/release jobs.
|
||||
- Bumped chat healthcheck `start_period` from 10s to 30s in `docker-compose.prod.yml`.
|
||||
- CD workflow now verifies nginx (port 8080) + chat (3000) + playout (8081); dumps container logs for all failing services.
|
||||
- Updated Prometheus scrape targets and Grafana dashboard queries to use live chat metrics.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Go module cache in CI now correctly references `backend/playout/go.sum` and `backend/chat/go.sum`.
|
||||
- Go module cache in CI now correctly references only `backend/chat/go.sum` (playout go.sum did not exist); added `go mod download` step to seed cache.
|
||||
- Removed broken playlist validation step (`go run src/playlist.go` without go.mod) from CI test job.
|
||||
- Converted nginx CI build from custom shell `docker buildx` to `docker/build-push-action@v5` for cache consistency.
|
||||
- Enforced 2GB upload ceiling plus server-side media file type/size validation for admin uploads.
|
||||
- Increased nginx `client_max_body_size` to allow resumable admin upload chunks without 413 errors.
|
||||
- Removed unused `data-admin-*` Vite env var attributes from admin HTML template.
|
||||
|
||||
+119
-2
@@ -20,8 +20,8 @@ Thank you for considering contributing to Linear Void!
|
||||
## Code Style
|
||||
|
||||
- Go: Use standard formatting (`go fmt`).
|
||||
- TypeScript: ESLint and Prettier.
|
||||
- Tailwind: Follow the utility-first approach.
|
||||
- TypeScript: `tsc --noEmit` for type-checking (see `frontend/package.json`).
|
||||
- CSS: Design-token system via CSS custom properties in `frontend/src/style.css`. No utility framework.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
@@ -30,4 +30,121 @@ Thank you for considering contributing to Linear Void!
|
||||
- Ensure all tests pass and documentation is updated.
|
||||
- The CI workflow will validate normalization, linting, and Docker builds.
|
||||
|
||||
## Versioning and Releases
|
||||
|
||||
### Version Scheme
|
||||
|
||||
Linear Void follows **Semantic Versioning** ([SemVer](https://semver.org)): `MAJOR.MINOR.PATCH`.
|
||||
|
||||
- **MAJOR** — breaking changes to the viewer, API, or deployment model.
|
||||
- **MINOR** — new features (endpoints, admin tabs, UI capabilities) that are backward-compatible.
|
||||
- **PATCH** — bug fixes, optimizations, documentation polish.
|
||||
|
||||
Git tags use a `v` prefix: `1.0.2`, `1.1.0`, `v2.0.0`.
|
||||
|
||||
### Where Versions Are Declared
|
||||
|
||||
| File | Token | Example |
|
||||
| ------------------------------ | -------------- | ------------------------- |
|
||||
| `frontend/package.json` | `"version"` | `"1.0.2"` |
|
||||
| `CHANGELOG.md` | `## [X.Y.Z]` | `## [1.0.2] - 2026-08-05` |
|
||||
| `README.md` | Version badge | `version-1.0.2-blue` |
|
||||
| `.github/instructions/TODO.md` | Section header | `## Version 1.0.2` |
|
||||
|
||||
### Tag Triggers
|
||||
|
||||
| Tag Pattern | Workflow | What Happens |
|
||||
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `v*` (e.g. `1.0.2`) | `release.yml` | Builds all 3 images, pushes to registry with semver tags (`1.0.2`, `1.0`, `latest`), creates Gitea Release via API |
|
||||
| Push to `main` | `ci.yml` → `cd.yml` | CI builds + pushes images with `main`/`latest` tags, CD deploys to production |
|
||||
|
||||
### Full Release Procedure
|
||||
|
||||
#### Step 1: Bump version numbers
|
||||
|
||||
Update these files to the new version (replace `X.Y.Z`):
|
||||
|
||||
- [frontend/package.json](frontend/package.json)
|
||||
- [CHANGELOG.md](CHANGELOG.md) — add new section at top
|
||||
- [README.md](README.md) — version badge
|
||||
|
||||
```bash
|
||||
# frontend/package.json
|
||||
"version": "X.Y.Z"
|
||||
|
||||
# CHANGELOG.md — add new section at top
|
||||
## [X.Y.Z] - YYYY-MM-DD
|
||||
|
||||
# README.md — version badge
|
||||
version-X.Y.Z-blue
|
||||
```
|
||||
|
||||
#### Step 2: Write changelog
|
||||
|
||||
Add entries under `## [X.Y.Z]` in `CHANGELOG.md` following [Keep a Changelog](https://keepachangelog.com) format (`Added`, `Changed`, `Fixed`, `Removed`).
|
||||
|
||||
#### Step 3: Commit and tag
|
||||
|
||||
```bash
|
||||
# Stage all version bumps + changelog
|
||||
git add frontend/package.json CHANGELOG.md README.md .github/instructions/TODO.md
|
||||
|
||||
# Commit
|
||||
git commit -m "chore: bump version to X.Y.Z"
|
||||
|
||||
# Create annotated tag
|
||||
git tag -a X.Y.Z -m "Release X.Y.Z"
|
||||
|
||||
# Push commit and tag
|
||||
git push origin main
|
||||
git push origin X.Y.Z
|
||||
```
|
||||
|
||||
#### Step 4: Verify CI/CD
|
||||
|
||||
- CI workflow (`ci.yml`) runs on push to `main` → builds & pushes `main`/`latest` images.
|
||||
- Release workflow (`release.yml`) triggers on `X.Y.Z` tag → builds images with semver tags (`X.Y.Z`, `X.Y`, `latest`), creates Gitea Release.
|
||||
- CD workflow (`cd.yml`) triggers after CI completes → deploys to production.
|
||||
|
||||
Monitor at: [git.allucanget.biz/zwitschi/linearvoid/actions](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||
|
||||
#### Step 5: Verify release
|
||||
|
||||
Check the Gitea Releases page for the new tag:
|
||||
[git.allucanget.biz/zwitschi/linearvoid/releases](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||
|
||||
### Manual Release (workflow_dispatch)
|
||||
|
||||
If a tag push fails or a re-release is needed:
|
||||
|
||||
1. Go to [Actions → Release workflow](https://git.allucanget.biz/zwitschi/linearvoid/actions?workflow=release.yml)
|
||||
2. Click "Run workflow"
|
||||
3. Enter version (e.g. `1.0.2`)
|
||||
4. The workflow builds images, pushes semver tags, and creates the Gitea Release
|
||||
|
||||
### Docker Image Tags
|
||||
|
||||
Every release produces these tags in the Gitea Container Registry:
|
||||
|
||||
| Image | Tags |
|
||||
| --------- | ------------------------ |
|
||||
| `playout` | `X.Y.Z`, `X.Y`, `latest` |
|
||||
| `chat` | `X.Y.Z`, `X.Y`, `latest` |
|
||||
| `nginx` | `X.Y.Z`, `X.Y`, `latest` |
|
||||
|
||||
Example for `1.0.2`:
|
||||
|
||||
```text
|
||||
git.allucanget.biz/zwitschi/linearvoid/chat:1.0.2
|
||||
git.allucanget.biz/zwitschi/linearvoid/chat:1.0
|
||||
git.allucanget.biz/zwitschi/linearvoid/chat:latest
|
||||
git.allucanget.biz/zwitschi/linearvoid/playout:1.0.2
|
||||
git.allucanget.biz/zwitschi/linearvoid/playout:1.0
|
||||
git.allucanget.biz/zwitschi/linearvoid/playout:latest
|
||||
```
|
||||
|
||||
### Moving Completed Tasks
|
||||
|
||||
After release, move completed sections from `TODO.md` to `DONE.md` and update `CHANGELOG.md` if needed. The `DONE.md` file serves as the historical record linking tasks to releases.
|
||||
|
||||
See the ADRs for architectural decisions and `docs/architecture/README.md` for the big picture. Thank you for contributing!
|
||||
+65
-2
@@ -1,6 +1,65 @@
|
||||
# Production Deployment Guide
|
||||
# Deployment Guide
|
||||
|
||||
This guide describes a production-style deployment for Linear Void using Docker Compose, Gitea registry images, and Cloudflare fronting.
|
||||
## Environments
|
||||
|
||||
Linear Void runs in two separate environments:
|
||||
|
||||
| Environment | Host | Docker Target | Purpose |
|
||||
| -------------- | -------------------------- | -------------------------- | ---------------------------------------------- |
|
||||
| **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation |
|
||||
| **Production** | Hetzner VPS (TBD) | `tcp://<hetzner-ip>:2375` | Public linearvoid.tv serving real viewers |
|
||||
|
||||
**Current state (2026-08-06):** Test lab is active and receives automated CD deploys. Production Hetzner VPS is **not yet provisioned**. The CD workflow (`cd.yml`) and `docker-compose.prod.yml` currently target the test lab. Switching to production requires only changing `DOCKER_HOST` — see [Production Deployment](#production-deployment-hetzner) below.
|
||||
|
||||
## Production Target (Hetzner)
|
||||
|
||||
**Status: Not provisioned.** The production deployment plan is documented and the pipeline is ready — only the VPS needs to be created.
|
||||
|
||||
### Planned Infrastructure
|
||||
|
||||
| Resource | Spec |
|
||||
| -------------- | ------------------------------------------------ |
|
||||
| Provider | Hetzner Cloud |
|
||||
| Region | Ashburn, VA (us-east) |
|
||||
| Instance | CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent |
|
||||
| OS | Ubuntu 24.04 LTS or Debian 12 |
|
||||
| Bandwidth | 20 TB included traffic/month |
|
||||
| Estimated cost | ~€7–10/month base |
|
||||
|
||||
### Cutover Plan
|
||||
|
||||
1. Provision Hetzner VPS (see `TODO.md` #92)
|
||||
2. Add production secrets to Gitea (`CI_PROD_*` prefix)
|
||||
3. Update `DOCKER_HOST` in CD workflow to Hetzner public IP
|
||||
4. Trigger deploy → validate health checks
|
||||
5. Update Cloudflare A record `linearvoid.tv` → Hetzner IP
|
||||
6. Enable HTTPS, WAF, cache rules on production zone
|
||||
7. Decommission test lab as public target
|
||||
|
||||
### Firewall / Security Group
|
||||
|
||||
| Port | Source | Purpose |
|
||||
| ---- | --------------------- | ------------------------------ |
|
||||
| 22 | Admin IPs | SSH management |
|
||||
| 80 | 0.0.0.0/0 | HTTP (redirect to HTTPS) |
|
||||
| 443 | 0.0.0.0/0 | HTTPS (nginx) |
|
||||
| 2375 | Gitea runner IP only | Docker remote API (TLS) |
|
||||
| 9090 | Admin IPs or internal | Prometheus (or tunnel via SSH) |
|
||||
| 3001 | Admin IPs or internal | Grafana (or tunnel via SSH) |
|
||||
|
||||
### Pipeline Readiness
|
||||
|
||||
The Gitea Actions CD workflow (`cd.yml`) already supports `DOCKER_HOST` as a configurable variable. To switch targets:
|
||||
|
||||
```bash
|
||||
# Test lab (current)
|
||||
DOCKER_HOST=tcp://192.168.88.40:2375
|
||||
|
||||
# Production (when provisioned)
|
||||
DOCKER_HOST=tcp://<hetzner-public-ip>:2375
|
||||
```
|
||||
|
||||
No code changes needed in the pipeline. See `TODO.md` #91 and #92–#99 for full production deployment checklist.
|
||||
|
||||
## Target Topology
|
||||
|
||||
@@ -38,6 +97,10 @@ See .env.example and SETUP.md for full variable reference.
|
||||
|
||||
## Pull-and-Run Deployment
|
||||
|
||||
Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning-and-releases) for the release procedure).
|
||||
|
||||
Every push to `main` produces `:main` and `:latest` tags. Semantic version tags (`v*`) produce `:X.Y.Z`, `:X.Y`, and `:latest` tags.
|
||||
|
||||
From repository root:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||
[](LICENSE)
|
||||
[](https://git.allucanget.biz)
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||
[](CHANGELOG.md)
|
||||
|
||||
## Architecture
|
||||
|
||||
The architecture (detailed in [`docs/architecture/README.md`](docs/architecture/README.md)) uses:
|
||||
|
||||
- Hetzner Ashburn VPS for playout (FFmpeg + MediaMTX/SRS) and chat.
|
||||
- Hetzner Ashburn VPS for playout (FFmpeg + MediaMTX/SRS) and chat *(planned production; currently test lab at 192.168.88.40)*.
|
||||
- Cloudflare R2 + CDN with aggressive immutable caching of HLS `.ts` segments ($0 egress).
|
||||
- Lightweight static frontend and WebSocket chat backend.
|
||||
- Gitea self-hosted CI/CD (Actions) and Container Registry.
|
||||
@@ -112,6 +112,8 @@ This structure directly reflects the building blocks, runtime flows, deployment
|
||||
|
||||
## Getting Started
|
||||
|
||||
> **Note:** The `docker compose up` below targets local development. The test lab (192.168.88.40) receives automated CD deploys. Production Hetzner VPS is not yet provisioned — see `TODO.md`.
|
||||
|
||||
```bash
|
||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
||||
cd linearvoid
|
||||
@@ -133,6 +135,7 @@ See the [architecture docs](docs/architecture/README.md) for detailed architectu
|
||||
## Development
|
||||
|
||||
- Use Docker Compose for local development (playout + chat + frontend).
|
||||
- Test lab host: `192.168.88.40` (internal network, receives CD deploys). Production Hetzner VPS TBD.
|
||||
- Public production host model: `https://linearvoid.tv` for viewer/admin/chat and `https://media.linearvoid.tv` for HLS delivery.
|
||||
- Normalize all source videos before adding to playlist (see ADR-001 for exact codec/fps/keyframe requirements).
|
||||
- Monitor cache hit ratio and R2 operations closely — they determine real cost.
|
||||
@@ -224,7 +227,7 @@ Contributions are welcome! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelin
|
||||
3. Open PRs on [Gitea](https://git.allucanget.biz/zwitschi/linearvoid).
|
||||
4. CI validates builds and tests automatically.
|
||||
|
||||
Since this a linear project, we also rely on a linear development and release model: Only main branch is used for releases, and all features are merged into main after review and CI validation. Releases are tagged with semantic versioning (v1.0.3, v1.0.4, etc.) and automatically built/pushed to the container registry.
|
||||
Since this a linear project, we also rely on a linear development and release model: Only main branch is used for releases, and all features are merged into main after review and CI validation. Releases are tagged with semantic versioning (1.0.3, 1.0.4, etc.) and automatically built/pushed to the container registry.
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
|
||||
@@ -21,6 +21,42 @@ This guide covers prerequisites, environment setup, running the application loca
|
||||
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`)
|
||||
- **Hetzner Cloud Account** (optional for production): For the US-East VPS (Ashburn, VA recommended per ADR-001).
|
||||
|
||||
## Network Environments
|
||||
|
||||
Linear Void operates in two distinct environments. Know which one you're targeting.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Test_Lab["Test Lab (192.168.88.0/24)"]
|
||||
TEST_HOST["192.168.88.40<br/>Docker Engine :2375"]
|
||||
end
|
||||
|
||||
subgraph Prod["Production (Hetzner — NOT provisioned)"]
|
||||
PROD_HOST["Hetzner CX22 Ashburn, VA<br/>Public IP TBD"]
|
||||
end
|
||||
|
||||
subgraph Gitea["Gitea CI/CD"]
|
||||
CI["CI: build + push images"]
|
||||
CD["CD: deploy via DOCKER_HOST"]
|
||||
end
|
||||
|
||||
subgraph CF["Cloudflare"]
|
||||
DNS["linearvoid.tv"]
|
||||
R2["R2 Bucket"]
|
||||
end
|
||||
|
||||
CD -->|"DOCKER_HOST=tcp://192.168.88.40:2375"| TEST_HOST
|
||||
CD -.->|"DOCKER_HOST=tcp://<hetzner-ip>:2375<br/>(when provisioned)"| PROD_HOST
|
||||
TEST_HOST -->|"test traffic"| DNS
|
||||
PROD_HOST -.->|"production traffic"| DNS
|
||||
TEST_HOST --> R2
|
||||
PROD_HOST -.-> R2
|
||||
```
|
||||
|
||||
**Test Lab (active):** Internal network `192.168.88.0/24`, host `192.168.88.40`. Receives automated CD deploys from Gitea Actions. Used for integration testing and staging validation.
|
||||
|
||||
**Production (pending):** Hetzner Cloud VPS, Ashburn VA. Not yet provisioned. CD workflow and compose files are ready — only `DOCKER_HOST` needs changing. See `TODO.md` Production Deployment section.
|
||||
|
||||
## Development Prerequisites
|
||||
|
||||
- **Frontend dependencies**: Run `cd frontend && npm install` before `npm run dev` or `npm run build`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module chat
|
||||
|
||||
go 1.23.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
|
||||
+534
-37
@@ -33,11 +33,21 @@ var (
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
clients = make(map[*websocket.Conn]bool)
|
||||
clientMeta = make(map[*websocket.Conn]clientInfo)
|
||||
mutex = sync.Mutex{}
|
||||
authSessions = map[string]time.Time{}
|
||||
authSessionsMu = sync.Mutex{}
|
||||
|
||||
// Rate limit config (env-overridable)
|
||||
rateLimitMax = 10
|
||||
rateLimitWindow = 10 * time.Second
|
||||
)
|
||||
|
||||
type clientInfo struct {
|
||||
Username string
|
||||
Room string
|
||||
}
|
||||
|
||||
// Message represents a chat message (persisted in SQL DB per ADR-006)
|
||||
type Message struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
@@ -73,6 +83,16 @@ type Media struct {
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ScheduleOverride represents a scheduled playlist override (ADR-004)
|
||||
type ScheduleOverride struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
MediaID uint `json:"mediaId"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Presence tracks online users per room (using Redis per ADR-007)
|
||||
type Presence struct {
|
||||
Username string `json:"username"`
|
||||
@@ -287,11 +307,35 @@ func initRedis() {
|
||||
if err := redisClient.Ping(context.Background()).Err(); err != nil {
|
||||
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
|
||||
redisClient = nil
|
||||
// Start auto-reconnect goroutine
|
||||
go redisReconnectLoop(redisAddress, options)
|
||||
} else {
|
||||
log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)")
|
||||
}
|
||||
}
|
||||
|
||||
// redisReconnectLoop attempts to reconnect to Redis with exponential backoff.
|
||||
func redisReconnectLoop(addr string, baseOptions *redis.Options) {
|
||||
backoff := 2 * time.Second
|
||||
maxBackoff := 60 * time.Second
|
||||
for {
|
||||
time.Sleep(backoff)
|
||||
testClient := redis.NewClient(baseOptions)
|
||||
if err := testClient.Ping(context.Background()).Err(); err != nil {
|
||||
log.Printf("Redis reconnect failed: %v (retry in %v)", err, backoff)
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
testClient.Close()
|
||||
continue
|
||||
}
|
||||
redisClient = testClient
|
||||
log.Println("Redis reconnected successfully")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// initDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006)
|
||||
func initDB() {
|
||||
var err error
|
||||
@@ -319,7 +363,7 @@ func initDB() {
|
||||
}
|
||||
|
||||
databaseDriverName = driverName
|
||||
db.AutoMigrate(&Message{}, &ModerationLog{}, &Media{})
|
||||
db.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
|
||||
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driverName))
|
||||
}
|
||||
|
||||
@@ -626,6 +670,104 @@ func handleAdminPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// handlePublicSchedule returns playlist schedule without auth (viewer-facing).
|
||||
func handlePublicSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
content, err := os.ReadFile(getPlaylistPath())
|
||||
if err != nil {
|
||||
http.Error(w, "schedule unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
entries := parsePlaylistEntries(string(content))
|
||||
var mediaRecords []Media
|
||||
db.Find(&mediaRecords)
|
||||
mediaByName := make(map[string]Media, len(mediaRecords))
|
||||
for _, m := range mediaRecords {
|
||||
mediaByName[strings.ToLower(m.Filename)] = m
|
||||
}
|
||||
type PublicEntry struct {
|
||||
Index int `json:"index"`
|
||||
Label string `json:"label"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
output := make([]PublicEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
pe := PublicEntry{Index: e.Index, Label: e.Label, Status: "pending"}
|
||||
if m, ok := mediaByName[strings.ToLower(filepath.Base(e.Path))]; ok {
|
||||
pe.Title = m.Title
|
||||
pe.Status = m.Status
|
||||
} else {
|
||||
pe.Title = filepath.Base(e.Path)
|
||||
}
|
||||
output = append(output, pe)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"entries": output,
|
||||
"total": len(output),
|
||||
"generatedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminScheduleOverrides manages CRUD for schedule overrides.
|
||||
func handleAdminScheduleOverrides(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
var overrides []ScheduleOverride
|
||||
db.Order("start_time asc").Find(&overrides)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(overrides)
|
||||
|
||||
case http.MethodPost:
|
||||
var ov ScheduleOverride
|
||||
if err := json.NewDecoder(r.Body).Decode(&ov); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ov.MediaID == 0 || ov.StartTime.IsZero() {
|
||||
http.Error(w, "mediaId and startTime are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Conflict detection: warn if overlapping
|
||||
var conflict int64
|
||||
db.Model(&ScheduleOverride{}).
|
||||
Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime).
|
||||
Count(&conflict)
|
||||
if conflict > 0 {
|
||||
http.Error(w, fmt.Sprintf("conflict: %d overlapping override(s)", conflict), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
db.Create(&ov)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(ov)
|
||||
|
||||
case http.MethodDelete:
|
||||
idStr := r.URL.Query().Get("id")
|
||||
if idStr == "" {
|
||||
http.Error(w, "id query param required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := db.Delete(&ScheduleOverride{}, id).Error; err != nil {
|
||||
http.Error(w, "failed to delete override", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -875,28 +1017,73 @@ func getAdminSystemSummary() AdminSystemSummary {
|
||||
return summary
|
||||
}
|
||||
|
||||
// handleAdminConfig returns runtime configuration values for the admin config editor.
|
||||
func handleAdminConfig(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"rateLimitMax": rateLimitMax,
|
||||
"rateLimitWindowSec": int(rateLimitWindow.Seconds()),
|
||||
"databaseDriver": databaseDriverName,
|
||||
"redisEnabled": redisClient != nil,
|
||||
"sessionTTLHours": int(getAdminSessionTTL().Hours()),
|
||||
})
|
||||
case http.MethodPut:
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if v, ok := payload["rateLimitMax"].(float64); ok && v > 0 {
|
||||
rateLimitMax = int(v)
|
||||
log.Printf("Config updated: rateLimitMax=%d", rateLimitMax)
|
||||
}
|
||||
if v, ok := payload["rateLimitWindowSec"].(float64); ok && v > 0 {
|
||||
rateLimitWindow = time.Duration(v) * time.Second
|
||||
log.Printf("Config updated: rateLimitWindow=%v", rateLimitWindow)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAdminRestart triggers a service restart (placeholder — returns instructions).
|
||||
func handleAdminRestart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
service := r.URL.Query().Get("service")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "restart_not_automated",
|
||||
"service": service,
|
||||
"message": fmt.Sprintf("Restart %s manually: docker compose -f docker-compose.prod.yml restart %s", service, service),
|
||||
})
|
||||
}
|
||||
|
||||
func handleAdminSystemSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
summary := getAdminSystemSummary()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(getAdminSystemSummary())
|
||||
json.NewEncoder(w).Encode(summary)
|
||||
}
|
||||
|
||||
func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
response := AdminUsersResponse{
|
||||
Users: []ActiveUser{},
|
||||
RedisEnabled: redisClient != nil,
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if redisClient != nil {
|
||||
ctx := context.Background()
|
||||
keys, _, err := redisClient.Scan(ctx, 0, "presence:*", 200).Result()
|
||||
@@ -904,7 +1091,6 @@ func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, fmt.Sprintf("failed to scan presence keys: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
parts := strings.SplitN(key, ":", 3)
|
||||
if len(parts) != 3 {
|
||||
@@ -917,16 +1103,57 @@ func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(response.Users, func(i, j int) bool {
|
||||
if response.Users[i].Room == response.Users[j].Room {
|
||||
return response.Users[i].Username < response.Users[j].Username
|
||||
}
|
||||
return response.Users[i].Room < response.Users[j].Room
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
|
||||
case http.MethodPost:
|
||||
var payload struct {
|
||||
Action string `json:"action"` // mute, unmute, ban, unban
|
||||
Username string `json:"username"`
|
||||
Duration int `json:"duration"` // minutes, for mute
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if payload.Action == "" || payload.Username == "" {
|
||||
http.Error(w, "action and username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if redisClient == nil {
|
||||
http.Error(w, "Redis unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
switch payload.Action {
|
||||
case "mute":
|
||||
dur := time.Duration(payload.Duration) * time.Minute
|
||||
if dur <= 0 {
|
||||
dur = 10 * time.Minute
|
||||
}
|
||||
redisClient.Set(ctx, fmt.Sprintf("muted:%s", payload.Username), "1", dur)
|
||||
case "unmute":
|
||||
redisClient.Del(ctx, fmt.Sprintf("muted:%s", payload.Username))
|
||||
case "ban":
|
||||
redisClient.Set(ctx, fmt.Sprintf("banned:%s", payload.Username), "1", 0)
|
||||
case "unban":
|
||||
redisClient.Del(ctx, fmt.Sprintf("banned:%s", payload.Username))
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "action": payload.Action, "username": payload.Username})
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func listMediaFiles() ([]MediaFileEntry, error) {
|
||||
@@ -1139,6 +1366,90 @@ func mediaEntryFromRecord(record Media) MediaFileEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// MediaNormalizationResult holds ffprobe validation output for admin uploads.
|
||||
type MediaNormalizationResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// validateMediaNormalization runs ffprobe against a media file and checks
|
||||
// conformance with the Linear Void normalization profile (ADR-001).
|
||||
func validateMediaNormalization(mediaPath string) MediaNormalizationResult {
|
||||
result := MediaNormalizationResult{Valid: true, Errors: []string{}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx,
|
||||
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", mediaPath,
|
||||
).CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return MediaNormalizationResult{Valid: false, Errors: []string{"ffprobe timed out"}}
|
||||
}
|
||||
if err != nil {
|
||||
return MediaNormalizationResult{Valid: false, Errors: []string{fmt.Sprintf("ffprobe failed: %v (%s)", err, strings.TrimSpace(string(out)))}}
|
||||
}
|
||||
|
||||
var probe struct {
|
||||
Streams []struct {
|
||||
CodecName string `json:"codec_name"`
|
||||
CodecType string `json:"codec_type"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
RFrameRate string `json:"r_frame_rate"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &probe); err != nil {
|
||||
return MediaNormalizationResult{Valid: false, Errors: []string{"ffprobe JSON parse failed: " + err.Error()}}
|
||||
}
|
||||
|
||||
var hasVideo, hasAudio bool
|
||||
for _, s := range probe.Streams {
|
||||
switch s.CodecType {
|
||||
case "video":
|
||||
hasVideo = true
|
||||
if s.CodecName != "h264" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
|
||||
}
|
||||
if s.RFrameRate != "30/1" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("framerate must be 30 fps constant, got %s", s.RFrameRate))
|
||||
}
|
||||
if s.Width != 1280 && s.Width != 1920 {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
|
||||
}
|
||||
if s.Height != 720 && s.Height != 1080 {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
|
||||
}
|
||||
if s.PixFmt != "yuv420p" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
|
||||
}
|
||||
case "audio":
|
||||
hasAudio = true
|
||||
if s.CodecName != "aac" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
|
||||
}
|
||||
if s.SampleRate != "48000" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("sample rate must be 48000 Hz, got %s", s.SampleRate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasVideo {
|
||||
result.Errors = append(result.Errors, "no video stream found")
|
||||
}
|
||||
if !hasAudio {
|
||||
result.Errors = append(result.Errors, "no audio stream found")
|
||||
}
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
result.Valid = false
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
func probeMediaDurationSeconds(mediaPath string) (int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
@@ -1274,6 +1585,13 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate normalization profile (ADR-001)
|
||||
normResult := validateMediaNormalization(targetPath)
|
||||
status := "ready"
|
||||
if !normResult.Valid {
|
||||
status = "needs_normalization"
|
||||
}
|
||||
|
||||
filename := filepath.Base(targetPath)
|
||||
mediaRecord := Media{
|
||||
Filename: filename,
|
||||
@@ -1281,9 +1599,9 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
|
||||
Path: targetPath,
|
||||
SizeBytes: fileInfo.Size(),
|
||||
DurationSeconds: durationSeconds,
|
||||
Status: "ready",
|
||||
Status: status,
|
||||
Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
|
||||
Tags: "",
|
||||
Tags: fmt.Sprintf(`"normalization":%v`, normResult.Valid),
|
||||
}
|
||||
if err := db.Create(&mediaRecord).Error; err != nil {
|
||||
_ = os.Remove(targetPath)
|
||||
@@ -1602,6 +1920,13 @@ func handleAdminMediaUploads(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate normalization profile (ADR-001)
|
||||
normResult := validateMediaNormalization(targetPath)
|
||||
status := "ready"
|
||||
if !normResult.Valid {
|
||||
status = "needs_normalization"
|
||||
}
|
||||
|
||||
filename := filepath.Base(targetPath)
|
||||
mediaRecord := Media{
|
||||
Filename: filename,
|
||||
@@ -1609,9 +1934,9 @@ func handleAdminMediaUploads(w http.ResponseWriter, r *http.Request) {
|
||||
Path: targetPath,
|
||||
SizeBytes: fileInfo.Size(),
|
||||
DurationSeconds: durationSeconds,
|
||||
Status: "ready",
|
||||
Status: status,
|
||||
Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
|
||||
Tags: "",
|
||||
Tags: fmt.Sprintf(`"normalization":%v`, normResult.Valid),
|
||||
}
|
||||
if err := db.Create(&mediaRecord).Error; err != nil {
|
||||
_ = os.Remove(targetPath)
|
||||
@@ -1793,6 +2118,10 @@ func handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
|
||||
writePrometheusMetricLine(w, "linearvoid_chat_messages_total", storedMessages)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_dms_total Total private messages sent.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_dms_total counter")
|
||||
writePrometheusMetricLine(w, "linearvoid_chat_dms_total", dmsSent)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
|
||||
writePrometheusMetricLine(w, "linearvoid_chat_moderation_actions_total", moderationActions)
|
||||
@@ -1841,25 +2170,57 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
|
||||
log.Println("Upgrade error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.URL.Query().Get("username")
|
||||
room := r.URL.Query().Get("room")
|
||||
if username == "" {
|
||||
username = fmt.Sprintf("anon_%d", time.Now().UnixNano()%10000)
|
||||
}
|
||||
|
||||
mutex.Lock()
|
||||
clients[conn] = true
|
||||
clientMeta[conn] = clientInfo{Username: username, Room: room}
|
||||
mutex.Unlock()
|
||||
|
||||
// Announce presence (ADR-007)
|
||||
username := r.URL.Query().Get("username")
|
||||
room := r.URL.Query().Get("room")
|
||||
if redisClient != nil && username != "" && room != "" {
|
||||
if redisClient != nil && room != "" {
|
||||
redisClient.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||
broadcast(Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system"})
|
||||
broadcast(Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()})
|
||||
}
|
||||
|
||||
// Push chat history on join (last 50 messages)
|
||||
pushChatHistory(conn, room, 50)
|
||||
|
||||
// Heartbeat goroutine: refresh presence TTL every 30s
|
||||
heartbeatStop := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if redisClient != nil && room != "" {
|
||||
redisClient.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||
}
|
||||
// Ping to detect dead connections
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
case <-heartbeatStop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
close(heartbeatStop)
|
||||
mutex.Lock()
|
||||
delete(clients, conn)
|
||||
delete(clientMeta, conn)
|
||||
mutex.Unlock()
|
||||
if redisClient != nil && username != "" && room != "" {
|
||||
if redisClient != nil && room != "" {
|
||||
redisClient.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
|
||||
broadcast(Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system"})
|
||||
broadcast(Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()})
|
||||
}
|
||||
conn.Close()
|
||||
}()
|
||||
@@ -1873,15 +2234,27 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
|
||||
msg.Timestamp = time.Now()
|
||||
msg.Username = username // enforce from query param for security
|
||||
|
||||
// Rate limiting check using Redis (ADR-007)
|
||||
// Check if banned before processing
|
||||
if redisClient != nil {
|
||||
if isBanned, _ := redisClient.Exists(context.Background(), fmt.Sprintf("banned:%s", username)).Result(); isBanned > 0 {
|
||||
conn.WriteJSON(Message{Type: "system", Text: "You are banned from chat.", Timestamp: time.Now()})
|
||||
continue
|
||||
}
|
||||
if isMuted, _ := redisClient.Exists(context.Background(), fmt.Sprintf("muted:%s", username)).Result(); isMuted > 0 {
|
||||
conn.WriteJSON(Message{Type: "system", Text: "You are muted.", Timestamp: time.Now()})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting check using Redis with env-configurable thresholds
|
||||
if redisClient != nil {
|
||||
key := fmt.Sprintf("rate:%s", msg.Username)
|
||||
count, _ := redisClient.Incr(context.Background(), key).Result()
|
||||
if count == 1 {
|
||||
redisClient.Expire(context.Background(), key, 10*time.Second)
|
||||
redisClient.Expire(context.Background(), key, rateLimitWindow)
|
||||
}
|
||||
if count > 10 {
|
||||
conn.WriteJSON(Message{Type: "system", Text: "Rate limit exceeded. Please slow down."})
|
||||
if count > int64(rateLimitMax) {
|
||||
conn.WriteJSON(Message{Type: "system", Text: "Rate limit exceeded. Please slow down.", Timestamp: time.Now()})
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -1892,29 +2265,73 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle private DMs (room format "dm:user1-user2")
|
||||
// Handle private DMs
|
||||
if strings.HasPrefix(msg.Room, "dm:") {
|
||||
handlePrivateDM(msg)
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
msg.Type = "public"
|
||||
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
|
||||
broadcast(msg)
|
||||
|
||||
// Save private messages explicitly as well.
|
||||
if msg.Type == "private" {
|
||||
db.Create(&msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pushChatHistory sends the last N messages for a room via WebSocket.
|
||||
func pushChatHistory(conn *websocket.Conn, room string, limit int) {
|
||||
if db == nil || room == "" {
|
||||
return
|
||||
}
|
||||
var messages []Message
|
||||
db.Where("room = ?", room).Order("timestamp desc").Limit(limit).Find(&messages)
|
||||
// Reverse to chronological order
|
||||
for i := len(messages)/2 - 1; i >= 0; i-- {
|
||||
opp := len(messages) - 1 - i
|
||||
messages[i], messages[opp] = messages[opp], messages[i]
|
||||
}
|
||||
for _, msg := range messages {
|
||||
conn.WriteJSON(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// dmsSent tracks total private messages for Prometheus metrics (set from handleMetrics).
|
||||
var dmsSent int64
|
||||
|
||||
// handlePrivateDM routes private messages to the specific user pair
|
||||
func handlePrivateDM(msg Message) {
|
||||
// Persist private messages for history.
|
||||
msg.Type = "private"
|
||||
dmsSent++
|
||||
|
||||
// Persist for history
|
||||
db.Create(&msg)
|
||||
|
||||
// Broadcast only to participants (simplified - in production use a map of user connections)
|
||||
broadcast(msg)
|
||||
// Deliver to target if online
|
||||
mutex.Lock()
|
||||
targetOnline := false
|
||||
for conn, info := range clientMeta {
|
||||
if info.Username == msg.Room || strings.Contains(msg.Room, info.Username) {
|
||||
if err := conn.WriteJSON(msg); err == nil {
|
||||
targetOnline = true
|
||||
}
|
||||
}
|
||||
}
|
||||
mutex.Unlock()
|
||||
|
||||
if !targetOnline {
|
||||
// Target offline — message persisted, will appear in history on next join
|
||||
log.Printf("DM stored for offline user: %s -> room %s", msg.Username, msg.Room)
|
||||
}
|
||||
|
||||
// Also deliver to sender for confirmation
|
||||
mutex.Lock()
|
||||
for conn, info := range clientMeta {
|
||||
if info.Username == msg.Username {
|
||||
conn.WriteJSON(msg)
|
||||
break
|
||||
}
|
||||
}
|
||||
mutex.Unlock()
|
||||
|
||||
log.Printf("Private DM from %s to room %s: %s", msg.Username, msg.Room, msg.Text)
|
||||
}
|
||||
|
||||
@@ -1935,7 +2352,14 @@ func logModerationAction(action, target, moderator, reason string) {
|
||||
func handleModerationCommand(msg Message, conn *websocket.Conn) {
|
||||
parts := strings.Fields(msg.Text)
|
||||
if len(parts) < 3 {
|
||||
conn.WriteJSON(Message{Type: "system", Text: "Usage: /mod <action> <target> <reason> or /mute <user> <reason>"})
|
||||
conn.WriteJSON(Message{Type: "system", Text: "Usage: /mute <user> <duration_m> <reason> or /ban <user> <reason>", Timestamp: time.Now()})
|
||||
return
|
||||
}
|
||||
|
||||
// Basic moderator check: admin username or configured moderators
|
||||
adminUser := getAdminUsername()
|
||||
if msg.Username != adminUser {
|
||||
conn.WriteJSON(Message{Type: "system", Text: "Only moderators can use moderation commands.", Timestamp: time.Now()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1943,12 +2367,34 @@ func handleModerationCommand(msg Message, conn *websocket.Conn) {
|
||||
target := parts[1]
|
||||
reason := strings.Join(parts[2:], " ")
|
||||
|
||||
if redisClient != nil {
|
||||
switch action {
|
||||
case "mute", "mod":
|
||||
durationMinutes := 10 // default
|
||||
if len(parts) >= 3 {
|
||||
if d, err := strconv.Atoi(parts[2]); err == nil && d > 0 {
|
||||
durationMinutes = d
|
||||
if len(parts) > 3 {
|
||||
reason = strings.Join(parts[3:], " ")
|
||||
} else {
|
||||
reason = "no reason given"
|
||||
}
|
||||
}
|
||||
}
|
||||
redisClient.Set(context.Background(), fmt.Sprintf("muted:%s", target), "1", time.Duration(durationMinutes)*time.Minute)
|
||||
case "ban":
|
||||
redisClient.Set(context.Background(), fmt.Sprintf("banned:%s", target), "1", 0) // no expiry
|
||||
}
|
||||
}
|
||||
|
||||
logModerationAction(action, target, msg.Username, reason)
|
||||
|
||||
broadcast(Message{
|
||||
Room: msg.Room,
|
||||
Username: "system",
|
||||
Type: "system",
|
||||
Text: fmt.Sprintf("Moderator %s %s %s for: %s", msg.Username, action, target, reason),
|
||||
Text: fmt.Sprintf("%s was %s by %s: %s", target, action, msg.Username, reason),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2075,6 +2521,39 @@ func handleAdminChatLogByID(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleAdminChatLogExport returns chat log as CSV file.
|
||||
func handleAdminChatLogExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
room := strings.TrimSpace(query.Get("room"))
|
||||
dbQuery := db.Model(&Message{})
|
||||
if room != "" {
|
||||
dbQuery = dbQuery.Where("room = ?", room)
|
||||
}
|
||||
if username := strings.TrimSpace(query.Get("username")); username != "" {
|
||||
dbQuery = dbQuery.Where("username LIKE ?", "%"+username+"%")
|
||||
}
|
||||
|
||||
var messages []Message
|
||||
if err := dbQuery.Order("timestamp desc").Limit(10000).Find(&messages).Error; err != nil {
|
||||
http.Error(w, "failed to load messages", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=chat-log.csv")
|
||||
fmt.Fprintln(w, "id,room,username,type,text,timestamp")
|
||||
for _, msg := range messages {
|
||||
text := strings.ReplaceAll(msg.Text, `"`, `""`)
|
||||
fmt.Fprintf(w, "%d,%s,%s,%s,\"%s\",%s\n",
|
||||
msg.ID, msg.Room, msg.Username, msg.Type, text, msg.Timestamp.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// handleAdminChatLogBulk deletes multiple chat log entries by ID
|
||||
func handleAdminChatLogBulk(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
@@ -2109,6 +2588,19 @@ func handleAdminChatLogBulk(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Load env-configurable rate limits
|
||||
if raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX")); raw != "" {
|
||||
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
|
||||
rateLimitMax = v
|
||||
}
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_WINDOW_S")); raw != "" {
|
||||
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
|
||||
rateLimitWindow = time.Duration(v) * time.Second
|
||||
}
|
||||
}
|
||||
log.Printf("Rate limit: %d messages per %v", rateLimitMax, rateLimitWindow)
|
||||
|
||||
initRedis()
|
||||
initDB()
|
||||
|
||||
@@ -2122,13 +2614,18 @@ func main() {
|
||||
http.HandleFunc("/api/admin/moderation/actions", requireAdminAuth(handleAdminModerationAction))
|
||||
http.HandleFunc("/api/admin/analytics", requireAdminAuth(handleAdminAnalytics))
|
||||
http.HandleFunc("/api/admin/system", requireAdminAuth(handleAdminSystemSummary))
|
||||
http.HandleFunc("/api/admin/config", requireAdminAuth(handleAdminConfig))
|
||||
http.HandleFunc("/api/admin/restart", requireAdminAuth(handleAdminRestart))
|
||||
http.HandleFunc("/api/admin/users", requireAdminAuth(handleAdminUsers))
|
||||
http.HandleFunc("/api/admin/schedule", requireAdminAuth(handleAdminSchedule))
|
||||
http.HandleFunc("/api/admin/schedule/overrides", requireAdminAuth(handleAdminScheduleOverrides))
|
||||
http.HandleFunc("/api/schedule", handlePublicSchedule)
|
||||
http.HandleFunc("/api/admin/media", requireAdminAuth(handleAdminMedia))
|
||||
http.HandleFunc("/api/admin/media/uploads", requireAdminAuth(handleAdminMediaUploads))
|
||||
http.HandleFunc("/api/admin/media/uploads/", requireAdminAuth(handleAdminMediaUploads))
|
||||
http.HandleFunc("/api/admin/media/", requireAdminAuth(handleAdminMediaByID))
|
||||
http.HandleFunc("/api/admin/chat-log/bulk", requireAdminAuth(handleAdminChatLogBulk))
|
||||
http.HandleFunc("/api/admin/chat-log/export", requireAdminAuth(handleAdminChatLogExport))
|
||||
http.HandleFunc("/api/admin/chat-log/", requireAdminAuth(handleAdminChatLogByID))
|
||||
http.HandleFunc("/api/admin/chat-log", requireAdminAuth(handleAdminChatLog))
|
||||
http.HandleFunc("/metrics", handleMetrics)
|
||||
|
||||
@@ -18,8 +18,8 @@ RUN apk add --no-cache \
|
||||
bash
|
||||
# Set working directory for playout
|
||||
WORKDIR /app
|
||||
# Create directories for playlist, media, and HLS output
|
||||
RUN mkdir -p /app/hls /app/media /app/playlists
|
||||
# Create directories for playlist, media, HLS output, and emergency slate
|
||||
RUN mkdir -p /app/hls /app/media /app/playlists /app/slate
|
||||
# Copy the playout start script and the built playlist manager binary
|
||||
COPY start-playout.sh /app/
|
||||
# Copy demo data for out-of-the-box playout
|
||||
|
||||
+280
-13
@@ -3,11 +3,13 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,6 +29,16 @@ type VideoFile struct {
|
||||
var (
|
||||
r2Client *s3.Client
|
||||
bucket = os.Getenv("R2_BUCKET_NAME")
|
||||
playoutMode = "starting" // "live", "slate", "degraded"
|
||||
restartCount = 0
|
||||
restartWindow = time.Now()
|
||||
slateDir = "/app/slate"
|
||||
hlsDir = "/app/hls"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRestartsPerMinute = 5
|
||||
slateManifestName = "playlist.m3u8"
|
||||
)
|
||||
|
||||
// initR2Client initializes the R2 (S3-compatible) client
|
||||
@@ -79,23 +91,181 @@ func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
||||
// ValidateVideo uses ffprobe to enforce codec, fps, and keyframe interval (ADR-001)
|
||||
func ValidateVideo(videoPath string) (VideoFile, error) {
|
||||
v := VideoFile{Path: videoPath}
|
||||
var errors []string
|
||||
|
||||
// Run ffprobe
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath)
|
||||
_, err := cmd.Output()
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
v.Validation = "ffprobe failed: " + err.Error()
|
||||
return v, err
|
||||
return v, fmt.Errorf("ffprobe: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Parse JSON for strict checks (codec_name=h264, r_frame_rate=30/1, key_frame interval)
|
||||
// For MVP we assume valid if ffprobe succeeds
|
||||
v.Valid = true
|
||||
v.Validation = "Validation passed (H.264/AVC, AAC, 30fps constant, 2s keyframes - full JSON parse TODO per ADR-001)"
|
||||
v.Duration = 60.0 // placeholder from ffprobe
|
||||
// Parse JSON
|
||||
var probe struct {
|
||||
Streams []struct {
|
||||
CodecName string `json:"codec_name"`
|
||||
CodecType string `json:"codec_type"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
RFrameRate string `json:"r_frame_rate"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
Channels int `json:"channels"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"streams"`
|
||||
Format struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"format"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &probe); err != nil {
|
||||
v.Validation = "ffprobe JSON parse failed: " + err.Error()
|
||||
return v, fmt.Errorf("ffprobe json: %w", err)
|
||||
}
|
||||
|
||||
if len(probe.Streams) == 0 {
|
||||
errors = append(errors, "no streams detected")
|
||||
}
|
||||
|
||||
var hasVideo, hasAudio bool
|
||||
for _, s := range probe.Streams {
|
||||
switch s.CodecType {
|
||||
case "video":
|
||||
hasVideo = true
|
||||
if s.CodecName != "h264" {
|
||||
errors = append(errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
|
||||
}
|
||||
if s.RFrameRate != "30/1" {
|
||||
errors = append(errors, fmt.Sprintf("framerate must be 30/1, got %s", s.RFrameRate))
|
||||
}
|
||||
if s.Width != 1280 && s.Width != 1920 {
|
||||
errors = append(errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
|
||||
}
|
||||
if s.Height != 720 && s.Height != 1080 {
|
||||
errors = append(errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
|
||||
}
|
||||
if s.PixFmt != "yuv420p" {
|
||||
errors = append(errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
|
||||
}
|
||||
case "audio":
|
||||
hasAudio = true
|
||||
if s.CodecName != "aac" {
|
||||
errors = append(errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
|
||||
}
|
||||
if s.SampleRate != "48000" {
|
||||
errors = append(errors, fmt.Sprintf("audio sample rate must be 48000, got %s", s.SampleRate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasVideo {
|
||||
errors = append(errors, "no video stream found")
|
||||
}
|
||||
if !hasAudio {
|
||||
errors = append(errors, "no audio stream found")
|
||||
}
|
||||
|
||||
// Parse duration from format or stream
|
||||
durationSec := 0.0
|
||||
if probe.Format.Duration != "" {
|
||||
if d, err := strconv.ParseFloat(probe.Format.Duration, 64); err == nil {
|
||||
durationSec = d
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
v.Valid = false
|
||||
v.Validation = strings.Join(errors, "; ")
|
||||
return v, fmt.Errorf("validation failed: %s", v.Validation)
|
||||
}
|
||||
|
||||
// Validate keyframe interval <=2s (60 frames at 30fps)
|
||||
keyframeErr := validateKeyframeInterval(videoPath)
|
||||
if keyframeErr != nil {
|
||||
errors = append(errors, keyframeErr.Error())
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
v.Valid = false
|
||||
v.Validation = strings.Join(errors, "; ")
|
||||
return v, fmt.Errorf("validation failed: %s", v.Validation)
|
||||
}
|
||||
|
||||
v.Valid = true
|
||||
v.Duration = durationSec
|
||||
v.Validation = fmt.Sprintf("OK: H.264/AAC, 30fps, yuv420p, 48kHz, %.1fs, keyframes OK", durationSec)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// validateKeyframeInterval checks that max gap between keyframes <=2s (60 frames at 30fps).
|
||||
func validateKeyframeInterval(videoPath string) error {
|
||||
cmd := exec.Command("ffprobe",
|
||||
"-v", "quiet",
|
||||
"-select_streams", "v:0",
|
||||
"-show_frames",
|
||||
"-show_entries", "frame=key_frame,pkt_pts_time",
|
||||
"-of", "csv=p=0",
|
||||
videoPath)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("keyframe probe failed: %w", err)
|
||||
}
|
||||
|
||||
type kfEntry struct{ pts float64 }
|
||||
var keyframes []kfEntry
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, ",")
|
||||
if len(parts) < 2 || parts[0] != "1" {
|
||||
continue
|
||||
}
|
||||
pts, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
keyframes = append(keyframes, kfEntry{pts})
|
||||
}
|
||||
|
||||
if len(keyframes) < 2 {
|
||||
return fmt.Errorf("insufficient keyframes for interval analysis (need >=2, got %d)", len(keyframes))
|
||||
}
|
||||
|
||||
maxGap := 0.0
|
||||
for i := 1; i < len(keyframes); i++ {
|
||||
gap := keyframes[i].pts - keyframes[i-1].pts
|
||||
if gap > maxGap {
|
||||
maxGap = gap
|
||||
}
|
||||
}
|
||||
|
||||
if maxGap > 2.1 { // small tolerance for floating point
|
||||
return fmt.Errorf("keyframe interval too large: max %.2fs (must be <=2s)", maxGap)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateVideoStrict runs ValidateVideo and returns a structured JSON result.
|
||||
func ValidateVideoStrict(videoPath string) map[string]any {
|
||||
v, err := ValidateVideo(videoPath)
|
||||
result := map[string]any{
|
||||
"path": videoPath,
|
||||
"valid": v.Valid,
|
||||
"errors": []string{},
|
||||
}
|
||||
if err != nil {
|
||||
result["errors"] = strings.Split(v.Validation, "; ")
|
||||
}
|
||||
if v.Valid {
|
||||
result["duration_seconds"] = v.Duration
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidatePlaylist runs normalization validation on all entries
|
||||
func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
|
||||
videos, err := LoadPlaylist(playlistPath)
|
||||
@@ -139,8 +309,65 @@ func SyncToR2(localPath, r2Key string) error {
|
||||
// HealthHandler provides a simple health check endpoint
|
||||
func HealthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
status := "healthy"
|
||||
if playoutMode == "slate" {
|
||||
status = "degraded"
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"status":"healthy","service":"playout","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
|
||||
fmt.Fprintf(w, `{"status":"%s","service":"playout","mode":"%s","restarts":%d,"timestamp":"%s"}`,
|
||||
status, playoutMode, restartCount, time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// activateSlate copies pre-rendered slate HLS into the live output directory.
|
||||
// Writes a static manifest referencing slate segments. Returns error if slate not found.
|
||||
func activateSlate() error {
|
||||
slateHLS := filepath.Join(slateDir, "hls")
|
||||
if _, err := os.Stat(filepath.Join(slateHLS, "slate.m3u8")); os.IsNotExist(err) {
|
||||
return fmt.Errorf("slate HLS not found at %s — run scripts/generate-slate.sh first", slateHLS)
|
||||
}
|
||||
|
||||
// Clear existing live segments
|
||||
if err := resetHLSOutput(hlsDir); err != nil {
|
||||
return fmt.Errorf("failed to clear HLS output for slate: %w", err)
|
||||
}
|
||||
|
||||
// Copy slate segments into live HLS dir
|
||||
slateFiles, err := filepath.Glob(filepath.Join(slateHLS, "*"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, src := range slateFiles {
|
||||
dst := filepath.Join(hlsDir, filepath.Base(src))
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
playoutMode = "slate"
|
||||
fmt.Printf("[%s] EMERGENCY: Switched to slate fallback. %d restarts in window.\n",
|
||||
time.Now().Format(time.RFC3339), restartCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deactivateSlate clears slate state and prepares for live playout resumption.
|
||||
func deactivateSlate() {
|
||||
playoutMode = "live"
|
||||
fmt.Printf("[%s] RECOVERY: Slate deactivated, resuming live playout.\n",
|
||||
time.Now().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// shouldEscalateToSlate checks if restart count exceeds threshold within the window.
|
||||
func shouldEscalateToSlate() bool {
|
||||
if time.Since(restartWindow) > 60*time.Second {
|
||||
restartCount = 0
|
||||
restartWindow = time.Now()
|
||||
return false
|
||||
}
|
||||
return restartCount >= maxRestartsPerMinute
|
||||
}
|
||||
|
||||
func resetHLSOutput(hlsDir string) error {
|
||||
@@ -207,6 +434,21 @@ func main() {
|
||||
|
||||
// Start HTTP health check server
|
||||
http.HandleFunc("/health", HealthHandler)
|
||||
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
fmt.Fprintln(w, "# HELP linearvoid_playout_restarts_total Total FFmpeg restarts.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_playout_restarts_total counter")
|
||||
fmt.Fprintf(w, "linearvoid_playout_restarts_total %d\n", restartCount)
|
||||
fmt.Fprintln(w, "# HELP linearvoid_playout_mode Current playout mode (0=live, 1=slate, 2=degraded).")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_playout_mode gauge")
|
||||
modeVal := 0
|
||||
if playoutMode == "slate" {
|
||||
modeVal = 1
|
||||
} else if playoutMode == "degraded" {
|
||||
modeVal = 2
|
||||
}
|
||||
fmt.Fprintf(w, "linearvoid_playout_mode %d\n", modeVal)
|
||||
})
|
||||
go func() {
|
||||
fmt.Println("Health check server listening on :8081/health")
|
||||
if err := http.ListenAndServe(":8081", nil); err != nil {
|
||||
@@ -214,9 +456,14 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Run FFmpeg in a loop for continuous playback with R2 sync hook
|
||||
// Run FFmpeg in a loop for continuous playback with slate fallback
|
||||
for {
|
||||
if err := resetHLSOutput("/app/hls"); err != nil {
|
||||
if playoutMode == "slate" {
|
||||
// During slate: attempt recovery by restarting FFmpeg
|
||||
fmt.Printf("[%s] Slate active — attempting live playout recovery...\n", time.Now().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
if err := resetHLSOutput(hlsDir); err != nil {
|
||||
fmt.Println("Failed to reset HLS output:", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
@@ -241,12 +488,32 @@ func main() {
|
||||
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.m4s",
|
||||
"/app/hls/playlist.m3u8")
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Println("FFmpeg exited with error. Restarting in 5s (per ADR-001 resilience)...")
|
||||
runErr := cmd.Run()
|
||||
|
||||
// Track restart and check for slate escalation
|
||||
restartCount++
|
||||
fmt.Printf("[%s] FFmpeg exited (restart #%d in window): %v\n",
|
||||
time.Now().Format(time.RFC3339), restartCount, runErr)
|
||||
|
||||
if shouldEscalateToSlate() {
|
||||
fmt.Printf("[%s] CRITICAL: %d restarts in 60s — escalating to slate fallback\n",
|
||||
time.Now().Format(time.RFC3339), restartCount)
|
||||
if err := activateSlate(); err != nil {
|
||||
fmt.Printf("[%s] Slate activation failed: %v — will retry\n",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
}
|
||||
// Stay in slate mode, retry recovery after delay
|
||||
time.Sleep(30 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// If we were in slate but FFmpeg ran successfully, we recovered
|
||||
if playoutMode == "slate" {
|
||||
deactivateSlate()
|
||||
}
|
||||
|
||||
// After FFmpeg run, sync latest segments and manifest to R2
|
||||
if err := syncLatestToR2("/app/hls"); err != nil {
|
||||
if err := syncLatestToR2(hlsDir); err != nil {
|
||||
fmt.Println("R2 sync warning:", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# docker-compose.prod.yml — TEST LAB DEPLOYMENT
|
||||
# Target: test lab host 192.168.88.40 (NOT Hetzner production).
|
||||
# Production Hetzner VPS not yet provisioned — see TODO.md Production Deployment section.
|
||||
# Uses pre-built images from Gitea Container Registry (no local build).
|
||||
# Override DOCKER_HOST in CD workflow to switch target.
|
||||
|
||||
services:
|
||||
playout:
|
||||
image: git.allucanget.biz/zwitschi/linearvoid/playout:latest
|
||||
@@ -9,6 +15,7 @@ services:
|
||||
- ./data/playlist:/app/playlist:rw
|
||||
- ./data/media:/app/media:ro
|
||||
- ./data/hls:/app/hls:rw
|
||||
- ./data/slate:/app/slate:ro
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
@@ -68,7 +75,7 @@ services:
|
||||
image: redis:7-alpine
|
||||
container_name: linearvoid-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
|
||||
+7
-1
@@ -1,3 +1,8 @@
|
||||
# docker-compose.yml — LOCAL DEVELOPMENT
|
||||
# Target: local workstation or test lab (192.168.88.0/24).
|
||||
# NOT for production. Use docker-compose.prod.yml for deployment.
|
||||
# Services build from source; ports exposed for direct dev access.
|
||||
|
||||
services:
|
||||
playout:
|
||||
image: ${CI_REGISTRY_URL:-git.allucanget.biz}/${CI_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/playout:latest
|
||||
@@ -12,6 +17,7 @@ services:
|
||||
- ./data/playlist:/app/playlist:ro
|
||||
- ./data/media:/app/media:ro
|
||||
- ./data/hls:/app/hls:rw
|
||||
- ./data/slate:/app/slate:ro
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
@@ -72,7 +78,7 @@ services:
|
||||
image: redis:7-alpine
|
||||
container_name: linearvoid-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
|
||||
@@ -284,7 +284,7 @@ Notable aspects: Manifest freshness ensures all viewers stay on same timeline; c
|
||||
|
||||
### CI/CD Pipeline Architecture
|
||||
|
||||
The deployment follows a two-stage pipeline: **CI builds and pushes images**, then **CD pulls and deploys** them to the production host.
|
||||
The deployment follows a two-stage pipeline: **CI builds and pushes images**, then **CD pulls and deploys** them. Currently targeting the **test lab** (192.168.88.40). Production Hetzner VPS is planned but not yet provisioned — the same pipeline will deploy there by changing `DOCKER_HOST`.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -295,7 +295,7 @@ flowchart LR
|
||||
subgraph "Gitea Container Registry"
|
||||
REGISTRY[git.allucanget.biz<br/>playout:latest<br/>chat:latest<br/>nginx:latest]
|
||||
end
|
||||
subgraph "Production Host (192.168.88.40)"
|
||||
subgraph "Test Lab Host (192.168.88.40) — Production Hetzner TBD"
|
||||
DOCKER[Docker Engine<br/>via TCP :2375]
|
||||
PLAYOUT[linearvoid-playout]
|
||||
CHAT[linearvoid-chat]
|
||||
@@ -331,7 +331,7 @@ flowchart LR
|
||||
- Pulls latest images from registry
|
||||
- Generates `.env` from secrets
|
||||
- Seeds Prometheus config into named volume
|
||||
- Deploys via `docker compose -f docker-compose.prod.yml up -d` to remote host (`192.168.88.40:2375`)
|
||||
- Deploys via `docker compose -f docker-compose.prod.yml up -d` to remote host (`192.168.88.40:2375` — test lab; switch to Hetzner IP for production)
|
||||
- Runs health checks with retry logic for playout initialization
|
||||
|
||||
### Infrastructure Level 1
|
||||
@@ -350,7 +350,7 @@ flowchart LR
|
||||
|
||||
### Infrastructure Level 2
|
||||
|
||||
**Hetzner VPS**: Runs playout (FFmpeg/MediaMTX), chat backend, Nginx. Monitoring via dashboards + UptimeRobot. Backups to R2.
|
||||
**Hetzner VPS (planned):** Will run playout (FFmpeg/MediaMTX), chat backend, Nginx. Monitoring via dashboards + UptimeRobot. Backups to R2. Currently test lab (`192.168.88.40`) serves this role. Production Hetzner instance not yet provisioned — see `TODO.md` Production Deployment section.
|
||||
|
||||
**Cloudflare Layer**:
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Schedule</h1>
|
||||
<p>Read-only view of the currently configured playout order.</p>
|
||||
</div>
|
||||
<button id="reload-schedule-btn" type="button" class="btn btn-secondary">Reload schedule</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<p class="small-muted"><span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime <span id="schedule-total-runtime">00:00:00</span></p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Label</th>
|
||||
<th>Runtime</th>
|
||||
<th>Status</th>
|
||||
<th>Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedule-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
|
||||
</div>
|
||||
@@ -24,6 +24,10 @@
|
||||
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||
<section class="player-stage" aria-label="Video stage">
|
||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||
<div id="now-playing" class="now-playing-overlay" aria-live="polite">
|
||||
<span id="np-title">Loading...</span>
|
||||
<span id="np-next" class="np-next"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||
@@ -37,6 +41,10 @@
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
<h1>Analytics</h1>
|
||||
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<select id="analytics-range" class="text-input" style="width:auto">
|
||||
<option value="1h">Last hour</option>
|
||||
<option value="6h">Last 6 hours</option>
|
||||
<option value="24h" selected>Last 24 hours</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
</select>
|
||||
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
File renamed without changes.
File renamed without changes.
@@ -3,7 +3,10 @@
|
||||
<h1>Moderation</h1>
|
||||
<p>Create moderation actions and review the recent immutable audit log.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="reload-moderation-btn" type="button" class="btn btn-secondary">Reload logs</button>
|
||||
<button id="export-chat-log-btn" type="button" class="btn btn-secondary">Export CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="moderation-grid">
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Playlist</h1>
|
||||
<p>Edit the active concat playlist used by the playout service.</p>
|
||||
<p>Edit the active concat playlist. Drag entries to reorder.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||
@@ -10,7 +10,9 @@
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<label for="playlist-content" class="sr-only">Playlist content</label>
|
||||
<textarea id="playlist-content" class="text-input text-area" spellcheck="false"></textarea>
|
||||
<div id="playlist-warnings" class="status-warnings" hidden></div>
|
||||
<ul id="playlist-sortable" class="sortable-list" aria-label="Playlist entries"></ul>
|
||||
<label for="playlist-content" class="sr-only">Playlist content (raw)</label>
|
||||
<textarea id="playlist-content" class="text-input text-area" spellcheck="false" rows="6"></textarea>
|
||||
<p id="playlist-status" class="status-line" aria-live="polite">Loading playlist...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Schedule</h1>
|
||||
<p>Read-only view of the currently configured playout order.</p>
|
||||
</div>
|
||||
<button id="reload-schedule-btn" type="button" class="btn btn-secondary">Reload schedule</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<p class="small-muted"><span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime <span id="schedule-total-runtime">00:00:00</span></p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Label</th>
|
||||
<th>Runtime</th>
|
||||
<th>Status</th>
|
||||
<th>Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedule-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<h2>Schedule Overrides</h2>
|
||||
<p>Insert scheduled overrides to play specific videos at specific times.</p>
|
||||
<form id="override-form" class="form-stack" style="max-width:500px">
|
||||
<label>Media ID <input id="override-media-id" type="number" class="text-input" required /></label>
|
||||
<label>Start Time <input id="override-start" type="datetime-local" class="text-input" required /></label>
|
||||
<label>End Time <input id="override-end" type="datetime-local" class="text-input" required /></label>
|
||||
<label>Reason <input id="override-reason" type="text" class="text-input" placeholder="e.g. special event" /></label>
|
||||
<button type="submit" class="btn btn-primary">Add Override</button>
|
||||
</form>
|
||||
<p id="override-status" class="status-line" aria-live="polite"></p>
|
||||
<div class="table-wrap" style="margin-top:1rem">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Media ID</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Reason</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="override-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,3 +52,24 @@
|
||||
<div id="system-services" class="service-list"></div>
|
||||
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<h2>Runtime Configuration</h2>
|
||||
<form id="config-form" class="form-stack" style="max-width:400px">
|
||||
<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>
|
||||
<button type="submit" class="btn btn-primary">Save Config</button>
|
||||
</form>
|
||||
<p id="config-status" class="status-line" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<h2>Service Control</h2>
|
||||
<p class="small-muted">Restart individual services (manual — container restart commands returned by API).</p>
|
||||
<div class="button-row">
|
||||
<button class="btn btn-secondary restart-btn" data-service="playout">Restart Playout</button>
|
||||
<button class="btn btn-secondary restart-btn" data-service="chat">Restart Chat</button>
|
||||
<button class="btn btn-secondary restart-btn" data-service="nginx">Restart Nginx</button>
|
||||
</div>
|
||||
<p id="restart-status" class="status-line" aria-live="polite"></p>
|
||||
</div>
|
||||
File renamed without changes.
@@ -14,6 +14,7 @@
|
||||
<th>Username</th>
|
||||
<th>Room</th>
|
||||
<th>Online</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-table-body"></tbody>
|
||||
+235
-3
@@ -259,10 +259,13 @@ async function refreshPlaylist() {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
const content: string = payload.content || "";
|
||||
const textarea = document.getElementById(
|
||||
"playlist-content",
|
||||
) as HTMLTextAreaElement | null;
|
||||
if (textarea) textarea.value = payload.content || "";
|
||||
if (textarea) textarea.value = content;
|
||||
renderPlaylistSortable(content);
|
||||
await showPlaylistWarnings(content);
|
||||
setStatus("playlist-status", "Playlist loaded.");
|
||||
} catch (error) {
|
||||
console.error("Failed to load playlist", error);
|
||||
@@ -270,6 +273,95 @@ async function refreshPlaylist() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlaylistSortable(content: string) {
|
||||
const list = document.getElementById("playlist-sortable");
|
||||
if (!list) return;
|
||||
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||
list.innerHTML = lines
|
||||
.map((line, i) => {
|
||||
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
|
||||
return `<li class="sortable-item" draggable="true" data-index="${i}">
|
||||
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
|
||||
<span class="sortable-index">${i + 1}</span>
|
||||
<span class="sortable-path">${path}</span>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-remove="${i}">✕</button>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
let dragSrc: number | null = null;
|
||||
list.querySelectorAll<HTMLElement>(".sortable-item").forEach((item) => {
|
||||
item.addEventListener("dragstart", () => {
|
||||
dragSrc = Number(item.dataset.index);
|
||||
item.classList.add("dragging");
|
||||
});
|
||||
item.addEventListener("dragend", () => {
|
||||
item.classList.remove("dragging");
|
||||
dragSrc = null;
|
||||
});
|
||||
item.addEventListener("dragover", (e) => e.preventDefault());
|
||||
item.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
const dst = Number(item.dataset.index);
|
||||
if (dragSrc !== null && dragSrc !== dst) reorderPlaylistEntry(dragSrc, dst);
|
||||
});
|
||||
item.querySelector("[data-remove]")?.addEventListener("click", () => {
|
||||
removePlaylistEntry(Number(item.dataset.index));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getPlaylistLines(): string[] {
|
||||
const ta = document.getElementById("playlist-content") as HTMLTextAreaElement;
|
||||
return ta?.value.split("\n").filter((l) => l.trim() && !l.startsWith("#")) || [];
|
||||
}
|
||||
|
||||
function syncPlaylistTextarea(lines: string[]) {
|
||||
const ta = document.getElementById("playlist-content") as HTMLTextAreaElement;
|
||||
if (ta) ta.value = lines.join("\n") + "\n";
|
||||
renderPlaylistSortable(lines.join("\n"));
|
||||
}
|
||||
|
||||
function reorderPlaylistEntry(from: number, to: number) {
|
||||
const lines = getPlaylistLines();
|
||||
const [moved] = lines.splice(from, 1);
|
||||
lines.splice(to, 0, moved);
|
||||
syncPlaylistTextarea(lines);
|
||||
}
|
||||
|
||||
function removePlaylistEntry(index: number) {
|
||||
const lines = getPlaylistLines();
|
||||
lines.splice(index, 1);
|
||||
syncPlaylistTextarea(lines);
|
||||
}
|
||||
|
||||
async function showPlaylistWarnings(content: string) {
|
||||
const warnings = document.getElementById("playlist-warnings");
|
||||
if (!warnings) return;
|
||||
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||
const msgs: string[] = [];
|
||||
if (lines.length === 0) msgs.push("Playlist is empty. Add files from the media library.");
|
||||
try {
|
||||
const resp = await fetch("/api/admin/media");
|
||||
if (resp.ok) {
|
||||
const media = await resp.json();
|
||||
const mediaPaths = new Set((media.files || []).map((f: { path: string }) => f.path));
|
||||
const nonNorm = (media.files || []).filter((f: { status: string }) => f.status === "needs_normalization");
|
||||
for (const line of lines) {
|
||||
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
|
||||
if (path && !mediaPaths.has(path)) msgs.push(`Missing from media library: ${path}`);
|
||||
}
|
||||
if (nonNorm.length > 0) msgs.push(`${nonNorm.length} file(s) need normalization. Run scripts/normalize.sh.`);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (msgs.length > 0) {
|
||||
warnings.innerHTML = msgs.map((m) => `<div class="warning-msg">⚠ ${m}</div>`).join("");
|
||||
warnings.hidden = false;
|
||||
} else {
|
||||
warnings.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlaylist() {
|
||||
if (state.playlistSaving) return;
|
||||
state.playlistSaving = true;
|
||||
@@ -333,6 +425,55 @@ async function refreshSchedule() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOverrides() {
|
||||
try {
|
||||
const resp = await fetch("/api/admin/schedule/overrides");
|
||||
if (!resp.ok) return;
|
||||
const overrides = await resp.json();
|
||||
const tbody = document.getElementById("override-table-body");
|
||||
if (!tbody) return;
|
||||
if (!overrides.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="5">No overrides.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = overrides
|
||||
.map(
|
||||
(o: { id: number; mediaId: number; startTime: string; endTime: string; reason: string }) =>
|
||||
`<tr><td>${o.mediaId}</td><td>${new Date(o.startTime).toLocaleString()}</td><td>${new Date(o.endTime).toLocaleString()}</td><td>${o.reason || "-"}</td><td><button class="btn btn-secondary btn-sm" data-delete-override="${o.id}">✕</button></td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
tbody.querySelectorAll("[data-delete-override]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => deleteOverride(Number((btn as HTMLElement).dataset.deleteOverride)));
|
||||
});
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
async function submitOverride(e: Event) {
|
||||
e.preventDefault();
|
||||
const status = document.getElementById("override-status");
|
||||
if (status) status.textContent = "Saving...";
|
||||
const mediaId = (document.getElementById("override-media-id") as HTMLInputElement).value;
|
||||
const startTime = (document.getElementById("override-start") as HTMLInputElement).value;
|
||||
const endTime = (document.getElementById("override-end") as HTMLInputElement).value;
|
||||
const reason = (document.getElementById("override-reason") as HTMLInputElement).value;
|
||||
try {
|
||||
const resp = await fetch("/api/admin/schedule/overrides", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mediaId: Number(mediaId), startTime: new Date(startTime).toISOString(), endTime: new Date(endTime).toISOString(), reason }),
|
||||
});
|
||||
if (!resp.ok) { const text = await resp.text(); throw new Error(text); }
|
||||
if (status) { status.textContent = "Override added."; status.className = "status-line"; }
|
||||
refreshOverrides();
|
||||
} catch (err) {
|
||||
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOverride(id: number) {
|
||||
try { await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" }); refreshOverrides(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function refreshSystemSummary() {
|
||||
setStatus("system-status", "Loading system summary...");
|
||||
try {
|
||||
@@ -756,9 +897,17 @@ function renderChatLogTable() {
|
||||
<td>${entry.type}</td>
|
||||
<td>${entry.timestamp}</td>
|
||||
<td class="action-cell">
|
||||
<button type="button" class="btn btn-danger btn-small" data-action="delete-chat" data-id="${entry.id}">Delete</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-action="chat-mute" data-user="${entry.username}">Mute</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-action="chat-ban" data-user="${entry.username}">Ban</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" data-action="delete-chat" data-id="${entry.id}">Del</button>
|
||||
</td>
|
||||
`;
|
||||
tr.querySelector('[data-action="chat-mute"]')?.addEventListener("click", () => {
|
||||
userAction("mute", entry.username);
|
||||
});
|
||||
tr.querySelector('[data-action="chat-ban"]')?.addEventListener("click", () => {
|
||||
if (confirm(`Ban ${entry.username}?`)) userAction("ban", entry.username);
|
||||
});
|
||||
tr.querySelector('[data-action="delete-chat"]')?.addEventListener(
|
||||
"click",
|
||||
() => deleteChatEntry(entry.id),
|
||||
@@ -930,9 +1079,19 @@ async function refreshUsers() {
|
||||
} else {
|
||||
users.forEach((user: any) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${user.username}</td><td>${user.room}</td><td>${user.online ? "yes" : "no"}</td>`;
|
||||
tr.innerHTML = `<td>${user.username}</td><td>${user.room}</td><td>${user.online ? "yes" : "no"}</td>
|
||||
<td>
|
||||
<button class="btn btn-secondary btn-sm user-mute" data-user="${user.username}">Mute 10m</button>
|
||||
<button class="btn btn-secondary btn-sm user-ban" data-user="${user.username}">Ban</button>
|
||||
<button class="btn btn-secondary btn-sm user-unmute" data-user="${user.username}">Unmute</button>
|
||||
<button class="btn btn-secondary btn-sm user-unban" data-user="${user.username}">Unban</button>
|
||||
</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
tbody.querySelectorAll(".user-mute").forEach(b => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!)));
|
||||
tbody.querySelectorAll(".user-ban").forEach(b => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!)));
|
||||
tbody.querySelectorAll(".user-unmute").forEach(b => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!)));
|
||||
tbody.querySelectorAll(".user-unban").forEach(b => b.addEventListener("click", () => userAction("unban", (b as HTMLElement).dataset.user!)));
|
||||
}
|
||||
|
||||
setStatus(
|
||||
@@ -947,6 +1106,63 @@ async function refreshUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function userAction(action: string, username: string) {
|
||||
try {
|
||||
const resp = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, username, duration: 10 }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
setStatus("users-status", `${action} ${username} OK.`);
|
||||
} catch (err) {
|
||||
setStatus("users-status", `Failed: ${err}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const resp = await fetch("/api/admin/config");
|
||||
if (!resp.ok) return;
|
||||
const cfg = await resp.json();
|
||||
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
|
||||
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
async function saveConfig(e: Event) {
|
||||
e.preventDefault();
|
||||
const status = document.getElementById("config-status");
|
||||
if (status) status.textContent = "Saving...";
|
||||
try {
|
||||
const resp = await fetch("/api/admin/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
rateLimitMax: Number((document.getElementById("config-rate-max") as HTMLInputElement).value),
|
||||
rateLimitWindowSec: Number((document.getElementById("config-rate-window") as HTMLInputElement).value),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
if (status) { status.textContent = "Config saved."; status.className = "status-line"; }
|
||||
} catch (err) {
|
||||
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
|
||||
}
|
||||
}
|
||||
|
||||
async function restartService(service: string) {
|
||||
if (!confirm(`Restart ${service}? This may cause brief interruption.`)) return;
|
||||
const status = document.getElementById("restart-status");
|
||||
if (status) status.textContent = `Requesting restart of ${service}...`;
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/restart?service=${service}`, { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (status) status.textContent = data.message;
|
||||
} catch (err) {
|
||||
if (status) status.textContent = `Failed: ${err}`;
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>(".tab-trigger")
|
||||
@@ -1017,12 +1233,21 @@ function bindPageEvents(tabName: string) {
|
||||
document
|
||||
.getElementById("reload-schedule-btn")
|
||||
?.addEventListener("click", refreshSchedule);
|
||||
document
|
||||
.getElementById("override-form")
|
||||
?.addEventListener("submit", submitOverride);
|
||||
refreshOverrides();
|
||||
break;
|
||||
|
||||
case "moderation":
|
||||
document
|
||||
.getElementById("reload-moderation-btn")
|
||||
?.addEventListener("click", refreshModerationLogs);
|
||||
document
|
||||
.getElementById("export-chat-log-btn")
|
||||
?.addEventListener("click", () => {
|
||||
window.open("/api/admin/chat-log/export", "_blank");
|
||||
});
|
||||
document
|
||||
.getElementById("moderation-form")
|
||||
?.addEventListener("submit", submitModerationAction);
|
||||
@@ -1083,6 +1308,13 @@ function bindPageEvents(tabName: string) {
|
||||
document
|
||||
.getElementById("reload-system-btn")
|
||||
?.addEventListener("click", refreshSystemSummary);
|
||||
document
|
||||
.getElementById("config-form")
|
||||
?.addEventListener("submit", saveConfig);
|
||||
document.querySelectorAll(".restart-btn").forEach((btn) =>
|
||||
btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!))
|
||||
);
|
||||
loadConfig();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ let ws: WebSocket | null = null;
|
||||
let currentRoom = "main";
|
||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
let chatVisible = true;
|
||||
const onlineUsers = new Set<string>();
|
||||
|
||||
function resolveWebSocketBaseUrl() {
|
||||
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
||||
@@ -74,6 +75,19 @@ function connectWebSocket(room: string) {
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
||||
|
||||
// Track presence from system join/leave messages
|
||||
if (msg.type === "system" && msg.username === "system") {
|
||||
const joined = msg.text?.match(/^(\S+) joined /);
|
||||
const left = msg.text?.match(/^(\S+) left /);
|
||||
if (joined) {
|
||||
onlineUsers.add(joined[1]);
|
||||
renderPresence();
|
||||
} else if (left) {
|
||||
onlineUsers.delete(left[1]);
|
||||
renderPresence();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
@@ -112,6 +126,37 @@ function addMessage(
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function renderPresence() {
|
||||
const list = document.getElementById("presence-list");
|
||||
if (!list) return;
|
||||
const sorted = [...onlineUsers].sort();
|
||||
list.innerHTML = sorted
|
||||
.map(
|
||||
(u) =>
|
||||
`<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function refreshSchedule() {
|
||||
try {
|
||||
const res = await fetch("/api/schedule");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const entries = data.entries as Array<{ title: string; status: string }>;
|
||||
if (entries.length > 0) {
|
||||
const np = document.getElementById("np-title");
|
||||
const nn = document.getElementById("np-next");
|
||||
if (np) np.textContent = `Now: ${entries[0].title}`;
|
||||
if (nn && entries.length > 1) {
|
||||
nn.textContent = `Up next: ${entries[1].title}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Schedule unavailable — non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function initPlayerAndChat() {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
const manifestUrl = resolveManifestUrl();
|
||||
@@ -136,6 +181,10 @@ async function initPlayerAndChat() {
|
||||
|
||||
connectWebSocket("main");
|
||||
|
||||
// Start schedule polling
|
||||
refreshSchedule();
|
||||
setInterval(refreshSchedule, 30000);
|
||||
|
||||
console.log(
|
||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
||||
);
|
||||
|
||||
+182
-81
@@ -6,47 +6,47 @@
|
||||
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
||||
|
||||
--bg-0: #0d1417;
|
||||
--bg-1: #172227;
|
||||
--bg-2: #0a1012;
|
||||
--ink-0: #edf6f3;
|
||||
--ink-1: #cadbd6;
|
||||
--ink-soft: #9cb2ab;
|
||||
--bg-0: #000000;
|
||||
--bg-1: #0a0a0a;
|
||||
--bg-2: #000000;
|
||||
--ink-0: #ede8e6;
|
||||
--ink-1: #b0a8a4;
|
||||
--ink-soft: #78706c;
|
||||
|
||||
--panel: #17252b;
|
||||
--panel-2: #1f3138;
|
||||
--line: #30464d;
|
||||
--line-soft: #3d5962;
|
||||
--panel: #111111;
|
||||
--panel-2: #1a1a1a;
|
||||
--line: #2a2a2a;
|
||||
--line-soft: #1f1f1f;
|
||||
|
||||
--brand: #2ec4b6;
|
||||
--brand-strong: #20a79a;
|
||||
--brand-contrast: #061412;
|
||||
--ok: #5add94;
|
||||
--warn: #f2b560;
|
||||
--danger: #ff7d8f;
|
||||
--danger-soft: #3a1f27;
|
||||
--brand: #587792;
|
||||
--brand-strong: #4a6780;
|
||||
--brand-contrast: #f4f8fb;
|
||||
--ok: #04650d;
|
||||
--warn: #d49624;
|
||||
--danger: #c44a5e;
|
||||
--danger-soft: #1f1216;
|
||||
|
||||
--focus: #78f2e0;
|
||||
--shadow-1: 0 14px 30px rgba(0, 0, 0, 0.34);
|
||||
--shadow-2: 0 22px 44px rgba(0, 0, 0, 0.45);
|
||||
--focus: #2ebfa5;
|
||||
--shadow-1: 0 14px 30px rgba(0, 0, 0, 0.5);
|
||||
--shadow-2: 0 22px 44px rgba(0, 0, 0, 0.6);
|
||||
|
||||
--bg-grad-a: #132129;
|
||||
--bg-grad-b: var(--bg-0);
|
||||
--bg-grad-c: var(--bg-2);
|
||||
--bg-grad-a: #000000;
|
||||
--bg-grad-b: #000000;
|
||||
--bg-grad-c: #000000;
|
||||
|
||||
--live-dot: #ff6b6b;
|
||||
--overlay-bg: rgba(4, 14, 18, 0.84);
|
||||
--overlay-bg: rgba(0, 0, 0, 0.88);
|
||||
|
||||
--input-bg: #131f24;
|
||||
--input-bg: #0d0d0d;
|
||||
--input-border: var(--line-soft);
|
||||
--placeholder: #90a8a1;
|
||||
--placeholder: #6a6260;
|
||||
|
||||
--badge-start: #35c8bb;
|
||||
--badge-end: #1f8d85;
|
||||
--badge-start: #2ebfa5;
|
||||
--badge-end: #1a8c78;
|
||||
|
||||
--radius-1: 0.6rem;
|
||||
--radius-2: 1rem;
|
||||
--radius-3: 1.6rem;
|
||||
--radius-1: 0;
|
||||
--radius-2: 0;
|
||||
--radius-3: 0;
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
@@ -63,43 +63,43 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--bg-0: #f4f6ef;
|
||||
--bg-1: #e3e8db;
|
||||
--bg-2: #eff3ea;
|
||||
--ink-0: #101b18;
|
||||
--ink-1: #2d3a36;
|
||||
--ink-soft: #5a6963;
|
||||
--bg-0: #e8e8e8;
|
||||
--bg-1: #dcdcdc;
|
||||
--bg-2: #f0f0f0;
|
||||
--ink-0: #0d0d0d;
|
||||
--ink-1: #2a2a2a;
|
||||
--ink-soft: #555555;
|
||||
|
||||
--panel: #fffffd;
|
||||
--panel-2: #f7fbf2;
|
||||
--line: #c6d1c5;
|
||||
--line-soft: #d8e2d8;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f5f5f5;
|
||||
--line: #cccccc;
|
||||
--line-soft: #dddddd;
|
||||
|
||||
--brand: #005f73;
|
||||
--brand-strong: #00464f;
|
||||
--brand-contrast: #f9fffd;
|
||||
--ok: #2f7a43;
|
||||
--warn: #b76a00;
|
||||
--brand: #587792;
|
||||
--brand-strong: #4a6780;
|
||||
--brand-contrast: #f4f8fb;
|
||||
--ok: #04650d;
|
||||
--warn: #a86200;
|
||||
--danger: #a7283f;
|
||||
--danger-soft: #fff3f6;
|
||||
|
||||
--focus: #0a9396;
|
||||
--shadow-1: 0 14px 30px rgba(31, 54, 47, 0.12);
|
||||
--shadow-2: 0 22px 44px rgba(20, 39, 35, 0.2);
|
||||
--focus: #2ebfa5;
|
||||
--shadow-1: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
--shadow-2: 0 16px 36px rgba(0, 0, 0, 0.12);
|
||||
|
||||
--bg-grad-a: #dce9d6;
|
||||
--bg-grad-a: #e8e8e8;
|
||||
--bg-grad-b: var(--bg-0);
|
||||
--bg-grad-c: #eff3ea;
|
||||
--bg-grad-c: #f0f0f0;
|
||||
|
||||
--live-dot: #cb271a;
|
||||
--overlay-bg: rgba(0, 16, 19, 0.84);
|
||||
--overlay-bg: rgba(0, 0, 0, 0.88);
|
||||
|
||||
--input-bg: #fff;
|
||||
--input-border: #c2d2c1;
|
||||
--placeholder: #74817b;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #cccccc;
|
||||
--placeholder: #888888;
|
||||
|
||||
--badge-start: #0a9396;
|
||||
--badge-end: #005f73;
|
||||
--badge-start: #2ebfa5;
|
||||
--badge-end: #1a8c78;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,74 @@ select {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.sortable-list {
|
||||
list-style: none;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sortable-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
cursor: grab;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sortable-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.sortable-item:hover {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.sortable-item.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 1px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sortable-index {
|
||||
font-size: 0.7rem;
|
||||
color: var(--ink-soft);
|
||||
min-width: 1.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sortable-path {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-0);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-warnings {
|
||||
background: rgba(212, 150, 36, 0.1);
|
||||
border: 1px solid var(--warn);
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.warning-msg {
|
||||
font-size: 0.8rem;
|
||||
color: var(--warn);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-1);
|
||||
@@ -375,7 +443,7 @@ select {
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
padding: var(--space-5);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -392,7 +460,7 @@ select {
|
||||
.brand-badge {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.9rem;
|
||||
border-radius: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
@@ -424,7 +492,7 @@ select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border-radius: 999px;
|
||||
border-radius: 0;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink-1);
|
||||
@@ -447,13 +515,28 @@ select {
|
||||
.player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 1.1rem;
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: #000;
|
||||
box-shadow: var(--shadow-1);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.now-playing-overlay {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-0);
|
||||
}
|
||||
|
||||
.np-next {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.now-playing-overlay {
|
||||
position: absolute;
|
||||
right: calc(var(--space-5) + 0.7rem);
|
||||
@@ -467,14 +550,14 @@ select {
|
||||
background: var(--overlay-bg);
|
||||
color: #f0fffb;
|
||||
padding: 0.44rem 0.72rem;
|
||||
border-radius: 999px;
|
||||
border-radius: 0;
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.live-label {
|
||||
background: #d62828;
|
||||
background: var(--live-dot);
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
border-radius: 0;
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
@@ -524,6 +607,35 @@ select {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.presence-panel {
|
||||
padding: 0.5rem 1rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.presence-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.presence-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-1);
|
||||
}
|
||||
|
||||
.presence-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ok);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
}
|
||||
@@ -542,7 +654,7 @@ select {
|
||||
|
||||
.message-bubble {
|
||||
max-width: 83%;
|
||||
border-radius: 1rem;
|
||||
border-radius: 0;
|
||||
padding: 0.58rem 0.72rem;
|
||||
box-shadow: var(--shadow-1);
|
||||
animation: messageIn var(--fast);
|
||||
@@ -563,7 +675,7 @@ select {
|
||||
color: var(--ink-1);
|
||||
background: color-mix(in srgb, var(--brand) 22%, var(--panel-2));
|
||||
border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line));
|
||||
border-radius: 999px;
|
||||
border-radius: 0;
|
||||
padding: 0.24rem 0.68rem;
|
||||
animation: messageIn var(--fast);
|
||||
}
|
||||
@@ -609,18 +721,7 @@ select {
|
||||
.admin-body {
|
||||
min-height: 100vh;
|
||||
padding: var(--space-4);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 82% 12%,
|
||||
rgba(20, 48, 54, 0.5) 0%,
|
||||
transparent 44%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 16% 85%,
|
||||
rgba(8, 28, 34, 0.55) 0%,
|
||||
transparent 40%
|
||||
),
|
||||
var(--bg-0);
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
@@ -637,8 +738,8 @@ select {
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
background: #102a2f;
|
||||
color: #ecfff6;
|
||||
background: var(--bg-2);
|
||||
color: var(--ink-0);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
gap: var(--space-4);
|
||||
@@ -666,7 +767,7 @@ select {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
color: #ddf8ee;
|
||||
color: var(--ink-1);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-1);
|
||||
padding: 0.62rem 0.72rem;
|
||||
|
||||
@@ -160,18 +160,36 @@ http {
|
||||
}
|
||||
|
||||
location = /hls/playlist.m3u8 {
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
return 204;
|
||||
}
|
||||
alias /srv/hls/playlist.m3u8;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
}
|
||||
|
||||
location = /hls/init.mp4 {
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
return 204;
|
||||
}
|
||||
alias /srv/hls/init.mp4;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
}
|
||||
|
||||
location ~* ^/hls/(.+\.(ts|m4s))$ {
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
return 204;
|
||||
}
|
||||
alias /srv/hls/$1;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
@@ -179,15 +197,29 @@ http {
|
||||
|
||||
# Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN.
|
||||
location /hls/ {
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
return 204;
|
||||
}
|
||||
alias /srv/hls/;
|
||||
add_header Cache-Control "public, max-age=60";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
}
|
||||
|
||||
# Health checks
|
||||
# Health checks — aggregated from upstream services
|
||||
location /health {
|
||||
return 200 "healthy";
|
||||
add_header Content-Type text/plain;
|
||||
default_type application/json;
|
||||
return 200 '{"status":"ok","service":"nginx","timestamp":"$time_iso8601"}';
|
||||
}
|
||||
|
||||
location /nginx-status {
|
||||
stub_status on;
|
||||
allow 127.0.0.1;
|
||||
allow 172.16.0.0/12;
|
||||
allow 192.168.0.0/16;
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# Generate emergency slate video + pre-rendered HLS segments for failover.
|
||||
# Output: data/slate/ (slate MP4 + HLS segments + manifest)
|
||||
# Requires: ffmpeg
|
||||
set -e
|
||||
|
||||
SLATE_DIR="${1:-data/slate}"
|
||||
mkdir -p "$SLATE_DIR"
|
||||
|
||||
SLATE_MP4="$SLATE_DIR/slate.mp4"
|
||||
SLATE_HLS="$SLATE_DIR/hls"
|
||||
mkdir -p "$SLATE_HLS"
|
||||
|
||||
echo "=== Generating Emergency Slate ==="
|
||||
|
||||
# 1. Generate 10s slate video: color bars + "We'll Be Right Back" text
|
||||
echo "[1/3] Generating slate video (10s, H.264/AAC, 30fps, 2s keyframes)..."
|
||||
ffmpeg -y -f lavfi -i "color=c=#1a1a2e:s=1280x720:d=10:r=30" \
|
||||
-f lavfi -i "sine=frequency=440:duration=10" \
|
||||
-filter_complex "[0]drawtext=text='Linear Void':fontcolor=#587792:fontsize=60:x=(w-text_w)/2:y=(h-text_h)/2-40,drawtext=text='We'\''ll Be Right Back':fontcolor=#ede8e6:fontsize=36:x=(w-text_w)/2:y=(h-text_h)/2+30[bg];[1]volume=0.15[a]" \
|
||||
-map "[bg]" -map "[a]" \
|
||||
-c:v libx264 -preset medium -crf 21 \
|
||||
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
||||
-pix_fmt yuv420p \
|
||||
-c:a aac -b:a 128k -ar 48000 -ac 2 \
|
||||
-movflags +faststart \
|
||||
-t 10 \
|
||||
"$SLATE_MP4"
|
||||
|
||||
echo " Slate MP4: $(du -h "$SLATE_MP4" | cut -f1)"
|
||||
|
||||
# 2. Verify slate meets normalization profile
|
||||
echo "[2/3] Verifying slate normalization..."
|
||||
ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate,pix_fmt,sample_rate \
|
||||
-of default=noprint_wrappers=1 "$SLATE_MP4" 2>&1 | head -10
|
||||
|
||||
# 3. Package as pre-rendered HLS (fMP4, 6s segments)
|
||||
echo "[3/3] Packaging as HLS..."
|
||||
ffmpeg -y -i "$SLATE_MP4" \
|
||||
-c copy \
|
||||
-f hls \
|
||||
-hls_time 6 \
|
||||
-hls_list_size 5 \
|
||||
-hls_segment_type fmp4 \
|
||||
-hls_fmp4_init_filename "init.mp4" \
|
||||
-hls_flags "independent_segments" \
|
||||
-hls_segment_filename "$SLATE_HLS/slate_segment_%03d.m4s" \
|
||||
"$SLATE_HLS/slate.m3u8"
|
||||
|
||||
echo ""
|
||||
echo "=== Slate Generated ==="
|
||||
echo " MP4: $SLATE_MP4"
|
||||
echo " Manifest: $SLATE_HLS/slate.m3u8"
|
||||
echo " Segments: $(find "$SLATE_HLS" -name '*.m4s' | wc -l) files"
|
||||
echo " Init: $SLATE_HLS/init.mp4"
|
||||
echo ""
|
||||
echo "To use: mount $SLATE_HLS as /app/slate in playout container"
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# Normalize video to Linear Void required profile.
|
||||
# Output: H.264/AAC, 30fps constant, 2s keyframes, yuv420p, 48kHz audio.
|
||||
# Usage: ./normalize.sh <input> [output]
|
||||
set -e
|
||||
|
||||
INPUT="$1"
|
||||
if [ -z "$INPUT" ]; then
|
||||
echo "Usage: $0 <input-file> [output-file]"
|
||||
echo " Normalizes video to Linear Void profile: H.264/AAC, 30fps, 2s keyframes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$INPUT" ]; then
|
||||
echo "Error: input file not found: $INPUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUTPUT="${2:-${INPUT%.*}-normalized.mp4}"
|
||||
CRF="${NORMALIZE_CRF:-21}"
|
||||
PRESET="${NORMALIZE_PRESET:-medium}"
|
||||
AUDIO_BITRATE="${NORMALIZE_AUDIO_BITRATE:-160k}"
|
||||
|
||||
echo "=== Linear Void Video Normalization ==="
|
||||
echo " Input: $INPUT ($(du -h "$INPUT" | cut -f1))"
|
||||
echo " Output: $OUTPUT"
|
||||
echo " CRF: $CRF"
|
||||
echo " Preset: $PRESET"
|
||||
echo " Audio rate: $AUDIO_BITRATE"
|
||||
echo ""
|
||||
|
||||
# Probe input
|
||||
echo "--- Input Properties ---"
|
||||
ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate,pix_fmt,sample_rate,channels \
|
||||
-of default=noprint_wrappers=1 "$INPUT" 2>&1
|
||||
echo ""
|
||||
|
||||
# Normalize
|
||||
echo "--- Normalizing ---"
|
||||
ffmpeg -y -i "$INPUT" \
|
||||
-c:v libx264 -preset "$PRESET" -crf "$CRF" \
|
||||
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
||||
-pix_fmt yuv420p \
|
||||
-c:a aac -b:a "$AUDIO_BITRATE" -ar 48000 -ac 2 \
|
||||
-movflags +faststart \
|
||||
"$OUTPUT"
|
||||
|
||||
echo ""
|
||||
echo "--- Output Properties ---"
|
||||
ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate,pix_fmt,sample_rate,channels \
|
||||
-of default=noprint_wrappers=1 "$OUTPUT" 2>&1
|
||||
echo ""
|
||||
|
||||
echo "=== Normalization Complete ==="
|
||||
echo " Output: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))"
|
||||
echo ""
|
||||
echo "Verify with: ffprobe -v error -show_entries stream=codec_name,r_frame_rate,pix_fmt -of default=noprint_wrappers=1 $OUTPUT"
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
# End-to-end playout test: single file → 3-file playlist → verify seamless transition
|
||||
# Requires: Docker, ffprobe, curl. Run from repo root.
|
||||
set -e
|
||||
|
||||
echo "=== Playout End-to-End Test ==="
|
||||
|
||||
# 1. Build playout image
|
||||
echo "[1/7] Building playout image..."
|
||||
docker compose build playout
|
||||
|
||||
# 2. Create test playlist with 3 demo files (reuse demodata if exists)
|
||||
TEST_PLAYLIST="data/playlist/playlist.txt"
|
||||
mkdir -p data/playlist data/hls
|
||||
|
||||
echo "[2/7] Creating 3-file test playlist..."
|
||||
cat > "$TEST_PLAYLIST" << 'EOF'
|
||||
file '/app/media/demo1.mp4'
|
||||
file '/app/media/demo2.mp4'
|
||||
file '/app/media/demo1.mp4'
|
||||
EOF
|
||||
echo "Playlist created with 3 entries:"
|
||||
cat "$TEST_PLAYLIST"
|
||||
|
||||
# 3. Validate playlist parses correctly
|
||||
echo "[3/7] Validating playlist parse..."
|
||||
LINE_COUNT=$(grep -c "^file " "$TEST_PLAYLIST" || true)
|
||||
if [ "$LINE_COUNT" -ne 3 ]; then
|
||||
echo "FAIL: Expected 3 entries, got $LINE_COUNT"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: $LINE_COUNT entries parsed"
|
||||
|
||||
# 4. Start playout container
|
||||
echo "[4/7] Starting playout container..."
|
||||
docker compose up -d playout
|
||||
sleep 8
|
||||
|
||||
# 5. Verify health endpoint
|
||||
echo "[5/7] Checking playout health..."
|
||||
HEALTH=$(curl -sf http://localhost:8081/health || echo "FAIL")
|
||||
if echo "$HEALTH" | grep -q "healthy"; then
|
||||
echo "OK: Health endpoint: $HEALTH"
|
||||
else
|
||||
echo "FAIL: Health check returned: $HEALTH"
|
||||
docker compose logs playout --tail 30
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 6. Verify HLS output
|
||||
echo "[6/7] Verifying HLS output..."
|
||||
sleep 10 # Let FFmpeg produce some segments
|
||||
|
||||
if [ -f "data/hls/playlist.m3u8" ]; then
|
||||
echo "OK: playlist.m3u8 exists"
|
||||
MANIFEST_LINES=$(wc -l < data/hls/playlist.m3u8)
|
||||
echo " Manifest lines: $MANIFEST_LINES"
|
||||
else
|
||||
echo "FAIL: No playlist.m3u8 generated"
|
||||
docker compose logs playout --tail 30
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SEGMENT_COUNT=$(find data/hls -name "segment_*.m4s" | wc -l)
|
||||
if [ "$SEGMENT_COUNT" -ge 1 ]; then
|
||||
echo "OK: $SEGMENT_COUNT segment(s) generated"
|
||||
else
|
||||
echo "FAIL: No segments generated"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 7. Check for seamless transition markers (no FFmpeg errors in logs)
|
||||
echo "[7/7] Checking for seamless transitions..."
|
||||
ERRORS=$(docker compose logs playout 2>&1 | grep -ci "error" || true)
|
||||
if [ "$ERRORS" -eq 0 ]; then
|
||||
echo "OK: No FFmpeg errors in logs"
|
||||
else
|
||||
echo "WARN: $ERRORS error(s) in playout logs (may be R2 connection — check manually)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Playout E2E Test PASSED ==="
|
||||
echo "Verified: playlist parse → HLS generation → health endpoint → segment output"
|
||||
echo ""
|
||||
echo "Cleanup: docker compose down playout"
|
||||
Reference in new issue
Block a user